diff --git a/.changeset/health-report.md b/.changeset/health-report.md
new file mode 100644
index 000000000..b8312e00a
--- /dev/null
+++ b/.changeset/health-report.md
@@ -0,0 +1,14 @@
+---
+'@svelte-vitals/core': minor
+'svelte-vitals': minor
+---
+
+Add the combined **Health Report** (#10): a single weighted Health score across the
+SEO, Performance, and Accessibility categories (equal weights by default, overridable
+via `Config.weights`), surfaced as the headline in the console/agent reporters and the
+MCP `analyze` output, with an optional `--min-health <0-100>` CI gate.
+
+**Breaking (JSON report):** the top-level `score` is now the combined Health score (it
+was the SEO score); the top-level `scoreModel` is removed; a `weights` field is added.
+Per-category scores remain under `categories` (e.g. `categories.seo.score` /
+`categories.seo.scoreModel`).
diff --git a/README.md b/README.md
index a5f35689f..399102b69 100644
--- a/README.md
+++ b/README.md
@@ -149,10 +149,11 @@ The project advances along two axes: **mode maturity** and **category coverage**
- **MCP server** (`@svelte-vitals/mcp`) — exposes `analyze` and `explain_rule` tools over stdio so an agent can run analysis in its tool loop and receive structured, fixable findings.
- **Performance checks** (`0.4`) — static `
` analysis: `width`/`height` (CLS) and a `loading` advisory, scored as a separate Performance category alongside SEO.
- **Accessibility checks** (`0.5`) — aggregates the Svelte compiler's `a11y_*` warnings (alt text, label association, ARIA, …) into a scored Accessibility category.
+- **Health Report** — a single weighted **Health** score combining SEO, Performance, and Accessibility (equal weights by default), shown as the headline in every reporter and the MCP `analyze` output; gate CI on it with `--min-health`.
**Upcoming**
-- **More categories** ([#10](https://github.com/oekazuma/svelte-vitals/issues/10)) — Upgrade checks, then a combined weighted Health Report — landing across `0.x` ahead of the `1.0` polish.
+- **Toward `1.0`** — rule-reference docs, a config file, and polish. The Upgrade/deprecation category was dropped (covered by official Svelte tooling — the compiler, the Svelte MCP, and `sv migrate`).
See the design document for the full vision.
diff --git a/docs/superpowers/plans/2026-06-23-health-report.md b/docs/superpowers/plans/2026-06-23-health-report.md
new file mode 100644
index 000000000..1b575318b
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-23-health-report.md
@@ -0,0 +1,562 @@
+# Combined Health Report Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a single weighted **Health** score (equal default weights across the present categories) surfaced as the JSON top-level `score`, a console/agent headline, and the MCP `analyze` output, plus an optional `--min-health` CI gate.
+
+**Architecture:** A new `computeHealth(results, config)` in core averages the existing `scoresByCategory` output by configurable weights (default equal). `buildJsonReport` exposes it as the top-level `score` (replacing the old SEO-only score) with a `weights` field; console/agent print a Health headline; the CLI's `run()` adds a `--min-health` gate on top of the unchanged severity-based exit. MCP gets it for free (it returns `buildJsonReport`).
+
+**Tech Stack:** TypeScript (ESM-only), `vitest`, `tsup`, pnpm workspaces.
+
+## Global Constraints
+
+- **ESM-only**, `tsup` `format: ['esm']`, `target: 'es2022'`; never add CJS.
+- **core stays runtime-agnostic** (no `node:` imports / no I/O in `@svelte-vitals/core`).
+- **Default weights are equal** (each present category weight `1`); effective weight `config.weights?.[cat] ?? 1`. No `--weights` CLI flag in this increment.
+- **Health over present categories only**: `scoresByCategory` returns only categories with findings/seeds; a suppressed/absent category is excluded and weights re-normalize. No present categories → Health `100`.
+- **Exit codes stay severity-based** (`hasFailureAtOrAbove`); `--min-health` adds an _additional_ gate, off unless set.
+- **JSON top-level `score` = Health** (was SEO) and top-level `scoreModel` is **removed** — a deliberate breaking change for `1.0`; per-category data stays in `categories`.
+- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `.
+
+---
+
+### Task 1: `Config.weights` + `computeHealth`
+
+**Files:**
+
+- Modify: `packages/core/src/types.ts` (add `weights?` to `Config`)
+- Modify: `packages/core/src/scoring/score.ts` (add `HealthResult` + `computeHealth`)
+- Modify: `packages/core/src/index.ts` (export `computeHealth`, `HealthResult`)
+- Test: `packages/core/test/health.test.ts` (new)
+
+**Interfaces:**
+
+- Consumes: `scoresByCategory`, `ScoreResult`, `Category`, `Config`, `Result` (all existing in core).
+- Produces:
+ - `Config.weights?: Partial>`
+ - `interface HealthResult { health: number; categories: Partial>; weights: Partial> }`
+ - `function computeHealth(results: Result[], config: Config): HealthResult`
+
+- [ ] **Step 1: Write the failing test** — `packages/core/test/health.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { computeHealth, defineConfig, type Result } from '../src/index.js';
+
+const seoFail = (route: string): Result => ({
+ id: 'SEO001',
+ category: 'seo',
+ severity: 'critical',
+ detection: { presence: 'none', value: 'absent' },
+ route,
+ message: 'Missing '
+});
+const pass = (id: string, category: Result['category'], route: string): Result => ({
+ id,
+ category,
+ severity: 'warning',
+ detection: { presence: 'own', value: 'static' },
+ route,
+ message: 'ok'
+});
+
+describe('computeHealth', () => {
+ it('averages present category scores with equal default weights', () => {
+ // SEO: one route, critical missing → low; performance + a11y: clean seeds → 100.
+ const results = [seoFail('/a'), pass('performance', 'performance', '/a'), pass('a11y', 'a11y', '/a')];
+ const { health, categories, weights } = computeHealth(results, defineConfig({}));
+ expect(categories.seo).toBeDefined();
+ expect(categories.performance!.score).toBe(100);
+ expect(categories.a11y!.score).toBe(100);
+ // equal weights → mean of the three category scores
+ const mean = Math.round((categories.seo!.score + 100 + 100) / 3);
+ expect(health).toBe(mean);
+ expect(weights).toEqual({ seo: 1, performance: 1, a11y: 1 });
+ });
+
+ it('honors Config.weights overrides', () => {
+ const results = [seoFail('/a'), pass('performance', 'performance', '/a')];
+ const equal = computeHealth(results, defineConfig({})).health;
+ const seoHeavy = computeHealth(results, defineConfig({ weights: { seo: 3, performance: 1 } })).health;
+ // weighting the low SEO score more heavily pulls Health below the equal-weight mean
+ expect(seoHeavy).toBeLessThan(equal);
+ });
+
+ it('excludes absent categories and re-normalizes (only SEO present)', () => {
+ const results = [pass('SEO001', 'seo', '/a')];
+ const { health, categories, weights } = computeHealth(results, defineConfig({}));
+ expect(Object.keys(categories)).toEqual(['seo']);
+ expect(weights).toEqual({ seo: 1 });
+ expect(health).toBe(100);
+ });
+
+ it('returns 100 when there are no results', () => {
+ expect(computeHealth([], defineConfig({})).health).toBe(100);
+ });
+});
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `pnpm --filter @svelte-vitals/core test -- health`
+Expected: FAIL — `computeHealth` is not a function.
+
+- [ ] **Step 3: Add `weights` to `Config`** in `packages/core/src/types.ts` — add the optional field to the interface (after `failOn`):
+
+```ts
+export interface Config {
+ treatDynamicAs: TreatDynamicAs;
+ /** Component names treated as meta sources of unknown content (design §11 layer 4). */
+ metaComponents: string[];
+ /** Per-rule overrides keyed by rule id (design §6). */
+ rules: Record;
+ /** Minimum severity that fails the run / CI (design §6). */
+ failOn: Severity;
+ /** Per-category weights for the combined Health score (default: equal, 1 each) (#10). */
+ weights?: Partial>;
+}
+```
+
+> `defaultConfig` leaves `weights` unset (it's optional → equal). `defineConfig` already spreads a `Partial`, so `defineConfig({ weights })` works with no change.
+
+- [ ] **Step 4: Add `computeHealth`** in `packages/core/src/scoring/score.ts` — append after `scoresByCategory`:
+
+```ts
+export interface HealthResult {
+ /** Weighted overall score across present categories (0–100). */
+ health: number;
+ categories: Partial>;
+ /** Effective weight used per present category. */
+ weights: Partial>;
+}
+
+/** Combined weighted Health score over the categories present in `results` (#10). */
+export function computeHealth(results: Result[], config: Config): HealthResult {
+ const categories = scoresByCategory(results, config);
+ const weights: Partial> = {};
+ let weighted = 0;
+ let total = 0;
+ for (const cat of Object.keys(categories) as Category[]) {
+ const w = config.weights?.[cat] ?? 1;
+ weights[cat] = w;
+ weighted += categories[cat]!.score * w;
+ total += w;
+ }
+ const health = total > 0 ? Math.round(weighted / total) : 100;
+ return { health, categories, weights };
+}
+```
+
+> `score.ts` already imports `Category, Config, Result, Severity` and defines `ScoreResult`; no new imports needed.
+
+- [ ] **Step 5: Export** from `packages/core/src/index.ts` — extend the scoring export line:
+
+```ts
+export { computeScore, scoresByCategory, computeHealth } from './scoring/score.js';
+export type { ScoreModel, ScoreResult, ScoreOptions, HealthResult } from './scoring/score.js';
+```
+
+> Check the existing scoring export lines in `index.ts` and merge these names in (don't duplicate the line). `ScoreModel`/`ScoreResult`/`ScoreOptions` are already exported there — add `HealthResult` to the type export and `computeHealth` to the value export.
+
+- [ ] **Step 6: Run tests + typecheck**
+
+Run: `pnpm --filter @svelte-vitals/core test -- health` then `pnpm --filter @svelte-vitals/core test` and `pnpm --filter @svelte-vitals/core typecheck`
+Expected: PASS (new health tests + all existing unchanged — `Config.weights` is additive/optional).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add packages/core/src/types.ts packages/core/src/scoring/score.ts packages/core/src/index.ts packages/core/test/health.test.ts
+git commit -m "feat(core): add Config.weights and computeHealth (#10)"
+```
+
+---
+
+### Task 2: JSON report — top-level `score` = Health, add `weights`, drop `scoreModel`
+
+**Files:**
+
+- Modify: `packages/core/src/reporter/json.ts`
+- Test: `packages/core/test/json-report.test.ts` (update existing)
+
+**Interfaces:**
+
+- Consumes: `computeHealth` (Task 1), existing `computeScore`, `scoresByCategory`, `Category`.
+- Produces: `JsonReport` with `score: number` (= Health), `weights: Partial>`, no top-level `scoreModel`; `categories` unchanged.
+
+- [ ] **Step 1: Read** `packages/core/test/json-report.test.ts` to see which assertions reference top-level `score`/`scoreModel`.
+
+- [ ] **Step 2: Update the test** — change top-level expectations to Health + `weights`, and move the `scoreModel` shape assertion into `categories.seo`:
+
+```ts
+it('emits the documented shape with only penalized findings', () => {
+ const json = JSON.parse(formatJsonReport(results, config, { version: '0.1.0' }));
+ expect(json.version).toBe('0.1.0');
+ expect(typeof json.score).toBe('number'); // score is now the combined Health
+ expect(json.weights).toBeDefined();
+ expect(json.scoreModel).toBeUndefined(); // top-level scoreModel removed
+ expect(json.categories.seo.scoreModel).toHaveProperty('routeAverage');
+ expect(json.summary.critical).toBe(1);
+ const routeA = json.routes.find((r: { route: string }) => r.route === '/a');
+ expect(routeA.issues).toHaveLength(1);
+ expect(routeA.issues[0].id).toBe('SEO002');
+ expect(routeA.issues[0].detection).toEqual({ presence: 'none', value: 'absent' });
+ expect(routeA.issues[0].docsUrl).toBe('https://svelte-vitals.dev/rules/SEO002');
+ expect(json.siteIssues).toHaveLength(1);
+ expect(json.siteIssues[0].id).toBe('SEO006');
+});
+
+it('top-level score equals the combined Health', () => {
+ const report = buildJsonReport(results, config, { version: '9.9.9' });
+ expect(report.score).toBe(computeHealth(results, config).health);
+ expect(report.weights).toEqual(computeHealth(results, config).weights);
+});
+```
+
+> Add `computeHealth` to the imports of this test file: `import { buildJsonReport, formatJsonReport, computeHealth, defineConfig, type Result } from '../src/index.js';`. Keep the existing `buildJsonReport returns the object formatJsonReport stringifies` test — it still holds.
+
+- [ ] **Step 3: Run it to verify it fails**
+
+Run: `pnpm --filter @svelte-vitals/core test -- json-report`
+Expected: FAIL — `json.scoreModel` still present / `json.weights` undefined / `json.score` is the SEO score not Health.
+
+- [ ] **Step 4: Update `buildJsonReport`** in `packages/core/src/reporter/json.ts`.
+
+Update imports (add `computeHealth`, keep `computeScore`; add `Category` type; `ScoreModel` still used by `categories`):
+
+```ts
+import type { Category, Config, Result } from '../types.js';
+import { computeScore, scoresByCategory, computeHealth, type ScoreModel } from '../scoring/score.js';
+import { summarize, effectiveSeverity, type Summary } from '../summary.js';
+import { isPenalized } from '../rule.js';
+```
+
+Update the `JsonReport` interface — replace `scoreModel` with `weights`:
+
+```ts
+export interface JsonReport {
+ version: string;
+ score: number; // combined Health score
+ weights: Partial>;
+ categories: Record;
+ summary: Summary;
+ routes: Array<{ route: string; score: number; issues: JsonIssue[] }>;
+ siteIssues: JsonIssue[];
+}
+```
+
+In `buildJsonReport`, replace the SEO-subset score block with the Health computation (keep everything else — `categories`, `routes`, `siteIssues`, `summary` — as is):
+
+```ts
+export function buildJsonReport(results: Result[], config: Config, meta: { version: string }): JsonReport {
+ const { health, categories: byCat, weights } = computeHealth(results, config);
+ const summary = summarize(results, config);
+
+ const categories = Object.fromEntries(
+ Object.entries(byCat).map(([cat, sr]) => [cat, { score: sr.score, scoreModel: sr.scoreModel }])
+ );
+ // ... existing routeMap / routes / siteIssues code unchanged ...
+
+ return { version: meta.version, score: health, weights, categories, summary, routes, siteIssues };
+}
+```
+
+> Remove the old `const seoResults = …; const { score, scoreModel } = computeScore(seoResults, config);` and the separate `const byCat = scoresByCategory(...)` (computeHealth already returns the categories). `computeScore` is still imported and used for the per-route `routes[].score`. Keep the `issueOf`/`JsonIssue` definitions and the `routes`/`siteIssues` construction exactly as they are.
+
+- [ ] **Step 5: Run tests + typecheck**
+
+Run: `pnpm --filter @svelte-vitals/core test -- json-report` then `pnpm --filter @svelte-vitals/core typecheck`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/reporter/json.ts packages/core/test/json-report.test.ts
+git commit -m "feat(core): json top-level score = Health, add weights, drop scoreModel (#10)"
+```
+
+---
+
+### Task 3: console + agent Health headline
+
+**Files:**
+
+- Modify: `packages/core/src/reporter/console.ts`
+- Modify: `packages/core/src/reporter/agent.ts`
+- Test: `packages/core/test/console-report.test.ts`, `packages/core/test/agent-report.test.ts`
+
+**Interfaces:**
+
+- Consumes: `computeHealth` (Task 1).
+- Produces: console output with a `Health: N/100` line above the per-category score lines; agent output with a `Health: N/100` line after the heading.
+
+- [ ] **Step 1: Write failing tests.**
+
+`console-report.test.ts` — add:
+
+```ts
+it('shows a combined Health headline above the category scores', () => {
+ const out = formatConsoleReport(results, config);
+ expect(out).toMatch(/Health: \d+\/100/);
+ expect(out).toMatch(/SEO Score: \d+\/100/); // per-category line still present
+});
+```
+
+`agent-report.test.ts` — add:
+
+```ts
+it('shows the Health score in the heading area', () => {
+ const md = formatAgentReport(results, config);
+ expect(md).toMatch(/Health: \d+\/100/);
+});
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `pnpm --filter @svelte-vitals/core test -- console-report agent-report`
+Expected: FAIL — no `Health: N/100` line.
+
+- [ ] **Step 3: Update `console.ts`** — import `computeHealth` and add the Health line to the header.
+
+Change the import:
+
+```ts
+import { computeScore, scoresByCategory, computeHealth } from '../scoring/score.js';
+```
+
+In `formatConsoleReport`, build the header with the Health line first:
+
+```ts
+const summary = summarize(results, config);
+const byCat = scoresByCategory(results, config);
+const { health } = computeHealth(results, config);
+const present = CATEGORY_ORDER.filter((c) => byCat[c] !== undefined);
+const header: string[] = [`Svelte Vitals · ${options.mode ?? 'static mode'}`, '', `Health: ${health}/100`];
+for (const c of present) {
+ header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]!));
+}
+const lines: string[] = [...header, ''];
+```
+
+> This inserts `Health: N/100` between the title line and the per-category `… Score: N/100` lines. Existing per-category assertions (`/SEO Score: \d+\/100/`) still pass.
+
+- [ ] **Step 4: Update `agent.ts`** — import `computeHealth` and add a Health line after the H1, before the empty-state check so it shows in both branches.
+
+```ts
+import { computeHealth } from '../scoring/score.js';
+```
+
+Replace the opening lines of `formatAgentReport`:
+
+```ts
+const failing = results.filter((r) => classify(r, config) === 'fail');
+const { health } = computeHealth(results, config);
+const lines: string[] = ['# svelte-vitals — fixes', '', `Health: ${health}/100`, ''];
+
+if (failing.length === 0) {
+ lines.push('No issues to fix.', '');
+ return lines.join('\n').replace(/\n+$/, '\n');
+}
+```
+
+> Confirm the current `agent.ts` opening (heading `'# svelte-vitals — fixes'`, then the `failing.length === 0` branch) and splice in the Health line as shown, preserving the rest.
+
+- [ ] **Step 5: Run tests + typecheck**
+
+Run: `pnpm --filter @svelte-vitals/core test -- console-report agent-report` then `pnpm --filter @svelte-vitals/core test` and `pnpm --filter @svelte-vitals/core typecheck`
+Expected: PASS (new headline assertions + all existing reporter tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/reporter/console.ts packages/core/src/reporter/agent.ts packages/core/test/console-report.test.ts packages/core/test/agent-report.test.ts
+git commit -m "feat(core): show combined Health headline in console and agent reports (#10)"
+```
+
+---
+
+### Task 4: `--min-health` CI gate (CLI)
+
+**Files:**
+
+- Modify: `packages/cli/src/index.ts` (`RunOptions.minHealth` + `run()` gate)
+- Modify: `packages/cli/src/bin.ts` (parse `--min-health`, help text)
+- Test: `packages/cli/test/run.test.ts` (add gate cases)
+
+**Interfaces:**
+
+- Consumes: `computeHealth` from `@svelte-vitals/core`.
+- Produces: `RunOptions.minHealth?: number`; `run()` returns `1` when Health < `minHealth` (in addition to the severity gate).
+
+- [ ] **Step 1: Write the failing test** — add to `packages/cli/test/run.test.ts` (reuse `capture()`, `CLEAN_ENV`, `fixtureDir`):
+
+```ts
+it('--min-health fails (exit 1) when Health is below the threshold', async () => {
+ const cap = capture();
+ // 100 is unreachable for the fixture (it has SEO failures), so the gate trips.
+ const code = await run({ cwd: fixtureDir, minHealth: 100, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV });
+ expect(code).toBe(1);
+});
+
+it('--min-health passes (exit 0) when Health meets the threshold and no failing severity', async () => {
+ const cap = capture();
+ // 0 is always met; with default failOn=critical the fixture's criticals still gate,
+ // so use a project-less assertion: a threshold of 0 must not be the cause of a failure.
+ const code = await run({ cwd: fixtureDir, minHealth: 0, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV });
+ // The fixture has a critical SEO finding, so severity still gates to 1; min-health=0 does not add a failure.
+ // Assert min-health=0 alone never forces 1 by comparing to the baseline (no minHealth).
+ const baseline = await run({ cwd: fixtureDir, log: capture().log, errorLog: capture().errorLog, env: CLEAN_ENV });
+ expect(code).toBe(baseline);
+});
+```
+
+> The fixture project has SEO criticals, so its severity gate already returns 1. These tests assert: (a) `minHealth: 100` returns 1 (Health < 100), and (b) `minHealth: 0` does not change the exit code vs no `minHealth` (the gate adds failures, never removes them). This isolates the `--min-health` behavior without needing a perfect-score fixture.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `pnpm --filter svelte-vitals test -- run`
+Expected: FAIL — `minHealth` is not an accepted `RunOptions` field / has no effect.
+
+- [ ] **Step 3: Add `minHealth` to `RunOptions` and the gate in `run()`** in `packages/cli/src/index.ts`.
+
+Add `computeHealth` to the `@svelte-vitals/core` import. Add the field to `RunOptions`:
+
+```ts
+export interface RunOptions {
+ // ... existing fields ...
+ /** Fail (exit 1) when the combined Health score is below this value (0–100). */
+ minHealth?: number;
+}
+```
+
+Update the exit computation (currently `const summary = summarize(results, config); return hasFailureAtOrAbove(summary, config.failOn) ? 1 : 0;`):
+
+```ts
+const summary = summarize(results, config);
+const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
+const failByHealth = opts.minHealth != null && computeHealth(results, config).health < opts.minHealth;
+return failBySeverity || failByHealth ? 1 : 0;
+```
+
+- [ ] **Step 4: Parse `--min-health` in `bin.ts`** — add `'min-health'` to the `mri` `string` option list, parse + validate, pass into `run()`, and document it in `HELP`.
+
+Add to the `string:` array in the `mri` call: `'min-health'`. After the `failOn` parsing block (invalid or out-of-range is a usage error — exit 2):
+
+```ts
+const minHealthRaw = argv['min-health'];
+let minHealth: number | undefined;
+if (minHealthRaw !== undefined) {
+ const n = Number(minHealthRaw);
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
+ console.error(`svelte-vitals: invalid --min-health '${minHealthRaw}'; expected a number 0-100.`);
+ process.exit(2);
+ }
+ minHealth = n;
+}
+```
+
+Add `minHealth` to the `run({ ... })` options object. Add a line to the `HELP` string under Options:
+
+```
+ --min-health <0-100> Fail (exit 1) when the combined Health score is below this value
+```
+
+- [ ] **Step 5: Run the CLI suite + typecheck**
+
+Run: `pnpm --filter svelte-vitals test -- run` then `pnpm --filter svelte-vitals test` and `pnpm --filter svelte-vitals typecheck`
+Expected: PASS (new gate tests + existing run tests unchanged — the severity gate is untouched).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/cli/src/index.ts packages/cli/src/bin.ts packages/cli/test/run.test.ts
+git commit -m "feat(cli): add --min-health CI gate on the combined Health score (#10)"
+```
+
+---
+
+### Task 5: README, changeset, full verification
+
+**Files:**
+
+- Modify: `README.md` (roadmap)
+- Create: `.changeset/health-report.md`
+- Test: `packages/mcp/test/analyze-tool.test.ts` (add a Health assertion)
+
+**Interfaces:** none (docs/release + one MCP assertion).
+
+- [ ] **Step 1: Add an MCP assertion** that `analyze` surfaces Health — add to `packages/mcp/test/analyze-tool.test.ts`'s happy-path test (it already asserts `report.score` is a number; add weights):
+
+```ts
+expect(report).toHaveProperty('weights');
+```
+
+(Place it next to the existing `expect(typeof report.score).toBe('number')` assertion. `report.score` is now the Health value; the existing assertion still holds.)
+
+- [ ] **Step 2: Run the MCP test**
+
+Run: `pnpm --filter @svelte-vitals/core build && pnpm --filter svelte-vitals build && pnpm --filter @svelte-vitals/mcp test -- analyze-tool`
+Expected: PASS. (MCP imports the built `dist` of core/cli, so build them first.)
+
+- [ ] **Step 3: Update the README roadmap.** In `README.md`, under **Shipped**, add:
+
+```md
+- **Health Report** — a single weighted **Health** score combining SEO, Performance, and Accessibility (equal weights by default), shown as the headline in every reporter and the MCP `analyze` output; gate CI on it with `--min-health`.
+```
+
+Replace the **Upcoming** `#10` bullet (which mentions Upgrade) with:
+
+```md
+**Upcoming**
+
+- **Toward `1.0`** — rule-reference docs, a config file, and polish. The Upgrade/deprecation category was dropped (covered by official Svelte tooling — the compiler, the Svelte MCP, and `sv migrate`).
+```
+
+- [ ] **Step 4: Add the changeset** — `.changeset/health-report.md`:
+
+```md
+---
+'@svelte-vitals/core': minor
+'svelte-vitals': minor
+---
+
+Add the combined **Health Report** (#10): a single weighted Health score across the
+SEO, Performance, and Accessibility categories (equal weights by default, overridable
+via `Config.weights`), surfaced as the headline in the console/agent reporters and the
+MCP `analyze` output, with an optional `--min-health <0-100>` CI gate.
+
+**Breaking (JSON report):** the top-level `score` is now the combined Health score (it
+was the SEO score); the top-level `scoreModel` is removed; a `weights` field is added.
+Per-category scores remain under `categories` (e.g. `categories.seo.score` /
+`categories.seo.scoreModel`).
+```
+
+- [ ] **Step 5: Full repo verification**
+
+Run: `pnpm -r typecheck && pnpm -r test && pnpm build && pnpm lint && pnpm check:publint`
+Expected: all green. (Run `pnpm format` first if prettier flags formatting. For attw, if the local root-owned npm cache blocks `check:publish`'s `npm pack`, verify attw via `npm_config_cache="$TMPDIR/npmcache" pnpm --workspace-concurrency=1 --filter @svelte-vitals/core --filter @svelte-vitals/vite --filter svelte-vitals --filter @svelte-vitals/mcp exec attw --pack . --profile esm-only`.) For any pnpm "no TTY" modules-dir error, prefix `CI=true`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add README.md .changeset/health-report.md packages/mcp/test/analyze-tool.test.ts
+git commit -m "docs(health): ship Health Report, roadmap + changeset (#10)"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+
+- `computeHealth` (equal default weights, present-only, re-normalize, empty→100) + `Config.weights` → Task 1. ✅
+- JSON `score`=Health, `weights` added, top-level `scoreModel` removed, `categories` intact → Task 2. ✅
+- console + agent Health headline; sarif/github unchanged → Task 3 (sarif/github simply not touched). ✅
+- `--min-health` gate + severity gate unchanged → Task 4. ✅
+- MCP surfaces Health for free → Task 5 (assertion only; no MCP code change). ✅
+- README roadmap (Health shipped, Upgrade dropped) + changeset (breaking JSON note) → Task 5. ✅
+- Weights have no CLI flag (config-file deferred) → honored (Task 4 adds only `--min-health`). ✅
+
+**Placeholder scan:** No "TBD"/"add error handling" placeholders; every code step shows full code. Task 4's test comments explain the baseline-comparison technique (the fixture has no perfect score), not a deferral.
+
+**Type consistency:** `computeHealth(results, config): HealthResult` (Task 1) consumed in json (Task 2), console/agent (Task 3), and cli run() (Task 4). `Config.weights?: Partial>` (Task 1) read by `computeHealth`. `JsonReport.weights` (Task 2) is the same `Partial>`. `RunOptions.minHealth?: number` (Task 4) parsed from `--min-health` in bin (Task 4).
diff --git a/docs/superpowers/specs/2026-06-23-health-report-design.md b/docs/superpowers/specs/2026-06-23-health-report-design.md
new file mode 100644
index 000000000..c8f259813
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-23-health-report-design.md
@@ -0,0 +1,102 @@
+# Design: Combined weighted Health Report (1.0 capstone)
+
+Issue: [#10](https://github.com/oekazuma/svelte-vitals/issues/10) (roadmap epic). The differentiated capstone: synthesize the per-category scores (SEO, Performance, A11y) into one weighted **Health** score, surfaced across reporters and the MCP `analyze` tool. This is the headline number for `1.0` and is svelte-vitals' own synthesis — not something official Svelte tooling provides.
+
+## Goal
+
+Add a single **Health** score = the weighted average of the present category scores, with **equal default weights** (1/3 each, re-normalized over whichever categories are present). Surface it as the top-level `score` in the JSON report, a console/agent headline, and (for free) the MCP `analyze` output. Add an optional `--min-health <0-100>` CI gate. Keep severity-based exit codes unchanged.
+
+Reuses the multi-category foundation from Performance v0.4 (`scoresByCategory`, category-aware reporters). Static + plugin modes (the report is computed from `Result[]`, mode-independent).
+
+## Decisions (settled in brainstorming)
+
+- **Default weights: equal** (each present category weight 1). Overridable via `Config.weights`; a CLI flag is deferred to the config-file feature (roadmap C).
+- **JSON top-level `score` becomes the Health score** (was the SEO score) — a deliberate breaking change, announced for `1.0` via the changeset. Per-category scores remain in `categories`.
+- **Exit codes stay severity-based** (`hasFailureAtOrAbove`); Health is informational unless the new optional `--min-health` flag is set.
+
+## core — `computeHealth` + `Config.weights`
+
+- **`Config`** gains `weights?: Partial>`. `defineConfig` passes it through; `defaultConfig` leaves it `undefined` (= equal). Effective weight: `weightOf(cat) = config.weights?.[cat] ?? 1`.
+- **`computeHealth(results, config): HealthResult`** (new, in `packages/core/src/scoring/score.ts`):
+ - `const byCat = scoresByCategory(results, config)` — only categories with findings/seeds (so a suppressed a11y category, or a project with no routes, is excluded).
+ - `health = round( Σ_present(score_c × weightOf(c)) / Σ_present(weightOf(c)) )`. When no categories are present, `health = 100` (consistent with `computeScore`'s empty → 100).
+ - Returns `{ health: number; categories: Partial>; weights: Partial> }` where `weights` is the **effective** weight actually used per present category (so the report can show how Health was derived).
+- Exported from `@svelte-vitals/core`'s index alongside `computeScore`/`scoresByCategory`.
+
+```ts
+export interface HealthResult {
+ health: number;
+ categories: Partial>;
+ weights: Partial>;
+}
+export function computeHealth(results: Result[], config: Config): HealthResult;
+```
+
+## core — JSON report reshape (`buildJsonReport`)
+
+The report object changes shape (breaking, for 1.0):
+
+- top-level **`score`** = `computeHealth(...).health` (was the SEO subset score).
+- top-level **`scoreModel`** is **removed** (it was the SEO route-average model and is now redundant with `categories.seo.scoreModel`).
+- add top-level **`weights`** = the effective per-category weights used for Health.
+- **`categories`** unchanged: `{ seo: { score, scoreModel }, performance: {…}, a11y: {…} }` (only present categories).
+- `summary`, `routes`, `siteIssues` unchanged.
+
+Result shape:
+
+```jsonc
+{
+ "version": "1.0.0",
+ "score": 91, // Health (weighted avg of present categories)
+ "weights": { "seo": 1, "performance": 1, "a11y": 1 },
+ "categories": {
+ "seo": { "score": 86, "scoreModel": { … } },
+ "performance": { "score": 95, "scoreModel": { … } },
+ "a11y": { "score": 92, "scoreModel": { … } }
+ },
+ "summary": { … },
+ "routes": [ … ],
+ "siteIssues": [ … ]
+}
+```
+
+`formatJsonReport` keeps stringifying `buildJsonReport`. The **MCP `analyze`** tool returns `buildJsonReport`'s object as `structuredContent`, so it surfaces `score`(=Health) + `weights` + `categories` with **no MCP code change**.
+
+## core — reporters
+
+- **console**: prepend a headline `Svelte Vitals · Health: N/100 (static mode)` (or a dedicated `Health: N/100` line at the very top), then the existing per-category score lines (`SEO Score:`, `Performance Score:`, `Accessibility Score:`), then findings. The per-category lines and findings are unchanged.
+- **agent**: add a single leading line `Health: N/100` after the `# svelte-vitals — fixes` heading (cheap, informative for the agent).
+- **sarif / github**: **unchanged** — a single overall score has no per-finding SARIF/annotation representation; these reporters keep emitting findings only.
+
+## cli — `--min-health` gate + wiring
+
+- **`bin.ts`**: parse `--min-health ` (string → number). Invalid or out-of-range value is a usage error — prints an error to stderr and exits 2 (consistent with how unknown `--reporter`/`--rules` ids are handled).
+- **`RunOptions`/`AnalyzeOptions`** gain `minHealth?: number`; `analyzeProject` does not need it (it returns raw results), but `run()` computes Health and applies the gate.
+- **`run()`** exit logic: compute `const { health } = computeHealth(results, config)`. Return `1` when `hasFailureAtOrAbove(summary, config.failOn)` **OR** (`opts.minHealth != null && health < opts.minHealth`). Exit `2` (execution error) unchanged. So `--min-health` adds a score gate on top of the existing severity gate; without it, behavior is unchanged.
+- Help text documents `--min-health`.
+
+> Weights are **not** a CLI flag in this increment (no `--weights`); they default to equal and are overridable programmatically via `defineConfig({ weights })`, with a config-file surface arriving in roadmap item C.
+
+## Backward-compat & migration
+
+- The JSON `score` semantic change (SEO → Health) and the removal of top-level `scoreModel` are breaking for JSON consumers. Per-category SEO data moves to `categories.seo`. Announce clearly in the changeset (this lands as the lead-in to `1.0`).
+- console/agent gain a headline line (additive to human output; no existing console assertion pins the absence of a Health line — verify and update the console/agent tests for the new headline).
+- sarif/github/exit-codes (default) unchanged.
+
+## Testing (TDD)
+
+- **core**: `computeHealth` — equal-weight mean of present categories; `Config.weights` override changes the result; a suppressed/absent category is excluded and weights re-normalize; no categories → 100; `weights` field reflects effective weights. `buildJsonReport` — `score` === `computeHealth().health`, top-level `scoreModel` gone, `weights` present, `categories` intact; `formatJsonReport` === `JSON.stringify(buildJsonReport)`.
+- **cli**: `run()` returns 1 when `--min-health` threshold is unmet even with no failing severity; returns 0 when Health ≥ threshold and no failing severity; severity gate still fires independently; invalid `--min-health` exits 2 (usage error). console/agent show the Health headline.
+- **mcp**: `analyze` `structuredContent.score` is the Health value and `weights` is present (the report flows through unchanged).
+- Existing per-category/score tests updated for the reshaped top-level (the `categories` map already carries per-category data, so most assertions move from top-level to `categories.seo`).
+
+## Roadmap / release
+
+- README roadmap: move the **combined Health Report** to Shipped; explicitly drop the Upgrade category (redundant with official Svelte tooling — compiler/MCP/Skills/`sv migrate`); state that `1.0` is the polish/stabilization of SEO + Performance + A11y + Health.
+- Changeset: `@svelte-vitals/core` **minor** (`computeHealth`, `Config.weights`, JSON reshape) and `svelte-vitals` **minor** (`--min-health`, console/agent headline). Call out the JSON `score`/`scoreModel` breaking change prominently.
+
+## Non-goals / follow-ups (post-Health, toward/after 1.0)
+
+- Rule-reference docs / fixing the `svelte-vitals.dev/rules/` dead `docsUrl` links (roadmap item B — 1.0-required, next).
+- Config-file support + `--weights` CLI flag (roadmap item C — 1.0-required).
+- Upgrade/deprecation category (declined), plugin-mode Performance parity, layout breakouts (#12) — post-1.0.
diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts
index cd69fce76..2da9ebd74 100644
--- a/packages/cli/src/bin.ts
+++ b/packages/cli/src/bin.ts
@@ -18,6 +18,7 @@ Options:
--json Alias for --reporter=json
--fail-on Fail (exit 1) when any finding reaches this severity: critical | warning | info
--fail-on-warning Alias for --fail-on=warning
+ --min-health <0-100> Fail (exit 1) when the combined Health score is below this value
--rules Comma-separated rule ids to enable (all others disabled)
--ignore Comma-separated rule ids to disable
-h, --help Show this help
@@ -34,7 +35,7 @@ async function main(): Promise {
const argv = mri(process.argv.slice(2), {
alias: { h: 'help', v: 'version' },
boolean: ['by-route', 'json', 'fail-on-warning'],
- string: ['meta-components', 'treat-dynamic-as', 'route', 'fail-on', 'reporter', 'rules', 'ignore']
+ string: ['meta-components', 'treat-dynamic-as', 'route', 'fail-on', 'reporter', 'rules', 'ignore', 'min-health']
});
if (argv.help) {
@@ -51,7 +52,18 @@ async function main(): Promise {
for (const e of errors) console.error(e);
if (!options) process.exit(2);
- const code = await run(options);
+ const minHealthRaw = argv['min-health'];
+ let minHealth: number | undefined;
+ if (minHealthRaw !== undefined) {
+ const n = Number(minHealthRaw);
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
+ console.error(`svelte-vitals: invalid --min-health '${minHealthRaw}'; expected a number 0-100.`);
+ process.exit(2);
+ }
+ minHealth = n;
+ }
+
+ const code = await run({ ...options, minHealth });
process.exit(code);
}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 8f61125db..6a1d45d9c 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -8,6 +8,7 @@ import {
formatGithubReport,
summarize,
hasFailureAtOrAbove,
+ computeHealth,
defineConfig,
selectRules,
applyRuleSeverities,
@@ -37,6 +38,8 @@ export interface RunOptions {
rules?: Record;
/** Override process.env for reporter auto-detection (mainly useful in tests). */
env?: NodeJS.ProcessEnv;
+ /** Fail (exit 1) when the combined Health score is below this value (0–100). */
+ minHealth?: number;
}
export function routeMatcher(glob: string | undefined): (route: string) => boolean {
@@ -107,6 +110,11 @@ export async function run(opts: RunOptions = {}): Promise {
const log = opts.log ?? ((line: string) => console.log(line));
const errorLog = opts.errorLog ?? ((line: string) => console.error(line));
+ if (opts.minHealth != null && (!Number.isFinite(opts.minHealth) || opts.minHealth < 0 || opts.minHealth > 100)) {
+ errorLog(`svelte-vitals: invalid minHealth '${opts.minHealth}'; expected a number 0-100.`);
+ return 2;
+ }
+
let analysis: AnalyzeResult;
try {
analysis = await analyzeProject({
@@ -155,7 +163,9 @@ export async function run(opts: RunOptions = {}): Promise {
log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false }));
}
const summary = summarize(results, config);
- return hasFailureAtOrAbove(summary, config.failOn) ? 1 : 0;
+ const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
+ const failByHealth = opts.minHealth != null && computeHealth(results, config).health < opts.minHealth;
+ return failBySeverity || failByHealth ? 1 : 0;
} catch (err) {
errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`);
return 2;
diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts
index 0eedc79ea..262087a9e 100644
--- a/packages/cli/test/run.test.ts
+++ b/packages/cli/test/run.test.ts
@@ -127,6 +127,42 @@ describe('run() performance rules', () => {
});
});
+describe('run() --min-health validation', () => {
+ it('returns exit 2 for an out-of-range minHealth (150)', async () => {
+ const cap = capture();
+ const code = await run({ cwd: fixtureDir, minHealth: 150, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV });
+ expect(code).toBe(2);
+ expect(cap.err.join('\n')).toContain('invalid minHealth');
+ });
+
+ it('returns exit 2 for a NaN minHealth', async () => {
+ const cap = capture();
+ const code = await run({ cwd: fixtureDir, minHealth: NaN, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV });
+ expect(code).toBe(2);
+ expect(cap.err.join('\n')).toContain('invalid minHealth');
+ });
+});
+
+describe('run() --min-health gate', () => {
+ it('--min-health fails (exit 1) when Health is below the threshold', async () => {
+ const cap = capture();
+ // 100 is unreachable for the fixture (it has SEO failures), so the gate trips.
+ const code = await run({ cwd: fixtureDir, minHealth: 100, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV });
+ expect(code).toBe(1);
+ });
+
+ it('--min-health passes (exit 0) when Health meets the threshold and no failing severity', async () => {
+ const cap = capture();
+ // 0 is always met; with default failOn=critical the fixture's criticals still gate,
+ // so use a project-less assertion: a threshold of 0 must not be the cause of a failure.
+ const code = await run({ cwd: fixtureDir, minHealth: 0, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV });
+ // The fixture has a critical SEO finding, so severity still gates to 1; min-health=0 does not add a failure.
+ // Assert min-health=0 alone never forces 1 by comparing to the baseline (no minHealth).
+ const baseline = await run({ cwd: fixtureDir, log: capture().log, errorLog: capture().errorLog, env: CLEAN_ENV });
+ expect(code).toBe(baseline);
+ });
+});
+
describe('run() accessibility rules', () => {
it('reports an Accessibility finding for an
without alt', async () => {
const cap = capture();
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3a6f82086..c5fef8d45 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -57,5 +57,5 @@ export { formatGithubReport } from './reporter/github.js';
export { selectRules, applyRuleSeverities } from './config-apply.js';
-export type { ScoreModel, ScoreResult, ScoreOptions } from './scoring/score.js';
-export { computeScore, scoresByCategory } from './scoring/score.js';
+export type { ScoreModel, ScoreResult, ScoreOptions, HealthResult } from './scoring/score.js';
+export { computeScore, scoresByCategory, computeHealth } from './scoring/score.js';
diff --git a/packages/core/src/reporter/agent.ts b/packages/core/src/reporter/agent.ts
index a091840e8..1c4695ce5 100644
--- a/packages/core/src/reporter/agent.ts
+++ b/packages/core/src/reporter/agent.ts
@@ -1,5 +1,6 @@
import type { Config, Result, Severity } from '../types.js';
import { classify, effectiveSeverity } from '../summary.js';
+import { computeHealth } from '../scoring/score.js';
/** Sort order so the most severe findings (and the groups holding them) surface first. */
const SEVERITY_RANK: Record = { critical: 0, warning: 1, info: 2 };
@@ -17,7 +18,8 @@ function mdTags(text: string): string {
/** Render failing findings as an agent-actionable Markdown remediation document (issue #18). */
export function formatAgentReport(results: Result[], config: Config): string {
const failing = results.filter((r) => classify(r, config) === 'fail');
- const lines: string[] = ['# svelte-vitals — fixes', ''];
+ const { health } = computeHealth(results, config);
+ const lines: string[] = ['# svelte-vitals — fixes', '', `Health: ${health}/100`, ''];
if (failing.length === 0) {
lines.push('No issues to fix.', '');
diff --git a/packages/core/src/reporter/console.ts b/packages/core/src/reporter/console.ts
index f33735a65..4834f7baf 100644
--- a/packages/core/src/reporter/console.ts
+++ b/packages/core/src/reporter/console.ts
@@ -1,6 +1,6 @@
import type { Category, Config, Result, Severity } from '../types.js';
import { classify, summarize, effectiveSeverity } from '../summary.js';
-import { computeScore, scoresByCategory, type ScoreResult } from '../scoring/score.js';
+import { computeScore, computeHealth, type ScoreResult } from '../scoring/score.js';
const RULE = '────────────────────────';
const SEVERITY_TITLE: Record = {
@@ -56,9 +56,9 @@ function byRouteTree(results: Result[], config: Config): string[] {
*/
export function formatConsoleReport(results: Result[], config: Config, options: ConsoleReportOptions = {}): string {
const summary = summarize(results, config);
- const byCat = scoresByCategory(results, config);
+ const { health, categories: byCat } = computeHealth(results, config);
const present = CATEGORY_ORDER.filter((c) => byCat[c] !== undefined);
- const header: string[] = [`Svelte Vitals · ${options.mode ?? 'static mode'}`, ''];
+ const header: string[] = [`Svelte Vitals · ${options.mode ?? 'static mode'}`, '', `Health: ${health}/100`];
for (const c of present) {
header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]!));
}
diff --git a/packages/core/src/reporter/json.ts b/packages/core/src/reporter/json.ts
index 156348e3a..a887ae38d 100644
--- a/packages/core/src/reporter/json.ts
+++ b/packages/core/src/reporter/json.ts
@@ -1,5 +1,5 @@
-import type { Config, Result } from '../types.js';
-import { computeScore, scoresByCategory, type ScoreModel } from '../scoring/score.js';
+import type { Category, Config, Result } from '../types.js';
+import { computeScore, computeHealth, type ScoreModel } from '../scoring/score.js';
import { summarize, effectiveSeverity, type Summary } from '../summary.js';
import { isPenalized } from '../rule.js';
@@ -21,8 +21,8 @@ type JsonIssue = ReturnType & { severity: ReturnType>;
categories: Record;
summary: Summary;
routes: Array<{ route: string; score: number; issues: JsonIssue[] }>;
@@ -31,12 +31,9 @@ export interface JsonReport {
/** Build the structured JSON report object (design §7). Shared by the json reporter and the MCP `analyze` tool (issue #24). */
export function buildJsonReport(results: Result[], config: Config, meta: { version: string }): JsonReport {
- // Top-level score = SEO subset for backward compat (existing consumers only see SEO).
- const seoResults = results.filter((r) => (r.category ?? 'seo') === 'seo');
- const { score, scoreModel } = computeScore(seoResults, config);
+ const { health, categories: byCat, weights } = computeHealth(results, config);
const summary = summarize(results, config);
- const byCat = scoresByCategory(results, config);
const categories = Object.fromEntries(
Object.entries(byCat).map(([cat, sr]) => [cat, { score: sr.score, scoreModel: sr.scoreModel }])
);
@@ -62,7 +59,7 @@ export function buildJsonReport(results: Result[], config: Config, meta: { versi
.filter((r) => r.route === undefined && isPenalized(r.detection, config.treatDynamicAs))
.map((r) => ({ ...issueOf(r), severity: effectiveSeverity(r, config) }));
- return { version: meta.version, score, scoreModel, categories, summary, routes, siteIssues };
+ return { version: meta.version, score: health, weights, categories, summary, routes, siteIssues };
}
/** Render results as the documented JSON report string (design §7). */
diff --git a/packages/core/src/scoring/score.ts b/packages/core/src/scoring/score.ts
index 371037a3c..0715a1269 100644
--- a/packages/core/src/scoring/score.ts
+++ b/packages/core/src/scoring/score.ts
@@ -95,3 +95,37 @@ export function scoresByCategory(results: Result[], config: Config): Partial>;
+ /** Effective weight used per present category. */
+ weights: Partial>;
+}
+
+/** Combined weighted Health score over the categories present in `results` (#10). */
+export function computeHealth(results: Result[], config: Config): HealthResult {
+ const categories = scoresByCategory(results, config);
+ const weights: Partial> = {};
+ let weighted = 0;
+ let total = 0;
+ for (const cat of Object.keys(categories) as Category[]) {
+ const w = config.weights?.[cat] ?? 1;
+ if (!Number.isFinite(w) || w < 0) {
+ throw new RangeError(`invalid weight for '${cat}'; expected a finite number >= 0.`);
+ }
+ weights[cat] = w;
+ weighted += categories[cat]!.score * w;
+ total += w;
+ }
+ // No present categories (e.g. no results) → perfect 100, consistent with computeScore's empty → 100.
+ if (Object.keys(weights).length === 0) return { health: 100, categories, weights };
+ // Present categories but all weights zero → no meaningful weighted average; a silent 100
+ // here would mask real findings (e.g. let a --min-health gate pass), so reject it.
+ if (total === 0) {
+ throw new RangeError('Health weights sum to 0; at least one present category must have a positive weight.');
+ }
+ const health = Math.round(weighted / total);
+ return { health, categories, weights };
+}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index d02f5662d..11171d7c6 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -83,6 +83,8 @@ export interface Config {
rules: Record;
/** Minimum severity that fails the run / CI (design §6). */
failOn: Severity;
+ /** Per-category weights for the combined Health score (default: equal, 1 each) (#10). */
+ weights?: Partial>;
}
export const defaultConfig: Config = {
diff --git a/packages/core/test/agent-report.test.ts b/packages/core/test/agent-report.test.ts
index ab2f7bb1d..4976effcb 100644
--- a/packages/core/test/agent-report.test.ts
+++ b/packages/core/test/agent-report.test.ts
@@ -31,6 +31,10 @@ const results: Result[] = [
];
describe('formatAgentReport', () => {
+ it('shows the Health score in the heading area', () => {
+ const md = formatAgentReport(results, config);
+ expect(md).toMatch(/Health: \d+\/100/);
+ });
it('groups performance findings and uses a category-neutral heading', () => {
const withPerf: Result[] = [
...results,
diff --git a/packages/core/test/console-report.test.ts b/packages/core/test/console-report.test.ts
index 8542b699c..50c7cc189 100644
--- a/packages/core/test/console-report.test.ts
+++ b/packages/core/test/console-report.test.ts
@@ -22,6 +22,11 @@ const results: Result[] = [
];
describe('formatConsoleReport', () => {
+ it('shows a combined Health headline above the category scores', () => {
+ const out = formatConsoleReport(results, config);
+ expect(out).toMatch(/Health: \d+\/100/);
+ expect(out).toMatch(/SEO Score: \d+\/100/); // per-category line still present
+ });
it('shows a score header and groups findings', () => {
const out = formatConsoleReport(results, config);
expect(out).toMatch(/SEO Score: \d+\/100/);
diff --git a/packages/core/test/health.test.ts b/packages/core/test/health.test.ts
new file mode 100644
index 000000000..e72ce89d2
--- /dev/null
+++ b/packages/core/test/health.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect } from 'vitest';
+import { computeHealth, defineConfig, type Result } from '../src/index.js';
+
+const seoFail = (route: string): Result => ({
+ id: 'SEO001',
+ category: 'seo',
+ severity: 'critical',
+ detection: { presence: 'none', value: 'absent' },
+ route,
+ message: 'Missing '
+});
+const pass = (id: string, category: Result['category'], route: string): Result => ({
+ id,
+ category,
+ severity: 'warning',
+ detection: { presence: 'own', value: 'static' },
+ route,
+ message: 'ok'
+});
+
+describe('computeHealth', () => {
+ it('averages present category scores with equal default weights', () => {
+ // SEO: one route, critical missing → low; performance + a11y: clean seeds → 100.
+ const results = [seoFail('/a'), pass('performance', 'performance', '/a'), pass('a11y', 'a11y', '/a')];
+ const { health, categories, weights } = computeHealth(results, defineConfig({}));
+ expect(categories.seo).toBeDefined();
+ expect(categories.performance!.score).toBe(100);
+ expect(categories.a11y!.score).toBe(100);
+ // equal weights → mean of the three category scores
+ const mean = Math.round((categories.seo!.score + 100 + 100) / 3);
+ expect(health).toBe(mean);
+ expect(weights).toEqual({ seo: 1, performance: 1, a11y: 1 });
+ });
+
+ it('honors Config.weights overrides', () => {
+ const results = [seoFail('/a'), pass('performance', 'performance', '/a')];
+ const equal = computeHealth(results, defineConfig({})).health;
+ const seoHeavy = computeHealth(results, defineConfig({ weights: { seo: 3, performance: 1 } })).health;
+ // weighting the low SEO score more heavily pulls Health below the equal-weight mean
+ expect(seoHeavy).toBeLessThan(equal);
+ });
+
+ it('excludes absent categories and re-normalizes (only SEO present)', () => {
+ const results = [pass('SEO001', 'seo', '/a')];
+ const { health, categories, weights } = computeHealth(results, defineConfig({}));
+ expect(Object.keys(categories)).toEqual(['seo']);
+ expect(weights).toEqual({ seo: 1 });
+ expect(health).toBe(100);
+ });
+
+ it('returns 100 when there are no results', () => {
+ expect(computeHealth([], defineConfig({})).health).toBe(100);
+ });
+
+ it('throws RangeError for a negative weight', () => {
+ const results = [seoFail('/a')];
+ expect(() => computeHealth(results, defineConfig({ weights: { seo: -1 } }))).toThrow(RangeError);
+ });
+
+ it('throws RangeError for a non-finite weight (NaN)', () => {
+ const results = [seoFail('/a')];
+ expect(() => computeHealth(results, defineConfig({ weights: { seo: NaN } }))).toThrow(RangeError);
+ });
+
+ it('throws RangeError when every present category has weight 0 (would otherwise mask findings)', () => {
+ const results = [seoFail('/a'), pass('performance', 'performance', '/a')];
+ expect(() => computeHealth(results, defineConfig({ weights: { seo: 0, performance: 0 } }))).toThrow(RangeError);
+ });
+
+ it('allows a 0 weight as long as another present category is positive', () => {
+ const results = [seoFail('/a'), pass('performance', 'performance', '/a')];
+ // seo dropped to weight 0 → Health is exactly the performance score (100).
+ const { health, weights } = computeHealth(results, defineConfig({ weights: { seo: 0, performance: 1 } }));
+ expect(weights).toEqual({ seo: 0, performance: 1 });
+ expect(health).toBe(100);
+ });
+});
diff --git a/packages/core/test/json-report.test.ts b/packages/core/test/json-report.test.ts
index 7cdc7dc40..743696ebf 100644
--- a/packages/core/test/json-report.test.ts
+++ b/packages/core/test/json-report.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { buildJsonReport, formatJsonReport, defineConfig, type Result } from '../src/index.js';
+import { buildJsonReport, formatJsonReport, computeHealth, defineConfig, type Result } from '../src/index.js';
const config = defineConfig({});
const results: Result[] = [
@@ -48,11 +48,13 @@ describe('formatJsonReport', () => {
it('emits the documented shape with only penalized findings', () => {
const json = JSON.parse(formatJsonReport(results, config, { version: '0.1.0' }));
expect(json.version).toBe('0.1.0');
- expect(typeof json.score).toBe('number');
- expect(json.scoreModel).toHaveProperty('routeAverage');
+ expect(typeof json.score).toBe('number'); // score is now the combined Health
+ expect(json.weights).toBeDefined();
+ expect(json.scoreModel).toBeUndefined(); // top-level scoreModel removed
+ expect(json.categories.seo.scoreModel).toHaveProperty('routeAverage');
expect(json.summary.critical).toBe(1);
const routeA = json.routes.find((r: { route: string }) => r.route === '/a');
- expect(routeA.issues).toHaveLength(1); // only the missing description (SEO001 passed)
+ expect(routeA.issues).toHaveLength(1);
expect(routeA.issues[0].id).toBe('SEO002');
expect(routeA.issues[0].detection).toEqual({ presence: 'none', value: 'absent' });
expect(routeA.issues[0].docsUrl).toBe('https://svelte-vitals.dev/rules/SEO002');
@@ -60,11 +62,16 @@ describe('formatJsonReport', () => {
expect(json.siteIssues[0].id).toBe('SEO006');
});
+ it('top-level score equals the combined Health', () => {
+ const report = buildJsonReport(results, config, { version: '9.9.9' });
+ expect(report.score).toBe(computeHealth(results, config).health);
+ expect(report.weights).toEqual(computeHealth(results, config).weights);
+ });
+
it('buildJsonReport returns the object formatJsonReport stringifies', () => {
const report = buildJsonReport(results, config, { version: '9.9.9' });
expect(report.version).toBe('9.9.9');
expect(report).toHaveProperty('score');
- expect(report).toHaveProperty('scoreModel');
expect(report).toHaveProperty('summary');
expect(Array.isArray(report.routes)).toBe(true);
expect(Array.isArray(report.siteIssues)).toBe(true);
diff --git a/packages/mcp/test/analyze-tool.test.ts b/packages/mcp/test/analyze-tool.test.ts
index c25f78359..eae30dfe3 100644
--- a/packages/mcp/test/analyze-tool.test.ts
+++ b/packages/mcp/test/analyze-tool.test.ts
@@ -11,8 +11,9 @@ describe('analyze tool', () => {
it('returns a structured JSON report for a project path', async () => {
const res = await handleAnalyze({ path: fixtureDir });
expect(res.isError).toBeFalsy();
- const report = res.structuredContent as { score: number; routes: unknown[]; summary: unknown };
+ const report = res.structuredContent as { score: number; weights: unknown; routes: unknown[]; summary: unknown };
expect(typeof report.score).toBe('number');
+ expect(report).toHaveProperty('weights');
expect(Array.isArray(report.routes)).toBe(true);
// The text payload and the structured payload must be the same report — guard
// against drift between the two shapes (summary, finding metadata, scores).