check-three-facts.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. #!/usr/bin/env python3
  2. # Staleness verifier for the fast-moving facts the threejs-ops skill encodes.
  3. #
  4. # Two modes (SKILL-RESOURCE-PROTOCOL.md §7):
  5. # --offline (default): NO network. Asserts the skill is internally consistent —
  6. # assets/three-facts.json parses, the version gates
  7. # (examples/js removed r148, UMD builds removed r160) are
  8. # stated in SKILL.md, the npm 0.<release> scheme example is
  9. # arithmetically coherent, every package the facts file
  10. # commits to is documented somewhere in the skill, and the
  11. # importmap-starter.html import map parses with both "three"
  12. # entries pinned to the same version. Runs in PR CI, MAY block.
  13. # --live: network. Probes the npm registry: every committed package
  14. # must still resolve (a 404 means renamed/removed = drift),
  15. # and three's latest dist-tag must still use major 0
  16. # (a 1.x would be the API-break signal that the whole skill
  17. # needs a review pass). Runs in the scheduled freshness
  18. # workflow and NEVER blocks a PR: transient network failure
  19. # is UNAVAILABLE (exit 7); only confirmed change is DRIFT (10).
  20. #
  21. # Usage: check-three-facts.py [--offline|--live] [--json] [-q] [--timeout SEC]
  22. # Input: none (reads the skill's own assets/ + references/ relative to this file)
  23. # Output: stdout = data only (TSV findings, or the --json envelope)
  24. # Stderr: headers, progress, warnings, errors
  25. # Exit: 0 ok, 2 usage, 3 not-found (skill files missing), 4 validation
  26. # (offline inconsistency), 7 unavailable (live network), 10 drift
  27. #
  28. # Examples:
  29. # check-three-facts.py --offline
  30. # check-three-facts.py --offline --json | jq '.data[] | select(.status!="ok")'
  31. # check-three-facts.py --live --timeout 15
  32. """Staleness verifier for threejs-ops (see header comment)."""
  33. from __future__ import annotations
  34. import argparse
  35. import json
  36. import re
  37. import sys
  38. from pathlib import Path
  39. from urllib.parse import quote
  40. EXIT_OK = 0
  41. EXIT_USAGE = 2
  42. EXIT_NOT_FOUND = 3
  43. EXIT_VALIDATION = 4
  44. EXIT_UNAVAILABLE = 7
  45. EXIT_DRIFT = 10
  46. SCHEMA = "claude-mods.threejs-ops.facts/v1"
  47. SKILL_ROOT = Path(__file__).resolve().parent.parent
  48. FACTS = SKILL_ROOT / "assets" / "three-facts.json"
  49. STARTER = SKILL_ROOT / "assets" / "importmap-starter.html"
  50. REFS = SKILL_ROOT / "references"
  51. SKILL_MD = SKILL_ROOT / "SKILL.md"
  52. REGISTRY = "https://registry.npmjs.org"
  53. class Finding:
  54. __slots__ = ("check", "status", "detail")
  55. def __init__(self, check: str, status: str, detail: str) -> None:
  56. self.check = check
  57. self.status = status # ok | fail | drift | unavailable
  58. self.detail = detail
  59. def as_dict(self) -> dict:
  60. return {"check": self.check, "status": self.status, "detail": self.detail}
  61. class _NotFound(Exception):
  62. pass
  63. def read_text(path: Path) -> str:
  64. return path.read_text(encoding="utf-8", errors="replace")
  65. def load_facts(findings: list[Finding]) -> dict | None:
  66. try:
  67. facts = json.loads(read_text(FACTS))
  68. findings.append(Finding("facts-json", "ok", "three-facts.json parses"))
  69. return facts
  70. except json.JSONDecodeError as exc:
  71. findings.append(Finding("facts-json", "fail", f"invalid JSON: {exc}"))
  72. return None
  73. # --------------------------------------------------------------------------- #
  74. # Offline checks #
  75. # --------------------------------------------------------------------------- #
  76. def run_offline(findings: list[Finding]) -> None:
  77. missing = [p for p in (FACTS, STARTER, SKILL_MD, REFS) if not p.exists()]
  78. if missing:
  79. for p in missing:
  80. findings.append(Finding("files-present", "fail", f"missing: {p}"))
  81. raise _NotFound()
  82. facts = load_facts(findings)
  83. if facts is None:
  84. return # nothing else is checkable
  85. skill_md = read_text(SKILL_MD)
  86. all_docs = skill_md + "".join(read_text(p) for p in sorted(REFS.glob("*.md")))
  87. # O1 — schema + as_of stamped.
  88. if facts.get("schema") == SCHEMA and re.match(r"\d{4}-\d{2}-\d{2}$", facts.get("as_of", "")):
  89. findings.append(Finding("facts-meta", "ok", f"schema {SCHEMA}, as_of {facts['as_of']}"))
  90. else:
  91. findings.append(Finding("facts-meta", "fail",
  92. f"schema={facts.get('schema')!r} as_of={facts.get('as_of')!r}"))
  93. # O2 — every version gate the facts commit to is stated in SKILL.md.
  94. gates = facts.get("version_gates", {})
  95. gate_items = [(k, v) for k, v in gates.items() if k != "_comment"]
  96. if len(gate_items) < 2:
  97. findings.append(Finding("gates", "fail", f"expected >= 2 version gates, got {len(gate_items)}"))
  98. for key, gate in gate_items:
  99. if not gate or not re.match(r"r\d{3}$", str(gate)):
  100. findings.append(Finding(f"gate:{key}", "fail", f"malformed gate {gate!r} in facts"))
  101. elif re.search(rf"\*\*{gate}\*\*|\b{gate}\b", skill_md):
  102. findings.append(Finding(f"gate:{key}", "ok", f"{gate} stated in SKILL.md"))
  103. else:
  104. findings.append(Finding(f"gate:{key}", "fail", f"{gate} not stated in SKILL.md"))
  105. # O3 — npm scheme example arithmetic: "three@0.NNN.p is rNNN".
  106. scheme = facts.get("npm_scheme", {})
  107. m = re.match(r"three@0\.(\d+)\.\d+ is r(\d+)$", scheme.get("example", ""))
  108. if m and m.group(1) == m.group(2) and scheme.get("major") == 0:
  109. findings.append(Finding("npm-scheme", "ok", f"example coherent ({scheme['example']})"))
  110. else:
  111. findings.append(Finding("npm-scheme", "fail",
  112. f"example {scheme.get('example')!r} incoherent or major != 0"))
  113. # O4 — every committed package is documented somewhere in the skill prose.
  114. pkgs = facts.get("packages", {})
  115. if not pkgs:
  116. findings.append(Finding("packages", "fail", "facts commit to zero packages"))
  117. for name in pkgs:
  118. if name in all_docs:
  119. findings.append(Finding(f"pkg-documented:{name}", "ok", "mentioned in SKILL.md/references"))
  120. else:
  121. findings.append(Finding(f"pkg-documented:{name}", "fail",
  122. "in facts but never mentioned in skill prose"))
  123. # O5 — importmap starter: the import map parses, has both entries, pins one version.
  124. starter = read_text(STARTER)
  125. im = re.search(r'<script type="importmap">\s*(\{.*?\})\s*</script>', starter, re.S)
  126. if not im:
  127. findings.append(Finding("importmap", "fail", "no importmap block in starter"))
  128. else:
  129. try:
  130. imports = json.loads(im.group(1))["imports"]
  131. three_url = imports.get("three", "")
  132. addons_url = imports.get("three/addons/", "")
  133. v_three = re.search(r"three@(0\.\d+\.\d+)/", three_url)
  134. v_addons = re.search(r"three@(0\.\d+\.\d+)/", addons_url)
  135. if not (v_three and v_addons):
  136. findings.append(Finding("importmap", "fail",
  137. "starter import map missing pinned three/addons entries"))
  138. elif v_three.group(1) != v_addons.group(1):
  139. findings.append(Finding("importmap", "fail",
  140. f"version mismatch: three@{v_three.group(1)} vs addons@{v_addons.group(1)}"))
  141. elif not addons_url.endswith("/"):
  142. findings.append(Finding("importmap", "fail", "three/addons/ URL missing trailing slash"))
  143. else:
  144. findings.append(Finding("importmap", "ok",
  145. f"both entries pinned to three@{v_three.group(1)}"))
  146. except (json.JSONDecodeError, KeyError) as exc:
  147. findings.append(Finding("importmap", "fail", f"import map does not parse: {exc}"))
  148. # O6 — every cc0 source / reference repo has an https url.
  149. for group in ("cc0_sources", "reference_repos"):
  150. bad = [e.get("id", "?") for e in facts.get(group, [])
  151. if not str(e.get("url", "")).startswith("https://")]
  152. if bad:
  153. findings.append(Finding(group, "fail", "no https url: " + ", ".join(bad)))
  154. else:
  155. findings.append(Finding(group, "ok", f"{len(facts.get(group, []))} entries addressable"))
  156. # --------------------------------------------------------------------------- #
  157. # Live checks #
  158. # --------------------------------------------------------------------------- #
  159. def run_live(findings: list[Finding], timeout: float) -> None:
  160. import urllib.error
  161. import urllib.request
  162. facts = load_facts(findings)
  163. if facts is None:
  164. raise _NotFound()
  165. def fetch_latest(pkg: str) -> tuple[str, dict | None]:
  166. """Return (resolved|notfound|unavailable, latest-manifest-or-None)."""
  167. url = f"{REGISTRY}/{quote(pkg, safe='')}/latest"
  168. req = urllib.request.Request(url, headers={"User-Agent": "threejs-ops-staleness/1",
  169. "Accept": "application/json"})
  170. try:
  171. with urllib.request.urlopen(req, timeout=timeout) as resp:
  172. if resp.status >= 400:
  173. return "unavailable", None
  174. return "resolved", json.loads(resp.read().decode("utf-8"))
  175. except urllib.error.HTTPError as e:
  176. if e.code in (404, 410):
  177. return "notfound", None
  178. return "unavailable", None
  179. except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
  180. return "unavailable", None
  181. # L1 — every committed package still resolves on npm.
  182. for name in facts.get("packages", {}):
  183. res, manifest = fetch_latest(name)
  184. if res == "resolved":
  185. findings.append(Finding(f"npm:{name}", "ok",
  186. f"latest {(manifest or {}).get('version', '?')}"))
  187. elif res == "notfound":
  188. findings.append(Finding(f"npm:{name}", "drift",
  189. "package gone from npm — renamed/removed, review skill"))
  190. else:
  191. findings.append(Finding(f"npm:{name}", "unavailable", "registry unreachable"))
  192. # L2 — three's versioning scheme still major-0 (a 1.x = API-break signal).
  193. res, manifest = fetch_latest("three")
  194. if res == "resolved":
  195. ver = (manifest or {}).get("version", "")
  196. if ver.startswith("0."):
  197. findings.append(Finding("three-major", "ok", f"three@{ver} still 0.<release> scheme"))
  198. else:
  199. findings.append(Finding("three-major", "drift",
  200. f"three@{ver} left major 0 — review the whole skill"))
  201. elif res == "notfound":
  202. findings.append(Finding("three-major", "drift", "npm has no 'three' package (!)"))
  203. else:
  204. findings.append(Finding("three-major", "unavailable", "registry unreachable"))
  205. # --------------------------------------------------------------------------- #
  206. # Main #
  207. # --------------------------------------------------------------------------- #
  208. def main(argv: list[str]) -> int:
  209. ap = argparse.ArgumentParser(add_help=True, description="threejs-ops staleness verifier")
  210. mode = ap.add_mutually_exclusive_group()
  211. mode.add_argument("--offline", action="store_true", help="structural/internal-consistency only (default)")
  212. mode.add_argument("--live", action="store_true", help="probe the npm registry (network)")
  213. ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
  214. ap.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress")
  215. ap.add_argument("--timeout", type=float, default=10.0, help="per-request timeout for --live (seconds)")
  216. try:
  217. args = ap.parse_args(argv)
  218. except SystemExit as e:
  219. # argparse exits 2 on bad args (matches USAGE); 0 on --help.
  220. return EXIT_USAGE if e.code not in (0, None) else EXIT_OK
  221. mode_name = "live" if args.live else "offline"
  222. def emit(msg: str) -> None:
  223. if not args.quiet:
  224. print(msg, file=sys.stderr)
  225. findings: list[Finding] = []
  226. emit(f"== check-three-facts ({mode_name}) ==")
  227. try:
  228. if args.live:
  229. run_live(findings, args.timeout)
  230. else:
  231. run_offline(findings)
  232. except _NotFound:
  233. if args.json:
  234. print(json.dumps({"error": {"code": "NOT_FOUND",
  235. "message": "skill files missing",
  236. "details": [f.as_dict() for f in findings]}}))
  237. for f in findings:
  238. emit(f" [{f.status.upper()}] {f.check}: {f.detail}")
  239. return EXIT_NOT_FOUND
  240. n_fail = sum(1 for f in findings if f.status == "fail")
  241. n_drift = sum(1 for f in findings if f.status == "drift")
  242. n_unavail = sum(1 for f in findings if f.status == "unavailable")
  243. # Output: stdout is data only.
  244. if args.json:
  245. print(json.dumps({
  246. "data": [f.as_dict() for f in findings],
  247. "meta": {"mode": mode_name, "count": len(findings),
  248. "fail": n_fail, "drift": n_drift, "unavailable": n_unavail,
  249. "schema": SCHEMA},
  250. }, indent=2))
  251. else:
  252. for f in findings:
  253. print(f"{f.check}\t{f.status}\t{f.detail}")
  254. # Progress summary to stderr.
  255. for f in findings:
  256. if f.status != "ok":
  257. emit(f" [{f.status.upper()}] {f.check}: {f.detail}")
  258. emit(f"-- {len(findings)} checks: {n_fail} fail, {n_drift} drift, {n_unavail} unavailable")
  259. # Exit precedence: inconsistency (offline) beats drift beats unavailable;
  260. # if the ONLY non-ok results are unavailable, exit 7, never 0.
  261. if n_fail:
  262. return EXIT_VALIDATION
  263. if n_drift:
  264. return EXIT_DRIFT
  265. if n_unavail:
  266. return EXIT_UNAVAILABLE
  267. return EXIT_OK
  268. if __name__ == "__main__":
  269. sys.exit(main(sys.argv[1:]))