Skip to content

fix: recognize upper-bound-only subsets - #950

Open
Narek-Khachikyan wants to merge 3 commits into
npm:mainfrom
Narek-Khachikyan:fix/subset-upper-bound
Open

Narek-Khachikyan wants to merge 3 commits into
npm:mainfrom
Narek-Khachikyan:fix/subset-upper-bound

Conversation

@Narek-Khachikyan

Copy link
Copy Markdown

Upper-bound-only ranges such as <2.0.0 incorrectly fail subset checks against *, and <1.0.0 fails against 0.x.

Use the lowest admitted version for the omitted lower bound and normalize exclusive stable upper bounds when prereleases are excluded. Track empty subset branches independently so they do not change a union's result.

Verified with npm test: all 54 files pass with 100% coverage. Separate lint, template, and commit-message checks also pass.

References

Fixes #949.
Related to #919.

Upper-bound-only ranges still have a minimum valid version. Normalize that bound before comparing ranges, and compare exclusive stable upper bounds as prerelease-zero bounds when prereleases are excluded.

Fixes npm#949
@Narek-Khachikyan
Narek-Khachikyan requested a review from a team as a code owner November 8, 2026 10:43

@1AbdullahButt 1AbdullahButt left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I read this against ranges/subset.js on main. Three notes:

  1. gt is now assumed to be set inside the dom loop. The if (gt) guard around the >/>= handling is gone, so higherGT(gt, c) and gt.operator run unconditionally. As far as I can tell that's safe: eqSet.size === 1 returns before the loop, lt-only now synthesises gt, and a sub with no comparators is the * special case handled earlier. But that's an implicit invariant spread over ~60 lines. A one-line comment at the loop (or an assert-style early return if !gt) would stop a future change to the EQ branch from turning into TypeError: Cannot read properties of undefined (reading 'operator').
  2. The -0 normalisation isn't in the algorithm comment. You added "If LT is set without GT or EQ, use the lowest admitted version for GT" to the header, but the rewrite of an exclusive stable <X into <X-0 (when !includePrerelease) also changes semantics and is what makes ['<1.0.0', '0.x', true] pass. A matching bullet would keep the header comment accurate, since it is effectively the spec for this function.
  3. sawNonNull moved into the outer loop. I agree with this: on main, a non-null earlier branch could make a later, entirely-null branch return false. It's a separate behaviour change from #949, though. The ['<0.0.0 || <0.0.0', '1.0.0', true] style tests cover it, but it'd be worth a sentence in the PR description so it isn't missed in the changelog.

The test table is thorough, especially the includePrerelease mirror cases and the loose <01.0.0 ones.

Static review; I didn't run the suite here.

Reviewed with help from Claude Code.

@Narek-Khachikyan

Narek-Khachikyan commented Nov 8, 2026 •

Copy link
Copy Markdown
Author

@1AbdullahButt Addressed in fd4f520:

  1. Added a comment immediately before the dom loop explaining why gt is always set: EQ cases return earlier, and ANY and upper-bound-only ranges receive a lower bound.
  2. Added the <X to <X-0 normalization to the algorithm comment, under the upper-bound-only case with prereleases excluded.
  3. The PR description already says, "Track empty subset branches independently so they do not change a union's result." That covers the sawNonNull change, so I left the description unchanged.

This commit only adds comments. Locally, npm test passed all 54 files with 100% coverage on Node 26.3.1, including ESLint and template-oss-check. All three PR commits passed commitlint. GitHub currently reports action_required for the new head's workflows; these local results do not establish a CI pass.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] subset() returns false for an upper-bound-only range such as subset('<2.0.0', '*')

2 participants