|
| 1 | +"""Validate conformance.toml against the pinned spec submodule's proposals. |
| 2 | +
|
| 3 | +Failure modes caught: |
| 4 | +
|
| 5 | +- Accepted proposal in the spec has no entry in conformance.toml. |
| 6 | +- Entry in conformance.toml refers to a proposal that doesn't exist in |
| 7 | + the spec, or refers to a proposal whose Status is not "Accepted" |
| 8 | + (e.g., Draft / Superseded — those are deliberately excluded from the |
| 9 | + manifest so the docs site doesn't claim impl status for unsettled |
| 10 | + proposals). |
| 11 | +- Entry has an unknown `status` value or a malformed `since` version. |
| 12 | +
|
| 13 | +Read-only; intended for CI. Non-zero exit on any failure with a |
| 14 | +human-readable diff. Runs under the repo's stdlib Python (>=3.12, so |
| 15 | +`tomllib` is always available). |
| 16 | +""" |
| 17 | + |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import re |
| 21 | +import sys |
| 22 | +import tomllib |
| 23 | +from pathlib import Path |
| 24 | +from typing import Any, cast |
| 25 | + |
| 26 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 27 | +MANIFEST_PATH = REPO_ROOT / "conformance.toml" |
| 28 | +PROPOSALS_DIR = REPO_ROOT / "openarmature-spec" / "proposals" |
| 29 | + |
| 30 | +ALLOWED_STATUSES = frozenset({"implemented", "partial", "textual-only", "not-yet"}) |
| 31 | +PROPOSAL_FILENAME_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.md$") |
| 32 | +STATUS_LINE_RE = re.compile(r"^- \*\*Status:\*\*\s*(.+?)\s*$", re.MULTILINE) |
| 33 | +SINCE_RE = re.compile(r"^\d+\.\d+\.\d+$") |
| 34 | + |
| 35 | + |
| 36 | +def parse_spec_proposals() -> dict[str, str]: |
| 37 | + # Returns {proposal_id: status} for every proposal markdown file in |
| 38 | + # the pinned spec submodule. proposal_id is the 4-digit string used |
| 39 | + # as the manifest key; status is the literal value from the file's |
| 40 | + # `- **Status:** ...` header line. |
| 41 | + if not PROPOSALS_DIR.is_dir(): |
| 42 | + sys.exit( |
| 43 | + f"::error::proposals dir not found at {PROPOSALS_DIR} — " |
| 44 | + "is the openarmature-spec submodule checked out?" |
| 45 | + ) |
| 46 | + |
| 47 | + result: dict[str, str] = {} |
| 48 | + for path in sorted(PROPOSALS_DIR.iterdir()): |
| 49 | + m = PROPOSAL_FILENAME_RE.match(path.name) |
| 50 | + if not m: |
| 51 | + continue |
| 52 | + proposal_id = m.group(1) |
| 53 | + text = path.read_text(encoding="utf-8") |
| 54 | + status_match = STATUS_LINE_RE.search(text) |
| 55 | + if not status_match: |
| 56 | + sys.exit(f"::error::proposal {proposal_id} ({path.name}) has no `- **Status:** ...` header line") |
| 57 | + result[proposal_id] = status_match.group(1).strip() |
| 58 | + return result |
| 59 | + |
| 60 | + |
| 61 | +def load_manifest() -> dict[str, Any]: |
| 62 | + # Returns {proposal_id: entry} for every [proposals."NNNN"] section |
| 63 | + # in conformance.toml. Each `entry` is typed as Any rather than |
| 64 | + # dict because TOML lets a user accidentally write a scalar value |
| 65 | + # under [proposals]; the caller validates the type per-entry and |
| 66 | + # emits a structured error on shape drift. |
| 67 | + if not MANIFEST_PATH.is_file(): |
| 68 | + sys.exit(f"::error::manifest not found at {MANIFEST_PATH}") |
| 69 | + |
| 70 | + with MANIFEST_PATH.open("rb") as f: |
| 71 | + data = tomllib.load(f) |
| 72 | + |
| 73 | + proposals = data.get("proposals", {}) |
| 74 | + if not isinstance(proposals, dict): |
| 75 | + sys.exit("::error::conformance.toml [proposals] table malformed") |
| 76 | + return cast(dict[str, Any], proposals) |
| 77 | + |
| 78 | + |
| 79 | +def main() -> int: |
| 80 | + spec = parse_spec_proposals() |
| 81 | + manifest = load_manifest() |
| 82 | + |
| 83 | + accepted_ids = {pid for pid, status in spec.items() if status == "Accepted"} |
| 84 | + manifest_ids = set(manifest.keys()) |
| 85 | + |
| 86 | + errors: list[str] = [] |
| 87 | + |
| 88 | + missing = sorted(accepted_ids - manifest_ids) |
| 89 | + for pid in missing: |
| 90 | + errors.append(f"Accepted spec proposal {pid} has no entry in conformance.toml") |
| 91 | + |
| 92 | + extra = sorted(manifest_ids - accepted_ids) |
| 93 | + for pid in extra: |
| 94 | + if pid not in spec: |
| 95 | + errors.append( |
| 96 | + f"conformance.toml entry {pid} refers to a proposal that " |
| 97 | + f"doesn't exist in openarmature-spec/proposals/" |
| 98 | + ) |
| 99 | + else: |
| 100 | + errors.append( |
| 101 | + f"conformance.toml entry {pid} refers to a proposal whose " |
| 102 | + f"spec Status is {spec[pid]!r}, not 'Accepted' — " |
| 103 | + f"drafts and superseded proposals should be omitted" |
| 104 | + ) |
| 105 | + |
| 106 | + for pid in sorted(manifest_ids): |
| 107 | + raw_entry = manifest[pid] |
| 108 | + if not isinstance(raw_entry, dict): |
| 109 | + errors.append( |
| 110 | + f"conformance.toml entry {pid} is not a table " |
| 111 | + f'(got {type(raw_entry).__name__}); check the [proposals."{pid}"] ' |
| 112 | + f"section is a table, not a scalar" |
| 113 | + ) |
| 114 | + continue |
| 115 | + entry = cast(dict[str, Any], raw_entry) |
| 116 | + status = entry.get("status") |
| 117 | + if status not in ALLOWED_STATUSES: |
| 118 | + errors.append( |
| 119 | + f"conformance.toml entry {pid} has unknown status {status!r} " |
| 120 | + f"(allowed: {sorted(ALLOWED_STATUSES)})" |
| 121 | + ) |
| 122 | + # `since` is required for every status except `not-yet`. |
| 123 | + since = entry.get("since") |
| 124 | + if status == "not-yet": |
| 125 | + if since is not None: |
| 126 | + errors.append( |
| 127 | + f"conformance.toml entry {pid} has status=not-yet but " |
| 128 | + f"also a `since` field — drop `since` for not-yet entries" |
| 129 | + ) |
| 130 | + else: |
| 131 | + if since is None: |
| 132 | + errors.append(f"conformance.toml entry {pid} has status={status!r} but no `since` field") |
| 133 | + elif not isinstance(since, str): |
| 134 | + errors.append( |
| 135 | + f"conformance.toml entry {pid} `since` value {since!r} is not a string " |
| 136 | + f"(got {type(since).__name__}); did you forget to quote it?" |
| 137 | + ) |
| 138 | + elif not SINCE_RE.match(since): |
| 139 | + errors.append( |
| 140 | + f"conformance.toml entry {pid} `since` value {since!r} is not in MAJOR.MINOR.PATCH form" |
| 141 | + ) |
| 142 | + |
| 143 | + if errors: |
| 144 | + print("conformance.toml validation failed:", file=sys.stderr) |
| 145 | + for err in errors: |
| 146 | + print(f" - {err}", file=sys.stderr) |
| 147 | + return 1 |
| 148 | + |
| 149 | + print(f"OK: {len(accepted_ids)} accepted proposals, {len(manifest_ids)} manifest entries, all consistent") |
| 150 | + return 0 |
| 151 | + |
| 152 | + |
| 153 | +if __name__ == "__main__": |
| 154 | + raise SystemExit(main()) |
0 commit comments