Initial commit: pygenium as git submodule

This commit is contained in:
2026-08-07 14:54:45 -04:00
commit 581436ed23
61 changed files with 9331 additions and 0 deletions

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

@@ -0,0 +1,25 @@
/**
* agents.test.ts — markdown agent-definition loader.
*/
import { describe, expect, it } from "bun:test";
import { extensionRoot, loadAgents } from "../src/agents.js";
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(/pygienium$/);
});
});

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, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
}
/** Capture process.stdout.write lines for the duration of `fn`. */
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
const out: string[] = [];
const write = process.stdout.write.bind(process.stdout);
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
chunk: unknown,
) => {
out.push(String(chunk).replace(/\r?\n$/, ""));
return true;
};
try {
await fn();
} finally {
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
}
return out;
}
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");
});
});

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

@@ -0,0 +1,354 @@
/**
* 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, mode: "print", 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, 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.only).toEqual(["alpha", "beta"]);
});
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("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 [");
});
});

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, mode: "print", 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();
});
});

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

@@ -0,0 +1,259 @@
/**
* 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 {
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,
mode: "print",
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();
});
});

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

@@ -0,0 +1,165 @@
/**
* complexity.test.ts — integration tests for the excessive complexity check.
*
* Tests verify:
* 1. A 55-decision-point function (must-refactor band) is refactored
* 2. A 40-decision-point function (heavy-skepticism band) is either refactored
* or has a documented justification in findings.md
* 3. Deep nesting and over-abstraction are simplified
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, readFile, 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,
fakeAgentRunner,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
/**
* A synthetic check that simulates a complexity scan finding two functions:
* - one at complexity 55 (must-refactor band)
* - one at complexity 40 (skepticism band)
*/
function synthComplexityCheck(): CheckDefinition {
return {
name: "synth-complexity",
label: "Synth Complexity",
description: "Synthetic complexity check for integration tests",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "scan",
buildScanTask: (_cwd, scope) => {
const findings = `# complexity — findings
## Cyclomatic complexity
| File | Function | Score | Band | Action |
|------|----------|-------|------|--------|
| target/index.ts:10 | complexFunction | 55 | 50+ | MUST refactor |
| target/index.ts:100 | moderateFunction | 40 | 35-49 | Skepticism — justify or refactor |
| target/index.ts:200 | simpleFunction | 8 | <35 | OK |
## Structural smells
- [high] target/index.ts:15 — deep nesting — 5 levels of nested if/else
- [med] target/index.ts:80 — unnecessary wrapper — trivial passthrough function
`;
// fakeAgentRunner parses one directive per line, so flatten the content
// onto a single escaped line; the on-disk file keeps the real text.
const oneLine = findings.split("\n").join(" ");
return `!write pygienium/checks/synth-complexity/findings.md "${oneLine}"\n!echo ${oneLine}`;
},
buildFixTask: (_cwd, _scope, findings) => {
const changes = `# complexity — changes\n\n2 refactoring(s) applied; 0 deferred for human review.\n\n## Applied\n\n- target/index.ts:10 — complexFunction split (was 55, now 22, 28)\n- target/index.ts:15 — nested conditionals flattened\n- target/index.ts:80 — trivial wrapper inlined\n\n## Deferred (needs human review)\n\n## Justified (kept at 3549)\n\n- target/index.ts:100 — moderateFunction (40) — kept: critical routing function on main path, would require major architectural change to split\n`;
const oneLine = changes.split("\n").join(" ");
return `!write pygienium/checks/synth-complexity/changes.md "${oneLine}"\n!echo ${oneLine}`;
},
gate: async (cwd) => {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
try {
const s = await stat(resolve(cwd));
return s.isDirectory() || s.isFile()
? undefined
: `target is not a file or directory: ${resolve(cwd)}`;
} catch {
return `target path does not exist: ${resolve(cwd)}`;
}
},
};
}
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
}
describe("complexity check integration", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(fakeAgentRunner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-complexity-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true });
});
it("runs a complexity scan and writes findings with cyclomatic scores", async () => {
registerCheck(synthComplexityCheck());
const check = synthComplexityCheck();
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state).toBeDefined();
expect(state?.checks["synth-complexity"].status).toBe("complete");
// Verify findings contain complexity scores
const findings = state?.checks["synth-complexity"].findings;
expect(findings).toContain("55");
expect(findings).toContain("40");
expect(findings).toContain("MUST refactor");
});
it("--fix refactors the 50+ function and documents changes", async () => {
const check = synthComplexityCheck();
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["synth-complexity"].status).toBe("complete");
// Verify changes document the refactoring
const changes = state?.checks["synth-complexity"].changes;
expect(changes).toContain("complexFunction split");
expect(changes).toContain("was 55");
});
it("justified 35-49 functions appear in changes with justification", async () => {
const check = synthComplexityCheck();
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
const changes = state?.checks["synth-complexity"].changes;
expect(changes).toContain("Justified");
expect(changes).toContain("moderateFunction");
expect(changes).toContain("critical");
});
it("marks check complete after --fix with no errors", async () => {
const check = synthComplexityCheck();
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["synth-complexity"].error).toBeUndefined();
expect(
state?.checks["synth-complexity"].phases.find((p) => p.id === "fix")
?.status,
).toBe("complete");
});
it("gate passes for existing target directory", async () => {
const check = synthComplexityCheck();
const gateResult = await check.gate(cwd);
expect(gateResult).toBeUndefined();
});
it("gate fails for nonexistent target", async () => {
const check = synthComplexityCheck();
const gateResult = await check.gate(
"/nonexistent/path/that/does/not/exist",
);
expect(gateResult).toContain("does not exist");
});
});

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 {
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, mode: "print", 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,
deepModulesCheck,
} from "../src/checks/deep-modules.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, mode: "print", 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,
defensiveGuardsCheck,
} from "../src/checks/defensive-guards.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, mode: "print", 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,38 @@
/**
* extensibility.test.ts — the registry extensibility claim (task 14).
*
* Proves a NEW check added as a file in `src/checks/` plus one `registerCheck()`
* entry yields a working `/pygienium-<name>` command with ZERO `index.ts`
* command-wiring changes. The witness is `src/checks/noop.ts`: importing it
* self-registers the `noop` check, after which the generic command-binding path
* (`registerPygieniumCommands`, the exact function `index.ts` calls) exposes
* `/pygienium-noop` and `/pygienium-help` lists it.
*/
import { describe, expect, it } from "bun:test";
import "../src/checks/noop.js";
import { getCheck, getAllChecks } from "../src/checks/registry.js";
import { registerPygieniumCommands } from "../src/commands.js";
import { buildPygieniumHelpLines } from "../src/help.js";
describe("registry extensibility (task 14)", () => {
it("the noop check file self-registers (no index.ts edits)", () => {
// Importing checks/noop.ts ran its top-level registerCheck(noopCheck).
expect(getCheck("noop")).toBeDefined();
expect(getAllChecks().some((c) => c.name === "noop")).toBe(true);
});
it("registerPygieniumCommands exposes /pygienium-noop (zero wiring)", () => {
const names: string[] = [];
registerPygieniumCommands((name) => names.push(name));
expect(names).toContain("pygienium-noop");
// And the operator commands are still wired.
expect(names).toContain("pygienium-help");
expect(names).toContain("pygienium-all");
});
it("/pygienium-help lists the noop check", () => {
const text = buildPygieniumHelpLines().join("\n");
expect(text).toContain("/pygienium-noop");
expect(text).toContain(getCheck("noop")!.description);
});
});

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]");
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");
});
});

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",
]);
});
});

View File

@@ -0,0 +1,420 @@
/**
* 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, mode: "print", 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 removed legacy 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"));
});
});