check-mapbox-facts.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. #!/usr/bin/env python3
  2. # Staleness verifier for the fast-moving facts the mapbox-ops skill encodes.
  3. #
  4. # Two modes (SKILL-RESOURCE-PROTOCOL.md §7):
  5. # --offline (default): NO network. Asserts the skill is internally consistent —
  6. # style-catalog.json parses, the v3 Standard config enums
  7. # (lightPreset/theme) agree between catalog and references,
  8. # the terrain tileset IDs and the weather >= 3.7 version gate
  9. # are stated consistently, no reference attributes camera roll
  10. # to Mapbox GL JS (roll is MapLibre GL JS v5 — Mapbox has none,
  11. # and freeCameraOptions orientation must be pitch+bearing-
  12. # representable), every classic style url matches its id,
  13. # every third-party entry is
  14. # addressable. Runs in PR CI and MAY block.
  15. # --live: network. Resolves the concrete third-party style-JSON URLs
  16. # and probes whether Mapbox GL JS has shipped a major beyond
  17. # v3 (which would mean the whole skill needs a review pass).
  18. # Runs in the scheduled freshness workflow and NEVER blocks a
  19. # PR: a transient network failure is UNAVAILABLE (exit 7), only
  20. # a confirmed change is DRIFT (exit 10).
  21. #
  22. # Usage: check-mapbox-facts.py [--offline|--live] [--json] [-q] [--timeout SEC]
  23. # Input: none (reads the skill's own assets/ + references/ relative to this file)
  24. # Output: stdout = data only (text findings, or the --json envelope)
  25. # Stderr: headers, progress, warnings, errors
  26. # Exit: 0 ok, 2 usage, 3 not-found (skill files missing), 4 validation
  27. # (offline inconsistency), 5 missing-dep, 7 unavailable (live network),
  28. # 10 drift (live: a URL 404'd, or GL JS major bumped past v3)
  29. #
  30. # Examples:
  31. # check-mapbox-facts.py --offline
  32. # check-mapbox-facts.py --offline --json | jq '.data[] | select(.status!="ok")'
  33. # check-mapbox-facts.py --live --timeout 15
  34. """Staleness verifier for mapbox-ops (see header comment)."""
  35. from __future__ import annotations
  36. import argparse
  37. import json
  38. import re
  39. import sys
  40. from pathlib import Path
  41. EXIT_OK = 0
  42. EXIT_USAGE = 2
  43. EXIT_NOT_FOUND = 3
  44. EXIT_VALIDATION = 4
  45. EXIT_MISSING_DEP = 5
  46. EXIT_UNAVAILABLE = 7
  47. EXIT_DRIFT = 10
  48. SCHEMA = "claude-mods.mapbox-ops.facts/v1"
  49. SKILL_ROOT = Path(__file__).resolve().parent.parent
  50. CATALOG = SKILL_ROOT / "assets" / "style-catalog.json"
  51. REFS = SKILL_ROOT / "references"
  52. SKILL_MD = SKILL_ROOT / "SKILL.md"
  53. # Facts the skill commits to. Changing these is a deliberate edit; the verifier
  54. # asserts the skill states them consistently across catalog + references.
  55. EXPECTED_LIGHT_PRESET = {"dawn", "day", "dusk", "night"}
  56. EXPECTED_THEME = {"default", "faded", "monochrome"}
  57. TERRAIN_DEM_ID = "mapbox.mapbox-terrain-dem-v1"
  58. TERRAIN_VECTOR_ID = "mapbox.mapbox-terrain-v2"
  59. GLJS_MAJOR = 3 # the skill is scoped to mapbox-gl-js v3.x
  60. class Finding:
  61. __slots__ = ("check", "status", "detail")
  62. def __init__(self, check: str, status: str, detail: str) -> None:
  63. self.check = check
  64. self.status = status # ok | fail | drift | unavailable
  65. self.detail = detail
  66. def as_dict(self) -> dict:
  67. return {"check": self.check, "status": self.status, "detail": self.detail}
  68. def read_text(path: Path) -> str:
  69. return path.read_text(encoding="utf-8", errors="replace")
  70. # --------------------------------------------------------------------------- #
  71. # Offline checks #
  72. # --------------------------------------------------------------------------- #
  73. def run_offline(findings: list[Finding]) -> None:
  74. # Required files present (else NOT_FOUND, distinct from inconsistency).
  75. missing = [p for p in (CATALOG, SKILL_MD, REFS) if not p.exists()]
  76. if missing:
  77. for p in missing:
  78. findings.append(Finding("files-present", "fail", f"missing: {p}"))
  79. raise _NotFound()
  80. # O1 — catalog parses.
  81. try:
  82. catalog = json.loads(read_text(CATALOG))
  83. findings.append(Finding("catalog-json", "ok", "style-catalog.json parses"))
  84. except json.JSONDecodeError as exc:
  85. findings.append(Finding("catalog-json", "fail", f"invalid JSON: {exc}"))
  86. return # nothing else is checkable
  87. presets = catalog.get("standard_presets", {})
  88. v3_md = read_text(REFS / "v3-standard-style.md") if (REFS / "v3-standard-style.md").exists() else ""
  89. # O2 — lightPreset enum: catalog matches the committed set AND each value is
  90. # documented in v3-standard-style.md.
  91. light = set(presets.get("lightPreset", []))
  92. if light != EXPECTED_LIGHT_PRESET:
  93. findings.append(Finding("lightPreset-enum", "fail",
  94. f"catalog {sorted(light)} != expected {sorted(EXPECTED_LIGHT_PRESET)}"))
  95. else:
  96. undoc = [v for v in EXPECTED_LIGHT_PRESET if v not in v3_md]
  97. if undoc:
  98. findings.append(Finding("lightPreset-enum", "fail",
  99. f"values not documented in v3-standard-style.md: {undoc}"))
  100. else:
  101. findings.append(Finding("lightPreset-enum", "ok", "dawn|day|dusk|night consistent"))
  102. # O3 — theme enum.
  103. theme = set(presets.get("theme", []))
  104. if theme != EXPECTED_THEME:
  105. findings.append(Finding("theme-enum", "fail",
  106. f"catalog {sorted(theme)} != expected {sorted(EXPECTED_THEME)}"))
  107. else:
  108. findings.append(Finding("theme-enum", "ok", "default|faded|monochrome consistent"))
  109. # O4 — terrain tileset IDs present in terrain.md.
  110. terrain_md = read_text(REFS / "terrain.md") if (REFS / "terrain.md").exists() else ""
  111. for tid in (TERRAIN_DEM_ID, TERRAIN_VECTOR_ID):
  112. if tid in terrain_md:
  113. findings.append(Finding(f"terrain-id:{tid}", "ok", "present in terrain.md"))
  114. else:
  115. findings.append(Finding(f"terrain-id:{tid}", "fail", "absent from terrain.md"))
  116. # O5 — weather version gate agrees between catalog effects comment and dataviz ref.
  117. effects_comment = catalog.get("effects", {}).get("_comment", "")
  118. dataviz_md = read_text(REFS / "dataviz-and-3d.md") if (REFS / "dataviz-and-3d.md").exists() else ""
  119. cat_ver = _first_gl_gate(effects_comment)
  120. ref_ver = _first_gl_gate(dataviz_md, near="setRain") or _first_gl_gate(dataviz_md, near="Weather")
  121. if cat_ver and ref_ver and cat_ver == ref_ver == "3.7":
  122. findings.append(Finding("weather-gate", "ok", "GL JS >= 3.7 consistent (catalog + dataviz-and-3d.md)"))
  123. else:
  124. findings.append(Finding("weather-gate", "fail",
  125. f"weather version gate mismatch (catalog={cat_ver!r}, ref={ref_ver!r}, want 3.7)"))
  126. # O6 — no native camera roll. Mapbox GL JS has no roll on any camera API
  127. # (FreeCameraOptions orientation "must be representable using only pitch and
  128. # bearing"); true roll is MapLibre GL JS v5. The reference must not attribute
  129. # a roll camera option / setRoll to Mapbox, and must keep the MapLibre pointer
  130. # for anyone who needs real roll.
  131. camera_md = read_text(REFS / "camera-and-animation.md") if (REFS / "camera-and-animation.md").exists() else ""
  132. cam_lines = camera_md.splitlines()
  133. roll_api = re.compile(r"setRoll|\{[^}]*\broll\b|\broll\s*:")
  134. misattributed = []
  135. for i, ln in enumerate(cam_lines):
  136. if roll_api.search(ln):
  137. context = " ".join(cam_lines[max(0, i - 2):i + 2])
  138. if "maplibre" not in context.lower():
  139. misattributed.append(ln.strip())
  140. if misattributed:
  141. findings.append(Finding("no-native-roll", "fail",
  142. "roll API attributed to Mapbox GL JS (it has none): "
  143. + " | ".join(misattributed[:3])))
  144. elif "maplibre" not in camera_md.lower():
  145. findings.append(Finding("no-native-roll", "fail",
  146. "MapLibre-v5 roll pointer missing from camera-and-animation.md"))
  147. else:
  148. findings.append(Finding("no-native-roll", "ok",
  149. "no Mapbox roll claim; MapLibre v5 pointer present"))
  150. # O7 — GL JS major scope: SKILL.md says v3.
  151. skill_md = read_text(SKILL_MD)
  152. if re.search(rf"v{GLJS_MAJOR}\.x", skill_md) and re.search(rf"v{GLJS_MAJOR}\b", skill_md):
  153. findings.append(Finding("gljs-major", "ok", f"skill scoped to v{GLJS_MAJOR}.x"))
  154. else:
  155. findings.append(Finding("gljs-major", "fail", f"SKILL.md no longer clearly scopes v{GLJS_MAJOR}.x"))
  156. # O8 — every classic style url tail matches its id.
  157. bad_urls = []
  158. for s in catalog.get("styles", []):
  159. sid, url = s.get("id", ""), s.get("url", "")
  160. if not url.endswith("/" + sid) and not url.endswith(sid):
  161. bad_urls.append(f"{sid} -> {url}")
  162. if bad_urls:
  163. findings.append(Finding("style-url-id", "fail", "url/id mismatch: " + "; ".join(bad_urls)))
  164. else:
  165. findings.append(Finding("style-url-id", "ok", f"{len(catalog.get('styles', []))} style urls match ids"))
  166. # O9 — every third-party entry is addressable (has a url or an explanatory note).
  167. unaddressable = [t.get("id", "?") for t in catalog.get("third_party", [])
  168. if not t.get("url") and not t.get("note")]
  169. if unaddressable:
  170. findings.append(Finding("third-party-addressable", "fail",
  171. "no url and no note: " + ", ".join(unaddressable)))
  172. else:
  173. findings.append(Finding("third-party-addressable", "ok",
  174. f"{len(catalog.get('third_party', []))} third-party entries addressable"))
  175. def _first_gl_gate(text: str, near: str | None = None) -> str | None:
  176. """Return the first 'GL JS >= 3.N' version found, optionally on a line mentioning `near`."""
  177. pat = re.compile(r"(?:GL JS\s*)?[>≥]=?\s*(3\.\d+)")
  178. if near:
  179. for line in text.splitlines():
  180. if near in line:
  181. m = pat.search(line)
  182. if m:
  183. return m.group(1)
  184. return None
  185. m = pat.search(text)
  186. return m.group(1) if m else None
  187. class _NotFound(Exception):
  188. pass
  189. # --------------------------------------------------------------------------- #
  190. # Live checks #
  191. # --------------------------------------------------------------------------- #
  192. def run_live(findings: list[Finding], timeout: float) -> None:
  193. import urllib.error
  194. import urllib.request
  195. try:
  196. catalog = json.loads(read_text(CATALOG))
  197. except (OSError, json.JSONDecodeError) as exc:
  198. findings.append(Finding("catalog-json", "fail", f"cannot read catalog: {exc}"))
  199. raise _NotFound()
  200. def probe(url: str) -> str:
  201. """Return resolved | notfound | unavailable for a URL (HEAD, GET fallback)."""
  202. for method in ("HEAD", "GET"):
  203. req = urllib.request.Request(url, method=method,
  204. headers={"User-Agent": "mapbox-ops-staleness/1"})
  205. try:
  206. with urllib.request.urlopen(req, timeout=timeout) as resp:
  207. return "resolved" if resp.status < 400 else "unavailable"
  208. except urllib.error.HTTPError as e:
  209. if e.code in (404, 410):
  210. return "notfound"
  211. if e.code in (403, 405, 429):
  212. # forbidden/method-not-allowed/rate-limited: exists or can't tell.
  213. if method == "HEAD":
  214. continue # retry with GET
  215. return "unavailable" if e.code == 429 else "resolved"
  216. return "unavailable"
  217. except (urllib.error.URLError, TimeoutError, OSError):
  218. return "unavailable"
  219. return "unavailable"
  220. # L1 — concrete third-party style URLs (skip templated/keyed ones).
  221. for t in catalog.get("third_party", []):
  222. url = t.get("url")
  223. if not url or "<" in url or "key=" in url:
  224. continue
  225. res = probe(url)
  226. status = {"resolved": "ok", "notfound": "drift", "unavailable": "unavailable"}[res]
  227. findings.append(Finding(f"url:{t.get('id', url)}", status, url))
  228. # L2 — has Mapbox GL JS shipped a major beyond v3? A live v4.0.0 on the CDN
  229. # means the skill's scope assumption needs a human review pass (drift, not error).
  230. cdn = "https://api.mapbox.com/mapbox-gl-js/v{}.0.0/mapbox-gl.js"
  231. v3 = probe(cdn.format(GLJS_MAJOR))
  232. if v3 == "unavailable":
  233. findings.append(Finding("gljs-cdn", "unavailable", "Mapbox CDN unreachable"))
  234. else:
  235. nxt = probe(cdn.format(GLJS_MAJOR + 1))
  236. if nxt == "resolved":
  237. findings.append(Finding("gljs-major-bump", "drift",
  238. f"mapbox-gl-js v{GLJS_MAJOR + 1}.0.0 is live — review skill scope"))
  239. elif nxt == "unavailable":
  240. findings.append(Finding("gljs-major-bump", "unavailable",
  241. f"could not probe v{GLJS_MAJOR + 1} (network)"))
  242. else:
  243. findings.append(Finding("gljs-major-bump", "ok",
  244. f"v{GLJS_MAJOR} current; no v{GLJS_MAJOR + 1} GA"))
  245. # --------------------------------------------------------------------------- #
  246. # Main #
  247. # --------------------------------------------------------------------------- #
  248. def main(argv: list[str]) -> int:
  249. ap = argparse.ArgumentParser(add_help=True, description="mapbox-ops staleness verifier")
  250. mode = ap.add_mutually_exclusive_group()
  251. mode.add_argument("--offline", action="store_true", help="structural/internal-consistency only (default)")
  252. mode.add_argument("--live", action="store_true", help="resolve URLs + probe GL JS major (network)")
  253. ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
  254. ap.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress")
  255. ap.add_argument("--timeout", type=float, default=10.0, help="per-request timeout for --live (seconds)")
  256. try:
  257. args = ap.parse_args(argv)
  258. except SystemExit as e:
  259. # argparse exits 2 on bad args (matches USAGE); 0 on --help.
  260. return EXIT_USAGE if e.code not in (0, None) else EXIT_OK
  261. live = args.live
  262. mode_name = "live" if live else "offline"
  263. def emit(msg: str) -> None:
  264. if not args.quiet:
  265. print(msg, file=sys.stderr)
  266. findings: list[Finding] = []
  267. emit(f"== check-mapbox-facts ({mode_name}) ==")
  268. try:
  269. if live:
  270. run_live(findings, args.timeout)
  271. else:
  272. run_offline(findings)
  273. except _NotFound:
  274. if args.json:
  275. print(json.dumps({"error": {"code": "NOT_FOUND",
  276. "message": "skill files missing",
  277. "details": [f.as_dict() for f in findings]}}))
  278. for f in findings:
  279. emit(f" [{f.status.upper()}] {f.check}: {f.detail}")
  280. return EXIT_NOT_FOUND
  281. n_fail = sum(1 for f in findings if f.status == "fail")
  282. n_drift = sum(1 for f in findings if f.status == "drift")
  283. n_unavail = sum(1 for f in findings if f.status == "unavailable")
  284. # Output: stdout is data only.
  285. if args.json:
  286. print(json.dumps({
  287. "data": [f.as_dict() for f in findings],
  288. "meta": {"mode": mode_name, "count": len(findings),
  289. "fail": n_fail, "drift": n_drift, "unavailable": n_unavail,
  290. "schema": SCHEMA},
  291. }, indent=2))
  292. else:
  293. for f in findings:
  294. print(f"{f.check}\t{f.status}\t{f.detail}")
  295. # Progress summary to stderr.
  296. for f in findings:
  297. if f.status != "ok":
  298. emit(f" [{f.status.upper()}] {f.check}: {f.detail}")
  299. emit(f"-- {len(findings)} checks: {n_fail} fail, {n_drift} drift, {n_unavail} unavailable")
  300. # Exit precedence: a real inconsistency (offline) or 404 (live) is the loudest
  301. # signal; an unavailable network is advisory and must never mask a clean run as
  302. # failing — but if the ONLY non-ok results are unavailable, exit 7, never 0.
  303. if n_fail:
  304. return EXIT_VALIDATION
  305. if n_drift:
  306. return EXIT_DRIFT
  307. if n_unavail:
  308. return EXIT_UNAVAILABLE
  309. return EXIT_OK
  310. if __name__ == "__main__":
  311. sys.exit(main(sys.argv[1:]))