/** * todos.test.ts — unit + integration tests for the todos (TODOs & stubs) check. * * Unit: `detectTodoStubs` over a multi-language fixture tree — markers, silent * stubs (placeholder return / empty body / pass-only body), loud stubs, and * the negative cases (a real adder, a `return null` catch handler, in-string * "TODO" flagged for recall, clean code never flagged). * * Integration (deterministic fake agent runner): the scan persists findings.md * with the three sections + machine-readable summary and a new/resolved delta * vs the previous run; --fix converts silent stubs to loud throws, preserves * markers, leaves loud stubs untouched, and records changes.md. */ 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, todosCheck, detectTodoStubs, todosPriorCounts, buildTodosScanTask, } from "../src/checks/todos.js"; function stubCtx(cwd: string): PygieniumCtx { return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; } /** * Seed `stubs.ts` with the full taxonomy: a TODO marker, a silent stub with a * placeholder return (getPrice), a silent stub with an empty body (notify), * and a loud stub (connect throws "Not implemented"). */ async function seedStubs(dir: string): Promise { await mkdir(dir, { recursive: true }).catch(() => {}); await writeFile( join(dir, "stubs.ts"), [ `// TODO: add pagination`, `export function getPrice(): number {`, ` return 0;`, `}`, ``, `export function notify(): void {}`, ``, `export function connect(): Promise {`, ` throw new Error("Not implemented");`, `}`, ``, ].join("\n"), "utf8", ); } /** Seed the multi-language fixture tree for the deterministic-detector tests. */ async function seedTree(dir: string): Promise { await mkdir(dir, { recursive: true }).catch(() => {}); await Promise.all([ writeFile( join(dir, "math.ts"), "export function add(a: number, b: number): number {\n return a + b;\n}\n", "utf8", ), writeFile( join(dir, "parse.ts"), [ `export function parse(input: string) {`, ` try {`, ` return JSON.parse(input);`, ` } catch {`, ` return null;`, ` }`, `}`, ``, ].join("\n"), "utf8", ), writeFile(join(dir, "stringlit.ts"), 'export const op = "TODO";\n', "utf8"), writeFile( join(dir, "stubs.ts"), [ `// TODO: add pagination`, `export function getPrice(): number {`, ` return 0;`, `}`, ``, `export function notify(): void {}`, ``, `export function connect(): Promise {`, ` throw new Error("Not implemented");`, `}`, ``, ].join("\n"), "utf8", ), writeFile( join(dir, "repo.py"), [ `class Repository:`, ` def find(self, uid):`, ` raise NotImplementedError # interface method`, ``, ` def save(self, record):`, ` pass`, ``, ].join("\n"), "utf8", ), writeFile( join(dir, "fetch.rs"), "fn fetch() -> Result {\n todo!()\n}\n", "utf8", ), ]); } describe("detectTodoStubs", () => { let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "pygienium-todos-unit-")); await seedTree(dir); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }).catch(() => {}); }); it("flags markers, silent stubs, and loud stubs across languages", async () => { const hits = await detectTodoStubs(dir); const marker = hits.filter((h) => h.kind === "marker"); const silent = hits.filter((h) => h.kind === "silent-stub"); const loud = hits.filter((h) => h.kind === "loud-stub"); // Clean code is never flagged: the adder and the `return null` catch // handler (a boundary handler, not a stub) produce zero hits. expect( hits.filter((h) => /(?:math\.ts|parse\.ts)$/.test(h.path)), ).toHaveLength(0); // Markers: the stubs.ts TODO comment, and the in-string "TODO" (recall — // the scan agent's job is to drop the string-literal noise). expect( marker.some( (h) => h.path.endsWith("stubs.ts") && h.line === 1 && h.snippet === "TODO", ), ).toBe(true); expect( marker.some( (h) => h.path.endsWith("stringlit.ts") && h.snippet === "TODO", ), ).toBe(true); // Silent stubs: lone placeholder return + empty body in stubs.ts, // pass-only body in repo.py — each with its enclosing function. const stubsSilent = silent.filter((h) => h.path.endsWith("stubs.ts")); expect( stubsSilent.some( (h) => h.snippet === "placeholder-return" && h.context === "getPrice", ), ).toBe(true); expect( stubsSilent.some( (h) => h.snippet === "empty-body" && h.context === "notify", ), ).toBe(true); expect( silent.some( (h) => h.path.endsWith("repo.py") && h.snippet === "pass-only" && h.context === "save", ), ).toBe(true); // Loud stubs: "Not implemented" throw, raise NotImplementedError, // rust todo!() — reported as tracked debt. expect( loud.some( (h) => h.path.endsWith("stubs.ts") && h.snippet === "Not implemented", ), ).toBe(true); expect( loud.some( (h) => h.path.endsWith("repo.py") && h.snippet === "NotImplementedError", ), ).toBe(true); expect( loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("), ).toBe(true); }); it("never descends into build/deploy output directories", async () => { // Generated bundles under framework build dirs must not feed the // pre-scan: they dominate candidate counts with minified noise (the // freno-dev failure flagged 119 of 124 candidates inside // `.output`/`.vercel` bundles, ballooning the scan task to 2.5 MB). for (const rel of [ join(".output", "public", "bundle.js"), join(".vercel", "output", "static", "app.js"), join(".netlify", "functions", "bundle.js"), ]) { const full = join(dir, rel); await mkdir(join(full, ".."), { recursive: true }); await writeFile( full, "// TODO: bundle placeholder\nfunction f(){ return 0; }\nthrow new Error('not implemented');\n", "utf8", ); } const hits = await detectTodoStubs(dir); expect( hits.filter((h) => /(?:\.output|\.vercel|\.netlify)[/\\]/.test(h.path)), ).toHaveLength(0); }); }); describe("todos 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(todosCheck); cwd = await mkdtemp(join(tmpdir(), "pygienium-todos-")); }); afterEach(async () => { resetAgentRunner(); await rm(cwd, { recursive: true, force: true }).catch(() => {}); }); it("is registered and uses the todos scanner agent", () => { const check = getCheck("todos"); expect(check).toBeDefined(); expect((check as CheckDefinition)?.agentName).toBe("todos"); }); it("scan-only writes findings.md with the three sections and leaves sources untouched", async () => { await seedStubs(cwd); const check = getCheck("todos")!; await handleCheckCommand(check, "", stubCtx(cwd)); const findings = await readFile(findingsPath(cwd), "utf8"); expect(findings).toContain( "summary: 1 marker(s), 2 silent stub(s), 1 loud stub(s)", ); expect(findings).toContain("new: 4"); // first run: everything is new expect(findings).toContain("## TODO markers"); expect(findings).toContain("## Silent stubs (actionable)"); expect(findings).toContain("placeholder-return"); expect(findings).toContain("Not implemented"); // Scan-only: no changes.md, sources untouched. expect(existsSync(changesPath(cwd))).toBe(false); const untouched = await readFile(join(cwd, "stubs.ts"), "utf8"); expect(untouched).toContain("return 0;"); // Run state records the scan summary as findings text. const state = await loadRunState(cwd); expect(state?.checks["todos"]?.status).toBe("complete"); expect(state?.checks["todos"]?.findings).toContain( "todos: 2 silent stub(s), 1 loud stub(s), 1 marker(s)", ); }); it("--fix converts silent stubs to loud throws, preserves markers and loud stubs, records changes.md", async () => { await seedStubs(cwd); const check = getCheck("todos")!; await handleCheckCommand(check, "--fix", stubCtx(cwd)); const cleaned = await readFile(join(cwd, "stubs.ts"), "utf8"); // Silent stubs now throw loudly, naming the function. expect(cleaned).toContain('throw new Error("todos: getPrice() is a stub")'); expect(cleaned).toContain('throw new Error("todos: notify() is a stub")'); // Markers are NEVER deleted and loud stubs are NEVER touched. expect(cleaned).toContain("// TODO: add pagination"); expect(cleaned).toContain('throw new Error("Not implemented")'); expect(cleaned).toContain("Cleaned by pygienium-todos"); // The placeholder bodies are gone. expect(cleaned).not.toContain("return 0;"); expect(cleaned).not.toContain("notify(): void {}"); const changes = await readFile(changesPath(cwd), "utf8"); expect(changes).toContain( "summary: 2 silent stub(s) converted to loud, 0 kept", ); expect(changes).toContain("getPrice()"); expect(changes).toContain("notify()"); const state = await loadRunState(cwd); expect(state?.checks["todos"]?.fix).toBe(true); expect(state?.checks["todos"]?.status).toBe("complete"); }); it("findings.md and changes.md live under .pygienium/checks/todos/", async () => { await seedStubs(cwd); const check = getCheck("todos")!; await handleCheckCommand(check, "--fix", stubCtx(cwd)); expect(findingsPath(cwd)).toBe( join(cwd, ".pygienium", "checks", "todos", "findings.md"), ); expect(changesPath(cwd)).toBe( join(cwd, ".pygienium", "checks", "todos", "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("todos")!; await handleCheckCommand(check, "", stubCtx(cwd)); const state = await loadRunState(cwd); expect(state?.checks["todos"]?.status).toBe("skipped"); }); it("reports a new/resolved delta against the previous run's counts", async () => { await seedStubs(cwd); const check = getCheck("todos")!; await handleCheckCommand(check, "", stubCtx(cwd)); // The previous run's verified counts are parseable from run-state. const prior = await todosPriorCounts(cwd); expect(prior).toEqual({ silent: 2, loud: 1, marker: 1 }); // Nobody flagged anything new and everything was resolved: the next scan // sees zero candidates → new: 0, resolved: 4 (all prior items). await rm(join(cwd, "stubs.ts")); const task = await buildTodosScanTask(cwd, { cwd, target: cwd, fix: false, rest: [], }); expect(task).toContain( "summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s)", ); expect(task).toContain("| new: 0 | resolved: 4 |"); }); it("truncates giant single-line candidates so the task prompt stays bounded", async () => { // A minified/generated single line can be hundreds of KB; embedding it // wholesale ballooned the freno-dev task to 2.5 MB and choked the // analysis agent. The task must carry a truncated prefix, never the // full line. const long = `// TODO: ${"x".repeat(400)}`; await writeFile( join(cwd, "huge.ts"), `${long}\nexport function f() { return 0; }\n`, "utf8", ); const task = await buildTodosScanTask(cwd, { cwd, target: cwd, fix: false, rest: [], }); expect(task).not.toContain("x".repeat(400)); expect(task).toContain("…"); }); });