220 lines
7.8 KiB
Python
220 lines
7.8 KiB
Python
|
|
#!/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())
|