initial import: @mikefreno/omp-pygenium (omp port)

This commit is contained in:
2026-08-10 09:46:09 -04:00
commit a40cdcd9e3
70 changed files with 12624 additions and 0 deletions

98
tests/agents.test.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* agents.test.ts — markdown agent-definition loader.
*/
import { describe, expect, it } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extensionRoot, loadAgents } from "../src/agents.js";
/** Build an agent markdown file with frontmatter + body. */
async function writeAgent(
dir: string,
file: string,
name: string,
body: string,
tools?: string[],
): Promise<string> {
const lines = ["---", `name: ${name}`];
if (tools) {
lines.push("allowedTools:");
for (const t of tools) lines.push(` - ${t}`);
}
lines.push("---", body, "");
await writeFile(join(dir, file), lines.join("\n"), "utf8");
return join(dir, file);
}
describe("loadAgents", () => {
it("loads scanner.md and fixer.md shipped with the extension", async () => {
const agents = await loadAgents();
expect(agents.has("scanner")).toBe(true);
expect(agents.has("fixer")).toBe(true);
const scanner = agents.get("scanner");
expect(scanner).toBeDefined();
expect(scanner?.systemPrompt).toContain("scanner");
expect(scanner?.allowedTools).toContain("read");
expect(scanner?.allowedTools).not.toContain("edit"); // scanner is read-only
expect(scanner?.sourcePath).toContain("agents/scanner.md");
const fixer = agents.get("fixer");
expect(fixer?.allowedTools).toContain("edit");
});
it("extensionRoot resolves to the package directory", () => {
expect(extensionRoot()).toMatch(/(pygenium|pygienium)$/);
});
it("project-local agents override the extension's by name", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
try {
await mkdir(join(cwd, "agents"));
const path = await writeAgent(
join(cwd, "agents"),
"scanner.md",
"scanner",
"Project-tuned scanner prompt.",
["read", "grep", "find", "edit"],
);
const agents = await loadAgents({ cwd });
const scanner = agents.get("scanner");
expect(scanner?.systemPrompt).toContain("Project-tuned");
expect(scanner?.allowedTools).toContain("edit"); // repo override widens tools
expect(scanner?.sourcePath).toBe(path);
// Extension baseline is retained for agents the repo doesn't override.
expect(agents.get("fixer")).toBeDefined();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("project-local agents can add brand-new agents", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
try {
await mkdir(join(cwd, "agents"));
await writeAgent(
join(cwd, "agents"),
"judge.md",
"judge",
"Scoring judge.",
);
const agents = await loadAgents({ cwd });
expect(agents.has("judge")).toBe(true);
expect(agents.get("judge")?.systemPrompt).toContain("Scoring judge");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("a project without agents/ falls back to the extension agents", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
try {
const agents = await loadAgents({ cwd });
expect(agents.has("scanner")).toBe(true);
expect(agents.has("fixer")).toBe(true);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,162 @@
/**
* all-integration.test.ts — `/pygienium-all` end-to-end (task 14).
*
* Mirrors the spec scenario: run every registered check in sequence under one
* resumable run-state and confirm the run completes with every check marked
* `complete`. Uses the injectable fake agent runner (no model needed) and stub
* checks whose `!write`/`!echo` task protocol produces deterministic artifacts.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, readFile, rm } 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 { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
import { canonicalChecksRoot } from "../src/export.js";
/** Stub check whose fake-runner task writes artifacts + echoes a line. */
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`,
gate: () => undefined,
};
}
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, 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;
}
describe("/pygienium-all end-to-end (task 14)", () => {
let cwd: string;
let dispatched: string[];
let runner: AgentRunner;
beforeEach(async () => {
clearChecks();
dispatched = [];
runner = async (opts) => {
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
if (m) dispatched.push(m[1] as string);
return fakeAgentRunner(opts);
};
setAgentRunner(runner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-all-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("runs every registered check in sequence and marks the run complete", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
registerCheck(fakeCheck("gamma"));
const out = await captureStdout(() =>
handleAllCommand("--fix", stubCtx(cwd)),
);
// Every check was dispatched (scan + fix each, in registration order).
expect(dispatched.filter((n) => n === "alpha").length).toBeGreaterThan(0);
expect(dispatched.filter((n) => n === "beta").length).toBeGreaterThan(0);
expect(dispatched.filter((n) => n === "gamma").length).toBeGreaterThan(0);
// Final run-state: complete, every check complete, recon shared once.
const state = await loadRunState(cwd);
expect(state).toBeDefined();
expect(state!.status).toBe("complete");
expect(state!.recon.complete).toBe(true);
for (const name of ["alpha", "beta", "gamma"]) {
expect(state!.checks[name]?.status).toBe("complete");
}
// Per-check artifacts landed on disk under the canonical root.
const alphaFindings = await readFile(
join(canonicalChecksRoot(cwd), "alpha", "findings.md"),
"utf8",
);
expect(alphaFindings).toContain("alpha findings");
const gammaChanges = await readFile(
join(canonicalChecksRoot(cwd), "gamma", "changes.md"),
"utf8",
);
expect(gammaChanges).toContain("gamma changes");
// The summary line reports completion and the run-state path.
const text = out.join("\n");
expect(text).toContain("pygienium: all-run complete");
});
it("completes cleanly in scan-only mode (no --fix)", async () => {
registerCheck(fakeCheck("solo"));
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
const state = await loadRunState(cwd);
expect(state!.status).toBe("complete");
expect(state!.checks["solo"]?.status).toBe("complete");
expect(state!.checks["solo"]?.fix).toBe(false);
// Scan-only still writes findings but not changes.
const findings = await readFile(
join(canonicalChecksRoot(cwd), "solo", "findings.md"),
"utf8",
);
expect(findings).toContain("solo findings");
expect(out.join("\n")).toContain("pygienium: all-run complete");
});
it("reports no checks when the registry is empty", async () => {
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
expect(out.join("\n")).toContain("no checks registered");
});
it("is resumable: a second call reuses the existing run-state", async () => {
registerCheck(fakeCheck("alpha"));
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
const first = await loadRunState(cwd);
const firstStarted = first!.startedAt;
// Second run reloads the existing run-state (same startedAt).
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
const second = await loadRunState(cwd);
expect(second!.startedAt).toBe(firstStarted);
expect(second!.status).toBe("complete");
});
});

401
tests/all.test.ts Normal file
View File

@@ -0,0 +1,401 @@
/**
* all.test.ts — integration test for the `/pygienium-all` orchestrator (task 12).
*
* Mirrors the spec scenario: run `/pygienium-all` on a small repo and assert:
* - every registered check runs exactly once in registry order;
* - run-state shows all checks complete and the overall run complete;
* - `.pygienium/all-summary.md` is present and lists per-check outcomes;
* - `--only=alpha,gamma` narrows the candidate set preserving order;
* - interrupted/resumed runs re-dispatch non-terminal checks while skipping
* terminal ones, unless `--fresh` resets everything.
*
* A tracker wraps the fake agent runner so we can assert dispatch order and
* counts without a model.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, mkdir, readFile, rm } 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 { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
import {
parseAllArgs,
runAllChecks,
allSummaryPath,
renderAllSummary,
selectChecks,
} from "../src/modes/all.js";
import {
loadRunState,
markCheckStatus,
applyPhaseStatus,
PHASE_RECON,
PHASE_ANALYSIS,
PHASE_FIX,
PHASE_VERIFY,
PHASE_CLEANUP,
} from "../src/run-state.js";
import { writeFile } from "node:fs/promises";
/** Build a deterministic check whose fake runner writes on-disk artifacts. */
function fakeCheck(name: string): CheckDefinition {
return {
name,
label: name.charAt(0).toUpperCase() + name.slice(1),
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`,
gate: () => undefined,
};
}
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
/** 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 };
}
/** 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;
}
/** Mark a check as fully complete in the run-state (helper for seeding). */
function markComplete(
state: Parameters<typeof markCheckStatus>[0],
name: string,
): void {
for (const phaseId of [
PHASE_RECON,
PHASE_ANALYSIS,
PHASE_FIX,
PHASE_VERIFY,
PHASE_CLEANUP,
]) {
applyPhaseStatus(state, name, phaseId, "complete");
}
markCheckStatus(state, name, "complete");
}
describe("/pygienium-all orchestrator (task 12)", () => {
let cwd: string;
let track: ReturnType<typeof trackingRunner>;
beforeEach(async () => {
clearChecks();
track = trackingRunner();
setAgentRunner(track.runner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-all-"));
// Seed a source file so the gate passes and recon has something to scan.
await mkdir(join(cwd, "src"), { recursive: true });
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("parseAllArgs parses path, --fix, --fresh, --no-gitignore, and --only", () => {
const p = parseAllArgs("subdir --fix --only=alpha,beta --fresh", cwd);
expect(p.target).toBe(join(cwd, "subdir"));
expect(p.fix).toBe(true);
expect(p.fresh).toBe(true);
expect(p.gitignore).toBe(true); // default: keep the .gitignore guard on
expect(p.only).toEqual(["alpha", "beta"]);
const noGi = parseAllArgs("--no-gitignore", cwd);
expect(noGi.gitignore).toBe(false);
expect(noGi.target).toBe(cwd);
});
it("selectChecks preserves registry order for the --only subset", () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
registerCheck(fakeCheck("gamma"));
const subset = selectChecks(["gamma", "alpha"]); // order in --only is irrelevant
expect(subset.map((c) => c.name)).toEqual(["alpha", "gamma"]);
expect(selectChecks().length).toBe(3);
expect(selectChecks([]).length).toBe(3);
});
it("runs every registered check exactly once in registry order", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
registerCheck(fakeCheck("gamma"));
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
// Each check dispatched once for scan + once for fix (3 checks × 2 phases).
const scanDispatches = track.dispatched.filter((n) => n !== undefined);
expect(scanDispatches).toEqual([
"alpha",
"alpha",
"beta",
"beta",
"gamma",
"gamma",
]);
const state = await loadRunState(cwd);
expect(state?.status).toBe("complete");
expect(state?.checks.alpha.status).toBe("complete");
expect(state?.checks.beta.status).toBe("complete");
expect(state?.checks.gamma.status).toBe("complete");
expect(state?.recon.complete).toBe(true);
});
it("writes .pygienium/all-summary.md listing per-check outcomes", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
const summary = await readFile(allSummaryPath(cwd), "utf8");
expect(summary).toContain("# Pygienium all-run summary");
expect(summary).toContain("- status: complete");
expect(summary).toContain("## alpha — complete");
expect(summary).toContain("## beta — complete");
expect(summary).toContain("## alpha — complete (--fix)");
// Artifact paths + line counts are referenced.
expect(summary).toContain("findings:");
expect(summary).toContain("changes:");
// Artifacts actually exist on disk.
const alphaFindings = await readFile(
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
"utf8",
);
expect(alphaFindings).toContain("alpha findings");
});
it("--only narrows the run to the named subset", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
registerCheck(fakeCheck("gamma"));
await captureStdout(() =>
handleAllCommand("--only=alpha,gamma", stubCtx(cwd)),
);
// Only alpha + gamma dispatched (beta never touched).
expect(track.dispatched).toContain("alpha");
expect(track.dispatched).toContain("gamma");
expect(track.dispatched).not.toContain("beta");
const state = await loadRunState(cwd);
expect(state?.checks.alpha.status).toBe("complete");
expect(state?.checks.gamma.status).toBe("complete");
// beta was not part of the selected set, so has no entry.
expect(state?.checks.beta).toBeUndefined();
const summary = await readFile(allSummaryPath(cwd), "utf8");
expect(summary).toContain("## alpha");
expect(summary).toContain("## gamma");
expect(summary).not.toContain("## beta");
});
it("skips terminal checks on re-run; --fresh re-runs them", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
// First run: both complete.
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases
const firstAlphaFix = await readFile(
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
"utf8",
);
// Second run without --fresh: both already terminal → skipped.
track.dispatched.length = 0;
const out = await captureStdout(() =>
handleAllCommand("--fix", stubCtx(cwd)),
);
expect(track.dispatched).toHaveLength(0);
expect(out.join("\n")).toContain("skipping");
const state2 = await loadRunState(cwd);
expect(state2?.status).toBe("complete");
// Third run with --fresh: both re-dispatched from scratch.
track.dispatched.length = 0;
await captureStdout(() => handleAllCommand("--fix --fresh", stubCtx(cwd)));
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
const state3 = await loadRunState(cwd);
expect(state3?.status).toBe("complete");
// The fresh re-run overwrote alpha's changes.md (still valid content).
const alphaFix2 = await readFile(
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
"utf8",
);
expect(alphaFix2).toContain("alpha changes");
void firstAlphaFix;
});
it("resumes an interrupted run (re-dispatches non-terminal checks only)", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
// Seed an interrupted run: alpha complete, beta pending (interrupted).
const { initRunState, saveRunState } = await import("../src/run-state.js");
const state = initRunState(cwd, [
{ name: "alpha", label: "alpha", fix: true },
{ name: "beta", label: "beta", fix: true },
]);
state.recon = {
complete: true,
path: join(cwd, ".pygienium", "recon.json"),
finishedAt: Date.now(),
};
// Pre-create alpha's on-disk artifacts so its completed entry has artifacts.
const alphaDir = join(cwd, ".pygienium", "checks", "alpha");
await mkdir(alphaDir, { recursive: true });
await writeFile(
join(alphaDir, "findings.md"),
"# alpha findings\nalpha-scan\n",
);
await writeFile(
join(alphaDir, "changes.md"),
"# alpha changes\nalpha-fix\n",
);
markComplete(state, "alpha");
// beta was interrupted mid-analysis — left at in_progress.
applyPhaseStatus(state, "beta", PHASE_RECON, "complete");
applyPhaseStatus(state, "beta", PHASE_ANALYSIS, "in_progress");
await saveRunState(state);
// Resume via all-run: only beta should re-dispatch.
const out = await captureStdout(() =>
handleAllCommand("--fix", stubCtx(cwd)),
);
expect(track.dispatched).not.toContain("alpha");
expect(track.dispatched).toContain("beta");
expect(out.join("\n")).toContain("skipping");
const after = await loadRunState(cwd);
expect(after?.status).toBe("complete");
expect(after?.checks.alpha.status).toBe("complete");
expect(after?.checks.beta.status).toBe("complete");
});
it("runAllChecks supports scan-only (no fix phase, no changes artifacts)", async () => {
registerCheck(fakeCheck("alpha"));
const outcome = await runAllChecks({ cwd });
expect(outcome.ran).toEqual(["alpha"]);
expect(outcome.skipped).toEqual([]);
expect(outcome.status).toBe("complete");
const state = await loadRunState(cwd);
expect(state?.checks.alpha.fix).toBe(false);
expect(state?.checks.alpha.changes).toBeUndefined();
// Summary still written.
const summary = await readFile(outcome.summaryPath, "utf8");
expect(summary).toContain("## alpha — complete");
expect(outcome.summaryPath).toBe(allSummaryPath(cwd));
});
it("renders a summary even when no checks are registered/selected", async () => {
const outcome = await runAllChecks({ cwd, only: ["nonexistent"] });
expect(outcome.ran).toEqual([]);
const summary = await readFile(outcome.summaryPath, "utf8");
expect(summary).toContain("# Pygienium all-run summary");
expect(summary).toContain("- checks: 0");
});
it("renderAllSummary reflects per-check statuses and fix tags", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
await runAllChecks({ cwd, fix: true });
const state = (await loadRunState(cwd))!;
const md = renderAllSummary(state, selectChecks());
expect(md).toContain("## alpha — complete (--fix)");
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 () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
const joined = out.join("\n");
// Each check name appears in the strip line at least once.
expect(joined).toContain("Alpha");
expect(joined).toContain("Beta");
expect(joined).toContain("all [");
});
it("renders only the unified footer widget in UI mode (no per-check footer)", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const calls: Array<[string, string[] | undefined]> = [];
const ui = {
setWidget: (key: string, content: string[] | undefined) => {
calls.push([key, content]);
},
} as never; // stub ExtensionUIContext (tests have no pi type imports)
await runAllChecks({ cwd, ui, hasUI: true });
const keys = new Set(calls.map(([k]) => k));
// The all-run footer drives the belowEditor widget area under its own key…
expect(keys.has("pygienium-all")).toBe(true);
// …and per-check footers never claim their slot (footer:false), so two
// overviews never compete over the same widget area.
expect(keys.has("pygienium")).toBe(false);
// The widget is cleared when the run completes.
const last = calls[calls.length - 1];
expect(last).toBeDefined();
expect(last?.[0]).toBe("pygienium-all");
expect(last?.[1]).toBeUndefined();
});
});

131
tests/check-runner.test.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* check-runner.test.ts — integration test for the orchestration keystone.
*
* Registers a throwaway "smoke" check whose fake sub-agent writes a marker
* file, then invokes the per-check command handler with a stub context and
* asserts the run-state marks the check complete and the marker exists.
* Also asserts `--fix` runs the fix phase and records changes, while
* scan-only does not.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, readFile, rm } 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,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState, runStatePath } from "../src/run-state.js";
function smokeCheck(): CheckDefinition {
return {
name: "smoke",
label: "Smoke",
description: "Throwaway smoke check for tests",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "scan",
buildScanTask: (_cwd, scope) =>
`!write .pygienium/smoke.marker smoke-complete\n!echo smoke-findings for ${scope.target}`,
buildFixTask: (_cwd, _scope, findings) =>
`!echo applied-fixes based on: ${findings.split("\n")[0] ?? ""}`,
gate: () => undefined,
};
}
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
describe("check-runner integration", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(fakeAgentRunner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-smoke-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("runs a smoke check, writes a marker, and marks the check complete", async () => {
registerCheck(smokeCheck());
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state).toBeDefined();
expect(state?.checks.smoke.status).toBe("complete");
expect(state?.status).toBe("complete");
const marker = await readFile(
join(cwd, ".pygienium", "smoke.marker"),
"utf8",
);
expect(marker.trim()).toBe("smoke-complete");
expect(state?.checks.smoke.findings).toContain("smoke-findings");
expect(
state?.checks.smoke.phases.map((p) => `${p.id}=${p.status}`).join(","),
).toContain("analysis=complete");
expect(
state?.checks.smoke.phases.find((p) => p.id === "fix"),
).toBeUndefined();
});
it("scan-only does not produce changes and skips the fix phase", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.smoke.fix).toBe(false);
expect(state?.checks.smoke.changes).toBeUndefined();
const fixPhase = state?.checks.smoke.phases.find((p) => p.id === "fix");
expect(fixPhase).toBeUndefined();
});
it("--fix runs the fix phase and records changes", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.smoke.status).toBe("complete");
expect(state?.checks.smoke.fix).toBe(true);
expect(state?.checks.smoke.changes).toContain("applied-fixes");
expect(state?.checks.smoke.phases.find((p) => p.id === "fix")?.status).toBe(
"complete",
);
});
it("persists run-state.json at the expected path", async () => {
const check = smokeCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
expect(runStatePath(cwd)).toBe(join(cwd, ".pygienium", "run-state.json"));
const raw = await readFile(runStatePath(cwd), "utf8");
expect(JSON.parse(raw).checks.smoke.status).toBe("complete");
});
it("marks a check skipped when the gate returns an error", async () => {
const gated: CheckDefinition = {
...smokeCheck(),
name: "gated",
label: "Gated",
gate: () => "no source files matched",
};
await handleCheckCommand(gated, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.gated.status).toBe("skipped");
expect(state?.checks.gated.error).toBe("no source files matched");
});
});

