Files
pygienium/src/checks/dead-code.ts
Michael Freno c605a709fb feat(run): resume-aware per-check runs, verify hooks, run-state hardening
/pygienium-<check> is now resume-aware (terminal checks skipped unless
--fresh) and shares run-state with all/resume; every check gets a verify
hook that fails loudly when a sub-agent returns ok with no artifact;
run-state clears stale errors on retry success and reconciles a run as
failed only when every check failed. Drops the superseded
hygiene-state.ts model.
2026-08-09 16:45:30 -04:00

1149 lines
37 KiB
TypeScript

/**
* 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
* `<cwd>/.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 {
registerCheck,
type CheckDefinition,
type 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<DeadCategory, string> = {
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<string[]> {
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<string | undefined> {
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<string>;
/** Files that reference this one via `import(...)` / runtime registration. */
dynamicImporters: Set<string>;
}
/** 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<string | undefined> {
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<DeadCodeReport> {
const files = await walkSourceFiles(target);
const infos = new Map<string, FileInfo>();
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<string>();
// 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<string>();
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<string>();
const packageJson = await findPackageJson(target, resolve(target));
const packageJsonText = packageJson
? await readFile(packageJson, "utf8").catch(() => undefined)
: undefined;
let packageJsonDeps: Record<string, string> | undefined;
if (packageJsonText) {
try {
const parsed = JSON.parse(packageJsonText) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
scripts?: Record<string, string>;
};
packageJsonDeps = {
...(parsed.dependencies ?? {}),
...(parsed.devDependencies ?? {}),
...(parsed.peerDependencies ?? {}),
...(parsed.optionalDependencies ?? {}),
};
const referencedNames = new Set<string>();
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<string, string>;
};
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<string, unknown>;
let touched = false;
for (const key of [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
]) {
const deps = parsed[key] as Record<string, string> | 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";
/** `<target>/.pygienium/checks/dead-code/findings.md` */
export function findingsPath(target: string): string {
return join(target, CHECK_DIRNAME, "findings.md");
}
/** `<target>/.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<string> {
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<string> {
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<string> {
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: <n> issue(s)`,
`1. [severity: high|med|low] <rel>:<line> — <description> (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<string> {
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. <rel>:<line> — <what changed> (auto)`,
`2. <rel> — <finding> — skipped: <reason> (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<string | undefined> {
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<string | undefined> {
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 deadCodeCheck: 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,
};
// Self-register so `index.ts` auto-discovers this check with zero wiring edits.
registerCheck(deadCodeCheck);