mirror of
https://github.com/kucherenko/jscpd.git
synced 2026-09-19 08:11:03 +08:00
2baf2890d4
basta reported most of a real Vue, Svelte or Astro project as dead: on
thirty GitHub-trending projects in those three ecosystems it flagged 1572
unused files, and some import in the tree contradicted 78% of them. The
misses were almost never in the analysis itself; they were imports only the
project's own build knows how to resolve. This reads them the way the build
does.
- Single-file components are read whole. The script of a .vue, .svelte or
.astro file is masked in place so positions stay real, and the markup is
scanned for the names it reads: <Foo />, kebab-case spellings, {{ }}
interpolations, Svelte actions, attribute expressions, and a literal
import() written in markup.
- Aliases come from vite.config.* resolve.alias and svelte.config.* kit.alias
as well as tsconfig paths, and SvelteKit's $lib is known without the
generated .svelte-kit/tsconfig.json no repository commits. The project's
own declaration now wins over the ~/ @/ project-root fallback.
- A computed import whose static head names a directory,
import(`./pages/${name}.vue`), and import.meta.glob('./locales/*.js') are
expanded into one namespace edge per file there, the way a bundler hands
back whole module objects. Such an import no longer marks its module as
dynamic access, so the module's own findings keep their confidence.
- In a monorepo a package name resolves through that package's package.json,
exports subpath by subpath with source conditions preferred over ./dist,
anywhere in the workspace pnpm-workspace.yaml, lerna.json, turbo.json or a
workspaces field marks out.
- A path literal in a build config resolves against the project root, and
Nitro's api/, routes/ and middleware/ are file-system routes.
On the same thirty projects reported unused files fell from 1572 to 468.
fixtures/dead-code-demo gains components/, bundler/ and monorepo/. On master
the bundler demo reports five files as certainly dead, four of them wrongly;
the monorepo demo reports badge.ts, which the app imports by package name.
Both now report only what is dead. The quote-aware byte scanning the markup
and config readers share lives in one place, lang::Quotes and
lang::outside_strings, so rust/crates keeps master's 25 clones.
484 lines
24 KiB
Markdown
484 lines
24 KiB
Markdown
# Extending basta: adding a language
|
|
|
|
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`](../rust/crates/basta/src/lang/mod.rs), 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](#3-the-contract) are the ones that took real bugs to find.
|
|
|
|
## 1. How a run works
|
|
|
|
```
|
|
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)
|
|
```
|
|
|
|
1. **Walk.** `cpd-finder`'s walker lists every file whose extension maps, via
|
|
`cpd_tokenizer::formats`, to a format some analyzer serves. This is why
|
|
[`Analyzer::formats`](../rust/crates/basta/src/lang/mod.rs) must name formats that table already knows.
|
|
2. **Read.** Each file is read once. The analyzer answers two questions from
|
|
the path and the bytes alone: `module_traits(path)` and
|
|
`is_self_starting(source)`.
|
|
3. **Analyze.** `analyze(input)` turns one file into `FileFacts` — 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.
|
|
4. **Entry points.** `entry.rs` decides which files the program starts from:
|
|
the analyzer's `entry_globs()`, its manifest readers, the traits and
|
|
self-starting flags from step 2, scripts in the tree that name a source
|
|
file, and the user's `--entry` globs.
|
|
5. **Graph.** `graph.rs` merges every file's facts into one address space,
|
|
asks each import's analyzer to `normalize_import` and `resolve` it, 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.
|
|
6. **Classify and report.** `classify.rs` turns unreachable things into
|
|
findings under five categories, `confidence.rs` scores each one, and the
|
|
`cpd-reporter` crate 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.
|
|
|
|
## 2. The trait
|
|
|
|
```rust
|
|
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_entry_directories(&self, directory: &Path, manifest: &str, text: &str) -> Vec<PathBuf> { vec![] }
|
|
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_entry_directories` | Directories a framework loads whole, from those same manifests | entry 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 |
|
|
|
|
## 3. The contract
|
|
|
|
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.
|
|
|
|
### Symbols
|
|
|
|
- **One `Symbol` per 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_LEVEL` means 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` + `parent` for 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 = a` then `X = b` in 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 nested
|
|
`def helper` must not merge with the top-level `helper`.
|
|
[`a_nested_def_does_not_swallow_a_top_level_name`]
|
|
- **`exported_as` is the name other modules import it by.** `export { a as b }`
|
|
gives `name: "a", exported_as: Some("b")`. For languages without an
|
|
`export` keyword, 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`, `OVERRIDE`** make a symbol a
|
|
reachability root or lower its confidence. Set `DECORATED` only 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_refs`** is the count of references from inside the file, minus
|
|
the ones that only *export* the name. `export { a }` and
|
|
`module.exports = { a }` are not uses of `a`.
|
|
[`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.
|
|
|
|
### Imports
|
|
|
|
- **One `Import` per module-level import, with the specifier exactly as
|
|
written.** Resolution is `resolve`'s job, not `analyze`'s.
|
|
- **`kind` is what is taken:** `Named(name)`, `Default`, `Namespace` (the
|
|
whole module object: `import * as`, `import m`, `const m = require()`),
|
|
`StarReExport`, `SideEffect` (`import './x'`, bare `require('./x')`), and
|
|
`Dynamic` for a specifier that is an expression. A `Namespace` or
|
|
`StarReExport` marks the target *wildcarded*: every export of it may be
|
|
reached without being named.
|
|
- **`local` is the binding the import creates,** as a `SymbolId` of kind
|
|
`Import`. That is how the unused-import rule works. A destructured
|
|
`const { a } = require('./m')` binds `a` — 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 annotations`
|
|
binds a name nobody can use.
|
|
|
|
### References
|
|
|
|
- **One `Reference` per identifier read,** with `from` set to the innermost
|
|
*reportable* enclosing declaration — top level or member — not simply the
|
|
innermost. A reference inside `const all = helper()` inside an exported
|
|
function belongs to the function, not to `all`; `all` is a local nothing
|
|
can ever reach, and attributing the call to it strands `helper`.
|
|
[`a_local_declaration_does_not_break_the_chain_to_what_it_calls`]
|
|
- **`Binding` for a plain name, `Member` for `obj.name` / `obj["name"]`,
|
|
`String` for an identifier-shaped string literal.** Strings never create
|
|
edges; they lower confidence. Object-literal *keys* are definitions, not
|
|
reads — do not record them as `Member`. [`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.route` alive. [`a_decorator_belongs_to_the_enclosing_scope_not_the_function_it_decorates`]
|
|
- **Assignment targets that are not plain names are reads.** `os.environ[k]
|
|
= v` reads `os`. Tuple targets bind each element.
|
|
[`an_attribute_assignment_reads_the_name_it_is_assigned_through`]
|
|
- **Store context is not a read.** `x = 1` does not use `x`.
|
|
|
|
### Dynamic access
|
|
|
|
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.
|
|
|
|
### Parse failures
|
|
|
|
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.
|
|
|
|
### Resolution
|
|
|
|
- **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__.py` above the scan root; it is documented at the call site.
|
|
- **Use `resolve::normalize` for `.`/`..`** so a specifier climbing out of the
|
|
scan cannot touch the filesystem or panic, and `resolve::append_extension`
|
|
for `x` → `x.ts`.
|
|
- **Use `normalize_import` for forms the syntax leaves ambiguous.** Python's
|
|
`from pkg import thing` is 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_configs` and parse it in `path_aliases`; the
|
|
collected `PathAlias`es reach every resolver through
|
|
`index.against_aliases(specifier, importer, …)`. Targets must be absolute
|
|
and the `scope` must 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 a `src/` layout resolves
|
|
`import mypkg.thing` from 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 the `TYPE_CHECKING` idiom 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.
|
|
|
|
## 4. A worked skeleton
|
|
|
|
The smallest analyzer that participates correctly. Replace the parser calls
|
|
with the language's; keep the shape.
|
|
|
|
```rust
|
|
//! 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
|
|
// 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.
|
|
|
|
## 5. Proving it right
|
|
|
|
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.
|
|
|
|
### Unit tests in the analyzer file
|
|
|
|
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`/`MEMBER` flags;
|
|
- the language's visibility rule sets `exported_as` correctly, including the
|
|
cases where it must *not* (`_private`, a name outside `__all__`);
|
|
- every import form yields the right `ImportKind` and 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()`;
|
|
- `resolve` handles relative, project-absolute and outside-the-scan
|
|
specifiers, and never panics on `../../..`;
|
|
- `module_traits`, `is_self_starting` and the manifest reader each have a
|
|
positive and a negative case.
|
|
|
|
### Graph tests
|
|
|
|
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.
|
|
|
|
### A fixture and a README
|
|
|
|
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.
|
|
|
|
### Real projects
|
|
|
|
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:
|
|
|
|
1. Run `basta <project> --min-confidence 0` and 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.
|
|
2. 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.
|
|
3. Run at `--min-confidence 90` and expect near-100% precision there. If a
|
|
guess is scoring 90, the confidence model is missing a reason — add a
|
|
`Reason` in `cpd_core::deadcode`, an `Evidence` field, and the
|
|
observation that sets it.
|
|
4. 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.
|
|
|
|
## 6. Things that look like analyzer work but are not
|
|
|
|
- **A new finding category.** That is a new `Category` in
|
|
`cpd_core::deadcode`, a rule in `classify::category_for`, a base score in
|
|
`confidence::base_score`, and a message. Analyzers do not report; they
|
|
describe.
|
|
- **A new reason a finding might be wrong.** A `Reason` in `cpd_core::deadcode`
|
|
with a penalty and an explanation, an `Evidence` field, and the line in
|
|
`classify::gather_evidence` that sets it. An analyzer supplies the fact
|
|
(usually a `SymbolFlags` bit); the classifier turns it into evidence.
|
|
- **A new reporter.** `cpd-reporter/src/deadcode/`, registered in
|
|
`create_dead_code_reporter`; the names must stay in step with the clone
|
|
reporters.
|
|
- **A resolution feature that spans languages** — `tsconfig.json` `paths`,
|
|
workspace aliases. Read the config in `resolve` of the language that owns
|
|
the config; the `ModuleIndex` is deliberately language-neutral.
|
|
|
|
## 7. Known gaps worth picking up
|
|
|
|
Documented so a contributor can start from a known place rather than
|
|
rediscover them:
|
|
|
|
- Alias configs other than `tsconfig.json`/`jsconfig.json` are not read: a
|
|
`@/x` declared only in `vite.config.ts`, `webpack.config.js` or a
|
|
`package.json` `imports` map still resolves to nothing.
|
|
- An `extends` that names a package (`"extends": "@tsconfig/node20"`) is not
|
|
followed, because it lives in `node_modules` and is never scanned.
|
|
- Re-export chains are not followed: `export { a } from './m'` keeps `m.a`
|
|
alive even when nothing imports `a` from the barrel.
|
|
- TypeScript `namespace` bodies are not opened as scopes, so their contents
|
|
are neither reported nor attributed.
|
|
- `Import.type_only`, `SymbolFlags::TYPE_ONLY`, `PRIVATE_NAME` and
|
|
`IN_DUNDER_ALL` are 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 … as` bindings are not
|
|
declared.
|
|
- PEP 420 namespace packages are not import roots: `from lib.context import x`
|
|
resolves only when `lib/` 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)` and `TypeVar("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.
|