233 lines
8.0 KiB
TypeScript
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");
|
|
});
|
|
});
|