Files
apk df7b27c793 feat(dead-code): find code nothing runs, via the new basta engine
Adds a second question about the same tree: not "what is duplicated" but
"what does nothing reach". A new `basta` crate builds the import graph from
the project's entry points and walks it, reporting unused files, unused
exports, unused module-private declarations, unused imports and — opt-in —
unused class members, across JavaScript, TypeScript, JSX, TSX and Python.

It ships two ways: `jscpd --dead-code` inside the existing binary, and a
standalone `basta` command. Both reuse the walker, the filters, all fifteen
reporters and the `--threshold` / `--exit-code` gates, so nothing new has to
be learned to run it.

Because it is a graph traversal rather than a reference count, dead code
cascades: a helper whose only caller is itself dead is reported too. Every
finding carries a confidence score and, below 100, the reasons it might be
wrong — `eval`, an unrecognised decorator, a wildcard re-export, a name seen
only in a string, a file in the scan that did not parse. `--min-confidence`
sets the floor at 60.

Entry points come from `package.json`, `pyproject.toml`, framework and
file-name conventions, and anything a shell script, CI workflow, Makefile or
Dockerfile names by path. CommonJS is read alongside ESM. A project that
renames its own import paths is read on its own terms: `paths` and `baseUrl`
from `tsconfig.json` and `jsconfig.json` are resolved, through a relative
`extends` chain and per package in a monorepo. A file named only by a path
string — a worker spawned through `new URL`, a build entry, a vitest setup
file — counts as used.

Languages plug in through one trait, `basta::lang::Analyzer`, which owns
parsing, specifier resolution, entry-point conventions, manifests, alias
configs and path traits. Nothing outside `lang/` is language-specific;
`docs/basta-extending.md` walks through adding one.

Validated against knip, fallow, vulture, skylos, fossil-mcp and repowise on
trending repositories. On a corpus of ten of them — 11k files, 3.3M lines —
every one of basta's 1,806 findings was checked against an import graph
rebuilt independently of it: no unused-file, unused-import or unused-symbol
claim survived as wrong.

Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
2026-09-16 09:23:13 +02:00

238 lines
6.3 KiB
Rust

