Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/lucky-badges-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'hermex': major
---

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.
134 changes: 124 additions & 10 deletions docs/examples.md

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion fixtures/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,19 @@ export const cases: FixtureCase[] = [
registry: true,
expectExit: 1,
},
{
name: 'comply-deprecated-packages',
proves:
'Deprecation detection with release-age switched off entirely — the #107 case. Before, deprecation was a by-product of release-age enrichment, so this configuration found nothing at all and this run would have been silently compliant on that axis. Now the registry is consulted for deprecation alone: a no-deprecated-packages row appears in the Rules table carrying npm own notice, and at severity error it fails comply on its own. The only case where the registry is reached without release-age, which is exactly the path that did not exist before.',
cwd: '.',
args: ['comply', '--config', 'configs/deprecated-packages.config.ts'],
registry: true,
expectExit: 1,
},
{
name: 'comply-all-rule-types',
proves:
'Every one of the eleven rule types in one run, at three severities — the only case that renders max-file-size, require-engine-version, codeowners, both package-field shapes, and release-age together. release-age itself never gets a Rules-table row (its display is the Packages table) — that split is what this case pins.',
'Every one of the twelve rule types in one run, at three severities — the only case that renders max-file-size, require-engine-version, codeowners, both package-field shapes, release-age and no-deprecated-packages together. release-age itself never gets a Rules-table row (its display is the Packages table) — that split is what this case pins, along with the only multi-badge Status cell in the fixtures (moment is both forbidden and deprecated).',
cwd: 'repos/all-rule-types',
args: ['comply'],
registry: true,
Expand Down
2 changes: 1 addition & 1 deletion fixtures/cases/comply-all-rule-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# `comply-all-rule-types`

Every one of the eleven rule types in one run, at three severities — the only case that renders max-file-size, require-engine-version, codeowners, both package-field shapes, and release-age together. release-age itself never gets a Rules-table row (its display is the Packages table) — that split is what this case pins.
Every one of the twelve rule types in one run, at three severities — the only case that renders max-file-size, require-engine-version, codeowners, both package-field shapes, release-age and no-deprecated-packages together. release-age itself never gets a Rules-table row (its display is the Packages table) — that split is what this case pins, along with the only multi-badge Status cell in the fixtures (moment is both forbidden and deprecated).

## What runs

Expand Down
29 changes: 29 additions & 0 deletions fixtures/cases/comply-deprecated-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!-- Generated by `pnpm run test:output`, every run. Edit the case in
fixtures/cases.ts, not this file. -->

# `comply-deprecated-packages`

Deprecation detection with release-age switched off entirely — the #107 case. Before, deprecation was a by-product of release-age enrichment, so this configuration found nothing at all and this run would have been silently compliant on that axis. Now the registry is consulted for deprecation alone: a no-deprecated-packages row appears in the Rules table carrying npm own notice, and at severity error it fails comply on its own. The only case where the registry is reached without release-age, which is exactly the path that did not exist before.

## What runs

| | |
| --- | --- |
| **Command** | `hermex comply --config configs/deprecated-packages.config.ts` |
| **Working directory** | `fixtures/` |
| **Config** | [`fixtures/configs/deprecated-packages.config.ts`](../configs/deprecated-packages.config.ts) |
| **Fixture** | [`fixtures`](..) — [overview](../README.md) |
| **Asserted exit code** | `1` |
| **Registry** | offline, served from [`fixtures/registry/timelines.ts`](../registry/timelines.ts) — never the network |

## Recorded output

Nothing is committed for this case's output — every run compares this tree's real output against a fresh build of the target branch (`--against`, `main` by default). A change to hermex's output shows up as a diff in that run's output-review report, never as a file in this repo.

## Run it locally

```bash
pnpm run test:output -- --filter comply-deprecated-packages
```

Defined in [`fixtures/cases.ts`](../cases.ts).
31 changes: 31 additions & 0 deletions fixtures/configs/deprecated-packages.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { HermexConfigInput } from '../../src/config/types.ts';
import base from '../hermex.config.ts';

/**
* `no-deprecated-packages` with `release-age` left empty — the #107 case.
*
* Deprecation used to be a by-product of release-age enrichment, so this
* configuration detected nothing at all: turning release-age off silently
* took deprecation with it. Here the registry is consulted for deprecation
* alone, which is what the output proves — a `no-deprecated-packages` row
* in the Rules table, carrying npm's own notice, on a run where release-age
* never looked anything up.
*
* `comply` prints no Packages table without release-age data, by design: a
* badge there is a cross-reference to the Rules row above it, not a second
* finding, and `comply` is the gating view. `scan-human-default` is where
* the Status column itself is reviewed.
*
* At `error`, so it also pins that deprecation can now fail `comply` on its
* own rather than being display-only.
*/
export default {
...base,
releaseAge: {
cacheDisabled: true,
},
rules: {
...base.rules,
'no-deprecated-packages': [{ severity: 'error', patterns: ['**'] }],
},
} satisfies HermexConfigInput;
19 changes: 14 additions & 5 deletions fixtures/repos/all-rule-types/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# `repos/all-rule-types/`

A repo engineered so **every one of the eleven rule types fires at once**, at
A repo engineered so **every one of the twelve rule types fires at once**, at
three different severities.

## What it proves

The primary fixture repo only ever trips three of the eleven. Without this
The primary fixture repo only ever trips three of the twelve. Without this
repo the rules table has never been reviewed with a `max-file-size` row, an
`require-engine-version` row, a `codeowners` row, or either of the two
package-field shapes in it — so nothing would catch a renderer that
Expand All @@ -30,6 +30,15 @@ field reports the offending value.
| `no-package-fields` | warn | `publishConfig.registry` points at an internal registry |
| `require-engine-version` | error | `engines.node` is `>=16`, below the required `>=20` |
| `codeowners` | info | fires **twice** — see below |
| `release-age` | error | `react@18.3.1` is overdue on a major, with `19.1.0` still in-window |
| `no-deprecated-packages` | warn | the registry reports `moment@2.29.4` deprecated |

`release-age` is the only rule that renders no row in the Rules table at
all — its display is the Packages table. `no-deprecated-packages` renders in
both: a Rules row carrying npm's own notice, and a Status badge on the
Packages row. Since `moment` is also forbidden, that row is the one place in
the fixtures where two Status badges appear together at different
severities.

`includes` is scoped to `src/` so `jest.config.js` is found by
`no-files` without also being parsed as source — the same scoping keeps
Expand Down Expand Up @@ -62,7 +71,7 @@ reads "have the wrong owner".

| Case | Command | Expects |
| --- | --- | --- |
| `comply-all-rule-types` | `hermex comply` | exit 1, all eleven rows in the human table |
| `comply-all-rule-types` | `hermex comply` | exit 1, every rule type represented — eleven in the Rules table, release-age in the Packages table |
| `comply-all-rule-types-json` | `hermex comply --format json` | exit 1, the machine-readable shape of each rule type |

## Layout
Expand All @@ -71,9 +80,9 @@ reads "have the wrong owner".
.babelrc no-files hit
.github/CODEOWNERS two of three src files covered
assets/logo.svg 1410 B, over the 1 KB max-file-size ceiling
hermex.config.ts all eleven rules, three severities
hermex.config.ts all twelve rules, three severities
jest.config.js no-files hit
package.json engines >=16, no license, publishConfig.registry, moment
package.json engines >=16, no license, publishConfig.registry, moment (forbidden + deprecated)
src/legacy.tsx owned by the wrong team
src/orphan.tsx owned by nobody
src/owned.tsx correctly owned
Expand Down
22 changes: 14 additions & 8 deletions fixtures/repos/all-rule-types/hermex.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import type { HermexConfigInput } from '../../../src/config/types.ts';

/**
* Every rule type hermex has, all firing at once, at three different
* severities. The primary fixture repo only ever trips three of the eleven —
* severities. The primary fixture repo only ever trips three of the twelve —
* so without this repo the rules table has never been reviewed with an
* `require-engine-version` row, a `require-codeowners` row, or either of the
* package-field shapes in it, and nothing would catch a renderer that
* mishandles `fieldPath` / `installedRange` / a long `matchedFiles` list.
* `release-age` is the eleventh and only rule that never renders a Rules-table
* row at all (its display is the Packages table) — this is the only case
* that exercises it alongside the other ten in one run.
* `release-age` is the only rule that never renders a Rules-table row at
* all (its display is the Packages table) — this is the only case that
* exercises it alongside the other eleven in one run. `moment` is both
* forbidden and deprecated here, which makes it the one row anywhere in the
* fixtures that carries two Status badges at different severities.
*
* Scoped to `src/` so `jest.config.js` is found by `no-files` without
* also being parsed as source — and so `assets/logo.svg`, which exists
Expand All @@ -22,11 +24,15 @@ export default {
},
rules: {
// react@18.3.1 is overdue on a major (19.0.0, breached) with a genuine
// compliant target still in-window (19.1.0) — the eleventh rule type,
// and the only one whose display lives in the Packages table rather
// than the Rules table (see src/utils/print-rules.ts's renderer
// dispatch).
// compliant target still in-window (19.1.0) — the only rule whose
// display lives in the Packages table rather than the Rules table (see
// src/utils/print-rules.ts's renderer dispatch).
'release-age': [{ severity: 'error', patterns: ['react'] }],
// moment@2.29.4 carries a deprecation notice in the fixture registry,
// and is already forbidden below — so its Packages row renders both
// badges at once, at two different severities, which is the only place
// the fixtures exercise a multi-badge Status cell.
'no-deprecated-packages': [{ severity: 'warn', patterns: ['moment'] }],
'no-files': [
{
severity: 'error',
Expand Down
2 changes: 1 addition & 1 deletion fixtures/repos/all-rule-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "hermex-fixture-all-rule-types",
"version": "1.0.0",
"private": true,
"description": "A repo engineered so every one of the eleven rule types fires at once, which no other fixture does: `license` is missing (require-package-fields), `publishConfig.registry` is present (no-package-fields), assets/logo.svg is over its 1 KB ceiling (max-file-size), engines.node is below the required range (require-engine-version), `build` and `test` scripts are absent (require-scripts), moment is declared (no-packages), typescript is not (require-packages), jest.config.js and .babelrc exist (no-files), .nvmrc does not (require-files), and CODEOWNERS leaves one file unowned and another owned by the wrong team (codeowners, twice).",
"description": "A repo engineered so every one of the twelve rule types fires at once, which no other fixture does: `license` is missing (require-package-fields), `publishConfig.registry` is present (no-package-fields), assets/logo.svg is over its 1 KB ceiling (max-file-size), engines.node is below the required range (require-engine-version), `build` and `test` scripts are absent (require-scripts), moment is declared and deprecated (no-packages, no-deprecated-packages), typescript is not (require-packages), jest.config.js and .babelrc exist (no-files), .nvmrc does not (require-files), and CODEOWNERS leaves one file unowned and another owned by the wrong team (codeowners, twice).",
"type": "module",
"engines": {
"node": ">=16"
Expand Down
24 changes: 15 additions & 9 deletions src/commands/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { findFiles } from '../utils/file-utils';
import { findAndParseLockfile } from '../lock-parser';
import { evaluateRules } from '../rules/evaluator';
import { collectDeclaredPackages } from '../rules/shared';
import { evaluateReleaseAge } from '../rules/release-age';
import { evaluateRegistryRules, needsRegistry } from '../rules/registry-rules';
import { applyOverrides } from '../config/overrides';
import { runPlugins } from '../plugins';
import type { HermexConfig } from '../config/types';
Expand Down Expand Up @@ -122,21 +122,27 @@ export async function runPipeline(
...evaluatorViolations,
];

// `rules['release-age']` being non-empty IS "release-age enabled" — no
// separate flag (see src/config/schema.ts's releaseAge block comment).
if (resolvedConfig.rules['release-age'].length > 0) {
// A rule family being non-empty IS "that rule enabled" — no separate
// flag (see src/config/schema.ts's releaseAge block comment). Both
// registry-backed families share one enrichment pass, so the gate is
// "does anything need the registry", not "is release-age on" (#107).
if (needsRegistry(resolvedConfig.rules)) {
const forReleaseAge = resolvedConfig.rules['release-age'].length > 0;
if (spinner.isEnabled)
spinner.start('Fetching release age from registry...');
const { enriched, violations, skipped } = await evaluateReleaseAge(
spinner.start(
forReleaseAge
? 'Fetching release age from registry...'
: 'Checking the registry for deprecated packages...',
);
const { enriched, violations, skipped } = await evaluateRegistryRules(
aggregated.packageDistribution,
resolvedConfig.releaseAge,
resolvedConfig.rules['release-age'],
resolvedConfig,
);
aggregated.packageDistribution = enriched;
aggregated.ruleViolations = [...aggregated.ruleViolations, ...violations];
spinner.succeed(
chalk.blue(
`Release age fetched${skipped > 0 ? chalk.gray(` (${skipped} packages skipped — registry unreachable or not found)`) : ''}`,
`${forReleaseAge ? 'Release age fetched' : 'Registry checked'}${skipped > 0 ? chalk.gray(` (${skipped} packages skipped — registry unreachable or not found)`) : ''}`,
),
);
}
Expand Down
Loading
Loading