check-migrate-facts.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. #!/usr/bin/env python3
  2. """Staleness verifier for migrate-ops: the framework/language target versions
  3. the skill hardcodes must stay stated where it claims, and must not silently lag
  4. reality.
  5. migrate-ops commits to specific target versions in its description and body —
  6. React 19, Laravel 11, Python 3.12, Node 22, TypeScript 5, Go 1.22, Rust 2024,
  7. PHP 8.4. Those are exactly the facts that drift silently
  8. (SKILL-RESOURCE-PROTOCOL.md §7): a line gets rewritten and drops a version, or
  9. the world moves (Python 3.12 → 3.13, Node 22 → 24) and the skill still names
  10. the old target. Two modes:
  11. --offline (default, safe for PR CI): structural consistency, no network.
  12. * assets/migrate-facts.json parses and carries the schema + an as_of date
  13. * every catalogued claim's regex still matches in each location it is
  14. recorded as appearing (description vs body) — the catalog can't drift
  15. from the docs
  16. * SKILL.md still carries a dated "verified as of <year>" currency note
  17. --live (scheduled freshness job, never a PR gate): resolves the current
  18. stable version of each product via endoflife.date (python, nodejs, laravel,
  19. php, go) and registry.npmjs.org (react, typescript), and flags any
  20. documented target that is no longer the latest stable major/line.
  21. Usage: check-migrate-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S]
  22. Input: argv flags only (no stdin).
  23. Output: stdout = findings (plain rows, or a --json envelope). Data only.
  24. Stderr: the verdict line, notices, errors.
  25. Exit: 0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable,
  26. 7 endoflife.date/npm unreachable (live, advisory — never a real failure),
  27. 10 drift found (offline: claim missing from a recorded location or
  28. currency note gone; live: a documented target lags the latest stable)
  29. Examples:
  30. check-migrate-facts.py --offline # PR CI: catalog ⇆ prose consistency
  31. check-migrate-facts.py --live # weekly: any target lagging latest?
  32. check-migrate-facts.py --offline --json | jq '.data[]'
  33. """
  34. from __future__ import annotations
  35. import argparse
  36. import datetime
  37. import json
  38. import re
  39. import sys
  40. import urllib.error
  41. import urllib.parse
  42. import urllib.request
  43. from pathlib import Path
  44. EX_OK = 0
  45. EX_USAGE = 2
  46. EX_NOTFOUND = 3
  47. EX_UNPARSEABLE = 4
  48. EX_UNAVAILABLE = 7
  49. EX_DRIFT = 10
  50. SCHEMA = "claude-mods.migrate-ops.facts/v1"
  51. HERE = Path(__file__).resolve().parent
  52. DEFAULT_CATALOG = HERE.parent / "assets" / "migrate-facts.json"
  53. DEFAULT_SKILL = HERE.parent
  54. ENDOFLIFE = "https://endoflife.date/api"
  55. NPM_REGISTRY = "https://registry.npmjs.org"
  56. CURRENCY_RE = re.compile(r"verified as of\s+(\d{4})", re.IGNORECASE)
  57. AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
  58. class Finding:
  59. __slots__ = ("check", "status", "detail")
  60. def __init__(self, check: str, status: str, detail: str) -> None:
  61. self.check = check
  62. self.status = status # ok | drift | unavailable
  63. self.detail = detail
  64. def as_dict(self) -> dict:
  65. return {"check": self.check, "status": self.status, "detail": self.detail}
  66. def load_catalog(path: Path) -> dict:
  67. if not path.is_file():
  68. print(f"error: facts catalog not found: {path}", file=sys.stderr)
  69. raise SystemExit(EX_NOTFOUND)
  70. try:
  71. data = json.loads(path.read_text(encoding="utf-8"))
  72. if not isinstance(data, dict) or data.get("schema") != SCHEMA:
  73. raise ValueError(f"schema must be {SCHEMA!r}")
  74. if not AS_OF_RE.match(str(data.get("as_of", ""))):
  75. raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}")
  76. if not isinstance(data.get("claims"), list) or not data["claims"]:
  77. raise ValueError("'claims' must be a non-empty array")
  78. for c in data["claims"]:
  79. for k in ("label", "version", "where", "pattern"):
  80. if k not in c:
  81. raise ValueError(f"claim missing {k}: {c!r}")
  82. return data
  83. except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
  84. print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr)
  85. raise SystemExit(EX_UNPARSEABLE)
  86. def split_skill(skill_md: str) -> tuple[str, str]:
  87. """Split SKILL.md into (description_value, body_text).
  88. description = the quoted value of the frontmatter `description:` field.
  89. body = everything after the closing `---` fence."""
  90. lines = skill_md.splitlines()
  91. if not lines or lines[0].strip() != "---":
  92. return "", skill_md
  93. end = None
  94. for i in range(1, len(lines)):
  95. if lines[i].strip() == "---":
  96. end = i
  97. break
  98. if end is None:
  99. return "", skill_md
  100. desc = ""
  101. for ln in lines[1:end]:
  102. if ln.strip().startswith("description:"):
  103. desc = ln.strip()[len("description:"):].strip()
  104. if len(desc) >= 2 and desc[0] in "\"'" and desc[-1] == desc[0]:
  105. desc = desc[1:-1]
  106. break
  107. body = "\n".join(lines[end + 1:])
  108. return desc, body
  109. def read_corpus(skill_dir: Path) -> tuple[str, str, str]:
  110. """Returns (skill_md, description, body_corpus). body_corpus = SKILL.md body
  111. + references/*.md (the prose a claim can be recorded as appearing in)."""
  112. doc = skill_dir / "SKILL.md"
  113. if not doc.is_file():
  114. print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
  115. raise SystemExit(EX_NOTFOUND)
  116. skill_md = doc.read_text(encoding="utf-8", errors="replace")
  117. desc, body = split_skill(skill_md)
  118. parts = [body]
  119. ref_dir = skill_dir / "references"
  120. if ref_dir.is_dir():
  121. for ref in sorted(ref_dir.glob("*.md")):
  122. parts.append(ref.read_text(encoding="utf-8", errors="replace"))
  123. return skill_md, desc, "\n".join(parts)
  124. def check_offline(catalog: dict, skill_dir: Path) -> list[Finding]:
  125. skill_md, desc, body_corpus = read_corpus(skill_dir)
  126. findings: list[Finding] = []
  127. if CURRENCY_RE.search(skill_md):
  128. m = CURRENCY_RE.search(skill_md)
  129. findings.append(Finding("currency-note", "ok", f"currency note dated {m.group(1)}"))
  130. else:
  131. findings.append(Finding("currency-note", "drift",
  132. "no dated 'verified as of <year>' currency note in SKILL.md"))
  133. for claim in catalog.get("claims", []):
  134. label = claim["label"]
  135. regex = re.compile(claim["pattern"], re.IGNORECASE)
  136. for loc in claim["where"]:
  137. text = desc if loc == "description" else body_corpus
  138. if regex.search(text):
  139. findings.append(Finding(f"claim:{label}:{loc}", "ok",
  140. f"{label} {claim['version']} present in {loc}"))
  141. else:
  142. findings.append(Finding(f"claim:{label}:{loc}", "drift",
  143. f"{label} {claim['version']} not found in {loc} "
  144. f"(pattern {claim['pattern']!r})"))
  145. return findings
  146. def _fetch(url: str, timeout: float, accept: str = "application/json") -> tuple[str, object]:
  147. req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-migrate-ops-check/1",
  148. "Accept": accept})
  149. try:
  150. with urllib.request.urlopen(req, timeout=timeout) as resp:
  151. return "ok", resp.read().decode("utf-8", errors="replace")
  152. except urllib.error.HTTPError as exc:
  153. if exc.code in (404, 410):
  154. return "notfound", exc.code
  155. return "unavailable", exc.code
  156. except (urllib.error.URLError, TimeoutError, OSError):
  157. return "unavailable", None
  158. def _vtup(s: str) -> tuple[int, ...]:
  159. return tuple(int(x) for x in re.findall(r"\d+", str(s))) or (0,)
  160. def _leading_int(s: str) -> str:
  161. m = re.match(r"\s*(\d+)", str(s))
  162. return m.group(1) if m else ""
  163. def _endoflife_latest(product: str, timeout: float) -> tuple[str, str | None]:
  164. """Return (status, latest_supported_cycle_or_None). status in ok|unavailable."""
  165. url = f"{ENDOFLIFE}/{urllib.parse.quote(product, safe='')}.json"
  166. status, payload = _fetch(url, timeout)
  167. if status != "ok":
  168. return "unavailable", None
  169. try:
  170. cycles = json.loads(payload)
  171. except json.JSONDecodeError:
  172. return "unavailable", None
  173. today = datetime.date.today()
  174. supported: list[str] = []
  175. for c in cycles:
  176. cyc = str(c.get("cycle", ""))
  177. if not cyc:
  178. continue
  179. eol = c.get("eol", False)
  180. is_eol = False
  181. if isinstance(eol, str):
  182. try:
  183. is_eol = datetime.date.fromisoformat(eol[:10]) < today
  184. except ValueError:
  185. is_eol = False
  186. elif eol is False:
  187. is_eol = False
  188. else:
  189. is_eol = bool(eol)
  190. if not is_eol:
  191. supported.append(cyc)
  192. pool = supported or [str(c.get("cycle", "")) for c in cycles if c.get("cycle")]
  193. if not pool:
  194. return "ok", None
  195. return "ok", max(pool, key=_vtup)
  196. def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]:
  197. """Return (status, version-or-detail). status in ok|notfound|unavailable."""
  198. url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='')}/latest"
  199. status, payload = _fetch(url, timeout)
  200. if status != "ok":
  201. return status, str(payload)
  202. try:
  203. return "ok", json.loads(payload).get("version", "")
  204. except json.JSONDecodeError:
  205. return "unavailable", "bad-json"
  206. def check_live(catalog: dict, timeout: float) -> list[Finding]:
  207. findings: list[Finding] = []
  208. for claim in catalog.get("claims", []):
  209. live = claim.get("live")
  210. label = claim["label"]
  211. if not live:
  212. findings.append(Finding(f"live:{label}", "ok", "not live-tracked (edition / no registry source)"))
  213. continue
  214. source = live.get("source")
  215. compare = live.get("compare", "major")
  216. documented = str(live.get("documented", ""))
  217. if source == "endoflife":
  218. status, latest = _endoflife_latest(live["product"], timeout)
  219. if status != "ok" or latest is None:
  220. findings.append(Finding(f"live:{label}", "unavailable",
  221. f"endoflife.date/{live['product']} unreachable"))
  222. continue
  223. if compare == "major":
  224. latest_major, doc_major = _leading_int(latest), _leading_int(documented)
  225. if latest_major and latest_major != doc_major:
  226. findings.append(Finding(f"live:{label}", "drift",
  227. f"{label} documented {documented} lags latest stable {latest}"))
  228. else:
  229. findings.append(Finding(f"live:{label}", "ok", f"latest stable {latest}"))
  230. else: # line: compare MAJOR.MINOR
  231. if _vtup(latest)[:2] != _vtup(documented)[:2]:
  232. findings.append(Finding(f"live:{label}", "drift",
  233. f"{label} documented {documented} lags latest stable {latest}"))
  234. else:
  235. findings.append(Finding(f"live:{label}", "ok", f"latest stable {latest}"))
  236. elif source == "npm":
  237. status, ver = _npm_latest(live["product"], timeout)
  238. if status == "notfound":
  239. findings.append(Finding(f"live:{label}", "drift",
  240. f"{live['product']} gone from npm — renamed/removed"))
  241. continue
  242. if status != "ok":
  243. findings.append(Finding(f"live:{label}", "unavailable",
  244. f"npm/{live['product']} unreachable"))
  245. continue
  246. latest_major, doc_major = _leading_int(ver), _leading_int(documented)
  247. if latest_major and latest_major != doc_major:
  248. findings.append(Finding(f"live:{label}", "drift",
  249. f"{label} documented {documented} lags latest {ver}"))
  250. else:
  251. findings.append(Finding(f"live:{label}", "ok", f"latest {ver}"))
  252. else:
  253. findings.append(Finding(f"live:{label}", "drift", f"unknown live source {source!r}"))
  254. return findings
  255. def main(argv: list[str]) -> int:
  256. p = argparse.ArgumentParser(
  257. prog="check-migrate-facts.py",
  258. description="Verify migrate-ops' hardcoded target versions stay stated (offline) and current (live).",
  259. )
  260. mode = p.add_mutually_exclusive_group()
  261. mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
  262. mode.add_argument("--live", action="store_true", help="resolve current versions via endoflife.date + npm")
  263. p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
  264. p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
  265. p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
  266. p.add_argument("--json", action="store_true", help="emit a JSON envelope")
  267. p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary")
  268. try:
  269. args = p.parse_args(argv)
  270. except SystemExit as exc:
  271. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  272. catalog = load_catalog(Path(args.catalog))
  273. live = args.live and not args.offline
  274. mode_name = "live" if live else "offline"
  275. findings = check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))
  276. n_drift = sum(1 for f in findings if f.status == "drift")
  277. n_unavail = sum(1 for f in findings if f.status == "unavailable")
  278. if args.json:
  279. print(json.dumps({
  280. "data": [f.as_dict() for f in findings],
  281. "meta": {"mode": mode_name, "count": len(findings),
  282. "drift": n_drift, "unavailable": n_unavail, "schema": SCHEMA},
  283. }, indent=2))
  284. else:
  285. for f in findings:
  286. print(f"{f.check}\t{f.status}\t{f.detail}")
  287. for f in findings:
  288. if f.status != "ok":
  289. print(f" [{f.status.upper()}] {f.check}: {f.detail}", file=sys.stderr)
  290. if not args.quiet:
  291. print(f"-- {len(findings)} checks: {n_drift} drift, {n_unavail} unavailable", file=sys.stderr)
  292. if n_drift:
  293. return EX_DRIFT
  294. if n_unavail:
  295. return EX_UNAVAILABLE
  296. return EX_OK
  297. if __name__ == "__main__":
  298. sys.exit(main(sys.argv[1:]))