Files
omp-pygienium/tests/defensive-guards.test.ts

233 lines
8.0 KiB
TypeScript

/**
* 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");
});
});