Skip to content

feat(core): score a category by how much is wrong, not whether anything is - #363

Merged
oekazuma merged 17 commits into
mainfrom
feat/score-proportionality
Aug 4, 2026
Merged

oekazuma merged 17 commits into
mainfrom
feat/score-proportionality

Conversation

@oekazuma

@oekazuma oekazuma commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

Why

A category score moved with whether something was wrong, not with how much.

Two symptoms, both measured. They share one cause.

A category could not reach most of its own scale. A key — a route or a source file — lost a fixed number of points per failing rule, and is only ever touched by rules of its own scope. So the lowest score a key could reach was fixed by that scope's rule inventory:

category / scope rules lowest reachable
architecture / component 8 info 92
seo / component 1 warning 95
performance / component 4 info, 1 warning 91
performance / route 3 info, 5 warning 72
security / component 4 warning, 1 critical 65
correctness / component 1 info, 10 warning, 3 critical 4
seo / route 10 info, 14 warning, 2 critical 0

Architecture is eight info rules and nothing else, so an architecture score was a nine-value scale presented as a hundred-value one. Three more scopes sat above 90 — this was not one miscalibrated category, it is what the model did wherever a scope's rules were few or cheap.

The average erased magnitude. One info finding moves a mean of N keys by 1/N. At the scale a field measurement reported — 585 keys — one finding and 276 findings both displayed 99.

Fixing either symptom alone fixes neither. Raising the deductions leaves architecture bounded; un-bounding architecture without making each affected key cost more leaves the mean pinned near 100.

This is the follow-up the previous scoring change recorded against itself: "Trading '100 is a lie' for '99 says nothing' is the right trade only if the second half gets fixed."

What

A key now scores the share of what it was measured against that is intact:

keyScore = inventoryWeight === 0 ? 100 : clamp(100 − (100 × failedWeight) / inventoryWeight)

inventoryWeight is max(observedInventory, failedWeight), where observedInventory sums DEDUCTION[severity] over the selected rules whose (category, scope) pair is among the pairs observed on that key. Keys are averaged in deficit space. Every category can now reach 0, and the 276-finding case reads 94 against 99 for one finding.

seo and correctness — the largest inventories, and the categories users read most — land within a point of their old values. The change concentrates where the scale was most compressed.

Decisions that are load-bearing rather than stylistic

The denominator is the scope's inventory, not the rules that applied. The faithful "not applicable" semantics collapses here: measuring each architecture rule's applies condition shows an ordinary component has one or two applicable architecture rules, so a props-less component a few lines over the size threshold would score 0. Flooring the denominator does not rescue it — at 300 keys with 10 affected, a per-key 0 yields a category mean of 96.7 and a per-key 80 yields 99.3, so the larger the floor the less of the fix survives.

The pair, not the scope alone. computeScore takes results, not a category, and three call sites pass multi-category sets — routes[].score, the console's route tree, and the Vite plugin. Summing over observed (category, scope) pairs defines those and reduces to the single-category rule exactly when the input is single-category.

Unchanged on purpose: sitePenalty stays absolute points (neither symptom applies to it — it is never divided by the key count), a critical still caps a category at 79, and a displayed 100 still means no finding among the checks that ran.

No signature cascade. computeScore derives the inventory itself from selectRules(allRules, config); ScoreOptions gains an optional rules for tests. Nothing under src/rules/ imports anything under src/scoring/, so the direction is free. Deliberately unlike the previous change that threaded a rule-id list through AnalyzeResult: there the CLI's --category narrowing was needed; here it would be wrong, since --category seo must not shrink seo's own inventory.

The arithmetic, which is where this could be wrong while looking right

Three forms were mandatory, each because the alternative loses a displayed point:

  • 100 − (100 × f) / i, never 100 × (1 − f / i) — the second yields 19.999999999999996 for f = 88, i = 110 and displays 19 for a true 20.
  • The mean in deficit space — a mean of key scores yields 49.99999999999999 for two keys at f = 3 and f = 6 against i = 9, displaying 49 for a true 50.
  • max(observedInventory, failedWeight) plus a zero guard — without them a result whose rule is absent from the inventory produces NaN, which reaches the JSON reporter as null.

