From 58d2cb2848b7a1f2fdfe720ef4ddd91ea4502473 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Thu, 4 Jun 2026 12:58:03 -0400 Subject: [PATCH] AFI parity: generate the matrix from conformance probes, not prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-adapter parity table was hand-maintained prose. An adapter that never wired a capability (FastAPI SSR, Axum WebSocket) got its gap relabelled "Django-only" or "out of scope — use native equivalents," and nothing went red. The de-scope was crystallized in five mutually-ratifying sites: the README §Stack-extensions table, the AFI fixture docstring ("channels/forms/shapes aren't AFI-common"), the core registry's extension-hook framing, the mizan-fastapi __init__ docstring, and a "CSRF is Django-only" comment in two adapters' session endpoints. Replace prose-parity with conformance-generated parity: - tests/afi/manifest.py declares the AFI-common surface as data — one list of capabilities, one of adapters. Applicability ("—") is derived from transport, never typed. - tests/afi/probes.py independently inspects each backend's source for the artifact a capability requires (comment-stripped, backend-scoped). Green means wired; a cell can't be set by editing a word. - tests/afi/test_capability_parity.py asserts every (capability × applicable adapter) pair is wired. 35 unwired gaps are now loud red TFDD tests, each naming an owed binding. No xfail/skip. - tests/afi/parity_table.py generates the README table from the probes; `make parity-check` fails CI on any hand-edit, like the codegen byte-parity. Purge the five de-scope sites. The IR byte-parity gate is unchanged and green. `make test-afi` is now intentionally red on the 35 gaps — that board is the owed parity work, itemized; a gap turns green by being wired, never described. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 19 +- README.md | 125 +++--- .../src/mizan_fastapi/__init__.py | 14 +- .../mizan-fastapi/src/mizan_fastapi/router.py | 9 +- backends/mizan-rust-axum/src/handlers.rs | 8 +- cores/mizan-python/src/mizan_core/registry.py | 16 +- tests/afi/fixture.py | 6 +- tests/afi/manifest.py | 167 ++++++++ tests/afi/parity_table.py | 141 +++++++ tests/afi/probes.py | 385 ++++++++++++++++++ tests/afi/test_capability_parity.py | 118 ++++++ 11 files changed, 915 insertions(+), 93 deletions(-) create mode 100644 tests/afi/manifest.py create mode 100644 tests/afi/parity_table.py create mode 100644 tests/afi/probes.py create mode 100644 tests/afi/test_capability_parity.py diff --git a/Makefile b/Makefile index 7531560..7578fe0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean +.PHONY: install test test-core test-django test-fastapi test-react test-afi parity-table parity-check test-integration docker-up docker-down clean CORE = cores/mizan-python DJANGO = backends/mizan-django @@ -30,11 +30,24 @@ test-fastapi: test-react: cd $(REACT) && npm test -# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent -# schemas for the same @client fixture. Substrate-level gate, not e2e. +# AFI conformance — two gates, substrate-level, not e2e: +# test_codegen_parity.py — Django/FastAPI/Rust emit byte-identical KDL IR. +# test_capability_parity.py — every (capability, applicable adapter) pair is +# probed for its wiring. RED on every unwired gap +# by design: that board is the owed work, itemized. test-afi: cd $(AFI) && uv run pytest +# Regenerate the README parity table from the live conformance probes. The table +# is generated output — never hand-edited. +parity-table: + cd $(AFI) && uv run python parity_table.py --write + +# CI gate: the committed README parity table matches what the probes report. +# Fails on any hand-edit, the same forcing function as the codegen byte-parity. +parity-check: + cd $(AFI) && uv run python parity_table.py --check + # ─── Integration Tests ────────────────────────────────────────────────────── test-integration: docker-up diff --git a/README.md b/README.md index a1fa87d..2f855d8 100644 --- a/README.md +++ b/README.md @@ -33,107 +33,86 @@ reference implementation; per-adapter support is inventoried below. ## Backend adapters -Every adapter implements the same AFI wire protocol. The matrix below inventories -support per adapter, grouped to separate protocol guarantees from Django-specific -features (forms, ORM projection, auth providers, SSR). A cell counts as supported only -when that adapter wires the capability into its own dispatch surface, not merely that a -shared core primitive exists. +Every adapter implements the same AFI wire protocol. The matrix below is **generated** +from the conformance probes in [`tests/afi/`](tests/afi/) by `make parity-table` — it is +output, not prose. A cell goes `✅` only when that adapter wires the capability into its +own dispatch surface; it cannot be set to "supported" or "Django-only" by editing this +file (a hand-edit fails `python tests/afi/parity_table.py --check` in CI, the same +forcing function the codegen byte-parity tests use). -Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport +Every capability in the matrix is **AFI-common** — each adapter owes a binding, and a +`❌` is a gap on the owed-work board, never a "this framework doesn't do that." The line +between AFI-common and genuinely backend-bound lives in +[`tests/afi/manifest.py`](tests/afi/manifest.py): what sits *outside* the matrix by +design is the `allauth` integration (a Django-ecosystem package) and the per-stack +*bindings* of common capabilities (`django-readers` is Django's Shapes binding; Django +Forms is Django's Forms binding) — the capability is common; the binding is not. + + +Legend: ✅ wired · ◑ partial (declared/stubbed) · ❌ gap (AFI-common, owed) · — not applicable to this adapter's transport + +Every capability below is **AFI-common**: each adapter owes a binding, and a ❌ is a gap on the owed-work board (`tests/afi/`), never a category. Backend-specific *bindings* of common capabilities (django-readers for Shapes, Django Forms for Forms) and genuinely Django-ecosystem features (allauth) are out of this matrix by design — see `tests/afi/manifest.py` for the line. ### Protocol core -The surface every Mizan adapter implements. - | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ | +| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ | | Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ | +| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ❌ | — | ✅ | | Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ | | Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ | -| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | ❌ ⁸ | -| File uploads (multipart, `Upload` type) | ✅ | ✅ | ❌ ⁹ | ❌ ⁹ | — ¹⁰ | +| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ❌ | +| File uploads (`Upload` type) | ✅ | ✅ | ❌ | ❌ | ❌ | ### Edge, cache & enforcement -Protocol transports and guarantees co-equal with the body channel in the spec. - | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ❌ | — ¹ | ✅ | -| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ✅ ¹¹ | +| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ◑ | ◑ | ✅ | | Origin-side HMAC cache | ✅ | ✅ | ❌ | ❌ | ✅ | | Edge manifest export | ✅ | ❌ | ❌ | — | ✅ | | PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ | -| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ | +| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ❌ | -> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce -> it — do not rely on `auth=` for access control on those adapters. -> -> Django, FastAPI, and TypeScript share one auth/invalidation/cache implementation -> (`mizan_core` for the Python adapters; the same spec, pinned cross-language, for TS). - -### Stack extensions (Django) - -Django ecosystem features Mizan wraps. Other adapters provide these only where the -target stack calls for them. +### Extension points | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ | -| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ | -| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ | -| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — | -| JWT auth (access / refresh) ¹² | ✅ | ✅ | ❌ | ❌ | ◑ ¹³ | -| MWT (edge identity token) | ✅ | ✅ | ❌ | — | ◑ ¹³ | -| SSR bridge | ✅ | ❌ | ❌ | — | ❌ | -| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ | +| WebSocket transport (`websocket=` declared) | ✅ | ❌ | ◑ | ❌ | ❌ | +| SSR bridge (subprocess renderer) | ✅ | ❌ | ❌ | ❌ | ❌ | +| JWT auth (access / refresh) | ✅ | ✅ | ❌ | ❌ | ◑ | +| MWT (edge identity token) | ✅ | ✅ | ❌ | — | ◑ | +| Typed query projection (Shapes) | ✅ | ❌ | ❌ | ❌ | ❌ | +| Forms (schema / validate / submit) | ✅ | ❌ | ◑ | ◑ | ❌ | **Notes** -1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP. - Invalidation rides in the JSON response body; there is no header channel. -2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum - WebSocket handler yet. -3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint. -4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every - adapter carries typed input/output through the KDL IR; the projection primitive - itself is Django-only. -5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not - enforce them. Rust/Axum has no enforcement either. -6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme - registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin) - rather than fetching over HTTP. -7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire - parity; CSRF is Django-only. -8. `mizan-ts` emits the Edge manifest (JSON) but has no KDL IR emitter, so it can't yet - feed the codegen — an unbuilt gap. A TypeScript backend still needs the generated - client (types + `callXxx`/`fetchXxx` + framework hooks); same language doesn't remove - the need for it. -9. The `mizan-codegen` crate parses the `upload` KDL node and emits the field across - targets (the Rust target lowers it to `Vec`). Multipart dispatch binding is wired - for Django and FastAPI only; the Rust/Axum and Tauri *adapters* have no upload concept - at dispatch yet. -10. The TypeScript column is the `mizan-ts` backend adapter, which has no upload - dispatch. The matching client side lives in the kernel (`@mizan/base`): `mizanCall` - auto-switches to `multipart/form-data` when any argument is a `File`. -11. `mizan-ts` dispatch now enforces `auth=` (`true`/`'staff'`/`'superuser'`/predicate) - against a host-supplied `Identity`, byte-matching the Python guard's denial messages. -12. JWT/MWT token logic is single-sourced in `mizan_core.auth`; Django and FastAPI ride - it. Session-validation (immediate-logout revocation) is Django-only — FastAPI mints - from its own credential check. -13. `mizan-ts` ships an optional `decodeMwt`/`decodeJwtBearer`/`identityFromMwt` helper - (HS256 via Node `crypto`, cross-language pin-tested against a Python-minted MWT) so a - TS edge worker can derive `Identity` from a Python-issued token. Identity source stays - host-supplied; `mizan-ts` does not mint from a session. +- **Invalidation — `X-Mizan-Invalidate` header** — The header channel is co-equal with the body channel in the spec. IPC transports carry invalidation in the response envelope instead. +- **Edge manifest export** — The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge. +- **MWT (edge identity token)** — MWT exists to key an edge cache; without an edge there is nothing to key. +- **Typed query projection (Shapes)** — The capability is AFI-common; the binding is per-ORM (django-readers on Django, the project's ORM elsewhere). +- **Forms (schema / validate / submit)** — The capability is AFI-common; the binding is per-framework (Django Forms on Django, Pydantic-or-equivalent elsewhere). + ## Conformance -Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It -currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and -the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability -runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned. +Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/), at +two layers: + +- **IR-shape parity** (`test_codegen_parity.py`) — Django, FastAPI, and the Rust adapter + emit byte-identical KDL for the same registered fixture. The IR is the contract; the + language that wrote the backend is irrelevant to the codegen-facing artifact. +- **Capability parity** (`test_capability_parity.py`) — every `(capability, applicable + adapter)` pair declared in `manifest.py` is probed for its actual wiring (`probes.py`). + A gap is a **red test that names the owed binding**, not a footnote. The suite is + intentionally red wherever a capability is unwired: that redness is the owed-work + board, itemized and loud, and a gap turns green by being *wired*, never by being + *described*. This is the per-capability gate the roadmap previously deferred. + +The generated table above is rendered from the capability layer, and the `--check` +diff keeps the README honest to the probes on every CI run. ## License diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index 9b27ea2..26f6276 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -2,9 +2,17 @@ mizan-fastapi — FastAPI backend adapter for the Mizan protocol. HTTP RPC dispatch and context bundling on top of mizan-core's function -registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI -projects use native equivalents (WebSocket, Pydantic, ORM-of-choice, -SSR frameworks). +registry, sharing the auth / invalidation / cache / upload core with the +Django adapter. + +WebSocket, Forms, Shapes, and the SSR bridge are AFI-common capabilities +this adapter does not wire yet — open gaps on the capability-parity board +(`tests/afi/`), not out-of-scope. "Use FastAPI's native WebSocket / an ORM +of choice" is the non-goal: the AFI exists precisely to wire those to the +generated typed client through one decorator, so an adapter that defers to +the native primitive isn't yet a complete AFI adapter. The SSR bridge in +particular is framework-agnostic (`mizan.ssr.bridge.SSRBridge` has no Django +coupling) and is mountable here directly. Usage: from fastapi import FastAPI diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index 0881ada..5c288f0 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -44,11 +44,12 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse: @router.get("/session/") async def session_init() -> JSONResponse: - """Session-init probe. Parity with mizan-django's session endpoint. + """Session-init endpoint. AFI-common; wired here at parity with mizan-django. - CSRF is a Django-only concern at the protocol level; FastAPI surfaces a - null token so the response shape stays uniform across backends. The - wire-parity harness uses this endpoint as its readiness probe. + The endpoint itself is the AFI-common surface. The CSRF *token* is a Django + session mechanism with no FastAPI equivalent, so this returns a null token — + the difference is in the token's backing mechanism, not in whether the + endpoint is owed. The wire-parity harness uses it as its readiness probe. """ return _no_store({"csrfToken": None}) diff --git a/backends/mizan-rust-axum/src/handlers.rs b/backends/mizan-rust-axum/src/handlers.rs index c1cbe19..c639fd7 100644 --- a/backends/mizan-rust-axum/src/handlers.rs +++ b/backends/mizan-rust-axum/src/handlers.rs @@ -153,9 +153,11 @@ fn coerce_query_args( out } -/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint. -/// CSRF is a Django-only concern; the Rust adapter returns a null token so -/// readiness-probe consumers see a well-formed response. +/// GET /session/ — the AFI-common session-init endpoint, wired at parity with +/// mizan-django and mizan-fastapi. The CSRF *token* is a Django session +/// mechanism with no Rust equivalent, so this returns a null token; the endpoint +/// itself is owed and present, and readiness-probe consumers get a well-formed +/// response. pub async fn session_init() -> Response { let body = serde_json::json!({ "csrfToken": null }); no_store(body) diff --git a/cores/mizan-python/src/mizan_core/registry.py b/cores/mizan-python/src/mizan_core/registry.py index 81e2119..71b4852 100644 --- a/cores/mizan-python/src/mizan_core/registry.py +++ b/cores/mizan-python/src/mizan_core/registry.py @@ -1,12 +1,16 @@ """ Mizan core registry — function and composition registration with an -extension hook for backend-specific registries (channels, forms, etc.) -to plug into. +extension hook for the AFI-common capabilities that need their own +sub-registry (channels/WebSocket, forms, shapes) to plug into. -This is the framework-agnostic registry. Backends own their own -type-specific registries (channels in Django Channels, forms in Django -Forms, websockets in FastAPI, etc.) and register them as extensions -here so the unified schema export can include them. +This is the framework-agnostic registry. The extension points +(channels, forms, websockets, shapes) are AFI-common: every adapter owes +a binding for each, and registers it here so the unified schema export +sees it. Django binds all of them today; the other adapters' unbound +extensions are gaps tracked by the capability-parity suite in +`tests/afi/`, not framework-specific features. The binding is per-stack +(Django Channels vs. native WebSocket, Django Forms vs. Pydantic); the +capability is common. """ from __future__ import annotations diff --git a/tests/afi/fixture.py b/tests/afi/fixture.py index 2447083..f80b65e 100644 --- a/tests/afi/fixture.py +++ b/tests/afi/fixture.py @@ -7,7 +7,11 @@ exercise the protocol axes both backends must agree on: - two context functions sharing a param (proves bundling + param elevation) - a mutation declaring `affects` on the context -No channels, no forms, no shapes — those aren't AFI-common. +This fixture is deliberately minimal: it exercises the axes the *IR-shape* +parity test (`test_codegen_parity.py`) needs to compare. It intentionally omits +channels / forms / shapes — NOT because those are outside the AFI (they are +AFI-common; see `manifest.py`), but because their per-adapter wiring is gated by +the *capability* parity suite (`test_capability_parity.py`), not by IR-shape. `register_fixture()` registers the functions with mizan_core.registry. Backend test apps import this module and call register_fixture() during diff --git a/tests/afi/manifest.py b/tests/afi/manifest.py new file mode 100644 index 0000000..54d270d --- /dev/null +++ b/tests/afi/manifest.py @@ -0,0 +1,167 @@ +""" +The AFI surface, as data — the single source of truth for what every Mizan +adapter owes. + +This module exists because "what is AFI-common" used to live as prose: a +README table, a fixture docstring, an adapter `__init__` comment. Prose drifts. +An adapter that didn't wire a capability got its gap relabelled "Django-only" +or "out of scope — use native equivalents," and nothing went red. Here the +surface is a list of `Capability` objects and the implementors are a list of +`Adapter` objects; the parity table and the conformance suite are both +*generated* from these two lists. A capability cannot be de-scoped by editing +a word — only by deleting it from `CAPABILITIES`, which is a reviewable diff, +not a buried cell. + +Applicability ("—" in the table) is DERIVED, never asserted: a capability that +`requires={"transport": "http"}` is simply not applicable to an IPC adapter. +The header-invalidation channel does not exist over Tauri IPC because IPC has +no headers — that is a fact about the transport, computed by `applies()`, not a +parity decision an agent gets to make. + +The line between AFI-common and genuinely-backend-bound: + + AFI-common Every adapter owes a binding. The protocol core, plus every + `register_extension` point (WebSocket, SSR, JWT, MWT, Shapes, + Forms). A missing one is a GAP (❌), never a category. + + Backend-bound Not in this manifest at all. `allauth` is a Django-ecosystem + package — legitimately Django-only. The *bindings* of common + capabilities are backend-specific (django-readers is Django's + Shapes binding; Django Forms is Django's Forms binding) — but + the *capability* is common, so it lives here and each adapter + owes its own binding, not an "N/A." +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class Tier(str, Enum): + """How the README groups capabilities — presentation only, not semantics.""" + + PROTOCOL_CORE = "Protocol core" + EDGE_CACHE = "Edge, cache & enforcement" + EXTENSION = "Extension points" + + +@dataclass(frozen=True) +class Capability: + """One thing every applicable adapter owes a binding for. + + `requires` declares preconditions an adapter must meet for this capability + to APPLY. The only key today is `transport` ("http" | "ipc"); a capability + that names one is inapplicable to adapters on the other, and `applies()` + renders that as a derived "—". An empty `requires` means transport-agnostic: + every adapter owes it, full stop. + """ + + id: str + title: str + tier: Tier + requires: dict[str, str] = field(default_factory=dict) + note: str = "" + + +@dataclass(frozen=True) +class Adapter: + """A backend adapter and the declared properties parity is computed against. + + `transport` is the load-bearing property: "http" adapters have a header + channel, an edge, a session endpoint; "ipc" adapters (Tauri) do not, and + those capabilities derive to "—" rather than counting as gaps. + """ + + id: str + title: str + language: str # "python" | "rust" | "typescript" + transport: str # "http" | "ipc" + + +# ─── The AFI-common surface ─────────────────────────────────────────────────── + +CAPABILITIES: list[Capability] = [ + # Protocol core — the wire contract every adapter implements. + Capability("rpc_call", "RPC call dispatch (`{result, invalidate}`)", Tier.PROTOCOL_CORE), + Capability("context_bundle", "Named-context bundle fetch", Tier.PROTOCOL_CORE), + Capability("invalidate_body", "Invalidation — JSON body", Tier.PROTOCOL_CORE), + Capability( + "invalidate_header", "Invalidation — `X-Mizan-Invalidate` header", + Tier.PROTOCOL_CORE, requires={"transport": "http"}, + note="The header channel is co-equal with the body channel in the spec. " + "IPC transports carry invalidation in the response envelope instead.", + ), + Capability("invalidate_autoscope", "Invalidation auto-scoping (three-tier)", Tier.PROTOCOL_CORE), + Capability("registration", "Function discovery / registration", Tier.PROTOCOL_CORE), + Capability("ir_export", "Codegen IR export (KDL)", Tier.PROTOCOL_CORE), + Capability("upload", "File uploads (`Upload` type)", Tier.PROTOCOL_CORE), + + # Edge, cache & enforcement. + Capability("auth_enforcement", "Auth-guard enforcement (`auth=…` rejects)", Tier.EDGE_CACHE), + Capability("origin_cache", "Origin-side HMAC cache", Tier.EDGE_CACHE), + Capability( + "edge_manifest", "Edge manifest export", Tier.EDGE_CACHE, + requires={"transport": "http"}, + note="The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge.", + ), + Capability( + "psr", "PSR (`render_strategy` in manifest)", Tier.EDGE_CACHE, + requires={"transport": "http"}, + ), + Capability( + "session_init", "Session / CSRF init endpoint", Tier.EDGE_CACHE, + requires={"transport": "http"}, + ), + + # Extension points — the `register_extension` surface. AFI-common, every one. + Capability("websocket", "WebSocket transport (`websocket=` declared)", Tier.EXTENSION), + Capability("ssr_bridge", "SSR bridge (subprocess renderer)", Tier.EXTENSION), + Capability("jwt", "JWT auth (access / refresh)", Tier.EXTENSION), + Capability( + "mwt", "MWT (edge identity token)", Tier.EXTENSION, + requires={"transport": "http"}, + note="MWT exists to key an edge cache; without an edge there is nothing to key.", + ), + Capability( + "shapes", "Typed query projection (Shapes)", Tier.EXTENSION, + note="The capability is AFI-common; the binding is per-ORM " + "(django-readers on Django, the project's ORM elsewhere).", + ), + Capability( + "forms", "Forms (schema / validate / submit)", Tier.EXTENSION, + note="The capability is AFI-common; the binding is per-framework " + "(Django Forms on Django, Pydantic-or-equivalent elsewhere).", + ), +] + + +# ─── The implementors ───────────────────────────────────────────────────────── + +ADAPTERS: list[Adapter] = [ + Adapter("django", "Django", "python", "http"), + Adapter("fastapi", "FastAPI", "python", "http"), + Adapter("rust_axum", "Rust / Axum", "rust", "http"), + Adapter("tauri", "Tauri", "rust", "ipc"), + Adapter("typescript", "TypeScript", "typescript", "http"), +] + + +CAPABILITIES_BY_ID: dict[str, Capability] = {c.id: c for c in CAPABILITIES} +ADAPTERS_BY_ID: dict[str, Adapter] = {a.id: a for a in ADAPTERS} + +# Every capability owes exactly one probe in probes.py. The meta-conformance +# test pins this so a capability can't be added without its gate. +PROBES_REQUIRED: int = len(CAPABILITIES) + + +def applies(capability: Capability, adapter: Adapter) -> bool: + """Whether `capability` is applicable to `adapter`, from declared properties. + + This is the ONLY source of "—" in the parity table. A `False` here is a + transport fact (IPC has no HTTP header channel), not a parity verdict. + """ + required_transport = capability.requires.get("transport") + if required_transport is not None and adapter.transport != required_transport: + return False + return True diff --git a/tests/afi/parity_table.py b/tests/afi/parity_table.py new file mode 100644 index 0000000..b98b3a9 --- /dev/null +++ b/tests/afi/parity_table.py @@ -0,0 +1,141 @@ +""" +Generate the README parity table from the conformance probes. + +The table in the README is *output*, not *input*. It is computed by running +every probe over every adapter and rendering the result. An agent can no longer +type "Django-only" into a cell, because no one types cells — `make parity-table` +overwrites the block between the markers, and `--check` fails CI if the +committed block has drifted from what the probes actually report (the same +forcing function the codegen byte-parity tests already use). + +Glyphs: + ✅ wired the probe found the artifact + ◑ partial declared or stubbed, not complete — counts as RED in the suite + ❌ gap AFI-common, owed, not wired + — n/a the capability does not exist over this adapter's transport + (derived from `manifest.applies`, never asserted) + +Usage: + python parity_table.py --write # regenerate the README block + python parity_table.py --check # exit 1 if README block is stale + python parity_table.py # print the block to stdout +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from manifest import ADAPTERS, CAPABILITIES, Tier, applies +from probes import run_probe + +README = Path(__file__).resolve().parents[2] / "README.md" + +START = "" +END = "" + +_GLYPH = {"pass": "✅", "partial": "◑", "fail": "❌"} + + +def _cell(cap, adapter) -> str: + if not applies(cap, adapter): + return "—" + return _GLYPH[run_probe(cap.id, adapter).state] + + +def _tier_table(tier: Tier) -> str: + caps = [c for c in CAPABILITIES if c.tier is tier] + if not caps: + return "" + header = "| Capability | " + " | ".join(a.title for a in ADAPTERS) + " |" + sep = "|---|" + "|".join(":---:" for _ in ADAPTERS) + "|" + rows = [] + for cap in caps: + cells = " | ".join(_cell(cap, a) for a in ADAPTERS) + rows.append(f"| {cap.title} | {cells} |") + return f"### {tier.value}\n\n{header}\n{sep}\n" + "\n".join(rows) + + +def _notes() -> str: + noted = [c for c in CAPABILITIES if c.note] + if not noted: + return "" + lines = ["**Notes**", ""] + for c in noted: + lines.append(f"- **{c.title}** — {c.note}") + return "\n".join(lines) + + +def generate_block() -> str: + """The full generated parity block: legend, one table per tier, notes.""" + legend = ( + "Legend: ✅ wired · ◑ partial (declared/stubbed) · ❌ gap (AFI-common, owed) · " + "— not applicable to this adapter's transport\n\n" + "Every capability below is **AFI-common**: each adapter owes a binding, and a " + "❌ is a gap on the owed-work board (`tests/afi/`), never a category. " + "Backend-specific *bindings* of common capabilities (django-readers for Shapes, " + "Django Forms for Forms) and genuinely Django-ecosystem features (allauth) are " + "out of this matrix by design — see `tests/afi/manifest.py` for the line." + ) + parts = [legend] + for tier in (Tier.PROTOCOL_CORE, Tier.EDGE_CACHE, Tier.EXTENSION): + table = _tier_table(tier) + if table: + parts.append(table) + notes = _notes() + if notes: + parts.append(notes) + return "\n\n".join(parts) + + +def _wrap(block: str) -> str: + return f"{START}\n{block}\n{END}" + + +def _splice(readme_text: str, block: str) -> str: + if START not in readme_text or END not in readme_text: + raise SystemExit( + f"README markers not found. Add a block bounded by:\n {START}\n {END}\n" + f"to {README} where the parity table should render." + ) + pre = readme_text.split(START)[0] + post = readme_text.split(END)[1] + return pre + _wrap(block) + post + + +def main(argv: list[str]) -> int: + block = generate_block() + mode = argv[1] if len(argv) > 1 else "--print" + + if mode == "--print": + print(block) + return 0 + + text = README.read_text(encoding="utf-8") + spliced = _splice(text, block) + + if mode == "--write": + if spliced != text: + README.write_text(spliced, encoding="utf-8") + print(f"Wrote parity table to {README}") + else: + print("Parity table already current.") + return 0 + + if mode == "--check": + if spliced != text: + print( + "README parity table is STALE. The committed table does not match what " + "the conformance probes report.\nRun: make parity-table", + file=sys.stderr, + ) + return 1 + print("README parity table is current.") + return 0 + + print(f"Unknown mode: {mode}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/afi/probes.py b/tests/afi/probes.py new file mode 100644 index 0000000..0d74bab --- /dev/null +++ b/tests/afi/probes.py @@ -0,0 +1,385 @@ +""" +Capability probes — the adversarial half of the conformance gate. + +A probe answers one question about one (capability, adapter) pair: *is this +capability actually wired into this adapter?* It answers by inspecting the +backend's own source for the concrete artifact the capability requires — a +route registration, an exported renderer, a management command, a call into the +shared core. It never asks the adapter to self-report (a self-report is the +thing that drifts), and it never reads a parity table (that would be circular). + +The single invariant: **a probe goes green only when the wiring exists.** You +cannot flip a cell by editing prose, a docstring, or a README — only by adding +the route / module / binding the probe looks for. + +Two facts learned by building this and watching it lie, now load-bearing: + +1. Comments are the false-positive engine. A doc-comment that says "the JS side + re-wraps it into a Promise.reject" made an auth-enforcement probe pass on the + word `reject`. So source is comment-stripped per language before matching, + and patterns target code identifiers (`MizanError::Unauthorized`, a route + macro, an exported function name), never prose-able words. + +2. AFI-common logic lives in the shared core; the adapter rides it. FastAPI's + JWT/MWT support *is* `mizan_core.auth.authenticate` — there is no `jwt` + literal in the FastAPI adapter, because the adapter delegates. Axum's KDL IR + lives in `cores/mizan-rust`, not the axum crate. So capabilities that are + genuinely core-provided (`jwt`, `mwt`, `ir_export`) probe the BACKEND scope + (adapter + its language core); capabilities the adapter must wire to its own + transport (the header on the response, the route, the manifest command, the + WebSocket handler) probe the ADAPTER scope only. + +Depth. These are *source-surface* probes: they confirm the artifact is present, +not that it behaves correctly under load. Runtime-behavior depth (mount, drive, +assert the wire bytes) is owed per adapter and lands with each gap's closure — +the runtime probe is written alongside the wiring it verifies. Surface-presence +is the floor that makes de-scope impossible; runtime is the ceiling each closure +raises, and the gap between them is named here, not hidden. + +`partial` (◑) is a real state: Axum declares `Transport::Websocket` in the IR +but routes no handler; mizan-ts decodes a JWT but mints none. It renders ◑ — and +the conformance suite still treats it as RED, because the suite asserts +`state == "pass"`. ◑ is visible honesty, never a shippable-green hiding place. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Callable, Literal + +from manifest import Adapter + +REPO_ROOT = Path(__file__).resolve().parents[2] +BACKENDS = REPO_ROOT / "backends" +CORES = REPO_ROOT / "cores" + +# Each adapter's own source root. Probes scoped "adapter" read only here. +ADAPTER_ROOTS: dict[str, Path] = { + "django": BACKENDS / "mizan-django" / "src" / "mizan", + "fastapi": BACKENDS / "mizan-fastapi" / "src" / "mizan_fastapi", + "rust_axum": BACKENDS / "mizan-rust-axum" / "src", + "tauri": BACKENDS / "mizan-tauri" / "src", + "typescript": BACKENDS / "mizan-ts" / "src", +} + +# The shared core each language's adapters ride. Probes scoped "backend" read +# the adapter root PLUS these — that is where core-provided AFI-common logic +# (token minting, KDL IR) actually lives. +CORE_ROOTS: dict[str, list[Path]] = { + "python": [CORES / "mizan-python" / "src" / "mizan_core"], + "rust": [CORES / "mizan-rust" / "src", CORES / "mizan-rust-macros" / "src"], + "typescript": [], # mizan-ts is self-contained; no shared core +} + +_SOURCE_EXTS = {".py", ".rs", ".ts", ".tsx"} +_SKIP_DIRS = {"node_modules", "target", "__pycache__", "dist", ".venv"} + + +State = Literal["pass", "partial", "fail"] + + +@dataclass(frozen=True) +class ProbeResult: + state: State + detail: str + + +# ─── Source collection + comment stripping ──────────────────────────────────── + + +def _strip_comments(text: str, language: str) -> str: + """Remove comments so prose can't satisfy a code-artifact pattern. + + Imperfect by design (a `#` inside a string may be clipped); the patterns are + code identifiers that live outside strings, and the cost of a rare false + strip is far below the cost of a comment-driven false `pass`. + """ + if language in ("rust", "typescript"): + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) # block /* */ (incl. /** */) + text = re.sub(r"(? str: + chunks: list[str] = [] + for root in roots: + if not root.exists(): + continue + for path in sorted(root.rglob("*")): + if not (path.is_file() and path.suffix in _SOURCE_EXTS): + continue + if any(part in _SKIP_DIRS for part in path.parts): + continue + chunks.append(_strip_comments(path.read_text(encoding="utf-8", errors="replace"), language)) + return "\n".join(chunks) + + +@lru_cache(maxsize=None) +def _adapter_text(adapter_id: str, language: str) -> str: + return _read_roots((ADAPTER_ROOTS[adapter_id],), language) + + +@lru_cache(maxsize=None) +def _backend_text(adapter_id: str, language: str) -> str: + roots = (ADAPTER_ROOTS[adapter_id], *CORE_ROOTS.get(language, [])) + return _read_roots(tuple(roots), language) + + +def _adapter(a: Adapter) -> str: + return _adapter_text(a.id, a.language) + + +def _backend(a: Adapter) -> str: + return _backend_text(a.id, a.language) + + +def _has_path(adapter_id: str, *relparts: str) -> bool: + return ADAPTER_ROOTS[adapter_id].joinpath(*relparts).exists() + + +def _hit(text: str, pattern: str) -> bool: + return re.search(pattern, text) is not None + + +def _wired(found: bool, what: str) -> ProbeResult: + return ProbeResult("pass", f"wired: {what}") if found else ProbeResult("fail", f"missing: {what}") + + +# ─── Per-capability detection ───────────────────────────────────────────────── +# +# Patterns are grounded in artifacts read directly from each adapter: +# django urls.py `path("call/", ...)`, channels/ ssr/ shapes/ forms/ dirs +# fastapi router.py `@router.post("/call/")`, header insert, /session/ +# axum handlers.rs `function_call`, `compute_invalidation`, no header/cache +# tauri lib.rs `mizan_invoke`, Envelope::Call/Fetch, no auth/cache/ws +# ts dispatch.ts `handleMutationCall`, checkAuth, token.ts decode-only + + +def _probe_rpc_call(a: Adapter) -> ProbeResult: + pat = { + "python": r'path\("call/|/call/|function_call', + "rust": r"function_call|mizan_invoke|Envelope::Call", + "typescript": r"handleMutationCall", + }[a.language] + return _wired(_hit(_adapter(a), pat), "RPC call dispatch") + + +def _probe_context_bundle(a: Adapter) -> ProbeResult: + pat = { + "python": r"context_fetch|/ctx/|ctx/<", + "rust": r"context_fetch|handle_fetch|Envelope::Fetch", + "typescript": r"handleContextFetch", + }[a.language] + return _wired(_hit(_adapter(a), pat), "named-context bundle fetch") + + +def _probe_invalidate_body(a: Adapter) -> ProbeResult: + pat = { + "python": r'"invalidate"|invalidate=|\binvalidate\b', + "rust": r"invalidate|InvalidationTarget", + "typescript": r"\.invalidate\b|invalidate:", + }[a.language] + return _wired(_hit(_adapter(a), pat), "`invalidate` key in response body") + + +def _probe_invalidate_header(a: Adapter) -> ProbeResult: + return _wired(_hit(_adapter(a), r"X-Mizan-Invalidate"), "`X-Mizan-Invalidate` header emission") + + +def _probe_invalidate_autoscope(a: Adapter) -> ProbeResult: + # The adapter wires auto-scoping by routing through the shared resolver. + pat = { + "python": r"resolve_invalidation|dispatch_call|dispatch_context", + "rust": r"compute_invalidation", + "typescript": r"resolveInvalidation", + }[a.language] + return _wired(_hit(_adapter(a), pat), "three-tier invalidation auto-scoping (shared resolver)") + + +def _probe_registration(a: Adapter) -> ProbeResult: + pat = { + "python": r"\bregister\b|get_function|registry", + "rust": r"FUNCTIONS|lookup_function|linkme", + "typescript": r"getFunction|\bregister\b|registry", + }[a.language] + return _wired(_hit(_adapter(a), pat), "function discovery / registration") + + +def _probe_ir_export(a: Adapter) -> ProbeResult: + # Core-provided: python build_ir/export cmd (adapter), rust kdl.rs/ir.rs (core). + if a.language == "python": + return _wired(_hit(_adapter(a), r"build_ir|export_mizan_ir"), "KDL IR export") + if a.language == "rust": + return _wired(_hit(_backend(a), r"to_kdl|emit_kdl|fn build_ir|kdl::"), "KDL IR export (rust core)") + # TypeScript: emits the edge manifest (JSON), but no codegen KDL IR (note 8). + if _hit(_adapter(a), r"to_kdl|emitKdl|buildIr|\bkdl\b"): + return ProbeResult("pass", "wired: KDL IR emitter") + return ProbeResult("fail", "missing: KDL IR emitter (manifest.ts emits edge JSON, not codegen IR)") + + +def _probe_upload(a: Adapter) -> ProbeResult: + pat = { + "python": r"bind_uploads|UploadedFile|multipart", + "rust": r"Multipart|multipart|bind_uploads", + "typescript": r"FormData|multipart", + }[a.language] + return _wired(_hit(_adapter(a), pat), "multipart / Upload-type dispatch binding") + + +def _probe_auth_enforcement(a: Adapter) -> ProbeResult: + if a.language == "python": + return _wired(_hit(_adapter(a), r"enforce_auth|authenticate\(|authguard"), "auth= enforcement") + if a.language == "typescript": + return _wired(_hit(_adapter(a), r"checkAuth|authDenial|AuthDenial"), "auth= enforcement") + # Rust adapters (axum, tauri): dispatch must reject on auth=. + if _hit(_adapter(a), r"Unauthorized|Forbidden|enforce_auth"): + return ProbeResult("pass", "wired: auth enforcement in dispatch") + if _hit(_backend(a), r"\bauth\b|is_private|\bprivate\b"): + return ProbeResult("partial", "auth/private carried in FunctionSpec; dispatch does not reject") + return ProbeResult("fail", "missing: auth= enforcement") + + +def _probe_origin_cache(a: Adapter) -> ProbeResult: + pat = { + "python": r"CacheOrchestrator|derive_cache_key|cfg\.cache|cache_get|cache_put", + "rust": r"derive_cache_key|CacheBackend|origin_cache", + "typescript": r"cacheGet|cachePut|deriveCacheKey", + }[a.language] + return _wired(_hit(_backend(a), pat), "origin-side HMAC cache") + + +def _probe_edge_manifest(a: Adapter) -> ProbeResult: + pat = { + "python": r"export_edge_manifest|generate_edge_manifest|edge_manifest", + "rust": r"edge_manifest|generate_manifest", + "typescript": r"generateManifest|EdgeManifest", + }[a.language] + return _wired(_hit(_adapter(a), pat), "edge manifest export") + + +def _probe_psr(a: Adapter) -> ProbeResult: + return _wired(_hit(_adapter(a), r"render_strategy|renderStrategy"), "PSR render_strategy") + + +def _probe_session_init(a: Adapter) -> ProbeResult: + pat = { + "python": r"session/|session_init", + "rust": r"session_init|/session/", + "typescript": r"sessionInit|/session/", + }[a.language] + return _wired(_hit(_adapter(a), pat), "session / CSRF init endpoint") + + +def _probe_websocket(a: Adapter) -> ProbeResult: + if a.id == "django": + return _wired(_has_path(a.id, "channels"), "Django Channels consumer") + if a.id == "fastapi": + return _wired(_hit(_adapter(a), r"WebSocket|@router\.websocket|websocket_route"), "FastAPI WebSocket route") + if a.id == "rust_axum": + if _hit(_adapter(a), r"WebSocketUpgrade|on_upgrade|ws_handler"): + return ProbeResult("pass", "wired: Axum WebSocket handler") + if _hit(_backend(a), r"Websocket|WebSocket"): + return ProbeResult("partial", "Transport::Websocket declared in IR; no Axum handler routed") + return ProbeResult("fail", "missing: WebSocket transport") + if a.id == "tauri": + return _wired(_hit(_adapter(a), r"subscription|Subscribe|emit_to|Channel<"), "IPC subscription channel") + return _wired(_hit(_adapter(a), r"WebSocket|websocket"), "WebSocket transport") + + +def _probe_ssr_bridge(a: Adapter) -> ProbeResult: + if a.id == "django": + return _wired(_has_path(a.id, "ssr", "bridge.py"), "Bun SSR subprocess bridge") + return _wired(_hit(_adapter(a), r"SSRBridge|renderToString|ssr_bridge"), "SSR bridge (subprocess renderer)") + + +def _probe_jwt(a: Adapter) -> ProbeResult: + # Core-provided for Python (mizan_core.auth); TS has decode-only helpers. + if a.language == "python": + return _wired(_hit(_backend(a), r"\bjwt\b|JWT|def authenticate"), "JWT auth (rides mizan_core.auth)") + if a.id == "typescript": + if _hit(_adapter(a), r"signJwt|mintJwt|createJwt|encodeJwt"): + return ProbeResult("pass", "wired: JWT mint + verify") + if _hit(_adapter(a), r"decodeJwt|decodeJwtBearer"): + return ProbeResult("partial", "decode-only helper; mints no tokens") + return ProbeResult("fail", "missing: JWT auth") + return _wired(_hit(_backend(a), r"\bjwt\b|JWT"), "JWT auth (access / refresh)") + + +def _probe_mwt(a: Adapter) -> ProbeResult: + if a.language == "python": + return _wired(_hit(_backend(a), r"\bmwt\b|MWT|X-Mizan-Token"), "MWT (edge identity token)") + if a.id == "typescript": + if _hit(_adapter(a), r"signMwt|mintMwt|createMwt"): + return ProbeResult("pass", "wired: MWT mint") + if _hit(_adapter(a), r"decodeMwt|identityFromMwt"): + return ProbeResult("partial", "decode-only helper; mints no tokens") + return ProbeResult("fail", "missing: MWT") + return _wired(_hit(_backend(a), r"\bmwt\b|MWT"), "MWT (edge identity token)") + + +def _probe_shapes(a: Adapter) -> ProbeResult: + if a.id == "django": + return _wired(_has_path(a.id, "shapes"), "django-readers Shapes binding") + pat = { + "python": r"django_readers|QuerysetProjection|shapes\.", + "rust": r"shapes::|QueryProjection", + "typescript": r"QueryProjection|shapesBinding", + }[a.language] + return _wired(_hit(_adapter(a), pat), "typed query-projection binding") + + +def _probe_forms(a: Adapter) -> ProbeResult: + if a.id == "django": + return _wired(_has_path(a.id, "forms"), "Django Forms binding (schema / validate / submit)") + if a.language == "rust": + if _hit(_adapter(a), r"form_submit|form_validate|FormRole::Submit"): + return ProbeResult("pass", "wired: form validate/submit endpoint") + if _hit(_backend(a), r"is_form|form_role|FormRole"): + return ProbeResult("partial", "is_form/form_role carried; no validate/submit endpoint") + return ProbeResult("fail", "missing: forms") + pat = { + "python": r"FormSchema|form_role|forms\.", + "typescript": r"FormSchema|formRole|formSubmit", + }[a.language] + return _wired(_hit(_adapter(a), pat), "forms (schema / validate / submit)") + + +PROBES: dict[str, Callable[[Adapter], ProbeResult]] = { + "rpc_call": _probe_rpc_call, + "context_bundle": _probe_context_bundle, + "invalidate_body": _probe_invalidate_body, + "invalidate_header": _probe_invalidate_header, + "invalidate_autoscope": _probe_invalidate_autoscope, + "registration": _probe_registration, + "ir_export": _probe_ir_export, + "upload": _probe_upload, + "auth_enforcement": _probe_auth_enforcement, + "origin_cache": _probe_origin_cache, + "edge_manifest": _probe_edge_manifest, + "psr": _probe_psr, + "session_init": _probe_session_init, + "websocket": _probe_websocket, + "ssr_bridge": _probe_ssr_bridge, + "jwt": _probe_jwt, + "mwt": _probe_mwt, + "shapes": _probe_shapes, + "forms": _probe_forms, +} + + +def run_probe(capability_id: str, adapter: Adapter) -> ProbeResult: + """Run the probe for one (capability, adapter) pair.""" + probe = PROBES.get(capability_id) + if probe is None: + raise KeyError( + f"No probe registered for capability '{capability_id}'. Every capability " + f"in manifest.CAPABILITIES owes a probe — add one to PROBES." + ) + return probe(adapter) diff --git a/tests/afi/test_capability_parity.py b/tests/afi/test_capability_parity.py new file mode 100644 index 0000000..0f60ed5 --- /dev/null +++ b/tests/afi/test_capability_parity.py @@ -0,0 +1,118 @@ +""" +AFI capability parity — the runtime/surface conformance gate. + +`test_codegen_parity.py` gates that the three backends emit byte-identical KDL. +That is necessary but narrow: it proves the IR agrees, not that an adapter +actually *implements* the capabilities the IR describes. The vacuum left by +"IR-shape only" is exactly where parity drifted — an adapter that never wired +SSR or WebSocket got its gap relabelled "Django-only" or "out of scope," and +nothing in the suite objected. + +This module closes that vacuum. It parametrizes over every (capability, +applicable-adapter) pair drawn from `manifest.py` and asserts the adapter +actually wires the capability (`probes.py`). It is designed to be RED wherever a +gap is real — each failure names one owed binding. That redness is not a broken +build; it is the board of owed work, itemized and loud, that the prior prose +table hid behind false-green. A gap turns green by being *wired*, never by being +*described*. + +Applicability is derived in `manifest.applies()` from the adapter's declared +transport, so a capability that simply does not exist over a transport (header +invalidation over Tauri IPC) is not parametrized here at all — it is a "—" in +the generated table, computed, not a verdict anyone typed. +""" + +from __future__ import annotations + +import pytest + +from manifest import ADAPTERS, CAPABILITIES, CAPABILITIES_BY_ID, PROBES_REQUIRED, applies +from probes import PROBES, run_probe + + +def _applicable_pairs() -> list[tuple[str, str, str]]: + """(capability_id, adapter_id, test_id) for every pair the protocol applies to.""" + pairs: list[tuple[str, str, str]] = [] + for cap in CAPABILITIES: + for adapter in ADAPTERS: + if applies(cap, adapter): + pairs.append((cap.id, adapter.id, f"{cap.id}::{adapter.id}")) + return pairs + + +_PAIRS = _applicable_pairs() + + +@pytest.mark.parametrize( + "capability_id,adapter_id", + [(c, a) for c, a, _ in _PAIRS], + ids=[tid for _, _, tid in _PAIRS], +) +def test_adapter_wires_capability(capability_id: str, adapter_id: str) -> None: + """The adapter must wire the AFI-common capability the protocol declares. + + A failure here is one owed binding, not a regression. The message names the + capability, the adapter, and what the probe could not find — that string is + the gap's specification until someone closes it by wiring the artifact. + """ + adapter = next(a for a in ADAPTERS if a.id == adapter_id) + cap = CAPABILITIES_BY_ID[capability_id] + result = run_probe(capability_id, adapter) + + assert result.state == "pass", ( + f"AFI parity gap — {adapter.title} does not wire '{cap.title}'.\n" + f" probe: {result.detail}\n" + f" state: {result.state}" + + (" (◑ partial — declared/stubbed but not complete)" if result.state == "partial" else "") + + f"\n This capability is AFI-common (manifest tier: {cap.tier.value}); every " + f"adapter owes a binding. Close it by wiring the artifact the probe looks for — " + f"not by editing a table." + ) + + +# ─── Meta-conformance: the manifest and the probes must stay in lockstep ─────── + + +def test_every_capability_has_a_probe() -> None: + """No capability may be declared without a probe — else it is unverifiable + and silently 'passes' by never being checked, recreating the original hole.""" + missing = [c.id for c in CAPABILITIES if c.id not in PROBES] + assert not missing, ( + f"Capabilities declared in manifest.py with no probe in probes.py: {missing}. " + f"An unprobed capability is an un-gated parity claim — exactly the drift this " + f"suite exists to prevent." + ) + + +def test_no_orphan_probes() -> None: + """No probe may exist for a capability the manifest doesn't declare — that + would be dead detection code drifting from the surface it claims to check.""" + orphans = [pid for pid in PROBES if pid not in CAPABILITIES_BY_ID] + assert not orphans, ( + f"Probes in probes.py with no matching capability in manifest.py: {orphans}." + ) + + +def test_probe_count_matches_required() -> None: + """Sanity pin: the manifest's own count of required probes equals the probe set.""" + assert len(PROBES) == PROBES_REQUIRED, ( + f"probes.py defines {len(PROBES)} probes; manifest expects {PROBES_REQUIRED}." + ) + + +def test_readme_parity_table_is_current() -> None: + """The README parity table is generated output; a hand-edit must fail here. + + This is the lock that makes the original lie inexpressible. The table can no + longer be edited to read 'Django-only' — it is spliced from the probe results + by `parity_table.py`, and this test asserts the committed block matches a fresh + regeneration. Drift → red → `make parity-table`. + """ + import parity_table + + text = parity_table.README.read_text(encoding="utf-8") + regenerated = parity_table._splice(text, parity_table.generate_block()) + assert regenerated == text, ( + "README parity table is stale or hand-edited. It is generated from the " + "conformance probes — run `make parity-table` to regenerate it." + )