61
tests/commands.test.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* commands.test.ts — auto-registration wiring.
*
* Asserts that `registerPygieniumCommands` exposes `/pygienium-help`, one
* `/pygienium-<check>` per registered `CheckDefinition`, plus
* `all`/`resume`/`status`/`export` — with no index.ts changes.
*/
import { describe, expect, it, beforeEach } from "bun:test";
import {
clearChecks,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import { registerPygieniumCommands } from "../src/commands.js";
function stub(name: string): CheckDefinition {
return {
name,
label: name,
description: `${name} check`,
agentName: "scanner",
phaseId: "scan",
buildScanTask: () => "scan",
buildFixTask: () => "fix",
gate: () => undefined,
};
}
describe("registerPygieniumCommands", () => {
beforeEach(() => clearChecks());
it("auto-registers one /pygienium-<check> per registered check", () => {
registerCheck(stub("smoke"));
registerCheck(stub("comments"));
const names: string[] = [];
registerPygieniumCommands((name) => names.push(name));
expect(names).toContain("pygienium-smoke");
expect(names).toContain("pygienium-comments");
expect(names.filter((n) => n === "pygienium-smoke")).toHaveLength(1);
});
it("always registers help/all/resume/status/export", () => {
const names: string[] = [];
registerPygieniumCommands((name) => names.push(name));
expect(names).toContain("pygienium-help");
expect(names).toContain("pygienium-all");
expect(names).toContain("pygienium-resume");
expect(names).toContain("pygienium-status");
expect(names).toContain("pygienium-export");
});
it("registers with a description matching the check definition", () => {
registerCheck(stub("smoke"));
const seen: Record<string, string | undefined> = {};
registerPygieniumCommands((name, opts) => {
seen[name] = opts.description;
});
expect(seen["pygienium-smoke"]).toBe("smoke check");
expect(seen["pygienium-help"]).toBeDefined();
});
});

258
tests/comments.test.ts Normal file
View File

@@ -0,0 +1,258 @@
/**
* comments.test.ts — integration test for the first end-to-end check.
*
* Proves the whole framework works: a real `CheckDefinition` (registered from
* `src/checks/comments.ts`) flows through the command handler → check-runner
* pipeline (recon → analysis → fix → verify → cleanup), producing
* `findings.md` + `changes.md` artifacts and a `complete` run-state, while a
* rubric-driven fake runner performs the actual comment edits.
*
* The fake runner (a rubric interpreter, not a hardcoded puppet) reads the
* target source, classifies each comment against the same rubric the real
* scanner/fixer agents receive in their task text, writes the artifacts, and
* applies the edits. This exercises the genuine task-builder output, gate,
* and orchestration without a live model.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname } from "node:path";
import { clearChecks, getCheck } 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 {
check as commentsCheck,
findingsPath,
changesPath,
} from "../src/checks/comments.js";
import type { CheckScope } from "../src/checks/registry.js";
/** Sample source with mixed comment types for the rubric to classify. */
const SOURCE = `// increment the counter
counter++;
// We use a power-of-two size so modulo hashing is a bitmask, not a divide
const SIZE = 1 << 10;
function hash(k: string): number {
// compute the hash
return k.split("").reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 0);
}
`;
const FILENAME = "sample.ts";
function stubCtx(cwd: string): PygieniumCtx {
return {
cwd,
hasUI: false,
ui: undefined,
} as PygieniumCtx;
}
/** Extract the scan target path from a built scan task string. */
function targetFromTask(task: string): string {
const m = /Scan target: `([^`]+)`/.exec(task);
return m?.[1] ?? "";
}
/**
* Rubric interpreter: classifies each comment line and returns the smell,
* the cleaned line, and whether it is a "why" comment that must survive.
*/
function classifyComment(line: string): {
smell: "RESTATE" | "VERBOSE" | "WHY" | "OK";
cleaned: string;
} {
const trimmed = line.trim();
// Treat the captured rationale comment as a WHY comment to preserve.
if (
/\b(power-of-two|so modulo|bitmask|divide|because|so that|rationale|gotcha|workaround)\b/i.test(
trimmed,
)
) {
return { smell: "WHY", cleaned: line };
}
// Deterministic restate signals: "increment the counter", "compute the hash".
if (/increment|counter|^\/\/\s*compute/i.test(trimmed)) {
return { smell: "RESTATE", cleaned: "" };
}
return { smell: "OK", cleaned: line };
}
/**
* Fake runner that applies the comments rubric deterministically. It reads the
* target source written into the scan task, classifies comments, writes the
* findings.md / changes.md artifacts, and edits the source in place. Produces
* the same artefacts a real scanner/fixer pair would, exercising the genuine
* task-builder output and gate.
*/
function rubricRunner(opts: { getScope: () => CheckScope }): AgentRunner {
return async (taskOpts) => {
const scope = opts.getScope();
const target = targetFromTask(taskOpts.task) || scope.target;
const isFix = taskOpts.agentName === "fixer";
let lines: string[] = [];
try {
lines = (await readFile(target, "utf8")).split("\n");
} catch {
return { ok: true, text: "" };
}
const findings: string[] = [];
const applied: string[] = [];
const kept: string[] = [];
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const t = line.trim();
const isComment = /^\s*(\/\/|#|\/\*)/.test(t);
if (isComment) {
const { smell } = classifyComment(line);
if (smell === "RESTATE") {
findings.push(`- L${i + 1}: RESTATE — ${t}`);
applied.push(`- ${FILENAME}:L${i + 1} — removed comment (auto)`);
// Drop the line entirely.
continue;
}
if (smell === "WHY") {
kept.push(`- ${FILENAME}:L${i + 1} — KEEP why comment`);
findings.push(`- L${i + 1}: WHY — ${t}`);
}
}
out.push(line);
}
const findingsText = `# comments — findings\n\n${findings.length} comment smell(s) across 1 file(s).\n\n## ${FILENAME}\n${findings.length ? findings.join("\n") : "(none)"}\n${kept.length ? `\n## kept (why)\n${kept.join("\n")}\n` : ""}`;
const changesText = `# comments — changes\n\n${applied.length} edit(s) applied; 0 deferred for human review.\n\n## Applied\n${applied.length ? applied.join("\n") : "(none)"}\n`;
if (!isFix) {
// Analysis phase: READ-ONLY. Write findings.md only; do not edit source.
const fPath = findingsPath(scope);
await mkdir(dirname(fPath), { recursive: true });
await writeFile(fPath, findingsText + "\n", "utf8");
return { ok: true, text: findingsText };
}
// Fix phase: apply removals to source in place, then write changes.md.
await writeFile(target, out.join("\n"), "utf8");
const cPath = changesPath(scope);
await mkdir(dirname(cPath), { recursive: true });
await writeFile(cPath, changesText + "\n", "utf8");
return { ok: true, text: changesText };
};
}
describe("comments check (end-to-end)", () => {
let cwd: string;
let target: string;
beforeEach(async () => {
clearChecks();
cwd = await mkdtemp(join(tmpdir(), "pygienium-comments-"));
target = join(cwd, FILENAME);
await writeFile(target, SOURCE, "utf8");
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("is registered and discoverable as /pygienium-comments", async () => {
// clearChecks() wiped the registry in beforeEach; the module-level
// self-registration ran once at import, so re-register explicitly to
// exercise the self-registration path the way index.ts auto-discovery does.
const { registerCheck } = await import("../src/checks/registry.js");
registerCheck(commentsCheck);
expect(commentsCheck.name).toBe("comments");
const def = getCheck("comments");
expect(def).toBeDefined();
expect(def?.name).toBe("comments");
});
it("produces findings.md without --fix and preserves why comments", async () => {
const scope: CheckScope = {
cwd,
target,
fix: false,
rest: [],
};
setAgentRunner(rubricRunner({ getScope: () => scope }));
const def = commentsCheck;
await handleCheckCommand(def, target, stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state).toBeDefined();
expect(state?.checks.comments.status).toBe("complete");
expect(state?.status).toBe("complete");
// scan-only: no fix phase, no changes artifact, source left untouched
expect(state?.checks.comments.fix).toBe(false);
expect(state?.checks.comments.changes).toBeUndefined();
expect(state?.checks.comments.findings).toContain("RESTATE");
const fText = await readFile(findingsPath(scope), "utf8");
expect(fText).toContain("findings");
expect(fText).toContain("WHY");
// scan is read-only: restating comments still present in the source
const unchanged = await readFile(target, "utf8");
expect(unchanged).toContain("// increment the counter");
expect(unchanged).toContain("// compute the hash");
expect(unchanged).toContain("power-of-two");
});
it("with --fix: removes restating, keeps why, writes changes.md", async () => {
const scope: CheckScope = {
cwd,
target,
fix: true,
rest: [],
};
setAgentRunner(rubricRunner({ getScope: () => scope }));
const def = commentsCheck;
await handleCheckCommand(def, `--fix ${target}`, stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks.comments.status).toBe("complete");
expect(state?.checks.comments.fix).toBe(true);
expect(state?.checks.comments.changes).toContain("removed comment (auto)");
// both artifacts present
const fText = await readFile(findingsPath(scope), "utf8");
const cText = await readFile(changesPath(scope), "utf8");
expect(fText).toContain("findings");
expect(cText).toContain("changes");
const cleaned = await readFile(target, "utf8");
expect(cleaned).not.toContain("// increment the counter");
expect(cleaned).not.toContain("// compute the hash");
expect(cleaned).toContain("power-of-two"); // why comment survives
expect(cleaned).toContain("counter++"); // real code intact
expect(cleaned).toContain("return k.split"); // logic untouched
});
it("marks phases complete and records artifacts in run-state", async () => {
const scope: CheckScope = { cwd, target, fix: true, rest: [] };
setAgentRunner(rubricRunner({ getScope: () => scope }));
await handleCheckCommand(commentsCheck, `--fix ${target}`, stubCtx(cwd));
const state = await loadRunState(cwd);
const check = state?.checks.comments;
expect(check).toBeDefined();
const statuses = check!.phases.map((p) => `${p.id}=${p.status}`);
expect(statuses).toContain("analysis=complete");
expect(statuses).toContain("fix=complete");
expect(statuses).toContain("verify=complete");
expect(statuses).toContain("cleanup=complete");
expect(check!.findings).toBeDefined();
expect(check!.changes).toBeDefined();
});
});

105
tests/complexity.test.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* complexity.test.ts — integration tests for the complexity hygiene check.
*
* Validates:
* 1. A function with cyclomatic complexity 50+ is flagged and refactored
* 2. A function with complexity 35-49 is flagged with justification required
* 3. Deep nesting and over-abstraction are simplified
*/
import {
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 {
check as complexityCheck,
buildComplexityScanTask,
buildComplexityFixTask,
} from "../src/checks/complexity.js";
import { registerCheck, clearChecks } from "../src/checks/registry.js";
import {
runAgentTask,
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
} from "../src/agent-runner.js";
describe("complexity check", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(tmpdir(), "pygienium-test-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
});
it("is registered with correct properties", () => {
expect(complexityCheck.name).toBe("complexity");
expect(complexityCheck.label).toBe("Complexity");
expect(complexityCheck.agentName).toBe("scanner");
expect(complexityCheck.fixAgentName).toBe("fixer");
});
it("builds scan task with complexity thresholds", () => {
const scope = {
cwd: tempDir,
target: tempDir,
fix: false,
rest: [],
};
const task = buildComplexityScanTask(tempDir, scope);
expect(task).toContain("cyclomatic complexity");
expect(task).toContain("50+");
expect(task).toContain("35-49");
expect(task).toContain("MUST refactor");
expect(task).toContain("findings.md");
});
it("builds fix task from findings", () => {
const scope = {
cwd: tempDir,
target: tempDir,
fix: true,
rest: [],
};
const findings =
"# complexity findings\n\n- myFunction: complexity 65 - must refactor";
const task = buildComplexityFixTask(tempDir, scope, findings);
expect(task).toContain("complexity fix");
expect(task).toContain("myFunction");
expect(task).toContain("changes.md");
});
});
describe("agent loading", () => {
it("loads scanner agent from agents directory", async () => {
const { loadAgents } = await import("../src/agents.js");
const agents = await loadAgents();
expect(agents.has("scanner")).toBe(true);
const scanner = agents.get("scanner");
expect(scanner).toBeDefined();
expect(scanner?.systemPrompt).toContain("scanner");
});
it("loads fixer agent from agents directory", async () => {
const { loadAgents } = await import("../src/agents.js");
const agents = await loadAgents();
expect(agents.has("fixer")).toBe(true);
const fixer = agents.get("fixer");
expect(fixer).toBeDefined();
expect(fixer?.allowedTools).toContain("edit");
});
});

623
tests/dead-code.test.ts Normal file
View File

@@ -0,0 +1,623 @@
/**
* dead-code.test.ts — integration tests for the dead-code check.
*
* Covers:
* - registry/help: registering the check auto-binds `/pygienium-dead-code`
* (zero index.ts wiring changes).
* - deterministic detection: unused export, dead file, obsolete compat shim,
* and unused dependency are classified; a dynamically-imported module is
* classified `review`.
* - E2E `--fix`: clearly-dead items are removed and listed in changes.md;
* the dynamically-imported module is preserved and flagged for review.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
clearChecks,
getAllChecks,
registerCheck,
getCheck,
} from "../src/checks/registry.js";
import {
check as deadCodeCheck,
detectDeadCode,
findingsPath,
changesPath,
renderFindingsMd,
applyDeadCodeFixes,
} from "../src/checks/dead-code.js";
import { buildPygieniumHelpLines } from "../src/help.js";
import {
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
} 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, hasUI: false, ui: undefined } as PygieniumCtx;
}
async function writeFixture(root: string): Promise<void> {
await mkdir(join(root, "src", "routes"), { recursive: true });
// package.json — carries an unused dependency + an entry `main`.
await writeFile(
join(root, "package.json"),
JSON.stringify(
{
name: "fixture",
version: "1.0.0",
main: "src/index.ts",
dependencies: {
leftoverpkg: "^1.0.0",
typescript: "^5.0.0",
},
devDependencies: {},
},
null,
2,
) + "\n",
);
// util.ts — one used export and one unused export.
await writeFile(
join(root, "src", "util.ts"),
[
"export function add(a: number, b: number): number {",
" return a + b;",
"}",
"",
"export function unusedHelper(): string {",
' return "never called anywhere";',
"}",
"",
].join("\n"),
);
// index.ts — the entry (reaches util and routes), its name is entry-like
// so the zero-importer rule must NOT flag it as dead.
await writeFile(
join(root, "src", "index.ts"),
[
'import { add } from "./util";',
'import { load } from "./routes";',
"add(1, 2);",
"load();",
"",
].join("\n"),
);
// routes/index.ts — live barrel that exposes a lazy loader.
await writeFile(
join(root, "src", "routes", "index.ts"),
['export const load = () => import("./lazy-route");', ""].join("\n"),
);
// lazy-route.ts — a shim reachable ONLY through a dynamic import. It must
// be classified `review` and never auto-removed.
await writeFile(
join(root, "src", "routes", "lazy-route.ts"),
["export function registerRoute(): void {", " return;", "}", ""].join(
"\n",
),
);
// compat.ts — an obsolete deprecated compat wrapper with zero importers.
await writeFile(
join(root, "src", "compat.ts"),
[
"// @deprecated obsolete compatibility wrapper — scheduled for removal.",
"export function legacyFormat(x: string): string {",
" return x;",
"}",
"",
].join("\n"),
);
// orphan.ts — a completely unreferenced module (dead file).
await writeFile(
join(root, "src", "orphan.ts"),
["export function orphan(): void {", " return;", "}", ""].join("\n"),
);
}
describe("dead-code registry + help", () => {
beforeEach(async () => {
clearChecks();
// Re-register after clearChecks (module-level registration runs on import).
registerCheck(deadCodeCheck);
});
it("registers a single dead-code check with a serialisable name", () => {
expect(getCheck("dead-code")).toBeDefined();
expect(getAllChecks().filter((c) => c.name === "dead-code")).toHaveLength(
1,
);
});
it("appears in /pygienium-help with its description (auto discovery)", () => {
const help = buildPygieniumHelpLines();
expect(help.join("\n")).toContain("/pygienium-dead-code");
expect(help.join("\n")).toContain(deadCodeCheck.description);
});
});
describe("dead-code detection", () => {
it("classifies unused export, dead file, compat shim, unused dep; preserves dynamic import", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-"));
try {
await writeFixture(root);
const report = await detectDeadCode(root);
expect(report.items.length).toBeGreaterThan(0);
const unusedExport = report.items.find(
(i) => i.category === "export" && i.name === "unusedHelper",
);
expect(unusedExport).toBeDefined();
expect(unusedExport?.review).toBe(false);
expect(unusedExport?.target).toBe("symbol");
const deadShim = report.items.find(
(i) => i.category === "shim" && i.rel === "src/compat.ts",
);
expect(deadShim).toBeDefined();
expect(deadShim?.review).toBe(false);
expect(deadShim?.target).toBe("file");
const deadFile = report.items.find(
(i) => i.category === "file" && i.rel === "src/orphan.ts",
);
expect(deadFile).toBeDefined();
expect(deadFile?.review).toBe(false);
const unusedDep = report.items.find(
(i) => i.category === "dep" && i.name === "leftoverpkg",
);
expect(unusedDep).toBeDefined();
// dynamic-import shim → review, always preserved
const dynamic = report.items.find(
(i) => i.rel === "src/routes/lazy-route.ts",
);
expect(dynamic).toBeDefined();
expect(dynamic?.review).toBe(true);
expect(dynamic?.target).toBe("file");
// used export + entry file are NOT flagged.
expect(report.items.find((i) => i.name === "add")).toBeUndefined();
expect(
report.items.find((i) => i.rel === "src/index.ts"),
).toBeUndefined();
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("renderFindingsMd groups findings under the four category headings", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-"));
try {
await writeFixture(root);
const report = await detectDeadCode(root);
const md = renderFindingsMd(report);
expect(md).toContain("## Unused exports");
expect(md).toContain("## Dead files (zero importers)");
expect(md).toContain("## Obsolete shims / migration helpers");
expect(md).toContain("## Unused dependencies");
expect(md).toMatch(/\[review\] src\/routes\/lazy-route\.ts/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("dead-code E2E (--fix)", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
registerCheck(deadCodeCheck);
setAgentRunner(fakeAgentRunner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-dead-e2e-"));
await writeFixture(cwd);
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("removes clearly-dead items, preserves dynamically-imported shim, and writes findings.md + changes.md", async () => {
// Make the pre-existing compat.ts scan-detected before the run to also
// prove the deterministic scan picks it up regardless of run order.
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd));
// --- Clearly-dead items are removed --------------------------------
// Unused export: removed from util.ts, live export preserved.
const util = await readFile(join(cwd, "src", "util.ts"), "utf8");
expect(util).not.toContain("unusedHelper");
expect(util).toContain("add");
// Obsolete compat shim: file deleted.
expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(false);
// Dead file: deleted.
expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(false);
// Unused dependency: removed from package.json.
const pkg = JSON.parse(
await readFile(join(cwd, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
expect(pkg.dependencies.leftoverpkg).toBeUndefined();
// Dynamically-imported shim: preserved, untouched.
expect(existsSync(join(cwd, "src", "routes", "lazy-route.ts"))).toBe(true);
const lazy = await readFile(
join(cwd, "src", "routes", "lazy-route.ts"),
"utf8",
);
expect(lazy).toContain("registerRoute");
// findings.md exists and is categorized.
expect(existsSync(findingsPath(cwd))).toBe(true);
const findingsMd = await readFile(findingsPath(cwd), "utf8");
expect(findingsMd).toContain("## Unused exports");
expect(findingsMd).toContain("## Dead files (zero importers)");
expect(findingsMd).toContain("## Obsolete shims / migration helpers");
expect(findingsMd).toContain("## Unused dependencies");
// changes.md lists the removals and the preserved review item.
expect(existsSync(changesPath(cwd))).toBe(true);
const changesMd = await readFile(changesPath(cwd), "utf8");
expect(changesMd).toContain("## Removed (auto)");
expect(changesMd).toContain("src/compat.ts");
expect(changesMd).toContain("unusedHelper");
expect(changesMd).toContain("leftoverpkg");
expect(changesMd).toContain("## Preserved for review (manual)");
expect(changesMd).toContain("lazy-route.ts"); // dynamic import preserved
});
it("records the run in run-state and marks the check complete", async () => {
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["dead-code"].status).toBe("complete");
expect(state?.checks["dead-code"].findings).toBeDefined();
expect(state?.checks["dead-code"].changes).toBeDefined();
});
it("runs a dry scan (no --fix) and does not write changes.md or touch files", async () => {
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
// Nothing removed without --fix.
expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(true);
expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(true);
const utils = await readFile(join(cwd, "src", "util.ts"), "utf8");
expect(utils).toContain("unusedHelper");
// Findings recorded, but no changes yet.
const state = await loadRunState(cwd);
expect(state?.checks["dead-code"].status).toBe("complete");
expect(state?.checks["dead-code"].changes).toBeUndefined();
});
});
describe("dead-code shim auto-delete safety (entry points + prose)", () => {
beforeEach(() => {
clearChecks();
registerCheck(deadCodeCheck);
setAgentRunner(fakeAgentRunner);
});
afterEach(() => {
resetAgentRunner();
});
it("never auto-deletes an entry point whose prose mentions 'legacy'", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-entry-"));
try {
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "package.json"),
JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n",
);
// Entry point, zero importers, prose contains 'legacy' — must survive.
await writeFile(
join(root, "src", "index.ts"),
[
"// handles legacy payloads",
"export function main(): void {}",
"",
].join("\n"),
);
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("never auto-deletes a *.test.ts whose description mentions 'deprecated'", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-test-"));
try {
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "src", "foo.test.ts"),
[
"import { describe, it } from 'bun:test';",
"describe('app', () => {",
" it('still supports the deprecated API', () => {});",
"});",
"",
].join("\n"),
);
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
expect(existsSync(join(root, "src", "foo.test.ts"))).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("flags an entry-like file tagged @deprecated for review, not deletion", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-tag-"));
try {
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "package.json"),
JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n",
);
await writeFile(
join(root, "src", "index.ts"),
["/** @deprecated */", "export function main(): void {}", ""].join(
"\n",
),
);
const report = await detectDeadCode(root);
const entry = report.items.find(
(i) => i.rel === "src/index.ts" && i.category === "shim",
);
expect(entry).toBeDefined();
expect(entry?.review).toBe(true);
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("dead-code barrel re-export retention", () => {
it("keeps modules reachable only through `export * from` / `export {…} from` barrels", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-"));
try {
// index.ts is entry-like; it aggregates barrelA, which aggregates
// barrelB both by star and by name. Neither barrel may be treated as
// a zero-importer dead file, and symbols only reachable through the
// star re-export must stay behind a review flag.
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "src", "index.ts"),
['export * from "./barrelA";', ""].join("\n"),
);
await writeFile(
join(root, "src", "barrelA.ts"),
[
'export * from "./barrelB";',
'export { namedB } from "./barrelB";',
"",
].join("\n"),
);
await writeFile(
join(root, "src", "barrelB.ts"),
[
"export const value = 1;",
"export const namedB = 2;",
"export const starOnly = 3;",
"",
].join("\n"),
);
const report = await detectDeadCode(root);
// Neither barrel is a dead-file candidate.
expect(
report.items.find(
(i) => i.category === "file" && i.rel === "src/barrelA.ts",
),
).toBeUndefined();
expect(
report.items.find(
(i) => i.category === "file" && i.rel === "src/barrelB.ts",
),
).toBeUndefined();
// Symbols in the star/named re-export target stay `review` — the
// deterministic fixer must not auto-delete them.
const value = report.items.find(
(i) =>
i.category === "export" &&
i.rel === "src/barrelB.ts" &&
i.name === "value",
);
expect(value).toBeDefined();
expect(value?.review).toBe(true);
const starOnly = report.items.find(
(i) =>
i.category === "export" &&
i.rel === "src/barrelB.ts" &&
i.name === "starOnly",
);
expect(starOnly).toBeDefined();
expect(starOnly?.review).toBe(true);
// The named re-export is referenced (by barrelA) so it is not dead.
expect(
report.items.find(
(i) =>
i.category === "export" &&
i.rel === "src/barrelB.ts" &&
i.name === "namedB",
),
).toBeUndefined();
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("`--fix` never deletes a barrel-exported module", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-e2e-"));
try {
clearChecks();
registerCheck(deadCodeCheck);
setAgentRunner(fakeAgentRunner);
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "src", "index.ts"),
['export * from "./barrelA";', ""].join("\n"),
);
await writeFile(
join(root, "src", "barrelA.ts"),
[
'export * from "./barrelB";',
'export { namedB } from "./barrelB";',
"",
].join("\n"),
);
await writeFile(
join(root, "src", "barrelB.ts"),
[
"export const value = 1;",
"export const namedB = 2;",
"export const starOnly = 3;",
"",
].join("\n"),
);
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
expect(existsSync(join(root, "src", "barrelA.ts"))).toBe(true);
expect(existsSync(join(root, "src", "barrelB.ts"))).toBe(true);
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
} finally {
resetAgentRunner();
await rm(root, { recursive: true, force: true });
}
});
});
describe("dead-code symbol removal (multi-statement bodies)", () => {
it("removes arrow-block consts, object consts, and inline-closing functions whole", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-shapes-"));
try {
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "src", "math.ts"),
[
"export const build = () => {",
" const a = 1;",
" return a + 2;",
"};",
"export function packed() {",
' return "x"; }',
'export const config = { retries: 3, label: "cfg" };',
"export function keep(): string {",
' return "keep";',
"}",
"export const keepVar = 9;",
"",
].join("\n"),
);
// Referenced exports keep math.ts alive and `keep`/`keepVar` used.
await writeFile(
join(root, "src", "app.ts"),
[
'import { keep, keepVar } from "./math";',
"console.log(keep(), keepVar);",
"",
].join("\n"),
);
const report = await detectDeadCode(root);
const names = report.items
.filter((i) => i.category === "export" && i.rel === "src/math.ts")
.map((i) => i.name);
expect(names).toContain("build");
expect(names).toContain("packed");
expect(names).toContain("config");
expect(names).not.toContain("keep");
expect(names).not.toContain("keepVar");
const { applied } = await applyDeadCodeFixes(report);
expect(applied.map((i) => i.name)).toEqual(
expect.arrayContaining(["build", "packed", "config"]),
);
const out = await readFile(join(root, "src", "math.ts"), "utf8");
expect(out).not.toContain("build");
expect(out).not.toContain("packed");
expect(out).not.toContain("config");
expect(out).toContain("keep");
expect(out).toContain("keepVar");
// No leftover arrow body from the removed declaration.
expect(out).not.toContain("return a + 2");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("preserves a symbol it cannot safely remove instead of corrupting source", async () => {
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-guard-"));
try {
const file = join(root, "weird.ts");
await writeFile(
file,
[
'export const rx = () => /[{;}]/.test("a;");',
"export const keep = 1;",
"",
].join("\n"),
);
const original = await readFile(file, "utf8");
// Hand-built report forces an auto removal attempt on a shape the
// scanner does not fully model (regex with braces/semicolons).
const { applyDeadCodeFixes: apply } = await import(
"../src/checks/dead-code.js"
);
const report = {
target: root,
scannedAt: new Date().toISOString(),
items: [
{
category: "export" as const,
path: file,
rel: "weird.ts",
name: "rx",
line: 1,
target: "symbol" as const,
review: false,
reason: "test",
},
],
};
const { applied } = await apply(report);
const after = await readFile(file, "utf8");
// Either the removal succeeded cleanly, or the file is untouched —
// never a truncated/corrupt intermediate.
if (applied.length === 0) {
expect(after).toBe(original);
} else {
expect(after).not.toContain("rx");
expect(after).toContain("keep");
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

171
tests/deep-modules.test.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* deep-modules.test.ts — integration test for the deep-modules check.
*
* Seeds a temp workspace with a pass-through wrapper module (a shallow
* abstraction), runs the check with the deterministic fake agent runner, and
* asserts:
* - the scan persists `findings.md` flagging the wrapper as a pass-through;
* - with `--fix`, the safe consolidation is applied (wrapper rewritten/
* removed) and `changes.md` records it (auto), while a risky
* external-importer case is listed for review (manual), never auto-applied.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
clearChecks,
getCheck,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import {
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
import {
findingsPath,
changesPath,
check as deepModulesCheck,
} from "../src/checks/deep-modules.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
/** Drop a pass-through wrapper module that forwards a single lib call. */
async function seedPassThrough(
dir: string,
): Promise<{ wrapper: string; lib: string }> {
const wrapper = join(dir, "wrapper.ts");
const lib = join(dir, "lib.ts");
await mkdir(dir, { recursive: true }).catch(() => {});
await writeFile(
lib,
`export function compute(x: number): number { return x * 2; }\n`,
"utf8",
);
// Shallow pass-through: forwards every argument to `lib` with zero added logic.
await writeFile(
wrapper,
`import { compute } from "./lib";\nexport function run(x: number) { return compute(x); }\n`,
"utf8",
);
return { wrapper, lib };
}
describe("deep-modules check", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(fakeAgentRunner);
// Re-register explicitly: the module's import-time registerCheck only
// runs once (module cache), so clearChecks + registerCheck restores it
// deterministically for each test.
registerCheck(deepModulesCheck);
cwd = await mkdtemp(join(tmpdir(), "pygienium-deep-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true }).catch(() => {});
});
it("is registered and uses the deep-modules scanner agent", () => {
const check = getCheck("deep-modules");
expect(check).toBeDefined();
expect((check as CheckDefinition)?.agentName).toBe("deep-modules");
});
it("flags the pass-through wrapper in findings.md", async () => {
await seedPassThrough(cwd);
const check = getCheck("deep-modules")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const findings = await readFile(findingsPath(cwd), "utf8");
expect(findings).toContain("wrapper.ts");
expect(findings).toContain("pass-through-wrapper");
expect(findings).toMatch(/importers:\s*0/);
// Run state records the scan summary as findings text.
const state = await loadRunState(cwd);
expect(state?.checks["deep-modules"]?.status).toBe("complete");
expect(state?.checks["deep-modules"]?.findings).toContain(
"deep-modules: 1 issue",
);
});
it("scan-only does not write changes.md", async () => {
await seedPassThrough(cwd);
const check = getCheck("deep-modules")!;
await handleCheckCommand(check, "", stubCtx(cwd));
expect(existsSync(changesPath(cwd))).toBe(false);
});
it("--fix applies the safe consolidation and records changes.md (auto), and defers the risky one (manual)", async () => {
await seedPassThrough(cwd);
// Also drop a "risky" adapter so we can assert it is NOT auto-applied.
await writeFile(
join(cwd, "risky-adapter.ts"),
`// adapter-layer with external importers — should be listed for review only\nexport const risky = true;\n`,
"utf8",
);
const check = getCheck("deep-modules")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const changes = await readFile(changesPath(cwd), "utf8");
// Safe pass-through: consolidation applied (auto).
expect(changes).toContain("wrapper.ts");
expect(changes).toMatch(/auto/);
expect(changes).toMatch(/consolidat/i);
// Risky adapter: listed for review, not auto-applied (manual).
expect(changes).toContain("risky-adapter.ts");
expect(changes).toMatch(/manual/);
// The safe wrapper was rewritten — no longer a pass-through.
const wrapperContent = await readFile(join(cwd, "wrapper.ts"), "utf8");
expect(wrapperContent).not.toContain("import { compute }");
expect(wrapperContent).toContain("Consolidated");
const state = await loadRunState(cwd);
expect(state?.checks["deep-modules"]?.fix).toBe(true);
expect(state?.checks["deep-modules"]?.changes).toContain(
"1 auto-applied, 1 deferred",
);
expect(state?.checks["deep-modules"]?.status).toBe("complete");
});
it("findings.md and changes.md live under .pygienium/checks/deep-modules/", async () => {
await seedPassThrough(cwd);
const check = getCheck("deep-modules")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
expect(findingsPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "deep-modules", "findings.md"),
);
expect(changesPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "deep-modules", "changes.md"),
);
});
it("skips when the target has no source files", async () => {
const empty = await mkdtemp(join(tmpdir(), "pygienium-empty-"));
try {
const check = getCheck("deep-modules")!;
await handleCheckCommand(check, empty, stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["deep-modules"]?.status).toBe("skipped");
} finally {
await rm(empty, { recursive: true, force: true }).catch(() => {});
}
});
});

View File

@@ -0,0 +1,232 @@
/**
* defensive-guards.test.ts — integration test for the defensive-guards check.
*
* Seeds a temp workspace with:
* - noise.ts: a redundant null check on a typed-non-null parameter PLUS a
* swallowing try/catch (both redundant);
* - boundary.ts: a try/catch around JSON.parse (a legitimate parsing
* boundary guard).
*
* Runs the check with the deterministic fake agent runner and asserts:
* - the scan persists findings.md separating redundant guards from boundary
* guards;
* - with --fix, the redundant guards are removed from noise.ts and changes.md
* records them (auto), while the JSON.parse guard in boundary.ts is
* preserved untouched (kept — boundary).
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
clearChecks,
getCheck,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import {
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
import {
findingsPath,
changesPath,
check as defensiveGuardsCheck,
} from "../src/checks/defensive-guards.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
/**
* Seed `noise.ts`: a redundant null check on a typed-non-null param plus a
* swallowing try/catch. Both are redundant — the type system guarantees
* `name` is a string, and the catch silently swallows the error.
*/
async function seedNoise(dir: string): Promise<string> {
const noise = join(dir, "noise.ts");
await mkdir(dir, { recursive: true }).catch(() => {});
await writeFile(
noise,
[
`export function greet(name: string) {`,
` if (name === null) return "";`,
` return \`hello \${name}\`;`,
`}`,
``,
`export function swallow() {`,
` try {`,
` doThing();`,
` } catch (e) {`,
` // swallowed`,
` }`,
`}`,
``,
`function doThing() {}`,
``,
].join("\n"),
"utf8",
);
return noise;
}
/**
* Seed `boundary.ts`: a try/catch around JSON.parse of untrusted input. This is
* a legitimate parsing boundary guard and must be PRESERVED by --fix.
*/
async function seedBoundary(dir: string): Promise<string> {
const boundary = join(dir, "boundary.ts");
await mkdir(dir, { recursive: true }).catch(() => {});
await writeFile(
boundary,
[
`export function parse(input: string) {`,
` try {`,
` return JSON.parse(input);`,
` } catch (e) {`,
` return null;`,
` }`,
`}`,
``,
].join("\n"),
"utf8",
);
return boundary;
}
describe("defensive-guards check", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(fakeAgentRunner);
// Re-register explicitly: the module's import-time registerCheck only
// runs once (module cache), so clearChecks + registerCheck restores it
// deterministically for each test.
registerCheck(defensiveGuardsCheck);
// Fresh empty tempdir per test; each test seeds itself so the skip
// test gets a genuinely empty cwd (the gate inspects cwd, not target).
cwd = await mkdtemp(join(tmpdir(), "pygienium-dg-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true }).catch(() => {});
});
it("is registered and uses the defensive-guards scanner agent", () => {
const check = getCheck("defensive-guards");
expect(check).toBeDefined();
expect((check as CheckDefinition)?.agentName).toBe("defensive-guards");
});
it("flags the redundant null check and swallowing try/catch, and keeps the JSON.parse boundary guard, in findings.md", async () => {
await seedNoise(cwd);
await seedBoundary(cwd);
const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const findings = await readFile(findingsPath(cwd), "utf8");
// Redundant guards are flagged with their kind.
expect(findings).toContain("noise.ts");
expect(findings).toContain("redundant-null-check");
expect(findings).toContain("swallowing-try-catch");
// The JSON.parse guard is classified as a boundary guard (kept).
expect(findings).toContain("boundary.ts");
expect(findings).toContain("keep-boundary");
expect(findings).toContain("parsing-guard");
// Run state records the scan summary as findings text.
const state = await loadRunState(cwd);
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
expect(state?.checks["defensive-guards"]?.findings).toContain(
"defensive-guards: 2 redundant",
);
});
it("scan-only does not write changes.md and does not touch source files", async () => {
const noise = await seedNoise(cwd);
const boundary = await seedBoundary(cwd);
const before = await readFile(noise, "utf8");
const beforeBoundary = await readFile(boundary, "utf8");
const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "", stubCtx(cwd));
expect(existsSync(changesPath(cwd))).toBe(false);
// Source files untouched by a scan-only run.
expect(await readFile(noise, "utf8")).toBe(before);
expect(await readFile(boundary, "utf8")).toBe(beforeBoundary);
});
it("--fix removes the redundant guards from noise.ts and records changes.md (auto), and preserves the JSON.parse boundary guard", async () => {
const noise = await seedNoise(cwd);
const boundary = await seedBoundary(cwd);
const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const changes = await readFile(changesPath(cwd), "utf8");
// Redundant guards: removed (auto).
expect(changes).toContain("noise.ts");
expect(changes).toMatch(/auto/);
expect(changes).toMatch(/redundant-null-check/);
expect(changes).toMatch(/swallowing-try-catch/);
// JSON.parse boundary guard: kept (boundary — with reason).
expect(changes).toContain("boundary.ts");
expect(changes).toMatch(/boundary/);
expect(changes).toMatch(/JSON.parse/);
// noise.ts no longer contains the redundant null check or the swallowing
// try/catch. The fixer leaves a header marker noting the cleanup.
const cleaned = await readFile(noise, "utf8");
expect(cleaned).not.toContain("=== null");
expect(cleaned).not.toMatch(/try\s*\{/);
expect(cleaned).toContain("Cleaned by pygienium-defensive-guards");
// The happy-path behaviour is preserved.
expect(cleaned).toContain("greet");
expect(cleaned).toContain("hello");
// boundary.ts is PRESERVED — the JSON.parse guard is untouched.
const keptBoundary = await readFile(boundary, "utf8");
expect(keptBoundary).toContain("JSON.parse");
expect(keptBoundary).toMatch(/try\s*\{/);
expect(keptBoundary).toMatch(/catch/);
// And it still returns null on parse failure (unchanged behaviour).
expect(keptBoundary).toContain("return null");
const state = await loadRunState(cwd);
expect(state?.checks["defensive-guards"]?.fix).toBe(true);
expect(state?.checks["defensive-guards"]?.changes).toContain(
"2 removed, 1 kept",
);
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
});
it("findings.md and changes.md live under .pygienium/checks/defensive-guards/", async () => {
await seedNoise(cwd);
await seedBoundary(cwd);
const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
expect(findingsPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "defensive-guards", "findings.md"),
);
expect(changesPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "defensive-guards", "changes.md"),
);
});
it("skips when the target has no source files", async () => {
// cwd is a fresh empty tempdir (no seeding) → the gate finds no source
// files and skips the check without spawning an agent.
const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["defensive-guards"]?.status).toBe("skipped");
});
});

View File

@@ -0,0 +1,62 @@
/**
* extensibility.test.ts — the registry extensibility claim (task 14).
*
* Proves a NEW check registered via the public API yields a working
* `/pygienium-<name>` command with ZERO `index.ts` command-wiring changes: an
* in-test `registerCheck()` call makes the generic command-binding path
* (`registerPygieniumCommands`, the exact function `index.ts` calls) expose
* `/pygienium-witness` and `/pygienium-help` lists it. The shipped checks are
* each exercised by their own test files, so this suite only needs a synthetic
* witness.
*/
import { describe, expect, it, afterEach } from "bun:test";
import {
clearChecks,
getCheck,
getAllChecks,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import { registerPygieniumCommands } from "../src/commands.js";
import { buildPygieniumHelpLines } from "../src/help.js";
/** A synthetic check registered only for this suite. */
const witnessCheck: CheckDefinition = {
name: "witness",
label: "Witness",
description: "Test-only check proving zero-wiring extensibility.",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "witness",
buildScanTask: () =>
"# Task: witness scan\nwrite findings.md: witness: 0 issues",
buildFixTask: () => "# Task: witness fix\nwrite changes.md: witness: 0 edits",
gate: () => undefined,
};
describe("registry extensibility (task 14)", () => {
afterEach(() => clearChecks());
it("a registered check is visible via getCheck/getAllChecks", () => {
registerCheck(witnessCheck);
expect(getCheck("witness")).toBe(witnessCheck);
expect(getAllChecks().some((c) => c.name === "witness")).toBe(true);
});
it("registerPygieniumCommands exposes /pygienium-<name> (zero wiring)", () => {
registerCheck(witnessCheck);
const names: string[] = [];
registerPygieniumCommands((name) => names.push(name));
expect(names).toContain("pygienium-witness");
// And the operator commands are still wired.
expect(names).toContain("pygienium-help");
expect(names).toContain("pygienium-all");
});
it("/pygienium-help lists a registered check", () => {
registerCheck(witnessCheck);
const text = buildPygieniumHelpLines().join("\n");
expect(text).toContain("/pygienium-witness");
expect(text).toContain(witnessCheck.description);
});
});

269
tests/footer.test.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* footer.test.ts — unit tests for the pipeline-overview footer.
*
* The footer is a presentation-only multi-line `belowEditor` widget: with no
* UI it tracks items but writes nothing; with a stub UI it pushes a string[]
* of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
* and clears on `done()`. The pure {@link renderFooterList} core is asserted
* directly (layout + theming); a light widget-glue test covers the wiring.
*/
import { describe, expect, it } from "bun:test";
import {
createPipelineFooter,
footerPhaseItems,
footerColor,
renderFooterList,
FOOTER_MARKER,
FOOTER_STATUS_KEY,
type FooterItem,
type FooterTheme,
} from "../src/footer.js";
import { PHASE_LABELS } from "../src/phases.js";
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
/** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
function fakeTheme(): FooterTheme {
return { fg: (color, text) => `${color}:${text}` };
}
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
function stubUi(theme: FooterTheme = fakeTheme()): {
ui: {
theme: FooterTheme;
setWidget: (
key: string,
content: string[] | undefined,
options?: { placement?: string },
) => void;
};
calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[];
} {
const calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[] = [];
return {
calls,
ui: {
theme,
setWidget(key, content, options) {
calls.push({ key, content, placement: options?.placement });
},
},
};
}
/** Build the canonical phase-id list a scan-only check uses. */
function scanPhaseIds(): string[] {
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
}
/** Items for the canonical scan-only pipeline. */
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
return scanPhaseIds().map((id) => ({
label: PHASE_LABELS[id] ?? id,
status,
}));
}
describe("renderFooterList", () => {
it("renders one bulleted, numbered, themed line per phase", () => {
const lines = renderFooterList(scanItems(), -1, fakeTheme());
expect(lines).toHaveLength(3);
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
expect(lines[0]).toBe("dim:• · 1. Recon");
expect(lines[1]).toBe("dim:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
const lines = renderFooterList(scanItems(), 1, fakeTheme());
expect(lines[0]).toBe("dim:• · 1. Recon");
// cursor (index 1) is pending-but-current → accent.
expect(lines[1]).toBe("accent:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes terminal statuses with success/error/warning colors", () => {
const items: FooterItem[] = [
{ label: "Recon", status: "complete" },
{ label: "Scan", status: "running" },
{ label: "Fix", status: "failed" },
{ label: "Verify", status: "skipped" },
];
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toBe("success:• ✓ 1. Recon");
expect(lines[1]).toBe("accent:• ● 2. Scan");
expect(lines[2]).toBe("error:• ✗ 3. Fix");
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
});
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
label: `S${i}`,
status: "pending" as const,
}));
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toContain("01. S0");
expect(lines[10]).toContain("11. S10");
});
});
describe("footerColor", () => {
it("maps each status to its piolium-style color token", () => {
expect(footerColor("complete", false)).toBe("success");
expect(footerColor("failed", false)).toBe("error");
expect(footerColor("skipped", false)).toBe("warning");
expect(footerColor("running", false)).toBe("accent");
expect(footerColor("pending", false)).toBe("dim");
// A pending item under the cursor reads as accent (current).
expect(footerColor("pending", true)).toBe("accent");
});
});
describe("createPipelineFooter", () => {
it("is a no-op without a UI but still tracks item state", () => {
// hasUI false: setWidget must never be called.
const footer = createPipelineFooter({ hasUI: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// No UI → no observable side effect, but getItems reflects state.
expect(footer.getItems()[0]?.status).toBe("running");
expect(footer.getTitle()).toBe("pygienium smoke");
});
it("renders the full pipeline as a belowEditor widget and clears on done", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
footer.setPipeline("pygienium smoke", items, 0);
// One setWidget call, under the canonical key, placement belowEditor.
expect(calls).toHaveLength(1);
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
// Title line first (dim), then one bulleted line per phase.
expect(lines[0]).toBe("dim:pygienium smoke");
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. Fixing`);
// The cursor item is marked running.
expect(footer.getItems()[0]?.status).toBe("running");
footer.done();
// done() pushes an undefined to clear the slot, then resets state.
const last = calls[calls.length - 1]!;
expect(last.content).toBeUndefined();
expect(last.placement).toBe("belowEditor");
expect(footer.getItems()).toHaveLength(0);
});
it("demotes the previous running item to pending when the cursor moves", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
// recon → complete, then advance to analysis.
footer.setItem(0, "complete");
footer.setCursor(1);
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("running");
});
it("does not demote a terminal item when the cursor advances past it", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
footer.setCursor(0); // recon running
footer.setItem(0, "complete");
footer.setCursor(1); // analysis running
footer.setItem(1, "complete");
footer.setCursor(2); // fix running
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("complete");
expect(items[2]?.status).toBe("running");
});
it("marks a skipped gate as every item skipped", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium comments",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
);
for (let i = 0; i < footer.getItems().length; i++) {
footer.setItem(i, "skipped");
}
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
});
it("can be disabled so it never touches the widget slot", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true, enabled: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// enabled:false suppresses every setWidget call (used by /pygienium-all
// which owns its own footer).
expect(calls).toHaveLength(0);
});
it("writes under a custom widget key (all-run owns its slot)", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
statusKey: "pygienium-all",
});
footer.setPipeline(
"pygienium: all",
[
{ label: "comments", status: "pending" },
{ label: "dead-code", status: "pending" },
],
0,
);
expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
expect(lines[0]).toBe("dim:pygienium: all");
// Cursor on the first check; second still pending (to come).
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
});
});
describe("footerPhaseItems", () => {
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
expect(items.every((i) => i.status === "pending")).toBe(true);
});
it("falls back to the raw id for unknown phases", () => {
const items = footerPhaseItems(["custom"], PHASE_LABELS);
expect(items[0]?.label).toBe("custom");
});
});

101
tests/help.test.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* help.test.ts — `/pygienium-help` content (task 14).
*
* Asserts the help block lists every operator command, every implemented flag,
* and at least 8 commands once a handful of checks are registered. The
* per-check command family and the dynamic checks list are registry-driven, so
* these tests register stub checks rather than importing the real ones.
*/
import { describe, expect, it, beforeEach } from "bun:test";
import {
clearChecks,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import {
COMMANDS,
CLI_FLAGS,
PYGIENIUM_FLAGS,
buildPygieniumHelpLines,
} from "../src/help.js";
function stub(name: string): CheckDefinition {
return {
name,
label: name,
description: `${name} check`,
agentName: "scanner",
phaseId: "scan",
buildScanTask: () => "scan",
buildFixTask: () => "fix",
gate: () => undefined,
};
}
describe("/pygienium-help content (task 14)", () => {
beforeEach(() => clearChecks());
it("COMMANDS lists every operator command with usage/description/example", () => {
const usages = COMMANDS.map((c) => c.usage);
expect(usages).toContain("pygienium-help");
expect(usages).toContain("pygienium-<check> [path] [--fix] [--fresh]");
expect(usages).toContain(
"pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
);
expect(usages).toContain("pygienium-status [path]");
expect(usages).toContain("pygienium-resume [path] [--fresh]");
expect(usages).toContain(
"pygienium-export [path] [--check=] [--status=] [--out=md|json]",
);
for (const cmd of COMMANDS) {
expect(cmd.description.length).toBeGreaterThan(0);
expect(cmd.example.length).toBeGreaterThan(0);
}
});
it("CLI_FLAGS lists every implemented flag", () => {
const names = CLI_FLAGS.map((f) => f.name);
expect(names).toContain("[path]");
expect(names).toContain("--fix");
expect(names).toContain("--fresh");
expect(names).toContain("--check=");
expect(names).toContain("--status=");
expect(names).toContain("--out=");
expect(CLI_FLAGS.length).toBeGreaterThanOrEqual(6);
// Back-compat alias points at the same array.
expect(PYGIENIUM_FLAGS).toBe(CLI_FLAGS);
});
it("buildPygieniumHelpLines surfaces every command usage and flag name", () => {
const text = buildPygieniumHelpLines().join("\n");
for (const cmd of COMMANDS) {
expect(text).toContain(`/${cmd.usage}`);
}
for (const flag of CLI_FLAGS) {
expect(text).toContain(flag.name);
}
});
it("lists 8+ commands once several checks are registered", () => {
registerCheck(stub("alpha"));
registerCheck(stub("beta"));
registerCheck(stub("gamma"));
const lines = buildPygieniumHelpLines();
// Any line that begins ` /pygienium-` is a command/check listing row.
const commandRows = lines.filter((l) => l.startsWith(" /pygienium-"));
// 6 operator command rows + 3 registered checks = 9.
expect(commandRows.length).toBeGreaterThanOrEqual(8);
// Each registered check is listed by name with its description.
const text = lines.join("\n");
expect(text).toContain("/pygienium-alpha");
expect(text).toContain("/pygienium-beta");
expect(text).toContain("/pygienium-gamma");
expect(text).toContain("alpha check");
});
it("notes the one-file + registerCheck extensibility workflow", () => {
const text = buildPygieniumHelpLines().join("\n");
expect(text).toContain("registerCheck");
expect(text).toContain("No index.ts command-wiring changes");
});
});

View 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, 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: "" };
}
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: "" };
}
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

69
tests/registry.test.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* registry.test.ts — unit tests for the check registry.
*/
import { describe, expect, it, beforeEach } from "bun:test";
import {
clearChecks,
getAllChecks,
getCheck,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
function stubCheck(name: string): CheckDefinition {
return {
name,
label: name,
description: `${name} check`,
agentName: "scanner",
phaseId: "scan",
buildScanTask: () => "scan",
buildFixTask: () => "fix",
gate: () => undefined,
};
}
describe("check registry", () => {
beforeEach(() => clearChecks());
it("registerCheck inserts and getAllChecks returns it", () => {
registerCheck(stubCheck("comments"));
const all = getAllChecks();
expect(all).toHaveLength(1);
expect(all[0]?.name).toBe("comments");
});
it("getCheck looks up by name", () => {
registerCheck(stubCheck("complexity"));
expect(getCheck("complexity")?.label).toBe("complexity");
expect(getCheck("missing")).toBeUndefined();
});
it("registerCheck throws on duplicate names", () => {
registerCheck(stubCheck("dup"));
expect(() => registerCheck(stubCheck("dup"))).toThrow(/Duplicate/);
});
it("registerCheck throws on invalid names", () => {
expect(() => registerCheck(stubCheck("Bad-Name"))).toThrow(/Invalid/);
expect(() => registerCheck(stubCheck("with space"))).toThrow(/Invalid/);
expect(() => registerCheck(stubCheck(""))).toThrow(/Invalid/);
});
it("clearChecks empties the registry", () => {
registerCheck(stubCheck("a"));
clearChecks();
expect(getAllChecks()).toHaveLength(0);
});
it("preserves insertion order", () => {
registerCheck(stubCheck("alpha"));
registerCheck(stubCheck("beta"));
registerCheck(stubCheck("gamma"));
expect(getAllChecks().map((c) => c.name)).toEqual([
"alpha",
"beta",
"gamma",
]);
});
});

137
tests/run-state.test.ts Normal file
View 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 });
}
});
});

View File

@@ -0,0 +1,422 @@
/**
* status-resume-export.test.ts — integration test for task 13.
*
* Mirrors the spec scenario: start a hypothetical `/pygienium-all`, treat it as
* interrupted (one check complete, one pending), then exercise
* `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` and assert:
*
* - status reports accurate per-check progress (one complete, one pending,
* run still in_progress);
* - resume re-dispatches the pending check and DOES NOT re-run the complete
* one (proven via the agent-runner call log), and the run ends complete;
* - --fresh re-dispatches even the complete check (proven via call log);
* - export produces a filtered markdown bundle on disk, and the --check=
* and --out=json filters work.
*
* A tracker wraps the fake agent runner so we can assert which checks were
* actually dispatched without depending on timing or a model.
*/
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, relative } 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 {
handleStatusCommand,
handleResumeCommand,
handleExportCommand,
type PygieniumCtx,
} from "../src/commands.js";
import {
initRunState,
loadRunState,
saveRunState,
markCheckStatus,
recordCheckOutput,
applyPhaseStatus,
PHASE_RECON,
PHASE_ANALYSIS,
PHASE_FIX,
PHASE_VERIFY,
PHASE_CLEANUP,
} from "../src/run-state.js";
import { formatRunStatus } from "../src/status.js";
import {
exportRun,
gatherExportEntries,
renderExportJson,
renderExportMarkdown,
parseExportFilters,
canonicalChecksRoot,
} from "../src/export.js";
import type { AgentTaskOptions } from "../src/agent-runner.js";
/** Build a deterministic check whose fake runner writes on-disk artifacts. */
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`,
gate: () => undefined,
};
}
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
/** 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) => {
// Tag by check name from the task text (`!write .pygienium/checks/<name>/`).
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
if (m) dispatched.push(m[1] as string);
return fakeAgentRunner(opts);
};
return { runner, dispatched };
}
/** 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;
}
/** Mark a check as fully complete in the run-state with captured output. */
function markComplete(
state: Parameters<typeof markCheckStatus>[0],
name: string,
findings: string,
changes: string,
): void {
for (const phaseId of [
PHASE_RECON,
PHASE_ANALYSIS,
PHASE_FIX,
PHASE_VERIFY,
PHASE_CLEANUP,
]) {
applyPhaseStatus(state, name, phaseId, "complete");
}
recordCheckOutput(state, name, { findings, changes });
markCheckStatus(state, name, "complete");
}
describe("status / resume / export (task 13)", () => {
let cwd: string;
let track: ReturnType<typeof trackingRunner>;
beforeEach(async () => {
clearChecks();
track = trackingRunner();
setAgentRunner(track.runner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-resume-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
/** Seed an interrupted two-check run: `alpha` complete, `beta` pending. */
async function seedInterruptedRun(
fix = true,
): Promise<{ state: ReturnType<typeof initRunState> }> {
const state = initRunState(cwd, [
{ name: "alpha", label: "alpha", fix },
{ name: "beta", label: "beta", fix },
]);
state.recon = {
complete: true,
path: `${cwd}/.pygienium/recon.json`,
finishedAt: Date.now(),
};
// Simulate alpha fully complete with on-disk artifacts + captured text.
markComplete(
state,
"alpha",
"# alpha findings\nalpha-scan",
"# alpha changes\nalpha-fix",
);
const alphaDir = join(canonicalChecksRoot(cwd), "alpha");
await mkdir(alphaDir, { recursive: true });
await writeFile(
join(alphaDir, "findings.md"),
"# alpha findings\nalpha-scan\n",
"utf8",
);
await writeFile(
join(alphaDir, "changes.md"),
"# alpha changes\nalpha-fix\n",
"utf8",
);
// beta left pending (the interruption). For alpha's recon phase, mark it too.
applyPhaseStatus(state, "beta", PHASE_RECON, "complete");
await saveRunState(state);
return { state };
}
it("formatRunStatus reports accurate per-check progress (alpha complete, beta pending)", async () => {
const { state } = await seedInterruptedRun();
const lines = formatRunStatus(state);
expect(lines.join("\n")).toContain("pygienium run — in_progress");
expect(lines.join("\n")).toContain("alpha — complete");
expect(lines.join("\n")).toContain("beta — pending");
// Artifacts captured on alpha appear; beta has none.
expect(lines.join("\n")).toContain("findings: 2 line(s)");
expect(lines.join("\n")).toContain("changes: 2 line(s)");
});
it("/pygienium-status prints the status line list end to end", async () => {
const { state } = await seedInterruptedRun();
const out = await captureStdout(() =>
handleStatusCommand("", stubCtx(cwd)),
);
expect(out.length).toBeGreaterThan(0);
expect(out.join("\n")).toContain("alpha — complete");
expect(out.join("\n")).toContain("beta — pending");
expect(out.join("\n")).toContain("pygienium run — in_progress");
void state;
});
it("/pygienium-status with no run state prints a not-found message", async () => {
const out = await captureStdout(() =>
handleStatusCommand("", stubCtx(cwd)),
);
expect(out.join("\n")).toContain("no run state found");
});
it("/pygienium-resume re-dispatches beta without re-running complete alpha", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const { state } = await seedInterruptedRun();
void state;
const out = await captureStdout(() =>
handleResumeCommand("", stubCtx(cwd)),
);
// alpha is complete and must NOT be re-dispatched; beta was pending.
expect(track.dispatched).not.toContain("alpha");
expect(track.dispatched).toContain("beta");
// The run should now be complete.
const after = await loadRunState(cwd);
expect(after?.status).toBe("complete");
expect(after?.checks.alpha.status).toBe("complete");
expect(after?.checks.beta.status).toBe("complete");
// beta's artifacts now exist on disk.
const betaFindings = await readFile(
join(canonicalChecksRoot(cwd), "beta", "findings.md"),
"utf8",
);
expect(betaFindings).toContain("beta findings");
// Summary mentions re-dispatched/skipped counts.
expect(out.join("\n")).toContain("re-dispatched 1");
expect(out.join("\n")).toContain("skipped 1");
});
it("/pygienium-resume --fresh re-dispatches the complete check too", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
await seedInterruptedRun();
await captureStdout(() => handleResumeCommand("--fresh", stubCtx(cwd)));
expect(track.dispatched).toContain("alpha");
expect(track.dispatched).toContain("beta");
const after = await loadRunState(cwd);
expect(after?.status).toBe("complete");
});
it("/pygienium-resume with no state prints nothing-to-resume", async () => {
const out = await captureStdout(() =>
handleResumeCommand("", stubCtx(cwd)),
);
expect(out.join("\n")).toContain("no run state to resume");
});
it("/pygienium-resume on an already-complete run refuses without --fresh", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const { state } = await seedInterruptedRun();
// Complete beta too so the whole run is complete.
markComplete(
state,
"beta",
"# beta findings\nbeta-scan",
"# beta changes\nbeta-fix",
);
await saveRunState(state);
const out = await captureStdout(() =>
handleResumeCommand("", stubCtx(cwd)),
);
expect(out.join("\n")).toContain("nothing to resume");
expect(track.dispatched).toHaveLength(0);
});
it("/pygienium-export writes a markdown bundle with both checks", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const { state } = await seedInterruptedRun();
// Run beta via resume so its artifacts land on disk.
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
void state;
await captureStdout(() => handleExportCommand("", stubCtx(cwd)));
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
expect(bundle).toContain("# Pygienium export");
expect(bundle).toContain("## alpha (complete)");
expect(bundle).toContain("## beta (complete)");
expect(bundle).toContain("# alpha findings");
expect(bundle).toContain("# beta findings");
});
it("/pygienium-export --check=beta produces a filtered bundle", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
await seedInterruptedRun();
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
await captureStdout(() =>
handleExportCommand("--check=beta", stubCtx(cwd)),
);
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
expect(bundle).toContain("## beta (complete)");
expect(bundle).not.toContain("## alpha");
});
it("/pygienium-export --status=failed includes only failed checks", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const { state } = await seedInterruptedRun();
// Mark alpha failed (artifacts already on disk from the seed); leave beta pending.
markCheckStatus(state, "alpha", "failed", "fake failure");
await saveRunState(state);
await captureStdout(() =>
handleExportCommand("--status=failed", stubCtx(cwd)),
);
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
expect(bundle).toContain("## alpha (failed)");
expect(bundle).not.toContain("## beta");
});
it("/pygienium-export --out=json writes JSON matching renderExportJson", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
await seedInterruptedRun();
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
const out = await captureStdout(() =>
handleExportCommand("--out=json", stubCtx(cwd)),
);
expect(out.join("\n")).toContain("export.json");
const raw = await readFile(join(cwd, ".pygienium", "export.json"), "utf8");
const parsed = JSON.parse(raw) as {
checks: Array<{ name: string; status: string; findings: string }>;
};
const names = parsed.checks.map((c) => c.name).sort();
expect(names).toEqual(["alpha", "beta"]);
expect(parsed.checks.find((c) => c.name === "alpha")?.findings).toContain(
"alpha findings",
);
// renderExportJson matches the on-disk content for the gathered set.
const state = await loadRunState(cwd);
const entries = await gatherExportEntries(cwd, state);
expect(renderExportJson(state, entries).trim()).toBe(raw.trim());
});
it("parseExportFilters splits comma lists and trims values", () => {
const f = parseExportFilters(
"--check=alpha,beta --status=complete,failed --out=json",
);
expect(f.check).toEqual(["alpha", "beta"]);
expect(f.status).toEqual(["complete", "failed"]);
expect(f.out).toBe("json");
});
it("gatherExportEntries reads only the canonical .pygienium/checks/ root", async () => {
await mkdir(join(cwd, ".pygienium", "checks", "alpha"), {
recursive: true,
});
await writeFile(
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
"# alpha findings\n",
"utf8",
);
// A stray pygienium/checks/ dir (the old non-hidden root) is ignored now
// that all checks write to the single canonical `.pygienium/checks/` root.
await mkdir(join(cwd, "pygienium", "checks", "ghost"), {
recursive: true,
});
await writeFile(
join(cwd, "pygienium", "checks", "ghost", "findings.md"),
"# ghost findings\n",
"utf8",
);
const entries = await gatherExportEntries(cwd, undefined);
const alpha = entries.find((e) => e.name === "alpha");
expect(alpha).toBeDefined();
expect(alpha?.findings).toContain("alpha findings");
expect(alpha?.status).toBe("unknown");
expect(alpha?.findingsPath).toBe(
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
);
expect(entries.find((e) => e.name === "ghost")).toBeUndefined();
});
it("renderExportMarkdown includes a 'no artifacts' note for empty checks", async () => {
const entries = [{ name: "ghost", status: "unknown" }];
const md = renderExportMarkdown(undefined, entries as never);
expect(md).toContain("## ghost (unknown)");
expect(md).toContain("no findings.md or changes.md on disk");
// And renderExportJson emits nulls for the empty check.
const json = renderExportJson(undefined, entries as never);
const parsed = JSON.parse(json) as {
checks: Array<{ findings: unknown; changes: unknown }>;
};
expect(parsed.checks[0]!.findings).toBeNull();
expect(parsed.checks[0]!.changes).toBeNull();
});
it("exportRun writes nothing useful and reports zero entries cleanly", async () => {
const result = await exportRun(cwd, undefined, {});
expect(result.entries).toHaveLength(0);
expect(relative(cwd, result.path)).toBe(join(".pygienium", "export.md"));
});
});

