gabriel / musehub public
check_consumer_contract.py python
171 lines 6.3 KB
Raw
sha256:f65847c6a4ae29872f3ccbc2420f91a8948949b84a06320bdaf2ac281c89ff8a docs(F7b): governance sync — BUILT awaiting Gabriel; carry … Human patch 26 days ago
1 """Prove MuseHub still serves the exact paths the Scooling adapter requires.
2
3 Why this exists
4 ---------------
5 Scooling's ``src/adapters/museHubRepoTransport.ts`` does not merely build request
6 URLs — it *re-derives* the expected pathname and rejects any response whose path
7 does not match, so a prefix change on the MuseHub side is a hard consumer failure
8 rather than a silent fallback. The ``/api`` prefix is therefore part of a
9 cross-repo contract, not an implementation detail of ``main.py``.
10
11 This matters because Phase 9A-4 F7 was reviewed with a request to register
12 ``overseer_provenance.router`` "near the other fixed-path routers" in
13 ``main.py``. Those routers are mounted at the *root*, while the auto-discovery
14 aggregate in ``musehub/api/routes/musehub/__init__.py`` is mounted with
15 ``prefix="/api"``. Applying that request as written would expose
16 ``/overseer-run-provenance/{run_ref}``, which Scooling refuses, and duplicate the
17 already-working ``/api`` registration.
18
19 Path templates are compared against ``app.openapi()``, which is public API and
20 resolves include-prefixes correctly. Route-status probing is deliberately *not*
21 used as the gate: MuseHub declares wildcard routes (``/{owner}/{repo_slug}`` and
22 its ``/api`` twin), so almost any two-segment path returns a non-404 by
23 shadowing. Only exact template equality distinguishes "endpoint exists" from
24 "wildcard swallowed it".
25
26 Contract status
27 ---------------
28 ``required``
29 Scooling depends on this path today. A missing template fails the check.
30 ``pending``
31 The path belongs to an unmerged MuseHub branch and is tracked elsewhere. It
32 is reported but does not fail, so this gate stays green on ``dev`` while
33 still naming the outstanding consumer blocker. Flipping an entry to
34 ``required`` after its branch merges is the mechanical proof that the
35 Scooling-side blocker is genuinely cleared.
36
37 Exit codes
38 ----------
39 ``0``
40 Every ``required`` template is present. Emits ``ARTIFACT_SHA256`` over the
41 resolved contract so L1 evidence changes whenever the contract or its
42 satisfaction changes.
43 ``1``
44 A ``required`` template is missing, or a ``pending`` entry lacks a tracking
45 reference.
46 ``2``
47 Harness failure — the app or its OpenAPI schema could not be produced.
48 """
49
50 from __future__ import annotations
51
52 import hashlib
53 import json
54 import logging
55 import sys
56 from dataclasses import dataclass
57
58 REQUIRED = "required"
59 PENDING = "pending"
60
61
62 @dataclass(frozen=True)
63 class ContractPath:
64 """One path template a Scooling adapter depends on.
65
66 Attributes:
67 template: Exact OpenAPI path template, including the ``/api`` prefix.
68 method: HTTP method Scooling issues.
69 consumer: ``path:line`` in the Scooling repo that pins this template.
70 status: ``required`` or ``pending``.
71 tracking: Branch, proposal, or issue reference; mandatory when pending.
72 """
73
74 template: str
75 method: str
76 consumer: str
77 status: str
78 tracking: str = ""
79
80
81 # Derived from Scooling src/adapters/museHubRepoTransport.ts, which is the only
82 # Scooling adapter targeting MuseHub (`baseUrl`); every `/api/v1/...` adapter
83 # targets the Knowtation hub (`hubUrl`) and is out of scope here.
84 CONTRACT: tuple[ContractPath, ...] = (
85 ContractPath(
86 template="/api/repos/{repo_id}",
87 method="get",
88 consumer="src/adapters/museHubRepoTransport.ts:274,290",
89 status=REQUIRED,
90 ),
91 ContractPath(
92 template="/api/repos/{repo_id}/commits",
93 method="get",
94 consumer="src/adapters/museHubRepoTransport.ts:229,257",
95 status=REQUIRED,
96 ),
97 ContractPath(
98 template="/api/overseer-run-provenance/{run_ref}",
99 method="get",
100 consumer="src/adapters/museHubRepoTransport.ts:466,481",
101 status=PENDING,
102 tracking="branch feat/9a-4-f7-overseer-provenance (proposal #9) not yet merged to dev",
103 ),
104 )
105
106
107 def main() -> int:
108 logging.disable(logging.CRITICAL)
109 try:
110 from musehub.main import app
111
112 schema = app.openapi()
113 except Exception as exc: # noqa: BLE001 — harness reports, never raises
114 print(f"FAIL: cannot produce OpenAPI schema: {exc!r}", file=sys.stderr)
115 print("Run tools/verify/check_app_imports.py for the traceback.", file=sys.stderr)
116 return 2
117
118 paths: dict[str, dict[str, object]] = schema.get("paths", {})
119 findings: list[str] = []
120 resolved: list[str] = []
121
122 for entry in CONTRACT:
123 if entry.status not in {REQUIRED, PENDING}:
124 findings.append(f"{entry.template}: unknown status {entry.status!r}")
125 continue
126 if entry.status == PENDING and not entry.tracking.strip():
127 findings.append(f"{entry.template}: pending without a tracking reference")
128 continue
129
130 operations = paths.get(entry.template)
131 present = operations is not None and entry.method in operations
132 resolved.append(f"{entry.status} {entry.method.upper()} {entry.template} present={present}")
133
134 if present:
135 if entry.status == PENDING:
136 print(
137 f"NOTE: {entry.template} is now served but still marked pending "
138 f"({entry.tracking}) — flip it to required once merged to dev"
139 )
140 continue
141
142 if entry.status == REQUIRED:
143 near = sorted(p for p in paths if entry.template.split("{")[0] in p)[:4]
144 findings.append(
145 f"{entry.method.upper()} {entry.template} is not served — "
146 f"Scooling {entry.consumer} rejects any other path. "
147 f"Nearest served: {near or 'none'}"
148 )
149 else:
150 print(f"PENDING: {entry.method.upper()} {entry.template} — {entry.tracking}")
151
152 if findings:
153 print("FAIL: consumer contract violations", file=sys.stderr)
154 for finding in findings:
155 print(f" - {finding}", file=sys.stderr)
156 return 1
157
158 digest = hashlib.sha256(
159 json.dumps(sorted(resolved), sort_keys=True).encode("utf-8")
160 ).hexdigest()
161 required_count = sum(1 for entry in CONTRACT if entry.status == REQUIRED)
162 print(
163 f"OK: {required_count}/{len(CONTRACT)} contract path(s) required and served; "
164 f"{len(paths)} OpenAPI paths total"
165 )
166 print(f"ARTIFACT_SHA256={digest}")
167 return 0
168
169
170 if __name__ == "__main__":
171 sys.exit(main())
File History 1 commit
sha256:f65847c6a4ae29872f3ccbc2420f91a8948949b84a06320bdaf2ac281c89ff8a docs(F7b): governance sync — BUILT awaiting Gabriel; carry … Human patch 26 days ago