diff --git a/src/shard_wiki/adapters/__init__.py b/src/shard_wiki/adapters/__init__.py index 9635b25..915678e 100644 --- a/src/shard_wiki/adapters/__init__.py +++ b/src/shard_wiki/adapters/__init__.py @@ -1,6 +1,22 @@ """adapters/ — the shard adapter contract (bottom waist) and concrete adapters.""" +from shard_wiki.adapters.conformance import ( + Check, + ConformanceError, + ConformanceReport, + assert_conformant, + run_conformance, +) from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter from shard_wiki.adapters.folder import FolderAdapter -__all__ = ["ShardAdapter", "FolderAdapter", "CONTRACT_VERSION"] +__all__ = [ + "ShardAdapter", + "FolderAdapter", + "CONTRACT_VERSION", + "Check", + "ConformanceReport", + "ConformanceError", + "run_conformance", + "assert_conformant", +] diff --git a/src/shard_wiki/adapters/conformance.py b/src/shard_wiki/adapters/conformance.py new file mode 100644 index 0000000..5bf9a14 --- /dev/null +++ b/src/shard_wiki/adapters/conformance.py @@ -0,0 +1,126 @@ +"""Adapter conformance — profiles are verified, not self-asserted (TSD §A.2, blueprint §6.6). + +Capability-as-data (I-3) is only sound if a binding's *declared* profile matches its *observed* +behaviour. This battery exercises a binding and reports, check by check, whether claim == reality; +``assert_conformant`` gates registration. A lying/buggy profile fails here instead of silently +poisoning degradation decisions downstream. + +This slice verifies the read path + honest absence of unclaimed verbs. Positive probes for +claimed write/diff/merge are deferred (they mutate) to a later workplan. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from shard_wiki.adapters.contract import ShardAdapter +from shard_wiki.model import NotSupported, Verb + +__all__ = ["Check", "ConformanceReport", "ConformanceError", "run_conformance", "assert_conformant"] + +# Optional verbs whose *absence* must be honest (calling an unclaimed one raises NotSupported). +_HONEST_ABSENCE_VERBS = (Verb.WRITE, Verb.DIFF, Verb.NOTIFY) +_PROBE = { + Verb.WRITE: lambda a: a.write("__probe__", ""), + Verb.DIFF: lambda a: a.diff("__probe__", "__probe2__"), + Verb.NOTIFY: lambda a: a.notify(), +} + + +@dataclass(frozen=True, slots=True) +class Check: + name: str + ok: bool + detail: str = "" + + +@dataclass(frozen=True, slots=True) +class ConformanceReport: + adapter: str + checks: tuple[Check, ...] + + @property + def ok(self) -> bool: + return all(c.ok for c in self.checks) + + @property + def failures(self) -> tuple[Check, ...]: + return tuple(c for c in self.checks if not c.ok) + + def diff(self) -> str: + return "; ".join(f"{c.name}: {c.detail}" for c in self.failures) or "conformant" + + +class ConformanceError(Exception): + def __init__(self, report: ConformanceReport) -> None: + super().__init__(f"{report.adapter} not conformant — {report.diff()}") + self.report = report + + +def _safe(fn, name: str, ok_detail: str = "") -> Check: + try: + return fn() + except Exception as exc: # noqa: BLE001 — a check must never crash the battery + return Check(name, False, f"unexpected error: {exc!r}") + + +def run_conformance(adapter: ShardAdapter) -> ConformanceReport: + checks: list[Check] = [] + profile = None + + def _profile_validates() -> Check: + nonlocal profile + profile = adapter.profile() + profile.validate() + return Check("profile-validates", True) + + checks.append(_safe(_profile_validates, "profile-validates")) + if profile is None: # profile() or validate() failed; can't probe further meaningfully + return ConformanceReport(type(adapter).__name__, tuple(checks)) + + # READ is the capability floor. + checks.append(Check("supports-read", profile.supports(Verb.READ), + "" if profile.supports(Verb.READ) else "READ not declared")) + + # READ round-trips: a declared-readable shard must actually read its own keys. + def _read_round_trips() -> Check: + keys = list(adapter.keys()) + if not keys: + return Check("read-round-trips", True, "empty shard") + page = adapter.read(keys[0]) + if page.identity.shard != adapter.shard_id: + return Check("read-round-trips", False, + f"page shard {page.identity.shard!r} != {adapter.shard_id!r}") + if not isinstance(page.body, str): + return Check("read-round-trips", False, "body is not text") + return Check("read-round-trips", True) + + if profile.supports(Verb.READ): + checks.append(_safe(_read_round_trips, "read-round-trips")) + + # Honest absence: an *unclaimed* optional verb must raise NotSupported when invoked. + for verb in _HONEST_ABSENCE_VERBS: + if profile.supports(verb): + continue # claimed → positive probe deferred (would mutate) + name = f"honest-absence:{verb.value}" + + def _probe(v=verb, n=name) -> Check: + try: + _PROBE[v](adapter) + except NotSupported: + return Check(n, True) + except Exception as exc: # noqa: BLE001 + return Check(n, False, f"raised {type(exc).__name__}, expected NotSupported") + return Check(n, False, "did not raise NotSupported though verb is unclaimed") + + checks.append(_probe()) + + return ConformanceReport(type(adapter).__name__, tuple(checks)) + + +def assert_conformant(adapter: ShardAdapter) -> ConformanceReport: + """Run the battery; raise :class:`ConformanceError` if any check fails. Returns the report.""" + report = run_conformance(adapter) + if not report.ok: + raise ConformanceError(report) + return report diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..1dd5548 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,67 @@ +"""Tests for the adapter conformance suite (SHARD-WP-0007 T4).""" + +from collections.abc import Iterable + +import pytest + +from shard_wiki.adapters import ( + ConformanceError, + FolderAdapter, + ShardAdapter, + assert_conformant, + run_conformance, +) +from shard_wiki.model import CapabilityProfile, Identity, Page +from shard_wiki.provenance import ProvenanceEnvelope + + +def test_folder_adapter_is_conformant(tmp_path): + (tmp_path / "Home.md").write_text("# Home", encoding="utf-8") + report = assert_conformant(FolderAdapter("shardA", tmp_path)) + assert report.ok + assert report.diff() == "conformant" + + +class _LyingReadAdapter(ShardAdapter): + """Claims READ but read() is broken — must fail conformance.""" + + def __init__(self, profile: CapabilityProfile) -> None: + self._profile = profile + + @property + def shard_id(self) -> str: + return "liar" + + def profile(self) -> CapabilityProfile: + return self._profile + + def keys(self) -> Iterable[str]: + return ["Home"] + + def read(self, key: str) -> Page: + # Returns a page attributed to the WRONG shard — a lie about its own content. + return Page(Identity("someone-else", key), "x", ProvenanceEnvelope(source_shard="x")) + + +def test_lying_read_adapter_fails_with_precise_diff(tmp_path): + good = FolderAdapter("shardA", tmp_path).profile() # a valid read-only profile + liar = _LyingReadAdapter(good) + report = run_conformance(liar) + assert not report.ok + assert "read-round-trips" in report.diff() + with pytest.raises(ConformanceError, match="read-round-trips"): + assert_conformant(liar) + + +class _DishonestWriteAdapter(FolderAdapter): + """Declares no WRITE (inherits read-only folder profile) but write() silently succeeds.""" + + def write(self, key: str, body: str) -> Page: # noqa: ARG002 + return self.read(next(iter(self.keys()))) + + +def test_dishonest_absence_fails(tmp_path): + (tmp_path / "Home.md").write_text("x", encoding="utf-8") + report = run_conformance(_DishonestWriteAdapter("shardA", tmp_path)) + assert not report.ok + assert "honest-absence:write" in report.diff() diff --git a/workplans/SHARD-WP-0007-foundation-implementation.md b/workplans/SHARD-WP-0007-foundation-implementation.md index db882f6..bb20b5b 100644 --- a/workplans/SHARD-WP-0007-foundation-implementation.md +++ b/workplans/SHARD-WP-0007-foundation-implementation.md @@ -86,7 +86,7 @@ git-or-none history). Imports `model/`, `provenance/`. Tests: read a temp folder ```task id: SHARD-WP-0007-T4 -status: todo +status: done priority: medium state_hub_task_id: "12d7fb8d-3842-4142-a462-9d1e6efe58bd" ```