initial import: @mikefreno/omp-pygenium (omp port)
This commit is contained in:
98
tests/agents.test.ts
Normal file
98
tests/agents.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* agents.test.ts — markdown agent-definition loader.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { extensionRoot, loadAgents } from "../src/agents.js";
|
||||
|
||||
/** Build an agent markdown file with frontmatter + body. */
|
||||
async function writeAgent(
|
||||
dir: string,
|
||||
file: string,
|
||||
name: string,
|
||||
body: string,
|
||||
tools?: string[],
|
||||
): Promise<string> {
|
||||
const lines = ["---", `name: ${name}`];
|
||||
if (tools) {
|
||||
lines.push("allowedTools:");
|
||||
for (const t of tools) lines.push(` - ${t}`);
|
||||
}
|
||||
lines.push("---", body, "");
|
||||
await writeFile(join(dir, file), lines.join("\n"), "utf8");
|
||||
return join(dir, file);
|
||||
}
|
||||
|
||||
describe("loadAgents", () => {
|
||||
it("loads scanner.md and fixer.md shipped with the extension", async () => {
|
||||
const agents = await loadAgents();
|
||||
expect(agents.has("scanner")).toBe(true);
|
||||
expect(agents.has("fixer")).toBe(true);
|
||||
const scanner = agents.get("scanner");
|
||||
expect(scanner).toBeDefined();
|
||||
expect(scanner?.systemPrompt).toContain("scanner");
|
||||
expect(scanner?.allowedTools).toContain("read");
|
||||
expect(scanner?.allowedTools).not.toContain("edit"); // scanner is read-only
|
||||
expect(scanner?.sourcePath).toContain("agents/scanner.md");
|
||||
const fixer = agents.get("fixer");
|
||||
expect(fixer?.allowedTools).toContain("edit");
|
||||
});
|
||||
|
||||
it("extensionRoot resolves to the package directory", () => {
|
||||
expect(extensionRoot()).toMatch(/(pygenium|pygienium)$/);
|
||||
});
|
||||
|
||||
it("project-local agents override the extension's by name", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
|
||||
try {
|
||||
await mkdir(join(cwd, "agents"));
|
||||
const path = await writeAgent(
|
||||
join(cwd, "agents"),
|
||||
"scanner.md",
|
||||
"scanner",
|
||||
"Project-tuned scanner prompt.",
|
||||
["read", "grep", "find", "edit"],
|
||||
);
|
||||
const agents = await loadAgents({ cwd });
|
||||
const scanner = agents.get("scanner");
|
||||
expect(scanner?.systemPrompt).toContain("Project-tuned");
|
||||
expect(scanner?.allowedTools).toContain("edit"); // repo override widens tools
|
||||
expect(scanner?.sourcePath).toBe(path);
|
||||
// Extension baseline is retained for agents the repo doesn't override.
|
||||
expect(agents.get("fixer")).toBeDefined();
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("project-local agents can add brand-new agents", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
|
||||
try {
|
||||
await mkdir(join(cwd, "agents"));
|
||||
await writeAgent(
|
||||
join(cwd, "agents"),
|
||||
"judge.md",
|
||||
"judge",
|
||||
"Scoring judge.",
|
||||
);
|
||||
const agents = await loadAgents({ cwd });
|
||||
expect(agents.has("judge")).toBe(true);
|
||||
expect(agents.get("judge")?.systemPrompt).toContain("Scoring judge");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a project without agents/ falls back to the extension agents", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
|
||||
try {
|
||||
const agents = await loadAgents({ cwd });
|
||||
expect(agents.has("scanner")).toBe(true);
|
||||
expect(agents.has("fixer")).toBe(true);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
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, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("/pygienium-all end-to-end (task 14)", () => {
|
||||
let cwd: string;
|
||||
let dispatched: string[];
|
||||
let runner: AgentRunner;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
dispatched = [];
|
||||
runner = async (opts) => {
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
setAgentRunner(runner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-all-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runs every registered check in sequence and marks the run complete", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleAllCommand("--fix", stubCtx(cwd)),
|
||||
);
|
||||
|
||||
// Every check was dispatched (scan + fix each, in registration order).
|
||||
expect(dispatched.filter((n) => n === "alpha").length).toBeGreaterThan(0);
|
||||
expect(dispatched.filter((n) => n === "beta").length).toBeGreaterThan(0);
|
||||
expect(dispatched.filter((n) => n === "gamma").length).toBeGreaterThan(0);
|
||||
|
||||
// Final run-state: complete, every check complete, recon shared once.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state).toBeDefined();
|
||||
expect(state!.status).toBe("complete");
|
||||
expect(state!.recon.complete).toBe(true);
|
||||
for (const name of ["alpha", "beta", "gamma"]) {
|
||||
expect(state!.checks[name]?.status).toBe("complete");
|
||||
}
|
||||
|
||||
// Per-check artifacts landed on disk under the canonical root.
|
||||
const alphaFindings = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "alpha", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(alphaFindings).toContain("alpha findings");
|
||||
const gammaChanges = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "gamma", "changes.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(gammaChanges).toContain("gamma changes");
|
||||
|
||||
// The summary line reports completion and the run-state path.
|
||||
const text = out.join("\n");
|
||||
expect(text).toContain("pygienium: all-run complete");
|
||||
});
|
||||
|
||||
it("completes cleanly in scan-only mode (no --fix)", async () => {
|
||||
registerCheck(fakeCheck("solo"));
|
||||
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state!.status).toBe("complete");
|
||||
expect(state!.checks["solo"]?.status).toBe("complete");
|
||||
expect(state!.checks["solo"]?.fix).toBe(false);
|
||||
// Scan-only still writes findings but not changes.
|
||||
const findings = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "solo", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(findings).toContain("solo findings");
|
||||
expect(out.join("\n")).toContain("pygienium: all-run complete");
|
||||
});
|
||||
|
||||
it("reports no checks when the registry is empty", async () => {
|
||||
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||
expect(out.join("\n")).toContain("no checks registered");
|
||||
});
|
||||
|
||||
it("is resumable: a second call reuses the existing run-state", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
const first = await loadRunState(cwd);
|
||||
const firstStarted = first!.startedAt;
|
||||
|
||||
// Second run reloads the existing run-state (same startedAt).
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
const second = await loadRunState(cwd);
|
||||
expect(second!.startedAt).toBe(firstStarted);
|
||||
expect(second!.status).toBe("complete");
|
||||
});
|
||||
});
|
||||
401
tests/all.test.ts
Normal file
401
tests/all.test.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* all.test.ts — integration test for the `/pygienium-all` orchestrator (task 12).
|
||||
*
|
||||
* Mirrors the spec scenario: run `/pygienium-all` on a small repo and assert:
|
||||
* - every registered check runs exactly once in registry order;
|
||||
* - run-state shows all checks complete and the overall run complete;
|
||||
* - `.pygienium/all-summary.md` is present and lists per-check outcomes;
|
||||
* - `--only=alpha,gamma` narrows the candidate set preserving order;
|
||||
* - interrupted/resumed runs re-dispatch non-terminal checks while skipping
|
||||
* terminal ones, unless `--fresh` resets everything.
|
||||
*
|
||||
* A tracker wraps the fake agent runner so we can assert dispatch order and
|
||||
* counts without a model.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import {
|
||||
parseAllArgs,
|
||||
runAllChecks,
|
||||
allSummaryPath,
|
||||
renderAllSummary,
|
||||
selectChecks,
|
||||
} from "../src/modes/all.js";
|
||||
import {
|
||||
loadRunState,
|
||||
markCheckStatus,
|
||||
applyPhaseStatus,
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
} from "../src/run-state.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
/** Build a deterministic check whose fake runner writes on-disk artifacts. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name.charAt(0).toUpperCase() + name.slice(1),
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||
const dispatched: string[] = [];
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, dispatched };
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mark a check as fully complete in the run-state (helper for seeding). */
|
||||
function markComplete(
|
||||
state: Parameters<typeof markCheckStatus>[0],
|
||||
name: string,
|
||||
): void {
|
||||
for (const phaseId of [
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
]) {
|
||||
applyPhaseStatus(state, name, phaseId, "complete");
|
||||
}
|
||||
markCheckStatus(state, name, "complete");
|
||||
}
|
||||
|
||||
describe("/pygienium-all orchestrator (task 12)", () => {
|
||||
let cwd: string;
|
||||
let track: ReturnType<typeof trackingRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-all-"));
|
||||
// Seed a source file so the gate passes and recon has something to scan.
|
||||
await mkdir(join(cwd, "src"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parseAllArgs parses path, --fix, --fresh, --no-gitignore, and --only", () => {
|
||||
const p = parseAllArgs("subdir --fix --only=alpha,beta --fresh", cwd);
|
||||
expect(p.target).toBe(join(cwd, "subdir"));
|
||||
expect(p.fix).toBe(true);
|
||||
expect(p.fresh).toBe(true);
|
||||
expect(p.gitignore).toBe(true); // default: keep the .gitignore guard on
|
||||
expect(p.only).toEqual(["alpha", "beta"]);
|
||||
const noGi = parseAllArgs("--no-gitignore", cwd);
|
||||
expect(noGi.gitignore).toBe(false);
|
||||
expect(noGi.target).toBe(cwd);
|
||||
});
|
||||
|
||||
it("selectChecks preserves registry order for the --only subset", () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
const subset = selectChecks(["gamma", "alpha"]); // order in --only is irrelevant
|
||||
expect(subset.map((c) => c.name)).toEqual(["alpha", "gamma"]);
|
||||
expect(selectChecks().length).toBe(3);
|
||||
expect(selectChecks([]).length).toBe(3);
|
||||
});
|
||||
|
||||
it("runs every registered check exactly once in registry order", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
|
||||
// Each check dispatched once for scan + once for fix (3 checks × 2 phases).
|
||||
const scanDispatches = track.dispatched.filter((n) => n !== undefined);
|
||||
expect(scanDispatches).toEqual([
|
||||
"alpha",
|
||||
"alpha",
|
||||
"beta",
|
||||
"beta",
|
||||
"gamma",
|
||||
"gamma",
|
||||
]);
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.status).toBe("complete");
|
||||
expect(state?.checks.alpha.status).toBe("complete");
|
||||
expect(state?.checks.beta.status).toBe("complete");
|
||||
expect(state?.checks.gamma.status).toBe("complete");
|
||||
expect(state?.recon.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("writes .pygienium/all-summary.md listing per-check outcomes", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
|
||||
const summary = await readFile(allSummaryPath(cwd), "utf8");
|
||||
expect(summary).toContain("# Pygienium all-run summary");
|
||||
expect(summary).toContain("- status: complete");
|
||||
expect(summary).toContain("## alpha — complete");
|
||||
expect(summary).toContain("## beta — complete");
|
||||
expect(summary).toContain("## alpha — complete (--fix)");
|
||||
// Artifact paths + line counts are referenced.
|
||||
expect(summary).toContain("findings:");
|
||||
expect(summary).toContain("changes:");
|
||||
// Artifacts actually exist on disk.
|
||||
const alphaFindings = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(alphaFindings).toContain("alpha findings");
|
||||
});
|
||||
|
||||
it("--only narrows the run to the named subset", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
|
||||
await captureStdout(() =>
|
||||
handleAllCommand("--only=alpha,gamma", stubCtx(cwd)),
|
||||
);
|
||||
|
||||
// Only alpha + gamma dispatched (beta never touched).
|
||||
expect(track.dispatched).toContain("alpha");
|
||||
expect(track.dispatched).toContain("gamma");
|
||||
expect(track.dispatched).not.toContain("beta");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.alpha.status).toBe("complete");
|
||||
expect(state?.checks.gamma.status).toBe("complete");
|
||||
// beta was not part of the selected set, so has no entry.
|
||||
expect(state?.checks.beta).toBeUndefined();
|
||||
|
||||
const summary = await readFile(allSummaryPath(cwd), "utf8");
|
||||
expect(summary).toContain("## alpha");
|
||||
expect(summary).toContain("## gamma");
|
||||
expect(summary).not.toContain("## beta");
|
||||
});
|
||||
|
||||
it("skips terminal checks on re-run; --fresh re-runs them", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
|
||||
// First run: both complete.
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases
|
||||
const firstAlphaFix = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Second run without --fresh: both already terminal → skipped.
|
||||
track.dispatched.length = 0;
|
||||
const out = await captureStdout(() =>
|
||||
handleAllCommand("--fix", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).toHaveLength(0);
|
||||
expect(out.join("\n")).toContain("skipping");
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.status).toBe("complete");
|
||||
|
||||
// Third run with --fresh: both re-dispatched from scratch.
|
||||
track.dispatched.length = 0;
|
||||
await captureStdout(() => handleAllCommand("--fix --fresh", stubCtx(cwd)));
|
||||
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
|
||||
const state3 = await loadRunState(cwd);
|
||||
expect(state3?.status).toBe("complete");
|
||||
// The fresh re-run overwrote alpha's changes.md (still valid content).
|
||||
const alphaFix2 = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(alphaFix2).toContain("alpha changes");
|
||||
void firstAlphaFix;
|
||||
});
|
||||
|
||||
it("resumes an interrupted run (re-dispatches non-terminal checks only)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
|
||||
// Seed an interrupted run: alpha complete, beta pending (interrupted).
|
||||
const { initRunState, saveRunState } = await import("../src/run-state.js");
|
||||
const state = initRunState(cwd, [
|
||||
{ name: "alpha", label: "alpha", fix: true },
|
||||
{ name: "beta", label: "beta", fix: true },
|
||||
]);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: join(cwd, ".pygienium", "recon.json"),
|
||||
finishedAt: Date.now(),
|
||||
};
|
||||
// Pre-create alpha's on-disk artifacts so its completed entry has artifacts.
|
||||
const alphaDir = join(cwd, ".pygienium", "checks", "alpha");
|
||||
await mkdir(alphaDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(alphaDir, "findings.md"),
|
||||
"# alpha findings\nalpha-scan\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(alphaDir, "changes.md"),
|
||||
"# alpha changes\nalpha-fix\n",
|
||||
);
|
||||
markComplete(state, "alpha");
|
||||
// beta was interrupted mid-analysis — left at in_progress.
|
||||
applyPhaseStatus(state, "beta", PHASE_RECON, "complete");
|
||||
applyPhaseStatus(state, "beta", PHASE_ANALYSIS, "in_progress");
|
||||
await saveRunState(state);
|
||||
|
||||
// Resume via all-run: only beta should re-dispatch.
|
||||
const out = await captureStdout(() =>
|
||||
handleAllCommand("--fix", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).not.toContain("alpha");
|
||||
expect(track.dispatched).toContain("beta");
|
||||
expect(out.join("\n")).toContain("skipping");
|
||||
|
||||
const after = await loadRunState(cwd);
|
||||
expect(after?.status).toBe("complete");
|
||||
expect(after?.checks.alpha.status).toBe("complete");
|
||||
expect(after?.checks.beta.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("runAllChecks supports scan-only (no fix phase, no changes artifacts)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
const outcome = await runAllChecks({ cwd });
|
||||
expect(outcome.ran).toEqual(["alpha"]);
|
||||
expect(outcome.skipped).toEqual([]);
|
||||
expect(outcome.status).toBe("complete");
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.alpha.fix).toBe(false);
|
||||
expect(state?.checks.alpha.changes).toBeUndefined();
|
||||
// Summary still written.
|
||||
const summary = await readFile(outcome.summaryPath, "utf8");
|
||||
expect(summary).toContain("## alpha — complete");
|
||||
expect(outcome.summaryPath).toBe(allSummaryPath(cwd));
|
||||
});
|
||||
|
||||
it("renders a summary even when no checks are registered/selected", async () => {
|
||||
const outcome = await runAllChecks({ cwd, only: ["nonexistent"] });
|
||||
expect(outcome.ran).toEqual([]);
|
||||
const summary = await readFile(outcome.summaryPath, "utf8");
|
||||
expect(summary).toContain("# Pygienium all-run summary");
|
||||
expect(summary).toContain("- checks: 0");
|
||||
});
|
||||
|
||||
it("renderAllSummary reflects per-check statuses and fix tags", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await runAllChecks({ cwd, fix: true });
|
||||
const state = (await loadRunState(cwd))!;
|
||||
const md = renderAllSummary(state, selectChecks());
|
||||
expect(md).toContain("## alpha — complete (--fix)");
|
||||
expect(md).toContain("phases: recon:C");
|
||||
});
|
||||
|
||||
it("all-summary never shows a stale error under a complete check", async () => {
|
||||
const { initRunState } = await import("../src/run-state.js");
|
||||
const state = initRunState(cwd, [
|
||||
{ name: "alpha", label: "Alpha", fix: false },
|
||||
]);
|
||||
const alpha = fakeCheck("alpha");
|
||||
// A hand-edited/legacy state can carry an error on a completed check
|
||||
// (the MagnaFluo run showed exactly this shape).
|
||||
markCheckStatus(state, "alpha", "complete", "legacy verify error");
|
||||
const md = renderAllSummary(state, [alpha]);
|
||||
expect(md).toContain("## alpha — complete");
|
||||
expect(md).not.toContain("- error:");
|
||||
// A genuinely failed check still surfaces its error.
|
||||
markCheckStatus(state, "alpha", "failed", "scan exploded");
|
||||
const md2 = renderAllSummary(state, [alpha]);
|
||||
expect(md2).toContain("- error: scan exploded");
|
||||
});
|
||||
|
||||
it("the unified strip surfaces every check name over the run", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||
const joined = out.join("\n");
|
||||
// Each check name appears in the strip line at least once.
|
||||
expect(joined).toContain("Alpha");
|
||||
expect(joined).toContain("Beta");
|
||||
expect(joined).toContain("all [");
|
||||
});
|
||||
|
||||
it("renders only the unified footer widget in UI mode (no per-check footer)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const calls: Array<[string, string[] | undefined]> = [];
|
||||
const ui = {
|
||||
setWidget: (key: string, content: string[] | undefined) => {
|
||||
calls.push([key, content]);
|
||||
},
|
||||
} as never; // stub ExtensionUIContext (tests have no pi type imports)
|
||||
|
||||
await runAllChecks({ cwd, ui, hasUI: true });
|
||||
|
||||
const keys = new Set(calls.map(([k]) => k));
|
||||
// The all-run footer drives the belowEditor widget area under its own key…
|
||||
expect(keys.has("pygienium-all")).toBe(true);
|
||||
// …and per-check footers never claim their slot (footer:false), so two
|
||||
// overviews never compete over the same widget area.
|
||||
expect(keys.has("pygienium")).toBe(false);
|
||||
// The widget is cleared when the run completes.
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(last?.[0]).toBe("pygienium-all");
|
||||
expect(last?.[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
131
tests/check-runner.test.ts
Normal file
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, 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();
|
||||
});
|
||||
});
|
||||
258
tests/comments.test.ts
Normal file
258
tests/comments.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* comments.test.ts — integration test for the first end-to-end check.
|
||||
*
|
||||
* Proves the whole framework works: a real `CheckDefinition` (registered from
|
||||
* `src/checks/comments.ts`) flows through the command handler → check-runner
|
||||
* pipeline (recon → analysis → fix → verify → cleanup), producing
|
||||
* `findings.md` + `changes.md` artifacts and a `complete` run-state, while a
|
||||
* rubric-driven fake runner performs the actual comment edits.
|
||||
*
|
||||
* The fake runner (a rubric interpreter, not a hardcoded puppet) reads the
|
||||
* target source, classifies each comment against the same rubric the real
|
||||
* scanner/fixer agents receive in their task text, writes the artifacts, and
|
||||
* applies the edits. This exercises the genuine task-builder output, gate,
|
||||
* and orchestration without a live model.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { clearChecks, getCheck } from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import {
|
||||
check as commentsCheck,
|
||||
findingsPath,
|
||||
changesPath,
|
||||
} from "../src/checks/comments.js";
|
||||
import type { CheckScope } from "../src/checks/registry.js";
|
||||
|
||||
/** Sample source with mixed comment types for the rubric to classify. */
|
||||
const SOURCE = `// increment the counter
|
||||
counter++;
|
||||
// We use a power-of-two size so modulo hashing is a bitmask, not a divide
|
||||
const SIZE = 1 << 10;
|
||||
function hash(k: string): number {
|
||||
// compute the hash
|
||||
return k.split("").reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 0);
|
||||
}
|
||||
`;
|
||||
const FILENAME = "sample.ts";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
ui: undefined,
|
||||
} as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Extract the scan target path from a built scan task string. */
|
||||
function targetFromTask(task: string): string {
|
||||
const m = /Scan target: `([^`]+)`/.exec(task);
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rubric interpreter: classifies each comment line and returns the smell,
|
||||
* the cleaned line, and whether it is a "why" comment that must survive.
|
||||
*/
|
||||
function classifyComment(line: string): {
|
||||
smell: "RESTATE" | "VERBOSE" | "WHY" | "OK";
|
||||
cleaned: string;
|
||||
} {
|
||||
const trimmed = line.trim();
|
||||
// Treat the captured rationale comment as a WHY comment to preserve.
|
||||
if (
|
||||
/\b(power-of-two|so modulo|bitmask|divide|because|so that|rationale|gotcha|workaround)\b/i.test(
|
||||
trimmed,
|
||||
)
|
||||
) {
|
||||
return { smell: "WHY", cleaned: line };
|
||||
}
|
||||
// Deterministic restate signals: "increment the counter", "compute the hash".
|
||||
if (/increment|counter|^\/\/\s*compute/i.test(trimmed)) {
|
||||
return { smell: "RESTATE", cleaned: "" };
|
||||
}
|
||||
return { smell: "OK", cleaned: line };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake runner that applies the comments rubric deterministically. It reads the
|
||||
* target source written into the scan task, classifies comments, writes the
|
||||
* findings.md / changes.md artifacts, and edits the source in place. Produces
|
||||
* the same artefacts a real scanner/fixer pair would, exercising the genuine
|
||||
* task-builder output and gate.
|
||||
*/
|
||||
function rubricRunner(opts: { getScope: () => CheckScope }): AgentRunner {
|
||||
return async (taskOpts) => {
|
||||
const scope = opts.getScope();
|
||||
const target = targetFromTask(taskOpts.task) || scope.target;
|
||||
const isFix = taskOpts.agentName === "fixer";
|
||||
|
||||
let lines: string[] = [];
|
||||
try {
|
||||
lines = (await readFile(target, "utf8")).split("\n");
|
||||
} catch {
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
|
||||
const findings: string[] = [];
|
||||
const applied: string[] = [];
|
||||
const kept: string[] = [];
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const t = line.trim();
|
||||
const isComment = /^\s*(\/\/|#|\/\*)/.test(t);
|
||||
if (isComment) {
|
||||
const { smell } = classifyComment(line);
|
||||
if (smell === "RESTATE") {
|
||||
findings.push(`- L${i + 1}: RESTATE — ${t}`);
|
||||
applied.push(`- ${FILENAME}:L${i + 1} — removed comment (auto)`);
|
||||
// Drop the line entirely.
|
||||
continue;
|
||||
}
|
||||
if (smell === "WHY") {
|
||||
kept.push(`- ${FILENAME}:L${i + 1} — KEEP why comment`);
|
||||
findings.push(`- L${i + 1}: WHY — ${t}`);
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const findingsText = `# comments — findings\n\n${findings.length} comment smell(s) across 1 file(s).\n\n## ${FILENAME}\n${findings.length ? findings.join("\n") : "(none)"}\n${kept.length ? `\n## kept (why)\n${kept.join("\n")}\n` : ""}`;
|
||||
const changesText = `# comments — changes\n\n${applied.length} edit(s) applied; 0 deferred for human review.\n\n## Applied\n${applied.length ? applied.join("\n") : "(none)"}\n`;
|
||||
|
||||
if (!isFix) {
|
||||
// Analysis phase: READ-ONLY. Write findings.md only; do not edit source.
|
||||
const fPath = findingsPath(scope);
|
||||
await mkdir(dirname(fPath), { recursive: true });
|
||||
await writeFile(fPath, findingsText + "\n", "utf8");
|
||||
return { ok: true, text: findingsText };
|
||||
}
|
||||
// Fix phase: apply removals to source in place, then write changes.md.
|
||||
await writeFile(target, out.join("\n"), "utf8");
|
||||
const cPath = changesPath(scope);
|
||||
await mkdir(dirname(cPath), { recursive: true });
|
||||
await writeFile(cPath, changesText + "\n", "utf8");
|
||||
return { ok: true, text: changesText };
|
||||
};
|
||||
}
|
||||
|
||||
describe("comments check (end-to-end)", () => {
|
||||
let cwd: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-comments-"));
|
||||
target = join(cwd, FILENAME);
|
||||
await writeFile(target, SOURCE, "utf8");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is registered and discoverable as /pygienium-comments", async () => {
|
||||
// clearChecks() wiped the registry in beforeEach; the module-level
|
||||
// self-registration ran once at import, so re-register explicitly to
|
||||
// exercise the self-registration path the way index.ts auto-discovery does.
|
||||
const { registerCheck } = await import("../src/checks/registry.js");
|
||||
registerCheck(commentsCheck);
|
||||
expect(commentsCheck.name).toBe("comments");
|
||||
const def = getCheck("comments");
|
||||
expect(def).toBeDefined();
|
||||
expect(def?.name).toBe("comments");
|
||||
});
|
||||
|
||||
it("produces findings.md without --fix and preserves why comments", async () => {
|
||||
const scope: CheckScope = {
|
||||
cwd,
|
||||
target,
|
||||
fix: false,
|
||||
rest: [],
|
||||
};
|
||||
setAgentRunner(rubricRunner({ getScope: () => scope }));
|
||||
|
||||
const def = commentsCheck;
|
||||
await handleCheckCommand(def, target, stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state).toBeDefined();
|
||||
expect(state?.checks.comments.status).toBe("complete");
|
||||
expect(state?.status).toBe("complete");
|
||||
|
||||
// scan-only: no fix phase, no changes artifact, source left untouched
|
||||
expect(state?.checks.comments.fix).toBe(false);
|
||||
expect(state?.checks.comments.changes).toBeUndefined();
|
||||
expect(state?.checks.comments.findings).toContain("RESTATE");
|
||||
const fText = await readFile(findingsPath(scope), "utf8");
|
||||
expect(fText).toContain("findings");
|
||||
expect(fText).toContain("WHY");
|
||||
|
||||
// scan is read-only: restating comments still present in the source
|
||||
const unchanged = await readFile(target, "utf8");
|
||||
expect(unchanged).toContain("// increment the counter");
|
||||
expect(unchanged).toContain("// compute the hash");
|
||||
expect(unchanged).toContain("power-of-two");
|
||||
});
|
||||
|
||||
it("with --fix: removes restating, keeps why, writes changes.md", async () => {
|
||||
const scope: CheckScope = {
|
||||
cwd,
|
||||
target,
|
||||
fix: true,
|
||||
rest: [],
|
||||
};
|
||||
setAgentRunner(rubricRunner({ getScope: () => scope }));
|
||||
|
||||
const def = commentsCheck;
|
||||
await handleCheckCommand(def, `--fix ${target}`, stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.comments.status).toBe("complete");
|
||||
expect(state?.checks.comments.fix).toBe(true);
|
||||
expect(state?.checks.comments.changes).toContain("removed comment (auto)");
|
||||
|
||||
// both artifacts present
|
||||
const fText = await readFile(findingsPath(scope), "utf8");
|
||||
const cText = await readFile(changesPath(scope), "utf8");
|
||||
expect(fText).toContain("findings");
|
||||
expect(cText).toContain("changes");
|
||||
|
||||
const cleaned = await readFile(target, "utf8");
|
||||
expect(cleaned).not.toContain("// increment the counter");
|
||||
expect(cleaned).not.toContain("// compute the hash");
|
||||
expect(cleaned).toContain("power-of-two"); // why comment survives
|
||||
expect(cleaned).toContain("counter++"); // real code intact
|
||||
expect(cleaned).toContain("return k.split"); // logic untouched
|
||||
});
|
||||
|
||||
it("marks phases complete and records artifacts in run-state", async () => {
|
||||
const scope: CheckScope = { cwd, target, fix: true, rest: [] };
|
||||
setAgentRunner(rubricRunner({ getScope: () => scope }));
|
||||
|
||||
await handleCheckCommand(commentsCheck, `--fix ${target}`, stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
const check = state?.checks.comments;
|
||||
expect(check).toBeDefined();
|
||||
const statuses = check!.phases.map((p) => `${p.id}=${p.status}`);
|
||||
expect(statuses).toContain("analysis=complete");
|
||||
expect(statuses).toContain("fix=complete");
|
||||
expect(statuses).toContain("verify=complete");
|
||||
expect(statuses).toContain("cleanup=complete");
|
||||
expect(check!.findings).toBeDefined();
|
||||
expect(check!.changes).toBeDefined();
|
||||
});
|
||||
});
|
||||
105
tests/complexity.test.ts
Normal file
105
tests/complexity.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* complexity.test.ts — integration tests for the complexity hygiene check.
|
||||
*
|
||||
* Validates:
|
||||
* 1. A function with cyclomatic complexity 50+ is flagged and refactored
|
||||
* 2. A function with complexity 35-49 is flagged with justification required
|
||||
* 3. Deep nesting and over-abstraction are simplified
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
check as complexityCheck,
|
||||
buildComplexityScanTask,
|
||||
buildComplexityFixTask,
|
||||
} from "../src/checks/complexity.js";
|
||||
import { registerCheck, clearChecks } from "../src/checks/registry.js";
|
||||
import {
|
||||
runAgentTask,
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
|
||||
describe("complexity check", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(tmpdir(), "pygienium-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered with correct properties", () => {
|
||||
expect(complexityCheck.name).toBe("complexity");
|
||||
expect(complexityCheck.label).toBe("Complexity");
|
||||
expect(complexityCheck.agentName).toBe("scanner");
|
||||
expect(complexityCheck.fixAgentName).toBe("fixer");
|
||||
});
|
||||
|
||||
it("builds scan task with complexity thresholds", () => {
|
||||
const scope = {
|
||||
cwd: tempDir,
|
||||
target: tempDir,
|
||||
fix: false,
|
||||
rest: [],
|
||||
};
|
||||
const task = buildComplexityScanTask(tempDir, scope);
|
||||
|
||||
expect(task).toContain("cyclomatic complexity");
|
||||
expect(task).toContain("50+");
|
||||
expect(task).toContain("35-49");
|
||||
expect(task).toContain("MUST refactor");
|
||||
expect(task).toContain("findings.md");
|
||||
});
|
||||
|
||||
it("builds fix task from findings", () => {
|
||||
const scope = {
|
||||
cwd: tempDir,
|
||||
target: tempDir,
|
||||
fix: true,
|
||||
rest: [],
|
||||
};
|
||||
const findings =
|
||||
"# complexity findings\n\n- myFunction: complexity 65 - must refactor";
|
||||
const task = buildComplexityFixTask(tempDir, scope, findings);
|
||||
|
||||
expect(task).toContain("complexity fix");
|
||||
expect(task).toContain("myFunction");
|
||||
expect(task).toContain("changes.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent loading", () => {
|
||||
it("loads scanner agent from agents directory", async () => {
|
||||
const { loadAgents } = await import("../src/agents.js");
|
||||
const agents = await loadAgents();
|
||||
|
||||
expect(agents.has("scanner")).toBe(true);
|
||||
const scanner = agents.get("scanner");
|
||||
expect(scanner).toBeDefined();
|
||||
expect(scanner?.systemPrompt).toContain("scanner");
|
||||
});
|
||||
|
||||
it("loads fixer agent from agents directory", async () => {
|
||||
const { loadAgents } = await import("../src/agents.js");
|
||||
const agents = await loadAgents();
|
||||
|
||||
expect(agents.has("fixer")).toBe(true);
|
||||
const fixer = agents.get("fixer");
|
||||
expect(fixer).toBeDefined();
|
||||
expect(fixer?.allowedTools).toContain("edit");
|
||||
});
|
||||
});
|
||||
623
tests/dead-code.test.ts
Normal file
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 {
|
||||
check as deadCodeCheck,
|
||||
detectDeadCode,
|
||||
findingsPath,
|
||||
changesPath,
|
||||
renderFindingsMd,
|
||||
applyDeadCodeFixes,
|
||||
} from "../src/checks/dead-code.js";
|
||||
import { buildPygieniumHelpLines } from "../src/help.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
async function writeFixture(root: string): Promise<void> {
|
||||
await mkdir(join(root, "src", "routes"), { recursive: true });
|
||||
|
||||
// package.json — carries an unused dependency + an entry `main`.
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "fixture",
|
||||
version: "1.0.0",
|
||||
main: "src/index.ts",
|
||||
dependencies: {
|
||||
leftoverpkg: "^1.0.0",
|
||||
typescript: "^5.0.0",
|
||||
},
|
||||
devDependencies: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
// util.ts — one used export and one unused export.
|
||||
await writeFile(
|
||||
join(root, "src", "util.ts"),
|
||||
[
|
||||
"export function add(a: number, b: number): number {",
|
||||
" return a + b;",
|
||||
"}",
|
||||
"",
|
||||
"export function unusedHelper(): string {",
|
||||
' return "never called anywhere";',
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// index.ts — the entry (reaches util and routes), its name is entry-like
|
||||
// so the zero-importer rule must NOT flag it as dead.
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
[
|
||||
'import { add } from "./util";',
|
||||
'import { load } from "./routes";',
|
||||
"add(1, 2);",
|
||||
"load();",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// routes/index.ts — live barrel that exposes a lazy loader.
|
||||
await writeFile(
|
||||
join(root, "src", "routes", "index.ts"),
|
||||
['export const load = () => import("./lazy-route");', ""].join("\n"),
|
||||
);
|
||||
|
||||
// lazy-route.ts — a shim reachable ONLY through a dynamic import. It must
|
||||
// be classified `review` and never auto-removed.
|
||||
await writeFile(
|
||||
join(root, "src", "routes", "lazy-route.ts"),
|
||||
["export function registerRoute(): void {", " return;", "}", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
|
||||
// compat.ts — an obsolete deprecated compat wrapper with zero importers.
|
||||
await writeFile(
|
||||
join(root, "src", "compat.ts"),
|
||||
[
|
||||
"// @deprecated obsolete compatibility wrapper — scheduled for removal.",
|
||||
"export function legacyFormat(x: string): string {",
|
||||
" return x;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// orphan.ts — a completely unreferenced module (dead file).
|
||||
await writeFile(
|
||||
join(root, "src", "orphan.ts"),
|
||||
["export function orphan(): void {", " return;", "}", ""].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("dead-code registry + help", () => {
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
// Re-register after clearChecks (module-level registration runs on import).
|
||||
registerCheck(deadCodeCheck);
|
||||
});
|
||||
|
||||
it("registers a single dead-code check with a serialisable name", () => {
|
||||
expect(getCheck("dead-code")).toBeDefined();
|
||||
expect(getAllChecks().filter((c) => c.name === "dead-code")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("appears in /pygienium-help with its description (auto discovery)", () => {
|
||||
const help = buildPygieniumHelpLines();
|
||||
expect(help.join("\n")).toContain("/pygienium-dead-code");
|
||||
expect(help.join("\n")).toContain(deadCodeCheck.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code detection", () => {
|
||||
it("classifies unused export, dead file, compat shim, unused dep; preserves dynamic import", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-"));
|
||||
try {
|
||||
await writeFixture(root);
|
||||
const report = await detectDeadCode(root);
|
||||
|
||||
expect(report.items.length).toBeGreaterThan(0);
|
||||
|
||||
const unusedExport = report.items.find(
|
||||
(i) => i.category === "export" && i.name === "unusedHelper",
|
||||
);
|
||||
expect(unusedExport).toBeDefined();
|
||||
expect(unusedExport?.review).toBe(false);
|
||||
expect(unusedExport?.target).toBe("symbol");
|
||||
|
||||
const deadShim = report.items.find(
|
||||
(i) => i.category === "shim" && i.rel === "src/compat.ts",
|
||||
);
|
||||
expect(deadShim).toBeDefined();
|
||||
expect(deadShim?.review).toBe(false);
|
||||
expect(deadShim?.target).toBe("file");
|
||||
|
||||
const deadFile = report.items.find(
|
||||
(i) => i.category === "file" && i.rel === "src/orphan.ts",
|
||||
);
|
||||
expect(deadFile).toBeDefined();
|
||||
expect(deadFile?.review).toBe(false);
|
||||
|
||||
const unusedDep = report.items.find(
|
||||
(i) => i.category === "dep" && i.name === "leftoverpkg",
|
||||
);
|
||||
expect(unusedDep).toBeDefined();
|
||||
|
||||
// dynamic-import shim → review, always preserved
|
||||
const dynamic = report.items.find(
|
||||
(i) => i.rel === "src/routes/lazy-route.ts",
|
||||
);
|
||||
expect(dynamic).toBeDefined();
|
||||
expect(dynamic?.review).toBe(true);
|
||||
expect(dynamic?.target).toBe("file");
|
||||
|
||||
// used export + entry file are NOT flagged.
|
||||
expect(report.items.find((i) => i.name === "add")).toBeUndefined();
|
||||
expect(
|
||||
report.items.find((i) => i.rel === "src/index.ts"),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renderFindingsMd groups findings under the four category headings", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-"));
|
||||
try {
|
||||
await writeFixture(root);
|
||||
const report = await detectDeadCode(root);
|
||||
const md = renderFindingsMd(report);
|
||||
expect(md).toContain("## Unused exports");
|
||||
expect(md).toContain("## Dead files (zero importers)");
|
||||
expect(md).toContain("## Obsolete shims / migration helpers");
|
||||
expect(md).toContain("## Unused dependencies");
|
||||
expect(md).toMatch(/\[review\] src\/routes\/lazy-route\.ts/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code E2E (--fix)", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
registerCheck(deadCodeCheck);
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-dead-e2e-"));
|
||||
await writeFixture(cwd);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes clearly-dead items, preserves dynamically-imported shim, and writes findings.md + changes.md", async () => {
|
||||
// Make the pre-existing compat.ts scan-detected before the run to also
|
||||
// prove the deterministic scan picks it up regardless of run order.
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd));
|
||||
|
||||
// --- Clearly-dead items are removed --------------------------------
|
||||
// Unused export: removed from util.ts, live export preserved.
|
||||
const util = await readFile(join(cwd, "src", "util.ts"), "utf8");
|
||||
expect(util).not.toContain("unusedHelper");
|
||||
expect(util).toContain("add");
|
||||
|
||||
// Obsolete compat shim: file deleted.
|
||||
expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(false);
|
||||
|
||||
// Dead file: deleted.
|
||||
expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(false);
|
||||
|
||||
// Unused dependency: removed from package.json.
|
||||
const pkg = JSON.parse(
|
||||
await readFile(join(cwd, "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> };
|
||||
expect(pkg.dependencies.leftoverpkg).toBeUndefined();
|
||||
|
||||
// Dynamically-imported shim: preserved, untouched.
|
||||
expect(existsSync(join(cwd, "src", "routes", "lazy-route.ts"))).toBe(true);
|
||||
const lazy = await readFile(
|
||||
join(cwd, "src", "routes", "lazy-route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(lazy).toContain("registerRoute");
|
||||
|
||||
// findings.md exists and is categorized.
|
||||
expect(existsSync(findingsPath(cwd))).toBe(true);
|
||||
const findingsMd = await readFile(findingsPath(cwd), "utf8");
|
||||
expect(findingsMd).toContain("## Unused exports");
|
||||
expect(findingsMd).toContain("## Dead files (zero importers)");
|
||||
expect(findingsMd).toContain("## Obsolete shims / migration helpers");
|
||||
expect(findingsMd).toContain("## Unused dependencies");
|
||||
|
||||
// changes.md lists the removals and the preserved review item.
|
||||
expect(existsSync(changesPath(cwd))).toBe(true);
|
||||
const changesMd = await readFile(changesPath(cwd), "utf8");
|
||||
expect(changesMd).toContain("## Removed (auto)");
|
||||
expect(changesMd).toContain("src/compat.ts");
|
||||
expect(changesMd).toContain("unusedHelper");
|
||||
expect(changesMd).toContain("leftoverpkg");
|
||||
expect(changesMd).toContain("## Preserved for review (manual)");
|
||||
expect(changesMd).toContain("lazy-route.ts"); // dynamic import preserved
|
||||
});
|
||||
|
||||
it("records the run in run-state and marks the check complete", async () => {
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["dead-code"].status).toBe("complete");
|
||||
expect(state?.checks["dead-code"].findings).toBeDefined();
|
||||
expect(state?.checks["dead-code"].changes).toBeDefined();
|
||||
});
|
||||
|
||||
it("runs a dry scan (no --fix) and does not write changes.md or touch files", async () => {
|
||||
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
|
||||
|
||||
// Nothing removed without --fix.
|
||||
expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(true);
|
||||
expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(true);
|
||||
const utils = await readFile(join(cwd, "src", "util.ts"), "utf8");
|
||||
expect(utils).toContain("unusedHelper");
|
||||
|
||||
// Findings recorded, but no changes yet.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["dead-code"].status).toBe("complete");
|
||||
expect(state?.checks["dead-code"].changes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code shim auto-delete safety (entry points + prose)", () => {
|
||||
beforeEach(() => {
|
||||
clearChecks();
|
||||
registerCheck(deadCodeCheck);
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAgentRunner();
|
||||
});
|
||||
|
||||
it("never auto-deletes an entry point whose prose mentions 'legacy'", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-entry-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n",
|
||||
);
|
||||
// Entry point, zero importers, prose contains 'legacy' — must survive.
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
[
|
||||
"// handles legacy payloads",
|
||||
"export function main(): void {}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("never auto-deletes a *.test.ts whose description mentions 'deprecated'", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-test-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "foo.test.ts"),
|
||||
[
|
||||
"import { describe, it } from 'bun:test';",
|
||||
"describe('app', () => {",
|
||||
" it('still supports the deprecated API', () => {});",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
expect(existsSync(join(root, "src", "foo.test.ts"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("flags an entry-like file tagged @deprecated for review, not deletion", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-tag-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
["/** @deprecated */", "export function main(): void {}", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
|
||||
const report = await detectDeadCode(root);
|
||||
const entry = report.items.find(
|
||||
(i) => i.rel === "src/index.ts" && i.category === "shim",
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.review).toBe(true);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code barrel re-export retention", () => {
|
||||
it("keeps modules reachable only through `export * from` / `export {…} from` barrels", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-"));
|
||||
try {
|
||||
// index.ts is entry-like; it aggregates barrelA, which aggregates
|
||||
// barrelB both by star and by name. Neither barrel may be treated as
|
||||
// a zero-importer dead file, and symbols only reachable through the
|
||||
// star re-export must stay behind a review flag.
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
['export * from "./barrelA";', ""].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelA.ts"),
|
||||
[
|
||||
'export * from "./barrelB";',
|
||||
'export { namedB } from "./barrelB";',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelB.ts"),
|
||||
[
|
||||
"export const value = 1;",
|
||||
"export const namedB = 2;",
|
||||
"export const starOnly = 3;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const report = await detectDeadCode(root);
|
||||
|
||||
// Neither barrel is a dead-file candidate.
|
||||
expect(
|
||||
report.items.find(
|
||||
(i) => i.category === "file" && i.rel === "src/barrelA.ts",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
report.items.find(
|
||||
(i) => i.category === "file" && i.rel === "src/barrelB.ts",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// Symbols in the star/named re-export target stay `review` — the
|
||||
// deterministic fixer must not auto-delete them.
|
||||
const value = report.items.find(
|
||||
(i) =>
|
||||
i.category === "export" &&
|
||||
i.rel === "src/barrelB.ts" &&
|
||||
i.name === "value",
|
||||
);
|
||||
expect(value).toBeDefined();
|
||||
expect(value?.review).toBe(true);
|
||||
const starOnly = report.items.find(
|
||||
(i) =>
|
||||
i.category === "export" &&
|
||||
i.rel === "src/barrelB.ts" &&
|
||||
i.name === "starOnly",
|
||||
);
|
||||
expect(starOnly).toBeDefined();
|
||||
expect(starOnly?.review).toBe(true);
|
||||
|
||||
// The named re-export is referenced (by barrelA) so it is not dead.
|
||||
expect(
|
||||
report.items.find(
|
||||
(i) =>
|
||||
i.category === "export" &&
|
||||
i.rel === "src/barrelB.ts" &&
|
||||
i.name === "namedB",
|
||||
),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("`--fix` never deletes a barrel-exported module", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-e2e-"));
|
||||
try {
|
||||
clearChecks();
|
||||
registerCheck(deadCodeCheck);
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
['export * from "./barrelA";', ""].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelA.ts"),
|
||||
[
|
||||
'export * from "./barrelB";',
|
||||
'export { namedB } from "./barrelB";',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelB.ts"),
|
||||
[
|
||||
"export const value = 1;",
|
||||
"export const namedB = 2;",
|
||||
"export const starOnly = 3;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
|
||||
expect(existsSync(join(root, "src", "barrelA.ts"))).toBe(true);
|
||||
expect(existsSync(join(root, "src", "barrelB.ts"))).toBe(true);
|
||||
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
|
||||
} finally {
|
||||
resetAgentRunner();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code symbol removal (multi-statement bodies)", () => {
|
||||
it("removes arrow-block consts, object consts, and inline-closing functions whole", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-shapes-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "math.ts"),
|
||||
[
|
||||
"export const build = () => {",
|
||||
" const a = 1;",
|
||||
" return a + 2;",
|
||||
"};",
|
||||
"export function packed() {",
|
||||
' return "x"; }',
|
||||
'export const config = { retries: 3, label: "cfg" };',
|
||||
"export function keep(): string {",
|
||||
' return "keep";',
|
||||
"}",
|
||||
"export const keepVar = 9;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
// Referenced exports keep math.ts alive and `keep`/`keepVar` used.
|
||||
await writeFile(
|
||||
join(root, "src", "app.ts"),
|
||||
[
|
||||
'import { keep, keepVar } from "./math";',
|
||||
"console.log(keep(), keepVar);",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const report = await detectDeadCode(root);
|
||||
const names = report.items
|
||||
.filter((i) => i.category === "export" && i.rel === "src/math.ts")
|
||||
.map((i) => i.name);
|
||||
expect(names).toContain("build");
|
||||
expect(names).toContain("packed");
|
||||
expect(names).toContain("config");
|
||||
expect(names).not.toContain("keep");
|
||||
expect(names).not.toContain("keepVar");
|
||||
|
||||
const { applied } = await applyDeadCodeFixes(report);
|
||||
expect(applied.map((i) => i.name)).toEqual(
|
||||
expect.arrayContaining(["build", "packed", "config"]),
|
||||
);
|
||||
|
||||
const out = await readFile(join(root, "src", "math.ts"), "utf8");
|
||||
expect(out).not.toContain("build");
|
||||
expect(out).not.toContain("packed");
|
||||
expect(out).not.toContain("config");
|
||||
expect(out).toContain("keep");
|
||||
expect(out).toContain("keepVar");
|
||||
// No leftover arrow body from the removed declaration.
|
||||
expect(out).not.toContain("return a + 2");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a symbol it cannot safely remove instead of corrupting source", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-guard-"));
|
||||
try {
|
||||
const file = join(root, "weird.ts");
|
||||
await writeFile(
|
||||
file,
|
||||
[
|
||||
'export const rx = () => /[{;}]/.test("a;");',
|
||||
"export const keep = 1;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const original = await readFile(file, "utf8");
|
||||
// Hand-built report forces an auto removal attempt on a shape the
|
||||
// scanner does not fully model (regex with braces/semicolons).
|
||||
const { applyDeadCodeFixes: apply } = await import(
|
||||
"../src/checks/dead-code.js"
|
||||
);
|
||||
const report = {
|
||||
target: root,
|
||||
scannedAt: new Date().toISOString(),
|
||||
items: [
|
||||
{
|
||||
category: "export" as const,
|
||||
path: file,
|
||||
rel: "weird.ts",
|
||||
name: "rx",
|
||||
line: 1,
|
||||
target: "symbol" as const,
|
||||
review: false,
|
||||
reason: "test",
|
||||
},
|
||||
],
|
||||
};
|
||||
const { applied } = await apply(report);
|
||||
const after = await readFile(file, "utf8");
|
||||
// Either the removal succeeded cleanly, or the file is untouched —
|
||||
// never a truncated/corrupt intermediate.
|
||||
if (applied.length === 0) {
|
||||
expect(after).toBe(original);
|
||||
} else {
|
||||
expect(after).not.toContain("rx");
|
||||
expect(after).toContain("keep");
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
171
tests/deep-modules.test.ts
Normal file
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,
|
||||
check as deepModulesCheck,
|
||||
} from "../src/checks/deep-modules.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Drop a pass-through wrapper module that forwards a single lib call. */
|
||||
async function seedPassThrough(
|
||||
dir: string,
|
||||
): Promise<{ wrapper: string; lib: string }> {
|
||||
const wrapper = join(dir, "wrapper.ts");
|
||||
const lib = join(dir, "lib.ts");
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
lib,
|
||||
`export function compute(x: number): number { return x * 2; }\n`,
|
||||
"utf8",
|
||||
);
|
||||
// Shallow pass-through: forwards every argument to `lib` with zero added logic.
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`import { compute } from "./lib";\nexport function run(x: number) { return compute(x); }\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { wrapper, lib };
|
||||
}
|
||||
|
||||
describe("deep-modules check", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
// Re-register explicitly: the module's import-time registerCheck only
|
||||
// runs once (module cache), so clearChecks + registerCheck restores it
|
||||
// deterministically for each test.
|
||||
registerCheck(deepModulesCheck);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-deep-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered and uses the deep-modules scanner agent", () => {
|
||||
const check = getCheck("deep-modules");
|
||||
expect(check).toBeDefined();
|
||||
expect((check as CheckDefinition)?.agentName).toBe("deep-modules");
|
||||
});
|
||||
|
||||
it("flags the pass-through wrapper in findings.md", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
const check = getCheck("deep-modules")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const findings = await readFile(findingsPath(cwd), "utf8");
|
||||
expect(findings).toContain("wrapper.ts");
|
||||
expect(findings).toContain("pass-through-wrapper");
|
||||
expect(findings).toMatch(/importers:\s*0/);
|
||||
|
||||
// Run state records the scan summary as findings text.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["deep-modules"]?.status).toBe("complete");
|
||||
expect(state?.checks["deep-modules"]?.findings).toContain(
|
||||
"deep-modules: 1 issue",
|
||||
);
|
||||
});
|
||||
|
||||
it("scan-only does not write changes.md", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
const check = getCheck("deep-modules")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
expect(existsSync(changesPath(cwd))).toBe(false);
|
||||
});
|
||||
|
||||
it("--fix applies the safe consolidation and records changes.md (auto), and defers the risky one (manual)", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
// Also drop a "risky" adapter so we can assert it is NOT auto-applied.
|
||||
await writeFile(
|
||||
join(cwd, "risky-adapter.ts"),
|
||||
`// adapter-layer with external importers — should be listed for review only\nexport const risky = true;\n`,
|
||||
"utf8",
|
||||
);
|
||||
const check = getCheck("deep-modules")!;
|
||||
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const changes = await readFile(changesPath(cwd), "utf8");
|
||||
// Safe pass-through: consolidation applied (auto).
|
||||
expect(changes).toContain("wrapper.ts");
|
||||
expect(changes).toMatch(/auto/);
|
||||
expect(changes).toMatch(/consolidat/i);
|
||||
|
||||
// Risky adapter: listed for review, not auto-applied (manual).
|
||||
expect(changes).toContain("risky-adapter.ts");
|
||||
expect(changes).toMatch(/manual/);
|
||||
|
||||
// The safe wrapper was rewritten — no longer a pass-through.
|
||||
const wrapperContent = await readFile(join(cwd, "wrapper.ts"), "utf8");
|
||||
expect(wrapperContent).not.toContain("import { compute }");
|
||||
expect(wrapperContent).toContain("Consolidated");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["deep-modules"]?.fix).toBe(true);
|
||||
expect(state?.checks["deep-modules"]?.changes).toContain(
|
||||
"1 auto-applied, 1 deferred",
|
||||
);
|
||||
expect(state?.checks["deep-modules"]?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("findings.md and changes.md live under .pygienium/checks/deep-modules/", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
const check = getCheck("deep-modules")!;
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
expect(findingsPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "deep-modules", "findings.md"),
|
||||
);
|
||||
expect(changesPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "deep-modules", "changes.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when the target has no source files", async () => {
|
||||
const empty = await mkdtemp(join(tmpdir(), "pygienium-empty-"));
|
||||
try {
|
||||
const check = getCheck("deep-modules")!;
|
||||
await handleCheckCommand(check, empty, stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["deep-modules"]?.status).toBe("skipped");
|
||||
} finally {
|
||||
await rm(empty, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
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,
|
||||
check as defensiveGuardsCheck,
|
||||
} from "../src/checks/defensive-guards.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `noise.ts`: a redundant null check on a typed-non-null param plus a
|
||||
* swallowing try/catch. Both are redundant — the type system guarantees
|
||||
* `name` is a string, and the catch silently swallows the error.
|
||||
*/
|
||||
async function seedNoise(dir: string): Promise<string> {
|
||||
const noise = join(dir, "noise.ts");
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
noise,
|
||||
[
|
||||
`export function greet(name: string) {`,
|
||||
` if (name === null) return "";`,
|
||||
` return \`hello \${name}\`;`,
|
||||
`}`,
|
||||
``,
|
||||
`export function swallow() {`,
|
||||
` try {`,
|
||||
` doThing();`,
|
||||
` } catch (e) {`,
|
||||
` // swallowed`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
`function doThing() {}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return noise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `boundary.ts`: a try/catch around JSON.parse of untrusted input. This is
|
||||
* a legitimate parsing boundary guard and must be PRESERVED by --fix.
|
||||
*/
|
||||
async function seedBoundary(dir: string): Promise<string> {
|
||||
const boundary = join(dir, "boundary.ts");
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
boundary,
|
||||
[
|
||||
`export function parse(input: string) {`,
|
||||
` try {`,
|
||||
` return JSON.parse(input);`,
|
||||
` } catch (e) {`,
|
||||
` return null;`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return boundary;
|
||||
}
|
||||
|
||||
describe("defensive-guards check", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
// Re-register explicitly: the module's import-time registerCheck only
|
||||
// runs once (module cache), so clearChecks + registerCheck restores it
|
||||
// deterministically for each test.
|
||||
registerCheck(defensiveGuardsCheck);
|
||||
// Fresh empty tempdir per test; each test seeds itself so the skip
|
||||
// test gets a genuinely empty cwd (the gate inspects cwd, not target).
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-dg-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered and uses the defensive-guards scanner agent", () => {
|
||||
const check = getCheck("defensive-guards");
|
||||
expect(check).toBeDefined();
|
||||
expect((check as CheckDefinition)?.agentName).toBe("defensive-guards");
|
||||
});
|
||||
|
||||
it("flags the redundant null check and swallowing try/catch, and keeps the JSON.parse boundary guard, in findings.md", async () => {
|
||||
await seedNoise(cwd);
|
||||
await seedBoundary(cwd);
|
||||
const check = getCheck("defensive-guards")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const findings = await readFile(findingsPath(cwd), "utf8");
|
||||
// Redundant guards are flagged with their kind.
|
||||
expect(findings).toContain("noise.ts");
|
||||
expect(findings).toContain("redundant-null-check");
|
||||
expect(findings).toContain("swallowing-try-catch");
|
||||
// The JSON.parse guard is classified as a boundary guard (kept).
|
||||
expect(findings).toContain("boundary.ts");
|
||||
expect(findings).toContain("keep-boundary");
|
||||
expect(findings).toContain("parsing-guard");
|
||||
|
||||
// Run state records the scan summary as findings text.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
|
||||
expect(state?.checks["defensive-guards"]?.findings).toContain(
|
||||
"defensive-guards: 2 redundant",
|
||||
);
|
||||
});
|
||||
|
||||
it("scan-only does not write changes.md and does not touch source files", async () => {
|
||||
const noise = await seedNoise(cwd);
|
||||
const boundary = await seedBoundary(cwd);
|
||||
const before = await readFile(noise, "utf8");
|
||||
const beforeBoundary = await readFile(boundary, "utf8");
|
||||
|
||||
const check = getCheck("defensive-guards")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
expect(existsSync(changesPath(cwd))).toBe(false);
|
||||
// Source files untouched by a scan-only run.
|
||||
expect(await readFile(noise, "utf8")).toBe(before);
|
||||
expect(await readFile(boundary, "utf8")).toBe(beforeBoundary);
|
||||
});
|
||||
|
||||
it("--fix removes the redundant guards from noise.ts and records changes.md (auto), and preserves the JSON.parse boundary guard", async () => {
|
||||
const noise = await seedNoise(cwd);
|
||||
const boundary = await seedBoundary(cwd);
|
||||
const check = getCheck("defensive-guards")!;
|
||||
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const changes = await readFile(changesPath(cwd), "utf8");
|
||||
// Redundant guards: removed (auto).
|
||||
expect(changes).toContain("noise.ts");
|
||||
expect(changes).toMatch(/auto/);
|
||||
expect(changes).toMatch(/redundant-null-check/);
|
||||
expect(changes).toMatch(/swallowing-try-catch/);
|
||||
// JSON.parse boundary guard: kept (boundary — with reason).
|
||||
expect(changes).toContain("boundary.ts");
|
||||
expect(changes).toMatch(/boundary/);
|
||||
expect(changes).toMatch(/JSON.parse/);
|
||||
|
||||
// noise.ts no longer contains the redundant null check or the swallowing
|
||||
// try/catch. The fixer leaves a header marker noting the cleanup.
|
||||
const cleaned = await readFile(noise, "utf8");
|
||||
expect(cleaned).not.toContain("=== null");
|
||||
expect(cleaned).not.toMatch(/try\s*\{/);
|
||||
expect(cleaned).toContain("Cleaned by pygienium-defensive-guards");
|
||||
// The happy-path behaviour is preserved.
|
||||
expect(cleaned).toContain("greet");
|
||||
expect(cleaned).toContain("hello");
|
||||
|
||||
// boundary.ts is PRESERVED — the JSON.parse guard is untouched.
|
||||
const keptBoundary = await readFile(boundary, "utf8");
|
||||
expect(keptBoundary).toContain("JSON.parse");
|
||||
expect(keptBoundary).toMatch(/try\s*\{/);
|
||||
expect(keptBoundary).toMatch(/catch/);
|
||||
// And it still returns null on parse failure (unchanged behaviour).
|
||||
expect(keptBoundary).toContain("return null");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["defensive-guards"]?.fix).toBe(true);
|
||||
expect(state?.checks["defensive-guards"]?.changes).toContain(
|
||||
"2 removed, 1 kept",
|
||||
);
|
||||
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("findings.md and changes.md live under .pygienium/checks/defensive-guards/", async () => {
|
||||
await seedNoise(cwd);
|
||||
await seedBoundary(cwd);
|
||||
const check = getCheck("defensive-guards")!;
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
expect(findingsPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "defensive-guards", "findings.md"),
|
||||
);
|
||||
expect(changesPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "defensive-guards", "changes.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when the target has no source files", async () => {
|
||||
// cwd is a fresh empty tempdir (no seeding) → the gate finds no source
|
||||
// files and skips the check without spawning an agent.
|
||||
const check = getCheck("defensive-guards")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["defensive-guards"]?.status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
62
tests/extensibility.test.ts
Normal file
62
tests/extensibility.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* extensibility.test.ts — the registry extensibility claim (task 14).
|
||||
*
|
||||
* Proves a NEW check registered via the public API yields a working
|
||||
* `/pygienium-<name>` command with ZERO `index.ts` command-wiring changes: an
|
||||
* in-test `registerCheck()` call makes the generic command-binding path
|
||||
* (`registerPygieniumCommands`, the exact function `index.ts` calls) expose
|
||||
* `/pygienium-witness` and `/pygienium-help` lists it. The shipped checks are
|
||||
* each exercised by their own test files, so this suite only needs a synthetic
|
||||
* witness.
|
||||
*/
|
||||
import { describe, expect, it, afterEach } from "bun:test";
|
||||
import {
|
||||
clearChecks,
|
||||
getCheck,
|
||||
getAllChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import { registerPygieniumCommands } from "../src/commands.js";
|
||||
import { buildPygieniumHelpLines } from "../src/help.js";
|
||||
|
||||
/** A synthetic check registered only for this suite. */
|
||||
const witnessCheck: CheckDefinition = {
|
||||
name: "witness",
|
||||
label: "Witness",
|
||||
description: "Test-only check proving zero-wiring extensibility.",
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "witness",
|
||||
buildScanTask: () =>
|
||||
"# Task: witness scan\nwrite findings.md: witness: 0 issues",
|
||||
buildFixTask: () => "# Task: witness fix\nwrite changes.md: witness: 0 edits",
|
||||
gate: () => undefined,
|
||||
};
|
||||
|
||||
describe("registry extensibility (task 14)", () => {
|
||||
afterEach(() => clearChecks());
|
||||
|
||||
it("a registered check is visible via getCheck/getAllChecks", () => {
|
||||
registerCheck(witnessCheck);
|
||||
expect(getCheck("witness")).toBe(witnessCheck);
|
||||
expect(getAllChecks().some((c) => c.name === "witness")).toBe(true);
|
||||
});
|
||||
|
||||
it("registerPygieniumCommands exposes /pygienium-<name> (zero wiring)", () => {
|
||||
registerCheck(witnessCheck);
|
||||
const names: string[] = [];
|
||||
registerPygieniumCommands((name) => names.push(name));
|
||||
expect(names).toContain("pygienium-witness");
|
||||
// And the operator commands are still wired.
|
||||
expect(names).toContain("pygienium-help");
|
||||
expect(names).toContain("pygienium-all");
|
||||
});
|
||||
|
||||
it("/pygienium-help lists a registered check", () => {
|
||||
registerCheck(witnessCheck);
|
||||
const text = buildPygieniumHelpLines().join("\n");
|
||||
expect(text).toContain("/pygienium-witness");
|
||||
expect(text).toContain(witnessCheck.description);
|
||||
});
|
||||
});
|
||||
269
tests/footer.test.ts
Normal file
269
tests/footer.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* footer.test.ts — unit tests for the pipeline-overview footer.
|
||||
*
|
||||
* The footer is a presentation-only multi-line `belowEditor` widget: with no
|
||||
* UI it tracks items but writes nothing; with a stub UI it pushes a string[]
|
||||
* of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
|
||||
* and clears on `done()`. The pure {@link renderFooterList} core is asserted
|
||||
* directly (layout + theming); a light widget-glue test covers the wiring.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
createPipelineFooter,
|
||||
footerPhaseItems,
|
||||
footerColor,
|
||||
renderFooterList,
|
||||
FOOTER_MARKER,
|
||||
FOOTER_STATUS_KEY,
|
||||
type FooterItem,
|
||||
type FooterTheme,
|
||||
} from "../src/footer.js";
|
||||
import { PHASE_LABELS } from "../src/phases.js";
|
||||
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
|
||||
|
||||
/** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
|
||||
function fakeTheme(): FooterTheme {
|
||||
return { fg: (color, text) => `${color}:${text}` };
|
||||
}
|
||||
|
||||
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
|
||||
function stubUi(theme: FooterTheme = fakeTheme()): {
|
||||
ui: {
|
||||
theme: FooterTheme;
|
||||
setWidget: (
|
||||
key: string,
|
||||
content: string[] | undefined,
|
||||
options?: { placement?: string },
|
||||
) => void;
|
||||
};
|
||||
calls: {
|
||||
key: string;
|
||||
content: string[] | undefined;
|
||||
placement?: string;
|
||||
}[];
|
||||
} {
|
||||
const calls: {
|
||||
key: string;
|
||||
content: string[] | undefined;
|
||||
placement?: string;
|
||||
}[] = [];
|
||||
return {
|
||||
calls,
|
||||
ui: {
|
||||
theme,
|
||||
setWidget(key, content, options) {
|
||||
calls.push({ key, content, placement: options?.placement });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the canonical phase-id list a scan-only check uses. */
|
||||
function scanPhaseIds(): string[] {
|
||||
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
|
||||
}
|
||||
|
||||
/** Items for the canonical scan-only pipeline. */
|
||||
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
|
||||
return scanPhaseIds().map((id) => ({
|
||||
label: PHASE_LABELS[id] ?? id,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("renderFooterList", () => {
|
||||
it("renders one bulleted, numbered, themed line per phase", () => {
|
||||
const lines = renderFooterList(scanItems(), -1, fakeTheme());
|
||||
expect(lines).toHaveLength(3);
|
||||
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
|
||||
expect(lines[0]).toBe("dim:• · 1. Recon");
|
||||
expect(lines[1]).toBe("dim:• · 2. Scanning");
|
||||
expect(lines[2]).toBe("dim:• · 3. Fixing");
|
||||
});
|
||||
|
||||
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
|
||||
const lines = renderFooterList(scanItems(), 1, fakeTheme());
|
||||
expect(lines[0]).toBe("dim:• · 1. Recon");
|
||||
// cursor (index 1) is pending-but-current → accent.
|
||||
expect(lines[1]).toBe("accent:• · 2. Scanning");
|
||||
expect(lines[2]).toBe("dim:• · 3. Fixing");
|
||||
});
|
||||
|
||||
it("themes terminal statuses with success/error/warning colors", () => {
|
||||
const items: FooterItem[] = [
|
||||
{ label: "Recon", status: "complete" },
|
||||
{ label: "Scan", status: "running" },
|
||||
{ label: "Fix", status: "failed" },
|
||||
{ label: "Verify", status: "skipped" },
|
||||
];
|
||||
const lines = renderFooterList(items, -1, fakeTheme());
|
||||
expect(lines[0]).toBe("success:• ✓ 1. Recon");
|
||||
expect(lines[1]).toBe("accent:• ● 2. Scan");
|
||||
expect(lines[2]).toBe("error:• ✗ 3. Fix");
|
||||
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
|
||||
});
|
||||
|
||||
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
|
||||
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
|
||||
label: `S${i}`,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
const lines = renderFooterList(items, -1, fakeTheme());
|
||||
expect(lines[0]).toContain("01. S0");
|
||||
expect(lines[10]).toContain("11. S10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("footerColor", () => {
|
||||
it("maps each status to its piolium-style color token", () => {
|
||||
expect(footerColor("complete", false)).toBe("success");
|
||||
expect(footerColor("failed", false)).toBe("error");
|
||||
expect(footerColor("skipped", false)).toBe("warning");
|
||||
expect(footerColor("running", false)).toBe("accent");
|
||||
expect(footerColor("pending", false)).toBe("dim");
|
||||
// A pending item under the cursor reads as accent (current).
|
||||
expect(footerColor("pending", true)).toBe("accent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPipelineFooter", () => {
|
||||
it("is a no-op without a UI but still tracks item state", () => {
|
||||
// hasUI false: setWidget must never be called.
|
||||
const footer = createPipelineFooter({ hasUI: false });
|
||||
footer.setPipeline("pygienium smoke", [
|
||||
{ label: "Recon", status: "pending" },
|
||||
]);
|
||||
footer.setCursor(0);
|
||||
footer.done();
|
||||
// No UI → no observable side effect, but getItems reflects state.
|
||||
expect(footer.getItems()[0]?.status).toBe("running");
|
||||
expect(footer.getTitle()).toBe("pygienium smoke");
|
||||
});
|
||||
|
||||
it("renders the full pipeline as a belowEditor widget and clears on done", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
|
||||
|
||||
footer.setPipeline("pygienium smoke", items, 0);
|
||||
|
||||
// One setWidget call, under the canonical key, placement belowEditor.
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
|
||||
expect(calls[0]?.placement).toBe("belowEditor");
|
||||
const lines = calls[0]?.content ?? [];
|
||||
// Title line first (dim), then one bulleted line per phase.
|
||||
expect(lines[0]).toBe("dim:pygienium smoke");
|
||||
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
|
||||
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
|
||||
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. Fixing`);
|
||||
// The cursor item is marked running.
|
||||
expect(footer.getItems()[0]?.status).toBe("running");
|
||||
|
||||
footer.done();
|
||||
// done() pushes an undefined to clear the slot, then resets state.
|
||||
const last = calls[calls.length - 1]!;
|
||||
expect(last.content).toBeUndefined();
|
||||
expect(last.placement).toBe("belowEditor");
|
||||
expect(footer.getItems()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("demotes the previous running item to pending when the cursor moves", () => {
|
||||
const { ui } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
footer.setPipeline(
|
||||
"pygienium smoke",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
0,
|
||||
);
|
||||
// recon → complete, then advance to analysis.
|
||||
footer.setItem(0, "complete");
|
||||
footer.setCursor(1);
|
||||
|
||||
const items = footer.getItems();
|
||||
expect(items[0]?.status).toBe("complete");
|
||||
expect(items[1]?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does not demote a terminal item when the cursor advances past it", () => {
|
||||
const { ui } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
footer.setPipeline(
|
||||
"pygienium smoke",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
0,
|
||||
);
|
||||
footer.setCursor(0); // recon running
|
||||
footer.setItem(0, "complete");
|
||||
footer.setCursor(1); // analysis running
|
||||
footer.setItem(1, "complete");
|
||||
footer.setCursor(2); // fix running
|
||||
const items = footer.getItems();
|
||||
expect(items[0]?.status).toBe("complete");
|
||||
expect(items[1]?.status).toBe("complete");
|
||||
expect(items[2]?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("marks a skipped gate as every item skipped", () => {
|
||||
const { ui } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
footer.setPipeline(
|
||||
"pygienium comments",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
);
|
||||
for (let i = 0; i < footer.getItems().length; i++) {
|
||||
footer.setItem(i, "skipped");
|
||||
}
|
||||
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
|
||||
});
|
||||
|
||||
it("can be disabled so it never touches the widget slot", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true, enabled: false });
|
||||
footer.setPipeline("pygienium smoke", [
|
||||
{ label: "Recon", status: "pending" },
|
||||
]);
|
||||
footer.setCursor(0);
|
||||
footer.done();
|
||||
// enabled:false suppresses every setWidget call (used by /pygienium-all
|
||||
// which owns its own footer).
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes under a custom widget key (all-run owns its slot)", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({
|
||||
ui,
|
||||
hasUI: true,
|
||||
statusKey: "pygienium-all",
|
||||
});
|
||||
footer.setPipeline(
|
||||
"pygienium: all",
|
||||
[
|
||||
{ label: "comments", status: "pending" },
|
||||
{ label: "dead-code", status: "pending" },
|
||||
],
|
||||
0,
|
||||
);
|
||||
expect(calls[0]?.key).toBe("pygienium-all");
|
||||
expect(calls[0]?.placement).toBe("belowEditor");
|
||||
const lines = calls[0]?.content ?? [];
|
||||
expect(lines[0]).toBe("dim:pygienium: all");
|
||||
// Cursor on the first check; second still pending (to come).
|
||||
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
|
||||
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("footerPhaseItems", () => {
|
||||
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
|
||||
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
|
||||
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
|
||||
expect(items.every((i) => i.status === "pending")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the raw id for unknown phases", () => {
|
||||
const items = footerPhaseItems(["custom"], PHASE_LABELS);
|
||||
expect(items[0]?.label).toBe("custom");
|
||||
});
|
||||
});
|
||||
101
tests/help.test.ts
Normal file
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] [--fresh]");
|
||||
expect(usages).toContain(
|
||||
"pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
||||
);
|
||||
expect(usages).toContain("pygienium-status [path]");
|
||||
expect(usages).toContain("pygienium-resume [path] [--fresh]");
|
||||
expect(usages).toContain(
|
||||
"pygienium-export [path] [--check=] [--status=] [--out=md|json]",
|
||||
);
|
||||
for (const cmd of COMMANDS) {
|
||||
expect(cmd.description.length).toBeGreaterThan(0);
|
||||
expect(cmd.example.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("CLI_FLAGS lists every implemented flag", () => {
|
||||
const names = CLI_FLAGS.map((f) => f.name);
|
||||
expect(names).toContain("[path]");
|
||||
expect(names).toContain("--fix");
|
||||
expect(names).toContain("--fresh");
|
||||
expect(names).toContain("--check=");
|
||||
expect(names).toContain("--status=");
|
||||
expect(names).toContain("--out=");
|
||||
expect(CLI_FLAGS.length).toBeGreaterThanOrEqual(6);
|
||||
// Back-compat alias points at the same array.
|
||||
expect(PYGIENIUM_FLAGS).toBe(CLI_FLAGS);
|
||||
});
|
||||
|
||||
it("buildPygieniumHelpLines surfaces every command usage and flag name", () => {
|
||||
const text = buildPygieniumHelpLines().join("\n");
|
||||
for (const cmd of COMMANDS) {
|
||||
expect(text).toContain(`/${cmd.usage}`);
|
||||
}
|
||||
for (const flag of CLI_FLAGS) {
|
||||
expect(text).toContain(flag.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists 8+ commands once several checks are registered", () => {
|
||||
registerCheck(stub("alpha"));
|
||||
registerCheck(stub("beta"));
|
||||
registerCheck(stub("gamma"));
|
||||
const lines = buildPygieniumHelpLines();
|
||||
// Any line that begins ` /pygienium-` is a command/check listing row.
|
||||
const commandRows = lines.filter((l) => l.startsWith(" /pygienium-"));
|
||||
// 6 operator command rows + 3 registered checks = 9.
|
||||
expect(commandRows.length).toBeGreaterThanOrEqual(8);
|
||||
// Each registered check is listed by name with its description.
|
||||
const text = lines.join("\n");
|
||||
expect(text).toContain("/pygienium-alpha");
|
||||
expect(text).toContain("/pygienium-beta");
|
||||
expect(text).toContain("/pygienium-gamma");
|
||||
expect(text).toContain("alpha check");
|
||||
});
|
||||
|
||||
it("notes the one-file + registerCheck extensibility workflow", () => {
|
||||
const text = buildPygieniumHelpLines().join("\n");
|
||||
expect(text).toContain("registerCheck");
|
||||
expect(text).toContain("No index.ts command-wiring changes");
|
||||
});
|
||||
});
|
||||
235
tests/per-check-resume.test.ts
Normal file
235
tests/per-check-resume.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* per-check-resume.test.ts — `/pygienium-<check>` resume semantics.
|
||||
*
|
||||
* The per-check command must be resume-aware (parity with
|
||||
* `/pygienium-all` and `/pygienium-resume`): a check already terminal
|
||||
* (`complete`/`skipped`) is skipped unless `--fresh`, and a failed check is
|
||||
* re-dispatched. This is what lets "running the original command again"
|
||||
* recover a partial run instead of blindly re-running every phase.
|
||||
*
|
||||
* A stateful tracker wraps the fake runner: the first call produces no
|
||||
* artifact (verify fails), the second writes findings.md (verify passes) —
|
||||
* modelling the intermittent empty-output bug recovering on retry.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Check whose fake runner writes findings.md and (with --fix) changes.md. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
// Verify hook asserting findings.md exists — like the real checks.
|
||||
verify: async (scope) => {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = join(scope.cwd, ".pygienium", "checks", name, "findings.md");
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `${name} verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||
const dispatched: string[] = [];
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, dispatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful runner: the first call returns ok with no artifact (verify fails),
|
||||
* the second writes findings.md (verify passes). Models the empty-output bug
|
||||
* recovering on retry.
|
||||
*/
|
||||
function flakyThenOkRunner(name: string): {
|
||||
runner: AgentRunner;
|
||||
calls: number;
|
||||
} {
|
||||
let calls = 0;
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, calls: 0 };
|
||||
}
|
||||
|
||||
describe("/pygienium-<check> resume semantics", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-pcr-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("skips an already-complete check and does not re-dispatch the agent", async () => {
|
||||
const track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
const check = fakeCheck("alpha");
|
||||
registerCheck(check);
|
||||
|
||||
// First run: completes and writes findings.md.
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
expect(track.dispatched).toEqual(["alpha"]);
|
||||
const state1 = await loadRunState(cwd);
|
||||
expect(state1?.checks.alpha.status).toBe("complete");
|
||||
|
||||
// Second run: terminal → skipped, no agent dispatch.
|
||||
const out = await captureStdout(() =>
|
||||
handleCheckCommand(check, "", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).toEqual(["alpha"]); // unchanged
|
||||
expect(out.join("\n")).toContain("already complete");
|
||||
expect(out.join("\n")).toContain("--fresh");
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.checks.alpha.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("--fresh re-runs a completed check from scratch", async () => {
|
||||
const track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
const check = fakeCheck("beta");
|
||||
registerCheck(check);
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
expect(track.dispatched).toEqual(["beta"]);
|
||||
|
||||
await captureStdout(() =>
|
||||
handleCheckCommand(check, "--fresh", stubCtx(cwd)),
|
||||
);
|
||||
// Dispatched again (now twice total).
|
||||
expect(track.dispatched).toEqual(["beta", "beta"]);
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.beta.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("re-runs a failed check and recovers when the agent produces the artifact on retry", async () => {
|
||||
const flaky = flakyThenOkRunner("gamma");
|
||||
// Expose the live call count via closure read after the run.
|
||||
let calls = 0;
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
void flaky; // (flakyThenOkRunner kept as a reference shape; use `runner` below)
|
||||
setAgentRunner(runner);
|
||||
|
||||
const check = fakeCheck("gamma");
|
||||
registerCheck(check);
|
||||
|
||||
// First run: agent returns ok with no artifact → verify fails.
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state1 = await loadRunState(cwd);
|
||||
expect(state1?.checks.gamma.status).toBe("failed");
|
||||
expect(state1?.checks.gamma.error).toContain("verify");
|
||||
expect(state1?.checks.gamma.error).toContain("findings.md");
|
||||
|
||||
// Resume: re-dispatch the failed check. Agent writes findings.md this
|
||||
// time → verify passes → complete.
|
||||
await captureStdout(() => handleCheckCommand(check, "", stubCtx(cwd)));
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.checks.gamma.status).toBe("complete");
|
||||
expect(state2?.checks.gamma.error).toBeUndefined();
|
||||
// The verify phase is now complete (not failed).
|
||||
const verify = state2?.checks.gamma.phases.find((p) => p.id === "verify");
|
||||
expect(verify?.status).toBe("complete");
|
||||
// And the artifact exists on disk.
|
||||
const findings = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "gamma", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(findings).toContain("gamma findings");
|
||||
});
|
||||
|
||||
it("does not treat a failed check as terminal (resume re-dispatches it)", async () => {
|
||||
const track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
const check = fakeCheck("delta");
|
||||
// Override verify to always fail so the check lands in `failed`.
|
||||
const alwaysFailing: CheckDefinition = {
|
||||
...check,
|
||||
name: "delta",
|
||||
verify: async () => "delta verify: forced failure",
|
||||
};
|
||||
registerCheck(alwaysFailing);
|
||||
|
||||
await handleCheckCommand(alwaysFailing, "", stubCtx(cwd));
|
||||
expect(track.dispatched).toEqual(["delta"]);
|
||||
const state1 = await loadRunState(cwd);
|
||||
expect(state1?.checks.delta.status).toBe("failed");
|
||||
|
||||
// Re-running the command re-dispatches (failed is NOT terminal).
|
||||
await captureStdout(() =>
|
||||
handleCheckCommand(alwaysFailing, "", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).toEqual(["delta", "delta"]);
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.checks.delta.status).toBe("failed"); // still failing
|
||||
});
|
||||
});
|
||||
|
||||
void mkdir; // silence unused-import lint under some configs
|
||||
69
tests/registry.test.ts
Normal file
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
137
tests/run-state.test.ts
Normal file
137
tests/run-state.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* run-state.test.ts — run-state reconciliation, error hygiene, and the
|
||||
* .gitignore guard (issues surfaced by the MagnaFluo all-run: "partial" for
|
||||
* all-failed runs, stale errors on completed checks, staged artifacts).
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ensureRunStateIgnored,
|
||||
initRunState,
|
||||
markCheckStatus,
|
||||
reconcileRunStatus,
|
||||
} from "../src/run-state.js";
|
||||
|
||||
/** Build a run state whose checks carry the given statuses. */
|
||||
function stateWith(
|
||||
...statuses: Array<[name: string, status: string]>
|
||||
): ReturnType<typeof initRunState> {
|
||||
const state = initRunState(
|
||||
"/virtual/cwd",
|
||||
statuses.map(([name]) => ({ name, label: name })),
|
||||
);
|
||||
for (const [name, status] of statuses) {
|
||||
markCheckStatus(
|
||||
state,
|
||||
name,
|
||||
status as "complete" | "failed" | "skipped",
|
||||
status === "failed" ? "boom" : undefined,
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("reconcileRunStatus", () => {
|
||||
it("is in_progress while nothing is terminal", () => {
|
||||
expect(reconcileRunStatus(stateWith())).toBe("in_progress");
|
||||
expect(
|
||||
reconcileRunStatus(initRunState("/virt", [{ name: "a", label: "a" }])),
|
||||
).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("is complete only when every check is complete", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "complete"], ["b", "complete"])),
|
||||
).toBe("complete");
|
||||
});
|
||||
|
||||
it("is partial when some checks failed and others completed", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "complete"], ["b", "failed"])),
|
||||
).toBe("partial");
|
||||
});
|
||||
|
||||
it("is partial when checks were skipped", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "complete"], ["b", "skipped"])),
|
||||
).toBe("partial");
|
||||
});
|
||||
|
||||
it("is failed when every check failed (not partial)", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "failed"], ["b", "failed"])),
|
||||
).toBe("failed");
|
||||
expect(reconcileRunStatus(stateWith(["a", "failed"]))).toBe("failed");
|
||||
});
|
||||
|
||||
it("is partial for a mixed failed/skipped run (some degraded, none ok)", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "failed"], ["b", "skipped"])),
|
||||
).toBe("partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("check error hygiene", () => {
|
||||
it("a failed check records its error", () => {
|
||||
const s = stateWith(["a", "failed"]);
|
||||
expect(s.checks.a?.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("a later success clears the stale error (resume-complete invariant)", () => {
|
||||
const s = stateWith(["a", "failed"]);
|
||||
expect(s.checks.a?.error).toBe("boom");
|
||||
markCheckStatus(s, "a", "complete");
|
||||
expect(s.checks.a?.error).toBeUndefined();
|
||||
expect(s.checks.a?.status).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRunStateIgnored", () => {
|
||||
let cwd: string;
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygium-git-"));
|
||||
await mkdir(join(cwd, ".git"), { recursive: true }); // pretend it's a work tree
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates .gitignore with .pygienium/ when absent", async () => {
|
||||
expect(await ensureRunStateIgnored(cwd)).toBe(true);
|
||||
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||
expect(content).toContain(".pygienium/");
|
||||
});
|
||||
|
||||
it("appends to an existing .gitignore without the marker", async () => {
|
||||
await writeFile(join(cwd, ".gitignore"), "node_modules/\n", "utf8");
|
||||
expect(await ensureRunStateIgnored(cwd)).toBe(true);
|
||||
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||
expect(content).toContain(".pygienium/");
|
||||
expect(content).toContain("node_modules/");
|
||||
});
|
||||
|
||||
it("leaves an existing marker untouched and reports no change", async () => {
|
||||
await writeFile(
|
||||
join(cwd, ".gitignore"),
|
||||
".pygienium/\nnode_modules/\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await ensureRunStateIgnored(cwd)).toBe(false);
|
||||
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||
expect(content).toBe(".pygienium/\nnode_modules/\n");
|
||||
});
|
||||
|
||||
it("is a no-op outside a git work tree", async () => {
|
||||
const plain = await mkdtemp(join(tmpdir(), "pygium-nogit-"));
|
||||
try {
|
||||
expect(await ensureRunStateIgnored(plain)).toBe(false);
|
||||
await expect(
|
||||
readFile(join(plain, ".gitignore"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(plain, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
422
tests/status-resume-export.test.ts
Normal file
422
tests/status-resume-export.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* status-resume-export.test.ts — integration test for task 13.
|
||||
*
|
||||
* Mirrors the spec scenario: start a hypothetical `/pygienium-all`, treat it as
|
||||
* interrupted (one check complete, one pending), then exercise
|
||||
* `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` and assert:
|
||||
*
|
||||
* - status reports accurate per-check progress (one complete, one pending,
|
||||
* run still in_progress);
|
||||
* - resume re-dispatches the pending check and DOES NOT re-run the complete
|
||||
* one (proven via the agent-runner call log), and the run ends complete;
|
||||
* - --fresh re-dispatches even the complete check (proven via call log);
|
||||
* - export produces a filtered markdown bundle on disk, and the --check=
|
||||
* and --out=json filters work.
|
||||
*
|
||||
* A tracker wraps the fake agent runner so we can assert which checks were
|
||||
* actually dispatched without depending on timing or a model.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import {
|
||||
handleStatusCommand,
|
||||
handleResumeCommand,
|
||||
handleExportCommand,
|
||||
type PygieniumCtx,
|
||||
} from "../src/commands.js";
|
||||
import {
|
||||
initRunState,
|
||||
loadRunState,
|
||||
saveRunState,
|
||||
markCheckStatus,
|
||||
recordCheckOutput,
|
||||
applyPhaseStatus,
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
} from "../src/run-state.js";
|
||||
import { formatRunStatus } from "../src/status.js";
|
||||
import {
|
||||
exportRun,
|
||||
gatherExportEntries,
|
||||
renderExportJson,
|
||||
renderExportMarkdown,
|
||||
parseExportFilters,
|
||||
canonicalChecksRoot,
|
||||
} from "../src/export.js";
|
||||
import type { AgentTaskOptions } from "../src/agent-runner.js";
|
||||
|
||||
/** Build a deterministic check whose fake runner writes on-disk artifacts. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||
const dispatched: string[] = [];
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
// Tag by check name from the task text (`!write .pygienium/checks/<name>/`).
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, dispatched };
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mark a check as fully complete in the run-state with captured output. */
|
||||
function markComplete(
|
||||
state: Parameters<typeof markCheckStatus>[0],
|
||||
name: string,
|
||||
findings: string,
|
||||
changes: string,
|
||||
): void {
|
||||
for (const phaseId of [
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
]) {
|
||||
applyPhaseStatus(state, name, phaseId, "complete");
|
||||
}
|
||||
recordCheckOutput(state, name, { findings, changes });
|
||||
markCheckStatus(state, name, "complete");
|
||||
}
|
||||
|
||||
describe("status / resume / export (task 13)", () => {
|
||||
let cwd: string;
|
||||
let track: ReturnType<typeof trackingRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-resume-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Seed an interrupted two-check run: `alpha` complete, `beta` pending. */
|
||||
async function seedInterruptedRun(
|
||||
fix = true,
|
||||
): Promise<{ state: ReturnType<typeof initRunState> }> {
|
||||
const state = initRunState(cwd, [
|
||||
{ name: "alpha", label: "alpha", fix },
|
||||
{ name: "beta", label: "beta", fix },
|
||||
]);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: `${cwd}/.pygienium/recon.json`,
|
||||
finishedAt: Date.now(),
|
||||
};
|
||||
// Simulate alpha fully complete with on-disk artifacts + captured text.
|
||||
markComplete(
|
||||
state,
|
||||
"alpha",
|
||||
"# alpha findings\nalpha-scan",
|
||||
"# alpha changes\nalpha-fix",
|
||||
);
|
||||
const alphaDir = join(canonicalChecksRoot(cwd), "alpha");
|
||||
await mkdir(alphaDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(alphaDir, "findings.md"),
|
||||
"# alpha findings\nalpha-scan\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(alphaDir, "changes.md"),
|
||||
"# alpha changes\nalpha-fix\n",
|
||||
"utf8",
|
||||
);
|
||||
// beta left pending (the interruption). For alpha's recon phase, mark it too.
|
||||
applyPhaseStatus(state, "beta", PHASE_RECON, "complete");
|
||||
await saveRunState(state);
|
||||
return { state };
|
||||
}
|
||||
|
||||
it("formatRunStatus reports accurate per-check progress (alpha complete, beta pending)", async () => {
|
||||
const { state } = await seedInterruptedRun();
|
||||
const lines = formatRunStatus(state);
|
||||
expect(lines.join("\n")).toContain("pygienium run — in_progress");
|
||||
expect(lines.join("\n")).toContain("alpha — complete");
|
||||
expect(lines.join("\n")).toContain("beta — pending");
|
||||
// Artifacts captured on alpha appear; beta has none.
|
||||
expect(lines.join("\n")).toContain("findings: 2 line(s)");
|
||||
expect(lines.join("\n")).toContain("changes: 2 line(s)");
|
||||
});
|
||||
|
||||
it("/pygienium-status prints the status line list end to end", async () => {
|
||||
const { state } = await seedInterruptedRun();
|
||||
const out = await captureStdout(() =>
|
||||
handleStatusCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out.join("\n")).toContain("alpha — complete");
|
||||
expect(out.join("\n")).toContain("beta — pending");
|
||||
expect(out.join("\n")).toContain("pygienium run — in_progress");
|
||||
void state;
|
||||
});
|
||||
|
||||
it("/pygienium-status with no run state prints a not-found message", async () => {
|
||||
const out = await captureStdout(() =>
|
||||
handleStatusCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("no run state found");
|
||||
});
|
||||
|
||||
it("/pygienium-resume re-dispatches beta without re-running complete alpha", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
void state;
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleResumeCommand("", stubCtx(cwd)),
|
||||
);
|
||||
|
||||
// alpha is complete and must NOT be re-dispatched; beta was pending.
|
||||
expect(track.dispatched).not.toContain("alpha");
|
||||
expect(track.dispatched).toContain("beta");
|
||||
|
||||
// The run should now be complete.
|
||||
const after = await loadRunState(cwd);
|
||||
expect(after?.status).toBe("complete");
|
||||
expect(after?.checks.alpha.status).toBe("complete");
|
||||
expect(after?.checks.beta.status).toBe("complete");
|
||||
|
||||
// beta's artifacts now exist on disk.
|
||||
const betaFindings = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "beta", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(betaFindings).toContain("beta findings");
|
||||
|
||||
// Summary mentions re-dispatched/skipped counts.
|
||||
expect(out.join("\n")).toContain("re-dispatched 1");
|
||||
expect(out.join("\n")).toContain("skipped 1");
|
||||
});
|
||||
|
||||
it("/pygienium-resume --fresh re-dispatches the complete check too", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await seedInterruptedRun();
|
||||
|
||||
await captureStdout(() => handleResumeCommand("--fresh", stubCtx(cwd)));
|
||||
|
||||
expect(track.dispatched).toContain("alpha");
|
||||
expect(track.dispatched).toContain("beta");
|
||||
const after = await loadRunState(cwd);
|
||||
expect(after?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("/pygienium-resume with no state prints nothing-to-resume", async () => {
|
||||
const out = await captureStdout(() =>
|
||||
handleResumeCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("no run state to resume");
|
||||
});
|
||||
|
||||
it("/pygienium-resume on an already-complete run refuses without --fresh", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
// Complete beta too so the whole run is complete.
|
||||
markComplete(
|
||||
state,
|
||||
"beta",
|
||||
"# beta findings\nbeta-scan",
|
||||
"# beta changes\nbeta-fix",
|
||||
);
|
||||
await saveRunState(state);
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleResumeCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("nothing to resume");
|
||||
expect(track.dispatched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/pygienium-export writes a markdown bundle with both checks", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
// Run beta via resume so its artifacts land on disk.
|
||||
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
|
||||
void state;
|
||||
|
||||
await captureStdout(() => handleExportCommand("", stubCtx(cwd)));
|
||||
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
|
||||
expect(bundle).toContain("# Pygienium export");
|
||||
expect(bundle).toContain("## alpha (complete)");
|
||||
expect(bundle).toContain("## beta (complete)");
|
||||
expect(bundle).toContain("# alpha findings");
|
||||
expect(bundle).toContain("# beta findings");
|
||||
});
|
||||
|
||||
it("/pygienium-export --check=beta produces a filtered bundle", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await seedInterruptedRun();
|
||||
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
|
||||
|
||||
await captureStdout(() =>
|
||||
handleExportCommand("--check=beta", stubCtx(cwd)),
|
||||
);
|
||||
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
|
||||
expect(bundle).toContain("## beta (complete)");
|
||||
expect(bundle).not.toContain("## alpha");
|
||||
});
|
||||
|
||||
it("/pygienium-export --status=failed includes only failed checks", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
// Mark alpha failed (artifacts already on disk from the seed); leave beta pending.
|
||||
markCheckStatus(state, "alpha", "failed", "fake failure");
|
||||
await saveRunState(state);
|
||||
|
||||
await captureStdout(() =>
|
||||
handleExportCommand("--status=failed", stubCtx(cwd)),
|
||||
);
|
||||
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
|
||||
expect(bundle).toContain("## alpha (failed)");
|
||||
expect(bundle).not.toContain("## beta");
|
||||
});
|
||||
|
||||
it("/pygienium-export --out=json writes JSON matching renderExportJson", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await seedInterruptedRun();
|
||||
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleExportCommand("--out=json", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("export.json");
|
||||
const raw = await readFile(join(cwd, ".pygienium", "export.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
checks: Array<{ name: string; status: string; findings: string }>;
|
||||
};
|
||||
const names = parsed.checks.map((c) => c.name).sort();
|
||||
expect(names).toEqual(["alpha", "beta"]);
|
||||
expect(parsed.checks.find((c) => c.name === "alpha")?.findings).toContain(
|
||||
"alpha findings",
|
||||
);
|
||||
|
||||
// renderExportJson matches the on-disk content for the gathered set.
|
||||
const state = await loadRunState(cwd);
|
||||
const entries = await gatherExportEntries(cwd, state);
|
||||
expect(renderExportJson(state, entries).trim()).toBe(raw.trim());
|
||||
});
|
||||
|
||||
it("parseExportFilters splits comma lists and trims values", () => {
|
||||
const f = parseExportFilters(
|
||||
"--check=alpha,beta --status=complete,failed --out=json",
|
||||
);
|
||||
expect(f.check).toEqual(["alpha", "beta"]);
|
||||
expect(f.status).toEqual(["complete", "failed"]);
|
||||
expect(f.out).toBe("json");
|
||||
});
|
||||
|
||||
it("gatherExportEntries reads only the canonical .pygienium/checks/ root", async () => {
|
||||
await mkdir(join(cwd, ".pygienium", "checks", "alpha"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
|
||||
"# alpha findings\n",
|
||||
"utf8",
|
||||
);
|
||||
// A stray pygienium/checks/ dir (the old non-hidden root) is ignored now
|
||||
// that all checks write to the single canonical `.pygienium/checks/` root.
|
||||
await mkdir(join(cwd, "pygienium", "checks", "ghost"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(cwd, "pygienium", "checks", "ghost", "findings.md"),
|
||||
"# ghost findings\n",
|
||||
"utf8",
|
||||
);
|
||||
const entries = await gatherExportEntries(cwd, undefined);
|
||||
const alpha = entries.find((e) => e.name === "alpha");
|
||||
expect(alpha).toBeDefined();
|
||||
expect(alpha?.findings).toContain("alpha findings");
|
||||
expect(alpha?.status).toBe("unknown");
|
||||
expect(alpha?.findingsPath).toBe(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
|
||||
);
|
||||
expect(entries.find((e) => e.name === "ghost")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renderExportMarkdown includes a 'no artifacts' note for empty checks", async () => {
|
||||
const entries = [{ name: "ghost", status: "unknown" }];
|
||||
const md = renderExportMarkdown(undefined, entries as never);
|
||||
expect(md).toContain("## ghost (unknown)");
|
||||
expect(md).toContain("no findings.md or changes.md on disk");
|
||||
// And renderExportJson emits nulls for the empty check.
|
||||
const json = renderExportJson(undefined, entries as never);
|
||||
const parsed = JSON.parse(json) as {
|
||||
checks: Array<{ findings: unknown; changes: unknown }>;
|
||||
};
|
||||
expect(parsed.checks[0]!.findings).toBeNull();
|
||||
expect(parsed.checks[0]!.changes).toBeNull();
|
||||
});
|
||||
|
||||
it("exportRun writes nothing useful and reports zero entries cleanly", async () => {
|
||||
const result = await exportRun(cwd, undefined, {});
|
||||
expect(result.entries).toHaveLength(0);
|
||||
expect(relative(cwd, result.path)).toBe(join(".pygienium", "export.md"));
|
||||
});
|
||||
});
|
||||
337
tests/todos.test.ts
Normal file
337
tests/todos.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* todos.test.ts — unit + integration tests for the todos (TODOs & stubs) check.
|
||||
*
|
||||
* Unit: `detectTodoStubs` over a multi-language fixture tree — markers, silent
|
||||
* stubs (placeholder return / empty body / pass-only body), loud stubs, and
|
||||
* the negative cases (a real adder, a `return null` catch handler, in-string
|
||||
* "TODO" flagged for recall, clean code never flagged).
|
||||
*
|
||||
* Integration (deterministic fake agent runner): the scan persists findings.md
|
||||
* with the three sections + machine-readable summary and a new/resolved delta
|
||||
* vs the previous run; --fix converts silent stubs to loud throws, preserves
|
||||
* markers, leaves loud stubs untouched, and records changes.md.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
getCheck,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import {
|
||||
findingsPath,
|
||||
changesPath,
|
||||
check as todosCheck,
|
||||
detectTodoStubs,
|
||||
todosPriorCounts,
|
||||
buildTodosScanTask,
|
||||
} from "../src/checks/todos.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `stubs.ts` with the full taxonomy: a TODO marker, a silent stub with a
|
||||
* placeholder return (getPrice), a silent stub with an empty body (notify),
|
||||
* and a loud stub (connect throws "Not implemented").
|
||||
*/
|
||||
async function seedStubs(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
join(dir, "stubs.ts"),
|
||||
[
|
||||
`// TODO: add pagination`,
|
||||
`export function getPrice(): number {`,
|
||||
` return 0;`,
|
||||
`}`,
|
||||
``,
|
||||
`export function notify(): void {}`,
|
||||
``,
|
||||
`export function connect(): Promise<void> {`,
|
||||
` throw new Error("Not implemented");`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/** Seed the multi-language fixture tree for the deterministic-detector tests. */
|
||||
async function seedTree(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(dir, "math.ts"),
|
||||
"export function add(a: number, b: number): number {\n return a + b;\n}\n",
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
join(dir, "parse.ts"),
|
||||
[
|
||||
`export function parse(input: string) {`,
|
||||
` try {`,
|
||||
` return JSON.parse(input);`,
|
||||
` } catch {`,
|
||||
` return null;`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(join(dir, "stringlit.ts"), 'export const op = "TODO";\n', "utf8"),
|
||||
writeFile(
|
||||
join(dir, "stubs.ts"),
|
||||
[
|
||||
`// TODO: add pagination`,
|
||||
`export function getPrice(): number {`,
|
||||
` return 0;`,
|
||||
`}`,
|
||||
``,
|
||||
`export function notify(): void {}`,
|
||||
``,
|
||||
`export function connect(): Promise<void> {`,
|
||||
` throw new Error("Not implemented");`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
join(dir, "repo.py"),
|
||||
[
|
||||
`class Repository:`,
|
||||
` def find(self, uid):`,
|
||||
` raise NotImplementedError # interface method`,
|
||||
``,
|
||||
` def save(self, record):`,
|
||||
` pass`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
join(dir, "fetch.rs"),
|
||||
"fn fetch() -> Result<u32, String> {\n todo!()\n}\n",
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("detectTodoStubs", () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "pygienium-todos-unit-"));
|
||||
await seedTree(dir);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("flags markers, silent stubs, and loud stubs across languages", async () => {
|
||||
const hits = await detectTodoStubs(dir);
|
||||
const marker = hits.filter((h) => h.kind === "marker");
|
||||
const silent = hits.filter((h) => h.kind === "silent-stub");
|
||||
const loud = hits.filter((h) => h.kind === "loud-stub");
|
||||
|
||||
// Clean code is never flagged: the adder and the `return null` catch
|
||||
// handler (a boundary handler, not a stub) produce zero hits.
|
||||
expect(
|
||||
hits.filter((h) => /(?:math\.ts|parse\.ts)$/.test(h.path)),
|
||||
).toHaveLength(0);
|
||||
|
||||
// Markers: the stubs.ts TODO comment, and the in-string "TODO" (recall —
|
||||
// the scan agent's job is to drop the string-literal noise).
|
||||
expect(
|
||||
marker.some(
|
||||
(h) =>
|
||||
h.path.endsWith("stubs.ts") && h.line === 1 && h.snippet === "TODO",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
marker.some(
|
||||
(h) => h.path.endsWith("stringlit.ts") && h.snippet === "TODO",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Silent stubs: lone placeholder return + empty body in stubs.ts,
|
||||
// pass-only body in repo.py — each with its enclosing function.
|
||||
const stubsSilent = silent.filter((h) => h.path.endsWith("stubs.ts"));
|
||||
expect(
|
||||
stubsSilent.some(
|
||||
(h) => h.snippet === "placeholder-return" && h.context === "getPrice",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
stubsSilent.some(
|
||||
(h) => h.snippet === "empty-body" && h.context === "notify",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
silent.some(
|
||||
(h) =>
|
||||
h.path.endsWith("repo.py") &&
|
||||
h.snippet === "pass-only" &&
|
||||
h.context === "save",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Loud stubs: "Not implemented" throw, raise NotImplementedError,
|
||||
// rust todo!() — reported as tracked debt.
|
||||
expect(
|
||||
loud.some(
|
||||
(h) => h.path.endsWith("stubs.ts") && h.snippet === "Not implemented",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loud.some(
|
||||
(h) =>
|
||||
h.path.endsWith("repo.py") && h.snippet === "NotImplementedError",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("todos check", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
// Re-register explicitly: the module's import-time registerCheck only
|
||||
// runs once (module cache), so clearChecks + registerCheck restores it
|
||||
// deterministically for each test.
|
||||
registerCheck(todosCheck);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-todos-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered and uses the todos scanner agent", () => {
|
||||
const check = getCheck("todos");
|
||||
expect(check).toBeDefined();
|
||||
expect((check as CheckDefinition)?.agentName).toBe("todos");
|
||||
});
|
||||
|
||||
it("scan-only writes findings.md with the three sections and leaves sources untouched", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const findings = await readFile(findingsPath(cwd), "utf8");
|
||||
expect(findings).toContain(
|
||||
"summary: 1 marker(s), 2 silent stub(s), 1 loud stub(s)",
|
||||
);
|
||||
expect(findings).toContain("new: 4"); // first run: everything is new
|
||||
expect(findings).toContain("## TODO markers");
|
||||
expect(findings).toContain("## Silent stubs (actionable)");
|
||||
expect(findings).toContain("placeholder-return");
|
||||
expect(findings).toContain("Not implemented");
|
||||
|
||||
// Scan-only: no changes.md, sources untouched.
|
||||
expect(existsSync(changesPath(cwd))).toBe(false);
|
||||
const untouched = await readFile(join(cwd, "stubs.ts"), "utf8");
|
||||
expect(untouched).toContain("return 0;");
|
||||
|
||||
// Run state records the scan summary as findings text.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["todos"]?.status).toBe("complete");
|
||||
expect(state?.checks["todos"]?.findings).toContain(
|
||||
"todos: 2 silent stub(s), 1 loud stub(s), 1 marker(s)",
|
||||
);
|
||||
});
|
||||
|
||||
it("--fix converts silent stubs to loud throws, preserves markers and loud stubs, records changes.md", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const cleaned = await readFile(join(cwd, "stubs.ts"), "utf8");
|
||||
// Silent stubs now throw loudly, naming the function.
|
||||
expect(cleaned).toContain('throw new Error("todos: getPrice() is a stub")');
|
||||
expect(cleaned).toContain('throw new Error("todos: notify() is a stub")');
|
||||
// Markers are NEVER deleted and loud stubs are NEVER touched.
|
||||
expect(cleaned).toContain("// TODO: add pagination");
|
||||
expect(cleaned).toContain('throw new Error("Not implemented")');
|
||||
expect(cleaned).toContain("Cleaned by pygienium-todos");
|
||||
// The placeholder bodies are gone.
|
||||
expect(cleaned).not.toContain("return 0;");
|
||||
expect(cleaned).not.toContain("notify(): void {}");
|
||||
|
||||
const changes = await readFile(changesPath(cwd), "utf8");
|
||||
expect(changes).toContain(
|
||||
"summary: 2 silent stub(s) converted to loud, 0 kept",
|
||||
);
|
||||
expect(changes).toContain("getPrice()");
|
||||
expect(changes).toContain("notify()");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["todos"]?.fix).toBe(true);
|
||||
expect(state?.checks["todos"]?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("findings.md and changes.md live under .pygienium/checks/todos/", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
expect(findingsPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "todos", "findings.md"),
|
||||
);
|
||||
expect(changesPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "todos", "changes.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when the target has no source files", async () => {
|
||||
// cwd is a fresh empty tempdir (no seeding) → the gate finds no source
|
||||
// files and skips the check without spawning an agent.
|
||||
const check = getCheck("todos")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["todos"]?.status).toBe("skipped");
|
||||
});
|
||||
|
||||
it("reports a new/resolved delta against the previous run's counts", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
// The previous run's verified counts are parseable from run-state.
|
||||
const prior = await todosPriorCounts(cwd);
|
||||
expect(prior).toEqual({ silent: 2, loud: 1, marker: 1 });
|
||||
|
||||
// Nobody flagged anything new and everything was resolved: the next scan
|
||||
// sees zero candidates → new: 0, resolved: 4 (all prior items).
|
||||
await rm(join(cwd, "stubs.ts"));
|
||||
const task = await buildTodosScanTask(cwd, {
|
||||
cwd,
|
||||
target: cwd,
|
||||
fix: false,
|
||||
rest: [],
|
||||
});
|
||||
expect(task).toContain(
|
||||
"summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s)",
|
||||
);
|
||||
expect(task).toContain("| new: 0 | resolved: 4 |");
|
||||
});
|
||||
});
|
||||
146
tests/verify-hooks.test.ts
Normal file
146
tests/verify-hooks.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* verify-hooks.test.ts — proves every artifact-producing check fails loudly
|
||||
* when its sub-agent returns ok without writing findings.md.
|
||||
*
|
||||
* This is the exact failure mode the MagniFluo run exposed: complexity,
|
||||
* deep-modules, and defensive-guards returned ok with empty text in
|
||||
* milliseconds, produced no findings.md, and — because they had no `verify`
|
||||
* hook — were stamped `complete` by the fallback gate re-run. todos was the
|
||||
* only one that failed, solely because it already had a verify hook.
|
||||
*
|
||||
* Each check now carries a `verify` hook asserting its artifacts landed. A
|
||||
* no-op agent runner (ok + empty text + no writes) must fail at verify with a
|
||||
* message naming the missing findings.md, and the check status must be
|
||||
* `failed` — never `complete`.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import { check as complexityCheck } from "../src/checks/complexity.js";
|
||||
import { check as deadCodeCheck } from "../src/checks/dead-code.js";
|
||||
import { check as deepModulesCheck } from "../src/checks/deep-modules.js";
|
||||
import { check as defensiveGuardsCheck } from "../src/checks/defensive-guards.js";
|
||||
|
||||
/** Agent runner that simulates the MagniFluo bug: ok, empty, no writes. */
|
||||
const noopRunner: AgentRunner = async () => ({
|
||||
ok: true,
|
||||
text: "",
|
||||
});
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
describe("verify hooks fail loudly on empty agent output", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(noopRunner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-verify-"));
|
||||
// Seed one source file so the source-file gates (deep-modules,
|
||||
// defensive-guards, dead-code) pass and the check reaches analysis.
|
||||
await writeFile(join(cwd, "sample.ts"), "export const x = 1;\n", "utf8");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
* Run a check with the no-op runner and assert it fails at verify — for
|
||||
* checks where the sub-agent (not the task builder) is responsible for
|
||||
* writing findings.md.
|
||||
*/
|
||||
async function assertFailsVerify(
|
||||
check: CheckDefinition,
|
||||
findingsNeedle: string,
|
||||
): Promise<void> {
|
||||
registerCheck(check);
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
const entry = state?.checks[check.name];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.status).toBe("failed");
|
||||
expect(entry?.error).toContain("verify");
|
||||
expect(entry?.error).toContain("findings.md");
|
||||
// The verify phase itself is marked failed (not analysis).
|
||||
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
|
||||
expect(verifyPhase?.status).toBe("failed");
|
||||
expect(verifyPhase?.error).toContain(findingsNeedle);
|
||||
// Analysis reported ok (the bug: ok + empty), but no findings captured.
|
||||
const analysisPhase = entry?.phases.find((p) => p.id === "analysis");
|
||||
expect(analysisPhase?.status).toBe("complete");
|
||||
expect(entry?.findings).toBe("");
|
||||
}
|
||||
|
||||
it("complexity fails verify when findings.md is missing", async () => {
|
||||
await assertFailsVerify(complexityCheck, "complexity verify");
|
||||
});
|
||||
|
||||
it("deep-modules fails verify when findings.md is missing", async () => {
|
||||
await assertFailsVerify(deepModulesCheck, "deep-modules verify");
|
||||
});
|
||||
|
||||
it("defensive-guards fails verify when findings.md is missing", async () => {
|
||||
await assertFailsVerify(defensiveGuardsCheck, "defensive-guards verify");
|
||||
});
|
||||
|
||||
/**
|
||||
* dead-code is hybrid: its `buildDeadCodeScanTask` deterministically
|
||||
* writes findings.md via a pre-scan BEFORE the agent runs. So a no-op
|
||||
* agent still leaves the artifact, and verify correctly passes — proving
|
||||
* the hook does not false-positive on dead-code's robust design. The
|
||||
* grep on the verify hook is still live: delete the pre-written file and
|
||||
* the same hook fails (asserted in the --fix case below for changes.md).
|
||||
*/
|
||||
it("dead-code verify passes with a no-op agent (deterministic pre-scan wrote findings.md)", async () => {
|
||||
registerCheck(deadCodeCheck);
|
||||
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
const entry = state?.checks["dead-code"];
|
||||
expect(entry?.status).toBe("complete");
|
||||
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
|
||||
expect(verifyPhase?.status).toBe("complete");
|
||||
// The findings.md the pre-scan wrote is on disk.
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const { findingsPath } = await import("../src/checks/dead-code.js");
|
||||
await expect(stat(findingsPath(cwd))).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("with --fix, a missing changes.md fails verify even when findings.md exists", async () => {
|
||||
// Defensive-guards: write findings.md ourselves so the findings check
|
||||
// passes, but leave changes.md absent — verify must still fail.
|
||||
const { mkdir, writeFile: wf } = await import("node:fs/promises");
|
||||
const { dirname } = await import("node:path");
|
||||
const { findingsPath } = await import("../src/checks/defensive-guards.js");
|
||||
const f = findingsPath(cwd);
|
||||
await mkdir(dirname(f), { recursive: true });
|
||||
await wf(f, "# findings\n", "utf8");
|
||||
|
||||
registerCheck(defensiveGuardsCheck);
|
||||
// Runner writes changes.md content into its text but never to disk.
|
||||
setAgentRunner(async () => ({ ok: true, text: "" }));
|
||||
await handleCheckCommand(defensiveGuardsCheck, "--fix", stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
const entry = state?.checks["defensive-guards"];
|
||||
expect(entry?.status).toBe("failed");
|
||||
expect(entry?.error).toContain("changes.md");
|
||||
expect(entry?.error).toContain("verify");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user