337
tests/todos.test.ts Normal file
View File

@@ -0,0 +1,337 @@
/**
* todos.test.ts — unit + integration tests for the todos (TODOs & stubs) check.
*
* Unit: `detectTodoStubs` over a multi-language fixture tree — markers, silent
* stubs (placeholder return / empty body / pass-only body), loud stubs, and
* the negative cases (a real adder, a `return null` catch handler, in-string
* "TODO" flagged for recall, clean code never flagged).
*
* Integration (deterministic fake agent runner): the scan persists findings.md
* with the three sections + machine-readable summary and a new/resolved delta
* vs the previous run; --fix converts silent stubs to loud throws, preserves
* markers, leaves loud stubs untouched, and records changes.md.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
clearChecks,
getCheck,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import {
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
import {
findingsPath,
changesPath,
check as todosCheck,
detectTodoStubs,
todosPriorCounts,
buildTodosScanTask,
} from "../src/checks/todos.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
/**
* Seed `stubs.ts` with the full taxonomy: a TODO marker, a silent stub with a
* placeholder return (getPrice), a silent stub with an empty body (notify),
* and a loud stub (connect throws "Not implemented").
*/
async function seedStubs(dir: string): Promise<void> {
await mkdir(dir, { recursive: true }).catch(() => {});
await writeFile(
join(dir, "stubs.ts"),
[
`// TODO: add pagination`,
`export function getPrice(): number {`,
` return 0;`,
`}`,
``,
`export function notify(): void {}`,
``,
`export function connect(): Promise<void> {`,
` throw new Error("Not implemented");`,
`}`,
``,
].join("\n"),
"utf8",
);
}
/** Seed the multi-language fixture tree for the deterministic-detector tests. */
async function seedTree(dir: string): Promise<void> {
await mkdir(dir, { recursive: true }).catch(() => {});
await Promise.all([
writeFile(
join(dir, "math.ts"),
"export function add(a: number, b: number): number {\n return a + b;\n}\n",
"utf8",
),
writeFile(
join(dir, "parse.ts"),
[
`export function parse(input: string) {`,
` try {`,
` return JSON.parse(input);`,
` } catch {`,
` return null;`,
` }`,
`}`,
``,
].join("\n"),
"utf8",
),
writeFile(join(dir, "stringlit.ts"), 'export const op = "TODO";\n', "utf8"),
writeFile(
join(dir, "stubs.ts"),
[
`// TODO: add pagination`,
`export function getPrice(): number {`,
` return 0;`,
`}`,
``,
`export function notify(): void {}`,
``,
`export function connect(): Promise<void> {`,
` throw new Error("Not implemented");`,
`}`,
``,
].join("\n"),
"utf8",
),
writeFile(
join(dir, "repo.py"),
[
`class Repository:`,
` def find(self, uid):`,
` raise NotImplementedError # interface method`,
``,
` def save(self, record):`,
` pass`,
``,
].join("\n"),
"utf8",
),
writeFile(
join(dir, "fetch.rs"),
"fn fetch() -> Result<u32, String> {\n todo!()\n}\n",
"utf8",
),
]);
}
describe("detectTodoStubs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "pygienium-todos-unit-"));
await seedTree(dir);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true }).catch(() => {});
});
it("flags markers, silent stubs, and loud stubs across languages", async () => {
const hits = await detectTodoStubs(dir);
const marker = hits.filter((h) => h.kind === "marker");
const silent = hits.filter((h) => h.kind === "silent-stub");
const loud = hits.filter((h) => h.kind === "loud-stub");
// Clean code is never flagged: the adder and the `return null` catch
// handler (a boundary handler, not a stub) produce zero hits.
expect(
hits.filter((h) => /(?:math\.ts|parse\.ts)$/.test(h.path)),
).toHaveLength(0);
// Markers: the stubs.ts TODO comment, and the in-string "TODO" (recall —
// the scan agent's job is to drop the string-literal noise).
expect(
marker.some(
(h) =>
h.path.endsWith("stubs.ts") && h.line === 1 && h.snippet === "TODO",
),
).toBe(true);
expect(
marker.some(
(h) => h.path.endsWith("stringlit.ts") && h.snippet === "TODO",
),
).toBe(true);
// Silent stubs: lone placeholder return + empty body in stubs.ts,
// pass-only body in repo.py — each with its enclosing function.
const stubsSilent = silent.filter((h) => h.path.endsWith("stubs.ts"));
expect(
stubsSilent.some(
(h) => h.snippet === "placeholder-return" && h.context === "getPrice",
),
).toBe(true);
expect(
stubsSilent.some(
(h) => h.snippet === "empty-body" && h.context === "notify",
),
).toBe(true);
expect(
silent.some(
(h) =>
h.path.endsWith("repo.py") &&
h.snippet === "pass-only" &&
h.context === "save",
),
).toBe(true);
// Loud stubs: "Not implemented" throw, raise NotImplementedError,
// rust todo!() — reported as tracked debt.
expect(
loud.some(
(h) => h.path.endsWith("stubs.ts") && h.snippet === "Not implemented",
),
).toBe(true);
expect(
loud.some(
(h) =>
h.path.endsWith("repo.py") && h.snippet === "NotImplementedError",
),
).toBe(true);
expect(
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
).toBe(true);
});
});
describe("todos check", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(fakeAgentRunner);
// Re-register explicitly: the module's import-time registerCheck only
// runs once (module cache), so clearChecks + registerCheck restores it
// deterministically for each test.
registerCheck(todosCheck);
cwd = await mkdtemp(join(tmpdir(), "pygienium-todos-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true }).catch(() => {});
});
it("is registered and uses the todos scanner agent", () => {
const check = getCheck("todos");
expect(check).toBeDefined();
expect((check as CheckDefinition)?.agentName).toBe("todos");
});
it("scan-only writes findings.md with the three sections and leaves sources untouched", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const findings = await readFile(findingsPath(cwd), "utf8");
expect(findings).toContain(
"summary: 1 marker(s), 2 silent stub(s), 1 loud stub(s)",
);
expect(findings).toContain("new: 4"); // first run: everything is new
expect(findings).toContain("## TODO markers");
expect(findings).toContain("## Silent stubs (actionable)");
expect(findings).toContain("placeholder-return");
expect(findings).toContain("Not implemented");
// Scan-only: no changes.md, sources untouched.
expect(existsSync(changesPath(cwd))).toBe(false);
const untouched = await readFile(join(cwd, "stubs.ts"), "utf8");
expect(untouched).toContain("return 0;");
// Run state records the scan summary as findings text.
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.status).toBe("complete");
expect(state?.checks["todos"]?.findings).toContain(
"todos: 2 silent stub(s), 1 loud stub(s), 1 marker(s)",
);
});
it("--fix converts silent stubs to loud throws, preserves markers and loud stubs, records changes.md", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const cleaned = await readFile(join(cwd, "stubs.ts"), "utf8");
// Silent stubs now throw loudly, naming the function.
expect(cleaned).toContain('throw new Error("todos: getPrice() is a stub")');
expect(cleaned).toContain('throw new Error("todos: notify() is a stub")');
// Markers are NEVER deleted and loud stubs are NEVER touched.
expect(cleaned).toContain("// TODO: add pagination");
expect(cleaned).toContain('throw new Error("Not implemented")');
expect(cleaned).toContain("Cleaned by pygienium-todos");
// The placeholder bodies are gone.
expect(cleaned).not.toContain("return 0;");
expect(cleaned).not.toContain("notify(): void {}");
const changes = await readFile(changesPath(cwd), "utf8");
expect(changes).toContain(
"summary: 2 silent stub(s) converted to loud, 0 kept",
);
expect(changes).toContain("getPrice()");
expect(changes).toContain("notify()");
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.fix).toBe(true);
expect(state?.checks["todos"]?.status).toBe("complete");
});
it("findings.md and changes.md live under .pygienium/checks/todos/", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
expect(findingsPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "todos", "findings.md"),
);
expect(changesPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "todos", "changes.md"),
);
});
it("skips when the target has no source files", async () => {
// cwd is a fresh empty tempdir (no seeding) → the gate finds no source
// files and skips the check without spawning an agent.
const check = getCheck("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.status).toBe("skipped");
});
it("reports a new/resolved delta against the previous run's counts", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
// The previous run's verified counts are parseable from run-state.
const prior = await todosPriorCounts(cwd);
expect(prior).toEqual({ silent: 2, loud: 1, marker: 1 });
// Nobody flagged anything new and everything was resolved: the next scan
// sees zero candidates → new: 0, resolved: 4 (all prior items).
await rm(join(cwd, "stubs.ts"));
const task = await buildTodosScanTask(cwd, {
cwd,
target: cwd,
fix: false,
rest: [],
});
expect(task).toContain(
"summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s)",
);
expect(task).toContain("| new: 0 | resolved: 4 |");
});
});

146
tests/verify-hooks.test.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* 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 { check as complexityCheck } from "../src/checks/complexity.js";
import { check as deadCodeCheck } from "../src/checks/dead-code.js";
import { check as deepModulesCheck } from "../src/checks/deep-modules.js";
import { check as 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: "",
});
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, 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: "" }));
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");
});
});