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