Dead code
A clone report says what is written twice. --dead-code asks the other question, what is never run: jscpd builds the import graph of the project from its real entry points, walks it, and reports every file, export, symbol and import the walk never reaches. Every finding is the answer to "no entry point reaches this", and carries a confidence that says how sure jscpd is.
deadCode section of .jscpd.json came in 5.3.1, Rust through --rust-diagnostics in 5.3.2. The analysis, basta, also ships on its own as the basta command.Run it
fixtures/dashboard-demo in the jscpd repository is a five-file TypeScript service with one legacy file nobody imports and one export nobody calls:
cd fixtures/dashboard-demo
jscpd . --dead-code
Unused files (1)
- src/legacy/manifest.ts certain 95% 10 lines
Unused exports (1)
- function src/checks.ts:19:17 checkBatch high 85% 3 lines
Found 2 dead code findings in 5 files (14.0% of 93 lines).
Done in 3ms
Each finding gives the kind of declaration, its file with line and column, its name, a confidence label with the score, and the number of lines it would free. The last line counts the findings, the files the run read, and the share of their lines that is dead. A run walks with the same filters as a clone run (--ignore, --format, .gitignore, --max-size, --follow-symlinks) and reports through the same reporter names.
Reading the output
Findings come in five categories. fixtures/dead-code-demo/typescript shows one of each of the four that are on by default:
cd fixtures/dead-code-demo/typescript
jscpd . --dead-code
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 4ms
| Category | What it means | In the demo |
|---|---|---|
unused-file | No entry point reaches the file through the import graph | src/legacy-export.ts: nothing imports it. Its own exports are not listed underneath, since the file is the finding |
unused-export | An exported name no reachable module imports | renderReceipt: exported from a live file, imported by nothing. Scored 85, since code outside the scan could import it |
unused-symbol | A module-private declaration nothing reaches | describeTotal: its only caller is renderReceipt, which is dead itself. Dead code reached only from dead code is dead too, which a reference count would miss |
unused-import | An import binding with no references | roundToCents: imported and never mentioned again |
unused-member | A class or enum member whose name is never read | Off by default. Without type information, x.render() could call any render in the project, so it is the least certain rule; --dead-code-categories all turns it on |
Python has no export keyword, so the split between the two symbol rules comes from convention: a module-level name without a leading underscore is the module's public surface and is reported as an unused export, a name with one is an unused symbol. A .vue, .svelte or .astro file is read whole, script and markup, since the markup is where a component's imports are used: a component rendered as <shipment-row>, a function called from {{ }}, a store read as $gaugeTheme all count as uses.
Confidence
Static analysis of JavaScript and Python cannot be certain, and jscpd does not pretend. Every finding carries a score from 0 to 100, labelled from low to certain, and below 100 the reasons it might be wrong. The score starts from a base set by how much inference the rule needs: an import binding with no references is a fact (100), an unused file or private symbol is near-certain (95 and 90), an unused export leaves room for a caller outside the scan (85), an unused member is a guess. It then loses points for each piece of contrary evidence: a file that calls eval or getattr, a decorator jscpd does not recognise, a wildcard re-export, a name that appears in a string, a file in the scan that did not parse. A file whose own path ends a string literal somewhere, the way a framework names a file it loads by itself, loses 40 points, which takes it under the default floor.
--min-confidence sets the floor, 60 by default. With the floor at 0, every finding shows with its reasons:
Unused exports (2)
- function screens/account/loyalty.screen.js:9:17 legacyPromoCode low 25% 3 lines
↳ part of the package's published API
- function screens/checkout/totals.js:5:17 splitBetween high 85% 3 lines
How jscpd decides what is dead
Everything rests on the entry points. They come from five places, in decreasing order of authority:
- Manifests.
package.json'smain,module,bin,exports,filesandscripts;pyproject.toml's[project.scripts]and entry-point tables. A manifest that names a built file (./dist/index.js) is mapped back to the source it was built from. - Frameworks. A router that turns
pages/into URLs, a runtime that loadsplugins/whole, a test runner that loads a setup file. jscpd detects a framework per directory from its config file, from the dependencies ofpackage.jsonor from its section there, and every framework that matches is in force at once. The built-in table,frameworks.yaml, holds 56 definitions in jscpd 5.4.0, Django, Alembic and Scrapy among them. A definition also lists the names its framework reads out of the code,getServerSidePropsin a Next page orngOnIniton an Angular class, and a declaration under one of them is used. The console report names what it detected. - Conventions.
src/index.ts,__main__.py,manage.py,pages/andapp/routes,*.config.ts, a.d.tsambient declaration, a file with a shebang, a Pythonif __name__ == "__main__"guard, and a package's__init__.py. - Scripts. A shell script, a CI workflow, a Makefile or a Dockerfile in the tree that names a source file by path runs it, copies it or ships it, so that file is an entry point.
- You.
--entry <glob>, repeatable, adds entry points and never removes one.
Import paths are then read the way the project's own build reads them: aliases from tsconfig.json and jsconfig.json paths, from vite.config.* resolve.alias and from svelte.config.* kit.alias (SvelteKit's $lib and WXT's @, ~, @@ and ~~ need no config, since the files that declare them are generated); globs such as import(`./pages/${name}.vue`) and import.meta.glob('./locales/*.js'), which reach every file their pattern matches; workspace package names, which resolve through that package's own package.json, exports subpath by subpath, preferring source conditions over ./dist. Two breadth-first walks follow, one over import edges to decide which files run and one over reference edges to decide which declarations run.
The Vite project in the demo keeps half its graph in vite.config.js, a template-literal import and a glob, and still reports only what is dead:
cd fixtures/dead-code-demo/bundler
jscpd . --dead-code
Frameworks: vite
Unused files (1)
- src/legacy/courier-api.js certain 95% 5 lines
Unused exports (1)
- function src/router.js:8:17 preloadPage high 85% 3 lines
Found 2 dead code findings in 9 files (12.5% of 64 lines).
Done in 7ms
Test files are always entry points, so a test file is never unused. Dead code inside one is off by default, and --include-tests turns it on. An export only the test suite imports is reported separately, and says so.
Common tasks
Keep only what jscpd is sure of
Raising the floor is the first thing to try on a codebase that does something unusual. On the whole demo folder, nine small projects, the exported names drop out at 90, since those are the findings a caller outside the scan could invalidate:
cd fixtures/dead-code-demo
jscpd . --dead-code
jscpd . --dead-code --min-confidence 90
Found 26 dead code findings in 47 files (30.7% of 449 lines).
Found 19 dead code findings in 47 files (26.3% of 449 lines).
--dead-code-categories narrows the report to the categories you want to act on, for example --dead-code-categories unused-file,unused-import for the two that are safest to delete.
Tell jscpd about files a framework starts
fixtures/dead-code-demo/frameworks/custom runs on an in-house router that mounts every screens/**/*.screen.js. With no description of the router (move its basta.frameworks.yaml out of the folder to see this), every screen reads as dead:
cd fixtures/dead-code-demo/frameworks/custom
jscpd . --dead-code
Unused files (3)
- screens/account/loyalty.screen.js certain 95% 19 lines
- screens/checkout/basket.screen.js certain 95% 9 lines
- screens/checkout/totals.js certain 95% 7 lines
Found 3 dead code findings in 5 files (68.6% of 51 lines).
Done in 4ms
--entry says it once for a run:
jscpd . --dead-code --entry 'screens/**/*.screen.js'
Unused exports (1)
- function screens/checkout/totals.js:5:17 splitBetween high 85% 3 lines
Found 1 dead code findings in 5 files (5.9% of 51 lines).
Done in 5ms
A description of the framework says it for good, in the shape of the built-in table. basta.frameworks.yaml (or .yml, .json) in the working directory is picked up on its own; the demo's is detected by the kioskRouter section of its package.json, roots the screens, and names guard as a function the router calls, so that export is never reported:
frameworks:
- name: kiosk-router
detect:
packageJsonKeys: [kioskRouter]
variables:
screensDir: screens
entry:
- "${screensDir}/**/*.screen.js"
globals:
- names: [guard]
files: ["${screensDir}/**/*.screen.js"]
Frameworks: kiosk-router
Unused exports (1)
- function screens/checkout/totals.js:5:17 splitBetween high 85% 3 lines
Found 1 dead code findings in 5 files (5.9% of 51 lines).
Done in 6ms
A screen is an entry point, so its exports are reported only with --include-entry-exports, at a low confidence that says why: the legacyPromoCode finding in the confidence example above comes from this project, and guard is not next to it.
Put the settings in .jscpd.json
A project that keeps a config says its dead-code settings there once, in a section called deadCode (dead-code and basta are the same key). fixtures/dead-code-demo/config has one with a confidence floor, a dead-code budget, an entry glob and an inline framework definition:
{
"threshold": 10,
"deadCode": {
"minConfidence": 90,
"threshold": 40,
"entry": ["tools/*.js"],
"frameworks": [
{
"name": "job-runner",
"detect": { "packageJsonKeys": ["jobRunner"] },
"entry": ["jobs/**/*.job.js"],
"globals": [{ "names": ["schedule"], "files": ["jobs/**/*.job.js"] }]
}
]
}
}
cd fixtures/dead-code-demo/config
jscpd . --dead-code
Using config from .jscpd.json
Frameworks: job-runner
Unused files (1)
- src/legacy-zpl.js certain 95% 7 lines
Found 1 dead code findings in 5 files (14.0% of 50 lines).
Done in 5ms
jscpd looks for the config in the working directory, so the same project scanned from its parent folder (jscpd config --dead-code) reports four findings: the job the runner starts, the tool run by hand, the legacy module and an export nothing calls. A flag beats the section for one run, and the section configures the mode without switching it on: a plain jscpd in that directory still looks for clones unless the section says "enabled": true.
Read Rust dead code from the compiler
jscpd does not parse Rust. The compiler already finds unused code with real name resolution, trait dispatch and macro expansion behind it, so jscpd reads the output of cargo check and reports it next to everything else. jscpd never runs cargo itself, because that would execute the project's build scripts:
cargo check --all-targets --message-format=json | jscpd . --dead-code --rust-diagnostics -
fixtures/dead-code-demo/rust commits that output as cargo-check.json, and its .jscpd.json names the file as rustDiagnostics, so a plain run works without a Rust toolchain:
cd fixtures/dead-code-demo/rust
jscpd . --dead-code
Using config from .jscpd.json
Unused symbols (4)
- function src/layout.rs:5:8 render_return certain 100% 3 lines
- function src/lib.rs:15:4 reprint certain 100% 3 lines
- class src/lib.rs:19:8 Roll certain 100% 4 lines
- variable src/lib.rs:30:7 MAX_PER_ROLL certain 100% 1 lines
Unused imports (1)
- import src/lib.rs:3:5 HashMap certain 100% 1 lines
Unused members (1)
- method src/lib.rs:25:8 fits certain 100% 3 lines
Found 6 dead code findings in 2 files (40.5% of 37 lines).
Done in 5ms
Every finding is at 100%, because the compiler resolved every name. dead_code on a function, struct, enum, constant or trait is an unused symbol, on a method, field or variant an unused member, and unused_imports is an unused import. The compiler's span covers only the name, so jscpd reads each item's real size from the source. A finding from a test target is reported only with --include-tests, at 85%. jscpd reports only what the compiler reports: a pub item of a library is never reported, code behind a feature the check did not build is reported as dead, and #[allow(dead_code)] hides an item from both. The same flag works with --dashboard and --health, so a Rust project gets its health badge from the check it already runs:
jscpd . --health --rust-diagnostics cargo-check.json
Health B 74/100 █████████████████▊░░░░░░ 30 lines of code (XS)
duplication 76 █████████░░░ 0.0% in rust (no data)
dead code 70 ████████▌░░░ 40.5%
complexity 75 █████████░░░ 0.0% in complex files
Gate it in CI
--threshold in a dead-code run is a budget for the dead share. The demo service at 14% fails a budget of 10 and exits 1:
cd fixtures/dashboard-demo
jscpd . --dead-code --threshold 10
ERROR: basta found too much dead code (14.0%) over threshold (10.0%)
Found 2 dead code findings in 5 files (14.0% of 93 lines).
--exit-code exits 1 (or the code you give) on any finding, --fail-on-empty exits 1 when the run read no file, and --reporters sarif writes basta-report.sarif for GitHub code scanning. Use jscpd in CI shows the workflows.
Options
| Flag | Config key | What it does | Default |
|---|---|---|---|
--dead-code | deadCode.enabled | Find dead code instead of clones | off |
--dead-code-categories <LIST> | deadCode.categories | unused-file, unused-export, unused-symbol, unused-import, unused-member, or all | the first four |
--min-confidence <N> | deadCode.minConfidence | Drop findings below this confidence, 0 to 100 | 60 |
--entry <GLOB> | deadCode.entry | Add entry points; the flag repeats | manifests, frameworks, conventions and scripts |
--include-tests | deadCode.includeTests | Report dead code inside test, fixture and example files | off |
--include-entry-exports | deadCode.includeEntryExports | Report exports of entry-point files, which are usually a public API | off |
--rust-diagnostics <FILE> | deadCode.rustDiagnostics | A file of cargo check --message-format=json output, or - for stdin (5.3.2+) | none |
--threshold <N> | deadCode.threshold, else the top-level threshold | Exit 1 when the dead share is over N percent | none |
The section also takes minLines (the smallest declaration worth reporting), ignore (globs skipped in a dead-code run only), frameworks (definitions inline), frameworksConfig (a file of definitions instead of basta.frameworks.yaml), framework (frameworks to take as present) and noFrameworks. A misspelled key inside the section is reported by name and the section is left out. The full option list is in CLI options and Configuration file.
Reporters
| Reporter | Output |
|---|---|
console | The list above; console-full adds the source lines of each finding |
ai | One line per finding with its confidence and message, for an agent |
json | basta-report.json: findings[] with category, path, name, symbolKind, language, start, end, lines, confidence and message, and statistics |
sarif | basta-report.sarif, with the category as the rule id, for code scanning |
markdown, html, csv, xml | basta-report.md, basta-report.html, basta-report.csv, basta-report.xml |
badge | basta-badge.svg |
codeclimate | basta-codeclimate.json, for GitLab code quality |
openmetrics | basta-report.txt |
The files go to --output (./report by default). The statistics object of the JSON report for the demo service:
"statistics": {
"files": 5,
"unparsed": 0,
"reachableFiles": 4,
"symbols": 22,
"entryPoints": 1,
"byCategory": [
{
"category": "unused-file",
"count": 1,
"lines": 10
},
{
"category": "unused-export",
"count": 1,
"lines": 3
}
],
"deadLines": 13,
"totalLines": 93,
"percentage": 13.978494623655912,
"detectionDate": "2026-10-06T14:49:51.755Z"
}
Exit codes
| Code | When |
|---|---|
0 | The run finished and no gate tripped. Findings alone do not change the code |
1 | The dead share is over --threshold or deadCode.threshold; a finding was made with --exit-code; no file was read with --fail-on-empty; an option is wrong, such as an unknown category |
N | --exit-code N and at least one finding |
Exit codes covers the rest.
In the health score
The health score has a dead-code dimension: the dead lines divided by the lines the analysis could read, scored on a half-life of 7.5% after the size adjustment. The dimension weighs as much as the share of the code jscpd could read, so in a project that is 30% TypeScript and 70% Go it counts for 0.3, and when that share is under 5% the dimension is left out and named n/a. Rust counts as read once you pass --rust-diagnostics. The health score has the formula and a worked example.
Limits
- An import jscpd cannot resolve is a missing edge, and the file behind it is reported as unused at high confidence. That happens with an alias declared somewhere jscpd does not read, a glob it cannot expand, a file a framework it does not know loads by name, and an
import()whose path is computed at runtime.--entry, a framework definition or a higher confidence floor are the fixes, in that order of precision. - No type inference: a member access matches members by name across the whole project, which is why
unused-memberis opt-in. - No runtime resolution:
getattr(obj, name), animport(expr)with no static directory to expand over, and a module object passed as a parameter are recorded as uncertainty and lower confidence. - No Markdown or MDX: an
importwritten in an.mdxpage is not an edge, so a component used only from content reads as unused. - An export used only inside its own file is not reported. The
exportkeyword is then unnecessary, and the code is alive. - Only the five categories: unused dependencies, unused files in other languages and unused local variables are out of scope. Rust has no unused-file or unused-export category, since the compiler reports neither.
- A file that fails to parse lowers the confidence of every finding in the run, because its references are unknown. The console trailer names such files, up to ten, and the JSON report lists them under
statistics.unparsedFiles.
Related
- Dashboard and health score shows the largest findings next to duplication and complexity.
- The health score explains how the dead share becomes a sub-score.
- Editors fades dead code in the editor through
jscpd --lsp. - Agents covers the
codebase-refactoringskill, which removes dead code as its second pass.
Dashboard and health score
One screen with a project's size, duplication, complexity and dead code from jscpd --dashboard, and the health badge alone from jscpd --health.
Comparing two codebases
Measure a port to another language, or check two implementations of one app for parity, with jscpd --compare: example reports in the console, JSON, Markdown and HTML, and how to read each one.