From 268f22184f7e32b4f876b8ac223736c43b302699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 19:55:58 +0000 Subject: [PATCH] feat: version_skew checks compatibility floor vs newest release tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old version_skew compared a workspace's recorded pin (workspace_version / version.txt) against the library __init__.py __version__ stamp. Both artifacts are now frozen — releases no longer commit either back to main (PyAutoConf#119 / PyAutoBuild#121) — so the check was inert: permanently MATCH on stale values, unfailable by any release. Re-point it at the live invariant (build-chain #155 Phase 4 task 2, fork (b) "mains authoritative"): each workspace's version.minimum_library_version floor vs the newest YYYY.M.D.B git tag on its library. Flag UNSATISFIABLE (RED) when a floor exceeds the newest released version — no installable release can satisfy it (the one invariant nothing guarded before); OK when satisfiable; UNKNOWN (STALE) when the newest release can't be resolved; BAD on unparseable input. Drops the now-obsolete AHEAD/BEHIND/MISMATCH legs. No library import, no network — reads local git tags only, inside the <30s tick budget. Also updates readiness.py (RED/STALE mapping + score key skew_unsatisfiable), dashboard.py rendering, capabilities.yaml gate_role, and the test suites. Yank-awareness (does the floor name a yanked release) is noted as a deeper, non-tick check left for follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Vx4gezFDNdXP39Q8WzqnTA --- health_agent/capabilities.yaml | 4 +- heart/checks/version_skew.py | 214 +++++++++++++++------------------ heart/dashboard.py | 12 +- heart/readiness.py | 37 +++--- tests/test_dashboard.py | 2 +- tests/test_readiness.py | 36 ++---- tests/test_version_skew.py | 168 +++++++++++++------------- 7 files changed, 218 insertions(+), 255 deletions(-) diff --git a/health_agent/capabilities.yaml b/health_agent/capabilities.yaml index 71001a5..ea4a170 100644 --- a/health_agent/capabilities.yaml +++ b/health_agent/capabilities.yaml @@ -75,8 +75,8 @@ continuous_checks: gate_role: "YELLOW if workspace validation not passing / stale / unknown (standing debt, advisory)" - id: version_skew impl: heart/checks/version_skew.py - measures: "each workspace's pinned version vs the installed library" - gate_role: "RED if AHEAD / MISMATCH (general.yaml vs version.txt) / BAD; YELLOW if BEHIND / UNKNOWN" + measures: "each workspace's compatibility floor (minimum_library_version) vs the newest library release tag" + gate_role: "RED if UNSATISFIABLE (floor exceeds newest release) / BAD; STALE if UNKNOWN (newest release unresolvable)" - id: manifest_drift impl: heart/checks/manifest_drift.py measures: "repo identity (name/owner) in Heart/Build/labels lists and checkout origins vs the PyAutoMind/repos.yaml body map" diff --git a/heart/checks/version_skew.py b/heart/checks/version_skew.py index c22e15b..a323be8 100644 --- a/heart/checks/version_skew.py +++ b/heart/checks/version_skew.py @@ -1,29 +1,37 @@ -"""heart/checks/version_skew.py — workspace pin vs installed library version. - -PyAutoHands's ``verify_workspace_versions.sh`` blocks a release if any -workspace's pinned version is AHEAD of its installed library — but that only -runs at ``pre_build`` time, so day-to-day the skew is invisible. This check -makes it continuous. - -For each polled workspace that carries a pin, it resolves: - -- the **pinned** version: ``config/general.yaml`` → ``version.workspace_version``, - falling back to a ``version.txt`` at the workspace root (same precedence as - ``verify_workspace_versions.sh``); -- the **installed** library version: by regex-reading ``__version__`` from the - matching library's ``/__init__.py`` source — never importing the heavy - library (keeps the tick cheap). - -Versions are ``YYYY.M.D.B`` tuples; the comparison yields MATCH / AHEAD / -BEHIND / BAD. Two further statuses mirror the hard-fail conditions of -``verify_workspace_versions.sh`` so Heart is the single authoritative readiness -gate: - -- **MISMATCH** — ``config/general.yaml`` and ``version.txt`` both exist and - disagree (a release-blocking inconsistency in ``verify_workspace_versions.sh``). -- **UNKNOWN** — the library ``__init__.py`` could not be read (the workspace is - pinned but the library isn't checked out); surfaced as caution, never a hard - block, matching the script's ``SKIP (cannot import …)`` behaviour. +"""heart/checks/version_skew.py — workspace compatibility floor vs newest release. + +Under the pre-2026-07 model this check compared a workspace's recorded pin +(``config/general.yaml`` → ``version.workspace_version`` / ``version.txt``) +against the library ``__init__.py`` ``__version__`` stamp. That model is gone: +releases no longer commit the stamp or the pin back to ``main`` (PyAutoConf#119 / +PyAutoBuild#121 — the daily "Update version to X" commits were the CI-storm / +cron-pause engine), so *both* artifacts are frozen and the old check was inert — +permanently MATCH on stale values, unfailable by any release. + +The live invariant now is the **compatibility floor**: each workspace records +``config/general.yaml`` → ``version.minimum_library_version`` — the oldest +library release whose API its scripts require — and users must be able to install +a release that satisfies it. So this check compares: + +- the **floor**: ``version.minimum_library_version`` for the workspace; +- the **newest release**: the highest ``YYYY.M.D.B`` git tag on the mapped + library checkout (read with ``git tag`` — no library import, no network, cheap + enough for the <30s tick). + +Statuses: + +- **UNSATISFIABLE** — floor > newest release: no released version satisfies the + floor, so a fresh ``pip install`` cannot pair with the workspace. Release- + blocking (this is the invariant nothing guarded before: the old leg was + unfailable by releases). +- **OK** — floor <= newest release. +- **BAD** — floor or newest release is unparseable. +- **UNKNOWN** — the library isn't checked out / carries no release tags, so the + newest release can't be resolved; surfaced as caution, never a hard block. + +Not covered here (deeper, non-tick checks own it): whether the floor names a +release that was later *yanked* on PyPI — that needs the PyPI API, not git tags. +An informational "floor lags far behind newest" signal is a possible future add. The result lands at ``$HEART_STATE_DIR/version_skew.json``. """ @@ -33,6 +41,7 @@ import json import os import re +import subprocess import sys from pathlib import Path from typing import Any @@ -48,7 +57,8 @@ or Path.home() / ".pyauto-heart" ) -_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) +_TAG_RE = re.compile(r"^\d{4}\.\d+\.\d+\.\d+$") + def workspace_library(config_path: Path | str = CONFIG_PATH) -> dict[str, tuple[str, str]]: """workspace name -> (library repo dir, package dir), from the policy @@ -59,48 +69,47 @@ def workspace_library(config_path: Path | str = CONFIG_PATH) -> dict[str, tuple[ return {ws: (spec["library"], spec["package"]) for ws, spec in block.items()} -def read_library_version(repo: str, pkg: str, root: Path = PYAUTO_ROOT) -> str | None: - """Regex-read ``__version__`` from ``//__init__.py`` (no import).""" - init = root / repo / pkg / "__init__.py" +def read_workspace_floor(workspace: str, root: Path = PYAUTO_ROOT) -> str | None: + """The compatibility floor: ``config/general.yaml`` → + ``version.minimum_library_version``. Returns None when absent (the + workspace is not a floor candidate).""" + general = root / workspace / "config" / "general.yaml" + if not general.is_file(): + return None try: - m = _VERSION_RE.search(init.read_text()) + data = yaml.safe_load(general.read_text()) or {} + except yaml.YAMLError: + return None + floor = (data.get("version") or {}).get("minimum_library_version") + return str(floor).strip() if floor else None + + +def _newest_version(tags: list[str]) -> str | None: + """Highest ``YYYY.M.D.B`` tag by numeric tuple, or None if none match.""" + versions = [t.strip() for t in tags if _TAG_RE.match(t.strip())] + if not versions: + return None + return max(versions, key=lambda v: tuple(int(p) for p in v.split("."))) + + +def newest_release_tag(repo: str, root: Path = PYAUTO_ROOT) -> str | None: + """Newest ``YYYY.M.D.B`` release tag on the library checkout (``git tag``), + or None when the repo isn't a checkout or carries no release tags. No + network — reads local tags only, so it stays inside the tick budget.""" + repo_dir = root / repo + if not (repo_dir / ".git").exists(): + return None + try: + proc = subprocess.run( + ["git", "-C", str(repo_dir), "tag"], + capture_output=True, + text=True, + ) except OSError: return None - return m.group(1) if m else None - - -def read_workspace_pin_sources( - workspace: str, root: Path = PYAUTO_ROOT -) -> tuple[str | None, str | None]: - """Return ``(general_yaml_version, version_txt)`` — either may be None. - - Kept separate from :func:`read_workspace_pin` so callers that need to detect - a general.yaml ↔ version.txt disagreement (MISMATCH) can see both sources. - """ - ws = root / workspace - yaml_v: str | None = None - general = ws / "config" / "general.yaml" - if general.is_file(): - try: - data = yaml.safe_load(general.read_text()) or {} - pin = (data.get("version") or {}).get("workspace_version") - if pin: - yaml_v = str(pin).strip() - except yaml.YAMLError: - pass - txt_v: str | None = None - vtxt = ws / "version.txt" - if vtxt.is_file(): - t = vtxt.read_text().strip() - if t: - txt_v = t - return yaml_v, txt_v - - -def read_workspace_pin(workspace: str, root: Path = PYAUTO_ROOT) -> str | None: - """Pinned version: config/general.yaml:version.workspace_version, then version.txt.""" - yaml_v, txt_v = read_workspace_pin_sources(workspace, root) - return yaml_v if yaml_v is not None else txt_v + if proc.returncode != 0: + return None + return _newest_version(proc.stdout.splitlines()) def _tuple(v: str) -> tuple[int, ...] | None: @@ -110,54 +119,34 @@ def _tuple(v: str) -> tuple[int, ...] | None: return None -def compare(pinned: str | None, installed: str | None) -> str: - """MATCH / AHEAD (pinned > installed) / BEHIND (pinned < installed) / BAD.""" - pt, it = _tuple(pinned or ""), _tuple(installed or "") - if pt is None or it is None: +def compare_floor(floor: str | None, newest: str | None) -> str: + """UNSATISFIABLE (floor > newest release) / OK (floor <= newest) / BAD.""" + ft, nt = _tuple(floor or ""), _tuple(newest or "") + if ft is None or nt is None: return "BAD" - if pt == it: - return "MATCH" - return "AHEAD" if pt > it else "BEHIND" + return "UNSATISFIABLE" if ft > nt else "OK" def run(root: Path = PYAUTO_ROOT) -> dict[str, Any]: workspaces = [] - for workspace, (repo, pkg) in workspace_library().items(): - yaml_v, txt_v = read_workspace_pin_sources(workspace, root) - if yaml_v is None and txt_v is None: - continue # no pin (e.g. *_test workspaces) → not a skew candidate - installed = read_library_version(repo, pkg, root) - - # general.yaml ↔ version.txt disagreement is the same release-blocking - # condition verify_workspace_versions.sh fails on. - if yaml_v is not None and txt_v is not None and yaml_v != txt_v: - workspaces.append( - { - "workspace": workspace, - "library": repo, - "pinned": yaml_v, - "version_txt": txt_v, - "installed": installed, - "status": "MISMATCH", - } - ) - continue - - pinned = yaml_v if yaml_v is not None else txt_v - # Library not checked out → cannot compare. Caution, not a hard block - # (mirrors the script's "SKIP (cannot import )"). - status = "UNKNOWN" if installed is None else compare(pinned, installed) + for workspace, (repo, _pkg) in workspace_library().items(): + floor = read_workspace_floor(workspace, root) + if floor is None: + continue # no floor recorded → not a candidate + newest = newest_release_tag(repo, root) + # Library not checked out / no tags → cannot resolve the newest release. + # Caution, not a hard block. + status = "UNKNOWN" if newest is None else compare_floor(floor, newest) workspaces.append( { "workspace": workspace, "library": repo, - "pinned": pinned, - "installed": installed, + "floor": floor, + "newest_release": newest, "status": status, } ) - result = {"workspaces": workspaces} - return result + return {"workspaces": workspaces} def main(argv: list[str]) -> int: @@ -172,28 +161,25 @@ def main(argv: list[str]) -> int: from heart.heart_color import c_ok, c_warn, c_fail, c_info, c_meta, glyph_ok, glyph_warn, glyph_fail workspaces = result["workspaces"] - ahead = [w for w in workspaces if w["status"] == "AHEAD"] - behind = [w for w in workspaces if w["status"] == "BEHIND"] - mismatch = [w for w in workspaces if w["status"] == "MISMATCH"] + unsatisfiable = [w for w in workspaces if w["status"] == "UNSATISFIABLE"] bad = [w for w in workspaces if w["status"] == "BAD"] - blocking = ahead + mismatch + bad # release-blocking statuses + unknown = [w for w in workspaces if w["status"] == "UNKNOWN"] + blocking = unsatisfiable + bad # release-blocking statuses if blocking: glyph = glyph_fail() parts = [] - if ahead: - parts.append(c_fail(f"{len(ahead)} ahead")) - if mismatch: - parts.append(c_fail(f"{len(mismatch)} mismatch")) + if unsatisfiable: + parts.append(c_fail(f"{len(unsatisfiable)} unsatisfiable")) if bad: parts.append(c_warn(f"{len(bad)} bad")) label = " ".join(parts) - elif behind: + elif unknown: glyph = glyph_warn() - label = c_warn(f"{len(behind)} behind") + label = c_warn(f"{len(unknown)} unknown") else: glyph = glyph_ok() - label = c_ok(f"{len(workspaces)} in sync") - print(f"{glyph} {c_info('version_skew')} {label} {c_meta(f'({len(workspaces)} pinned)')}") + label = c_ok(f"{len(workspaces)} floors satisfiable") + print(f"{glyph} {c_info('version_skew')} {label} {c_meta(f'({len(workspaces)} floors)')}") return 0 diff --git a/heart/dashboard.py b/heart/dashboard.py index 7969583..546ded6 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -501,19 +501,19 @@ def build_board( sections.append(_unobs_section("version_skew", "Version skew")) else: skew = (snapshot.get("version_skew") or {}).get("workspaces") or [] - off = [w for w in skew if isinstance(w, dict) and w.get("status") not in ("MATCH", None)] - blocking = [w for w in off if str(w.get("status")).upper() in ("AHEAD", "MISMATCH", "BAD")] + off = [w for w in skew if isinstance(w, dict) and w.get("status") not in ("OK", None)] + blocking = [w for w in off if str(w.get("status")).upper() in ("UNSATISFIABLE", "BAD")] if off: st = FAIL if blocking else WARN - summary = f"{len(blocking)} blocking" if blocking else f"{len(off)} skewed" + summary = f"{len(blocking)} blocking" if blocking else f"{len(off)} unresolved" details = [ - f"{w.get('status')}: {w.get('workspace')} pinned {w.get('pinned')} " - f"vs installed {w.get('installed')}" + f"{w.get('status')}: {w.get('workspace')} floor {w.get('floor')} " + f"vs newest {w.get('library')} release {w.get('newest_release')}" for w in off[:8] ] sections.append(Section("version_skew", "Version skew", st, summary, details)) elif skew: - sections.append(Section("version_skew", "Version skew", OK, "all workspaces in sync", [])) + sections.append(Section("version_skew", "Version skew", OK, "all floors satisfiable", [])) # Install verification --------------------------------------------------- vi = snapshot.get("verify_install") or {} diff --git a/heart/readiness.py b/heart/readiness.py index cabe105..6fff40b 100644 --- a/heart/readiness.py +++ b/heart/readiness.py @@ -12,14 +12,16 @@ - **RED** (a real blocker) if any of the 5 libraries has failing CI, is off ``main``, has uncommitted source changes, or is behind origin; or any workspace - is pinned AHEAD of its installed library, has a ``general.yaml`` ↔ - ``version.txt`` MISMATCH, or an unparseable (BAD) version; or the deep install - verification last reported ``ready == false``; or the release-validation - report last ingested reports ``release_ready == false`` (a stage failed). + records a compatibility floor (``version.minimum_library_version``) that + exceeds the newest released version of its library (UNSATISFIABLE — no + installable release can satisfy it), or an unparseable (BAD) floor/tag; or the + deep install verification last reported ``ready == false``; or the + release-validation report last ingested reports ``release_ready == false`` (a + stage failed). - **YELLOW** (caution) for soft signals: workspace-validation not passing (the workspace scripts/notebooks carry standing debt, so this is advisory — never a hard block), script-timing regressions, stale open PRs, stale parked scripts, a - workspace pinned BEHIND, a stale or never-run install verification, **no fresh + stale or never-run install verification, **no fresh release-validation report for the current source** (absent, stale by age, or whose ``commit_shas`` no longer match the current ``main`` HEADs, or which ran under a profile other than ``release``), and — crucially — any *unknown* @@ -109,7 +111,7 @@ "lib_dirty": (15, 30), "lib_behind": (20, 40), "test_failing": (15, 15), - "skew_ahead": (25, 50), + "skew_unsatisfiable": (25, 50), "lib_unknown": (10, 30), "test_unknown": (10, 10), "timing_red": (15, 15), @@ -117,8 +119,6 @@ "profiling_drift": (10, 30), "open_pr": (5, 15), "parked": (5, 15), - "skew_behind": (8, 24), - "skew_mismatch": (25, 50), "skew_bad": (25, 50), "skew_unknown": (10, 30), "install_not_ready": (40, 40), @@ -344,33 +344,28 @@ def scope_local(msg: str, key: str) -> None: else: scope_local("test run status unknown (no report.json)", "test_unknown") - # --- version skew (RED ahead / YELLOW behind) --- + # --- version skew (RED floor unsatisfiable / STALE unknown) --- skew = snapshot.get("version_skew") if isinstance(skew, dict): for w in skew.get("workspaces") or []: if not isinstance(w, dict): continue status = str(w.get("status", "")).upper() - if status == "AHEAD": - red.append(f"{w.get('workspace')}: pinned {w.get('pinned')} AHEAD of installed {w.get('installed')}") - hit("skew_ahead") - elif status == "MISMATCH": + if status == "UNSATISFIABLE": red.append( - f"{w.get('workspace')}: general.yaml {w.get('pinned')} " - f"≠ version.txt {w.get('version_txt')}" + f"{w.get('workspace')}: floor {w.get('floor')} exceeds newest " + f"{w.get('library')} release {w.get('newest_release')} " + f"(no installable version satisfies it)" ) - hit("skew_mismatch") + hit("skew_unsatisfiable") elif status == "BAD": red.append( f"{w.get('workspace')}: unparseable version " - f"(pinned {w.get('pinned')} / installed {w.get('installed')})" + f"(floor {w.get('floor')} / newest {w.get('newest_release')})" ) hit("skew_bad") - elif status == "BEHIND": - yellow.append(f"{w.get('workspace')}: pinned BEHIND installed {w.get('installed')}") - hit("skew_behind") elif status == "UNKNOWN": - stale.append(f"{w.get('workspace')}: installed {w.get('library')} version unknown") + stale.append(f"{w.get('workspace')}: newest {w.get('library')} release unknown") hit("skew_unknown") # --- manifest drift (YELLOW — identity hygiene vs PyAutoMind/repos.yaml) --- diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2966577..98186fd 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -41,7 +41,7 @@ def make_snapshot(**overrides) -> dict: "script_timing": {"red_count": 0, "yellow_count": 0, "green_count": 10}, "test_run": {"ready": True, "passed": 100, "failed": 0, "parked_stale_count": 0, "run_label": "2026.6.1"}, - "version_skew": {"workspaces": [{"workspace": "autolens_workspace", "status": "MATCH"}]}, + "version_skew": {"workspaces": [{"workspace": "autolens_workspace", "status": "OK"}]}, "validation_report": { "release_ready": True, "testpypi_version": "2026.6.1.1.dev100", "profile": "release", "stages": {"rehearse": {"status": "pass"}, diff --git a/tests/test_readiness.py b/tests/test_readiness.py index c62297b..e1df623 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -50,7 +50,7 @@ def make_snapshot(**overrides) -> dict: "repos": {lib: _green_lib(SHAS[lib]) for lib in LIBS}, "script_timing": {"red_count": 0, "yellow_count": 0, "green_count": 10}, "test_run": {"ready": True, "passed": 100, "failed": 0, "parked_stale_count": 0}, - "version_skew": {"workspaces": [{"workspace": "autolens_workspace", "status": "MATCH"}]}, + "version_skew": {"workspaces": [{"workspace": "autolens_workspace", "status": "OK"}]}, # fresh passing install verification (ts == snapshot ts → age 0, not stale) "verify_install": {"ready": True, "ts": "2026-06-01T00:00:00+00:00", "version": "2026.6.1.1", "checks": []}, @@ -109,31 +109,21 @@ def test_test_run_failing_is_yellow_not_red(): assert v["score"] == 85 -def test_version_skew_ahead_is_red(): +def test_version_skew_unsatisfiable_floor_is_red(): snap = make_snapshot(version_skew={"workspaces": [ - {"workspace": "autolens_workspace", "pinned": "2026.6.1.1", "installed": "2026.5.1.1", "status": "AHEAD"} - ]}) - v = compute(snap) - assert v["verdict"] == "red" - assert any("AHEAD" in r for r in v["red_reasons"]) - assert v["score"] == 75 - - -def test_version_skew_mismatch_is_red(): - snap = make_snapshot(version_skew={"workspaces": [ - {"workspace": "autolens_workspace", "pinned": "2026.6.1.2", - "version_txt": "2026.1.1.1", "installed": "2026.6.1.2", "status": "MISMATCH"} + {"workspace": "autolens_workspace", "library": "PyAutoLens", + "floor": "2026.8.1.1", "newest_release": "2026.7.15.1", "status": "UNSATISFIABLE"} ]}) v = compute(snap) assert v["verdict"] == "red" - assert any("general.yaml" in r and "version.txt" in r for r in v["red_reasons"]) + assert any("floor" in r and "exceeds" in r for r in v["red_reasons"]) assert v["score"] == 75 def test_version_skew_bad_is_red(): snap = make_snapshot(version_skew={"workspaces": [ - {"workspace": "autolens_workspace", "pinned": "not.a.version", - "installed": "2026.6.1.2", "status": "BAD"} + {"workspace": "autolens_workspace", "floor": "not.a.version", + "newest_release": "2026.7.15.1", "status": "BAD"} ]}) v = compute(snap) assert v["verdict"] == "red" @@ -143,11 +133,11 @@ def test_version_skew_bad_is_red(): def test_version_skew_unknown_is_stale_tier(): snap = make_snapshot(version_skew={"workspaces": [ {"workspace": "autolens_workspace", "library": "PyAutoLens", - "pinned": "2026.6.1.1", "installed": None, "status": "UNKNOWN"} + "floor": "2026.7.9.1", "newest_release": None, "status": "UNKNOWN"} ]}) v = compute(snap) assert v["verdict"] == "stale" - assert any("version unknown" in r for r in v["stale_reasons"]) + assert any("release unknown" in r for r in v["stale_reasons"]) def test_install_verification_failed_is_red(): @@ -423,14 +413,6 @@ def test_old_open_pr_is_yellow(): assert any("open PR" in r for r in v["yellow_reasons"]) -def test_version_skew_behind_is_yellow(): - snap = make_snapshot(version_skew={"workspaces": [ - {"workspace": "autolens_workspace", "installed": "2026.6.1.1", "status": "BEHIND"} - ]}) - v = compute(snap) - assert v["verdict"] == "yellow" - - def test_parked_stale_is_yellow(): v = compute(make_snapshot(test_run={"ready": True, "parked_stale_count": 3})) assert v["verdict"] == "yellow" diff --git a/tests/test_version_skew.py b/tests/test_version_skew.py index 0fc255e..a122750 100644 --- a/tests/test_version_skew.py +++ b/tests/test_version_skew.py @@ -1,4 +1,4 @@ -"""tests/test_version_skew.py — workspace pin vs installed library compare.""" +"""tests/test_version_skew.py — workspace compatibility floor vs newest release.""" from __future__ import annotations @@ -7,115 +7,115 @@ from heart.checks import version_skew as vs -@pytest.mark.parametrize("pinned,installed,expected", [ - ("2026.5.29.4", "2026.5.29.4", "MATCH"), - ("2026.6.1.1", "2026.5.29.4", "AHEAD"), - ("2026.5.1.1", "2026.5.29.4", "BEHIND"), - ("2026.5.29.4", None, "BAD"), - (None, "2026.5.29.4", "BAD"), - ("not.a.version", "2026.5.29.4", "BAD"), - ("2026.5.29", "2026.5.29.4", "BEHIND"), # shorter tuple compares as less +@pytest.mark.parametrize("floor,newest,expected", [ + ("2026.7.9.1", "2026.7.9.1", "OK"), # floor == newest release + ("2026.5.1.1", "2026.7.9.1", "OK"), # floor older than newest + ("2026.7.15.1", "2026.7.9.1", "UNSATISFIABLE"), # floor ahead of newest release + ("2026.7.9", "2026.7.9.1", "OK"), # shorter tuple compares as less + ("2026.7.9.2", "2026.7.9.1", "UNSATISFIABLE"), + ("not.a.version", "2026.7.9.1", "BAD"), + ("2026.7.9.1", None, "BAD"), + (None, "2026.7.9.1", "BAD"), ]) -def test_compare(pinned, installed, expected): - assert vs.compare(pinned, installed) == expected +def test_compare_floor(floor, newest, expected): + assert vs.compare_floor(floor, newest) == expected -def test_read_library_version_regex(tmp_path): - repo = tmp_path / "PyAutoFit" / "autofit" - repo.mkdir(parents=True) - (repo / "__init__.py").write_text( - 'from x import y\n__version__ = "2026.5.29.4"\nfoo = 1\n' +@pytest.mark.parametrize("tags,expected", [ + (["2026.5.29.4", "2026.7.9.1", "2026.7.15.1"], "2026.7.15.1"), + (["2026.7.9.1", "v-not-a-version", "latest"], "2026.7.9.1"), + (["2026.10.1.1", "2026.7.15.1"], "2026.10.1.1"), # numeric, not lexical + ([], None), + (["nightly", "dev"], None), +]) +def test_newest_version(tags, expected): + assert vs._newest_version(tags) == expected + + +def test_read_workspace_floor(tmp_path): + ws = tmp_path / "autolens_workspace" / "config" + ws.mkdir(parents=True) + (ws / "general.yaml").write_text( + "version:\n" + " minimum_library_version: 2026.7.9.1\n" + " workspace_version: 2026.7.9.1\n" ) - assert vs.read_library_version("PyAutoFit", "autofit", root=tmp_path) == "2026.5.29.4" + assert vs.read_workspace_floor("autolens_workspace", root=tmp_path) == "2026.7.9.1" + + +def test_read_workspace_floor_absent(tmp_path): + # A general.yaml with no floor key → None (not a candidate). + ws = tmp_path / "autolens_workspace" / "config" + ws.mkdir(parents=True) + (ws / "general.yaml").write_text("version:\n python_version_check: true\n") + assert vs.read_workspace_floor("autolens_workspace", root=tmp_path) is None + # No config dir at all → None. + assert vs.read_workspace_floor("autofit_workspace", root=tmp_path) is None + +def test_newest_release_tag_reads_git_tags(tmp_path): + import subprocess -def test_read_library_version_missing(tmp_path): - assert vs.read_library_version("Nope", "nope", root=tmp_path) is None + repo = tmp_path / "PyAutoLens" + repo.mkdir() + env = { + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + } + subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True) + (repo / "f").write_text("x") + subprocess.run(["git", "-C", str(repo), "add", "."], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "c"], check=True, env={**env}) + for t in ("2026.5.29.4", "2026.7.9.1", "2026.7.15.1"): + subprocess.run(["git", "-C", str(repo), "tag", t], check=True) + assert vs.newest_release_tag("PyAutoLens", root=tmp_path) == "2026.7.15.1" -def test_read_workspace_pin_general_yaml_precedence(tmp_path): +def test_newest_release_tag_none_when_not_a_checkout(tmp_path): + (tmp_path / "PyAutoLens").mkdir() # no .git + assert vs.newest_release_tag("PyAutoLens", root=tmp_path) is None + + +def test_run_skips_workspaces_without_a_floor(tmp_path, monkeypatch): + # Only autolens_workspace has a floor; others are skipped silently. ws = tmp_path / "autolens_workspace" / "config" ws.mkdir(parents=True) - (ws / "general.yaml").write_text("version:\n workspace_version: 2026.6.1.2\n") - # version.txt disagrees; general.yaml must win. - (tmp_path / "autolens_workspace" / "version.txt").write_text("2026.1.1.1\n") - assert vs.read_workspace_pin("autolens_workspace", root=tmp_path) == "2026.6.1.2" - - -def test_read_workspace_pin_version_txt_fallback(tmp_path): - ws = tmp_path / "autolens_workspace" - (ws / "config").mkdir(parents=True) - (ws / "config" / "general.yaml").write_text("version:\n python_version_check: true\n") - (ws / "version.txt").write_text("2026.5.29.4\n") - assert vs.read_workspace_pin("autolens_workspace", root=tmp_path) == "2026.5.29.4" - - -def test_read_workspace_pin_none_when_unpinned(tmp_path): - (tmp_path / "autolens_workspace_test").mkdir(parents=True) - assert vs.read_workspace_pin("autolens_workspace_test", root=tmp_path) is None - - -def test_run_skips_unpinned_and_classifies(tmp_path): - # autolens_workspace pinned ahead of installed; HowToFit in sync. - al = tmp_path / "autolens_workspace" / "config" - al.mkdir(parents=True) - (al / "general.yaml").write_text("version:\n workspace_version: 2026.6.1.1\n") - lens = tmp_path / "PyAutoLens" / "autolens" - lens.mkdir(parents=True) - (lens / "__init__.py").write_text('__version__ = "2026.5.29.4"\n') - # no autofit_workspace dir at all → skipped silently + (ws / "general.yaml").write_text("version:\n minimum_library_version: 2026.7.9.1\n") + monkeypatch.setattr(vs, "newest_release_tag", lambda repo, root=tmp_path: "2026.7.15.1") result = vs.run(root=tmp_path) by_ws = {w["workspace"]: w for w in result["workspaces"]} - assert by_ws["autolens_workspace"]["status"] == "AHEAD" - assert "autofit_workspace" not in by_ws # unpinned/missing → skipped - + assert by_ws["autolens_workspace"]["status"] == "OK" + assert "autofit_workspace" not in by_ws # no floor → skipped -def test_read_workspace_pin_sources_returns_both(tmp_path): - ws = tmp_path / "autolens_workspace" - (ws / "config").mkdir(parents=True) - (ws / "config" / "general.yaml").write_text( - "version:\n workspace_version: 2026.6.1.2\n" - ) - (ws / "version.txt").write_text("2026.1.1.1\n") - assert vs.read_workspace_pin_sources("autolens_workspace", root=tmp_path) == ( - "2026.6.1.2", - "2026.1.1.1", - ) - -def test_run_flags_general_yaml_version_txt_mismatch(tmp_path): - # general.yaml and version.txt both present and disagree → MISMATCH, - # the same release-blocking condition verify_workspace_versions.sh fails on. - ws = tmp_path / "autolens_workspace" - (ws / "config").mkdir(parents=True) - (ws / "config" / "general.yaml").write_text( - "version:\n workspace_version: 2026.6.1.2\n" - ) - (ws / "version.txt").write_text("2026.1.1.1\n") - lens = tmp_path / "PyAutoLens" / "autolens" - lens.mkdir(parents=True) - (lens / "__init__.py").write_text('__version__ = "2026.6.1.2"\n') +def test_run_flags_unsatisfiable_floor(tmp_path, monkeypatch): + # Floor ahead of the newest released version → UNSATISFIABLE (release-blocking). + ws = tmp_path / "autolens_workspace" / "config" + ws.mkdir(parents=True) + (ws / "general.yaml").write_text("version:\n minimum_library_version: 2026.8.1.1\n") + monkeypatch.setattr(vs, "newest_release_tag", lambda repo, root=tmp_path: "2026.7.15.1") result = vs.run(root=tmp_path) w = {x["workspace"]: x for x in result["workspaces"]}["autolens_workspace"] - assert w["status"] == "MISMATCH" - assert w["pinned"] == "2026.6.1.2" and w["version_txt"] == "2026.1.1.1" + assert w["status"] == "UNSATISFIABLE" + assert w["floor"] == "2026.8.1.1" and w["newest_release"] == "2026.7.15.1" -def test_run_unknown_when_library_not_checked_out(tmp_path): - # Pinned workspace but no library __init__.py to read → UNKNOWN (caution), - # never a hard block — mirrors the script's "SKIP (cannot import )". +def test_run_unknown_when_newest_release_unresolvable(tmp_path, monkeypatch): + # Floored workspace but the library has no resolvable release tag → UNKNOWN + # (caution), never a hard block. ws = tmp_path / "autolens_workspace" / "config" ws.mkdir(parents=True) - (ws / "general.yaml").write_text("version:\n workspace_version: 2026.6.1.1\n") + (ws / "general.yaml").write_text("version:\n minimum_library_version: 2026.7.9.1\n") + monkeypatch.setattr(vs, "newest_release_tag", lambda repo, root=tmp_path: None) result = vs.run(root=tmp_path) w = {x["workspace"]: x for x in result["workspaces"]}["autolens_workspace"] assert w["status"] == "UNKNOWN" - assert w["installed"] is None + assert w["newest_release"] is None -def test_autolens_assistant_is_a_pinned_workspace(): +def test_autolens_assistant_is_a_polled_workspace(): # Gap closed vs verify_workspace_versions.sh, which covers 8 workspaces. - # The map now lives in config/repos.yaml `version_skew` (the policy file). + # The map lives in config/repos.yaml `version_skew` (the policy file). mapping = vs.workspace_library() assert "autolens_assistant" in mapping assert mapping["autolens_assistant"] == ("PyAutoLens", "autolens")