Skip to content

fix(ci): the declared Node floor must satisfy every dependency range, on one workspace floor - #156

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-137-node-floor-satisfies
Aug 22, 2026
Merged

fix(ci): the declared Node floor must satisfy every dependency range, on one workspace floor#156
os-zhuang merged 4 commits into
mainfrom
claude/issue-137-node-floor-satisfies

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #137
Fixes #138

Two adjudicated cards, one defect form — a declared Node floor that is not
mechanically true — folded into one PR with one commit each.

#137 — the declared floor must satisfy every dependency range

The gate reduced each dependency's engines.node to the lowest version
satisfying it and took the maximum. That is blind to a gap inside a
disjunctive range: yargs@18.0.0 and yargs-parser@22.0.0 declare
^20.19.0 || ^22.12.0 || >=23, whose minimum is 20.19.0, so they never moved
the maximum — while the root >=22 claimed Node 22.0.0 through 22.11.x was
supported, a window that range excludes.

Ruled: option 2 paired with option 1. A new unsupported rule checks the floor
the other way round, and the declarations tighten to >=22.12.0 so it lands
green.

The comparator parser grew from a lower-bound reduction to a full interval, so
it can answer "does version X satisfy range R". Both rules are derived from one
parser, so there is a single notion of an understood range — two parsers that
disagreed about that would put a range under one rule and out of the other.

unsupported strictly subsumes lockfile: any floor below the max-of-minimums
also fails to satisfy the range that produced that minimum, so a lockfile
finding always arrives with an unsupported twin, and a fixture pins that pair.
lockfile is kept as the more actionable half — it names the version to bump
to, which a gap cannot.

#138 — one floor for the workspace

