Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/thick-parrots-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@docker-doctor/cli": minor
---

Replace the saturating score formula with a monotonic, asymptotic curve.

The score used to be `100 - penalty` (floored at 0), which meant any project with roughly 10 errors or 25 warnings scored exactly 0 and stayed there no matter how much worse it got — the score became inert on messy repos, and score-regression checks (e.g. "re-scan, confirm the score did not drop") stopped working once a project hit the floor.

The score is now `round(100 * e^(-penalty / 70))`, using the same per-severity penalty weights as before (error 10, warning 4, info 1). This curve approaches 0 without ever getting stuck there, so the score keeps moving and stays meaningful across the whole range.

**Breaking:**

- Score values have changed. A given set of diagnostics will now produce a different numeric score than before (e.g. a single error used to score 90, now scores ~87; a project that used to floor at 0 will now show a small nonzero number that keeps decreasing as issues pile up).
- Existing badges, dashboards, or stored scores will show different numbers after upgrading — this is expected, not a regression.
- The `Excellent 🏆` / `Good ✅` / `Needs Work ⚠️` / `Critical 🚨` label thresholds (>=90 / >=75 / >=50 / below) are unchanged.
- The JSON report now includes a `schemaVersion` field (currently `2`) to make future score/shape changes detectable.
6 changes: 6 additions & 0 deletions packages/core/src/report.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Diagnostic, ProjectInfo } from "./types/index";

// Bump whenever the JSON report shape or the score formula/weights change.
// The unversioned shape shipped before this field existed is implicitly 1.
export const REPORT_SCHEMA_VERSION = 2;

export interface JsonReport {
diagnostics: {
column?: number;
Expand All @@ -12,6 +16,7 @@ export interface JsonReport {
}[];
label: string;
project: ProjectInfo;
schemaVersion: number;
score: number;
timestamp: string;
}
Expand All @@ -33,6 +38,7 @@ export const toJsonReport = (
})),
label,
project,
schemaVersion: REPORT_SCHEMA_VERSION,
score,
timestamp: new Date().toISOString(),
});
17 changes: 16 additions & 1 deletion packages/core/src/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,22 @@ export const calculateScore = (
}
}

const score = Math.max(0, 100 - penalty);
// Asymptotic decay curve: score = round(100 * e^(-penalty / K)).
//
// The old `max(0, 100 - penalty)` formula saturates at 0 once penalty
// reaches 100 (e.g. ~10 errors), so a messy project and a catastrophic
// one are indistinguishable and the score can never register a fix.
// This curve approaches (but never reaches) 0, so it stays monotonic
// and responsive across the whole range instead of going inert.
//
// K=70 was chosen so a single warning (penalty 4) still scores ~94,
// comfortably inside the "Excellent" (>=90) bucket, while errors and
// repeated warnings still meaningfully erode the score. K=40 (the
// naive "half-life at penalty ~28" choice) was tried first and pushed
// a single warning down to ~90 - right on the Excellent/Good boundary,
// which is too harsh a penalty for one warning.
const K = 70;
const score = Math.round(100 * Math.exp(-penalty / K));

let label = "Critical 🚨";
if (score >= 90) {
Expand Down
86 changes: 86 additions & 0 deletions packages/core/test/scoring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, test, expect } from "bun:test";

import { calculateScore } from "../src/scoring";
import type { Diagnostic } from "../src/types/index";

const diag = (severity: "error" | "warning" | "info"): Diagnostic => ({
file: "Dockerfile",
help: "",
message: "",
rule: "docker-doctor/test",
severity,
});

const many = (severity: "error" | "warning" | "info", n: number) =>
Array.from({ length: n }, () => diag(severity));

describe("calculateScore (asymptotic curve, K=70)", () => {
test("a perfect project scores 100", () => {
expect(calculateScore([]).score).toBe(100);
});

test("one error scores 87", () => {
expect(calculateScore(many("error", 1)).score).toBe(87);
});

test("one warning stays comfortably inside Excellent (~94)", () => {
const { score, label } = calculateScore(many("warning", 1));
expect(score).toBe(94);
expect(label).toBe("Excellent 🏆");
});

test("one info scores 99", () => {
expect(calculateScore(many("info", 1)).score).toBe(99);
});

test("is strictly monotonic and never saturates", () => {
const a = calculateScore(many("error", 10)).score;
const b = calculateScore(many("error", 20)).score;
const c = calculateScore(many("error", 40)).score;
expect(b).toBeLessThan(a);
expect(c).toBeLessThan(b);
});

test("score is always an integer in range", () => {
for (const n of [0, 1, 5, 25, 100, 500]) {
const { score } = calculateScore(many("warning", n));
expect(Number.isInteger(score)).toBe(true);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(100);
}
});

test("fixing an issue always raises the score", () => {
const before = calculateScore(many("warning", 30)).score;
const after = calculateScore(many("warning", 29)).score;
expect(after).toBeGreaterThan(before);
});

test("label is Excellent at score exactly 90", () => {
// penalty 7 -> round(100 * e^(-7/70)) = 90
const { score, label } = calculateScore(many("info", 7));
expect(score).toBe(90);
expect(label).toBe("Excellent 🏆");
});

test("label is Good at score exactly 75", () => {
// penalty 20 -> round(100 * e^(-20/70)) = 75
const { score, label } = calculateScore(many("info", 20));
expect(score).toBe(75);
expect(label).toBe("Good ✅");
});

test("label is Needs Work at score exactly 50", () => {
// penalty 48 -> round(100 * e^(-48/70)) = 50
const { score, label } = calculateScore(many("info", 48));
expect(score).toBe(50);
expect(label).toBe("Needs Work ⚠️");
});

test("label is Critical at score exactly 49", () => {
// penalty 50 -> round(100 * e^(-50/70)) = 49
const { score, label } = calculateScore(many("info", 50));
expect(score).toBe(49);
expect(label).toBe("Critical 🚨");
});
});
22 changes: 22 additions & 0 deletions packages/docker-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ export default {
};
```

## How the score works

Every scan produces a 0-100 health score alongside a label (`Excellent 🏆`, `Good ✅`, `Needs Work ⚠️`, `Critical 🚨`).

Each diagnostic adds a penalty based on severity:

| Severity | Penalty |
| --------- | ------- |
| `error` | 10 |
| `warning` | 4 |
| `info` | 1 |

The penalties are summed, then the score is computed as an asymptotic decay curve rather than a simple subtraction:

```
score = round(100 * e^(-penalty / K)) // K = 70
```

A perfect project (no diagnostics) always scores exactly 100. As penalty increases, the score keeps decreasing — it approaches 0 but never gets stuck there, so the score stays meaningful (and can still register improvement) even on projects with a lot of findings. `K = 70` was chosen so a single warning (penalty 4) still lands around 94 — comfortably inside the `Excellent` bucket — while errors and repeated warnings continue to meaningfully erode the score.

The label thresholds are unchanged: `>= 90` Excellent, `>= 75` Good, `>= 50` Needs Work, otherwise Critical.

## API

```ts
Expand Down
9 changes: 8 additions & 1 deletion packages/docker-doctor/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ describe("--json contract", () => {
const { stdout } = await runCli([fixture("with-error"), "--json"]);
const report = JSON.parse(stdout);
expect(Object.keys(report).toSorted()).toEqual(
["diagnostics", "label", "project", "score", "timestamp"].toSorted()
[
"diagnostics",
"label",
"project",
"schemaVersion",
"score",
"timestamp",
].toSorted()
);
});

Expand Down