Guides

Dead code

Find the files, exports, symbols and imports nothing runs with jscpd --dead-code, in JavaScript, TypeScript, Python and compiler-checked Rust, each finding with a confidence.

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.

Available from jscpd 5.3.0 for JavaScript, TypeScript, JSX, TSX, Vue, Svelte, Astro and Python, ESM and CommonJS alike. Framework detection and the 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:

Terminal
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:

Terminal
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
CategoryWhat it meansIn the demo
unused-fileNo entry point reaches the file through the import graphsrc/legacy-export.ts: nothing imports it. Its own exports are not listed underneath, since the file is the finding
unused-exportAn exported name no reachable module importsrenderReceipt: exported from a live file, imported by nothing. Scored 85, since code outside the scan could import it
unused-symbolA module-private declaration nothing reachesdescribeTotal: 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-importAn import binding with no referencesroundToCents: imported and never mentioned again
unused-memberA class or enum member whose name is never readOff 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:

  1. Manifests. package.json's main, module, bin, exports, files and scripts; 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.
  2. Frameworks. A router that turns pages/ into URLs, a runtime that loads plugins/ whole, a test runner that loads a setup file. jscpd detects a framework per directory from its config file, from the dependencies of package.json or 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, getServerSideProps in a Next page or ngOnInit on an Angular class, and a declaration under one of them is used. The console report names what it detected.
  3. Conventions. src/index.ts, __main__.py, manage.py, pages/ and app/ routes, *.config.ts, a .d.ts ambient declaration, a file with a shebang, a Python if __name__ == "__main__" guard, and a package's __init__.py.
  4. 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.
  5. 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:

Terminal
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:

Terminal
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:

Terminal
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:

Terminal
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:

basta.frameworks.yaml
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:

.jscpd.json
{
  "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"] }]
      }
    ]
  }
}
Terminal
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:

Terminal
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:

Terminal
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:

Terminal
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:

Terminal
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

FlagConfig keyWhat it doesDefault
--dead-codedeadCode.enabledFind dead code instead of clonesoff
--dead-code-categories <LIST>deadCode.categoriesunused-file, unused-export, unused-symbol, unused-import, unused-member, or allthe first four
--min-confidence <N>deadCode.minConfidenceDrop findings below this confidence, 0 to 10060
--entry <GLOB>deadCode.entryAdd entry points; the flag repeatsmanifests, frameworks, conventions and scripts
--include-testsdeadCode.includeTestsReport dead code inside test, fixture and example filesoff
--include-entry-exportsdeadCode.includeEntryExportsReport exports of entry-point files, which are usually a public APIoff
--rust-diagnostics <FILE>deadCode.rustDiagnosticsA file of cargo check --message-format=json output, or - for stdin (5.3.2+)none
--threshold <N>deadCode.threshold, else the top-level thresholdExit 1 when the dead share is over N percentnone

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

ReporterOutput
consoleThe list above; console-full adds the source lines of each finding
aiOne line per finding with its confidence and message, for an agent
jsonbasta-report.json: findings[] with category, path, name, symbolKind, language, start, end, lines, confidence and message, and statistics
sarifbasta-report.sarif, with the category as the rule id, for code scanning
markdown, html, csv, xmlbasta-report.md, basta-report.html, basta-report.csv, basta-report.xml
badgebasta-badge.svg
codeclimatebasta-codeclimate.json, for GitLab code quality
openmetricsbasta-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

CodeWhen
0The run finished and no gate tripped. Findings alone do not change the code
1The 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-member is opt-in.
  • No runtime resolution: getattr(obj, name), an import(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 import written in an .mdx page 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 export keyword 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.
  • 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-refactoring skill, which removes dead code as its second pass.