The design was reviewed adversarially three times and rejected twice before approval. That process caught, among others, two table values computed with rounding — the operation the predecessor design exists to forbid — and a justification for deficit space that was arithmetically false: a clean project's mean is exactly 100 under either form, so the test that justification implied could not fail. The real case is an exactly-integral mean of unclean keys. One residual is stated rather than smoothed: a multi-key exactly-integral mean may display one point low, bounded at one point and verified by exhaustive and randomized sweeps against exact rational truth.

Verification

core 1210, cli 805, vite 206 tests pass; typecheck clean in all three after rebuilding core; lint and format clean.

Every guard was confirmed load-bearing by mutation, not assumed: the evaluation order, the deficit-space mean, max, the zero guard, the ?? 0 on the inventory lookup, the pair in both directions, and the inventory's dependence on configuration.

That last set exists because the whole-branch review found the opposite. The test written to hold the evaluation order held nothing — mutating the shipped code to either wrong form left all 1206 tests green, because the deficit-space mean turns 100 − 19.999999999999996 into exactly 80, rounding away the very error the anchor was chosen to expose. It is now anchored on f = 28, i = 50 (44 shipped, 43 mutated), with the original kept as a labelled non-load-bearing example. Two further guards were found untested the same way and now have tests.

Migration

Every score changes, most of them downward, and by more than a point. A --min-health gate calibrated against the old numbers will start failing; recalibrate against the new scale. routes[].score in the JSON report changes meaning the same way. Stored baselines are unaffected — they key on findings, not scores.

Out of scope, found while reviewing

pnpm smoke's JSON check fails deterministically on main as well as here: the fixture report is 67,656 bytes, past the 64 KiB pipe buffer, and the analyze path calls process.exit after console.log. Reproduced against main at 6174836. It needs its own issue and is untouched by this branch.

Design: docs/superpowers/specs/2026-08-04-score-proportionality-design.md. Plan: docs/superpowers/plans/2026-08-04-score-proportionality.md.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Health scores now use severity-weighted proportional scoring based on applicable checks.
    • Scores can reach 0 and better reflect the number and severity of findings.
    • Duplicate findings count once per route and rule, using the highest severity.
  • Documentation
    • Updated English and Japanese health-report and JSON reporter guidance.
    • Added guidance to recalibrate --min-health thresholds after upgrading.
  • Tests
    • Expanded coverage for scoring boundaries, disabled rules, severity overrides, and edge cases.

oekazuma added 15 commits August 4, 2026 10:12
Six findings, all reproduced: two table values were rounded (the operation the
predecessor spec exists to forbid); the model was undefined at the three call sites
that pass multi-category results; 100*(1-f/i) loses a displayed point on integral
values; 0/0 reached clamp as NaN; the route mean's exact-integer premise no longer
holds; the cli call-site count was wrong.
The clean-project argument for deficit space was false — a mean of exact 100s is
exact under either arithmetic, so the test it implied could not fail. The real case
is an exactly-integral mean of unclean keys, and deficit space narrows that class
without closing it; both are now stated with fixtures.
computeScore now measures each route/file against the inventory of
rules that could apply to its (category, scope) pair, instead of a
flat 100-point start with fixed per-severity deductions. This lets a
key reach 0 and keeps its magnitude proportional to how much of its
pair actually failed, rather than compressing everything into a few
fixed steps.

Also removes the DEDUCTION duplication between score.ts and
inventory.ts: it now has a single declaration, exported from
inventory.ts.
…t it actually protects

The zero-denominator case was already handled by the `inventoryWeight === 0` ternary
below it, not by this guard. Without `max`, a penalized finding whose weight exceeds
its own pair's inventory (treatDynamicAs: 'warn' promoting severity, or a rule absent
from the inventory) would push a key's deficit negative and silently score 100 despite
a real finding.
…sted guards

