#!/usr/bin/env python3 """Dead-code audit for any project tree. Walks the current directory and flags every file, function, class, import, constant, and exported symbol that has no detected reference. Writes a Markdown report (DEAD_CODE_REPORT.md) you can read top to bottom. Languages handled: Python, TypeScript, TSX, JavaScript, JSX, Shell. What it knows about (so it doesn't false-positive on them): - Decorator-registered functions (@mcp.tool, @router.*, @app.tool, @.on(...), @.handle(...)) treated as USED. - Cross-language references: Python module names referenced as strings from TS/JS (and vice versa) count as "used". - Conventional entry points: __init__.py, __main__.py, main()/__main__ blocks, files referenced from package.json scripts, tsconfig include patterns, Dockerfile entrypoints. - Test/spec files are scanned but treated as USED leaves (a file that only tests are referencing is still flagged so you know). What it does NOT do: - Modify or delete code. You get a report; you decide. - Resolve fully dynamic dispatch (e.g. eval, getattr-by-string). Flagged items in those cases are best-effort. External tools it will auto-install if available (otherwise skipped): - vulture (Python; pip-installs into current venv if missing) - knip / ts-prune (TS/JS; you install manually if you want this depth) - shellcheck (Shell; brew install if missing — script just warns) Usage: cd /path/to/your/project python3 /path/to/audit-deadcode.py Optional flags: --strict Don't whitelist decorator-registered functions --no-install Skip auto-installing vulture --out PATH Write the Markdown to PATH instead of ./DEAD_CODE_REPORT.md --include DIR Restrict scanning to DIR (relative). Can be passed multiple times. --exclude DIR Add DIR to the default exclude list. Exit codes: 0 Scan completed (report may still have findings) 1 Hard failure (e.g. cwd unreadable) """ from __future__ import annotations import argparse import ast import json import os import re import shutil import subprocess import sys from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Iterable # ---------- config ---------- DEFAULT_EXCLUDES = { ".git", ".venv", "venv", "node_modules", "__pycache__", ".cache", "dist", "build", "build-staging", ".next", "out", "coverage", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".vscode", ".idea", "bin", "lib", "include", "share", # OpenSwarm-specific noise "mcp-bundles", "uv-bin", } PY_EXTS = {".py"} JS_EXTS = {".js", ".jsx", ".mjs", ".cjs"} TS_EXTS = {".ts", ".tsx", ".mts", ".cts"} SH_EXTS = {".sh", ".bash", ".zsh"} ALL_CODE_EXTS = PY_EXTS | JS_EXTS | TS_EXTS | SH_EXTS # Files we never call dead even if no one references them. ALWAYS_LIVE_FILES = { "__init__.py", "__main__.py", "conftest.py", "main.py", "app.py", "index.js", "index.ts", "setup.py", "setup.cfg", "pyproject.toml", "package.json", "tsconfig.json", "vite.config.ts", "webpack.config.js", "Dockerfile", "Makefile", "README.md", "LICENSE", "manage.py", } # Filename patterns that are entry-pointy. ENTRY_PATTERNS = ( re.compile(r"^run.*\.(sh|py|js|ts)$"), re.compile(r"^test_.*\.py$"), re.compile(r".*\.test\.(ts|tsx|js|jsx)$"), re.compile(r".*\.spec\.(ts|tsx|js|jsx)$"), ) # Decorator names that mean "this function is registered/wired in, not orphaned". LIVE_DECORATOR_PATTERNS = ( re.compile(r"^(?:[\w.]+\.)?(?:tool|command|task|cli)\b"), # @mcp.tool, @cli.command re.compile(r"^(?:[\w.]+\.)?(?:get|post|put|delete|patch|head|options)\b"), # FastAPI routes re.compile(r"^(?:[\w.]+\.)?(?:on|on_message|on_event|handle|hook|listener)\b"), re.compile(r"^(?:[\w.]+\.)?(?:route|websocket|middleware)\b"), re.compile(r"^(?:[\w.]+\.)?(?:fixture|hookimpl|hookspec)\b"), re.compile(r"^(?:[\w.]+\.)?(?:typechecked|app|router|api)\."), # @app.something re.compile(r"^(?:[\w.]+\.)?(?:property|staticmethod|classmethod|cached_property)$"), re.compile(r"^(?:[\w.]+\.)?(?:asynccontextmanager|contextmanager|dataclass)$"), ) # Tokens that are common false-positive sinks — if a "dead" symbol's name # appears in a file as text (not just an import), assume it's referenced. REFERENCE_HINT_FILES = ( "package.json", ".env.example", ".env", "README.md", "GETTING_STARTED.md", "CHANGELOG.md", "ARCHITECTURE.md", "pyproject.toml", "uv.lock", "requirements.txt", "Dockerfile", ) # Function names that are inherently "used" by convention. ALWAYS_LIVE_FUNCS = { "main", "__init__", "__main__", "__enter__", "__exit__", "__aenter__", "__aexit__", "__await__", "__iter__", "__anext__", "__repr__", "__str__", "__eq__", "__hash__", "__getitem__", "__setitem__", "__delitem__", "__len__", "__contains__", "__call__", "__new__", "__init_subclass__", "__class_getitem__", "setUp", "tearDown", } # ---------- data ---------- @dataclass class Finding: kind: str # 'unused_file', 'unused_function', 'unused_class', 'unused_import', 'unused_const' path: str # file path relative to root line: int = 0 name: str = "" detail: str = "" confidence: str = "medium" # 'high' / 'medium' / 'low' @dataclass class PySymbol: name: str kind: str # 'function' / 'class' / 'const' file: Path line: int decorators: list[str] = field(default_factory=list) # ---------- helpers ---------- def info(msg: str) -> None: sys.stderr.write(f" {msg}\n") def warn(msg: str) -> None: sys.stderr.write(f" ⚠ {msg}\n") def heading(msg: str) -> None: sys.stderr.write(f"\n=== {msg} ===\n") def is_excluded(path: Path, extra_excludes: set[str]) -> bool: parts = set(path.parts) if parts & DEFAULT_EXCLUDES: return True if parts & extra_excludes: return True return False def walk_code(root: Path, includes: list[Path] | None, extra_excludes: set[str]) -> Iterable[Path]: bases = includes or [root] for base in bases: if base.is_file(): yield base continue for p in base.rglob("*"): if not p.is_file(): continue rel = p.relative_to(root) if is_excluded(rel, extra_excludes): continue if p.suffix.lower() in ALL_CODE_EXTS: yield p def is_entry_filename(name: str) -> bool: if name in ALWAYS_LIVE_FILES: return True return any(p.match(name) for p in ENTRY_PATTERNS) def decorator_to_name(node: ast.AST) -> str: """Stringify a decorator AST node for matching against LIVE_DECORATOR_PATTERNS.""" if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): return f"{decorator_to_name(node.value)}.{node.attr}" if isinstance(node, ast.Call): return decorator_to_name(node.func) + "(...)" return "" def decorator_is_live(deco: str) -> bool: """True if a decorator name matches a 'this function is wired in' pattern.""" return any(p.search(deco) for p in LIVE_DECORATOR_PATTERNS) # ---------- Python analysis ---------- def parse_python_symbols(files: list[Path], root: Path) -> dict[str, list[PySymbol]]: """For every Python file, list its top-level + class-level function/class/const definitions.""" out: dict[str, list[PySymbol]] = defaultdict(list) for f in files: if f.suffix != ".py": continue try: tree = ast.parse(f.read_text(encoding="utf-8", errors="ignore")) except (SyntaxError, UnicodeDecodeError): continue def visit(body, container_class: str | None = None): for node in body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): decos = [decorator_to_name(d) for d in node.decorator_list] fq = f"{container_class}.{node.name}" if container_class else node.name out[str(f.relative_to(root))].append(PySymbol( name=fq, kind="function", file=f, line=node.lineno, decorators=decos )) elif isinstance(node, ast.ClassDef): decos = [decorator_to_name(d) for d in node.decorator_list] out[str(f.relative_to(root))].append(PySymbol( name=node.name, kind="class", file=f, line=node.lineno, decorators=decos )) visit(node.body, container_class=node.name) elif isinstance(node, ast.Assign): for tgt in node.targets: if isinstance(tgt, ast.Name) and tgt.id.isupper() and not tgt.id.startswith("_"): out[str(f.relative_to(root))].append(PySymbol( name=tgt.id, kind="const", file=f, line=node.lineno )) visit(tree.body) return out def parse_python_imports(files: list[Path], root: Path) -> dict[str, set[str]]: """Map file -> set of imported names (so we can find unused imports later).""" used: dict[str, set[str]] = defaultdict(set) for f in files: if f.suffix != ".py": continue try: text = f.read_text(encoding="utf-8", errors="ignore") tree = ast.parse(text) except (SyntaxError, UnicodeDecodeError): continue # Names actually referenced in code (Name + Attribute roots). for node in ast.walk(tree): if isinstance(node, ast.Name): used[str(f.relative_to(root))].add(node.id) elif isinstance(node, ast.Attribute): root_name = node while isinstance(root_name, ast.Attribute): root_name = root_name.value if isinstance(root_name, ast.Name): used[str(f.relative_to(root))].add(root_name.id) return used def python_module_path(file: Path, root: Path) -> str: """Convert backend/apps/instagram_mcp/server.py -> backend.apps.instagram_mcp.server""" rel = file.relative_to(root).with_suffix("") parts = rel.parts if parts and parts[-1] == "__init__": parts = parts[:-1] return ".".join(parts) # ---------- text search across the tree ---------- def build_text_corpus(files: list[Path], root: Path) -> dict[str, str]: """Read every file's text once so subsequent grep-like searches are fast.""" corpus: dict[str, str] = {} for f in files: try: corpus[str(f.relative_to(root))] = f.read_text(encoding="utf-8", errors="ignore") except Exception: corpus[str(f.relative_to(root))] = "" # Also pick up reference-hint files (.env.example, package.json, README). for hint in REFERENCE_HINT_FILES: for f in root.rglob(hint): rel = str(f.relative_to(root)) if rel in corpus: continue if is_excluded(Path(rel), set()): continue try: corpus[rel] = f.read_text(encoding="utf-8", errors="ignore") except Exception: pass return corpus def file_is_referenced(stem: str, module_path: str, file_rel: str, corpus: dict[str, str]) -> bool: """Anywhere in the corpus (other than this file itself), is this file mentioned? Looks for ACTUAL import / require / spawn / script patterns rather than a bare-name regex — otherwise a Python helper named `logger.py` looks 'referenced' anywhere a local `logger = logging.getLogger(...)` exists. """ needles: list[re.Pattern] = [] if module_path: esc = re.escape(module_path) # Python: `from foo.bar import` / `import foo.bar` / `python -m foo.bar` needles.append(re.compile(rf"(?:from|import|-m)\s+{esc}\b")) # TS/JS/JSON: 'foo.bar' or "foo.bar" as a string literal (e.g. mcp_config args) needles.append(re.compile(rf"['\"]{esc}['\"]")) # Same without the trailing .module name (parent package import) if "." in module_path: parent = module_path.rsplit(".", 1)[0] esc_parent = re.escape(parent) needles.append(re.compile(rf"(?:from|import|-m)\s+{esc_parent}\b")) # Stem-based references — catches sibling-style imports (`from rate_limiter # import ...` for a `src/rate_limiter.py` that runs with src/ on the path), # shell-script invocations, and module-as-string mentions. if stem: esc_stem = re.escape(stem) # 'foo.py' / 'foo.sh' / 'foo.js' explicitly referenced as a file needles.append(re.compile(rf"[\"'/\s]{esc_stem}\.(?:py|sh|js|ts|jsx|tsx|bash)\b")) # `bash scripts/foo` / `python foo.py` / `node foo` invocations needles.append(re.compile(rf"(?:bash|sh|node|python\d?|uvx|uv\s+run)\s+[\w./-]*{esc_stem}\b")) # Python: `from foo import X` / `import foo` where foo is the bare module name needles.append(re.compile(rf"(?:from|import)\s+{esc_stem}\b")) # TS/JS: `from "foo"` / `from './foo'` / `require('foo')` needles.append(re.compile(rf"(?:from|require\(|import\()\s*['\"][\w./-]*{esc_stem}['\"]")) for rel, text in corpus.items(): if rel == file_rel: continue for pat in needles: if pat.search(text): return True return False def symbol_is_referenced(name: str, defining_file: str, corpus: dict[str, str]) -> bool: """Is this symbol mentioned BEYOND its defining line? A symbol is 'used' if: - Its name appears in any OTHER file in the corpus, OR - Its name appears MORE THAN ONCE in its defining file (the `def` line plus at least one call/reference somewhere else in the file). Pure-name regex; doesn't distinguish call-site from definition. Counting occurrences in the defining file handles private helpers (`_foo()`) used only by their siblings. """ pattern = re.compile(rf"\b{re.escape(name)}\b") own_text = corpus.get(defining_file, "") if len(pattern.findall(own_text)) > 1: return True for rel, text in corpus.items(): if rel == defining_file: continue if pattern.search(text): return True return False # ---------- external tools ---------- def ensure_vulture(no_install: bool) -> str | None: """Return the vulture exe path, installing into the current venv if missing.""" exe = shutil.which("vulture") if exe: return exe if no_install: warn("vulture not installed and --no-install given; skipping deeper Python dead-code scan.") return None info("vulture not found — installing into current Python env…") try: subprocess.run( [sys.executable, "-m", "pip", "install", "--quiet", "vulture"], check=True, timeout=120, ) except subprocess.CalledProcessError as exc: warn(f"pip install vulture failed: {exc}. Continuing without it.") return None return shutil.which("vulture") or None def run_vulture(exe: str, root: Path, extra_excludes: set[str]) -> list[dict]: """Run vulture, return parsed findings.""" args = [exe, str(root), "--min-confidence", "60"] # Pass exclude paths exclude_globs = ",".join([f"*/{e}/*" for e in (DEFAULT_EXCLUDES | extra_excludes)]) if exclude_globs: args.extend(["--exclude", exclude_globs]) try: result = subprocess.run(args, capture_output=True, text=True, timeout=180) except subprocess.TimeoutExpired: warn("vulture timed out after 180s.") return [] out: list[dict] = [] # vulture format: path:line: unused name 'foo' (NN% confidence) pat = re.compile(r"^(.*?):(\d+):\s*unused\s+(\w+)\s+'([^']+)'\s+\((\d+)% confidence\)") for line in result.stdout.splitlines(): m = pat.match(line) if not m: continue path, lineno, kind, name, conf = m.groups() out.append({"path": path, "line": int(lineno), "kind": kind, "name": name, "confidence": int(conf)}) return out def has_tool(name: str) -> bool: return shutil.which(name) is not None # ---------- markdown report ---------- def emit_report(out_path: Path, findings: dict[str, list[Finding]], stats: dict) -> None: """Write the Markdown report.""" lines: list[str] = [] lines.append("# Dead-Code Audit Report\n") lines.append(f"_Generated by `audit-deadcode.py` against `{stats['root']}`._\n") lines.append("") lines.append("## Summary\n") lines.append(f"- Files scanned: **{stats['files_scanned']}**") lines.append(f"- Lines of code: **~{stats['loc']:,}**") total = sum(len(v) for v in findings.values()) lines.append(f"- Findings: **{total}**") for k, v in findings.items(): if v: lines.append(f" - {k}: {len(v)}") lines.append("") if not total: lines.append("Nothing flagged — clean tree. ✨\n") out_path.write_text("\n".join(lines)) return section_titles = { "unused_files": "Unused Files", "unused_functions": "Unused Functions", "unused_classes": "Unused Classes", "unused_consts": "Unused Constants", "unused_imports": "Unused Imports", "decorator_skipped": "Decorator-Registered (skipped — verify if any look orphaned)", } for key, title in section_titles.items(): items = findings.get(key) or [] if not items: continue lines.append(f"## {title}\n") lines.append(f"_{len(items)} item(s)._\n") # Group by file by_file: dict[str, list[Finding]] = defaultdict(list) for f in items: by_file[f.path].append(f) for path in sorted(by_file.keys()): fitems = by_file[path] lines.append(f"### `{path}`") for f in sorted(fitems, key=lambda x: x.line): loc = f"L{f.line}" if f.line else "" lbl = f.name or "(file)" detail = f" — {f.detail}" if f.detail else "" conf_marker = "" if f.confidence == "medium" else f" _({f.confidence} confidence)_" lines.append(f"- **{lbl}** {loc}{detail}{conf_marker}") lines.append("") lines.append("---\n") lines.append( "_Caveats: dynamic dispatch (eval, getattr-by-string, JSON-driven calls), " "cross-language references, and runtime-only entry points (e.g. `python -m X` " "invoked from a JSON config) cannot be statically verified. Cross-check anything " "you're not sure about before deleting._\n" ) out_path.write_text("\n".join(lines)) # ---------- main ---------- def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--strict", action="store_true", help="Don't whitelist decorator-registered functions.") parser.add_argument("--no-install", action="store_true", help="Skip auto-installing vulture.") parser.add_argument("--out", type=Path, default=Path("DEAD_CODE_REPORT.md"), help="Output Markdown path.") parser.add_argument("--include", action="append", default=[], help="Restrict to this subdir (repeatable).") parser.add_argument("--exclude", action="append", default=[], help="Add to exclude list (repeatable).") args = parser.parse_args(argv) root = Path.cwd().resolve() extra_excludes = set(args.exclude) includes = [(root / i).resolve() for i in args.include] if args.include else None heading("Phase 1 — Walking the tree") files = list(walk_code(root, includes, extra_excludes)) info(f"{len(files)} code files found") if not files: warn(f"No code files in {root}. Nothing to audit.") return 0 heading("Phase 2 — Reading every file into memory") corpus = build_text_corpus(files, root) loc = sum(text.count("\n") for text in corpus.values()) info(f"~{loc:,} LOC across {len(corpus)} files (including reference-hint files)") findings: dict[str, list[Finding]] = defaultdict(list) heading("Phase 3 — Unused FILES (no other file references them)") py_files = [f for f in files if f.suffix == ".py"] for f in files: rel = str(f.relative_to(root)) name = f.name if is_entry_filename(name): continue # Skip the audit script itself if f.resolve() == Path(__file__).resolve(): continue if f.suffix == ".py": mod = python_module_path(f, root) if not file_is_referenced(f.stem, mod, rel, corpus): findings["unused_files"].append(Finding( kind="unused_file", path=rel, name=name, detail=f"no other file references `{f.stem}` or `{mod}`", confidence="medium", )) else: # JS/TS/SH: just look for the filename or stem if not file_is_referenced(f.stem, f.name, rel, corpus): findings["unused_files"].append(Finding( kind="unused_file", path=rel, name=name, detail=f"no other file references `{f.stem}` or `{f.name}`", confidence="medium", )) info(f"{len(findings['unused_files'])} unused files") heading("Phase 4 — Unused Python symbols (decorator-aware)") symbols = parse_python_symbols(py_files, root) total_syms = sum(len(v) for v in symbols.values()) info(f"Parsed {total_syms} Python symbols across {len(symbols)} files") deco_skipped = 0 for rel, syms in symbols.items(): for sym in syms: if sym.name in ALWAYS_LIVE_FUNCS: continue # Decorator-aware skip (unless --strict) if not args.strict and sym.decorators and any(decorator_is_live(d) for d in sym.decorators): findings["decorator_skipped"].append(Finding( kind="decorator_skipped", path=rel, name=sym.name, line=sym.line, detail=", ".join(f"@{d}" for d in sym.decorators), confidence="low", )) deco_skipped += 1 continue # Bare name search across corpus if symbol_is_referenced(sym.name.split(".")[-1], rel, corpus): continue kind_key = {"function": "unused_functions", "class": "unused_classes", "const": "unused_consts"}[sym.kind] findings[kind_key].append(Finding( kind=kind_key, path=rel, name=sym.name, line=sym.line, detail=", ".join(f"@{d}" for d in sym.decorators) if sym.decorators else "", confidence="medium" if not sym.decorators else "low", )) info(f"{len(findings['unused_functions'])} functions, {len(findings['unused_classes'])} classes, " f"{len(findings['unused_consts'])} consts flagged; {deco_skipped} skipped via decorator whitelist") heading("Phase 5 — Vulture (deeper Python scan, including unused imports + locals)") vulture = ensure_vulture(args.no_install) if vulture: vul = run_vulture(vulture, root, extra_excludes) # Convert vulture findings into our format, deduping against what we already # have. Critically, also dedupe against decorator_skipped so vulture's # "unused function send_message" doesn't show up after we explicitly # declared it live-via-decorator. already = {(f.path, f.line, f.name) for fs in findings.values() for f in fs} deco_skip_keys = {(f.path, f.name) for f in findings.get("decorator_skipped", [])} added = 0 for v in vul: try: rel = str(Path(v["path"]).resolve().relative_to(root)) except ValueError: rel = v["path"] key = (rel, v["line"], v["name"]) if key in already: continue # Skip if we already marked this function as decorator-registered. if (rel, v["name"]) in deco_skip_keys: continue kind_map = { "import": "unused_imports", "variable": "unused_consts", "function": "unused_functions", "method": "unused_functions", "class": "unused_classes", "attribute": "unused_consts", "property": "unused_functions", } bucket = kind_map.get(v["kind"], "unused_consts") findings[bucket].append(Finding( kind=bucket, path=rel, name=v["name"], line=v["line"], detail=f"vulture: unused {v['kind']}", confidence="high" if v["confidence"] >= 80 else ("medium" if v["confidence"] >= 70 else "low"), )) already.add(key) added += 1 info(f"+{added} findings from vulture") else: info("Skipped (vulture not available).") heading("Phase 6 — TS/JS deep scan (knip if installed)") if has_tool("knip"): try: result = subprocess.run(["knip", "--reporter", "json"], capture_output=True, text=True, timeout=180, cwd=root) data = json.loads(result.stdout or "{}") for issue_type, items in (data.get("issues") or {}).items(): for item in items if isinstance(items, list) else []: path = item.get("file") or item.get("filePath", "") line = item.get("line") or 0 name = item.get("symbol") or item.get("name") or "" bucket = "unused_imports" if "import" in issue_type else ( "unused_files" if "file" in issue_type else "unused_functions" ) findings[bucket].append(Finding( kind=bucket, path=path, name=name, line=line, detail=f"knip: {issue_type}", confidence="medium", )) info(f"knip reported {sum(len(v) for v in (data.get('issues') or {}).values() if isinstance(v, list))} issues") except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as exc: warn(f"knip ran but output couldn't be parsed: {exc}") elif has_tool("ts-prune"): info("knip not found; ts-prune detected, but knip is preferred. Install: npm i -g knip") else: info("knip/ts-prune not installed. TS/JS deep scan skipped. Install: npm i -g knip") heading("Phase 7 — Writing report") stats = {"root": str(root), "files_scanned": len(files), "loc": loc} out = args.out if args.out.is_absolute() else (root / args.out) emit_report(out, findings, stats) info(f"Wrote {out.relative_to(root)}") total = sum(len(v) for v in findings.values() if v and v[0].kind != "decorator_skipped") sys.stdout.write(f"\n{total} actionable findings written to {out}\n") if findings.get("decorator_skipped"): sys.stdout.write(f"(plus {len(findings['decorator_skipped'])} decorator-registered functions listed for sanity check)\n") return 0 if __name__ == "__main__": sys.exit(main())