basta finds dead code by building a graph of every file in a scan and walking
it from the project's entry points. The graph, the reachability passes, the
confidence model, the classifier, the reporters and both command lines are
language-agnostic. Everything a language contributes goes through one trait,
Analyzer, and one registry line.
Adding a language is one new file under rust/crates/basta/src/lang/.
This guide is the contract that file has to honour, a worked skeleton, and the way to prove the result is right. Read it once before writing an analyzer; the invariants in §3 are the ones that took real bugs to find.
walk ──► read ──► analyze (per file, parallel) ──► graph (whole project) ──► classify ──► report
│ │ │ │
ModuleTraits FileFacts reachable_modules Finding + confidence
is_self_starting symbols / imports / reachable_symbols
references (two passes: all roots,
production roots)
- Walk.
cpd-finder's walker lists every file whose extension maps, viacpd_tokenizer::formats, to a format some analyzer serves. This is whyAnalyzer::formatsmust name formats that table already knows. - Read. Each file is read once. The analyzer answers two questions from
the path and the bytes alone:
module_traits(path)andis_self_starting(source). - Analyze.
analyze(input)turns one file intoFileFacts— its declarations (Symbol), its imports (Import) and the names it reads (Reference). Strictly one file: nothing here may look at another file, the filesystem, or the index. - Entry points.
entry.rsdecides which files the program starts from: the analyzer'sentry_globs(), its manifest readers, the frameworksframework.rsdetects (see section 6), the traits and self-starting flags from step 2, scripts in the tree that name a source file, and the user's--entryglobs. - Graph.
graph.rsmerges every file's facts into one address space, asks each import's analyzer tonormalize_importandresolveit, and runs breadth-first reachability twice — once from every entry point (tests included) and once from production entry points only. The difference is what "used only by tests" means. - Classify and report.
classify.rsturns unreachable things into findings under five categories,confidence.rsscores each one, and thecpd-reportercrate renders them.
The design rule that follows from this: an analyzer states facts; it never decides. Whether a declaration is dead is the graph's call, made with every language's facts in hand. An analyzer that "helpfully" suppresses a symbol it thinks is used, or invents a reference it thinks should exist, breaks the cascade and the confidence model for everyone.
pub trait Analyzer: Send + Sync {
// Required.
fn language(&self) -> &'static str; // "ruby"
fn formats(&self) -> &'static [&'static str]; // &["ruby"]
fn analyze(&self, input: &AnalyzeInput<'_>) -> FileFacts;
fn resolve(&self, specifier: &str, importer: &Path, index: &ModuleIndex) -> Option<ModuleId>;
// Optional — defaults mean "nothing special".
fn normalize_import(&self, import: &mut Import, importer: &Path, index: &ModuleIndex) {}
fn entry_globs(&self) -> &'static [&'static str] { &[] }
fn test_globs(&self) -> &'static [&'static str] { &[] }
fn is_self_starting(&self, source: &str) -> bool { source.starts_with("#!") }
fn manifests(&self) -> &'static [&'static str] { &[] }
fn manifest_entries(&self, directory: &Path, manifest: &str, text: &str) -> Vec<PathBuf> { vec![] }
fn manifest_signals(&self, manifest: &str, text: &str) -> ManifestSignals { ManifestSignals::default() }
fn config_setting(&self, config: &str, text: &str, key: &str) -> Option<Setting> { None }
fn alias_configs(&self) -> &'static [&'static str] { &[] }
fn path_aliases(&self, directory: &Path, config: &str, text: &str) -> Vec<PathAlias> { vec![] }
fn import_roots(&self, modules: &[PathBuf]) -> Vec<PathBuf> { vec![] }
fn module_traits(&self, path: &str) -> ModuleTraits { ModuleTraits::default() }
}| Method | Answers | Who consumes it |
|---|---|---|
language |
The id in reports (Finding.language) |
reporters |
formats |
Which jscpd formats this analyzer parses | walker, --format, --list |
analyze |
What one file declares, imports and reads | graph |
resolve |
Which scanned file a specifier names | graph |
normalize_import |
Rewrites an ambiguous import before resolution | graph |
entry_globs |
Files that are entry points by convention | entry detection |
test_globs |
File-name patterns of tests in this language | entry detection, classifier |
is_self_starting |
Does this file declare it runs on its own | entry detection |
manifests / manifest_entries |
Manifest files and what they name | entry detection |
manifest_signals |
The dependencies and sections a manifest declares | framework detection |
config_setting |
The literal a framework config written in this language assigns to a key | framework detection |
alias_configs / path_aliases |
Config files that rename import paths, and what they declare | resolution |
import_roots |
Directories the tree itself implies imports are rooted at | resolution |
module_traits |
Path-only facts: package surface, ambient, attribute reach | classifier, entry detection |
These are the invariants the graph relies on. Each one was found by a false positive on a real codebase; the tests that pin them are named in brackets.
- One
Symbolper declaration the language would let a reader delete. Functions, classes, methods, fields, module-level variables, imports, enums and their members, type aliases. Not parameters, not locals inside a function body, not destructured temporaries. TOP_LEVELmeans module scope. The classifier reports only top-level declarations and class members; anything nested is covered by whatever encloses it. Get this flag right or nothing is reported.MEMBER+parentfor class members. Members are matched by name across the whole scan (basta infers no types), so they are reported under a separate, opt-in category with a low base confidence.- Rebinding is one declaration.
X = athenX = bin the same scope is a single symbol, or the first copy is reported as unused while every reader resolves to the second. Key your dedup by scope, not by class: a nesteddef helpermust not merge with the top-levelhelper. [a_nested_def_does_not_swallow_a_top_level_name] exported_asis the name other modules import it by.export { a as b }givesname: "a", exported_as: Some("b"). For languages without anexportkeyword, encode the convention: Python uses__all__when present and the underscore rule otherwise, and treats an import as exported only in an__init__.py, in__all__, or with an explicit re-export marker.MAGIC,DECORATED,ABSTRACT,OVERRIDEmake a symbol a reachability root or lower its confidence. SetDECORATEDonly for decorators you do not recognise;@property-style decorators that never call the function themselves are inert. [inert_decorators_do_not_count_as_framework_magic]local_refsis the count of references from inside the file, minus the ones that only export the name.export { a }andmodule.exports = { a }are not uses ofa. [an_export_clause_is_not_a_use_of_the_symbol]- A name you could not read is
<computed>or similar. Such a symbol is never reported, which is right: it cannot be matched against anything.
- One
Importper module-level import, with the specifier exactly as written. Resolution isresolve's job, notanalyze's. kindis what is taken:Named(name),Default,Namespace(the whole module object:import * as,import m,const m = require()),StarReExport,SideEffect(import './x', barerequire('./x')), andDynamicfor a specifier that is an expression. ANamespaceorStarReExportmarks the target wildcarded: every export of it may be reached without being named.localis the binding the import creates, as aSymbolIdof kindImport. That is how the unused-import rule works. A destructuredconst { a } = require('./m')bindsa— re-kind it as an import.- Both module systems, if the language has more than one. CommonJS was
invisible for a whole release because only ESM went through the module
record. [
commonjs_require_forms_become_imports] - Compiler directives are not imports.
from __future__ import annotationsbinds a name nobody can use.
- One
Referenceper identifier read, withfromset to the innermost reportable enclosing declaration — top level or member — not simply the innermost. A reference insideconst all = helper()inside an exported function belongs to the function, not toall;allis a local nothing can ever reach, and attributing the call to it strandshelper. [a_local_declaration_does_not_break_the_chain_to_what_it_calls] Bindingfor a plain name,Memberforobj.name/obj["name"],Stringfor an identifier-shaped string literal. Strings never create edges; they lower confidence. Object-literal keys are definitions, not reads — do not record them asMember. [an_object_literal_key_is_not_a_member_read]- Decorators and class bases are read by the enclosing scope, not by the
declaration they decorate. A dead handler must not be the thing keeping
its own
@app.routealive. [a_decorator_belongs_to_the_enclosing_scope_not_the_function_it_decorates] - Assignment targets that are not plain names are reads.
os.environ[k] = vreadsos. Tuple targets bind each element. [an_attribute_assignment_reads_the_name_it_is_assigned_through] - Store context is not a read.
x = 1does not usex.
Set FileFacts::has_dynamic_access when the file resolves names at runtime:
eval, getattr, globals(), require(expr), import(expr), computed
member access with a non-literal key. Every finding in such a file loses
confidence. Do not try to resolve these; record them.
Return FileFacts::unparsed() and nothing else. The run continues, the file
is counted and listed, every finding in the run carries UnparsedModule, and
nothing inside the file is ever reported. Never return partial facts from a
parse that gave up — half a symbol table looks exactly like a file full of
dead code.
-
Consult only
ModuleIndex.index.get(path)says whether a file was scanned. A specifier that resolves to nothing is not an edge; that is the right answer for a dependency or the standard library. The one sanctioned exception is Python's package-root search, which has to see an__init__.pyabove the scan root; it is documented at the call site. -
Use
resolve::normalizefor./..so a specifier climbing out of the scan cannot touch the filesystem or panic, andresolve::append_extensionforx→x.ts. -
Use
normalize_importfor forms the syntax leaves ambiguous. Python'sfrom pkg import thingis a name or a submodule; only the index knows. -
Read the project's own aliases if the language has them. Declare the config file in
alias_configsand parse it inpath_aliases; the collectedPathAliases reach every resolver throughindex.against_aliases(specifier, importer, …). Targets must be absolute and thescopemust be the directory of the config that declared them, so one package of a monorepo never borrows its neighbour's aliases.This is not a nicety. A project that renames its own import paths and is read without them looks almost entirely unreachable, and the findings come out confident, because nothing the resolver can see imports those files. A benchmark of ten trending repositories put basta at 0.96% wrong on nine of them and 36.5% on the one with a
"@/*"alias, before this existed. -
Derive the roots the tree implies, in
import_roots. Python's is the parent of every top-level package, which is how asrc/layout resolvesimport mypkg.thingfrom a script that belongs to no package at all. Consulted once, after the index is built, and tried last — a scan root is what the user asked for, a derived root is an inference. -
Read every position a name can hide in. A type annotation may be a string (
def f(m: "torch.nn.Conv1d")), and theTYPE_CHECKINGidiom exists so that it is one. Parse those: before Python did, 275 of 834 unused-import findings on a trending corpus were wrong, all of them this.
The smallest analyzer that participates correctly. Replace the parser calls with the language's; keep the shape.
//! Ruby, through <parser crate>.
use super::{AnalyzeInput, Analyzer, is_identifier_like};
use crate::model::{
FileFacts, Import, ImportKind, ModuleId, ModuleTraits, Reference, ReferenceKind, Symbol,
SymbolFlags, SymbolId, SymbolKind,
};
use crate::resolve::{ModuleIndex, append_extension, normalize};
use cpd_core::models::Location;
use cpd_tokenizer::line_index::LineIndex;
use std::path::{Path, PathBuf};
pub struct RubyAnalyzer;
impl Analyzer for RubyAnalyzer {
fn language(&self) -> &'static str { "ruby" }
// Must exist in cpd_tokenizer::formats — `ruby` covers .rb, .rake, ...
fn formats(&self) -> &'static [&'static str] { &["ruby"] }
fn analyze(&self, input: &AnalyzeInput<'_>) -> FileFacts {
let Ok(tree) = parse(input.source) else {
return FileFacts::unparsed();
};
let lines = LineIndex::new(input.source.as_bytes());
let mut walk = Walk::new(input.module, &lines, input.source.len());
walk.visit(&tree);
walk.finish()
}
fn resolve(&self, specifier: &str, importer: &Path, index: &ModuleIndex) -> Option<ModuleId> {
// `require_relative 'x'` → x.rb next to the importer.
if let Some(relative) = specifier.strip_prefix("relative:") {
let base = normalize(&importer.parent()?.join(relative));
return index.get(&append_extension(&base, "rb"));
}
// `require 'lib/x'` → try each scan root, then a `lib/` layout.
index.against_roots(specifier, |base| {
index.get(&append_extension(base, "rb"))
})
}
fn entry_globs(&self) -> &'static [&'static str] {
&["**/config.ru", "**/Rakefile", "bin/*", "**/db/seeds.rb", "**/config/**/*.rb"]
}
fn test_globs(&self) -> &'static [&'static str] {
&["**/*_spec.rb", "**/*_test.rb", "**/spec_helper.rb"]
}
fn manifests(&self) -> &'static [&'static str] { &["*.gemspec"] }
fn manifest_entries(&self, directory: &Path, _: &str, text: &str) -> Vec<PathBuf> {
// spec.files / spec.executables name what ships.
gemspec_files(text).map(|f| directory.join(f)).collect()
}
fn module_traits(&self, path: &str) -> ModuleTraits {
ModuleTraits {
// Ruby reaches constants through the object model, so a
// member-style read of a name anywhere is evidence.
names_are_attributes: true,
..ModuleTraits::default()
}
}
}
struct Walk<'a> {
symbols: Vec<Symbol>,
imports: Vec<Import>,
references: Vec<Reference>,
module: ModuleId,
lines: &'a LineIndex,
len: usize,
/// Open declarations, innermost last, each flagged reportable or not.
stack: Vec<(SymbolId, bool)>,
class_stack: Vec<SymbolId>,
dynamic: bool,
}
impl<'a> Walk<'a> {
fn loc(&self, offset: u32) -> Location {
self.lines.location((offset as usize).min(self.len))
}
/// The declaration a reference belongs to: the innermost *reportable* one.
fn current(&self) -> Option<SymbolId> {
self.stack.iter().rev().find_map(|(id, ok)| ok.then_some(*id))
}
fn declare(&mut self, name: String, kind: SymbolKind, start: u32, end: u32, mut flags: SymbolFlags) -> SymbolId {
let top_level = self.stack.is_empty();
let member = kind.is_member();
flags.set(SymbolFlags::TOP_LEVEL, top_level);
flags.set(SymbolFlags::MEMBER, member);
let (start, end) = (self.loc(start), self.loc(end));
let id = SymbolId(self.symbols.len() as u32);
self.symbols.push(Symbol {
id, module: self.module, name, kind, flags,
lines: end.line.saturating_sub(start.line) + 1,
start, end,
exported_as: None, // set per the language's visibility rule
parent: member.then(|| self.class_stack.last().copied()).flatten(),
local_refs: 0, // count after the walk
});
self.stack.push((id, top_level || member));
id
}
fn reference(&mut self, name: String, kind: ReferenceKind, at: u32) {
let from = self.current();
let at = self.loc(at);
self.references.push(Reference { module: self.module, name, kind, from, at });
}
// visit(): on a def → declare + push, walk body, pop.
// on a constant read → reference(Binding)
// on `x.y` → reference(Member, "y")
// on `require 'x'` → imports.push(Import { kind: SideEffect, .. })
// on `send(:name)` / `const_get` → self.dynamic = true
// on a string literal that is_identifier_like → remember it,
// emit as ReferenceKind::String in finish()
}Then register it:
// rust/crates/basta/src/lang/mod.rs
pub mod ruby;
pub static ANALYZERS: &[&dyn Analyzer] =
&[&javascript::JsAnalyzer, &python::PythonAnalyzer, &ruby::RubyAnalyzer];That is the whole change outside the new file. --list, --format ruby,
the walker, the reporters and jscpd --dead-code pick it up from the
registry.
Accuracy is the product. A language that parses but resolves badly reports a working application as dead, and a tool that does that gets turned off. Work through these in order.
Every analyzer has the same test helpers — facts(source) and
symbol(&facts, name) — and covers the same ground. Copy the shape from
python.rs:
- declarations get their kinds, spans and
TOP_LEVEL/MEMBERflags; - the language's visibility rule sets
exported_ascorrectly, including the cases where it must not (_private, a name outside__all__); - every import form yields the right
ImportKindand binds the right local; - references are attributed to the enclosing declaration
(
references_are_attributed_to_the_enclosing_declaration); - an export clause / re-export marker is not a use;
- each dynamic construct sets
has_dynamic_access, and static access does not; - an unparsable file yields
FileFacts::unparsed(); resolvehandles relative, project-absolute and outside-the-scan specifiers, and never panics on../../..;module_traits,is_self_startingand the manifest reader each have a positive and a negative case.
Use crate::test_scan::build(files, entries, tests) — it takes (path, source) pairs, picks the analyzer from the extension, and returns a resolved
Graph. It knows nothing about any particular language. Assert
reachability with test_scan::reachable(&graph, path, name). At minimum:
- a module imported from an entry point is reachable and an orphan is not;
- dead code cascades (a helper called only from a dead function is dead);
- the top level of an imported module runs (its references are roots);
- a class's methods keep the chain open;
- your language's equivalent of a namespace import wildcards its target.
Every feature ships a runnable demo under fixtures/dead-code-demo/<language>/
with one file per category — an unused file, an unused export, a cascading
unused symbol, an unused import — and a README section listing the exact
command and the exact Found N dead code findings line. Run every command
you write down. The smoke job scans fixtures/ as a whole, so keep the
demo's prose and code unique.
Unit tests prove the parser; only real code proves the heuristics. Pick two or three open-source projects in the language — one library, one application, one with a framework — and:
- Run
basta <project> --min-confidence 0and read the categories first. Hundreds of unused imports means the import form is not recognised. Hundreds of unused symbols in files that are clearly used means an entry point or a resolution rule is missing. - Sample twenty findings at the default confidence and verify each by hand
(
grep -rnw <name>). Anything used is a bug in a rule, not in the project; find the rule. - Run at
--min-confidence 90and expect near-100% precision there. If a guess is scoring 90, the confidence model is missing a reason — add aReasonincpd_core::deadcode, anEvidencefield, and the observation that sets it. - Cross-check against the language's own dead-code tool (vulture for Python, knip for TypeScript). Perfect agreement is not the goal — those tools report different things — but every finding basta has that they lack needs an explanation you can write down.
Record what you found in the PR: the projects, the counts before and after each fix, and the false-positive classes you closed. The next language will hit the same classes.
- A new finding category. That is a new
Categoryincpd_core::deadcode, a rule inclassify::category_for, a base score inconfidence::base_score, and a message. Analyzers do not report; they describe. - A new reason a finding might be wrong. A
Reasonincpd_core::deadcodewith a penalty and an explanation, anEvidencefield, and the line inclassify::gather_evidencethat sets it. An analyzer supplies the fact (usually aSymbolFlagsbit); the classifier turns it into evidence. - A new framework. What a framework starts without an import — routes,
plugin directories, handlers by convention — is data, not code: one entry
in
rust/crates/basta/frameworks.yaml, whose header documents the schema (detectby config file, dependency orpackage.jsonsection;variablesread from the config;bases,entryglobs, wholedirectories,autoImports, andglobals— the names the framework reads, which setSymbolFlags::FRAMEWORK_GLOBALinanalyze.rsso the graph roots the declaration and the classifier never reports it).framework::tests::the_built_in_table_is_validchecks the table, and aFramework::root(...)/reaches(...)test pins what a definition roots. The analyzer's part is only reading:manifest_signalsfor the manifest,config_settingfor a config written in its language. A project carries the same shape inbasta.frameworks.yamlfor frameworks that are nobody else's. - A new reporter.
cpd-reporter/src/deadcode/, registered increate_dead_code_reporter; the names must stay in step with the clone reporters. - A resolution feature that spans languages —
tsconfig.jsonpaths, workspace aliases. Read the config inresolveof the language that owns the config; theModuleIndexis deliberately language-neutral.
Documented so a contributor can start from a known place rather than rediscover them:
- Alias configs other than
tsconfig.json/jsconfig.jsonare not read: a@/xdeclared only invite.config.ts,webpack.config.jsor apackage.jsonimportsmap still resolves to nothing. - An
extendsthat names a package ("extends": "@tsconfig/node20") is not followed, because it lives innode_modulesand is never scanned. - Re-export chains are not followed:
export { a } from './m'keepsm.aalive even when nothing importsafrom the barrel. - TypeScript
namespacebodies are not opened as scopes, so their contents are neither reported nor attributed. Import.type_only,SymbolFlags::TYPE_ONLY,PRIVATE_NAMEandIN_DUNDER_ALLare recorded but no rule consults them yet.- A file named by a path string is only seen when the string carries an
explicit source extension (
"./worker.ts"). A build config that assembles the path, or names a directory, is still invisible. - Python module-level
for/with/except … asbindings are not declared. - PEP 420 namespace packages are not import roots:
from lib.context import xresolves only whenlib/carries an__init__.py, since any directory at all could otherwise be one. - A name quoted anywhere other than an annotation is still only weak evidence.
cast("Widget", x)andTypeVar("T", bound="Widget")do not count as reads. - An export used only inside its own file is deliberately not reported; it is a style question, not dead code.