"""SDK version guard (C2.5, closes C-N3) — LOAD-BEARING (§11). The seam this file keeps alive: every SDK attribute premise in ``sdk_client.py`` was verified against a CONCRETE version range (0.2.110 read at source level, release notes through 0.2.120; sdk-review 2026-07-16). The pin ``claude-agent-sdk>=0.2.111,<0.3`` freezes that range — this guard makes an upgrade outside it a RED test naming exactly which premises must be re-verified, instead of a silent behaviour drift. Offline-safe: reads installed package metadata only — no key, no network. """ from __future__ import annotations import importlib.metadata from pathlib import Path import pytest _PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml" # The verified range — MUST match the pyproject pin (bound below). _VERIFIED_FLOOR = (0, 2, 111) _VERIFIED_CEILING = (0, 3) def _pin_for(floor: tuple[int, ...], ceiling: tuple[int, ...]) -> str: """The pyproject requirement string the verified range implies. DERIVED, never a third hand-maintained copy: the range this guard enforces and the pin it asserts on cannot drift apart, because moving the range moves the expected pin with it (value-proof below). """ low = ".".join(str(part) for part in floor) high = ".".join(str(part) for part in ceiling) return f"claude-agent-sdk>={low},<{high}" _PIN = _pin_for(_VERIFIED_FLOOR, _VERIFIED_CEILING) # The sdk_client.py attribute premises the verified range vouches for # (sdk-review 2026-07-16, verified against package source through 0.2.120). _SDK_PREMISES = ( "AssistantMessage.error/.model/.content", "ResultMessage.usage/.total_cost_usd/.is_error/.subtype/.errors", "ClaudeAgentOptions.max_budget_usd/.setting_sources/.system_prompt/.max_turns/.model/.tools", "TextBlock.text", "query() yields AssistantMessage then a closing ResultMessage", ) def _parse(raw: str) -> tuple[int, ...]: return tuple(int(part) for part in raw.split(".")[:3]) def _dependencies_block(text: str) -> str: """The ``dependencies = [...]`` array of a pyproject, as text (ANCHOR). Fail-closed: a pyproject that no longer declares the array raises rather than returning "" — a silently empty anchor would make every assertion below vacuous (the degeneration mode measured on the §12 guard). """ start = text.find("dependencies = [") if start == -1: raise ValueError("pyproject declares no 'dependencies = [' array — anchor lost") end = text.find("]", start) if end == -1: raise ValueError("pyproject 'dependencies' array is unterminated — anchor lost") return text[start : end + 1] def _pin_is_declared(text: str, pin: str) -> bool: """True when ``pin`` is a real runtime dependency — not merely present somewhere. Two narrowings, each one a measured degeneration rather than a precaution: ``pin in text`` was GREEN while the real dependency had drifted below the verified floor and the literal survived in a trailing comment (measured 2026-07-31), so the match is scoped to the anchor AND requires the QUOTED form with comments stripped — a comment sits inside the array too, so the slice alone still does not detach. """ body = "\n".join(line.split("#", 1)[0] for line in _dependencies_block(text).splitlines()) return f'"{pin}"' in body def check_sdk_version() -> str: """Fail if the installed SDK is outside the verified range — naming the premises.""" raw = importlib.metadata.version("claude-agent-sdk") if not (_VERIFIED_FLOOR <= _parse(raw) < _VERIFIED_CEILING): raise AssertionError( f"claude-agent-sdk {raw} is OUTSIDE the verified range " f"{_PIN.removeprefix('claude-agent-sdk')}. " "Re-verify the sdk_client.py attribute premises against the new version " "BEFORE widening the pin (pyproject.toml + this guard together): " + "; ".join(_SDK_PREMISES) ) return raw class TestSdkVersionGuard: def test_the_installed_sdk_is_within_the_verified_range(self) -> None: assert check_sdk_version() == importlib.metadata.version("claude-agent-sdk") def test_a_version_above_the_ceiling_trips_the_guard( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.3.0") with pytest.raises(AssertionError) as err: check_sdk_version() message = str(err.value) assert "sdk_client.py" in message for premise in _SDK_PREMISES: assert premise in message def test_a_version_below_the_floor_trips_the_guard( self, monkeypatch: pytest.MonkeyPatch ) -> None: # 0.2.110 works but misses the 0.2.111 read-loop fixes (NDJSON # whitespace on >64 KiB lines, plain-string content TypeError, # zombie subprocess on cancellation) — below the floor is unverified. monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.2.110") with pytest.raises(AssertionError): check_sdk_version() def test_the_pyproject_pin_matches_the_verified_range(self) -> None: # The seam: what uv installs and what this guard vouches for are ONE range. assert _pin_is_declared(_PYPROJECT.read_text(encoding="utf-8"), _PIN) class TestTheSeamGoesRedWhenDetached: """LOAD-BEARING (§11): the assertions above must FAIL on a detached pyproject. Red-proofs run against a mutated COPY of the pyproject text — never against ``pyproject.toml`` itself. Each one is a former manual spot-check made permanent: a detach-proof that dies with the session is not a proof. """ def test_red_when_the_pin_survives_only_outside_the_dependencies_block(self) -> None: # The MEASURED degeneration: the real dependency drops below the verified # floor while the exact pin string lives on in a trailing comment. The # unanchored `_PIN in text` assertion stayed GREEN through exactly this. text = _PYPROJECT.read_text(encoding="utf-8") mutated = text.replace(f'"{_PIN}",', f'"claude-agent-sdk>=0.2.110,<0.3", # was: {_PIN}') assert mutated != text, "mutation did not apply — the red-proof would be vacuous" assert _PIN in mutated, "the literal must survive, or this proves the wrong thing" assert not _pin_is_declared(mutated, _PIN) def test_red_when_the_verified_floor_drifts_from_the_pin(self) -> None: # VALUE-proof, not merely a detach-proof: moving the range this guard # enforces moves the pin it demands, so the two cannot part company. drifted = _pin_for((0, 2, 110), _VERIFIED_CEILING) assert drifted != _PIN assert not _pin_is_declared(_PYPROJECT.read_text(encoding="utf-8"), drifted) def test_red_when_the_verified_ceiling_drifts_from_the_pin(self) -> None: drifted = _pin_for(_VERIFIED_FLOOR, (0, 4)) assert drifted != _PIN assert not _pin_is_declared(_PYPROJECT.read_text(encoding="utf-8"), drifted) def test_the_anchor_is_fail_closed_when_the_array_is_renamed(self) -> None: # An anchor that degenerates to "" would make every assertion above vacuous. text = _PYPROJECT.read_text(encoding="utf-8").replace("dependencies = [", "deps = [") with pytest.raises(ValueError, match="anchor lost"): _dependencies_block(text) def test_the_anchor_does_not_degenerate_to_the_whole_file(self) -> None: # Mutate the ANCHOR itself: a slice that silently widened to the entire # pyproject would re-admit the substring-anywhere defect unnoticed. text = _PYPROJECT.read_text(encoding="utf-8") block = _dependencies_block(text) assert len(block) < len(text) assert "[project]" not in block