`jscpd rust/` reported 48 clones. This folds every one that was repetition rather than a deliberate copy, leaving 34 across `rust/` and 14 in `rust/crates`, all of the kinds the #1053 pass kept on purpose: fixture corpora, `use` blocks, the TypeScript file and its JavaScript twin, `impl Reporter` headers and the `ts_strip` visitor pair. - basta's JSONC reader copied a string-skipping loop into both `strip_jsonc` and `drop_trailing_commas`. It is now `strip_comments` followed by a comma pass over `lang::outside_strings`, the quote-aware scanning the bundler config readers already share. - The JavaScript and Python analyzer tests each built their own module index, import lookup, binding-reference lookup and member-read list. They live once in `test_scan` as `index_of`, `import`, `binding_reference` and `member_reads`. - `test_scan::TempTree` writes a test's files and removes them when dropped, so a failing assertion no longer leaves its directory behind; the script and `extends` tests that repeated the setup use it. - Four tsconfig `paths` tests with one shape are one table-driven test. - The CLI integration tests share `duplicate_shapes`, `history_stdout` and `assert_only_the_real_file_was_scanned`. - `scripts/publish-npm.sh` repeated a 43-line "publish the platform package, then the main packages under --force-main" tail in two branches, and the "publish unless already live, or show the dry run" block five times. They are `publish_platform_target` and `publish_package`. Run against the old script with the build, the registry check and `npm` stubbed, all eleven combinations of --target, --all, --force-main, --dry-run, --provenance, a failed build and missing platforms print the same lines and exit the same. Behaviour is unchanged: the fixtures corpus reports in every mode, the summary, the console output and the dead-code demo are byte-identical.
basta
Find dead code: unused files, exports, declarations and imports across JavaScript, TypeScript, Vue, Svelte, Astro and Python.
basta builds the import graph from a project's entry points, walks it, and
reports what it never reaches. It ships as its own basta command and inside
jscpd as jscpd --dead-code.
basta src
Unused files (1)
- src/legacy-export.ts certain 95% 11 lines
Unused exports (1)
- function src/invoice.ts:21:17 renderReceipt high 85% 3 lines
Unused symbols (1)
- function src/invoice.ts:29:10 describeTotal certain 90% 3 lines
Unused imports (1)
- import src/invoice.ts:2:10 roundToCents certain 100% 1 lines
Found 4 dead code findings in 4 files (31.6% of 57 lines).
Done in 12ms
What makes it different
It is a traversal, not a reference count. A helper whose only caller is itself dead is reported too. That cascade is most of what a reference-counting linter misses.
It tells you how sure it is. Static analysis of JavaScript and Python
cannot be certain, so every finding carries a score from 0 to 100 and the
reasons it is not higher — a file that calls eval or getattr, an
unrecognised decorator, a wildcard re-export, a name that shows up in a string
literal. --min-confidence sets the floor; the default is 60.
It reads components, not just scripts. A .vue, .svelte or .astro
file is a module wrapped in markup, and the markup is where a component's
imports are actually used. basta reads both halves, so <Foo /> counts as the
use of import Foo from './Foo.vue' — kebab-case spellings, {{ … }}
interpolations, Svelte actions and attribute expressions included. A tool that
stops at </script> does not merely miss findings here; it reports every
component import in the project as dead.
It knows the conventions. A package's __init__.py re-exports are its API,
not unused imports. from __future__ import annotations is a directive, not a
binding. # noqa: F401 and PEP 484's import x as x mean a deliberate
re-export. A package.json pointing at dist/index.js means src/index.ts.
CommonJS counts: require('./x'), module.exports = { a } and a literal
import('./x') are edges like any import. A shell script or CI workflow
that names a source file keeps it alive.
Usage
basta [PATHS]...
--categories <LIST> unused-file, unused-export, unused-symbol,
unused-import, unused-member, or `all`
--min-confidence <N> drop findings below this score (default: 60)
--entry <GLOB> treat matching files as entry points (repeatable)
--ignore <GLOB> skip matching files (repeatable)
--include-tests report dead code inside test files
--include-entry-exports report exports of entry points
--min-lines <N> only report declarations this many lines or longer
-r, --reporters <LIST> console, json, sarif, html, markdown, csv, xml,
codeclimate, openmetrics, badge, xcode, ai, silent
-o, --output <DIR> where file reporters write (default: report)
--threshold <PERCENT> fail when dead code exceeds this share
--exit-code <CODE> exit with this code when anything is found
--format <FORMAT> restrict the scan (repeatable)
--list print the formats basta analyzes
Entry points
Every finding is the answer to nothing reaches this, so the entry points are
the whole basis of the result. basta finds them from package.json
(main, module, bin, exports, files, scripts), pyproject.toml
([project.scripts] and entry-point tables), scripts in the tree (*.sh,
CI workflows, Makefiles, Dockerfiles) that name a source file, and
conventions: src/index.ts,
__main__.py, manage.py, framework routes under pages/ and app/,
*.config.ts, .d.ts declarations, shebangs, if __name__ == "__main__",
every __init__.py, and the file-system routes of the component frameworks —
pages/, layouts/, src/routes/, app.vue, error.vue.
When a project does something none of that covers, say so once:
basta src --entry 'src/handlers/**' --entry 'scripts/*.ts'
It reads the project's own build. resolve.alias from vite.config.*,
kit.alias and $lib from SvelteKit, paths from tsconfig.json, and — in a
monorepo — every workspace package's name, so @acme/ui/date reaches the file
that package's exports names. A specifier computed over a directory
(import(`./locales/${l}.json`), import.meta.glob('./lang/**/*.ts')) is
expanded to the files its pattern matches, the way a bundler expands it; a
literal import('./x.svelte') in a component's markup and the imports of an
Astro client <script> are edges like any other.
A framework that loads whole directories is read from its own config rather
than guessed at: a nuxt.config.ts beside the tree means components/,
composables/, utils/, middleware/, plugins/, modules/ and server/
are reached by the framework, with no file importing them. When no config
declares them, ~/x, @/x and ~~/x fall back to the project root, which is
what they mean in Nuxt — its alias table is generated into .nuxt/ and never
scanned.
Adding a language
One file under src/lang/ implementing Analyzer, and one line in the
ANALYZERS registry. The trait is the only place a language is allowed to be
special: it parses one file into declarations, imports and references,
resolves that language's specifiers against the index of scanned modules,
and declares its entry-point conventions, manifests and path traits. The
walker, the graph, the confidence model, the classifier, the reporters and
both CLIs are language-agnostic and pick the new language up from the
registry.
docs/basta-extending.md is the
walk-through: how a run works, the contract an analyzer has to honour (each
rule there was a real false positive once), a worked skeleton, and how to
prove a new language right against real projects.
License
MIT