Files
Dario LipicarandClaude Opus 4.8 8d4236f945 feat: one semver implementation for the packaging stack (#30)
* feat: one semver implementation for the packaging stack

Version handling was duplicated across the packaging repos and the copies
disagreed with each other and with the spec. This makes logos-package the
single home for it.

Precedence is defined by SemVer 2.0.0, so it is delegated to a vendored
library (z4kn4fein/cpp-semver v0.4.0, MIT, single header, C++17):

  - numeric pre-release identifiers compare NUMERICALLY, so 1.0.0-rc.2 <
    1.0.0-rc.11. Every previous copy got this wrong -- the downloader
    compared the whole pre-release tag as one ASCII string, while lgpm and
    the package-manager UI dropped the tag entirely (atoi("0-rc1") == 0),
    so a pre-release compared EQUAL to its own release.
  - build metadata is ignored for precedence (spec 10).

Ranges (^ ~ x * || >= <= > < =) are NOT in the semver spec -- they are an
npm convention the manifests already use -- so that layer is written once,
in include/logos/semver.hpp, on top of the library. It adopts npm's
pre-release rule: a range never matches a pre-release unless the range
itself names one at the same major.minor.patch. Without it `^1.0.0` matches
`2.0.0-alpha`, i.e. an unreleased alpha of the next major satisfies a caret
range on 1.x and can be resolved as a dependency.

Exposed three ways so every consumer reaches the same code:

  - include/logos/semver.hpp, header-only and dependency-free (the
    package-manager UI is a QML plugin with no native link deps and must be
    able to include it without dragging in zlib/ICU/libsodium).
  - the lgx_semver_* C ABI.
  - `lgx semver compare|sort|satisfies|valid|valid-range`, for the catalog
    builder (logos-modules-release-tool's index.py), which is stdlib-only
    but already requires lgx -- so it can order versions without growing a
    second implementation in Python that could drift.

Manifest range validation now delegates here too; it was a separate regex
that accepted ranges the resolver could not actually evaluate.

Validation is strict but comparison is lenient (parse vs parse_lenient):
real manifests carry partial versions like "1.0", and treating those as
invalid at compare time would sort them below every real version and
silently reorder existing catalogs.

tests/test_semver.cpp covers the spec's own precedence chain verbatim, the
pre-release cases that were previously untested everywhere, and the npm
range rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: print full nix build logs

Without -L a failing build is truncated to its last 25 lines, which hides
the first compiler error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: include <cstdint> before cpp-semver (Linux/libstdc++ build)

cpp-semver 0.4.0 uses uint64_t throughout but never includes <cstdint>
itself. libc++ drags it in transitively via <string>/<regex>, so this built
on macOS -- but libstdc++ stopped doing that in GCC 13, and on Linux the
header failed to parse with "'uint64_t' does not name a type". That
collapsed semver::version, which surfaced as a wall of bogus "has no member
named 'major'" errors pointing at our own header rather than the real cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: add a headers-only package output

For consumers that need the shared semver implementation
(include/logos/semver.hpp) but must NOT link liblgx.

The package-manager UI is a Qt plugin, and the module builder copies every
*.so/*.dylib an external library ships into the plugin's output lib/.
ui-host then scans that directory and tries to load each file as a Qt
plugin: pointing it at the `lib` output put liblgx.dylib there, ui-host
failed with "is not a Qt plugin", and the entire UI never rendered.

Shipping no library at all is what makes `headers` safe -- there is nothing
to copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: reject silently-widening ranges; install cpp-semver header via FetchContent

Addresses Copilot review on #30.

valid_range accepted '1.x.3', '1..2' and 'x.1', silently widening them to
'1.x' / '1' / '*' — a range claiming more than it means, which breaks the
syntax-validation contract. parse_partial now requires every component after
the first wildcard/empty one to also be a wildcard/empty; trailing wildcards
('1.2.x', '1.x') stay valid. Tests added.

The FetchContent path forced SEMVER_INSTALL off, so a downstream
'cmake --install' would ship logos/semver.hpp without the <semver/semver.hpp>
it includes. Install the fetched header alongside ours. (The find_package /
Nix path already vendors it.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: reject a non-SemVer package version at validate/verify time

Manifest::validate() only checked that 'version' was non-empty, never that it
was a valid version. So a package versioned '0.1.2.3' (four sections), 'v1.0.0'
or '1.0' passed 'lgx verify', got published, and only misbehaved later: it's
unparseable to the comparators, so it sorts BELOW every valid version (can
never be 'latest') and orders against other junk by raw byte comparison
(0.1.2.10 < 0.1.2.9).

validate() now requires a full SemVer 2.0.0 version via the shared
logos::semver::valid(), so 'lgx verify' fails loudly at build/publish time
instead. Confirmed end-to-end: a package with version 0.1.2.3 now fails with
"'version' is not a valid SemVer 2.0.0 version".

Every real module in the workspace already uses X.Y.Z, so nothing is broken.
This closes the gap the comparison layer only worked around: comparison stays
lenient (it must tolerate whatever is already in old catalogs), but validation
gates what new packages may ship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:26:46 -03:00

276 lines
11 KiB
C++

// Conformance tests for the shared semver implementation.
//
// The precedence cases are taken from the SemVer 2.0.0 spec itself
// (https://semver.org/spec/v2.0.0.html, §10-§11). The range cases pin the npm
// dialect the manifests use — ranges are not part of the spec, so they need
// their own contract.
//
// Several cases below are regressions for bugs the five previous hand-rolled
// copies shipped; they are called out inline.
#include <gtest/gtest.h>
#include "logos/semver.hpp"
#include <string>
#include <vector>
using namespace logos::semver;
namespace {
// Assert a strictly ascending precedence chain, and that it holds in both
// directions (so an asymmetric comparator can't sneak through).
void ExpectAscending(const std::vector<std::string>& chain) {
for (size_t i = 0; i + 1 < chain.size(); ++i) {
const std::string& lo = chain[i];
const std::string& hi = chain[i + 1];
EXPECT_EQ(compare(lo, hi), -1) << lo << " should precede " << hi;
EXPECT_EQ(compare(hi, lo), 1) << hi << " should follow " << lo;
EXPECT_EQ(compare(lo, lo), 0) << lo << " should equal itself";
}
}
} // namespace
// ─────────────────────────────── precedence ───────────────────────────────
// Spec §11: "1.0.0 < 2.0.0 < 2.1.0 < 2.1.1".
TEST(Semver, SpecMainPrecedenceChain) {
ExpectAscending({"1.0.0", "2.0.0", "2.1.0", "2.1.1"});
}
// Spec §11: "1.0.0-alpha < 1.0.0".
TEST(Semver, PreReleasePrecedesItsRelease) {
ExpectAscending({"1.0.0-alpha", "1.0.0"});
}
// Spec §11, verbatim: the full pre-release ordering example.
TEST(Semver, SpecPreReleasePrecedenceChain) {
ExpectAscending({
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-alpha.beta",
"1.0.0-beta",
"1.0.0-beta.2",
"1.0.0-beta.11",
"1.0.0-rc.1",
"1.0.0",
});
}
// REGRESSION. Every previous implementation got this wrong: the downloader
// compared the whole pre-release tag as one ASCII string (so "rc.2" > "rc.11"),
// while lgpm and the UI dropped the tag entirely (so "1.0.0-rc.1" == "1.0.0").
// Spec §11: numeric identifiers are compared numerically.
TEST(Semver, NumericPreReleaseIdentifiersCompareNumerically) {
EXPECT_EQ(compare("1.0.0-rc.2", "1.0.0-rc.11"), -1);
EXPECT_EQ(compare("1.0.0-beta.2", "1.0.0-beta.11"), -1);
EXPECT_EQ(compare("1.0.0-alpha.9", "1.0.0-alpha.10"), -1);
}
// Spec §11: "Numeric identifiers always have lower precedence than
// alphanumeric identifiers."
TEST(Semver, NumericSortsBelowAlphanumeric) {
EXPECT_EQ(compare("1.0.0-1", "1.0.0-alpha"), -1);
EXPECT_EQ(compare("1.0.0-alpha.1", "1.0.0-alpha.beta"), -1);
}
// Spec §11: "A larger set of pre-release fields has a higher precedence than a
// smaller set, if all of the preceding identifiers are equal."
TEST(Semver, LargerPreReleaseFieldSetWins) {
EXPECT_EQ(compare("1.0.0-alpha", "1.0.0-alpha.1"), -1);
EXPECT_EQ(compare("1.0.0-beta", "1.0.0-beta.0"), -1);
}
// Spec §10: "Build metadata MUST be ignored when determining version
// precedence."
TEST(Semver, BuildMetadataIgnoredForPrecedence) {
EXPECT_EQ(compare("1.0.0+build.1", "1.0.0+build.2"), 0);
EXPECT_EQ(compare("1.0.0+anything", "1.0.0"), 0);
EXPECT_EQ(compare("1.0.0-rc.1+a", "1.0.0-rc.1+b"), 0);
}
// ─────────────────────────────── validation ───────────────────────────────
TEST(Semver, AcceptsValidVersions) {
EXPECT_TRUE(valid("0.0.0"));
EXPECT_TRUE(valid("1.2.3"));
EXPECT_TRUE(valid("1.0.0-alpha.beta.1"));
EXPECT_TRUE(valid("1.0.0-rc.1+build.99"));
EXPECT_TRUE(valid("10.20.30"));
}
TEST(Semver, RejectsInvalidVersions) {
EXPECT_FALSE(valid(""));
EXPECT_FALSE(valid("1"));
EXPECT_FALSE(valid("1.0")); // partial is not a version
EXPECT_FALSE(valid("01.0.0")); // §2: no leading zeroes
EXPECT_FALSE(valid("1.0.0-")); // empty pre-release
EXPECT_FALSE(valid("1.0.0-01")); // §9: no leading zeroes in numeric ids
EXPECT_FALSE(valid("v1.0.0")); // the `v` prefix is not part of a version
EXPECT_FALSE(valid("banana"));
}
// An unparseable version must never win "latest" — a catalog carrying a junk
// version string should sort it to the bottom, not the top.
TEST(Semver, InvalidVersionsSortBelowValidOnes) {
EXPECT_EQ(compare("banana", "0.0.1"), -1);
EXPECT_EQ(compare("0.0.1", "banana"), 1);
EXPECT_EQ(compare("banana", "banana"), 0);
EXPECT_EQ(compare("", "0.0.1"), -1);
}
// Validation is strict, comparison is lenient — deliberately.
//
// `valid()` holds a manifest to the spec. But comparison also has to cope with
// the partial versions that real manifests and catalogs already carry (every
// comparator this header replaced accepted them). If `1.0` were "invalid" at
// compare time it would sort below every real version and silently reorder
// existing catalogs, which is a much worse outcome than accepting a
// sloppy-but-unambiguous string.
TEST(Semver, ComparisonIsLenientAboutPartialVersions) {
EXPECT_FALSE(valid("1.0")); // ...but not a *valid* version
EXPECT_EQ(compare("1.0", "1.0.0"), 0); // ...yet it still compares as 1.0.0
EXPECT_EQ(compare("2.0", "1.0.0"), 1);
EXPECT_EQ(compare("v1.2.3", "1.2.3"), 0); // a `v` prefix is tolerated too
EXPECT_EQ(compare("1", "1.0.0"), 0);
}
// ──────────────────────────────── sorting ─────────────────────────────────
// Descending is the order the catalog stores `versions[]` in, and `versions[0]`
// is what every client shows as "latest".
TEST(Semver, SortDescendingPutsHighestFirst) {
std::vector<std::string> v = {"1.0.0", "1.0.0-rc.11", "1.0.0-rc.2", "2.0.0-alpha", "1.9.0"};
sort(v, /*descending=*/true);
EXPECT_EQ(v, (std::vector<std::string>{"2.0.0-alpha", "1.9.0", "1.0.0", "1.0.0-rc.11", "1.0.0-rc.2"}));
}
TEST(Semver, SortAscending) {
std::vector<std::string> v = {"1.0.10", "1.0.9", "1.0.0-rc.1"};
sort(v);
EXPECT_EQ(v, (std::vector<std::string>{"1.0.0-rc.1", "1.0.9", "1.0.10"}));
}
// ───────────────────────────────── ranges ─────────────────────────────────
TEST(Semver, CaretRanges) {
EXPECT_TRUE(satisfies("1.2.3", "^1.2.3"));
EXPECT_TRUE(satisfies("1.9.9", "^1.2.3"));
EXPECT_FALSE(satisfies("2.0.0", "^1.2.3"));
EXPECT_FALSE(satisfies("1.2.2", "^1.2.3"));
// Below 1.0.0 the caret tightens to the leftmost non-zero component.
EXPECT_TRUE(satisfies("0.2.9", "^0.2.3"));
EXPECT_FALSE(satisfies("0.3.0", "^0.2.3"));
EXPECT_TRUE(satisfies("0.0.3", "^0.0.3"));
EXPECT_FALSE(satisfies("0.0.4", "^0.0.3"));
// Partial bodies.
EXPECT_TRUE(satisfies("1.9.9", "^1.2"));
EXPECT_FALSE(satisfies("2.0.0", "^1.2"));
EXPECT_TRUE(satisfies("1.9.9", "^1"));
EXPECT_FALSE(satisfies("2.0.0", "^1"));
}
TEST(Semver, TildeRanges) {
EXPECT_TRUE(satisfies("1.2.9", "~1.2.3"));
EXPECT_FALSE(satisfies("1.3.0", "~1.2.3"));
EXPECT_TRUE(satisfies("1.2.0", "~1.2"));
EXPECT_FALSE(satisfies("1.3.0", "~1.2"));
EXPECT_TRUE(satisfies("1.9.0", "~1"));
EXPECT_FALSE(satisfies("2.0.0", "~1"));
}
TEST(Semver, WildcardAndComparatorRanges) {
EXPECT_TRUE(satisfies("1.2.9", "1.2.x"));
EXPECT_FALSE(satisfies("1.3.0", "1.2.x"));
EXPECT_TRUE(satisfies("9.9.9", "*"));
EXPECT_TRUE(satisfies("9.9.9", "latest"));
EXPECT_TRUE(satisfies("1.2.3", "=1.2.3"));
EXPECT_FALSE(satisfies("1.2.4", "=1.2.3"));
// Partial inequalities round the bound outward, per npm: `>1.2` means
// ">= 1.3.0", not "> 1.2.0".
EXPECT_TRUE(satisfies("1.3.0", ">1.2"));
EXPECT_FALSE(satisfies("1.2.9", ">1.2"));
EXPECT_TRUE(satisfies("1.2.0", ">=1.2"));
}
TEST(Semver, ConjunctionAndDisjunction) {
EXPECT_TRUE(satisfies("1.5.0", ">=1.0.0 <2.0.0"));
EXPECT_FALSE(satisfies("2.0.0", ">=1.0.0 <2.0.0"));
EXPECT_TRUE(satisfies("3.0.0", "^1.0.0 || ^3.0.0"));
EXPECT_TRUE(satisfies("1.2.0", "^1.0.0 || ^3.0.0"));
EXPECT_FALSE(satisfies("2.0.0", "^1.0.0 || ^3.0.0"));
}
// REGRESSION, and the sharpest edge of the whole change. The npm rule: a range
// never matches a pre-release unless the range itself names one at the same
// major.minor.patch.
//
// The old matcher had no such rule, so `^1.0.0` matched `2.0.0-alpha` — an
// unreleased alpha of the *next major* silently satisfying a caret range on 1.x
// and getting installed as a dependency.
TEST(Semver, RangesDoNotMatchUnrequestedPreReleases) {
EXPECT_FALSE(satisfies("2.0.0-alpha", "^1.0.0"));
EXPECT_FALSE(satisfies("1.5.0-beta.1", "^1.0.0"));
EXPECT_FALSE(satisfies("1.0.0-rc.1", ">=1.0.0"));
EXPECT_FALSE(satisfies("1.0.0-alpha", "*"));
EXPECT_FALSE(satisfies("2.0.0-alpha", ">=1.0.0 <3.0.0"));
}
// ...but a range that explicitly opts in still gets them.
TEST(Semver, RangesMatchPreReleasesWhenExplicitlyRequested) {
EXPECT_TRUE(satisfies("1.0.0-rc.2", "^1.0.0-rc.1"));
EXPECT_TRUE(satisfies("1.0.0-rc.1", "=1.0.0-rc.1"));
EXPECT_TRUE(satisfies("1.0.0-rc.1", ">=1.0.0-rc.1 <2.0.0"));
EXPECT_TRUE(satisfies("1.0.0", "^1.0.0-rc.1")); // the real release still matches
// Opt-in is scoped to that exact major.minor.patch: asking for 1.0.0
// pre-releases does not let 1.5.0's pre-releases through.
EXPECT_FALSE(satisfies("1.5.0-beta.1", "^1.0.0-rc.1"));
}
TEST(Semver, RangeSyntaxValidation) {
EXPECT_TRUE(valid_range("^1.2.3"));
EXPECT_TRUE(valid_range("1.2.x"));
EXPECT_TRUE(valid_range(">=1.0.0 <2.0.0"));
EXPECT_TRUE(valid_range("^1.0.0 || ^2.0.0"));
EXPECT_TRUE(valid_range("*"));
EXPECT_FALSE(valid_range(""));
EXPECT_FALSE(valid_range("garbage!!"));
EXPECT_FALSE(valid_range("^1.0.0 ||"));
EXPECT_FALSE(valid_range("1.2.3.4"));
// Hyphen ranges are npm syntax we deliberately do not support. Reject them
// rather than silently misreading the `-` as part of a version.
EXPECT_FALSE(valid_range("1.2.3 - 2.3.4"));
// A wildcard (or empty) component must not be followed by a concrete one.
// These previously slipped through and silently widened: `1.x.3` -> `1.x`,
// `1..2` -> `1`, `x.1` -> `*`. A range must not claim more than it means.
EXPECT_FALSE(valid_range("1.x.3"));
EXPECT_FALSE(valid_range("1..2"));
EXPECT_FALSE(valid_range("x.1"));
EXPECT_FALSE(valid_range(".1.2"));
// ...but a trailing wildcard is the normal, valid form.
EXPECT_TRUE(valid_range("1.2.x"));
EXPECT_TRUE(valid_range("1.x"));
EXPECT_TRUE(valid_range("^1.x"));
}
// An absent `version` field means "no constraint", which is distinct from an
// explicit `*` (a real wildcard, and therefore still subject to the
// pre-release rule above).
TEST(Semver, EmptyRangeIsUnconstrained) {
EXPECT_TRUE(satisfies("1.2.3", ""));
EXPECT_TRUE(satisfies("1.0.0-alpha", ""));
}
TEST(Semver, SatisfiesRejectsInvalidInput) {
EXPECT_FALSE(satisfies("banana", "^1.0.0"));
EXPECT_FALSE(satisfies("1.0.0", "garbage!!"));
}