Feat: declare the Node.js floor on the workspace root and derive the checked manifest list (stacked on #544) - #549
Open
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 2, 2026 16:32
The published workspaces declare engines.node, but the workspace root did not, so a contributor running npm install in the repo root got no signal about an unsupported Node version until CI failed. npm evaluates a project's own engines during install, emitting EBADENGINE (a hard failure under engine-strict), so the root declaration is what makes the floor visible at the point of onboarding. The lockfile change is npm re-syncing the root manifest mirror; no dependency was added or removed.
The manifest list was hardcoded, so a workspace added later inherited no coverage: adding a fourth workspace with no engines block left the suite green. Deriving the list from the root manifest's workspaces array makes a new workspace generate its own assertions, which fail until it declares the shared range. Replaces the assertion that the root leaves engines undefined, now that the root declares it; the root is covered by the derived list like any other manifest. The unpublished-fixture guard takes over the job of pinning which manifests must stay without a floor.
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no existing issue.
Stacked on #544 (
feat/declare-node-engines-published-packages), which declaresengines.nodeon the three published workspaces. This PR is the two-commit delta thatcloses the gaps a correctness review found in that base. It is deliberately not a
competing implementation — see "Collision check" below.
Problem: Two gaps remain after #544.
The workspace root declares no floor, and a test forbids adding one. Feat: declare a supported Node.js range (>=20.6.0) on the published packages and enforce it against the lockfile #544 declares
engines.nodeoncore,devandintegrationsonly, on the rationale that "adeclaration there reaches nobody". That is true for registry consumers and false for
contributors: npm evaluates a project's own
enginesduringnpm install. Verifieddirectly:
So a contributor on an unsupported Node currently learns about it from a CI failure
rather than from
npm install. Worse, Feat: declare a supported Node.js range (>=20.6.0) on the published packages and enforce it against the lockfile #544 pins the omission withit('leaves engines off the workspace root'), so anyone who wants the root declarationhas to delete a test to get it.
A workspace added later inherits no coverage. The regression test hardcoded
const PUBLISHED_WORKSPACES = ['core', 'dev', 'integrations'], so the "cannot beomitted from a future workspace" half of the invariant did not exist. Proven by
mutation: adding a fourth workspace with no
enginesblock left the suite green at8/8.
Solution:
"engines": {"node": ">=20.6.0"}on the rootpackage.json, in the sameposition among the metadata keys as the three workspace manifests use (after
repository). The lockfile hunk is npm re-syncing the root manifest mirror — nodependency was added or removed.
workspacesarray(
const MANIFEST_DIRS = ['.', ...]) instead of hardcoding it, so a new workspacegenerates its own assertions and fails until it declares the shared range.
Two consequential test edits, called out explicitly rather than slipped in:
it('leaves engines off the workspace root'). It asserted exactly thebehaviour this PR reverses, so it cannot coexist with the fix. The root is not left
uncovered: it is now the
'.'entry inMANIFEST_DIRSand is covered by'%s declares the shared engines.node range'(asserts the exact literal) and'%s declares a floor every runtime dependency accepts'(asserts the floor against thelockfile) — strictly stronger than the assertion it replaces, which only checked that
the field was absent.
it('leaves engines off unpublished test fixtures'). The "which manifests muststay without a floor" job that the removed test was doing genuinely does need an owner;
unpublished fixtures are the correct target for it, since they inherit the floor through
the workspace they link against.
readManifestbecame synchronous becauseit.eachneeds the derived workspace list atcollection time.
readLockfileis untouched.Range: unchanged from the base branch at
>=20.6.0, which is the exact intersection ofthe
engines.noderanges in the runtime dependency closure (Node 20.5.0 is rejected by 27@opentelemetry/*packages declaring^18.19.0 || >=20.6.0; 20.6.0 satisfies every range).It is grounded in the committed
package-lock.jsonand pinned from below by the lockfiletest. Maintainers should ratify the number consciously rather than inherit it from the
stack — if you prefer the rounder
>=20, it is a one-line edit toEXPECTED_NODE_RANGEplus the four manifests, and the lockfile test will then (correctly) fail until the OTel
constraint is addressed.
Known imprecision, inherited and not introduced here: the check validates the floor
of the declared range, not every version the range admits.
>=20.6.0also admits Node21.x, which 3 entries in the closure reject (
@ts-morph/common/node_modules/{balanced-match, brace-expansion,minimatch}, range18 || 20 || >=22). Node 21 is EOL and>=20has thesame hole, so this is not a defect this PR introduces; flagging it so the test's name is not
read as a stronger guarantee than it makes.
Collision check (required before implementation; recorded here either way). Ran:
448 open PRs; six are adjacent to this work. #544 (
>=20.6.0, 3 manifests, same testpath), #508 (
>=20, 3 manifests, same test path, plus.nvmrc/CI), #445 (>=22.12.0, 3manifests, test under
tests/integration/package_manifests/), #133 (root + 3 manifests at>=22.0.0, no test), #509 (README + root + CI) and #510 (stacked on #508). No single PRdelivers root coverage together with a workspace-derived regression test, which is the gap
this PR fills. Because the change merely overlaps rather than duplicating, it is stacked on
#544's branch rather than branched from
main, so it adds no seventh competingimplementation. The four PRs propose four mutually incompatible floors (
>=20,>=20.6.0,>=22.0.0,>=22.12.0) on the same lines of the same files; a maintainer will need topick one and close the rest, and this delta applies cleanly on top of whichever survives.
eslint.config.jsis intentionally untouched: it contains noeslint-plugin-nimport andno
versionoption, so the "drop redundant version options" half of the original task wasvacuous.
Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Mutation testing — every assertion proven able to fail. Each mutation was applied on its
own, run, then reverted (the tree was re-verified clean after each):
enginesdeleted from the root manifestexpected undefined to be '>=20.6.0'/./package.json declares engines.node undefined, which is not a satisfiable semver rangenewpkgwith noengines, appended toworkspacesnewpkg/package.json declares engines.node undefined, which is not a satisfiable semver rangeenginesdeleted fromintegrationsintegrations/package.json declares engines.node undefined, ...enginesadded to the unpublished fixtureexpected { node: '>=20.6.0' } to be undefinedcorefloor lowered to>=20.5.0engines.node >=20.5.0 admits Node 20.5.0, which the listed runtime dependencies reject ...: expected [ …(27) ] to deeply equal []Mutations 1 and 2 are the two that passed against the base branch (8/8 green) and now
fail. Mutation 2 also demonstrates the list is genuinely derived: the case count rises 11 →
13 because the new workspace generates its own assertions.
Regression suite (the only place an
enginesmismatch could surface at install time, sincethese fixtures link the workspaces via
file:):Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Run from the repository root on Node >= 20.6.0:
CI note: this PR is stacked on #544, so its base is not
main. The validation workflow isscoped to
pull_request: branches: [main]and therefore never triggers here — onlyauto-assignran. Everything above was run locally against the exact pushed commit6c62a1d2.To see the consumer-facing effect, install on Node < 20.6.0 and observe
npm warn EBADENGINE; withengine-strict=truein.npmrcthe install fails outright.npm installafter the change produces no lockfile churn beyond the 3-line rootmanifest mirror shown in the diff.
Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.