All per-check artifacts, all-summary, and export move from pygienium/ to .pygienium/; ensureRunStateIgnored appends .pygienium/ to the target repo's .gitignore on first run (opt out with --no-gitignore), so a run never stages its own output. Export now reads a single canonical root.
1123 lines
36 KiB
TypeScript
1123 lines
36 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;
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
|
|
// Self-register so `index.ts` auto-discovers this check with zero wiring edits.
|
|
registerCheck(deadCodeCheck);
|