Initial commit: pygenium as git submodule
This commit is contained in:
25
tests/agents.test.ts
Normal file
25
tests/agents.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
162
tests/all-integration.test.ts
Normal file
162
tests/all-integration.test.ts
Normal 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
354
tests/all.test.ts
Normal 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
131
tests/check-runner.test.ts
Normal 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
61
tests/commands.test.ts
Normal 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
259
tests/comments.test.ts
Normal 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
165
tests/complexity.test.ts
Normal 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 35–49)\n\n- target/index.ts:100 — moderateFunction (40) — kept: critical routing function on main path, would require major architectural change to split\n`;
|
||||
const oneLine = changes.split("\n").join(" ");
|
||||
return `!write pygienium/checks/synth-complexity/changes.md "${oneLine}"\n!echo ${oneLine}`;
|
||||
},
|
||||
gate: async (cwd) => {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const { resolve } = await import("node:path");
|
||||
try {
|
||||
const s = await stat(resolve(cwd));
|
||||
return s.isDirectory() || s.isFile()
|
||||
? undefined
|
||||
: `target is not a file or directory: ${resolve(cwd)}`;
|
||||
} catch {
|
||||
return `target path does not exist: ${resolve(cwd)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
describe("complexity check integration", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
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
623
tests/dead-code.test.ts
Normal 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
171
tests/deep-modules.test.ts
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
232
tests/defensive-guards.test.ts
Normal file
232
tests/defensive-guards.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
38
tests/extensibility.test.ts
Normal file
38
tests/extensibility.test.ts
Normal 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
101
tests/help.test.ts
Normal 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
69
tests/registry.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
420
tests/status-resume-export.test.ts
Normal file
420
tests/status-resume-export.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user