#!/usr/bin/env python3 """Exports nothing imports, and assets nothing renders. ## Why Dead code is not merely clutter. It is read, maintained, and trusted: somebody eventually changes it, and nothing happens. On the repository this was written against, two issues had already been filed by hand for exactly this — four progress components exported and imported nowhere, and a 1.1 MB image shipping in every container while being rendered by nothing. Both are the kind of thing a person finds by accident and a script finds every time. ## What it checks **Exports.** A symbol exported from a module and referenced nowhere but its own declaration. Framework entry points are excluded by name, because a Next.js `page.tsx` exporting `metadata` or a route exporting `GET` is *called by the framework* and referencing it would be wrong. **Assets.** A file under the asset roots whose name appears in no source file. ## Tuned for near-silence `GUARDS.md` rule 5: a check that is wrong six times in ten is one people learn to skip. So the reference scan covers **tests as well as source** — the first draft of this scanned only `src/` and reported every test-only helper as dead, which is the mistake that makes a tool untrustworthy on its first run. It still cannot see a symbol referenced only by string name, by a build step, or from outside the repository. Treat findings as candidates, and delete only what you have confirmed. python3 scripts/dead-code.py python3 scripts/dead-code.py --src src --refs src tests --assets public python3 scripts/dead-code.py --exports-only Exit codes: 0 nothing found. 1 candidates found. 2 nothing was scanned. """ from __future__ import annotations import argparse import os import re import sys from collections import Counter SKIP_DIRS = {"node_modules", ".next", ".git", "dist", "build", "__pycache__"} CODE_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs") # An asset is referenced from prose and configuration as often as from code — a # README embeds a screenshot, a manifest names an icon, a stylesheet loads a # font. Scanning only code reported twelve screenshots as dead when the README # renders every one of them, which is exactly the first-run noise that ends a # tool's credibility. PROSE_EXTS = CODE_EXTS + (".md", ".mdx", ".json", ".yml", ".yaml", ".html", ".css", ".webmanifest") EXPORT = re.compile( r"^export\s+(?:async\s+)?(?:function|const|let|class|type|interface|enum)\s+(\w+)" ) # `export default function FaqPage()` is called by the framework, and the name # is incidental — it exists so a stack trace reads well. Matching it reported # every page and layout in the application as dead, which is the kind of first # run that ends a tool's credibility. DEFAULT_EXPORT = re.compile(r"^export\s+default\b") # Called by the framework, never imported. Reporting these would be worse than # reporting nothing: it teaches the reader that the tool does not understand the # project, and everything after it gets skipped too. FRAMEWORK = { "default", "metadata", "generateMetadata", "generateStaticParams", "dynamic", "revalidate", "runtime", "fetchCache", "preferredRegion", "maxDuration", "viewport", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "middleware", "config", "loader", "action", } def walk(roots: list[str], exts: tuple[str, ...]) -> list[str]: found: list[str] = [] for root in roots: if os.path.isfile(root): found.append(root) continue for base, dirs, names in os.walk(root): dirs[:] = [d for d in dirs if d not in SKIP_DIRS] found.extend( os.path.join(base, n) for n in names if not exts or n.endswith(exts) ) return found def main() -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--src", nargs="+", default=["src"], help="where exports live") parser.add_argument( "--refs", nargs="+", default=["src", "tests", "app", "scripts", "notices"], help="where references may appear — include tests, or every helper reads as dead", ) parser.add_argument("--assets", nargs="+", default=["public"], help="asset roots") parser.add_argument("--exports-only", action="store_true") parser.add_argument("--assets-only", action="store_true") args = parser.parse_args() sources = walk(args.src, CODE_EXTS) references = walk([r for r in args.refs if os.path.exists(r)], CODE_EXTS) if not sources and not args.assets_only: print("dead-code: nothing to scan.", file=sys.stderr) return 2 # One pass over every referencing file, counting every identifier and # remembering the raw text for asset lookups. counts: Counter[str] = Counter() corpus: list[str] = [] for path in references: try: with open(path, encoding="utf8", errors="ignore") as handle: body = handle.read() except OSError: continue corpus.append(body) counts.update(re.findall(r"\b\w+\b", body)) findings = 0 if not args.assets_only: exported: dict[str, str] = {} for path in sources: try: with open(path, encoding="utf8", errors="ignore") as handle: for line in handle: if DEFAULT_EXPORT.match(line): continue match = EXPORT.match(line) if match and match.group(1) not in FRAMEWORK: exported.setdefault(match.group(1), path) except OSError: continue # Exactly one occurrence is the declaration itself and nothing else. dead = sorted((name, path) for name, path in exported.items() if counts[name] <= 1) if dead: print("exported and referenced nowhere:") for name, path in dead: print(f" {name:<32} {path}") findings += len(dead) if not args.exports_only: # A wider net than the export scan uses, for the reason above. asset_refs = walk([r for r in args.refs if os.path.exists(r)], PROSE_EXTS) asset_refs += [p for p in walk(["."], (".md",)) if p.count(os.sep) <= 1] text = "\n".join(corpus) for path in set(asset_refs): try: with open(path, encoding="utf8", errors="ignore") as handle: text += "\n" + handle.read() except OSError: continue assets = walk([a for a in args.assets if os.path.exists(a)], ()) orphans = [] for path in assets: name = os.path.basename(path) # By basename and by path — a reference is written either way, and # missing one direction is how a false positive gets in. if name in text or path in text or path.split(os.sep, 1)[-1] in text: continue try: size = os.path.getsize(path) except OSError: size = 0 orphans.append((size, path)) if orphans: print("\nasset files nothing references:" if findings else "asset files nothing references:") for size, path in sorted(orphans, reverse=True): print(f" {size / 1024:9.1f} KB {path}") findings += len(orphans) if not findings: print( f"dead-code: nothing unreferenced across {len(sources)} source " f"and {len(references)} referencing file(s).", file=sys.stderr, ) return 0 print( f"\ndead-code: {findings} candidate(s). A symbol referenced only by " "string name, or from outside this repository, will appear here and is " "not dead — confirm before deleting.", file=sys.stderr, ) return 1 if __name__ == "__main__": raise SystemExit(main())