Repository navigation
Description
I'm building a new oracle-based testing framework. It uses fast-check to generate inputs and sequences of calls, then judges each result against an independent model of the documented behavior. I'm trying it on popular libraries to learn whether it finds useful problems. node-semver is a good test case because its small public API combines a formal version specification with detailed range and prerelease rules.
This is a provisional trial run. Some findings are probably wrong or unimportant; please treat each as a report to confirm or dismiss. Several already have reports or proposed fixes, linked below. I'm including them to make the results reproducible, without claiming novelty.
The reproductions below run against node-semver 7.8.6, commit b37b033, on Node 24.19.0, macOS arm64. I reran them on an untouched archive of that commit, separately from my locally modified investigation copy.
What would help most:
- If you have time for one case, please look at B: comparison equality is nontransitive. I did not find an exact matching report in the tracker searches.
- The other cases mainly confirm existing reports or proposed fixes. Please flag any expectation that is wrong, intended, or too marginal to matter.
- Do you have a view on any of the documentation and behavior questions below?
- Please feel free to close or ignore the rest.
How the testing worked
Rules first: I wrote laws from the README, SemVer 2.0.0, tests and deliberate comments, marking them documented, test-pinned or open. One example is that prerelease admission belongs to a whole comparator set, not separate membership calls.
Independent model: structured version cores and identifier sequences, exact decimal arithmetic, and comparator conjunctions joined by alternatives. Expected answers do not use node-semver parsing, comparison or expansion helpers. For the finite release-range cases I enumerate the complete domain. A separate decimal-order/carry implementation checks the failure classes; no external semver package supplies expected answers.
Edge inputs and checked checks: generators favor equal prefixes, adjacent integers, dotted prefixes, class/options combinations, empty alternatives, minimum cores and swapped operands. Small failing inputs become tap replays. Calibration rejects planted wrong observations, and mutation testing challenges the checks. Later arithmetic probes are bounded enumerations. This is not a completeness claim.
The generated code is local; I can share it. Every concrete example here is an executable tap test. Counts are conditional on the targeted inputs, not estimates of user impact. The upstream reproductions use the untouched commit; some investigation sweeps also used a locally modified snapshot, identified in their counts.
Findings at a glance
The letters identify test groups, not counts of independent root causes. Existing pull requests are related work; linking one does not mean I tested its patch.
| Test | Trigger | Expected → actual | Related work |
|---|---|---|---|
| B | Build equality | Transitive → 0, 0, -1 | No exact match located |
| A | Large numeric IDs | Ordered → equal | PR #880 |
| C | Comparator input | Boolean → TypeError | #908; PR #935 |
| D | Dotted prefix | Advance → repeat | PR #883 closed; #870 merged |
| E | Large counter | Advance → repeat | PR #916 |
| F | Core overflow | Valid or reject → invalid | PR #922 |
| G | Empty OR branch | Minimum → null | PR #896 |
| H | Subset contract | README true → false | #703; PRs #854, #894 |
| I1 | Empty / wildcard | false → true | #909; PRs #913, #914 |
| I2 | ANY / prerelease | Symmetric → true / false | PR #914 |
| I3 | Shared prerelease | true → false | PR #885 |
A and B both involve identifier comparison, but B occurs even with small integers. D and E both involve increment histories, with different triggers. The intersection cases are kept separate because empty domains, symmetry and a concrete shared witness support different checks.
Reproductions
Put the JavaScript blocks in test/proposed/oracle-report.js, with these imports once at the top. They use the project's tap style; the blocks inside the expandable sections are part of the same executable file.
"use strict";
const { test } = require("tap");
const semver = require("../../");Run the combined file with npx tap --no-coverage test/proposed/oracle-report.js. The finding groups are expected to fail on the tested commit; the question and preservation groups pass.
B. Build comparison reports nontransitive equality
test("B: build comparison equality is transitive", (t) => {
const a = "1.0.0+1";
const b = "1.0.0+01";
const c = "1.0.0+01.0";
const ab = semver.compareBuild(a, b);
const ac = semver.compareBuild(a, c);
const bc = semver.compareBuild(b, c);
t.ok(
ab !== 0 || ac !== 0 || bc === 0,
"if a equals b and a equals c, then b equals c",
);
t.end();
});Actual: ab and ac are 0, but bc is -1: one value compares equal to two values that do not compare equal to each other.
Why I think it's wrong: README Comparison describes compareBuild as a comparator that sorts versions in ascending order when passed to Array.sort(). Comparison equality must be transitive for that ordering to be consistent. This test permits either numeric or spelling-sensitive build ordering; it does not choose a leading-zero tie policy. The existing class tests pin prefix-length ordering for identically spelled components. This case exposes the combination of numeric ties and an early return before later components, rather than imposing SemVer precedence on build metadata.
How often: 273 of 6,908 generated build pairs and 336 of 1,600 separately enumerated pairs disagreed with numeric-tie continuation. Those are the original model's conditional counts, not counts from a generated transitivity campaign; the three-value test here establishes the narrower ordering inconsistency directly.
Previously reported patterns and contract conflict: A, C–I
A. Distinct large numeric prereleases compare equal
test("A: distinct large numeric prereleases retain their order", (t) => {
const a = "1.0.0-9007199254740992";
const b = "1.0.0-9007199254740993";
t.equal(semver.compare(a, b), -1);
t.equal(semver.eq(a, b), false);
t.equal(semver.satisfies(a, "<" + b), true);
t.end();
});Actual: compare returns 0, eq returns true, and the strict upper-bound membership returns false.
Why I think it's wrong: SemVer 2.0.0 §11.4 requires numeric prerelease identifiers to compare numerically. The README names that specification, and the existing really big numeric prerelease value test in test/classes/semver.js explicitly accepts larger identifiers. These two valid decimal integers are distinct. PR #880 reports the same comparison.
How often: 96 of 8,809 generated version checkpoints failed on large numeric precision; 22 of 15,043 range checkpoints also exposed its membership consequence. Those range cases overlap the conversion exception below.
C. A Range made from a Comparator throws on prerelease membership
test("C: converting a Comparator preserves prerelease membership", (t) => {
for (const includePrerelease of [false, true]) {
const options = { includePrerelease };
const c = new semver.Comparator(">=1.0.0-a", options);
const r = new semver.Range(c, options);
t.equal(r.test("1.0.1"), true);
t.doesNotThrow(() => t.equal(r.test("1.0.0-b"), true));
}
t.end();
});Actual: the release check passes, but testing the same-core prerelease throws TypeError: Cannot read properties of undefined (reading 'includePrerelease') in both option modes.
Why I think it's wrong: convert comparator to range in test/classes/range.js supports this constructor input, although its assertion checks only raw. README Prerelease Tags admits this candidate: it exceeds the endpoint and has the same version core. Existing #908 reports this exception; PR #935 proposes a fix.
How often: 134 generated query plans threw in both option modes: 268 of 1,110 attempted Comparator-to-Range membership calls. Other range plans did not attempt that conversion.
D. A dotted increment prefix makes consecutive class calls stall
test("D: dotted identifiers remain components across class increments", (t) => {
const v = new semver.SemVer("1.0.0");
v.inc("prerelease", "alpha.beta");
t.same(v.prerelease, ["alpha", "beta", 0]);
t.equal(v.version, "1.0.1-alpha.beta.0");
v.inc("prerelease", "alpha.beta");
t.equal(v.version, "1.0.1-alpha.beta.1");
t.end();
});Actual: the first prerelease array is ['alpha.beta', 0], while the rendered version has separate dot-delimited identifiers. The second call returns 1.0.1-alpha.beta.0 again.
Why I think it's wrong: README Prerelease Identifiers and the increment fixtures support identifier prefixes and advancing an existing prerelease. The parsed field contract uses separate components. An incremented instance should remain consistent with its rendered version, and the next call should advance the counter. Closed, unmerged PR #883 concerns the malformed array; merged PR #870 is related dotted-prefix work. This replay still fails at the tested commit.
How often: 222 of 6,949 generated increment plans had a serialized class progression or error discrepancy with a compound prefix; a separate enumeration found 2,520 of 4,320. Other failures concerned stored shape or numeric field types only and are excluded from those progression counts.
E. Repeated large prerelease increments stop advancing
test("E: repeated large prerelease increments keep advancing", (t) => {
const v = new semver.SemVer("0.0.0-9007199254740990");
for (let step = 0; step < 3; step++) {
const before = v.version;
v.inc("prerelease");
t.not(v.version, before);
}
t.end();
});Actual: the three class calls produce suffixes 9007199254740991, 9007199254740992, then 9007199254740992 again.
Why I think it's wrong: the README's inc contract says an existing prerelease is incremented, and the increment fixtures advance existing counters. The test only requires each successful increment to change the version; it permits a transition to a new subcounter and does not demand arbitrary-precision arithmetic. PR #916 reports the same repeated-instance stall.
How often: two of four five-step class chains around the storage boundary stalled on each snapshot. The separate 497-plan arithmetic matrix also exercises function signatures and receiving parsers, but its exact-successor disagreements include a policy choice, so I do not count them all as stalls.
F. A successful increment returns a version outside the supported core domain
test("F: successful increments stay within the supported core domain", (t) => {
const out = semver.inc("0.0.9007199254740991", "patch");
t.ok(
out === null || semver.valid(out) !== null,
"reject overflow or return a supported version",
);
t.end();
});Actual: out is 0.0.9007199254740992, and valid(out) is null.
Why I think it's wrong: the README describes inc as returning an incremented version. README Coercion states the maximum supported component is Number.MAX_SAFE_INTEGER; constructor and range-parsing tests independently enforce that boundary. This test allows rejection or a supported output. It does not select wrapping, carry, error type or receiver state after failure. PR #922 reports this overflow family.
How often: the same 497-plan arithmetic sweep found 40 rows across two snapshots where an overflowing core increment returned successfully outside the independently modeled domain. The nearby representable-core predictions agreed.
G. An empty OR branch hides another branch's minimum
test("G: an empty alternative cannot hide a valid minimum", (t) => {
const r = ">=1.0.1 <1.0.1 || >=1.0.2 <=1.0.3";
const minimum = semver.minVersion(r);
t.equal(minimum && minimum.version, "1.0.2");
t.equal(semver.satisfies("1.0.2", r), true);
t.end();
});Actual: minVersion returns null, even though the witness membership check passes.
Why I think it's wrong: README Ranges promises the lowest version that can match. Here default prerelease admission excludes prereleases, the first branch is empty, and the second branch contains exactly the release cores at patches 2 and 3, ignoring build metadata. The independent minimum is therefore fixed. test/ranges/min-version.js already combines an impossible branch with a valid branch in both orders, but the impossible branch has a higher lower endpoint. This replay reverses that relation. PR #896 reports this pattern.
How often: 54 of 126 bounded cases failed per snapshot; all 72 neighboring cases passed. The failures include both alternative orders. Public and direct minVersion entry points agreed.
H. Collective subset coverage: README versus branchwise rule
test("H: separate alternatives can collectively contain a range", (t) => {
t.equal(semver.subset(">=1.0.0 <=1.0.1", "1.0.0 || 1.0.1"), true);
t.equal(semver.subset(">=1.0.0 <=1.0.1", "1.0.0"), false);
t.end();
});Actual: the first result is false; the missing-point control correctly returns false.
Why I think it's wrong: the README subset contract says the subrange is entirely contained by the superrange. With default admission these two ranges contain exactly the same two release cores, with build ignored. No single alternative covers the source interval, but their union does. Existing #703 and PRs #854, #894 concern collective alternative coverage. There is competing implementation authority: the opening comment in ranges/subset.js explicitly requires each nonempty source branch to fit inside one target branch. That rule explains the result but is stronger than the README containment promise. I read this as a possible contract mismatch; if branchwise containment is intentional, the public description should make that limitation clear.
How often: 126 of 342 bounded cases failed per snapshot: complete point unions and adjacent-interval unions. Missing-point and identical-interval controls passed. These counts describe finite release domains, not general prerelease containment.
I. Intersection edge cases
I1: an empty domain against a wildcard.
test("I1: a wildcard cannot give an empty range an intersection", (t) => {
t.equal(semver.intersects("<0.0.0", "*"), false);
t.equal(semver.intersects("*", "<0.0.0"), false);
t.equal(
semver.intersects("<0.0.0-0", "*", { includePrerelease: true }),
false,
);
t.end();
});Actual: all three return true.
Why I think it's wrong: the existing comparator-intersection fixtures and deliberate minimum-version guard treat these domains as empty. No release precedes the minimum release in default mode, and no valid version precedes the minimum prerelease in inclusion mode. The wildcard cannot add a member to an empty domain. Existing #909 reports this; PRs #913 and #914 propose fixes. How often: 83 of 6,780 generated intersection pairs exposed the empty-release-domain/wildcard case. The older #521 is related history.
I2: swapping operands changes the answer.
test("I2: intersection is symmetric for a wildcard and exact prerelease", (t) => {
t.equal(semver.intersects("*", "1.0.0-a"), semver.intersects("1.0.0-a", "*"));
t.end();
});Actual: the forward result is true, the reverse is false.
Why I think it's wrong: intersection is symmetric under either shared-version or abstract-bound interpretations. This test requires only symmetry, without choosing a prerelease intersection policy. PR #914 also includes this exact pattern.
I3: two non-singleton prerelease intervals share an admitted version.
test("I3: an admitted common version establishes an intersection", (t) => {
const a = ">=0.0.0-0";
const b = "<0.0.0-a";
t.equal(semver.satisfies("0.0.0-0", a), true);
t.equal(semver.satisfies("0.0.0-0", b), true);
t.equal(semver.intersects(a, b), true);
t.end();
});Actual: both membership checks pass, but intersects returns false.
Why I think it's wrong: ordinary SemVer order places the supplied witness inside both bounds, and both explicitly admit its prerelease core under README Prerelease Tags. The intervals overlap under either interpretation above. This avoids the unsettled adjacent-open-interval question. PR #885 reports false negatives for minimum-core prerelease upper bounds.
Range normalization, documentation and behavior questions
Already acknowledged range normalization behavior
#906 reports wildcard OR absorption removing an explicit prerelease alternative. A maintainer's reply confirms it as a bug. I originally treated the deliberate star-absorption comment and README's per-set membership rule as conflicting authority; the tracker now gives that question an answer. This current-behavior test records the tested commit's result, rather than endorsing it:
test("Q1: a wildcard OR branch absorbs an explicit prerelease branch", (t) => {
t.equal(semver.satisfies("1.0.0-a", "=1.0.0-a"), true);
t.equal(semver.satisfies("1.0.0-a", "* || =1.0.0-a"), false);
t.end();
});Questions about intended behavior
The following assertions record the tested commit's answers. They are not proposed policy requirements; some are documentation clarifications with established behavior.
- Contextual zero-bound removal. The comment in
internal/re.jssays>=0.0.0is like a star, and parsing fixtures pin that standalone normalization. In this context the candidate is below an explicit lower bound, but the default answer is true. Should the README describe this contextual exception, or should the bound remain meaningful?
test("Q2: the zero release lower bound disappears in default mode", (t) => {
const r = ">=0.0.0 <0.0.0-a";
t.equal(semver.satisfies("0.0.0-0", r), true);
t.equal(semver.satisfies("0.0.0-0", r, { includePrerelease: true }), false);
t.end();
});- Prerelease inclusion and shorthand expansion.
prereleaseOverridesintest/fixtures/range-parse.jsfixes these differences, so the tested behavior is clear. Could the README clarify thatincludePrereleasechanges some shorthand bounds as well as the admission gate? Related #799 already discusses the hyphen expansion.
test("Q3: prerelease inclusion changes some shorthand bounds", (t) => {
const options = { includePrerelease: true };
t.equal(semver.satisfies("1.2.0-a", "~1.2", options), true);
t.equal(semver.satisfies("1.2.0-a", "~1.2.0", options), false);
t.equal(semver.satisfies("1.0.0-a", "1.0.0 - 2.0.0", options), true);
t.equal(semver.satisfies("1.0.0-a", ">=1.0.0 <=2.0.0", options), false);
t.end();
});- The BNF versus tested strict syntax. README Range Grammar and
range.bnfderive wildcard tails that strict fixtures reject, while the fixtures accept the extra tilde spelling below. Should the published grammar describe the tested language? Existing #392 and PR #895 cover grammar alignment.
test("Q4: strict range grammar differs from the BNF", (t) => {
t.equal(semver.validRange("1.x.5"), null);
t.equal(semver.validRange("x.1"), null);
t.equal(semver.validRange("~>3.2.1"), ">=3.2.1 <3.3.0-0");
t.end();
});- Leading equals: resolved behavior, stale prose. README Versions says a leading equals or v is stripped, while strict parsing treats them differently. In closed #690, a maintainer says strict equals support is not needed and asks that the docs reflect reality. This is a documentation follow-up, not a request to change strict acceptance.
test("Q5: strict version parsing treats equals and v differently", (t) => {
t.equal(semver.valid("=1.2.3"), null);
t.equal(semver.valid("v1.2.3"), "1.2.3");
t.end();
});- What does general interval intersection promise? Is the contract abstract bound overlap, or existence of a satisfying version? The two differ between adjacent releases: no default-admitted version lies between these endpoints, but the current answer is true. The
isSatisfiablecomment inclasses/range.jssays “exists a version which can satisfy it,” supporting the existence reading; I found no adjacent-open fixture that settles how that intent reachesintersects. The finding tests avoid depending on this choice.
test("Q6: adjacent open release bounds report an intersection", (t) => {
t.equal(semver.intersects(">1.0.0", "<1.0.1"), true);
t.end();
});- Numeric prerelease field types. Tests require small numeric identifiers to be numbers and a very large identifier to remain a string, but do not pin the exact transition. Is the boundary itself public API, or may callers accept either representation when exact? Both values below are exactly representable. I allow either representation in the open cell; this is separate from numeric advancement.
test("Q7: numeric prerelease storage changes at the safe maximum", (t) => {
t.same(semver.prerelease("0.0.0-9007199254740990"), [9007199254740990]);
t.same(semver.prerelease("0.0.0-9007199254740991"), ["9007199254740991"]);
t.end();
});- Large numeric counters after parsing. Should a large numeric identifier stored as a string advance mathematically, or start a new subcounter? The fixtures show ordinary rightmost-counter advancement but do not pin this boundary. PR fix: preserve numeric prerelease representation when incrementing #916 explicitly preserves the appended-counter convention. I have not promoted the exact-successor expectation to a finding; the repeated-instance stall above is separate.
test("Q8: parsing a large counter currently starts a new subcounter", (t) => {
t.equal(
semver.inc("0.0.0-9007199254740991", "prerelease"),
"0.0.0-9007199254740991.0",
);
t.end();
});Existing input-preservation control
Existing passing control
The function increment tests already serialize instances before and after a call. Their rc.1+build.4 fixture has two distinct build components and would catch a build-array reversal. The simpler replay here passes too. It checks existing intent; I am not proposing it as new unit-test coverage. The observation gap I repaired was in my generated driver.
test("P: functional increments preserve a multi-component build", (t) => {
const v = new semver.SemVer("1.0.0+a.0");
const before = JSON.stringify(v);
t.equal(semver.inc(v, "patch"), "1.0.1");
t.equal(JSON.stringify(v), before);
t.end();
});Appendix
Environment. node-semver 7.8.6 at the commit above; Node 24.19.0; macOS arm64; tap 16.3.10; fast-check 4.10.2; Vitest 4.1.12 for the generated oracle campaigns. The small reproductions use tap and the native CommonJS entry point. No external reference implementation is used.
Reproduction results. From the clean archived package root, I ran npx tap --no-coverage test/proposed/oracle-report.js (using the already installed tap binary locally). All A–I finding groups fail, including I1, I2 and I3; the current-behavior question groups and P pass. The combined file has 20 groups, 11 failing and 9 passing.
| Groups | Untouched commit, Node 24.19.0 / macOS arm64 |
|---|---|
| A, B, C, D, E, F, G, H, I1, I2, I3 | Fail |
| Q1–Q8 and P | Pass |
Scale and interpretation. The arithmetic sweep contained 497 plans per snapshot; finite minimum and subset sweeps contained 126 and 342 cases per snapshot. The second snapshot contains local changes, so it is not evidence of untouched-upstream correctness. A non-stopping sweep ran the original oracle model, driver and checker on 10,000 generated histories, plus 70 enumeration batches: 50,409 checkpoints and 1,986,440 direct exported-function or constructor calls (excluding class-method and internal calls). A second decimal-order/carry implementation checked the semantic failure classes. Representation-only increment disagreements were kept out of progression counts. These frequencies are conditional on the targeted grammar, not prevalence estimates.
Related tracker items. Read-only issue and pull-request checks were made on November 10, 2026. Open: #392, #703, #799, #906, #908, #909; PRs #854, #880, #885, #894, #895, #896, #913, #914, #916, #922, #935. Closed: #521, #690; PR #883 closed without merge; PR #870 merged. I searched for related reports; this does not establish novelty for any unmatched case.
Limits. This is not a complete audit of every public API or arbitrary invalid string. The permanent generators have bounded identifier counts and lengths. General prerelease interval semantics, arbitrary identifier-base coercions, overflow error/state policy, debugging output and runtimes other than the one listed remain outside the established claims. A full mutation scan on the locally modified investigation copy is a separate check; its reviewed dispositions are not an upstream correctness score. I am not claiming zero remaining gaps.
Activity