The f=88,i=110 anchor for `keeps an integral score integral` passes under both
wrong evaluation orders (100*(1-f/i) and 100*(f/i)): the deficit-space mean's
subtraction cancels their fp error at that value, so the test held nothing.
Add f=28,i=50 (single key) and a two-key mean at shared inventory 11, both
verified to discriminate; keep the old anchor as a non-load-bearing example.

Also add: a computeScore test that a config.rules severity override reaches
the default inventory (not just buildInventory called directly), and a test
for the `?? 0` guard on inventory.get(p) — the real NaN guard for a rule
that's off in config but still carried through options.rules.
It described the applied-rules semantics the score-proportionality design
rejected ("share of the checks that ran ... that passed"). The denominator is
the inventory of the (category, scope) pairs the route touched, which
includes selected rules that had nothing to say about that particular route.
…lity design

selectRules and buildInventory only read top-level config.rules, so an
overrides entry that raises a rule's severity for a glob leaves the
denominator at the rule's base severity while the result carries the raised
one — a third way failedWeight can exceed observedInventory, alongside the
two already recorded. Measured on the eight-info architecture pair: 0 when
raised only in overrides, 31.8 when the same raise is made at top level.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bde5386d-abc4-4746-a788-d0679e2b69ca

📥 Commits

Reviewing files that changed from the base of the PR and between 60d0495 and 5f21d53.

📒 Files selected for processing (5)
  • .changeset/score-proportionality.md
  • docs/superpowers/plans/2026-08-04-score-proportionality.md
  • docs/superpowers/specs/2026-08-04-score-proportionality-design.md
  • packages/core/src/scoring/score.ts
  • packages/core/test/score.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • .changeset/score-proportionality.md
  • docs/superpowers/plans/2026-08-04-score-proportionality.md
  • packages/core/src/scoring/score.ts
  • packages/core/test/score.test.ts
  • docs/superpowers/specs/2026-08-04-score-proportionality-design.md

📝 Walkthrough

Walkthrough

The PR replaces fixed per-finding deductions with severity-weighted proportional category scores. It adds category/scope inventories, updates computeScore, expands core tests, updates English and Japanese reporting documentation, and adds a minor-release changeset.

Changes

Proportional scoring

