Guides

Project Health Score

One 0-100 score with a grade for a codebase, from how much of it is duplicated, dead or concentrated in complex files, extensible with coverage, test and security metrics.

Duplication, dead code and complexity each tell part of the story. --health folds them into one number you can put on a README, track over time, or gate on, and --dashboard shows it on top of the screen.

Available from jscpd 5.3.0. Dead code is measured for JavaScript, TypeScript and Python, including Vue, Svelte and Astro components, and for Rust when you pass the compiler's diagnostics with --rust-diagnostics. This page describes the formula as of 5.3.2.

Running it

Terminal
jscpd src --health                                   # the badge
jscpd src --health --reporters json,badge            # jscpd-health.json + jscpd-health-badge.svg
jscpd src --health --health-input metrics.json       # with coverage, tests, security
Health  B   74/100  █████████████████▊░░░░░░  93 lines of code (XS)
  duplication   75  █████████░░░  5.4% in typescript (no text)
  dead code     72  ████████▋░░░  14.0%
  complexity    76  █████████▏░░  0.0% in complex files

Each sub-score is followed by what it was measured from. A dimension that cannot be measured is named (dead code n/a) rather than scored as perfect.

How it is calculated

The score is built in five steps. Each one is small, and the JSON report carries every intermediate number, so you can check a score by hand.

1. 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.

2. Measure three shares

Every dimension is a share of lines, in percent. Because they are shares, a project is not penalized for being large.

DimensionShare
Duplicationduplicated lines ÷ code lines. The same lines jscpd's own percentage counts, so the score and --threshold talk about one number
Dead codedead lines ÷ the lines the dead-code analysis could read
Complexitylines 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

3. 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 (see the table below). At 300 lines the prior dominates. At 50,000 lines it no longer matters. The JSON report has both numbers, value and adjusted.

4. Turn each share into a sub-score

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.

DimensionMedianHalf-life
Duplication3.5%8.5%
Dead code3.1%7.5%
Complexity20.9% of lines in complex files50%

The half-lives were chosen so that the median project scores 75 in each dimension. The numbers 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. They are constants of the release: they do not change from day to day, so a score only moves when your code does.

5. 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, 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 skipped and the report says so.

Grades: A from 85, B from 70, C from 55, D from 40, then E. The badge also shows a size class: XS under 1K lines of code, S under 10K, M under 100K, L under 1M, then XL.

Two scores are comparable only when they are built from the same dimensions.

A worked example

The run at the top of this page is fixtures/dashboard-demo: 93 lines of TypeScript, with 5 duplicated lines, 13 dead lines and no complex file.

ShareAdjustedSub-score
Duplication5 ÷ 93 = 5.4%(5.4 × 93 + 3.5 × 2000) ÷ 2093 = 3.6%100 × 2^(−3.6 ÷ 8.5) = 74.7
Dead code13 ÷ 93 = 14.0%(14.0 × 93 + 3.1 × 2000) ÷ 2093 = 3.6%100 × 2^(−3.6 ÷ 7.5) = 71.8
Complexity0 ÷ 93 = 0.0%(0 × 93 + 20.9 × 2000) ÷ 2093 = 20.0%100 × 2^(−20.0 ÷ 50) = 75.8

The overall score is the cube root of 74.7 × 71.8 × 75.8, which is 74.1, a B. These are the numbers in jscpd-health.json. The adjusted shares are shown rounded here, and jscpd rounds only at the end.

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.

jscpd.dev also publishes a rolling sample of GitHub trending projects, measured daily, at jscpd.dev/health-corpus.json. It is there for comparison. The constants above do not follow it.

Metrics from other tools

--health-input FILE (config key healthInput) adds what jscpd cannot measure. A metric is either a ready 0-100 score, or a value with the halfLife that turns it into one; "direction": "higher" scores the distance to max (default 100).

metrics.json
{
  "metrics": [
    { "id": "coverage", "value": 81, "direction": "higher", "halfLife": 40 },
    { "id": "failing-tests", "value": 2, "halfLife": 5 },
    { "id": "security", "score": 100, "weight": 2 }
  ]
}

With halfLife: 40, 81% coverage is 19 short of 100 and scores 72; 60% scores 50. A CI job typically writes this file from its coverage and audit steps, then runs jscpd . --health --health-input metrics.json.

Tuning

The same object can live under health in .jscpd.json, together with the tuning of the built-in dimensions:

.jscpd.json
{
  "health": {
    "duplication": { "halfLife": 5, "weight": 2 },
    "deadCode": { "weight": 0 },
    "complexFile": 80,
    "metrics": [{ "id": "security", "score": 100 }]
  }
}

A weight of 0 leaves a dimension out. An unknown key, or a metric that cannot be scored, is an error rather than a silently different score.

Reporters and exit codes

ReporterOutput
consoleThe badge
aiOne line: health 74 B (duplication 75, dead-code 72, complexity 76; 93 code lines)
jsonjscpd-health.json: score, grade, size, and for each dimension its value, adjusted value, lines, half-life, weight and score
badgejscpd-health-badge.svg
markdown / htmljscpd-health.md / jscpd-health.html: the same score and dimension table, for a PR comment or a status page

The exit gates of a clone run apply: --threshold, --exit-code and --fail-on-empty. A runnable example, with a metrics file, is in fixtures/dashboard-demo.