From 61d29eb500dcd4c207588c9b4f274d455474bf97 Mon Sep 17 00:00:00 2001 From: Alfred Mazimbe Date: Mon, 19 Aug 2024 15:25:30 +0100 Subject: [PATCH] Add semver 2 versioning in dependabot common Why: To be used as a standard for ecosystems that do not have a versioning standard. --- common/lib/dependabot/sem_version.rb | 134 +++++++++++++++ common/spec/dependabot/sem_version_spec.rb | 190 +++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 common/lib/dependabot/sem_version.rb create mode 100644 common/spec/dependabot/sem_version_spec.rb diff --git a/common/lib/dependabot/sem_version.rb b/common/lib/dependabot/sem_version.rb new file mode 100644 index 0000000000..5cb2a68ce6 --- /dev/null +++ b/common/lib/dependabot/sem_version.rb @@ -0,0 +1,134 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" + +# See https://semver.org/spec/v2.0.0.html for semver 2 details +# +module Dependabot + class SemVersion + extend T::Sig + extend T::Helpers + include Comparable + + SEMVER_REGEX = /^ + (0|[1-9]\d*)\. # major + (0|[1-9]\d*)\. # minor + (0|[1-9]\d*) # patch + (?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))? # pre release + (?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))? # build metadata + $/x + + sig { returns(String) } + attr_accessor :major + + sig { returns(String) } + attr_accessor :minor + + sig { returns(String) } + attr_accessor :patch + + sig { returns(T.nilable(String)) } + attr_accessor :build + + sig { returns(T.nilable(String)) } + attr_accessor :prerelease + + sig { params(version: String).void } + def initialize(version) + tokens = parse(version) + @major = T.let(T.must(tokens[:major]), String) + @minor = T.let(T.must(tokens[:minor]), String) + @patch = T.let(T.must(tokens[:patch]), String) + @build = T.let(tokens[:build], T.nilable(String)) + @prerelease = T.let(tokens[:prerelease], T.nilable(String)) + end + + sig { returns(T::Boolean) } + def prerelease? + !!prerelease + end + + sig { returns(String) } + def to_s + value = [major, minor, patch].join(".") + value += "-#{prerelease}" if prerelease + value += "+#{build}" if build + value + end + + sig { returns(String) } + def inspect + "#<#{self.class} #{self}>" + end + + sig { params(other: ::Dependabot::SemVersion).returns(T::Boolean) } + def eql?(other) + other.is_a?(self.class) && to_s == other.to_s + end + + sig { params(other: ::Dependabot::SemVersion).returns(Integer) } + def <=>(other) + maj = major.to_i <=> other.major.to_i + return maj unless maj.zero? + + min = minor.to_i <=> other.minor.to_i + return min unless min.zero? + + pat = patch.to_i <=> other.patch.to_i + return pat unless pat.zero? + + pre = compare_prereleases(prerelease, other.prerelease) + return pre unless pre.zero? + + 0 + end + + sig { params(version: T.nilable(String)).returns(T::Boolean) } + def self.correct?(version) + return false if version.nil? + + version.match?(SEMVER_REGEX) + end + + private + + sig { params(version: String).returns(T::Hash[Symbol, T.nilable(String)]) } + def parse(version) + match = version.match(SEMVER_REGEX) + raise ArgumentError, "Malformed version number string #{version}" unless match + + major, minor, patch, prerelease, build = match.captures + raise ArgumentError, "Malformed version number string #{version}" if minor.empty? || patch.empty? + + { major: major, minor: minor, patch: patch, prerelease: prerelease, build: build } + end + + sig { params(prerelease1: T.nilable(String), prerelease2: T.nilable(String)).returns(Integer) } + def compare_prereleases(prerelease1, prerelease2) # rubocop:disable Metrics/PerceivedComplexity + return 0 if prerelease1.nil? && prerelease2.nil? + return -1 if prerelease2.nil? + return 1 if prerelease1.nil? + + prerelease1_tokens = prerelease1.split(".") + prerelease2_tokens = prerelease2.split(".") + + prerelease1_tokens.zip(prerelease2_tokens) do |t1, t2| + return 1 if t2.nil? # t2 can be nil, in which case it loses + + # If they're both ints, convert to such + # If one's an int and the other isn't, the string version of the int gets correctly compared + if t1 =~ /^\d+$/ && t2 =~ /^\d+$/ + t1 = t1.to_i + t2 = t2.to_i + end + + comp = t1 <=> t2 + return comp unless comp.zero? + end + + # If we got this far, either they're equal (same length) or they won + prerelease1_tokens.length == prerelease2_tokens.length ? 0 : -1 + end + end +end diff --git a/common/spec/dependabot/sem_version_spec.rb b/common/spec/dependabot/sem_version_spec.rb new file mode 100644 index 0000000000..0f84022fc8 --- /dev/null +++ b/common/spec/dependabot/sem_version_spec.rb @@ -0,0 +1,190 @@ +# typed: true +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/sem_version" + +RSpec.describe Dependabot::SemVersion do + subject(:version) { described_class.new(version_string) } + + let(:valid_versions) do + %w( 0.0.4 1.2.3 10.20.30 1.1.2-prerelease+meta 1.1.2+meta 1.1.2+meta-valid 1.0.0-alpha + 1.0.0-beta 1.0.0-alpha.beta 1.0.0-alpha.beta.1 1.0.0-alpha.1 1.0.0-alpha0.valid + 1.0.0-alpha.0valid 1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay + 1.0.0-rc.1+build.1 2.0.0-rc.1+build.123 1.2.3-beta 10.2.3-DEV-SNAPSHOT + 1.2.3-SNAPSHOT-123 2.0.0+build.1848 2.0.1-alpha.1227 1.0.0-alpha+beta + 1.2.3----RC-SNAPSHOT.12.9.1--.12+788 1.2.3----R-S.12.9.1--.12+meta + 1.2.3----RC-SNAPSHOT.12.9.1--.12 1.0.0+0.build.1-rc.10000aaa-kk-0.1 + 9999999.999999999.99999999 1.0.0-0A.is.legal) + end + + let(:invalid_versions) do + %w(1 1.2 1.2.3-0123 1.2.3-0123.0123 1.1.2+.123 +invalid -invalid -invalid+invalid -invalid.01 alpha alpha.beta + alpha.beta.1 alpha.1 alpha+beta alpha_beta alpha. alpha.. beta 1.0.0-alpha_beta -alpha. 1.0.0-alpha.. + 1.0.0-alpha..1 1.0.0-alpha...1 1.0.0-alpha....1 1.0.0-alpha.....1 1.0.0-alpha......1 1.0.0-alpha.......1 + 01.1.1 1.01.1 1.1.01 1.2.3.DEV 1.2-SNAPSHOT 1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788 1.2-RC-SNAPSHOT + -1.0.3-gamma+b7718 +justmeta 9.8.7+meta+meta 9.8.7-whatever+meta+meta + 999.9999.99999----RC-SNAPSHOT.12.09.1----..12) + end + + describe "#initialize" do + context "when the version is invalid" do + let(:version_string) { "1" } + let(:error_message) { "Malformed version number string #{version_string}" } + + it "raises an error" do + expect { version }.to raise_error(ArgumentError, error_message) + end + end + end + + describe "to_s" do + it "returns the correct value" do + valid_versions.each do |version| + expect(described_class.new(version).to_s).to eq(version) + end + end + end + + describe "#inspect" do + subject { described_class.new(version_string).inspect } + + let(:version_string) { "1.0.0+build1" } + + it { is_expected.to eq("#<#{described_class} #{version_string}>") } + end + + describe "#eql?" do + let(:first) { described_class.new("1.2.3-rc.1+build1") } + let(:second) { described_class.new("1.2.3-rc.1+build1") } + + it "returns true for equal semver values" do + expect(first).to eql(second) + end + end + + describe "#<=>" do + it "sorts version strings semantically" do + versions = [] + + versions << described_class.new("1.0.0-alpha") + versions << described_class.new("1.0.0-alpha.1") + versions << described_class.new("1.0.0-alpha.1.beta.gamma") + versions << described_class.new("1.0.0-alpha.beta") + versions << described_class.new("1.0.0-alpha.beta.1") + versions << described_class.new("1.0.0-beta") + versions << described_class.new("1.0.0-beta.2") + versions << described_class.new("1.0.0-beta.11") + versions << described_class.new("1.0.0-rc.1") + versions << described_class.new("1.0.0") + expect(versions.shuffle.sort).to eq(versions) + end + + context "when comparing numerical prereleases" do + let(:first) { described_class.new("1.0.0-rc.2") } + let(:second) { described_class.new("1.0.0-rc.10") } + + it "compares numerically" do + expect(first <=> second).to eq(-1) + expect(second <=> first).to eq(1) + end + end + + context "when comparing alphanumerical prereleases" do + let(:first) { described_class.new("1.0.0-alpha10") } + let(:second) { described_class.new("1.0.0-alpha2") } + + it "compares lexicographically" do + expect(first <=> second).to eq(-1) + expect(second <=> first).to eq(1) + end + end + + context "when comparing versions that contain build data" do + let(:first) { described_class.new("1.0.0+build-123") } + let(:second) { described_class.new("1.0.0+build-456") } + + it "ignores build metadata" do + expect(first <=> second).to eq(0) + end + end + end + + describe "#prerelease?" do + subject { version.prerelease? } + + context "with an alpha" do + let(:version_string) { "1.0.0-alpha" } + + it { is_expected.to be(true) } + end + + context "with a capitalised alpha" do + let(:version_string) { "1.0.0-Alpha" } + + it { is_expected.to be(true) } + end + + context "with a dev token" do + let(:version_string) { "1.2.1-dev-65" } + + it { is_expected.to be(true) } + end + + context "with a 'pre' pre-release separated with a -" do + let(:version_string) { "2.10.0-pre0" } + + it { is_expected.to be(true) } + end + + context "with a release" do + let(:version_string) { "1.0.0" } + + it { is_expected.to be(false) } + end + + context "with a + separated alphanumeric build identifier" do + let(:version_string) { "1.0.0+build1" } + + it { is_expected.to be(false) } + end + + context "with an 'alpha' separated by a -" do + let(:version_string) { "1.0.0-alpha+001" } + + it { is_expected.to be(true) } + end + end + + describe ".correct?" do + subject { described_class.correct?(version_string) } + + context "with a nil version" do + let(:version_string) { nil } + + it { is_expected.to be(false) } + end + + context "with an empty version" do + let(:version_string) { "" } + + it { is_expected.to be(false) } + end + + context "with valid semver2 strings" do + it "returns true" do + valid_versions.each do |version| + expect(described_class.correct?(version)).to be(true) + end + end + end + + context "with invalid semver2 strings" do + it "returns false" do + invalid_versions.each do |version| + expect(described_class.correct?(version)).to be(false) + end + end + end + end +end