/** * dead-code.test.ts — integration tests for the dead-code check. * * Covers: * - registry/help: registering the check auto-binds `/pygienium-dead-code` * (zero index.ts wiring changes). * - deterministic detection: unused export, dead file, obsolete compat shim, * and unused dependency are classified; a dynamically-imported module is * classified `review`. * - E2E `--fix`: clearly-dead items are removed and listed in changes.md; * the dynamically-imported module is preserved and flagged for review. */ import { describe, expect, it, beforeEach, afterEach } from "bun:test"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearChecks, getAllChecks, registerCheck, getCheck, } from "../src/checks/registry.js"; import { check as deadCodeCheck, detectDeadCode, findingsPath, changesPath, renderFindingsMd, applyDeadCodeFixes, } from "../src/checks/dead-code.js"; import { buildPygieniumHelpLines } from "../src/help.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"; function stubCtx(cwd: string): PygieniumCtx { return { cwd, hasUI: false, ui: undefined } as PygieniumCtx; } async function writeFixture(root: string): Promise { await mkdir(join(root, "src", "routes"), { recursive: true }); // package.json — carries an unused dependency + an entry `main`. await writeFile( join(root, "package.json"), JSON.stringify( { name: "fixture", version: "1.0.0", main: "src/index.ts", dependencies: { leftoverpkg: "^1.0.0", typescript: "^5.0.0", }, devDependencies: {}, }, null, 2, ) + "\n", ); // util.ts — one used export and one unused export. await writeFile( join(root, "src", "util.ts"), [ "export function add(a: number, b: number): number {", " return a + b;", "}", "", "export function unusedHelper(): string {", ' return "never called anywhere";', "}", "", ].join("\n"), ); // index.ts — the entry (reaches util and routes), its name is entry-like // so the zero-importer rule must NOT flag it as dead. await writeFile( join(root, "src", "index.ts"), [ 'import { add } from "./util";', 'import { load } from "./routes";', "add(1, 2);", "load();", "", ].join("\n"), ); // routes/index.ts — live barrel that exposes a lazy loader. await writeFile( join(root, "src", "routes", "index.ts"), ['export const load = () => import("./lazy-route");', ""].join("\n"), ); // lazy-route.ts — a shim reachable ONLY through a dynamic import. It must // be classified `review` and never auto-removed. await writeFile( join(root, "src", "routes", "lazy-route.ts"), ["export function registerRoute(): void {", " return;", "}", ""].join( "\n", ), ); // compat.ts — an obsolete deprecated compat wrapper with zero importers. await writeFile( join(root, "src", "compat.ts"), [ "// @deprecated obsolete compatibility wrapper — scheduled for removal.", "export function legacyFormat(x: string): string {", " return x;", "}", "", ].join("\n"), ); // orphan.ts — a completely unreferenced module (dead file). await writeFile( join(root, "src", "orphan.ts"), ["export function orphan(): void {", " return;", "}", ""].join("\n"), ); } describe("dead-code registry + help", () => { beforeEach(async () => { clearChecks(); // Re-register after clearChecks (module-level registration runs on import). registerCheck(deadCodeCheck); }); it("registers a single dead-code check with a serialisable name", () => { expect(getCheck("dead-code")).toBeDefined(); expect(getAllChecks().filter((c) => c.name === "dead-code")).toHaveLength( 1, ); }); it("appears in /pygienium-help with its description (auto discovery)", () => { const help = buildPygieniumHelpLines(); expect(help.join("\n")).toContain("/pygienium-dead-code"); expect(help.join("\n")).toContain(deadCodeCheck.description); }); }); describe("dead-code detection", () => { it("classifies unused export, dead file, compat shim, unused dep; preserves dynamic import", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-")); try { await writeFixture(root); const report = await detectDeadCode(root); expect(report.items.length).toBeGreaterThan(0); const unusedExport = report.items.find( (i) => i.category === "export" && i.name === "unusedHelper", ); expect(unusedExport).toBeDefined(); expect(unusedExport?.review).toBe(false); expect(unusedExport?.target).toBe("symbol"); const deadShim = report.items.find( (i) => i.category === "shim" && i.rel === "src/compat.ts", ); expect(deadShim).toBeDefined(); expect(deadShim?.review).toBe(false); expect(deadShim?.target).toBe("file"); const deadFile = report.items.find( (i) => i.category === "file" && i.rel === "src/orphan.ts", ); expect(deadFile).toBeDefined(); expect(deadFile?.review).toBe(false); const unusedDep = report.items.find( (i) => i.category === "dep" && i.name === "leftoverpkg", ); expect(unusedDep).toBeDefined(); // dynamic-import shim → review, always preserved const dynamic = report.items.find( (i) => i.rel === "src/routes/lazy-route.ts", ); expect(dynamic).toBeDefined(); expect(dynamic?.review).toBe(true); expect(dynamic?.target).toBe("file"); // used export + entry file are NOT flagged. expect(report.items.find((i) => i.name === "add")).toBeUndefined(); expect( report.items.find((i) => i.rel === "src/index.ts"), ).toBeUndefined(); } finally { await rm(root, { recursive: true, force: true }); } }); it("renderFindingsMd groups findings under the four category headings", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-")); try { await writeFixture(root); const report = await detectDeadCode(root); const md = renderFindingsMd(report); expect(md).toContain("## Unused exports"); expect(md).toContain("## Dead files (zero importers)"); expect(md).toContain("## Obsolete shims / migration helpers"); expect(md).toContain("## Unused dependencies"); expect(md).toMatch(/\[review\] src\/routes\/lazy-route\.ts/); } finally { await rm(root, { recursive: true, force: true }); } }); }); describe("dead-code E2E (--fix)", () => { let cwd: string; beforeEach(async () => { clearChecks(); registerCheck(deadCodeCheck); setAgentRunner(fakeAgentRunner); cwd = await mkdtemp(join(tmpdir(), "pygienium-dead-e2e-")); await writeFixture(cwd); }); afterEach(async () => { resetAgentRunner(); await rm(cwd, { recursive: true, force: true }); }); it("removes clearly-dead items, preserves dynamically-imported shim, and writes findings.md + changes.md", async () => { // Make the pre-existing compat.ts scan-detected before the run to also // prove the deterministic scan picks it up regardless of run order. await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd)); // --- Clearly-dead items are removed -------------------------------- // Unused export: removed from util.ts, live export preserved. const util = await readFile(join(cwd, "src", "util.ts"), "utf8"); expect(util).not.toContain("unusedHelper"); expect(util).toContain("add"); // Obsolete compat shim: file deleted. expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(false); // Dead file: deleted. expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(false); // Unused dependency: removed from package.json. const pkg = JSON.parse( await readFile(join(cwd, "package.json"), "utf8"), ) as { dependencies: Record }; expect(pkg.dependencies.leftoverpkg).toBeUndefined(); // Dynamically-imported shim: preserved, untouched. expect(existsSync(join(cwd, "src", "routes", "lazy-route.ts"))).toBe(true); const lazy = await readFile( join(cwd, "src", "routes", "lazy-route.ts"), "utf8", ); expect(lazy).toContain("registerRoute"); // findings.md exists and is categorized. expect(existsSync(findingsPath(cwd))).toBe(true); const findingsMd = await readFile(findingsPath(cwd), "utf8"); expect(findingsMd).toContain("## Unused exports"); expect(findingsMd).toContain("## Dead files (zero importers)"); expect(findingsMd).toContain("## Obsolete shims / migration helpers"); expect(findingsMd).toContain("## Unused dependencies"); // changes.md lists the removals and the preserved review item. expect(existsSync(changesPath(cwd))).toBe(true); const changesMd = await readFile(changesPath(cwd), "utf8"); expect(changesMd).toContain("## Removed (auto)"); expect(changesMd).toContain("src/compat.ts"); expect(changesMd).toContain("unusedHelper"); expect(changesMd).toContain("leftoverpkg"); expect(changesMd).toContain("## Preserved for review (manual)"); expect(changesMd).toContain("lazy-route.ts"); // dynamic import preserved }); it("records the run in run-state and marks the check complete", async () => { await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd)); const state = await loadRunState(cwd); expect(state?.checks["dead-code"].status).toBe("complete"); expect(state?.checks["dead-code"].findings).toBeDefined(); expect(state?.checks["dead-code"].changes).toBeDefined(); }); it("runs a dry scan (no --fix) and does not write changes.md or touch files", async () => { await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd)); // Nothing removed without --fix. expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(true); expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(true); const utils = await readFile(join(cwd, "src", "util.ts"), "utf8"); expect(utils).toContain("unusedHelper"); // Findings recorded, but no changes yet. const state = await loadRunState(cwd); expect(state?.checks["dead-code"].status).toBe("complete"); expect(state?.checks["dead-code"].changes).toBeUndefined(); }); }); describe("dead-code shim auto-delete safety (entry points + prose)", () => { beforeEach(() => { clearChecks(); registerCheck(deadCodeCheck); setAgentRunner(fakeAgentRunner); }); afterEach(() => { resetAgentRunner(); }); it("never auto-deletes an entry point whose prose mentions 'legacy'", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-entry-")); try { await mkdir(join(root, "src"), { recursive: true }); await writeFile( join(root, "package.json"), JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n", ); // Entry point, zero importers, prose contains 'legacy' — must survive. await writeFile( join(root, "src", "index.ts"), [ "// handles legacy payloads", "export function main(): void {}", "", ].join("\n"), ); await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); expect(existsSync(join(root, "src", "index.ts"))).toBe(true); } finally { await rm(root, { recursive: true, force: true }); } }); it("never auto-deletes a *.test.ts whose description mentions 'deprecated'", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-test-")); try { await mkdir(join(root, "src"), { recursive: true }); await writeFile( join(root, "src", "foo.test.ts"), [ "import { describe, it } from 'bun:test';", "describe('app', () => {", " it('still supports the deprecated API', () => {});", "});", "", ].join("\n"), ); await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); expect(existsSync(join(root, "src", "foo.test.ts"))).toBe(true); } finally { await rm(root, { recursive: true, force: true }); } }); it("flags an entry-like file tagged @deprecated for review, not deletion", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-tag-")); try { await mkdir(join(root, "src"), { recursive: true }); await writeFile( join(root, "package.json"), JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n", ); await writeFile( join(root, "src", "index.ts"), ["/** @deprecated */", "export function main(): void {}", ""].join( "\n", ), ); const report = await detectDeadCode(root); const entry = report.items.find( (i) => i.rel === "src/index.ts" && i.category === "shim", ); expect(entry).toBeDefined(); expect(entry?.review).toBe(true); await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); expect(existsSync(join(root, "src", "index.ts"))).toBe(true); } finally { await rm(root, { recursive: true, force: true }); } }); }); describe("dead-code barrel re-export retention", () => { it("keeps modules reachable only through `export * from` / `export {…} from` barrels", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-")); try { // index.ts is entry-like; it aggregates barrelA, which aggregates // barrelB both by star and by name. Neither barrel may be treated as // a zero-importer dead file, and symbols only reachable through the // star re-export must stay behind a review flag. await mkdir(join(root, "src"), { recursive: true }); await writeFile( join(root, "src", "index.ts"), ['export * from "./barrelA";', ""].join("\n"), ); await writeFile( join(root, "src", "barrelA.ts"), [ 'export * from "./barrelB";', 'export { namedB } from "./barrelB";', "", ].join("\n"), ); await writeFile( join(root, "src", "barrelB.ts"), [ "export const value = 1;", "export const namedB = 2;", "export const starOnly = 3;", "", ].join("\n"), ); const report = await detectDeadCode(root); // Neither barrel is a dead-file candidate. expect( report.items.find( (i) => i.category === "file" && i.rel === "src/barrelA.ts", ), ).toBeUndefined(); expect( report.items.find( (i) => i.category === "file" && i.rel === "src/barrelB.ts", ), ).toBeUndefined(); // Symbols in the star/named re-export target stay `review` — the // deterministic fixer must not auto-delete them. const value = report.items.find( (i) => i.category === "export" && i.rel === "src/barrelB.ts" && i.name === "value", ); expect(value).toBeDefined(); expect(value?.review).toBe(true); const starOnly = report.items.find( (i) => i.category === "export" && i.rel === "src/barrelB.ts" && i.name === "starOnly", ); expect(starOnly).toBeDefined(); expect(starOnly?.review).toBe(true); // The named re-export is referenced (by barrelA) so it is not dead. expect( report.items.find( (i) => i.category === "export" && i.rel === "src/barrelB.ts" && i.name === "namedB", ), ).toBeUndefined(); } finally { await rm(root, { recursive: true, force: true }); } }); it("`--fix` never deletes a barrel-exported module", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-e2e-")); try { clearChecks(); registerCheck(deadCodeCheck); setAgentRunner(fakeAgentRunner); await mkdir(join(root, "src"), { recursive: true }); await writeFile( join(root, "src", "index.ts"), ['export * from "./barrelA";', ""].join("\n"), ); await writeFile( join(root, "src", "barrelA.ts"), [ 'export * from "./barrelB";', 'export { namedB } from "./barrelB";', "", ].join("\n"), ); await writeFile( join(root, "src", "barrelB.ts"), [ "export const value = 1;", "export const namedB = 2;", "export const starOnly = 3;", "", ].join("\n"), ); await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); expect(existsSync(join(root, "src", "barrelA.ts"))).toBe(true); expect(existsSync(join(root, "src", "barrelB.ts"))).toBe(true); expect(existsSync(join(root, "src", "index.ts"))).toBe(true); } finally { resetAgentRunner(); await rm(root, { recursive: true, force: true }); } }); }); describe("dead-code symbol removal (multi-statement bodies)", () => { it("removes arrow-block consts, object consts, and inline-closing functions whole", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-shapes-")); try { await mkdir(join(root, "src"), { recursive: true }); await writeFile( join(root, "src", "math.ts"), [ "export const build = () => {", " const a = 1;", " return a + 2;", "};", "export function packed() {", ' return "x"; }', 'export const config = { retries: 3, label: "cfg" };', "export function keep(): string {", ' return "keep";', "}", "export const keepVar = 9;", "", ].join("\n"), ); // Referenced exports keep math.ts alive and `keep`/`keepVar` used. await writeFile( join(root, "src", "app.ts"), [ 'import { keep, keepVar } from "./math";', "console.log(keep(), keepVar);", "", ].join("\n"), ); const report = await detectDeadCode(root); const names = report.items .filter((i) => i.category === "export" && i.rel === "src/math.ts") .map((i) => i.name); expect(names).toContain("build"); expect(names).toContain("packed"); expect(names).toContain("config"); expect(names).not.toContain("keep"); expect(names).not.toContain("keepVar"); const { applied } = await applyDeadCodeFixes(report); expect(applied.map((i) => i.name)).toEqual( expect.arrayContaining(["build", "packed", "config"]), ); const out = await readFile(join(root, "src", "math.ts"), "utf8"); expect(out).not.toContain("build"); expect(out).not.toContain("packed"); expect(out).not.toContain("config"); expect(out).toContain("keep"); expect(out).toContain("keepVar"); // No leftover arrow body from the removed declaration. expect(out).not.toContain("return a + 2"); } finally { await rm(root, { recursive: true, force: true }); } }); it("preserves a symbol it cannot safely remove instead of corrupting source", async () => { const root = await mkdtemp(join(tmpdir(), "pygienium-dead-guard-")); try { const file = join(root, "weird.ts"); await writeFile( file, [ 'export const rx = () => /[{;}]/.test("a;");', "export const keep = 1;", "", ].join("\n"), ); const original = await readFile(file, "utf8"); // Hand-built report forces an auto removal attempt on a shape the // scanner does not fully model (regex with braces/semicolons). const { applyDeadCodeFixes: apply } = await import( "../src/checks/dead-code.js" ); const report = { target: root, scannedAt: new Date().toISOString(), items: [ { category: "export" as const, path: file, rel: "weird.ts", name: "rx", line: 1, target: "symbol" as const, review: false, reason: "test", }, ], }; const { applied } = await apply(report); const after = await readFile(file, "utf8"); // Either the removal succeeded cleanly, or the file is untouched — // never a truncated/corrupt intermediate. if (applied.length === 0) { expect(after).toBe(original); } else { expect(after).not.toContain("rx"); expect(after).toContain("keep"); } } finally { await rm(root, { recursive: true, force: true }); } }); });