/** * checks/dead-code.ts — dead code and obsolete paths check. * * Finds unreferenced exports, files with zero importers, obsolete compatibility * shims / migration helpers, and unused dependencies — then removes the * clearly-dead items (engineering rule: no backward-compat layers) while * preserving items that are only reachable through dynamic imports or runtime * registration, which are listed for human review instead (pi-lens * "suspected dead weight" semantics: zero/single-importer files get a review * flag, not an unconditional delete). * * The check is hybrid by design: `buildScanTask`/`buildFixTask` run a * deterministic pre-scan (import-graph + heuristics) and materialise * `/.pygienium/checks/dead-code/`, * then hand the sub-agent a prompt to verify/refine the pre-computed report. * That keeps the E2E behaviour reproducible (and testable without a model) * while the sub-agent's language-level judgment stays authoritative for the * ambiguous cases. * * @module pygienium/checks/dead-code */ import { readFile, readdir, stat, mkdir, writeFile, rm, } from "node:fs/promises"; import { basename, dirname, join, relative, resolve } from "node:path"; import type { CheckDefinition, CheckScope } from "./registry.js"; import { isScopeSource, SCOPE_EXCLUDE_DIRS, scopeRulesMarkdown, } from "./scope.js"; /** Dead-code categories recognised by the rubric. */ export type DeadCategory = "export" | "file" | "shim" | "dep"; /** Removal target of a dead-code item: the whole file or one symbol. */ export type RemovalTarget = "file" | "symbol"; /** One dead-code finding. */ export interface DeadCodeItem { category: DeadCategory; /** Absolute path of the offending file. */ path: string; /** Path relative to the scanned target (for reports). */ rel: string; /** Symbol name when the item is an export/shim symbol. */ name?: string; /** 1-based line of the declaration, when known. */ line?: number; /** Whether to remove the whole file or just the symbol. */ target: RemovalTarget; /** * `true` when removal is ambiguous — dynamically imported, runtime * registered, an entry point, or a still-referenced compat shim. Review * items are preserved by `--fix` and surfaced for human judgement. */ review: boolean; /** Human-readable justification. */ reason: string; } /** Full report of one dead-code scan. */ export interface DeadCodeReport { /** Absolute scanned target. */ target: string; /** ISO timestamp of the scan. */ scannedAt: string; /** All candidates, unsorted across categories. */ items: DeadCodeItem[]; } /** Categories in report display order. */ export const CATEGORY_LABELS: Record = { export: "Unused exports", file: "Dead files (zero importers)", shim: "Obsolete shims / migration helpers", dep: "Unused dependencies", }; /** Extensions tried when resolving a bare import specifier. */ const RESOLVE_EXTENSIONS = [ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ]; /** Filename signals of a compat/deprecated/migration shim. */ const SHIM_NAME_RE = /(?:compat|legacy|deprecated|obsolete|obsoleted|migration|migrate|backcompat|back-compat|fallback|shim)/i; /** Inline signals of an actually-obsolete/deprecated declaration. Tagged * markers only — bare prose words ("deprecated", "obsolete", "legacy", * "compat") are deliberately excluded so generic comments cannot mislabel an * ordinary or entry-like file as a compat shim and auto-delete it on `--fix`. */ const SHIM_TAG_RE = /@deprecated|@obsolete|@deprecat(?:ed|ion)?|migration helper|\bback(?:wards?-)?compat\b/i; /** Entry-ish basenames — zero-importer files that are likely app entries. */ const ENTRY_NAME_RE = /^(?:index|main|cli|app|server|entry|bin|start)\./i; /** Strip a leading scope (`@scope/name`) so deps match `name` and `name/sub`. */ function depStem(spec: string): string { return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : (spec.split("/")[0] ?? spec); } /** Recursively collect source files under `root` (or `[root]` when it is a file). */ async function walkSourceFiles(root: string): Promise { const st = await stat(root).catch(() => undefined); if (!st) return []; if (st.isFile()) return isScopeSource(root) ? [root] : []; const out: string[] = []; const stack = [root]; while (stack.length > 0) { const dir = stack.pop() as string; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (SCOPE_EXCLUDE_DIRS.has(entry.name)) continue; stack.push(full); } else if (entry.isFile() && isScopeSource(entry.name)) { out.push(full); } } } return out.sort(); } /** Resolve an import specifier to an absolute project file, or undefined. */ async function resolveImport( fromFile: string, spec: string, ): Promise { if (!spec.startsWith(".") && !spec.startsWith("/")) return undefined; const base = resolve(dirname(fromFile), spec); const candidates = [base]; for (const e of RESOLVE_EXTENSIONS) { candidates.push(base + e); candidates.push(join(base, `index${e}`)); } for (const candidate of candidates) { const st = await stat(candidate).catch(() => undefined); if (st?.isFile()) return candidate; } return undefined; } interface FileInfo { path: string; rel: string; text: string; /** Files that import this one via a static `import`/`require` statement. */ staticImporters: Set; /** Files that reference this one via `import(...)` / runtime registration. */ dynamicImporters: Set; } /** One exported symbol: the bound name and whether it comes from an `export {}` list. */ interface DeclaredExport { name: string; line: number; /** `true` when exported via `export { … }` (a list), not a direct declaration. */ fromList: boolean; } /** Extract declared export names with their 1-based declaration lines. */ function declaredExports(text: string): DeclaredExport[] { const out: DeclaredExport[] = []; const re = /export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g; for (const m of text.matchAll(re)) { if (m[1]) out.push({ name: m[1] as string, line: lineOf(text, m.index ?? 0), fromList: false, }); } // `export { a, b as c }` named export lists (local re-exports). const reExport = /export\s*\{([^}]*)\}/g; for (const m of text.matchAll(reExport)) { const inner = m[1] ?? ""; for (const part of inner.split(",")) { const name = part .trim() .split(/\s+as\s+/)[0] ?.trim(); if (name && /^[A-Za-z_$][\w$]*$/.test(name)) { out.push({ name, line: lineOf(text, m.index ?? 0), fromList: true, }); } } } return out; } function lineOf(text: string, index: number): number { return text.slice(0, index).split("\n").length; } /** Does `text` look like a barrel (aggregating re-exports)? */ function isBarrel(text: string): boolean { return /export\s*\{|export\s+\*\s+from|export\s*\{[^}]*\}\s*from/.test(text); } /** Fetch the nearest package.json from `target` upward (within `cwd`). */ async function findPackageJson( target: string, cwd: string, ): Promise { const dir = stat(target) .then((s) => (s.isDirectory() ? target : dirname(target))) .catch(() => dirname(target)); const start = await dir; const stop = resolve(cwd); let current = start; // eslint-disable-next-line no-constant-condition while (true) { const candidate = join(current, "package.json"); if (await stat(candidate).catch(() => undefined)) return candidate; if (current === stop || dirname(current) === current) return undefined; current = dirname(current); } } /** * Deterministic dead-code scan: walks the target, builds a lightweight import * graph, and classifies candidates into `export` / `file` / `shim` / `dep`. * Ambiguity (dynamic imports, runtime registration, entry points, referenced * compat shims) is captured via the `review` flag rather than guessed away. */ export async function detectDeadCode(target: string): Promise { const files = await walkSourceFiles(target); const infos = new Map(); for (const path of files) { const text = await readFile(path, "utf8").catch(() => ""); infos.set(path, { path, rel: relative(target, path), text, staticImporters: new Set(), dynamicImporters: new Set(), }); } // --- Pass 1: resolve every import/require/re-export edge ----------------- const allSpecs = new Set(); // Paths that are the SOURCE of a `export { … } from` / `export * from` // re-export. Their whole export surface is reachable through the barrel even // when no other file names the symbols, so their exports must never be // auto-removed by a bare name search. const reexportTargets = new Set(); for (const info of infos.values()) { const text = info.text; // static: re `import x from '…'`, `import '…'`, `require('…')` const staticRe = /(?:^\s*import\s+(?:[^'"`]*?\s+from\s+)?|require\(\s*)(['"`])([^'"`]+)\1/gm; for (const m of text.matchAll(staticRe)) { const spec = m[2] as string; allSpecs.add(spec); const resolved = await resolveImport(info.path, spec); if (resolved) infos.get(resolved)?.staticImporters.add(info.path); } // barrel re-exports aggregate into a barrel graph edge: `export * from '…'` const barrelStarRe = /^\s*export\s+\*\s+from\s*(['"`])([^'"`]+)\1/gm; for (const m of text.matchAll(barrelStarRe)) { const spec = m[2] as string; allSpecs.add(spec); const resolved = await resolveImport(info.path, spec); if (resolved) { infos.get(resolved)?.staticImporters.add(info.path); reexportTargets.add(resolved); } } // `export { a, b } from '…'` const barrelNamedRe = /^\s*export\s*\{[^}]*\}\s*from\s*(['"`])([^'"`]+)\1/gm; for (const m of text.matchAll(barrelNamedRe)) { const spec = m[2] as string; allSpecs.add(spec); const resolved = await resolveImport(info.path, spec); if (resolved) { infos.get(resolved)?.staticImporters.add(info.path); reexportTargets.add(resolved); } } // dynamic: `import("…")` const dynamicRe = /import\(\s*['"`]([^'"`]+)['"`]\s*\)/g; for (const m of text.matchAll(dynamicRe)) { const spec = m[1] as string; allSpecs.add(spec); const resolved = await resolveImport(info.path, spec); if (resolved) infos.get(resolved)?.dynamicImporters.add(info.path); } } // --- Pass 2: classify candidates ---------------------------------------- const items: DeadCodeItem[] = []; // Paths whose whole file is a candidate (dead file, dynamic module, or // compat shim) — their exports are handled at file level, never individually. const wholeFileTarget = new Set(); const packageJson = await findPackageJson(target, resolve(target)); const packageJsonText = packageJson ? await readFile(packageJson, "utf8").catch(() => undefined) : undefined; let packageJsonDeps: Record | undefined; if (packageJsonText) { try { const parsed = JSON.parse(packageJsonText) as { dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; scripts?: Record; }; packageJsonDeps = { ...(parsed.dependencies ?? {}), ...(parsed.devDependencies ?? {}), ...(parsed.peerDependencies ?? {}), ...(parsed.optionalDependencies ?? {}), }; const referencedNames = new Set(); for (const spec of allSpecs) referencedNames.add(depStem(spec)); for (const name of Object.keys(packageJsonDeps)) { const stem = depStem(name); const inScripts = Object.values(parsed.scripts ?? {}).some((s) => s.includes(stem), ); if (!referencedNames.has(stem) && !inScripts) { items.push({ category: "dep", path: packageJson as string, rel: relative(target, packageJson as string), name, target: "symbol", review: false, reason: `"${name}" is declared in package.json but never imported or required by any source file.`, }); } } } catch { /* unparseable package.json — skip dep analysis */ } } const packageJsonMain = packageJsonText && packageJson ? (() => { try { const p = JSON.parse(packageJsonText) as { main?: string; bin?: string | Record; }; return [p.main, ...Object.values(p.bin ?? {})] .filter((v): v is string => typeof v === "string") .map((v) => resolve(dirname(packageJson), v)); } catch { return [] as string[]; } })() : ([] as string[]); for (const info of infos.values()) { const staticImports = info.staticImporters.size; const dynamicImports = info.dynamicImporters.size; const filename = basename(info.path); const shimNamed = SHIM_NAME_RE.test(filename); const shimText = SHIM_TAG_RE.test(info.text); const isShim = shimNamed || shimText; const entryLike = ENTRY_NAME_RE.test(filename) || packageJsonMain.includes(info.path) || /\.(?:config|test|spec|setup|env)\./i.test(filename) || /^test(?:s)?[\\/]/i.test(info.rel); if (isShim) { // Auto-remove ONLY when the signal is trustworthy (filename match or // a tagged marker), the file has no importers, and it is NOT an entry // point / test / config file. Entry-like files and any shim that is // still imported are deferred to manual review — never auto-deleted, // so prose or naming alone can never delete a live entry point. const autoRemovable = (shimNamed || shimText) && staticImports === 0 && dynamicImports === 0 && !entryLike; items.push({ category: "shim", path: info.path, rel: info.rel, target: "file", review: !autoRemovable, reason: shimNamed ? `"${filename}" matches a compat/legacy/migration shim naming pattern.` : `"${info.rel}" carries a deprecated/obsolete tag (manual review).`, }); // Whole-file handling subsumes any symbol-level export cleanup. wholeFileTarget.add(info.path); continue; } if (staticImports === 0 && dynamicImports === 0 && !entryLike) { // Zero importers, zero dynamic references, not an entry point. items.push({ category: "file", path: info.path, rel: info.rel, target: "file", review: false, reason: "No file statically or dynamically imports this module.", }); wholeFileTarget.add(info.path); continue; } if (staticImports === 0 && dynamicImports > 0) { // Only reachable through dynamic import / runtime registration. items.push({ category: "file", path: info.path, rel: info.rel, target: "file", review: true, reason: "Only referenced via dynamic import / runtime registration — removal is a judgement call.", }); // Preserve whole file; never pick at its exports (runtime-registered). wholeFileTarget.add(info.path); continue; } // Unused exported symbols — only for files not slated for whole-file // treatment (dead files, dynamic/runtime modules, compat shims). if (wholeFileTarget.has(info.path)) continue; // Exports of a barrel SOURCE (re-exported by `export * from` / // `export {…} from`) are reachable by name through the barrel even when // no other file spells the symbol out — the review flag gates deletion. const barrel = isBarrel(info.text); const reexported = reexportTargets.has(info.path) || barrel || /^\s*export\s+\*\s+from/.test(info.text); const exports = declaredExports(info.text); for (const exp of exports) { const externalRefs = [...infos.values()].some( (other) => other.path !== info.path && new RegExp(`\\b${escapeRegExp(exp.name)}\\b`).test(other.text), ); if (externalRefs) continue; // Anything reachable through a barrel or exported via an `export {}` // list stays behind a review flag: a bare name search cannot prove it // is dead, so the deterministic fixer must not auto-remove it. const needsReview = reexported || exp.fromList; items.push({ category: "export", path: info.path, rel: info.rel, name: exp.name, line: exp.line, target: "symbol", review: needsReview || externalRefs || (staticImports > 0 && dynamicImports > 0), reason: needsReview ? exp.fromList ? `"${exp.name}" is exported via an export list (${info.rel}) — removal is a judgement call.` : `"${exp.name}" is reachable through a barrel/reexport (${info.rel}) and referenced nowhere by name — confirm before removing.` : `Exported symbol "${exp.name}" is never imported or referenced by any other source file.`, }); } } return { target, scannedAt: new Date().toISOString(), items }; } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // --------------------------------------------------------------------------- // Report renderers // --------------------------------------------------------------------------- /** Render `findings.md` for the report, grouped by category with review flags. */ export function renderFindingsMd(report: DeadCodeReport): string { const lines: string[] = []; lines.push("# Dead code findings"); lines.push(""); lines.push(`- Target: \`${report.target}\``); lines.push(`- Scanned: ${report.scannedAt}`); lines.push(`- Items: ${report.items.length}`); lines.push(""); for (const category of Object.keys(CATEGORY_LABELS) as DeadCategory[]) { const items = report.items.filter((i) => i.category === category); if (items.length === 0) continue; lines.push(`## ${CATEGORY_LABELS[category]}`); lines.push(""); for (const item of items) { const loc = item.name ? `${item.rel}:${item.line ?? "?"} — ${item.name}` : `${item.rel}`; const flag = item.review ? "review" : "auto"; lines.push(`- [${flag}] ${loc} — ${item.reason}`); } lines.push(""); } return lines.join("\n").trimEnd() + "\n"; } /** Render `changes.md` for applied edits + preserved review items. */ export function renderChangesMd( applied: DeadCodeItem[], preserved: DeadCodeItem[], ): string { const lines: string[] = []; lines.push("# Dead code changes"); lines.push(""); lines.push(`- Applied: ${applied.length}`); lines.push(`- Preserved for review: ${preserved.length}`); lines.push(""); lines.push("## Removed (auto)"); lines.push(""); if (applied.length === 0) lines.push("- none"); for (const item of applied) { if (item.category === "dep") { lines.push(`- dep: ${item.rel} — remove "${item.name}" (auto)`); } else if (item.target === "file") { lines.push(`- file: ${item.rel} — deleted (auto)`); } else { lines.push( `- export: ${item.rel}:${item.line ?? "?"} — ${item.name} — removed (auto)`, ); } } lines.push(""); lines.push("## Preserved for review (manual)"); lines.push(""); if (preserved.length === 0) lines.push("- none"); for (const item of preserved) { const kind = item.category === "dep" ? "dep" : item.target === "file" ? "file" : "export"; const loc = item.name ? `${item.rel}:${item.line ?? "?"} — ${item.name}` : item.rel; lines.push(`- ${kind}: ${loc} — ${item.reason}`); } return lines.join("\n").trimEnd() + "\n"; } // --------------------------------------------------------------------------- // Fix application // --------------------------------------------------------------------------- /** * Compute the fix plan for a report: the clearly-dead items to remove and the * review items to preserve. Review items are never touched by `--fix`. */ export function planFixes(report: DeadCodeReport): { remove: DeadCodeItem[]; preserve: DeadCodeItem[]; } { const remove: DeadCodeItem[] = []; const preserve: DeadCodeItem[] = []; for (const item of report.items) { if (item.review) preserve.push(item); else remove.push(item); } return { remove, preserve }; } /** * Remove one exported symbol declaration from JS/TS source, returning the * new text. Handles direct declarations (`export function/class/const/let/var * name …`), named export-list members (`export { name }`); optionally followed * by `from "…"`), and single- or multi-statement bodies (arrow functions with * block bodies, object literals, inline-closing braces). * * Uses a brace/token-aware scanner rather than a regex so a declaration is * removed whole instead of being truncated at the first `;` or bailing on an * unusual closing layout. A final delimiter-balance guard refuses to write a * corrupt file: if the edit would unbalance the source, the original text is * returned unchanged and the caller must not claim a removal. */ function removeExportSymbol(text: string, name: string): string { const esc = escapeRegExp(name); // 1. Direct declaration: `export (async) (function|class|const|let|var) name`. const direct = new RegExp( `export\\s+(?:async\\s+)?(enum|interface|type|function|class|const|let|var)\\s+${esc}\\b`, ); const dm = direct.exec(text); if (dm) { const keyword = dm[1] as string; const bodyStart = dm.index + dm[0].length; const funcLike = keyword === "function" || keyword === "class"; const end = scanDeclarationEnd(text, bodyStart, funcLike); if (end === -1) return text; const next = spliceStatement(text, dm.index, end); return delimitersBalanced(next) ? next : text; } // 2. Export-list member: `export { a, name as b, c }` (optionally `from …`). return removeFromExportList(text, name); } /** Index just past the end of a declaration body, or -1 when unbalanced. */ function scanDeclarationEnd( text: string, start: number, funcLike: boolean, ): number { let i = start; let mode: "line" | "block" | "regex" | '"' | "'" | "`" | null = null; let paren = 0; let bracket = 0; let brace = 0; while (i < text.length) { const c = text[i]; const n = text[i + 1]; if (mode === "line") { if (c === "\n") mode = null; i++; continue; } if (mode === "block") { if (c === "*" && n === "/") { mode = null; i += 2; } else { i++; } continue; } if (mode === '"' || mode === "'" || mode === "`") { if (c === "\\") { i += 2; } else { if (c === mode) mode = null; i++; } continue; } if (mode === "regex") { if (c === "\\") { i += 2; } else { if (c === "/") mode = null; i++; } continue; } if (c === "/" && n === "/") { mode = "line"; i += 2; continue; } if (c === "/" && n === "*") { mode = "block"; i += 2; continue; } if (c === '"' || c === "'" || c === "`") { mode = c; i++; continue; } if (c === "/" && looksLikeRegexStart(text, i)) { mode = "regex"; i++; continue; } if (c === "(") paren++; else if (c === ")") { if (paren > 0) paren--; } else if (c === "[") bracket++; else if (c === "]") { if (bracket > 0) bracket--; } else if (c === "{") brace++; else if (c === "}") { if (brace > 0) brace--; if (paren === 0 && bracket === 0 && brace === 0) { if (funcLike) return i + 1; // Statement end at a closing brace (object literal / arrow body): // fold in an immediately-following `;` if present. let j = i + 1; while (j < text.length && /\s/.test(text[j]) && text[j] !== "\n") { j++; } return text[j] === ";" ? j + 1 : i + 1; } } else if ( !funcLike && c === ";" && paren === 0 && bracket === 0 && brace === 0 ) { return i + 1; } i++; } return -1; } /** Recognize a regex literal start at `/` (division operators are not). */ function looksLikeRegexStart(text: string, i: number): boolean { let j = i - 1; while (j >= 0 && /\s/.test(text[j])) j--; if (j < 0) return true; return !/[A-Za-z0-9_)]}\\`'"`]/.test(text[j]); } /** Remove the first `[start, end)` slice, collapsing the vacated line. */ function spliceStatement(text: string, start: number, end: number): string { return text.slice(0, start) + text.slice(end).replace(/^\s*\n+/, ""); } /** Remove `name` from an `export { … }` / `export { … } from "…"` list. */ function removeFromExportList(text: string, name: string): string { const listRe = /export\s*\{/g; let m: RegExpExecArray | null; while ((m = listRe.exec(text))) { const openIdx = (m.index ?? 0) + m[0].length - 1; const closeIdx = findClosingBrace(text, openIdx); if (closeIdx === -1) continue; const inner = text.slice(openIdx + 1, closeIdx); const parts = inner.split(",").map((p) => p.trim()); const remaining = parts.filter((p) => localName(p) !== name); if (remaining.length === parts.length) continue; // name not in this list if (remaining.length === 0) { // The whole statement becomes empty — drop it (including `from …`). const end = statementTailEnd(text, closeIdx); const next = spliceStatement(text, m.index ?? 0, end); return delimitersBalanced(next) ? next : text; } const rebuilt = remaining.map((p) => ` ${p}`).join(","); const next = text.slice(0, openIdx) + "{" + rebuilt + " }" + text.slice(closeIdx + 1); return delimitersBalanced(next) ? next : text; } return text; } /** The local binding name left of `as` in an export-list member. */ function localName(part: string): string { return part.split(/\s+as\s+/)[0]?.trim() ?? part.trim(); } /** Index one past the `}` that matches `{` at `openIdx`, or -1. */ function findClosingBrace(text: string, openIdx: number): number { let depth = 0; for (let i = openIdx; i < text.length; i++) { if (text[i] === "{") depth++; else if (text[i] === "}") { depth--; if (depth === 0) return i; } } return -1; } /** End index of an emptied export statement: past `}`, optional `from '…'`, `;`. */ function statementTailEnd(text: string, closeIdx: number): number { const i = closeIdx + 1; const from = /^\s*from\s*(['"`])((?:[^'"`;])*)\1\s*;?/.exec(text.slice(i)); if (from) return i + from[0].length; if (text[i] === ";") return i + 1; return i; } /** Whether `(), [], {}` are balanced (strings/templates/comments skipped). */ function delimitersBalanced(text: string): boolean { let paren = 0; let bracket = 0; let brace = 0; let mode: "line" | "block" | "regex" | '"' | "'" | "`" | null = null; let i = 0; while (i < text.length) { const c = text[i]; const n = text[i + 1]; if (mode === "line") { if (c === "\n") mode = null; i++; continue; } if (mode === "block") { if (c === "*" && n === "/") { mode = null; i += 2; } else { i++; } continue; } if (mode === '"' || mode === "'" || mode === "`") { if (c === "\\") { i += 2; } else { if (c === mode) mode = null; i++; } continue; } if (mode === "regex") { if (c === "\\") { i += 2; } else { if (c === "/") mode = null; i++; } continue; } if (c === "/" && n === "/") { mode = "line"; i += 2; continue; } if (c === "/" && n === "*") { mode = "block"; i += 2; continue; } if (c === '"' || c === "'" || c === "`") { mode = c; i++; continue; } if (c === "/" && looksLikeRegexStart(text, i)) { mode = "regex"; i++; continue; } if (c === "(") paren++; else if (c === ")") paren--; else if (c === "[") bracket++; else if (c === "]") bracket--; else if (c === "{") brace++; else if (c === "}") brace--; i++; } return paren === 0 && bracket === 0 && brace === 0; } /** * Apply the deterministic fixes for a report. Returns the items actually * removed and the preserved review items. `dryRun` returns the plan without * touching the filesystem. */ export async function applyDeadCodeFixes( report: DeadCodeReport, dryRun = false, ): Promise<{ applied: DeadCodeItem[]; preserved: DeadCodeItem[] }> { const { remove, preserve } = planFixes(report); const applied: DeadCodeItem[] = []; for (const item of remove) { // Review-flag semantics gate deletions at the mutation site too: an item // marked for review is never deleted, even if it slipped into `remove` // (e.g. a hand-built report or a classifier that flipped a flag). if (item.review) { preserve.push(item); continue; } if (item.category === "dep") { // Rewrite package.json minus the unused dependency. try { const raw = await readFile(item.path, "utf8"); const parsed = JSON.parse(raw) as Record; let touched = false; for (const key of [ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", ]) { const deps = parsed[key] as Record | undefined; if (deps && item.name && deps[item.name] !== undefined) { delete deps[item.name]; touched = true; } } if (touched) { if (!dryRun) await writeFile( item.path, JSON.stringify(parsed, null, 2) + "\n", "utf8", ); applied.push(item); } } catch { /* leave unparseable manifests alone */ } continue; } if (item.target === "file") { if (!dryRun) await rm(item.path, { force: true }); applied.push(item); continue; } // Symbol-level removal (unused export / inline shim). try { const text = await readFile(item.path, "utf8"); const next = removeExportSymbol(text, item.name ?? ""); if (next !== text) { if (!dryRun) await writeFile(item.path, next, "utf8"); applied.push(item); } } catch { /* unreadable file — leave it alone */ } } return { applied, preserved: preserve }; } // --------------------------------------------------------------------------- // Artifact paths // --------------------------------------------------------------------------- const CHECK_DIRNAME = ".pygienium/checks/dead-code"; /** `/.pygienium/checks/dead-code/findings.md` */ export function findingsPath(target: string): string { return join(target, CHECK_DIRNAME, "findings.md"); } /** `/.pygienium/checks/dead-code/changes.md` */ export function changesPath(target: string): string { return join(target, CHECK_DIRNAME, "changes.md"); } /** Write findings.md, returning its absolute path. */ export async function writeFindingsFile( target: string, report: DeadCodeReport, ): Promise { const path = findingsPath(target); await mkdir(dirname(path), { recursive: true }); await writeFile(path, renderFindingsMd(report), "utf8"); return path; } /** Write changes.md, returning its absolute path. */ export async function writeChangesFile( target: string, applied: DeadCodeItem[], preserved: DeadCodeItem[], ): Promise { const path = changesPath(target); await mkdir(dirname(path), { recursive: true }); await writeFile(path, renderChangesMd(applied, preserved), "utf8"); return path; } // --------------------------------------------------------------------------- // Task builders (sub-agent prompts) // --------------------------------------------------------------------------- /** * Scan task: pre-compute the candidate report, write `findings.md`, then ask * the sub-agent to verify each candidate via grep/import-graph and emit the * findings block. The pre-computed report keeps the agent's job to * confirm/refute rather than re-derive the import graph from scratch. */ export async function buildDeadCodeScanTask( cwd: string, scope: CheckScope, ): Promise { const report = await detectDeadCode(scope.target); const path = await writeFindingsFile(scope.target, report); return [ `# Dead-code scan: ${scope.target}`, ``, `A deterministic import-graph pre-scan already ran. Its full, categorized report is`, `written to \`${path}\`. Verify each candidate with \`grep\`/import-graph reasoning and`, `correct any false positives before reporting.`, ``, `## Rubric — categorize findings by type`, ``, `1. **export** — exported functions/classes/consts referenced by no other module.`, `2. **file** — files with zero importers (cross-check the pre-scan graph). Files only`, ` reachable via \`import(...)\` or runtime registration are NOT dead: mark them`, ` \`review\` and keep them in the findings under the review flag.`, `3. **shim** — obsolete compatibility wrappers, deprecated aliases, migration`, ` helpers (engineering rule: remove obsolete paths — do not keep compat layers).`, `4. **dep** — dependencies declared in package.json but never imported/required.`, ``, `Items that are clearly dead (no importers, no dynamic reference, not an entry)`, `are flagged \`auto\`. Items whose removal is ambiguous — dynamic imports, runtime`, `registration (plugin manifests, lazy routes, decorators), entry points, or`, `still-referenced compat shims — must be flagged \`review\` and never auto-removed.`, ``, `Write your verified findings back to \`${path}\` (same format), then end your`, `response with a fenced \`findings\` block:`, ``, "```findings", `dead-code: issue(s)`, `1. [severity: high|med|low] : (auto|review)`, "```", ``, `Target root: ${scope.target}. Work dir: ${cwd}.`, ``, scopeRulesMarkdown(), ].join("\n"); } /** * Fix task: deterministically remove the clearly-dead items, write * `changes.md`, then hand the sub-agent the remaining verification work. * Review-flagged items (dynamic imports, runtime registration) are preserved * and reported as `manual` — the sub-agent must NOT remove them. */ export async function buildDeadCodeFixTask( cwd: string, scope: CheckScope, findings: string, ): Promise { const report = await detectDeadCode(scope.target); const { applied, preserved } = await applyDeadCodeFixes(report); const path = await writeChangesFile(scope.target, applied, preserved); return [ `# Dead-code fix: ${scope.target}`, ``, `A deterministic fixer already ran against the pre-scan graph. By design it ONLY`, `removed \`review:false\` (\`auto\`) items — unused exports, zero-importer files, obsolete`, `shims, unused deps — and never touched \`review\` items. The change log is at`, `\`${path}\`. Review it; then restore any auto-removal that looks like a false`, `positive (the scan agent's classifier can be wrong) and re-list it as \`manual\`,`, `and complete anything the pre-scan missed (use \`edit\` for symbol-level`, `removals, \`rm\` via bash for dead files).`, ``, `## Hard rules`, ``, `- REMOVE clearly-dead items: unused exports, zero-importer files, obsolete`, ` compat/migration shims, unused dependencies. Engineering rule: no compat layers.`, `- NEVER remove \`review\`-flagged items: dynamically imported modules, runtime-`, ` registered plugins/lazy routes, entry points, barrel re-export sources, or`, ` export-list symbols. They stay — list them as manual.`, `- If a listed auto-removal is wrong, revert it and record it as \`manual\` in`, ` \`${path}\` rather than letting it stand.`, `- Do not touch public API that is still imported; report it as \`manual\` instead.`, ``, `## Scanner findings`, ``, "```", findings, "```", ``, `Update \`${path}\` to reflect any additional removals you performed, then end your`, `response with a fenced \`changes\` block:`, ``, "```changes", `1. : (auto)`, `2. — skipped: (manual)`, "```", ``, `Target root: ${scope.target}. Work dir: ${cwd}.`, ].join("\n"); } // --------------------------------------------------------------------------- // Gate + registration // --------------------------------------------------------------------------- /** Skip when the target holds no source files worth scanning. */ export async function deadCodeGate(cwd: string): Promise { const files = await walkSourceFiles(cwd); if (files.length === 0) { return "no source files found under target"; } return undefined; } /** * Verify hook: confirms the check actually produced its artifacts (mirrors * {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` * must exist; after `--fix` `changes.md` must exist too. Catches a sub-agent * that returns ok with no output — which would otherwise be a false * `complete`. */ async function deadCodeVerify(scope: CheckScope): Promise { const f = findingsPath(scope.target); try { await stat(f); } catch { return `dead-code verify: expected findings.md at ${f} after scan, none found.`; } if (scope.fix) { const c = changesPath(scope.target); try { await stat(c); } catch { return `dead-code verify: expected changes.md at ${c} after --fix, none found.`; } } return undefined; } /** The registered `CheckDefinition` (module self-registers on import). */ export const check: CheckDefinition = { name: "dead-code", label: "Dead code", description: "Find unreferenced exports, dead files, obsolete compat shims, migration helpers, and unused dependencies; remove clearly-dead items and flag dynamic/runtime ones for review.", agentName: "scanner", fixAgentName: "fixer", phaseId: "scan", buildScanTask: buildDeadCodeScanTask, buildFixTask: buildDeadCodeFixTask, gate: deadCodeGate, verify: deadCodeVerify, }; // No self-registration here: `index.ts` auto-discovers every `checks/*.ts` // that exports `check` and registers it.