tools/ci-scripts is a real workspace package (pnpm-workspace.yaml includes
tools/*, and it owns the test task the required build job runs) and
declared >=20.0.0, a whole major below the other two. Ruled: one floor. It
moves to >=22.12.0 and joins DECLARATION_FILES, so all three governed
declarations agree and the advisory note for it stops being emitted.

.node-version deliberately does not join that list. It is a
version-manager pin rather than a range, already checked by its own
node-version rule; putting a bare 22 through the range parser would read it
as the floor 22.0.0 and call the repo's own >=22.12.0 a disagreement. The
reason now sits next to the list so the next reader does not fold it in.

Evidence

Expected-red, measured rather than asserted. With the new rule and main's
declarations still in place, the gate exits 1 on this repo:

❌ 2 finding(s).
- **unsupported** — package.json declares engines.node ">=22", so it claims Node 22.0.0
  is supported — but yargs-parser@22.0.0, yargs@18.0.0 declare engines.node
  "^20.19.0 || ^22.12.0 || >=23", which 22.0.0 does not satisfy
- **unsupported** — apps/docs/package.json declares engines.node ">=22.0.0", ...

#138's observable criterion — the ungoverned note disappears. Before, on
cb41537:

ℹ️ 1 note(s), reported and not blocking:

- **ungoverned** — tools/ci-scripts/package.json declares engines.node ">=20.0.0",
  which this gate does not check — it governs package.json and apps/docs/package.json only

After — no notes section at all, and the file appears as a governed
declaration:

| Declaration | Value | Floor |
| --- | --- | --- |
| `package.json` | `>=22.12.0` | 22.12.0 |
| `apps/docs/package.json` | `>=22.12.0` | 22.12.0 |
| `tools/ci-scripts/package.json` | `>=22.12.0` | 22.12.0 |
| `.node-version` | `22` | pin |

✅ Every declared floor clears what the dependency tree requires, and the declarations agree.

Two ablations, each restored under a trap and confirmed on disk both ways.

  • Revert DECLARATION_FILES to two entries → the dedicated fixture goes red
    (the tools/ci-scripts declaration disagrees fired [ungoverned] expected [declarations]) and the advisory note returns. Restored; tree clean.
  • Revert the three declarations to main's values, keeping the new rule → gate
    exits 1 with 12 findings. Restored; tree clean.

Fixtures. unsupported ships two red fixtures. The load-bearing one is
a floor inside a gap in a disjunctive range, which fires [unsupported]
alonelockfile stays silent, which is precisely the blind spot this rule
closes. Its values are this repo's own former declarations, so it is also their
regression test. A second pins the exclusive-floor path (>22.0.0 claims 22.0.1,
not 22.0.0). unsupported also joins SILENT_RULES: 426 of the 428 ranges in
this lockfile ask for far less than the floor and must never fire.

A new SATISFIES_CASES table (24 cases) pins the predicate where it disagrees
with the reduction — <=18.0.0 reduces to 0.0.0 yet excludes 22.12.0;
18 || 20 || >=22 has holes at 19 and 21. All 18 pre-existing RANGE_CASES
still pass unchanged, which is the evidence that the parser refactor preserved
the reduction exactly.

Gates run on 592ede4, the final commit — re-run on this head after the
merge below, not carried over from the pre-merge run (exit codes captured
before any pipe; verdict lines quoted from the gates themselves):

Gate CI job Result
check-node-floor.mjs --self-test node-floor / Self-test ✓ self-test: 17 rule case(s), 24 satisfies case(s) and 18 range case(s) — exit 0
check-node-floor.mjs node-floor / Check ✅ Every declared floor clears what the dependency tree requires — exit 0
node run-self-tests.mjs build / turbo run test ✓ 2 self-test(s) passed — exit 0

Declared narrowing. apps/docs type-check and build were not run
locally. This diff changes no TypeScript, no MDX, no dependency and no config
those steps read. --frozen-lockfile is unaffected: .npmrc sets no
engine-strict and the lockfile's importers section records no engines
(measured — zero matches). CI runs the farm regardless.

Lockfile re-derived at finalisation, not quoted forward. Four Dependabot PRs
were expected to move pnpm-lock.yaml under this branch. main did move while
this branch was open — cb41537f0a830d — but that was #152, a docs-only
commit touching no package.json and not the lockfile, so none of the relay has
landed yet. origin/main was merged in at f0a830d and the numbers re-derived
on the merged tree: 428 engines blocks, wrangler@4.95.0, both yargs ranges
still ^20.19.0 || ^22.12.0 || >=23, and 0 of 428 ranges unsatisfied at
22.12.0
. If the relay lands before this merges, that last number is the one to
re-check.

One bounded in-place fix, declared

Third commit, outside the dispatched file surface: ci.yml's comment on the
node-floor job enumerated "the three Node floor declarations" and missed
tools/ci-scripts. That is the same stale-count defect #138 exists to fix,
sitting in the comment on the job that does the checking. Mechanical, no new
verification surface, no other claim on the file. Declared as a surface
increment on #137.

Notes for review

  • The triage comment on Root engines.node >=22 claims support for 22.0.0–22.11.x, a window yargs does not support #137 flagged a confidence gap: whether any downstream
    consumer advertises 22.0.0–22.11.x support that >=22.12.0 would newly
    exclude. Measured: all three packages are private: true, so nothing here is
    published and no consumer can read these fields. CI pins node-version: 22,
    which resolves well above 22.12.0.
  • unsupported judges the floor and nothing above it. A dependency excluding
    some higher version is deliberately not a finding — engines.node declares
    where support starts. That limitation is written into the script header,
    where the old one used to be.

Generated by Claude Code

claude added 4 commits August 22, 2026 14:32
…range

The node floor gate reduced each dependency's `engines.node` to the lowest
version satisfying it and took the maximum. That is blind to a gap INSIDE a
disjunctive range: `yargs@18.0.0` declares `^20.19.0 || ^22.12.0 || >=23`,
whose minimum is 20.19.0, so it never moved the maximum — while the repo's
`>=22` claimed Node 22.0.0 through 22.11.x was supported, a window that range
excludes.

The gate now also checks the floor the other way round: the lowest version each
declaration claims to support must itself satisfy every `engines.node` range in
the tree. The comparator parser grew from a lower-bound reduction to a full
interval so it can answer that, with one parser behind both rules so there is a
single notion of an understood range.

`unsupported` strictly subsumes `lockfile`, which is kept as the more
actionable half — it names the version to bump to, which a gap cannot. The
fixture that separates them (a floor clearing every minimum and still landing
in a hole) is this repo's own former declarations, pinned as a regression test.

Paired with the declaration change the rule requires: root and `apps/docs` move
from `>=22`/`>=22.0.0` to `>=22.12.0`, the lowest version the tree actually
supports throughout.

Fixes #137

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFwZj1a84ZxFUcWAi5H8S5
…rkspace

`tools/ci-scripts` is a real workspace package — `pnpm-workspace.yaml` includes
`tools/*` and it owns the `test` task the required `build` job runs — and it
declared `>=20.0.0`, a whole major below the other two declarations. The gate
reported that as an advisory `ungoverned` note rather than checking it, because
whether the workspace should hold one floor or several was undecided.

It is decided: one floor. A single lockfile installs under one Node, so a
per-package floor nothing installs separately is a claim nobody can act on. The
package moves to the workspace floor `>=22.12.0` and joins `DECLARATION_FILES`,
so all three declarations are now governed and the advisory note for it stops
being emitted.

`.node-version` deliberately does NOT join that list. It is a version-manager
pin rather than a range, already checked by its own rule; putting it through
the range parser would read a bare `22` as the floor 22.0.0 and call the
repo's own `>=22.12.0` a disagreement. The reason is now recorded next to the
list so the next reader does not fold it in.

The self-test writes the third declaration in every fixture, and a new case
pins that the file is genuinely read as a declaration: a floor disagreement
only it can see. Reverting the list to two entries turns that case red.

Fixes #138

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFwZj1a84ZxFUcWAi5H8S5
Same stale count the governed-set change fixes, in the comment on the job that
does the checking: it enumerated the root and `apps/docs` `engines.node` plus
`.node-version` and missed `tools/ci-scripts`, which is now governed too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFwZj1a84ZxFUcWAi5H8S5
@os-zhuang
os-zhuang marked this pull request as ready for review August 22, 2026 14:44
@os-zhuang
os-zhuang merged commit ca34d36 into main Aug 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants