Skip to content

feat(output)!: give the packages table a status-badge hook, and deprecation its own rule - #187

Merged
Gallevy merged 4 commits into
mainfrom
claude/hermex-ui-columns-868576
Sep 13, 2026
Merged

Gallevy merged 4 commits into
mainfrom
claude/hermex-ui-columns-868576

Conversation

@Gallevy

@Gallevy Gallevy commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Closes #86. Closes #107. Supersedes #148.

The problem

The packages table had no model for "a package rule has something to say about this row." Each flag bolted its own display on, glued to the front of the package name:

│ [DEPRECATED] [BANNED] moment │ 2.29.4 │ …

[RESTRICTED] invented a config concept that never existed — both badges came from one no-packages rule, chosen by a severity ternary — and it collapsed warn and info into the same word, even though the rest of the tool keeps them carefully apart.

Separately, deprecation was only ever detected as a by-product of release-age enrichment. Turn release-age off and deprecation silently vanished, even though the enricher's own comment calls deprecation "a policy/registry fact independent of" release age.

The shape

A declared contributor table — src/utils/package-status.ts — the packages-table counterpart to ONE_ROW_PER_VIOLATION in print-rules.ts. A badge exists if and only if a package rule produced a violation for that row, and its icon is that violation's severity. Adding a future package rule is one entry there and nothing else: no new column, no change to any renderer.

┌─────────┬───────────┬───────────────────────────────────┬───────────────┐
│ Package │ Installed │ Minimum target                    │ Status        │
├─────────┼───────────┼───────────────────────────────────┼───────────────┤
│ react   │ 18.3.1    │ 🟢                                │               │
│ zod     │ 3.23.8    │ minor 3.24.0 (12 days remaining)  │               │
│ moment  │ 2.29.4    │ 🔴 major 4.2.0 (40 days overdue)  │ 🔵 DEPRECATED │
│ eslint  │ 8.57.0    │ 🟢                                │ 🔴 FORBIDDEN  │
└─────────┴───────────┴───────────────────────────────────┴───────────────┘

Notes:
  🔵 moment → deprecated: Moment.js is a legacy project, now in maintenance mode

release-age deliberately has no contributor — its verdict is a version comparison, not a flag, so the version zone is its display. That's recorded in the contributor array's doc comment, the same kind of declared rendering choice ReleaseAgeViolation already makes about it rendering no Rules-table row.

Anything a badge can't hold — npm's deprecation notice, multiple resolved copies, an unenforced overdue nested copy — goes to the existing Notes block, stdout-only.

no-deprecated-packages

Deprecation is now an inventory fact on PackageDistribution, recorded by the registry pass, plus a rule that judges it.

It resolves last-match-wins against an implicit ['**'] baseline at severity info — the same governance release-age uses, and not the way no-packages works. That matters: an 'off' entry has to survive resolution, or the package falls through to the baseline and 'off' silently does nothing. tests/config/overrides.test.ts pins that asymmetry with the no-packages contrast right beside it.

The baseline sets severity, not enablement. A deprecated package stays visible with no configuration at all, an error entry makes it fail comply, and an 'off' entry exempts it:

release-age no-deprecated-packages registry fetched? a deprecated package shows
configured unconfigured yes 🔵 DEPRECATED — today's behaviour, preserved
off configured yes at the matching entry's severity — #107 fixed
off unconfigured no nothing — no new network cost

Both registry-backed rules read the same fetched document through one enrichFromRegistry pass, so enabling the second costs no extra requests.

Bugs fixed in passing

  • A release-age breach governed by an info-severity entry rendered 🔴, and so did one governed by an 'off' entry that produced no violation at all — both from a severity === 'warn' ? 'warn' : 'error' ternary that swept them into the error branch. Now 🔵 and unadorned respectively.
  • 🔵 meant two unrelated things at once: "an info-severity violation exists" and "an upgrade is coming due." A pending upgrade now renders without an icon.
  • Chart mode measured maxLabelLength from the bare package name while padding a badge-prefixed, ANSI-coloured string, so any flagged row pushed its bar out of column ([RESTRICTED] badge invents a concept that doesn't exist and hides the warn/info distinction #86 reported this). Badges now trail the bar, which removes the mismatch rather than compensating for it.
  • The enricher's merge loop keyed on whether a release-age entry was computed rather than on whether the fetch succeeded — which would have silently discarded every deprecation fact the moment release-age was off. This was the single most likely way Unbundle deprecation from releaseAge #107 shipped broken.
  • --summary-file crammed the literal string deprecated into the Target cell. It's a Status badge now, and the target cell comes from the same function the human table calls, so releaseAge: clarify root vs nested scope; yarn first-wins + summary UX mismatch #57's "both surfaces recommend the same version" is structural rather than two call sites kept in step by hand.

Breaking changes

Changeset: major.

Two notes for review

comply's packages-table gate is unchanged, on purpose. I widened it so badges would show there, then reverted: it made every comply run print a full table to repeat a cross-reference the Rules table already carries. That revert took 7 output-review cases from diff back to byte-identical. The remaining 9 diffs are cases that genuinely change.

docs-templates/__examples.md is deliberately untouched — please don't "regenerate the docs" on top of this. That template is pre-v3 stale (forbid_packages, enforceOn, "type": instead of ruleId:), and scripts/update-docs.js shells node dist/cli.js while the build emits dist/cli.mjs, with nothing in package.json or CI invoking it. Running the generator would overwrite the good docs/examples.md with ~350 lines of obsolete content. Repairing or deleting that pipeline is its own task. (#148 edited the template, which is one reason it's superseded rather than merged.)

Test plan

  • pnpm run typecheck
  • pnpm run lint
  • pnpm run format:ci
  • pnpm run build
  • pnpm run test:ci — 1164 tests pass (up from 1124), 55 files
  • pnpm run test:output — 9 of 28 cases changed, 0 invariant breaches
  • Ran comply against fixtures/ with release-age off and no-deprecated-packages at error: the registry is reached, moment is deprecated renders in the Rules table with npm's own notice, exit 1
  • Ran comply against fixtures/repos/all-rule-types: moment renders 🔴 FORBIDDEN 🟡 DEPRECATED — two badges at two severities — alongside the Installed/Minimum target columns
  • Chart mode: bars align across a short and a long package name with one row flagged

New coverage includes tests/utils/package-status.test.ts (the contributor contract, so the next person adding one finds it beside the extension point), detectDeprecatedPackages in tests/utils/package-rules.test.ts, the 'off'-survives-resolution pair in tests/config/overrides.test.ts, and enricher tests for deprecation with no release-age policy and for a failed fetch recording neither field.

🤖 Generated with Claude Code

…cation its own rule

The packages table had no model for "a package rule has something to say
about this row" — each flag bolted its own display on, glued to the front of
the package name. This gives it one: a declared contributor table
(src/utils/package-status.ts), the packages-table counterpart to
ONE_ROW_PER_VIOLATION in print-rules.ts. A badge exists if and only if a
package rule produced a violation for that row, and its icon is that
violation's severity. Adding a future package rule is one entry there and
nothing else — no new column, no change to any renderer.

  ┌─────────┬───────────┬───────────────────────────────────┬───────────────┐
  │ Package │ Installed │ Minimum target                    │ Status        │
  ├─────────┼───────────┼───────────────────────────────────┼───────────────┤
  │ react   │ 18.3.1    │ 🟢                                │               │
  │ moment  │ 2.29.4    │ 🔴 major 4.2.0 (40 days overdue)  │ 🔵 DEPRECATED │
  │ eslint  │ 8.57.0    │ 🟢                                │ 🔴 FORBIDDEN  │
  └─────────┴───────────┴───────────────────────────────────┴───────────────┘

release-age keeps the version zone — its verdict is a version comparison,
not a flag — so Status is purely the hook for everything else.

Deprecation used to be detected only as a by-product of release-age
enrichment, so turning release-age off silently turned deprecation detection
off with it, even though the enricher's own comment called deprecation "a
policy/registry fact independent of" release age. It is now an inventory
fact recorded by the registry pass plus a rule that judges it, resolved
last-match-wins against an implicit ['**'] baseline at severity 'info' — the
same governance release-age uses, which is what lets 'off' genuinely exempt
a package instead of falling through to the baseline. So a deprecated
package stays visible with no configuration, an 'error' entry makes it fail
comply, and both registry-backed rules share one request per installed
package. A repo configuring neither still makes no network calls.

BREAKING CHANGE: packages[].releaseAge.deprecated moved to
packages[].deprecated in --format json, and `deprecated` was removed from
release-age entries in ruleViolations[]. Deprecation is now its own
ruleId: "no-deprecated-packages" entry, so a repo with release-age
configured gains info-severity violations it did not have before (they do
not affect compliance.status or the exit code). The Target column is now
Minimum target in both the human table and --summary-file, whose header row
changed accordingly. The [BANNED]/[RESTRICTED]/[DEPRECATED] name prefixes
and the gray [not enforced] suffix are gone.

Fixes visible in the same output:

- A release-age breach governed by an info-severity entry rendered red, and
  so did one governed by an 'off' entry that produced no violation at all.
  Both came from a `severity === 'warn' ? 'warn' : 'error'` ternary that
  swept info and off into the error branch.
- 🔵 meant two unrelated things at once — "an info-severity violation
  exists" and "an upgrade is coming due". A pending upgrade now renders
  without an icon.
- Chart mode measured its label padding from the bare package name while
  padding a badge-prefixed, ANSI-coloured string, so any flagged row pushed
  its bar out of column. Badges now trail the bar, which removes the
  mismatch rather than compensating for it.
- The enricher's merge loop keyed on whether a release-age entry was
  computed rather than on whether the fetch succeeded, which would have
  silently discarded every deprecation fact once release-age was off.

Closes #86
Closes #107

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: 90d99be

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

Coverage Report

Status Category Percentage Covered / Total
🟢 Lines 99.18%
⬆️ +0.0%
1702 / 1716
🟢 Statements 98.64%
⬇️ -0.0%
1887 / 1913
🟢 Functions 98.95%
⬆️ +0.0%
380 / 384
🟢 Branches 97.62%
⬆️ +0.1%
1314 / 1346
File Coverage
File Stmts Branches Functions Lines
Changed Files
src/utils/print-rules.ts 93%
⬆️ +0.2%
91.35%
⬆️ +0.6%
100%
🟰 ±0%
94.56%
⬆️ +0.2%
src/npm-registry/enricher.ts 95.07%
⬇️ -0.7%
90.37%
⬆️ +0.5%
100%
🟰 ±0%
99.16%
⬆️ +0.0%
src/utils/print-packages.ts 99%
⬆️ +0.1%
98.43%
⬆️ +1.8%
100%
🟰 ±0%
100%
🟰 ±0%
src/config/overrides.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/config/schema.ts 100%
🟰 ±0%
96.55%
🟰 ±0%
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-distribution.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/utils/package-flags.ts 100% 100% 100% 100%
src/utils/package-rules.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 #237 for commit 90d99be by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Output Review

9 of 28 case(s) changed · 1 with no baseline · 0 invariant breach(es) · all 28 cases

Reference: a53b1d1 — reused from cache.

Note

No baseline for 1 case(s)
The target branch could not run this case — its build produced no output at all, so there is nothing to diff against and every added line below is the whole output appearing, not changing. Usually this means the branch adds a config key the target branch’s schema rejects, in which case it resolves itself on merge. Each case page carries the reference build’s stderr.

  • comply-deprecated-packages — the reference exited 1 without printing to stdout.
Case Change
scan-human-default +17 −17 case →
scan-human-all-sections +17 −17 case →
comply-release-age +34 −29 case →
comply-release-age-unscoped +34 −29 case →
comply-deprecated-packages no baseline case →
comply-all-rule-types +13 −8 case →
comply-all-rule-types-json +11 −3 case →
release-age-root-scope +7 −7 case →
release-age-tree-scope +7 −7 case →

Open a case to read its diff, its config and its full output. The cases marked no baseline cannot be diffed from here — read their output on its own merits, since they will compare normally once this merges. If this is the change you meant, apply the output:approved label — see CONTRIBUTING.md.

@Gallevy Gallevy added the output:approved You read the output-review diff and confirmed it label Sep 13, 2026
Review feedback on the packages table: the cell mixed two casing conventions,
left one row bare, and contradicted itself.

- Lead with the version and move the bump tier into the parenthetical:
  `major 4.2.0 (40 days overdue)` becomes `4.2.0 (major, 40 days overdue)`.
  The version is the actionable part and was buried third, behind an icon and
  a word you can infer by comparing it to the Installed column.

