Files
pygienium/tests/complexity.test.ts

166 lines
6.0 KiB
TypeScript
Raw Blame History

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