Skip to content

refactor(no-outdated-packages)!: split registry facts from rule verdict - #191

Merged
Gallevy merged 6 commits into
mainfrom
claude/split-outdated-packages-layers
Sep 13, 2026
Merged

Gallevy merged 6 commits into
mainfrom
claude/split-outdated-packages-layers

Conversation

@Gallevy

@Gallevy Gallevy commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Closes #189.

no-outdated-packages was the only rule whose verdict was computed in the registry layer and stored on the display object. Every other rule goes facts → rule → violations → display; this one had the enricher stamp severity, scope and worstLevel onto PackageDistribution.releaseAge, and the Packages table read the verdict back off the package instead of joining to violations the way the Flags column already does.

The practical consequence, and the thing this PR actually fixes: a payload describing the repo was varying by how the repo is judged. Two repos with identical lockfiles and different thresholds got different packages[], and the block changed shape depending on which rules were switched on.

Facts

packages[].releaseAge → packages[].releases:

"releases": {
  "resolved": [
    {
      "version": "18.3.1",
      "isRoot": true,
      "newer": [
        { "version": "19.0.0", "releasedDaysAgo": 400, "semverBump": "major" },
        { "version": "19.1.0", "releasedDaysAgo": 10, "semverBump": "major", "isLatest": true }
      ]
    },
    { "version": "17.0.2", "isRoot": false, "newer": [ /* … */ ] }
  ],
  "latestVersion": "19.1.0",
  "latestReleasedDaysAgo": 10
}

Every resolved copy, root and nested alike, flagged but unranked. Byte-identical whatever your thresholds, severity or scope say. The only way config affects it is presence: absent when no registry-backed rule ran, because nothing fetched it.

enrichFromRegistry takes no policy parameter at all any more — that's the structural guarantee behind the sentence above, not a convention.

Verdict

{
  "ruleId": "no-outdated-packages",
  "severity": "error",
  "packageName": "moment",
  "measuredVersion": "2.29.4",
  "overdueTier": "minor",
  "daysOverdue": 946,
  "scope": "root"
}

Scope moved with the rest

Per review: scope belongs to the rule, and the facts should present both copies neutrally. It used to collapse two installed copies into one policy-chosen answer before anyone asked — which is exactly why advisoryBreaches existed as a stored field, since it was "the copies the scope excluded". Both are derived now, by the layer that owns the decision. The same releases payload produces the root and tree answers; there's a test asserting precisely that.

One place policy is applied

assessPackage is it, and both consumers call it rather than reading a cached answer — the rule to emit violations, the Packages table to render rows no violation covers. An 'off' entry still shows a recommendation, recomputed rather than stored. printPackages gained the resolved rule entries to make that possible; both call sites already held the config.

Field-level

Before After
worstLevel overdueTier on the violation
installedVersion measuredVersion
breachReleasedDaysAgo + thresholdDays daysOverdue
AvailableUpgrade.level deleted — a total function of semverBump
minCompliantVersion + …ReleasedDaysAgo + …InWindow + …Bump deleted — computed on demand
advisoryBreaches, evaluatedVersions derived, not stored

inWindow is gone as you asked. The old shape let minCompliantVersion hold a version that was not compliant, rescued only by a boolean beside it saying the recommendation wouldn't clear the breach. Nothing stores that contradiction now.

recommendedTarget is gone as a stored field, so its bad name went with it. Where the concept survives internally it is minimumUpgrade — the least disruptive upgrade that actually satisfies the policy.

The JSON case

