338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
/**
|
|
* 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,
|
|
check as todosCheck,
|
|
detectTodoStubs,
|
|
todosPriorCounts,
|
|
buildTodosScanTask,
|
|
} from "../src/checks/todos.js";
|
|
|
|
function stubCtx(cwd: string): PygieniumCtx {
|
|
return { cwd, 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<void> {
|
|
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<void> {`,
|
|
` 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<void> {
|
|
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<void> {`,
|
|
` 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<u32, String> {\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);
|
|
});
|
|
});
|
|
|
|
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 |");
|
|
});
|
|
});
|