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