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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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-zhuangos-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):

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

Declared narrowing.apps/docstype-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

…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 mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-zhuang@claude