- Badges are lowercase — `forbidden`, `deprecated` — matching the semver
  words beside them and the rule ids they come from. The icon already carries
  the emphasis; uppercasing the word too put two casing conventions in one
  row.

- A pending upgrade keeps the all-clear icon: `🟢 3.24.0 (minor, due in 12
  days)`. Leaving it bare next to iconned rows read as a rendering failure
  rather than as a deliberate absence of verdict. "due in N days" replaces "N
  days remaining", which read as a leftover budget rather than a date.

- A row with nothing to check renders `—` rather than blank, so it can be
  told apart from 🟢. Blank meant both "checked, nothing to do" and "the
  registry never answered", which are opposite claims. `—` covers both
  no-installed-version (the Installed column already shows `N/A`) and
  registry miss; the row reads left to right.

- `describeUpgradeTarget` no longer says "no compliant release available"
  while naming a version in the same breath. That branch is only reachable
  when a newer release exists and simply has not been taken — being on the
  newest release that exists breaches nothing, so no tier is breached and no
  row is produced — and it contradicted `minCompliantVersion`'s own
  documented rule that being on latest counts as compliant. It now reads
  `2.30.1 (minor, 455 days overdue)`. #26's arithmetic is untouched; only its
  wording was wrong.

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
"Status" claimed to be the row's verdict while holding only part of it: the
release-age result lives in the Minimum target column and never appears
here, so the name promised a completeness the column does not have. The
singular also fought the contents, which are a list.

"Flags" is plural, claims nothing about completeness, and reads naturally
with the values it holds — this package is flagged forbidden and deprecated.
The values are adjectives about the package itself, which is the real line
between this column and the version columns beside it: those are about the
version you are on, these are about the package.

Renamed through to the code, so grepping the header lands on the module that
produces it: `package-status.ts` → `package-flags.ts`, `PackageStatus` →
`PackageFlag`, `collectPackageStatuses` → `collectPackageFlags`,
`formatPackageStatus` → `formatPackageFlags`, `describeStatusDetails` →
`describeFlagDetails`, `PACKAGE_STATUS_CONTRIBUTORS` →
`PACKAGE_FLAG_CONTRIBUTORS`.

Header text only in `--summary-file`; no change to what any surface renders
beyond the one word.

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 15:30
@github-actions github-actions Bot removed the output:approved You read the output-review diff and confirmed it label Sep 13, 2026
@Gallevy Gallevy added the output:approved You read the output-review diff and confirmed it label Sep 13, 2026
@Gallevy
Gallevy merged commit ec7ba75 into main Sep 13, 2026
7 of 8 checks passed
@Gallevy
Gallevy deleted the claude/hermex-ui-columns-868576 branch September 13, 2026 15:35
Gallevy added a commit that referenced this pull request Sep 13, 2026
Every other rule id names a policy — `no-files`, `require-files`,
`max-file-size`, `no-packages`, `require-engine-version`. `release-age` was
the only one naming the thing being measured instead. With #187 landing
`no-deprecated-packages`, the registry-backed family now reads
`no-deprecated-packages` / `no-outdated-packages` as a pair.

It also collided with ecosystem vocabulary in the worst direction:
`minimumReleaseAge` (Renovate, pnpm) and npm's `--before` all mean "don't
install anything younger than N days". This rule is the inverse — don't stay
behind an upgrade that has already aged past your threshold — so the old name
inverted the polarity for anyone arriving from those tools.

`rules['release-age']` still works, in both the base `rules` block and inside
`overrides[].rules`, and warns on load. Deprecated keys are migrated in a new
`src/config/deprecations.ts` before Zod sees the config, which is what lets
`HermexConfigSchema` stay `.strict()` — the schema describes only the current
vocabulary and one module knows the old one.

Authoring both spellings is an error rather than a silent precedence rule:
the two would resolve to one governing entry per package, and quietly picking
a winner would hide a policy decision the user never made.

Internal field names (`worstLevel`, `UpgradeLevel`, `minCompliant*`) are
deliberately untouched — they belong to the layering fix in #189, which will
move rather than rename them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Unbundle deprecation from releaseAge [RESTRICTED] badge invents a concept that doesn't exist and hides the warn/info distinction

1 participant