Skip to content

Version Packages (beta) - #133

Open
hermex-release[bot] wants to merge 1 commit into
mainfrom
changeset-release/main
Open

hermex-release[bot] wants to merge 1 commit into
mainfrom
changeset-release/main

Conversation

@hermex-release

@hermex-release hermex-release Bot commented Aug 15, 2026 •

Copy link
Copy Markdown

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.

⚠️⚠️⚠️⚠️⚠️⚠️

main is currently in pre mode so this branch has prereleases rather than normal releases. If you want to exit prereleases, run changeset pre exit on main.

⚠️⚠️⚠️⚠️⚠️⚠️

Releases

hermex@3.0.0-beta.0

Major Changes

  • #173 2837621 Thanks @Gallevy! - releaseAge.enforceOn is 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 > 0 or an enforceOn match. usageCount counts 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 in packages[] 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. enforceOn decides severity and nothing else:

    enforceOn Mandatory (error) Advisory (warn)
    [] (the default) nothing every installed package
    ['**'] every installed package nothing
    ['@my-org/*'] @my-org/… everything else

    Breaking — repos that leave enforceOn empty. 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 fail comply until you name something. A repo relying on the old default will see comply stop failing on overdue dependencies — a silent loosening, so check this before upgrading. To keep the old behaviour:

    releaseAge: { enforceOn: ['**'] }

    Use ['**'], not ['*']: these are micromatch globs and a single * stops at the / in a scoped name, so ['*'] would enforce react while leaving @my-org/ui advisory.

    isReleaseAgeTarget is removed from the public API; the target set is now every entry in packages[] with a non-null version.

  • #187 ec7ba75 Thanks @Gallevy! - Packages table: a status-badge hook, and deprecation as its own rule

    Closes #86 and #107.

    A Flags column on the packages table. A badge exists if and only if a package rule produced a
    violation for that row, and its icon is that violation's severity. no-packages and the new
    no-deprecated-packages contribute 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 of
    release-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 severity info, the way release-age is. So a
    deprecated package stays visible with no configuration, an error entry makes it fail comply, 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.deprecated moved to packages[].deprecated, and deprecated
      was removed from release-age entries in ruleViolations[]. Deprecation is now reported as its own
      ruleId: "no-deprecated-packages" entry, which also means 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 packages table's Target column is now Minimum target, in both the human table and
      --summary-file, whose header row changed accordingly. The cell leads with the version and moves
      the bump tier into the parenthetical beside the timing: major 4.2.0 (40 days overdue) becomes
      4.2.0 (major, 40 days overdue). A pending upgrade reads 3.24.0 (minor, due in 12 days) rather
      than 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").
    • The [BANNED] / [RESTRICTED] / [DEPRECATED] prefixes on package names are gone, replaced by
      lowercase forbidden / deprecated badges in a new Flags column. [RESTRICTED]
      named a config concept that never existed — both badges came from one no-packages rule — and it
      collapsed warn and info into a single word.
    • The gray [not enforced] suffix is gone; the severity icon carries that on its own.

    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. They now render blue and unadorned respectively.
    • 🔵 meant two unrelated things at once — "an info-severity violation exists" and "an upgrade is coming
      due". A pending upgrade now carries the all-clear 🟢 instead, since nothing about it is a verdict.
    • release-age named 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 reads
      2.30.1 (minor, 455 days overdue). The arithmetic is unchanged; only the wording was wrong (#26).
    • Chart mode measured its label padding from the bare package name while padding a badge-prefixed,
      ANSI-colored string, so any flagged row pushed its bar out of column. Badges now trail the bar.
  • #126 d5f456d Thanks @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.ts to use the new rule keys under rules and overrides[].rules. The JSON output's ruleViolations entries carry a ruleId field instead of type, using the new ids.

  • #191 044719c Thanks @Gallevy! - refactor(no-outdated-packages)!: make packages[] policy-free

    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 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 your
    config: 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.

    "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 is listed, root and nested alike, with no judgment about
    which ones count — that is the scope decision 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 violation
    • installedVersion → measuredVersion — the old name read as "the version
      you installed" while under scope: 'tree' it meant the worst offending
      nested copy
    • breachReleasedDaysAgo + thresholdDays → daysOverdue, the one number
      the two were only ever subtracted to produce
    • AvailableUpgrade.level deleted — it was a total function of semverBump
    • minCompliantVersion + …ReleasedDaysAgo + …InWindow + …Bump gone.
      The old shape let minCompliantVersion hold a version that was not
      compliant, rescued only by minCompliantInWindow: false beside it. The
      recommendation is now computed on demand and never stored.
    • advisoryBreaches and evaluatedVersions gone from the payload; both are
      derived from releases.resolved plus the governing entry.

    enrichFromRegistry takes no policy at all — no thresholds, no scope, no
    severity. assessPackage (src/rules/no-outdated-packages.ts) is the single
    place 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 in
    package-flags.ts. printPackagesTable no longer special-cases this rule by
    name.

    Rendered output is unchanged.

  • #190 fda8b4b Thanks @Gallevy! - feat(rules)!: rename release-age to no-outdated-packages

    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, and it collided with
    established ecosystem vocabulary in the worst possible 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.

    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 base
    rules block and inside overrides[].rules. It prints a deprecation warning
    on config load (stderr, so --format json on stdout stays clean). 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 you never made.

    --json consumers: violations from this rule now carry
    ruleId: 'no-outdated-packages' instead of ruleId: 'release-age'.

  • #150 d9b7ce6 Thanks @Gallevy! - Removed the unused packages.internal config option and its [int] marker in package output.

    Config authors using packages.internal in hermex.config.ts must remove it — the key is no longer part of the schema. The internal field is also gone from the PackageDistribution and PackageInventoryEntry exported types.

  • #155 d86818a Thanks @Gallevy! - hermex.config.ts is 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 default did 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 by overrides[].rules) for stray or misspelled keys before upgrading — anything not in the documented schema will now throw.

Minor Changes

  • #168 dd78b06 Thanks @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 70fe046 Thanks @Gallevy! - Fix summary.patternCounts so it partitions cleanly against the totals it should explain: renamed imports.aliased to imports.named.aliased (still counted alongside imports.named, not subtracted from it — totalUsagePatterns counts aliased imports separately too) and added a usage.props bucket so props analysis is no longer missing from the pattern breakdown. imports.aliased consumers must switch to imports.named.aliased.

  • #176 ff4f120 Thanks @Gallevy! - New parser config 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.ts asserts the two produce identical UsageReports over every source file in fixtures/ plus ~70 per-analyzer snippets, and the e2e suite diffs a full scan --format json run between them. tests/oxc-parser/analysis.test.ts asserts 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 visitChildren to 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 untyped function field that the visitor skips), and oxc-experimental reproduces 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 fe5ac5b Thanks @Gallevy! - Score versus on files that import a package, not on JSX renders.

    versus is sold as migration tracking, but it scored each package with usageCount — 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 consolidating import { 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 renderCount and 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 a 0 renders that would read as a finding.

    The same count is on every packages[] row as importingFileCount, beside usageCount. 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 versus group can also name a package the repo does not have — misspelled, under packages.ignore, or never installed. That now reports not found in this repo (present: false in 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 tsconfig path resolution, no record of unresolved specifiers — and it deliberately leaves usageCount alone rather than reinterpreting it, so that design stays open.

    Closes #174.

  • #170 75b3033 Thanks @Gallevy! - Added a max-file-size rule: give it glob patterns and a maxSize, and any matching file over that ceiling is reported. maxSize accepts a plain byte count (204800) or a size with a unit ('200kb', '1.5mb', '500b') — units are binary, so 1 KB is 1024 B, and kib/mib/gib are accepted spellings of the same values. A file sitting exactly on the ceiling passes.

    rules: {
      'max-file-size': [
        { severity: 'error', patterns: ['**/*.svg'], maxSize: '200kb' },
      ],
    }

    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 json the violation carries maxSizeBytes and an oversizeFiles array of { file, sizeBytes }, largest first. The rule participates in overrides on the same terms as every other pattern-keyed rule — identity is the pattern list, and severity 'off' cancels it.

Patch Changes

  • #182 78a9e27 Thanks @Gallevy! - Resolve a component's package from its import specifier in constant time.

    findComponentSource runs once per JSX element, and each call re-sorted the
    entire 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 Set instead: one hash probe, no
    allocation, no scan.

    Resolution is unchanged for every specifier a lockfile can produce — bare,
    subpath, scoped, relative, absolute and unresolvable all resolve as before.

  • #185 400d653 Thanks @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 3b8ff8e Thanks @Gallevy! - allVersions in lockfile resolution output is now sorted by semver instead of lexicographically, so a package resolved at 1.9.0 and 1.10.0 reports ["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 17ede5f Thanks @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 baseline now means only that a case is new in the branch. Repo tooling only — no change to hermex itself.

  • #146 293e14e Thanks @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 48e7711 Thanks @Gallevy! - --format json now honours the output.* section toggles instead of always dumping the full report (#63, #91). output.packages: false omits packages, output.components: false omits components, and output.versus: false omits versus — the keys are dropped entirely rather than emitted empty, so disabling a section actually shrinks the file (components[] and packages[] are the bulk of a stored scan). summary.patternCounts drops when output.patterns and output.details are both false, since both of those human sections render that same array.

    version, the summary counters, ruleViolations and compliance are never gated: they are the machine-readable verdict, and comply prints rules in human mode regardless of output.rules, so honouring it here would make the JSON lossier than the terminal output it mirrors. output.summary has no clean JSON counterpart — the human Summary table shows derived metrics that share only filesAnalyzed with 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, versus and summary.patternCounts on the exported HermexScanResult type become optional, matching the fields a config can now remove.

@github-actions

github-actions Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Output Review

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

Reference: 022312b — reused from cache.

Output is unchanged. Nothing to review.

@github-actions

github-actions Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🟢 Lines 98.59%
🟰 ±0%
1749 / 1774
🟢 Statements 98.14%
🟰 ±0%
1955 / 1992
🟢 Functions 97.86%
🟰 ±0%
413 / 422
🟢 Branches 97.02%
🟰 ±0%
1339 / 1380
File Coverage
File Stmts Branches Functions Lines
Changed Files
package.json 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #251 for commit 2426f45 by the Vitest Coverage Report Action

@Gallevy Gallevy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Block until we fix approval gate

@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch 3 times, most recently from cb664e4 to 9b28484 Compare August 15, 2026 17:32
@Gallevy Gallevy added the release:pending Version Packages PR — merging this triggers a release label Aug 15, 2026
@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch 13 times, most recently from e062eb5 to f1e8f13 Compare August 16, 2026 08:17
@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch 5 times, most recently from 4f08a58 to 6755b26 Compare August 31, 2026 18:59
@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch from 6755b26 to b2c5063 Compare September 11, 2026 22:00
@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch 10 times, most recently from 8a46904 to d9ace79 Compare September 13, 2026 10:12
@hermex-release hermex-release Bot changed the title Version Packages Version Packages (beta) Sep 13, 2026
@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch 3 times, most recently from 866a52e to d576358 Compare September 13, 2026 21:04
@hermex-release
hermex-release Bot force-pushed the changeset-release/main branch from d576358 to 2426f45 Compare September 13, 2026 21:22

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compare:benchmarks compare:size release:pending Version Packages PR — merging this triggers a release

Projects

None yet

1 participant