Repository navigation
feat(output)!: give the packages table a status-badge hook, and deprecation its own rule - #187
Conversation
…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>
🦋 Changeset detectedLatest commit: 90d99be The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Output Review9 of 28 case(s) changed · 1 with no baseline · 0 invariant breach(es) · all 28 cases Reference: Note No baseline for 1 case(s)
Open a case to read its diff, its config and its full output. The cases marked |
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>
"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>
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>
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:
[RESTRICTED]invented a config concept that never existed — both badges came from oneno-packagesrule, chosen by a severity ternary — and it collapsedwarnandinfointo 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 toONE_ROW_PER_VIOLATIONinprint-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.release-agedeliberately 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 choiceReleaseAgeViolationalready 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-packagesDeprecation 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 severityinfo— the same governancerelease-ageuses, and not the wayno-packagesworks. 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.tspins that asymmetry with theno-packagescontrast right beside it.The baseline sets severity, not enablement. A deprecated package stays visible with no configuration at all, an
errorentry makes it failcomply, and an'off'entry exempts it:release-ageno-deprecated-packagesBoth registry-backed rules read the same fetched document through one
enrichFromRegistrypass, so enabling the second costs no extra requests.Bugs fixed in passing
info-severity entry rendered 🔴, and so did one governed by an'off'entry that produced no violation at all — both from aseverity === 'warn' ? 'warn' : 'error'ternary that swept them into the error branch. Now 🔵 and unadorned respectively.maxLabelLengthfrom 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.--summary-filecrammed the literal stringdeprecatedinto 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
--format json:packages[].releaseAge.deprecated→packages[].deprecated;deprecatedremoved fromrelease-ageentries inruleViolations[]. Deprecation is now its ownruleId: "no-deprecated-packages"entry, so a repo with release-age configured gainsinfo-severity violations it didn't have before (they don't affectcompliance.statusor the exit code).Target→Minimum target, in the human table and--summary-file, whose header row changed accordingly. "Minimum" is load-bearing: the cell names the most conservative release that clears the breach, not the newest available (releaseAge overdue / human output uses newest bump date while worstLevel uses oldest #24, releaseAge minCompliantVersion should fall back to latest when no in-window upgrade exists #26).[BANNED]/[RESTRICTED]/[DEPRECATED]name prefixes and the gray[not enforced]suffix are gone.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 everycomplyrun print a full table to repeat a cross-reference the Rules table already carries. That revert took 7 output-review cases fromdiffback to byte-identical. The remaining 9 diffs are cases that genuinely change.docs-templates/__examples.mdis deliberately untouched — please don't "regenerate the docs" on top of this. That template is pre-v3 stale (forbid_packages,enforceOn,"type":instead ofruleId:), andscripts/update-docs.jsshellsnode dist/cli.jswhile the build emitsdist/cli.mjs, with nothing inpackage.jsonor CI invoking it. Running the generator would overwrite the gooddocs/examples.mdwith ~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 typecheckpnpm run lintpnpm run format:cipnpm run buildpnpm run test:ci— 1164 tests pass (up from 1124), 55 filespnpm run test:output— 9 of 28 cases changed, 0 invariant breachescomplyagainstfixtures/with release-age off andno-deprecated-packagesaterror: the registry is reached,moment is deprecatedrenders in the Rules table with npm's own notice, exit 1complyagainstfixtures/repos/all-rule-types:momentrenders🔴 FORBIDDEN 🟡 DEPRECATED— two badges at two severities — alongside the Installed/Minimum target columnsNew coverage includes
tests/utils/package-status.test.ts(the contributor contract, so the next person adding one finds it beside the extension point),detectDeprecatedPackagesintests/utils/package-rules.test.ts, the'off'-survives-resolution pair intests/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