// rust/crates/cpd/tests/dead_code.rs — the `--dead-code` mode end to end.
//
// These drive the real binary over the demo fixtures, so they check the thing
// a user actually runs: flag parsing, the mode switch, reporter selection and
// the exit code, not just the engine underneath.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn cpd_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_cpd"))
}
/// The repository root, from this crate's manifest directory.
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("crates/cpd is three levels below the repository root")
.to_path_buf()
}
fn demo(sub: &str) -> PathBuf {
repo_root().join("fixtures/dead-code-demo").join(sub)
}
fn run(args: &[&str]) -> Output {
Command::new(cpd_bin())
.args(args)
.output()
.expect("failed to run cpd")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[test]
fn dead_code_reports_every_category_on_the_typescript_demo() {
let path = demo("typescript");
let out = run(&[
"--dead-code",
path.to_str().unwrap(),
"--no-colors",
"--no-tips",
]);
let text = stdout(&out);
for expected in [
"Unused files",
"src/legacy-export.ts",
"Unused exports",
"renderReceipt",
"Unused symbols",
"describeTotal",
"Unused imports",
"roundToCents",
] {
assert!(text.contains(expected), "missing {expected:?} in:\n{text}");
}
assert!(
text.contains("Found 4 dead code findings"),
"the demo is pinned at four findings:\n{text}"
);
}
#[test]
fn dead_code_reports_the_python_demo() {
let path = demo("python");
let out = run(&[
"--dead-code",
path.to_str().unwrap(),
"--no-colors",
"--no-tips",
]);
let text = stdout(&out);
for expected in ["shop/legacy.py", "refund_order", "_unused_rounding"] {
assert!(text.contains(expected), "missing {expected:?} in:\n{text}");
}
assert!(
!text.contains("complete_order"),
"a name __init__.py re-exports and __main__.py calls is not dead:\n{text}"
);
}
#[test]
fn the_basta_alias_runs_the_same_mode() {
let path = demo("typescript");
let with_flag = stdout(&run(&[
"--dead-code",
path.to_str().unwrap(),
"--no-colors",
"--no-tips",
]));
let with_alias = stdout(&run(&[
"--basta",
path.to_str().unwrap(),
"--no-colors",
"--no-tips",
]));
assert_eq!(
with_flag.lines().filter(|l| l.starts_with(" - ")).count(),
with_alias.lines().filter(|l| l.starts_with(" - ")).count(),
);
}
#[test]
fn without_the_flag_jscpd_still_looks_for_duplicates() {
let path = demo("typescript");
let text = stdout(&run(&[
path.to_str().unwrap(),
"--no-colors",
"--no-tips",
"-r",
"console",
]));
assert!(
!text.contains("dead code findings"),
"dead-code detection must be opt-in:\n{text}"
);
}
#[test]
fn raising_the_confidence_floor_drops_the_uncertain_findings() {
let path = demo("typescript");
let strict = stdout(&run(&[
"--dead-code",
path.to_str().unwrap(),
"--min-confidence",
"90",
"--no-colors",
"--no-tips",
]));
assert!(
!strict.contains("renderReceipt"),
"an export something outside the scan could call is not a 90 finding:\n{strict}"
);
assert!(strict.contains("describeTotal"), "{strict}");
}
#[test]
fn categories_can_be_narrowed() {
let path = demo("typescript");
let text = stdout(&run(&[
"--dead-code",
path.to_str().unwrap(),
"--dead-code-categories",
"unused-import",
"--no-colors",
"--no-tips",
]));
assert!(text.contains("roundToCents"), "{text}");
assert!(!text.contains("Unused files"), "{text}");
assert!(text.contains("Found 1 dead code finding"), "{text}");
}
#[test]
fn an_unknown_category_fails_with_a_message_rather_than_scanning() {
let path = demo("typescript");
let out = run(&[
"--dead-code",
path.to_str().unwrap(),
"--dead-code-categories",
"unused-everything",
"--no-colors",
]);
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("unknown category"), "{stderr}");
}
#[test]
fn an_entry_glob_makes_a_file_live() {
let path = demo("typescript");
let text = stdout(&run(&[
"--dead-code",
path.to_str().unwrap(),
"--entry",
"src/legacy-export.ts",
"--no-colors",
"--no-tips",
]));
assert!(
!text.contains("Unused files"),
"a project says once that a file is an entry point:\n{text}"
);
}
#[test]
fn file_reporters_write_a_dead_code_report() {
let path = demo("python");
let out_dir = std::env::temp_dir().join(format!("basta-cli-{}", std::process::id()));
std::fs::remove_dir_all(&out_dir).ok();
let out = run(&[
"--dead-code",
path.to_str().unwrap(),
"-r",
"json,sarif",
"-o",
out_dir.to_str().unwrap(),
"--no-colors",
"--no-tips",
]);
assert_eq!(out.status.code(), Some(0));
let json = std::fs::read_to_string(out_dir.join("basta-report.json")).expect("json report");
assert!(json.contains("\"unused-file\""), "{json}");
assert!(out_dir.join("basta-report.sarif").is_file());
std::fs::remove_dir_all(&out_dir).ok();
}
#[test]
fn exit_code_is_returned_when_something_is_found() {
let path = demo("python");
let found = run(&[
"--dead-code",
path.to_str().unwrap(),
"--exit-code",
"7",
"-r",
"silent",
]);
assert_eq!(found.status.code(), Some(7));
}
#[test]
fn a_format_dead_code_cannot_analyze_is_refused_rather_than_scanning_nothing() {
let path = demo("typescript");
let out = run(&[
"--dead-code",
path.to_str().unwrap(),
"--format",
"java",
"--no-colors",
]);
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("selected no format"), "{stderr}");
}