Added comply-release-age-json, against the same recorded registry as comply-release-age. You were right that the surface was thin: the only JSON case that reached this rule covered one package on one path. The new case shows the whole thing at once — no-in-window-target (#26), a cross-tier target (#57), coming-due, an 'off' entry carrying full facts and no violation, and a version conflict whose nested copy is overdue but out of scope.

Verification

  • pnpm run typecheck / pnpm run lint / pnpm run format — clean
  • pnpm run test:ci — 1198 passing, 58 files
  • Rendered output unchanged, verified against the offline fixture registry

enricher.test.ts keeps its regression cases whole rather than splitting them, since each pins a specific reported bug (#24, #26, #29, #57) that only means something with both layers in play; its helper now runs the rule so those assertions read the assessment. tests/rules/no-outdated-packages.test.ts unit-tests the assessment on its own.

Expect comply-release-age-json to show as a new case and comply-all-rule-types-json to change — both are the intended --json reshape, and no human-output case should move.

🤖 Generated with Claude Code

This was the only rule whose verdict was computed in the registry layer and
stored on the display object. Every other rule goes facts -> rule ->
violations -> display; this one had `enrichFromRegistry` stamp `severity`,
`scope` and `worstLevel` onto `PackageDistribution.releaseAge`, and the
Packages table read the verdict straight back off the package rather than
joining to violations the way the Flags column already does.

That mixing is why the field names resisted fixing: `ReleaseAgeEntry` was
registry facts, rule verdict and display state at once, so no name could be
honest about what it held.

`releaseAge` is facts only now. The verdict is `overdueTier` on the
violation, derived from `upgrades` (which holds breached tiers only) rather
than stored a second time. `AvailableUpgrade.level` is deleted outright — it
was a total function of `semverBump` on the same object. `installedVersion`
becomes `measuredVersion`, which is what it always meant under tree scope.
The four `minCompliant*` fields and their negating boolean collapse into one
`recommendedTarget`, so a "compliant version" field can no longer hold a
version that isn't compliant.

Severity is no longer an input to the timeline math at all. `thresholds` and
`scope` stay, because the arithmetic genuinely needs them — you cannot say
which copies count, or which tier is past its line, without them.

Display: package rules now declare columns through a registry
(`src/utils/package-columns.ts`), the counterpart to the badge registry in
`package-flags.ts`. `no-outdated-packages` contributes Installed / Minimum
target through it, so `printPackagesTable` no longer tests for that rule by
name. The cell takes its icon by joining the violation, which is what keeps
it and the rules table from disagreeing. A breached package with no
violation is one governed by an 'off' entry, and renders with no icon —
derived now, not read from a stored severity.

Rendered output is unchanged; verified against the fixture registry.

Closes #189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: f6c90f7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
hermex Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Output Review

2 of 30 case(s) changed · 0 invariant breach(es) · all 30 cases

Reference: fda8b4b — reused from cache.

Case Change
comply-release-age-json +53 −57 case →
comply-all-rule-types-json +37 −39 case →

Open a case to read its diff, its config and its full output. If this is the change you meant, apply the output:approved label — see CONTRIBUTING.md.

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🟢 Lines 99.2%
⬆️ +0.0%
1750 / 1764
🟢 Statements 98.68%
⬆️ +0.0%
1956 / 1982
🟢 Functions 99.04%
⬆️ +0.1%
415 / 419
🟢 Branches 97.31%
⬇️ -0.3%
1339 / 1376
File Coverage
File Stmts Branches Functions Lines
Changed Files
src/npm-registry/enricher.ts 91.17%
⬇️ -3.9%
81.81%
⬇️ -8.6%
100%
🟰 ±0%
98.03%
⬇️ -1.1%
src/rules/no-outdated-packages.ts 98.87% 90.9% 100% 100%
src/utils/print-packages.ts 99.03%
⬆️ +0.0%
98.33%
⬇️ -0.1%
100%
🟰 ±0%
100%
🟰 ±0%
src/rules/shared.ts 100%
🟰 ±0%
94.11%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/utils/format-utils.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/utils/package-columns.ts 100% 100% 100% 100%
src/utils/package-distribution.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/utils/write-summary-file.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
tests/helpers/mock-reports.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #247 for commit f6c90f7 by the Vitest Coverage Report Action

Gallevy and others added 3 commits September 13, 2026 21:10
It had no direct test — only the e2e path exercised it, which the coverage
run skips. Pins that the `releaseAge` config block reaches the registry
layer as connection/infra only, which is the invariant #189 rests on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts #191 from "strip severity" to the full separation: the registry
layer takes no policy at all, and `packages[]` stops being a function of
config.

The practical complaint this answers is that a payload describing the repo
was varying by how the repo is judged. Two repos with identical lockfiles
and different thresholds got different `packages[]`, and the block changed
shape depending on which rules were on. Now `releases` holds every resolved
copy and what was published after each, root and nested alike, flagged but
unranked. It is byte-identical whatever the thresholds, severity or scope
say, because none of them are inputs to producing it.

Scope moved with the rest. It used to collapse two installed copies into one
policy-chosen answer before anyone asked, which is why `advisoryBreaches`
existed as a stored field — it was "the copies the scope excluded". Both are
derived now, by the layer that owns the decision.

`assessPackage` is the single place policy is applied, and both consumers
call it rather than reading a cached answer: the rule to emit violations,
the Packages table to render rows no violation covers. An 'off' entry still
shows a recommendation, recomputed rather than stored.

Field-level: `worstLevel` -> `overdueTier` on the violation;
`installedVersion` -> `measuredVersion`; `breachReleasedDaysAgo` +
`thresholdDays` -> `daysOverdue`; `AvailableUpgrade.level` deleted as a
total function of `semverBump`; the four `minCompliant*` fields and their
negating boolean deleted outright, so a "compliant version" field can no
longer hold a version that is not compliant.

Adds `comply-release-age-json`, which pins this rule's JSON surface end to
end. Until now the only JSON case reaching it covered one package on one
path — thin for what is arguably the most consequential rule here.

Rendered output is unchanged; verified against the fixture registry.

Closes #189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate asked "is there release-age data?" as a proxy for "did
`no-outdated-packages` run?". That held only while release facts were
themselves gated on the policy — which is exactly the coupling this branch
removes. With facts recorded whenever the registry is consulted, a
`no-deprecated-packages`-only run grew a Packages table it never had.

Asks the resolved rules directly instead, which comply now holds, so a
per-repo `overrides[]` entry still counts. Found by output review on
comply-deprecated-packages; pinned by an e2e test, since nothing in the unit
suite covered the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gallevy Gallevy added the output:approved You read the output-review diff and confirmed it label Sep 13, 2026
Second instance of the same proxy the previous commit fixed in comply, and
the one that actually reached output: `applies` asked only whether release
facts existed. Facts are recorded whenever the registry is consulted now, so
a `no-deprecated-packages`-only `scan` grew an Installed/Minimum target pair
full of em dashes — columns belonging to a rule that was never configured.

`comply` hid it because its own gate short-circuits the whole section;
`scan` gates on `output.packages` instead, so that path rendered it.

`applies` now takes the column context and needs both halves: the rule is
configured AND there is data to show. Covered at both levels — a unit test
for the registry decision and an e2e test on the `scan` path that exposed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the output:approved You read the output-review diff and confirmed it label Sep 13, 2026
…d-packages

Answers why output review missed the em-dash column bug: six fixture
configs enable a registry-backed rule and not one of them was used by a
`scan` case. Every scan case ran a config with no registry rules; every
registry case ran `comply`, whose own gate short-circuits the Packages
section before the column registry is consulted. `scan` × registry-rule was
an empty cell in the matrix, and that is the cell the bug lived in.

Verified the case actually catches it: reverting `applies` to the
data-only check makes scan-deprecated-packages diff against main, and
restoring it makes it match again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gallevy Gallevy added the output:approved You read the output-review diff and confirmed it label Sep 13, 2026
@Gallevy
Gallevy enabled auto-merge (squash) September 13, 2026 20:31
@Gallevy
Gallevy merged commit 044719c into main Sep 13, 2026
7 of 8 checks passed
@Gallevy
Gallevy deleted the claude/split-outdated-packages-layers branch September 13, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

output:approved You read the output-review diff and confirmed it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(release-age): separate registry facts, rule verdict, and display state

1 participant