Repository navigation
Version Packages (beta) - #133
Open
hermex-release[bot] wants to merge 1 commit into
Open
hermex-release[bot] wants to merge 1 commit into
hermex-release[bot] wants to merge 1 commit into
Conversation
Contributor
Output Review0 of 30 case(s) changed · 0 invariant breach(es) · all 30 cases Reference: Output is unchanged. Nothing to review. |
Contributor
Coverage Report
File Coverage
|
|||||||||||||||||||||||||||||||||||
Gallevy
requested changes
Aug 15, 2026
Gallevy
left a comment
Owner
There was a problem hiding this comment.
Block until we fix approval gate
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
3 times, most recently
from
August 15, 2026 17:32
cb664e4 to
9b28484
Compare
1 task
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
13 times, most recently
from
August 16, 2026 08:17
e062eb5 to
f1e8f13
Compare
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
5 times, most recently
from
August 31, 2026 18:59
4f08a58 to
6755b26
Compare
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
from
September 11, 2026 22:00
6755b26 to
b2c5063
Compare
9 tasks
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
10 times, most recently
from
September 13, 2026 10:12
8a46904 to
d9ace79
Compare
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
3 times, most recently
from
September 13, 2026 21:04
866a52e to
d576358
Compare
hermex-release
Bot
force-pushed
the
changeset-release/main
branch
from
September 13, 2026 21:22
d576358 to
2426f45
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
mainis currently in pre mode so this branch has prereleases rather than normal releases. If you want to exit prereleases, runchangeset pre exitonmain.Releases
hermex@3.0.0-beta.0
Major Changes
#173
2837621Thanks @Gallevy! -releaseAge.enforceOnis now a plain glob list of the packages that are mandatory, with no special case for the empty one, and release-age enrichment covers every installed package the repo owns rather than only the ones it renders as JSX components.Reporting. Enrichment previously required
usageCount > 0or anenforceOnmatch.usageCountcounts JSX component rendering, which has nothing to do with whether an installed version is stale, so every dependency consumed as functions or hooks was silently exempt from the check: it appeared inpackages[]with a version and a blank Target cell, beside component-shaped siblings carrying full advisory rows (#171). Every installed package now gets a Target. The cost is one registry request per installed dependency rather than per rendered one.Enforcement.
enforceOndecides severity and nothing else:enforceOnerror)warn)[](the default)['**']['@my-org/*']@my-org/…Breaking — repos that leave
enforceOnempty. An empty list previously meant "enforce everything", inverting the meaning of a list that names the mandatory packages. It now matches nothing and enforces nothing, so release age can no longer failcomplyuntil you name something. A repo relying on the old default will seecomplystop failing on overdue dependencies — a silent loosening, so check this before upgrading. To keep the old behaviour:Use
['**'], not['*']: these are micromatch globs and a single*stops at the/in a scoped name, so['*']would enforcereactwhile leaving@my-org/uiadvisory.isReleaseAgeTargetis removed from the public API; the target set is now every entry inpackages[]with a non-nullversion.#187
ec7ba75Thanks @Gallevy! - Packages table: a status-badge hook, and deprecation as its own ruleCloses #86 and #107.
A
Flagscolumn on the packages table. A badge exists if and only if a package rule produced aviolation for that row, and its icon is that violation's severity.
no-packagesand the newno-deprecated-packagescontribute today; a future package rule plugs into the same declaration table(
src/utils/package-status.ts) and gets a badge with no change to any renderer.no-deprecated-packages, a new rule. Deprecation used to be detected only as a by-product ofrelease-age enrichment, so turning release-age off silently turned deprecation detection off with it.
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 severityinfo, the way release-age is. So adeprecated package stays visible with no configuration, an
errorentry makes it failcomply, and an'off'entry genuinely exempts it. Both registry-backed rules share one request per installed package,and a repo that configures neither still makes no network calls.
Breaking changes:
--format json:packages[].releaseAge.deprecatedmoved topackages[].deprecated, anddeprecatedwas removed from
release-ageentries inruleViolations[]. Deprecation is now reported as its ownruleId: "no-deprecated-packages"entry, which also means a repo withrelease-ageconfigured gainsinfo-severity violations it did not have before (they do not affectcompliance.statusor the exitcode).
Targetcolumn is nowMinimum target, in both the human table and--summary-file, whose header row changed accordingly. The cell leads with the version and movesthe bump tier into the parenthetical beside the timing:
major 4.2.0 (40 days overdue)becomes4.2.0 (major, 40 days overdue). A pending upgrade reads3.24.0 (minor, due in 12 days)ratherthan
minor 3.24.0 (12 days remaining), and a row with nothing to check at all renders—rather than blank, so it can be told apart from 🟢 ("checked, nothing to do").
[BANNED]/[RESTRICTED]/[DEPRECATED]prefixes on package names are gone, replaced bylowercase
forbidden/deprecatedbadges in a new Flags column.[RESTRICTED]named a config concept that never existed — both badges came from one
no-packagesrule — and itcollapsed
warnandinfointo a single word.[not enforced]suffix is gone; the severity icon carries that on its own.Fixes, visible in the same output:
info-severity entry rendered red, and so did one governed by an'off'entry that produced no violation at all. They now render blue and unadorned respectively.due". A pending upgrade now carries the all-clear 🟢 instead, since nothing about it is a verdict.
release-agenamed a version and then said none was available:minor 2.30.1 (no compliant release available). 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 row is produced — and it
contradicted
minCompliantVersion's own rule that being on latest counts as compliant. It now reads2.30.1 (minor, 455 days overdue). The arithmetic is unchanged; only the wording was wrong (#26).ANSI-colored string, so any flagged row pushed its bar out of column. Badges now trail the bar.
#126
d5f456dThanks @Gallevy! - Renamed every rule id to kebab-case and standardized the require-x/no-x naming convention (detect_files→no-files,require_files→require-files,require_packages→require-packages,forbid_packages→no-packages,require_scripts→require-scripts,require_package_fields→require-package-fields,forbid_package_fields→no-package-fields,engine_version→require-engine-version,codeowners→require-codeowners).Config authors must update
hermex.config.tsto use the new rule keys underrulesandoverrides[].rules. The JSON output'sruleViolationsentries carry aruleIdfield instead oftype, using the new ids.#191
044719cThanks @Gallevy! - refactor(no-outdated-packages)!: makepackages[]policy-freeno-outdated-packageswas the only rule whose verdict was computed in theregistry layer and stored on the display object. Every other rule goes
facts → rule → violations → display; this one had the enricher stamp
severity,scopeandworstLevelontoPackageDistribution.releaseAge,and the Packages table read the verdict straight back off the package
instead of joining to violations the way the Flags column already does.
The practical consequence was that
packages[]was a function of yourconfig: two repos with identical lockfiles but different thresholds got
different payloads, and the whole block vanished or changed shape depending
on which rules were switched on.
packages[].releaseAge→packages[].releases, facts only.Every resolved copy is listed, root and nested alike, with no judgment about
which ones count — that is the
scopedecision and it belongs to the rule.This object is byte-identical whatever your thresholds, severity or scope
say, because none of them are inputs to producing it.
The verdict moved to the violation, which is the only place policy is
applied:
{ "ruleId": "no-outdated-packages", "severity": "error", "packageName": "moment", "measuredVersion": "2.29.4", "overdueTier": "minor", "daysOverdue": 946, "scope": "root" }Field-level changes:
worstLevel→overdueTier('minor' | 'major'), on the violationinstalledVersion→measuredVersion— the old name read as "the versionyou installed" while under
scope: 'tree'it meant the worst offendingnested copy
breachReleasedDaysAgo+thresholdDays→daysOverdue, the one numberthe two were only ever subtracted to produce
AvailableUpgrade.leveldeleted — it was a total function ofsemverBumpminCompliantVersion+…ReleasedDaysAgo+…InWindow+…Bumpgone.The old shape let
minCompliantVersionhold a version that was notcompliant, rescued only by
minCompliantInWindow: falsebeside it. Therecommendation is now computed on demand and never stored.
advisoryBreachesandevaluatedVersionsgone from the payload; both arederived from
releases.resolvedplus the governing entry.enrichFromRegistrytakes no policy at all — no thresholds, no scope, noseverity.
assessPackage(src/rules/no-outdated-packages.ts) is the singleplace policy is applied, and both consumers call it: the rule to emit
violations, and the Packages table to render rows no violation covers (an
'off'entry still shows a recommendation).Display: package rules declare their columns through a registry
(
src/utils/package-columns.ts), the counterpart to the badge registry inpackage-flags.ts.printPackagesTableno longer special-cases this rule byname.
Rendered output is unchanged.
#190
fda8b4bThanks @Gallevy! - feat(rules)!: renamerelease-agetono-outdated-packagesEvery other rule id names a policy —
no-files,require-files,max-file-size,no-packages,require-engine-version.release-agewasthe only one naming the thing being measured instead, and it collided with
established ecosystem vocabulary in the worst possible direction:
minimumReleaseAge(Renovate, pnpm) and npm's--beforeall mean don'tinstall 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.
Migration. Rename the key; nothing else changes.
rules: { - 'release-age': [{ severity: 'error', patterns: ['@my-org/*'] }], + 'no-outdated-packages': [{ severity: 'error', patterns: ['@my-org/*'] }], }rules['release-age']still works and behaves identically, in both the baserulesblock and insideoverrides[].rules. It prints a deprecation warningon config load (stderr, so
--format jsonon stdout stays clean). Authoringboth 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 you never made.
--jsonconsumers: violations from this rule now carryruleId: 'no-outdated-packages'instead ofruleId: 'release-age'.#150
d9b7ce6Thanks @Gallevy! - Removed the unusedpackages.internalconfig option and its[int]marker in package output.Config authors using
packages.internalinhermex.config.tsmust remove it — the key is no longer part of the schema. Theinternalfield is also gone from thePackageDistributionandPackageInventoryEntryexported types.#155
d86818aThanks @Gallevy! -hermex.config.tsis now validated strictly: a config file with no default export throws instead of silently loading as an all-defaults config, and any config object (root or nested —rules,overrides[].rules,output,releaseAge, etc.) carrying an unrecognized key throws instead of silently dropping it.Both were previously silent failures: a misspelled rule key parsed cleanly and enforced nothing, and a config file that forgot
export defaultdid the same. Both now fail loudly at load time, naming the offending key or the missing export.Config authors should check
hermex.config.ts(and any files referenced byoverrides[].rules) for stray or misspelled keys before upgrading — anything not in the documented schema will now throw.Minor Changes
#168
dd78b06Thanks @Gallevy! - Add the plugin API: run another tool as part of a hermex run and fold its findings into one verdict. A plugin is a plain object with a canonical name and a hooks envelope; the single hook, onRunComplete, runs after the inventory and hermex's own rules and before rendering, and contributes namespaced violations through ctx.violations.add. Granularity and severity belong to the plugin, which configures itself, so hermex.config.ts never becomes a second place to configure the wrapped tool. A plugin that throws aborts the run with exit 2 rather than degrading silently. Includes an oxlint recipe in docs/plugins.md.#157
70fe046Thanks @Gallevy! - Fixsummary.patternCountsso it partitions cleanly against the totals it should explain: renamedimports.aliasedtoimports.named.aliased(still counted alongsideimports.named, not subtracted from it —totalUsagePatternscounts aliased imports separately too) and added ausage.propsbucket so props analysis is no longer missing from the pattern breakdown.imports.aliasedconsumers must switch toimports.named.aliased.#176
ff4f120Thanks @Gallevy! - Newparserconfig option selects the AST front-end. It defaults to'swc'— unchanged behaviour for every existing config — and adds an opt-in'oxc-experimental'that parses with oxc-parser instead of@swc/core.Only the parse step is swapped. oxc's ESTree AST is normalized into the node vocabulary the analyzers already read, so the same visitor, the same pattern analyzers and the same report generator run under either front-end — there is no second copy of the analysis to keep in sync.
tests/oxc-parser/parity.test.tsasserts the two produce identicalUsageReports over every source file infixtures/plus ~70 per-analyzer snippets, and the e2e suite diffs a fullscan --format jsonrun between them.tests/oxc-parser/analysis.test.tsasserts the oxc path positively, so parity cannot pass by both front-ends finding nothing.Measured on the fixture corpus (41 files, 200 rounds, ms/pass): the parser plus its native binding drops from ~27 MB installed to ~3 MB, about 9x smaller. That is the reason to reach for it today.
Scans are currently slower end to end — 14.0 → 18.3 ms/pass — and the breakdown says why. oxc's parse is genuinely faster (11.7 → 7.7 ms, ~1.5x), but normalizing its AST into the analyzers' node shape costs 7.0 ms, more than that saves, and the analysis walk is a further 1.3 ms slower because the normalized tree carries more fields than SWC's native one (63.3k vs 51.9k) for
visitChildrento iterate. Both costs come from normalization being an eager deep copy; removing it means teaching the analyzers to read oxc's AST directly. The option is experimental and opt-in for exactly that reason, and a run using it prints a notice naming the parser.The normalization is deliberately faithful to SWC rather than to ESTree, including where SWC sees less: a class method's body is not analyzed under
swc(SWC stores the function in an untypedfunctionfield that the visitor skips), andoxc-experimentalreproduces that instead of quietly analyzing more than the default parser does. Widening it is a change to the analysis, and belongs in its own change.#184
fe5ac5bThanks @Gallevy! - Scoreversuson files that import a package, not on JSX renders.versusis sold as migration tracking, but it scored each package withusageCount— which measures JSX component rendering. Every migration people actually track this way is function-only (moment→date-fns,lodash→es-toolkit,redux→zustand), and on that axis both sides read 0, so a migration 80% finished rendered identically to one never started and the section fell through to "No usage detected for any package in this group". Component-library pairs worked, which is exactly what the fixtures covered.Each entry's
count— the number the percentages are computed from — is now how many scanned files import the package. Files rather than bound symbols, so consolidatingimport { format, parse, addDays }down to one helper does not read as migration progress, and one unit for every group, so the two sides of a comparison are always commensurable: a group with a component library on one side and a hook library on the other would otherwise be measured one way on the left and another on the right.The render count is not lost. It rides along as
renderCountand prints beside the file count —(8 files, 33 renders)— because the two answer different questions: files are how much of a migration is done, renders are how much editing is left. A package that renders nothing shows files alone, rather than a0 rendersthat would read as a finding.The same count is on every
packages[]row asimportingFileCount, besideusageCount. The two are independent axes and both are kept: the first is imports, the second is JSX renders, and for a package used only as a function the second is always 0. Nothing that existed changed meaning.A
versusgroup can also name a package the repo does not have — misspelled, underpackages.ignore, or never installed. That now reportsnot found in this repo(present: falsein JSON) instead of a confident 0%, because "nobody has migrated yet" and "hermex cannot see this package" call for opposite reactions from a reader.This is not the full imported axis of #163 — no per-file import sites, no
tsconfigpath resolution, no record of unresolved specifiers — and it deliberately leavesusageCountalone rather than reinterpreting it, so that design stays open.Closes #174.
#170
75b3033Thanks @Gallevy! - Added amax-file-sizerule: give it globpatternsand amaxSize, and any matching file over that ceiling is reported.maxSizeaccepts a plain byte count (204800) or a size with a unit ('200kb','1.5mb','500b') — units are binary, so 1 KB is 1024 B, andkib/mib/gibare accepted spellings of the same values. A file sitting exactly on the ceiling passes.Each rule produces one violation listing every file over its ceiling, so one pattern is one row in the rules table however many assets it catches. Under
--format jsonthe violation carriesmaxSizeBytesand anoversizeFilesarray of{ file, sizeBytes }, largest first. The rule participates inoverrideson the same terms as every other pattern-keyed rule — identity is the pattern list, and severity'off'cancels it.Patch Changes
#182
78a9e27Thanks @Gallevy! - Resolve a component's package from its import specifier in constant time.findComponentSourceruns once per JSX element, and each call re-sorted theentire lockfile package list and prefix-matched down it — O(P log P + P) work
per element, against a P that spans the whole lockfile. An npm package name is
exactly one path segment, or two when scoped, so the name is now read straight
off the specifier and looked up in a
Setinstead: one hash probe, noallocation, no scan.
Resolution is unchanged for every specifier a lockfile can produce — bare,
subpath, scoped, relative, absolute and unresolvable all resolve as before.
#185
400d653Thanks @Gallevy! - Update dependencies to their latest versions: @swc/core 1.16.2, js-yaml 5.4.1, oxc-parser 0.149.0, and zod 4.6.2 at runtime, plus the dev toolchain (vitest and @vitest/coverage-v8 5.0.0, oxlint 1.82.0, oxfmt 0.67.0, tsdown 0.23.0, @changesets/cli 3.0.2, @changesets/changelog-github 1.0.1, @types/node 26.5.1).#154
3b8ff8eThanks @Gallevy! -allVersionsin lockfile resolution output is now sorted by semver instead of lexicographically, so a package resolved at1.9.0and1.10.0reports["1.9.0", "1.10.0"]instead of["1.10.0", "1.9.0"]. Non-semver version strings (git URLs,file:links, workspace protocol) are collated last, in stable lexicographic order.#180
17ede5fThanks @Gallevy! - Output review now runs each hermex build against the fixtures from its own checkout, rather than running both builds over the working tree's fixtures. Pairing every build with the config it shipped with is what gives a rule-adding PR a baseline at all: the target branch's hermex reads the target branch's config, instead of meeting a key its.strict()schema rejects and printing nothing.no baselinenow means only that a case is new in the branch. Repo tooling only — no change to hermex itself.#146
293e14eThanks @Gallevy! - Sort ruleViolations by severity (error, then warn, then info) across every surface — the rules table, --summary-file, and --format json — so a failing row is never buried among passing ones#134
48e7711Thanks @Gallevy! ---format jsonnow honours theoutput.*section toggles instead of always dumping the full report (#63, #91).output.packages: falseomitspackages,output.components: falseomitscomponents, andoutput.versus: falseomitsversus— the keys are dropped entirely rather than emitted empty, so disabling a section actually shrinks the file (components[]andpackages[]are the bulk of a stored scan).summary.patternCountsdrops whenoutput.patternsandoutput.detailsare both false, since both of those human sections render that same array.version, thesummarycounters,ruleViolationsandcomplianceare never gated: they are the machine-readable verdict, andcomplyprints rules in human mode regardless ofoutput.rules, so honouring it here would make the JSON lossier than the terminal output it mirrors.output.summaryhas no clean JSON counterpart — the human Summary table shows derived metrics that share onlyfilesAnalyzedwith the serialized counters.Default output is unchanged — every section is enabled by default, so this only takes effect for a config that explicitly opts a section out.
packages,components,versusandsummary.patternCountson the exportedHermexScanResulttype become optional, matching the fields a config can now remove.