rot-scan
v0.3.0
Published
Language-agnostic dead code analyzer powered by tree-sitter
Maintainers
Readme
rot-scan
Language-agnostic code-rot scanner powered by tree-sitter. No AI, no cloud, no language-specific toolchain on your machine. JS, TS, TSX, Python.
Install
Run without installing:
npx -y rot-scanOr install globally:
npm install -g rot-scan
rot-scanNode 20+. No auth, no postinstall step — the 4 tree-sitter wasm grammars ship inside the package.
Use with coding agents
rot-scan ships an MCP server — works in Claude Code, Cursor, and Windsurf natively:
{
"mcpServers": {
"rot-scan": {
"command": "npx",
"args": ["-y", "rot-scan", "mcp"]
}
}
}Drop this into .claude/settings.json / .cursor/mcp.json / ~/.codeium/windsurf/mcp_config.json. See docs/agents/ for per-agent details.
Your agent can then ask for a health score (rot_scan_summary), list findings (rot_scan_run), or fetch rule docs (rot-scan://rules/{name}). Every finding carries an explicit suggestedAction (remove-export, delete-file, remove-dependency, etc.) so agents can drive cleanups directly.
Unlike Knip's MCP (JS/TS only), rot-scan's MCP handles JS + TS + TSX + Python in one server with zero config.
Usage
# Score the current directory
rot-scan
# Score specific paths
rot-scan src/ packages/
# CI gate — fail if health score drops below 80
rot-scan --min-score 80
# JSON output
rot-scan --format json
# Scope to a language subset
rot-scan --lang ts,tsx src/
# Disable a rule for this run
rot-scan --rule no-unused-locals=offEvery run renders a health score banner:
72 / 100 Good
██████████████░░░░░░
47 findings across 183 files in 2.1s
src/services/auth.ts
unused-export:23:14 Exported "authKeys" is used inside this file but
imported by nothing — remove the `export` keyword.
...Grades: Healthy 90+, Good 75-89, Warning 50-74, Poor 25-49, Critical <25.
Exit codes: 0 pass, 1 findings or score below --min-score, 2 internal error (unparseable files).
Flags
rot-scan [paths...] [options]
Positionals:
paths One or more directories or files. Default: cwd.
Rules & detection:
--rule NAME=SEVERITY Per-rule override, repeatable. SEVERITY is error|warn|off.
--detect <list> [legacy] Comma-separated rule kinds: exports,files,locals,unreachable.
--no-string-refs Disable the dynamic-dispatch heuristic.
--no-path-refs Disable the cross-language path-literal heuristic.
Scope:
--lang <list> Restrict to languages: js,ts,tsx,py (or full names).
--ignore <glob> Extra ignore pattern, repeatable.
--exclude-tests Skip common test-file conventions.
--entry-points <files> Comma-separated entry points in addition to auto-detect.
Score & CI:
--min-score <n> Fail if health score < n (0-100).
--no-score Hide the score banner.
Output:
--format <fmt> text | json (default text).
Config:
--config <path> Path to .rot-scanrc.json.
--no-ignore-comments Disable `// rot-scan:ignore` comment support.Config
Drop .rot-scanrc.json at the repo root:
{
"presets": ["vite", "nestjs"],
"entryPoints": ["src/index.ts", "scripts/cli.ts"],
"ignore": ["src/generated/**"],
"ignoreSymbols": ["publicAPI"],
"dynamicPatterns": ["**/routes/**"],
"rules": {
"no-unused-exports": "error",
"no-unused-locals": "off"
},
"heuristics": { "stringRefs": true, "pathRefs": true }
}Rules (see docs/rules/ for each):
| Rule | Category | Weight | What it flags |
|------|----------|-------:|---------------|
| no-unused-exports | correctness | ×1 | Exported symbols with no importers |
| no-unused-files | correctness | ×2 | Orphan files (no importers, not entry points) |
| no-unused-locals | suspicious | ×0.5 | File-local declarations never referenced |
| no-unreachable-code | correctness | ×2 | Statements after return/throw/break/continue |
| no-unused-deps | correctness | ×1 | package.json entries never imported |
Presets expand into entryPoints + dynamicPatterns for common frameworks: nextjs, vite, remix, nestjs, storybook, vitest, drizzle, tsup, sveltekit, astro, hono, solid, nuxt.
Inline ignores:
// rot-scan:ignore
export function registeredByDecorator() {}
// rot-scan:ignore-file (put at top of file to skip it entirely)Limitations
rot-scan reads your source code statically — it never runs anything. That's fast and safe, but a few patterns are genuinely invisible to a tool that only reads. Here's what to watch for and how to work around each one.
Calling functions by string name
const handlers = { handleClick, handleSubmit };
handlers[eventName](); // which one gets called? depends on runtimerot-scan can't follow handlers[eventName]() to a specific function, so handleClick and handleSubmit look unused. As a safety net, the tool scans string literals in your code — so if the string "handleClick" appears anywhere, the function stays marked as used. But if the name is assembled at runtime ("handle" + event), it's invisible.
What to do: list the file in dynamicPatterns (stops the tool from flagging its exports), or list specific names in ignoreSymbols.
Code stripped by your bundler
if (process.env.NODE_ENV !== "production") {
debugOnlyThing(); // bundlers delete this in prod
}rot-scan reads your source, not your built bundle. Code inside dev-only branches looks alive to the tool even though your production build has it removed. The symmetric case: something only called inside a dev branch looks "used," but really isn't in prod.
What to do: if you want a "what's dead in production" report, point rot-scan at your build output (e.g. dist/) instead of src/.
Framework files loaded by filename
Some frameworks load files by convention, not by import:
- Rails autoloads
UsersControllerfromusers_controller.rb - Next.js makes every
pages/foo.tsxa route - NestJS collects controllers/services by decorator at app startup
rot-scan doesn't see these files as imported, so they look orphaned.
What to do: add a preset — nextjs, nestjs, remix, vite, rails, storybook, vitest, drizzle, tsup. Each one teaches rot-scan about that framework's conventions. For custom conventions, extend entryPoints + dynamicPatterns yourself.
Scripts invoked across languages
spawn("python", ["scripts/train.py"]);rot-scan analyzes each language's imports separately. It won't know that a JS file invokes a Python script. As a fallback, it looks at string literals — if the exact path "scripts/train.py" appears, the Python file stays marked as used. But runtime-assembled paths ("scripts/" + name + ".py") are invisible.
What to do: add the target script to entryPoints.
Shadowed variables in nested scopes
If you reuse a variable name in nested scopes (an outer x and an inner x), rot-scan's scope tracker counts inner uses as also referring to the outer x. The inner use "leaks" into the outer scope's count. Usually harmless; in pathological cases it may mask an unused outer variable.
What to do: if you hit it, file a fixture and we'll extend the scope walker to handle shadowing correctly.
Default exports
export default function greet() {}The no-unused-exports rule skips export default entirely. Matching a default export to its importer reliably requires tracking each file's default binding through renames (import foo from "./x" is really importing default), which needs type-level info that tree-sitter queries don't provide.
What to do: nothing — these just won't be flagged. If default-only dead code is a concern, convert the exports to named form.
Side-effect-only imports
import "./init"; // runs the file for its side effects, no bindingrot-scan currently only tracks imports that have a named or namespace binding. A bare side-effect import doesn't mark the target as imported, so it may get flagged as an orphan file.
What to do: add the file to entryPoints.
