check-cloudflare-facts.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. #!/usr/bin/env python3
  2. """Staleness verifier for cloudflare-ops: the Wrangler major line, the
  3. recommended compatibility_date, and the config filename convention must stay
  4. real, stated, and current.
  5. cloudflare-ops assumes Wrangler v4.x, recommends a `wrangler.jsonc` config,
  6. and pins a specific `compatibility_date` in its skeleton. Those are exactly
  7. the facts that drift silently (SKILL-RESOURCE-PROTOCOL.md §7): Wrangler ships
  8. a v5 and the old `wrangler publish`-era advice rots, the prose stops naming
  9. `wrangler.jsonc`, or the compatibility_date sits stale — and nobody notices
  10. for months. Two modes:
  11. --offline (default, safe for PR CI): structural consistency, no network.
  12. * assets/cloudflare-facts.json parses and carries the schema + an as_of date
  13. * every catalogued fact's prose_token is still named in the skill prose
  14. (SKILL.md + references/*.md + assets/wrangler.jsonc.template)
  15. * SKILL.md still carries a dated "verified as of <year>" currency note
  16. --live (scheduled freshness job, never a PR gate): does Wrangler still
  17. resolve on npm, and has its major moved off the documented v4 line?
  18. Usage: check-cloudflare-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S]
  19. Input: argv flags only (no stdin).
  20. Output: stdout = findings (plain rows, or a --json envelope). Data only.
  21. Stderr: the verdict line, notices, errors.
  22. Exit: 0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable,
  23. 7 npm unreachable (live, advisory — never a real failure),
  24. 10 drift found (offline: fact no longer named / currency note gone;
  25. live: wrangler gone from npm or major drifted off v4)
  26. Examples:
  27. check-cloudflare-facts.py --offline # PR CI: facts ⇆ prose consistency
  28. check-cloudflare-facts.py --live # weekly: wrangler still v4 on npm?
  29. check-cloudflare-facts.py --offline --json | jq '.data[]'
  30. """
  31. from __future__ import annotations
  32. import argparse
  33. import json
  34. import re
  35. import sys
  36. import urllib.error
  37. import urllib.parse
  38. import urllib.request
  39. from pathlib import Path
  40. EX_OK = 0
  41. EX_USAGE = 2
  42. EX_NOTFOUND = 3
  43. EX_UNPARSEABLE = 4
  44. EX_UNAVAILABLE = 7
  45. EX_DRIFT = 10
  46. SCHEMA = "claude-mods.cloudflare-ops.facts/v1"
  47. HERE = Path(__file__).resolve().parent
  48. DEFAULT_CATALOG = HERE.parent / "assets" / "cloudflare-facts.json"
  49. DEFAULT_SKILL = HERE.parent
  50. NPM_REGISTRY = "https://registry.npmjs.org"
  51. CURRENCY_RE = re.compile(r"verified as of\s+(\d{4})", re.IGNORECASE)
  52. AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
  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 | drift | unavailable
  58. self.detail = detail
  59. def as_dict(self) -> dict:
  60. return {"check": self.check, "status": self.status, "detail": self.detail}
  61. def load_catalog(path: Path) -> dict:
  62. if not path.is_file():
  63. print(f"error: facts catalog not found: {path}", file=sys.stderr)
  64. raise SystemExit(EX_NOTFOUND)
  65. try:
  66. data = json.loads(path.read_text(encoding="utf-8"))
  67. if not isinstance(data, dict) or data.get("schema") != SCHEMA:
  68. raise ValueError(f"schema must be {SCHEMA!r}")
  69. if not AS_OF_RE.match(str(data.get("as_of", ""))):
  70. raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}")
  71. for key in ("wrangler", "compatibility_date", "config_format"):
  72. if not isinstance(data.get(key), dict) or "prose_token" not in data[key]:
  73. raise ValueError(f"fact {key!r} missing prose_token")
  74. return data
  75. except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
  76. print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr)
  77. raise SystemExit(EX_UNPARSEABLE)
  78. def read_corpus(skill_dir: Path) -> tuple[str, str]:
  79. """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md
  80. + assets/wrangler.jsonc.template (the compatibility_date pin lives there)."""
  81. doc = skill_dir / "SKILL.md"
  82. if not doc.is_file():
  83. print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
  84. raise SystemExit(EX_NOTFOUND)
  85. skill_md = doc.read_text(encoding="utf-8", errors="replace")
  86. parts = [skill_md]
  87. ref_dir = skill_dir / "references"
  88. if ref_dir.is_dir():
  89. for ref in sorted(ref_dir.glob("*.md")):
  90. parts.append(ref.read_text(encoding="utf-8", errors="replace"))
  91. template = skill_dir / "assets" / "wrangler.jsonc.template"
  92. if template.is_file():
  93. parts.append(template.read_text(encoding="utf-8", errors="replace"))
  94. return skill_md, "\n".join(parts)
  95. def check_offline(catalog: dict, skill_dir: Path) -> list[Finding]:
  96. skill_md, corpus = read_corpus(skill_dir)
  97. lower = corpus.lower()
  98. findings: list[Finding] = []
  99. if CURRENCY_RE.search(skill_md):
  100. m = CURRENCY_RE.search(skill_md)
  101. findings.append(Finding("currency-note", "ok", f"currency note dated {m.group(1)}"))
  102. else:
  103. findings.append(Finding("currency-note", "drift",
  104. "no dated 'verified as of <year>' currency note in SKILL.md"))
  105. for key in ("wrangler", "compatibility_date", "config_format"):
  106. fact = catalog[key]
  107. token = str(fact["prose_token"])
  108. if token.lower() in lower:
  109. findings.append(Finding(f"fact:{key}", "ok", f"{token!r} named in skill prose"))
  110. else:
  111. findings.append(Finding(f"fact:{key}", "drift",
  112. f"prose_token {token!r} no longer named in skill prose"))
  113. return findings
  114. def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]:
  115. """Return (status, version-or-detail). status in ok|notfound|unavailable."""
  116. url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='')}/latest"
  117. req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-cloudflare-ops-check/1",
  118. "Accept": "application/json"})
  119. try:
  120. with urllib.request.urlopen(req, timeout=timeout) as resp:
  121. payload = resp.read().decode("utf-8", errors="replace")
  122. except urllib.error.HTTPError as exc:
  123. if exc.code in (404, 410):
  124. return "notfound", str(exc.code)
  125. return "unavailable", str(exc.code)
  126. except (urllib.error.URLError, TimeoutError, OSError):
  127. return "unavailable", ""
  128. try:
  129. return "ok", json.loads(payload).get("version", "")
  130. except json.JSONDecodeError:
  131. return "unavailable", "bad-json"
  132. def check_live(catalog: dict, timeout: float) -> list[Finding]:
  133. findings: list[Finding] = []
  134. wr = catalog["wrangler"]
  135. documented_major = str(wr["documented_major"])
  136. status, ver = _npm_latest(wr["live"]["product"], timeout)
  137. if status == "notfound":
  138. findings.append(Finding("npm:wrangler", "drift",
  139. "wrangler gone from npm — renamed/removed, review skill"))
  140. return findings
  141. if status != "ok":
  142. findings.append(Finding("npm:wrangler", "unavailable", "npm registry unreachable"))
  143. return findings
  144. m = re.match(r"\s*(\d+)", ver)
  145. latest_major = m.group(1) if m else ""
  146. if latest_major and latest_major != documented_major:
  147. findings.append(Finding("npm:wrangler", "drift",
  148. f"wrangler@{ver} major {latest_major} != documented v{documented_major}.x — review skill"))
  149. else:
  150. findings.append(Finding("npm:wrangler", "ok", f"latest {ver} (major {latest_major})"))
  151. return findings
  152. def main(argv: list[str]) -> int:
  153. p = argparse.ArgumentParser(
  154. prog="check-cloudflare-facts.py",
  155. description="Verify cloudflare-ops' Wrangler major + config facts stay stated (offline) and current (live).",
  156. )
  157. mode = p.add_mutually_exclusive_group()
  158. mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
  159. mode.add_argument("--live", action="store_true", help="probe npm for wrangler major drift")
  160. p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
  161. p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/ + assets/)")
  162. p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
  163. p.add_argument("--json", action="store_true", help="emit a JSON envelope")
  164. p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary")
  165. try:
  166. args = p.parse_args(argv)
  167. except SystemExit as exc:
  168. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  169. catalog = load_catalog(Path(args.catalog))
  170. live = args.live and not args.offline
  171. mode_name = "live" if live else "offline"
  172. findings = check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))
  173. n_drift = sum(1 for f in findings if f.status == "drift")
  174. n_unavail = sum(1 for f in findings if f.status == "unavailable")
  175. if args.json:
  176. print(json.dumps({
  177. "data": [f.as_dict() for f in findings],
  178. "meta": {"mode": mode_name, "count": len(findings),
  179. "drift": n_drift, "unavailable": n_unavail, "schema": SCHEMA},
  180. }, indent=2))
  181. else:
  182. for f in findings:
  183. print(f"{f.check}\t{f.status}\t{f.detail}")
  184. for f in findings:
  185. if f.status != "ok":
  186. print(f" [{f.status.upper()}] {f.check}: {f.detail}", file=sys.stderr)
  187. if not args.quiet:
  188. print(f"-- {len(findings)} checks: {n_drift} drift, {n_unavail} unavailable", file=sys.stderr)
  189. if n_drift:
  190. return EX_DRIFT
  191. if n_unavail:
  192. return EX_UNAVAILABLE
  193. return EX_OK
  194. if __name__ == "__main__":
  195. sys.exit(main(sys.argv[1:]))