The health score
One number for a codebase is only useful when you know what moves it. The health score is built so that every point has a cause you can find in the code: a share of lines went up or down. Knowing the steps tells you which pass pays off first and why a small project with one dead file still scores in the seventies.
| Step | What happens | Constants |
|---|---|---|
| 1. Count the code | Only code files count; prose, data and markup files are left out of every fraction | |
| 2. Measure three shares | Duplicated lines, dead lines and lines in complex files, each divided by the code lines, in percent | a file is complex from a complexity of 50 |
| 3. Adjust for size | Each share is mixed with 2000 lines of a typical project | medians 3.5%, 3.1%, 20.9%; 2000 lines of prior |
| 4. Score each share | A half-life curve: 100 at zero, 50 at one half-life, 25 at two | half-lives 8.5%, 7.5%, 50% |
| 5. Combine | The weighted geometric mean of the sub-scores, then a grade and a size class | grades A from 85, B from 70, C from 55, D from 40 |
The JSON report of a --health run carries every intermediate number, so you can check a score by hand. The constants are fixed in the release and do not follow a live sample, so a score only moves when your code does.
Count the code
Only code files count. Prose (Markdown, text), data (JSON, YAML, TOML, CSV, lock files) and markup (HTML, XML, SVG, CSS, templates) are left out of every calculation, on both sides of each fraction. A folder of copied JSON snapshots does not lower the score, and a long unique HTML page does not raise it.
A Vue, Svelte or Astro component is a code file. Its <template> and <style> blocks are not counted as duplicated code, even when they repeat: the exclusion follows the clone's own format, so a style rule copied between two components stays out of the duplication share while the components themselves count in full. The console line names what it measured and, only when the project has files in that category, what it left out, such as 5.4% in typescript (no text).
The worked example on this page is fixtures/dashboard-demo in the jscpd repository: seven files, of which five are TypeScript with 93 lines together. The README and the bash in its code fences are left out.
Measure three shares
Every dimension is a share of lines, in percent. Because they are shares, a project is not penalized for being large.
| Dimension | Share | In the example |
|---|---|---|
| Duplication | duplicated lines ÷ code lines, the same lines jscpd's own percentage counts, so the score and --threshold talk about one number | 6 ÷ 93 = 6.5% |
| Dead code | dead lines ÷ the lines the dead-code analysis could read | 13 ÷ 93 = 14.0% |
| Complexity | lines in complex files ÷ code lines. A file is complex from a complexity of 50 (complexFile). Complexity hurts when it piles up in a few files, and a mean would hide that | 0 ÷ 93 = 0.0% |
Adjust for size
In a small project one finding is a large share: a single clone in 300 lines is 5%. So each share is mixed with 2000 lines of a typical project before it is scored:
adjusted = (share × lines + median × 2000) ÷ (lines + 2000)
median is what a typical project shows for that dimension. At 300 lines the prior dominates. At 50,000 lines it no longer matters. The JSON report has both numbers, value and adjusted.
| Dimension | Median | In the example |
|---|---|---|
| Duplication | 3.5% | (6.5 × 93 + 3.5 × 2000) ÷ 2093 = 3.6% |
| Dead code | 3.1% | (14.0 × 93 + 3.1 × 2000) ÷ 2093 = 3.6% |
| Complexity | 20.9% of lines in complex files | (0 × 93 + 20.9 × 2000) ÷ 2093 = 20.0% |
Score each share
score = 100 × 2^(−adjusted ÷ halfLife)
The score is 100 at zero, 50 at one half-life and 25 at two. It has no cliff and no dead zone: every extra percent costs something, and no single percent costs everything.
| Dimension | Half-life | In the example |
|---|---|---|
| Duplication | 8.5% | 100 × 2^(−3.6 ÷ 8.5) = 74.4 |
| Dead code | 7.5% | 100 × 2^(−3.6 ÷ 7.5) = 71.8 |
| Complexity | 50% | 100 × 2^(−20.0 ÷ 50) = 75.8 |
The medians and half-lives were chosen so that the median project scores 75 in each dimension. They come from 42 open source projects taken from GitHub trending, from 1.3K to 878K lines of code; 35 of them have JavaScript, TypeScript or Python and were used for dead code. jscpd.dev also publishes a rolling sample of trending projects, measured daily, at jscpd.dev/health-corpus.json. It is there for comparison; the constants do not follow it.
Combine
The overall score is the weighted geometric mean of the sub-scores:
score = exp( Σ weight × ln(subscore) ÷ Σ weight )
A geometric mean does not let one good dimension hide a bad one. A project that is 40% dead code is not rescued by low duplication. A sub-score is never taken below 1 in this step, so one zero cannot erase every other dimension.
All weights are 1 unless you change them, with one exception. Dead code weighs as much as the share of the code it could read: in a project that is 30% TypeScript and 70% Go, the dead-code sub-score gets a weight of 0.3. Rust counts as read once you pass --rust-diagnostics. When the languages it can read are under 5% of the code, dead code is left out and the badge says n/a with the reason. A dimension left out is named, never scored as perfect, and two scores are comparable only when they are built from the same dimensions.
Grades: A from 85, B from 70, C from 55, D from 40, then E. The badge also shows a size class from the code lines: XS under 1K, S under 10K, M under 100K, L under 1M, then XL.
The worked example
jscpd . --health on the demo prints:
Health B 74/100 █████████████████▊░░░░░░ 93 lines of code (XS)
duplication 74 ████████▉░░░ 6.5% in typescript (no text)
dead code 72 ████████▋░░░ 14.0%
complexity 76 █████████▏░░ 0.0% in complex files
The steps above in one table, with the numbers jscpd-health.json holds:
Share (value) | adjusted | Sub-score | |
|---|---|---|---|
| Duplication | 6 ÷ 93 = 6.5% | 3.6% | 74.4 |
| Dead code | 13 ÷ 93 = 14.0% | 3.6% | 71.8 |
| Complexity | 0 ÷ 93 = 0.0% | 20.0% | 75.8 |
The overall score is the cube root of 74.4 × 71.8 × 75.8, which is 74.0, a B. The adjusted shares are shown rounded here, and jscpd rounds only at the end, which is why the two 3.6% give different sub-scores.
The example also shows what the size adjustment does. 14% dead code would score 27 on its own. In a project of 93 lines that is thirteen lines, too few to judge by, so the score stays near the typical 75. The same 14% in a project of 50,000 lines scores about 28.
Metrics from other tools
The score can take dimensions jscpd does not measure, such as test coverage or the result of a security scan, through a metrics file or the health object of the config. A metric is either a ready 0-100 score, or a value with the halfLife that turns it into one on the same curve as the built-in dimensions. With "direction": "higher" the curve runs on the distance to max (100 unless you set it): with a half-life of 40, 81% coverage is 19 short of 100 and scores 72, and 60% scores 50. Each metric has a weight, 1 unless you set it, and joins the geometric mean like any other dimension. Metrics are not adjusted for size, since a coverage percentage of a small project is as true as that of a large one.
The same object tunes the built-in dimensions: a halfLife and a weight per dimension, and complexFile for the bar a file has to reach to count as complex. A weight of 0 leaves a dimension out. Dashboard and health score shows the file, the command and the output.
What it does not measure
- Correctness, tests, security and performance, unless you feed them in as metrics.
- Dead code in languages other than JavaScript, TypeScript, Python and compiler-checked Rust. In a project without them the score has two dimensions, which the badge says.
- Complexity as a reviewer would judge it. The number is a cyclomatic estimate counted from the token stream, one path per function plus one per branch, and the dimension only counts how many lines sit in files above the bar.
- Duplication in prose, data and markup, and clones the run's options do not look for. A run without
--ignore-identifierscounts no renamed copies, so the score depends on the detection options as much as on the code. - Change over time. The score is a snapshot; Duplication over git history tracks one of its dimensions across commits.
Further reading
- Dashboard and health score for running
--healthand--dashboard, the reporters, the metrics file, the tuning keys and the exit codes. - Dead code for what the dead-code dimension counts and how confident each finding is.
- Types of code clones for what the duplication share counts.
- Trending for the score of GitHub trending repositories, measured daily.