Layer / File(s) Summary
Scoring design and rollout contract
docs/superpowers/plans/..., docs/superpowers/specs/...
Defines weighted category/scope inventories, proportional deficits, averaging behavior, preserved penalties and caps, API wiring, validation, and migration effects.
Rule inventory construction
packages/core/src/scoring/inventory.ts
Adds severity weights, configured severity resolution, category/scope inventory construction, and rule-to-scope mapping.
Proportional score computation
packages/core/src/scoring/score.ts
Adds ScoreOptions.rules and computes route scores from weighted deficits across observed rule pairs.
Scoring and health validation
packages/core/test/inventory.test.ts, packages/core/test/score.test.ts, packages/core/test/health.test.ts
Adds coverage for inventories, proportional scores, duplicate findings, configuration, disabled and unknown rules, arithmetic precision, and health behavior.
Release and documentation updates
docs/src/content/docs/guides/(reporting)/*, docs/src/content/docs/ja/guides/(reporting)/*, .changeset/score-proportionality.md
Documents weighted route scores in both languages and records minor releases and recalibration requirements.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant computeScore
  participant selectRules
  participant buildInventory
  participant ruleScopes
  participant RouteResults
  computeScore->>selectRules: select configured or supplied rules
  computeScore->>buildInventory: build category/scope inventory
  computeScore->>ruleScopes: map rule IDs to category/scope pairs
  computeScore->>RouteResults: group route pairs and calculate weighted deficits
  RouteResults-->>computeScore: return route scores
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change from fixed deductions to proportional category scoring based on the extent of findings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
.changeset/score-proportionality.md (1)

10-12: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid a fixed rule count in the release note.

The statement "architecture is eight info rules" will become stale when rules are added or removed. Use durable wording, or state that eight is only the count for this release.
Based on learnings, avoid hard-coded counts for extensible rule sets; use durable wording unless this count is a fixed contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/score-proportionality.md around lines 10 - 12, Update the release
note’s explanation around the architecture category to remove the hard-coded
“eight” rule count, using durable wording that describes the cap without
specifying an extensible rule total.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/score-proportionality.md:
- Around line 18-22: Update the score-change description in
score-proportionality.md to use conditional, project-dependent wording: scores
and health-gate outcomes may change under the new model, but clean reports can
retain a displayed score of 100 and the magnitude or direction of changes is not
guaranteed for every project. Preserve the existing explanation of recalibrating
--min-health and the routes[].score meaning.

In `@docs/superpowers/plans/2026-08-04-score-proportionality.md`:
- Around line 159-163: Add a dated supersession note near severityOf documenting
that the shipped implementation returns undefined for an off injected rule and
skips it, resulting in the expected inventory of 15. Keep the historical code
block unchanged, and reference packages/core/src/scoring/inventory.ts plus its
regression test as the implementation and verification sources.

In `@packages/core/src/scoring/score.ts`:
- Around line 45-47: Filter the selected rules before both buildInventory and
ruleScopes in the score flow around options.rules, ensuring disabled injected
rules are excluded from pairOf as well as the inventory. Preserve scoring so a
disabled rule producing a finding scores 0 in mixed enabled/disabled pairs, and
add a regression test covering an off info rule paired with an enabled warning
rule.

In `@packages/core/test/score.test.ts`:
- Line 360: Rename the tests in packages/core/test/score.test.ts at lines
360-360 and 379-379 to describe the preserved score behavior, removing
“load-bearing,” anchor, and evaluation-order rationale; update line 465-465 to
describe the zero-inventory behavior instead of the “?? 0” NaN guard. No
assertion or implementation changes are needed.

---

Nitpick comments:
In @.changeset/score-proportionality.md:
- Around line 10-12: Update the release note’s explanation around the
architecture category to remove the hard-coded “eight” rule count, using durable
wording that describes the cap without specifying an extensible rule total.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4aea75e8-ba39-42c4-9f2d-b3e05fb53ab4

📥 Commits

Reviewing files that changed from the base of the PR and between 6174836 and 60d0495.

📒 Files selected for processing (12)
  • .changeset/score-proportionality.md
  • docs/src/content/docs/guides/(reporting)/health-report.md
  • docs/src/content/docs/guides/(reporting)/reporters.md
  • docs/src/content/docs/ja/guides/(reporting)/health-report.md
  • docs/src/content/docs/ja/guides/(reporting)/reporters.md
  • docs/superpowers/plans/2026-08-04-score-proportionality.md
  • docs/superpowers/specs/2026-08-04-score-proportionality-design.md
  • packages/core/src/scoring/inventory.ts
  • packages/core/src/scoring/score.ts
  • packages/core/test/health.test.ts
  • packages/core/test/inventory.test.ts
  • packages/core/test/score.test.ts

Comment thread .changeset/score-proportionality.md Outdated
Comment thread docs/superpowers/plans/2026-08-04-score-proportionality.md
Comment thread packages/core/src/scoring/score.ts Outdated
Comment thread packages/core/test/score.test.ts Outdated
`buildInventory` filtered `off` rules; `ruleScopes` did not. An injected disabled
rule therefore mapped to a pair it contributed no weight to, so the same finding
scored a key 80 when another rule shared its pair and 0 when it did not. Both
helpers now take the same selected list.
…gences

"Every score changes" was false for a clean report, which still displays 100. The
plan's Task 1 also contradicted itself on disabled rules, and the design described
the rules escape hatch as taking a list as given.
@oekazuma
oekazuma merged commit f9390f0 into main Aug 4, 2026
8 checks passed
@oekazuma
oekazuma deleted the feat/score-proportionality branch August 4, 2026 05:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant