diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efa30c..2f77b6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,14 @@ jobs: # ``tests/unit/test_observability_otel.py``. run: uv sync --frozen --all-extras --group examples + - name: Validate conformance.toml against pinned spec + # Catches drift between conformance.toml and the pinned spec + # submodule's proposals/. External readers (notably the + # openarmature-spec docs build) fetch this manifest to surface + # python's impl status on the proposals index page, so a stale + # entry quietly serves wrong data to the docs site. + run: uv run python scripts/check_conformance_manifest.py + - name: Lint (ruff check) run: uv run ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 667e05f..17a0ce2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,13 @@ jobs: ) PY + - name: Validate conformance.toml against pinned spec + # Mirrors the equivalent step in ci.yml. Catches drift between + # conformance.toml and the pinned spec submodule's proposals/ + # at release time as well as PR time, so a tag push that + # bypassed PR review still fails before publishing. + run: uv run python scripts/check_conformance_manifest.py + - name: Lint (ruff check) run: uv run ruff check . diff --git a/RELEASING.md b/RELEASING.md index ac8c142..4a336c7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -40,6 +40,14 @@ changelog entry. release that changed user-visible behavior is reflected in the upcoming version's section. The date matches the day the rc tag is pushed (refresh it again at the real-release step). +- [ ] **`conformance.toml` is current.** Any proposal whose impl + landed in this cycle has its `[proposals."NNNN"]` entry — either + newly added (set `since` to the version about to ship) or + adjusted (e.g., `not-yet` → `implemented`). If the pinned spec + submodule was bumped, also bump `[manifest].spec_pin` to the new + tag. The CI guard at `scripts/check_conformance_manifest.py` + enforces structural consistency, but it can't check that + semantic status reflects reality — read the diff manually. - [ ] **Docs sweep for stale references.** For each behavior change in the upcoming release, grep the docs for the old wording, file paths, and flag descriptions; reconcile in the same PR as the diff --git a/conformance.toml b/conformance.toml new file mode 100644 index 0000000..1748d48 --- /dev/null +++ b/conformance.toml @@ -0,0 +1,151 @@ +# Conformance manifest for openarmature-python. +# +# Records which openarmature-spec proposals are implemented in this +# package, and starting at which release version. Intended to be read +# by external consumers (notably the openarmature-spec docs build, +# which surfaces a per-implementation status column on the proposals +# index page). +# +# Stable URL (read-only, fetched at docs-build time): +# https://raw.githubusercontent.com/LunarCommand/openarmature-python/main/conformance.toml +# +# Maintenance: keep in sync with CHANGELOG.md. The CI guard at +# scripts/check_conformance_manifest.py validates this file against the +# pinned spec submodule's proposals/ directory on every PR and release +# build; any Accepted proposal lacking an entry (or any entry pointing +# at a non-existent / non-Accepted proposal) fails the build. +# +# Scope: this file lists ONLY proposals visible in the pinned spec +# submodule (openarmature-spec at the SHA pinned by this repo's +# submodule). Proposals accepted on the spec's main branch after this +# repo's last spec bump are intentionally absent — surfacing the gap +# between pinned-spec and spec-head is the consumer's job (e.g., the +# spec docs site computes the difference and renders accordingly). +# +# Convention: this file is only updated as part of release PRs. Between +# releases, the manifest reflects the most-recently-published version +# so external readers never see a `since` referring to an unreleased +# pre-tag commit. + +[manifest] +implementation = "openarmature-python" +spec_pin = "v0.22.1" + +# Status values: +# implemented — shipped behavior matches the proposal's contract +# partial — partial impl; consult `note` for what's missing +# textual-only — accepted proposal is purely textual (reframe, +# clarification, template) with no module-level +# change required; CHANGELOG note explains why +# not-yet — accepted in spec, not yet shipped in this package +# +# Drafts and Superseded proposals are deliberately absent from this +# file. The CI guard requires entries only for proposals whose spec +# header reads `Status: Accepted`. + +[proposals."0001"] +status = "implemented" +since = "0.5.0" + +[proposals."0002"] +status = "implemented" +since = "0.5.0" + +[proposals."0003"] +status = "implemented" +since = "0.5.0" + +[proposals."0004"] +status = "implemented" +since = "0.5.0" + +[proposals."0005"] +status = "implemented" +since = "0.5.0" + +[proposals."0006"] +status = "implemented" +since = "0.5.0" + +[proposals."0007"] +status = "implemented" +since = "0.5.0" + +[proposals."0008"] +status = "implemented" +since = "0.5.0" + +[proposals."0009"] +status = "implemented" +since = "0.9.0" + +[proposals."0010"] +status = "implemented" +since = "0.9.0" + +[proposals."0011"] +status = "implemented" +since = "0.6.0" + +[proposals."0012"] +status = "implemented" +since = "0.5.0" + +[proposals."0013"] +status = "implemented" +since = "0.5.0" + +[proposals."0014"] +status = "implemented" +since = "0.6.0" + +[proposals."0015"] +status = "implemented" +since = "0.6.0" + +[proposals."0016"] +status = "implemented" +since = "0.6.0" + +[proposals."0017"] +status = "implemented" +since = "0.6.0" + +[proposals."0018"] +status = "implemented" +since = "0.6.0" + +[proposals."0019"] +status = "textual-only" +since = "0.9.0" +note = "Purely textual reframe of llm-provider §8 as a catalog of wire-format mappings (OpenAI-compatible body nested under §8.1). No module-level change required." + +[proposals."0024"] +status = "implemented" +since = "0.8.0" + +[proposals."0025"] +status = "implemented" +since = "0.9.0" + +[proposals."0026"] +status = "textual-only" +since = "0.9.0" +note = "Purely textual §8 framing paragraph; the existing OpenAI §8.1 mapping is the template's reference shape, so no module-level work was needed." + +[proposals."0027"] +status = "implemented" +since = "0.9.0" + +[proposals."0028"] +status = "implemented" +since = "0.9.0" + +[proposals."0029"] +status = "implemented" +since = "0.9.0" + +[proposals."0030"] +status = "textual-only" +since = "0.9.0" +note = "Drain snapshot semantic and timeout-input validation already implemented as part of the proposal 0010 impl PR (v0.9.0); no additional module-level work needed." diff --git a/scripts/check_conformance_manifest.py b/scripts/check_conformance_manifest.py new file mode 100644 index 0000000..3d1a1da --- /dev/null +++ b/scripts/check_conformance_manifest.py @@ -0,0 +1,154 @@ +"""Validate conformance.toml against the pinned spec submodule's proposals. + +Failure modes caught: + +- Accepted proposal in the spec has no entry in conformance.toml. +- Entry in conformance.toml refers to a proposal that doesn't exist in + the spec, or refers to a proposal whose Status is not "Accepted" + (e.g., Draft / Superseded — those are deliberately excluded from the + manifest so the docs site doesn't claim impl status for unsettled + proposals). +- Entry has an unknown `status` value or a malformed `since` version. + +Read-only; intended for CI. Non-zero exit on any failure with a +human-readable diff. Runs under the repo's stdlib Python (>=3.12, so +`tomllib` is always available). +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path +from typing import Any, cast + +REPO_ROOT = Path(__file__).resolve().parent.parent +MANIFEST_PATH = REPO_ROOT / "conformance.toml" +PROPOSALS_DIR = REPO_ROOT / "openarmature-spec" / "proposals" + +ALLOWED_STATUSES = frozenset({"implemented", "partial", "textual-only", "not-yet"}) +PROPOSAL_FILENAME_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.md$") +STATUS_LINE_RE = re.compile(r"^- \*\*Status:\*\*\s*(.+?)\s*$", re.MULTILINE) +SINCE_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +def parse_spec_proposals() -> dict[str, str]: + # Returns {proposal_id: status} for every proposal markdown file in + # the pinned spec submodule. proposal_id is the 4-digit string used + # as the manifest key; status is the literal value from the file's + # `- **Status:** ...` header line. + if not PROPOSALS_DIR.is_dir(): + sys.exit( + f"::error::proposals dir not found at {PROPOSALS_DIR} — " + "is the openarmature-spec submodule checked out?" + ) + + result: dict[str, str] = {} + for path in sorted(PROPOSALS_DIR.iterdir()): + m = PROPOSAL_FILENAME_RE.match(path.name) + if not m: + continue + proposal_id = m.group(1) + text = path.read_text(encoding="utf-8") + status_match = STATUS_LINE_RE.search(text) + if not status_match: + sys.exit(f"::error::proposal {proposal_id} ({path.name}) has no `- **Status:** ...` header line") + result[proposal_id] = status_match.group(1).strip() + return result + + +def load_manifest() -> dict[str, Any]: + # Returns {proposal_id: entry} for every [proposals."NNNN"] section + # in conformance.toml. Each `entry` is typed as Any rather than + # dict because TOML lets a user accidentally write a scalar value + # under [proposals]; the caller validates the type per-entry and + # emits a structured error on shape drift. + if not MANIFEST_PATH.is_file(): + sys.exit(f"::error::manifest not found at {MANIFEST_PATH}") + + with MANIFEST_PATH.open("rb") as f: + data = tomllib.load(f) + + proposals = data.get("proposals", {}) + if not isinstance(proposals, dict): + sys.exit("::error::conformance.toml [proposals] table malformed") + return cast(dict[str, Any], proposals) + + +def main() -> int: + spec = parse_spec_proposals() + manifest = load_manifest() + + accepted_ids = {pid for pid, status in spec.items() if status == "Accepted"} + manifest_ids = set(manifest.keys()) + + errors: list[str] = [] + + missing = sorted(accepted_ids - manifest_ids) + for pid in missing: + errors.append(f"Accepted spec proposal {pid} has no entry in conformance.toml") + + extra = sorted(manifest_ids - accepted_ids) + for pid in extra: + if pid not in spec: + errors.append( + f"conformance.toml entry {pid} refers to a proposal that " + f"doesn't exist in openarmature-spec/proposals/" + ) + else: + errors.append( + f"conformance.toml entry {pid} refers to a proposal whose " + f"spec Status is {spec[pid]!r}, not 'Accepted' — " + f"drafts and superseded proposals should be omitted" + ) + + for pid in sorted(manifest_ids): + raw_entry = manifest[pid] + if not isinstance(raw_entry, dict): + errors.append( + f"conformance.toml entry {pid} is not a table " + f'(got {type(raw_entry).__name__}); check the [proposals."{pid}"] ' + f"section is a table, not a scalar" + ) + continue + entry = cast(dict[str, Any], raw_entry) + status = entry.get("status") + if status not in ALLOWED_STATUSES: + errors.append( + f"conformance.toml entry {pid} has unknown status {status!r} " + f"(allowed: {sorted(ALLOWED_STATUSES)})" + ) + # `since` is required for every status except `not-yet`. + since = entry.get("since") + if status == "not-yet": + if since is not None: + errors.append( + f"conformance.toml entry {pid} has status=not-yet but " + f"also a `since` field — drop `since` for not-yet entries" + ) + else: + if since is None: + errors.append(f"conformance.toml entry {pid} has status={status!r} but no `since` field") + elif not isinstance(since, str): + errors.append( + f"conformance.toml entry {pid} `since` value {since!r} is not a string " + f"(got {type(since).__name__}); did you forget to quote it?" + ) + elif not SINCE_RE.match(since): + errors.append( + f"conformance.toml entry {pid} `since` value {since!r} is not in MAJOR.MINOR.PATCH form" + ) + + if errors: + print("conformance.toml validation failed:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + + print(f"OK: {len(accepted_ids)} accepted proposals, {len(manifest_ids)} manifest entries, all consistent") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())