From 4f3195e31c82f7317bc6a72751153d51682639b9 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 5 Mar 2026 12:17:58 +0000 Subject: [PATCH] Add vers-spec test suite and fix spec compliance Adds the official package-url/vers-spec test suite as a git submodule and a test runner for version comparison, range containment, and native-to-vers conversion across supported schemes. All 1516 spec assertions pass. Fixes found through the spec suite: - Gem pessimistic upper bounds use minimal segments (2.1 not 2.1.0) - Unbounded ranges include scheme in vers URI - Exact versions omit = prefix (implicit per spec) - Constraint sorting follows spec ordering by version - npm: tilde with prerelease, operator+x-range, bare version lists - npm: normalize versions to semver three segments - npm: fix || groups to parse space-separated AND constraints - Exclusion-only ranges start from unbounded - Detect != pattern in to_vers_string output --- .gitmodules | 3 ++ lib/vers/parser.rb | 86 +++++++++++++++++++++++++++++------ test/test_parser.rb | 6 +-- test/test_vers_spec_suite.rb | 88 ++++++++++++++++++++++++++++++++++++ test/vers-spec | 1 + 5 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 .gitmodules create mode 100644 test/test_vers_spec_suite.rb create mode 160000 test/vers-spec diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..eadf658 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test/vers-spec"] + path = test/vers-spec + url = https://github.com/package-url/vers-spec.git diff --git a/lib/vers/parser.rb b/lib/vers/parser.rb index 20d8615..93b5979 100644 --- a/lib/vers/parser.rb +++ b/lib/vers/parser.rb @@ -109,16 +109,26 @@ def parse_native(range_string, scheme) # @return [String] The vers URI string # def to_vers_string(version_range, scheme) - return "*" if version_range.unbounded? + return "vers:#{scheme}/*" if version_range.unbounded? return "vers:#{scheme}/" if version_range.empty? intervals = version_range.raw_constraints || version_range.intervals constraints = [] + # Detect != pattern: two intervals (-∞,V) ∪ (V,+∞) + if intervals.length == 2 + a, b = intervals + if a.min.nil? && !a.max_inclusive && b.max.nil? && !b.min_inclusive && a.max == b.min + constraints << "!=#{a.max}" + constraints.sort_by! { |c| sort_key_for_constraint(c) } + return "vers:#{scheme}/#{constraints.join('|')}" + end + end + intervals.each do |interval| if interval.min == interval.max && interval.min_inclusive && interval.max_inclusive # Exact version - constraints << "=#{interval.min}" + constraints << interval.min.to_s else # Range constraints if interval.min @@ -134,7 +144,6 @@ def to_vers_string(version_range, scheme) end constraints.sort_by! { |c| sort_key_for_constraint(c) } - constraints.uniq! "vers:#{scheme}/#{constraints.join('|')}" end @@ -144,7 +153,7 @@ def to_vers_string(version_range, scheme) def sort_key_for_constraint(constraint) version = constraint.sub(/\A[><=!]+/, '') v = Version.cached_new(version) - [v.major || 0, v.minor || 0, v.patch || 0, constraint] + [v, constraint] end def parse_constraints(constraints_string, scheme) @@ -164,8 +173,14 @@ def parse_constraints(constraints_string, scheme) end end - # Start with the union of all positive constraints - range = VersionRange.new(intervals, scheme: interval_scheme) + # Start with the union of all positive constraints, or unbounded if only exclusions + range = if intervals.any? + VersionRange.new(intervals, scheme: interval_scheme) + elsif exclusions.any? + VersionRange.unbounded + else + VersionRange.new([], scheme: interval_scheme) + end # Apply exclusions exclusions.each do |version| @@ -185,7 +200,7 @@ def parse_npm_range(range_string) # Handle || (OR) operator if range_string.include?('||') or_parts = range_string.split('||').map(&:strip) - ranges = or_parts.map { |part| parse_npm_single_range(part) } + ranges = or_parts.map { |part| parse_npm_range(part) } return ranges.reduce { |acc, range| acc.union(range) } end @@ -206,7 +221,13 @@ def parse_npm_range(range_string) end end ranges = merged.map { |part| parse_npm_single_range(part) } - ranges.reduce { |acc, range| acc.intersect(range) } + # If all parts are bare versions (no operators), treat as union + all_exact = merged.all? { |part| part.match?(/\A\d/) } + if all_exact + ranges.reduce { |acc, range| acc.union(range) } + else + ranges.reduce { |acc, range| acc.intersect(range) } + end end def parse_npm_single_range(range_string) @@ -256,8 +277,25 @@ def parse_npm_single_range(range_string) # Invalid patterns that should raise errors raise ArgumentError, "Invalid NPM range format: #{range_string}" else + # Check for operator + x-range (e.g. ">=2.2.x", ">=1.x") + if range_string.match(/\A[><=]+(\d+)\.[xX*]\z/) + major = $1.to_i + return VersionRange.new([ + Interval.new(min: "#{major}.0.0", max: "#{major + 1}.0.0", min_inclusive: true, max_inclusive: false) + ]) + end + if range_string.match(/\A[><=]+(\d+)\.(\d+)\.[xX*]\z/) + major = $1.to_i + minor = $2.to_i + return VersionRange.new([ + Interval.new(min: "#{major}.#{minor}.0", max: "#{major}.#{minor + 1}.0", min_inclusive: true, max_inclusive: false) + ]) + end # Standard constraint constraint = Constraint.parse(range_string) + # Normalize version to semver (npm always uses 3 segments) + normalized_version = Version.cached_new(constraint.version).to_s + constraint = Constraint.new(constraint.operator, normalized_version) if constraint.exclusion? VersionRange.unbounded.exclude(constraint.version) else @@ -289,9 +327,27 @@ def parse_caret_range(version) def parse_tilde_range(version) v = Version.cached_new(version) - upper_version = if v.minor + + if v.prerelease + # ~0.8.0-pre := >=0.8.0-pre <0.8.0 OR >=0.8.0 <0.8.1 + # Prereleases only match their own major.minor.patch + base = "#{v.major}.#{v.minor || 0}.#{v.patch || 0}" + next_patch = "#{v.major}.#{v.minor || 0}.#{(v.patch || 0) + 1}" + pre_range = VersionRange.new([ + Interval.new(min: version, max: base, min_inclusive: true, max_inclusive: false) + ]) + release_range = VersionRange.new([ + Interval.new(min: base, max: next_patch, min_inclusive: true, max_inclusive: false) + ]) + return pre_range.union(release_range) + end + + upper_version = if v.patch # ~1.2.3 := >=1.2.3 <1.3.0 "#{v.major}.#{v.minor + 1}.0" + elsif v.minor + # ~1.2 := >=1.2.0 <1.3.0 + "#{v.major}.#{v.minor + 1}.0" else # ~1 := >=1.0.0 <2.0.0 "#{v.major + 1}.0.0" @@ -318,14 +374,14 @@ def parse_gem_range(range_string) def parse_pessimistic_range(version) v = Version.cached_new(version) upper_version = if v.patch - # ~> 1.2.3 := >= 1.2.3, < 1.3.0 - "#{v.major}.#{v.minor + 1}.0" + # ~> 1.2.3 := >= 1.2.3, < 1.3 + "#{v.major}.#{v.minor + 1}" elsif v.minor - # ~> 1.2 := >= 1.2.0, < 2.0.0 - "#{v.major + 1}.0.0" + # ~> 1.2 := >= 1.2.0, < 2 + "#{v.major + 1}" else - # ~> 1 := >= 1.0.0, < 2.0.0 - "#{v.major + 1}.0.0" + # ~> 1 := >= 1.0.0, < 2 + "#{v.major + 1}" end VersionRange.new([ diff --git a/test/test_parser.rb b/test/test_parser.rb index 5d904c7..f10693d 100644 --- a/test/test_parser.rb +++ b/test/test_parser.rb @@ -388,13 +388,13 @@ def test_to_vers_string_basic def test_to_vers_string_exact range = Vers::VersionRange.exact("1.2.3") vers_string = @parser.to_vers_string(range, "gem") - assert_equal "vers:gem/=1.2.3", vers_string + assert_equal "vers:gem/1.2.3", vers_string end def test_to_vers_string_unbounded range = Vers::VersionRange.unbounded vers_string = @parser.to_vers_string(range, "pypi") - assert_equal "*", vers_string + assert_equal "vers:pypi/*", vers_string end def test_to_vers_string_empty @@ -597,7 +597,7 @@ def test_round_trip_npm_space_separated def test_round_trip_gem_pessimistic range = @parser.parse_native("~> 1.2.3", "gem") vers = @parser.to_vers_string(range, "gem") - assert_equal "vers:gem/>=1.2.3|<1.3.0", vers + assert_equal "vers:gem/>=1.2.3|<1.3", vers end def test_round_trip_reparse_equivalence diff --git a/test/test_vers_spec_suite.rb b/test/test_vers_spec_suite.rb new file mode 100644 index 0000000..6a29c76 --- /dev/null +++ b/test/test_vers_spec_suite.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'json' + +class TestVersSpecSuite < Minitest::Test + SPEC_DIR = File.join(File.dirname(__FILE__), 'vers-spec', 'tests') + SUPPORTED_SCHEMES = %w[gem maven npm nuget pypi].freeze + + def test_version_comparison + each_test_file("_version_cmp_test.json") do |test_case| + scheme = test_case.dig('input', 'input_scheme') + next unless SUPPORTED_SCHEMES.include?(scheme) + + versions = test_case.dig('input', 'versions') + expected = test_case['expected_output'] + + if test_case['test_type'] == 'equality' + result = Vers.compare_with_scheme(versions[0], versions[1], scheme) + if expected == true + assert_equal 0, result, "#{test_case['description']}: #{versions[0]} should equal #{versions[1]}" + else + refute_equal 0, result, "#{test_case['description']}: #{versions[0]} should not equal #{versions[1]}" + end + else + sorted = versions.sort { |a, b| Vers.compare_with_scheme(a, b, scheme) } + # NuGet prerelease tags are case-insensitive; normalize for comparison + if scheme == 'nuget' + sorted = sorted.map { |v| normalize_nuget_version(v) } + end + assert_equal expected, sorted, "#{test_case['description']}: sorting #{versions.inspect}" + end + end + end + + def test_range_from_native + each_test_file("_range_from_native_test.json") do |test_case| + scheme = test_case.dig('input', 'scheme') + next unless SUPPORTED_SCHEMES.include?(scheme) + + native_range = test_case.dig('input', 'native_range') + expected_vers = test_case['expected_output'] + + range = Vers.parse_native(native_range, scheme) + generated = Vers.to_vers_string(range, scheme) + + assert_equal expected_vers, generated, + "#{test_case['description']}: native '#{native_range}'" + end + end + + def test_range_containment + each_test_file("_range_containment_test.json") do |test_case| + vers_string = test_case.dig('input', 'vers') + version = test_case.dig('input', 'version') + expected = test_case['expected_output'] + + scheme = vers_string.match(/\Avers:([^\/]+)\//)[1] rescue nil + next unless scheme && SUPPORTED_SCHEMES.include?(scheme) + + range = Vers.parse(vers_string) + result = range.contains?(version) + assert_equal expected, result, + "#{test_case['description']}: #{vers_string} contains? #{version}" + end + end + + def each_test_file(suffix) + Dir.glob(File.join(SPEC_DIR, "*#{suffix}")).each do |file| + data = JSON.parse(File.read(file)) + data['tests'].each { |test_case| yield test_case } + end + end + + def normalize_nuget_version(version) + if version.include?('-') + base, rest = version.split('-', 2) + if rest.include?('+') + pre, build = rest.split('+', 2) + "#{base}-#{pre.downcase}+#{build}" + else + "#{base}-#{rest.downcase}" + end + else + version + end + end +end diff --git a/test/vers-spec b/test/vers-spec new file mode 160000 index 0000000..87e8dc3 --- /dev/null +++ b/test/vers-spec @@ -0,0 +1 @@ +Subproject commit 87e8dc351bd13fa0f84dc2460e3d9929d13b5493