Project-Template/docs/architecture/scripts/dead-code.py

220 lines
7.8 KiB
Python
Raw Normal View History

#!/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())