feat(store): evaluate compatible_versions, not just the floor

Closes the gap CodeRabbit surfaced on #427. `compatible_versions` is the
canonical compatibility contract -- schema/manifest_schema.json marks it
required, all 42 published manifests carry it -- and it is the only field that
can express an *upper* bound. `ledmatrix_min_version` is a floor and cannot
say "not compatible with 4.x".

The gate read only the floor, so a plugin declaring ["2.0.0 - 2.9.9"] would be
installed on 3.2.0 regardless of having said it stops at 2.x.

check() now evaluates both and the more restrictive wins. The array is a set of
alternatives (satisfying any one entry suffices), supporting every form the
schema permits: >=, <=, >, <, ~, ^, a bare exact version, and an inclusive
"A - B" range, with prerelease/build suffixes tolerated.

Refusal still requires evidence. Anything unparseable, absent, or below
TRUSTWORTHY_FLOOR resolves to compatible.

That last point needed a new strict parser. parse_semver is deliberately
lenient -- it strips non-digits and yields (0, 0, 0) for a string with no
numbers at all. Harmless for a floor (0.0.0 never blocks) but wrong for a
range, where the same leniency turned an unreadable spec into a *refusal*: a
manifest whose only entry was garbage got compared against 0.0.0 and refused.
Range specs are now shape-checked first, so garbage reads as "no evidence".
parse_semver itself is unchanged, since the loader depends on its behaviour.

Verified: 815 core unit tests pass, 18 of them new. Swept the real registry --
all 42 published manifests, at cores 1.0.0 / 2.0.0 / 3.1.0 / 3.2.0 / 4.0.0 --
and nothing is refused at any of them. The gate stays inert for shipped
plugins, which is the property that makes it safe to land ahead of B5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
This commit is contained in:
ChuckBuilds
2026-08-03 18:07:59 -04:00
co-authored by Claude Opus 5
parent 183e23edb3
commit f31b458bfd
2 changed files with 202 additions and 13 deletions
+82
View File
@@ -230,3 +230,85 @@ class TestLoaderAndStoreAgree:
)
assert loader_would_warn is (not expected)
assert hasattr(PluginLoader, "_warn_if_incompatible")
# --------------------------------------------------------------------------
# compatible_versions — the schema-required field, and the only one that can
# express an upper bound
# --------------------------------------------------------------------------
class TestCompatibleVersions:
@pytest.mark.parametrize("spec,core,expected", [
(">=2.0.0", "3.2.0", True),
(">=2.0.0", "1.9.9", False),
("<=3.0.0", "3.2.0", False),
("<=3.0.0", "2.9.0", True),
(">3.2.0", "3.2.0", False),
("<4.0.0", "3.2.0", True),
("3.2.0", "3.2.0", True), # bare == exact match
("3.2.0", "3.2.1", False),
("~3.2.0", "3.2.9", True), # patch-level only
("~3.2.0", "3.3.0", False),
("^3.2.0", "3.9.9", True), # minor + patch
("^3.2.0", "4.0.0", False),
("2.0.0 - 3.2.0", "3.2.0", True), # inclusive both ends
("2.0.0 - 3.2.0", "2.0.0", True),
("2.0.0 - 3.2.0", "3.2.1", False),
("v3.2.0", "3.2.0", True), # leading v tolerated
("3.2.0-beta.1", "3.2.0", True), # prerelease suffix ignored
])
def test_range_forms(self, spec, core, expected):
got = compatibility.satisfies_compatible_versions(
{"compatible_versions": [spec]}, compatibility.parse_semver(core))
assert got is expected, f"{spec!r} vs {core}"
def test_array_is_alternatives_not_conjunction(self):
"""Satisfying any one entry is enough — otherwise ['<2.0.0','>=3.0.0']
could never be satisfied by anything."""
m = {"compatible_versions": ["<2.0.0", ">=3.0.0"]}
assert compatibility.satisfies_compatible_versions(
m, compatibility.parse_semver("3.2.0")) is True
def test_absent_or_unparseable_is_no_evidence(self):
core = compatibility.parse_semver("3.2.0")
assert compatibility.satisfies_compatible_versions({}, core) is None
assert compatibility.satisfies_compatible_versions(
{"compatible_versions": []}, core) is None
assert compatibility.satisfies_compatible_versions(
{"compatible_versions": ["not a version"]}, core) is None
# One unparseable entry alongside a good one must not poison the result.
assert compatibility.satisfies_compatible_versions(
{"compatible_versions": ["garbage", ">=2.0.0"]}, core) is True
class TestMoreRestrictiveWins:
def test_upper_bound_blocks_a_core_that_clears_the_floor(self):
"""The gap this closes: the floor says 2.0.0 and the core is 3.2.0, so
the floor alone would allow it — but the plugin said it stops at 2.x."""
m = {"name": "Legacy Plugin",
"compatible_versions": ["2.0.0 - 2.9.9"],
"versions": [{"ledmatrix_min_version": "2.0.0"}]}
ok, reason = compatibility.check(m, "3.2.0")
assert ok is False
assert "2.0.0 - 2.9.9" in reason and "3.2.0" in reason
def test_floor_blocks_when_ranges_would_allow(self):
m = {"name": "Needs Newer",
"compatible_versions": [">=1.0.0"],
"versions": [{"ledmatrix_min_version": "9.9.9"}]}
ok, reason = compatibility.check(m, "3.2.0")
assert ok is False
assert "9.9.9" in reason
def test_both_satisfied_allows(self):
m = {"compatible_versions": [">=2.0.0"],
"versions": [{"ledmatrix_min_version": "2.0.0"}]}
assert compatibility.check(m, "3.2.0") == (True, None)
def test_untrustworthy_core_still_bypasses_both_checks(self):
"""A core reporting 1.0.0 fails `>=2.0.0`, which 41 of 42 published
manifests declare. Blocking there would empty the plugin store for
exactly the users who cannot be helped by it."""
m = {"compatible_versions": [">=2.0.0"],
"versions": [{"ledmatrix_min_version": "2.0.0"}]}
assert compatibility.check(m, "1.0.0") == (True, None)