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