check-react-facts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. #!/usr/bin/env python3
  2. """Staleness verifier for react-ops: the React 19 facts the skill encodes must
  3. stay real and named in the prose.
  4. react-ops centers on React 19 (use(), Actions, useActionState, useFormStatus,
  5. useOptimistic, React Compiler) and names an ecosystem stack (Zustand, Jotai,
  6. Redux Toolkit, TanStack Query, React Hook Form, Zod). That is exactly the fact
  7. that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): a package moves a major
  8. version upstream, or the prose stops mentioning a package the catalog lists, and
  9. nobody notices for months. Two modes guard it:
  10. --offline (default, safe for PR CI): structural consistency, no network.
  11. * assets/react-facts.json parses and every package + version gate is named
  12. somewhere in the skill prose (SKILL.md / references/*.md) — the catalog
  13. can't drift from the docs
  14. * SKILL.md still carries a dated "as of <year>" currency note
  15. --live (scheduled freshness.yml, never a PR gate): query the npm registry for
  16. each package's latest dist-tag; flag DRIFT when the live major is newer than
  17. the documented major (the skill is now behind), or when a package is gone
  18. (404). Transient registry failure is UNAVAILABLE (exit 7), never a failure.
  19. Usage: check-react-facts.py [--offline | --live] [--facts FILE] [--skill DIR] [--json] [--timeout S]
  20. Input: argv flags only (no stdin).
  21. Output: stdout = findings (plain rows, or a --json envelope). Data only.
  22. Stderr: the verdict line, notices, errors.
  23. Exit: 0 ok, 2 usage, 3 facts/skill missing, 4 facts unparseable,
  24. 7 npm registry unreachable (live, advisory — never a real failure),
  25. 10 drift (offline: uncited/undocumented/missing note; live: major ahead or gone)
  26. Examples:
  27. check-react-facts.py --offline # PR CI: catalog ⇆ prose consistency
  28. check-react-facts.py --live # weekly: is any documented major behind npm?
  29. check-react-facts.py --offline --json | jq '.data[]'
  30. """
  31. from __future__ import annotations
  32. import argparse
  33. import json
  34. import os
  35. import re
  36. import sys
  37. import urllib.error
  38. import urllib.parse
  39. import urllib.request
  40. from pathlib import Path
  41. EX_OK = 0
  42. EX_USAGE = 2
  43. EX_NOTFOUND = 3
  44. EX_UNPARSEABLE = 4
  45. EX_UNAVAILABLE = 7
  46. EX_DRIFT = 10
  47. SCHEMA = "claude-mods.react-ops.facts/v1"
  48. HERE = Path(__file__).resolve().parent
  49. DEFAULT_FACTS = HERE.parent / "assets" / "react-facts.json"
  50. DEFAULT_SKILL = HERE.parent
  51. REGISTRY = "https://registry.npmjs.org"
  52. CURRENCY_RE = re.compile(r"as of 20\d\d")
  53. class Term:
  54. """Minimal ANSI helper. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
  55. bound stream's TTY + encoding so piped data stays plain ASCII."""
  56. _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"}
  57. def __init__(self, stream=sys.stderr):
  58. enc = (getattr(stream, "encoding", "") or "").lower()
  59. self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
  60. if os.environ.get("FORCE_COLOR"):
  61. self.color = True
  62. elif (os.environ.get("NO_COLOR") is not None
  63. or os.environ.get("TERM") == "dumb"
  64. or not getattr(stream, "isatty", lambda: False)()):
  65. self.color = False
  66. else:
  67. self.color = True
  68. def c(self, name, text):
  69. return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
  70. def mark(self, ok):
  71. g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
  72. return self.c("green" if ok else "red", g)
  73. def load_facts(path: Path) -> dict:
  74. if not path.is_file():
  75. print(f"error: facts catalog not found: {path}", file=sys.stderr)
  76. raise SystemExit(EX_NOTFOUND)
  77. try:
  78. data = json.loads(path.read_text(encoding="utf-8"))
  79. if data.get("schema") != SCHEMA:
  80. raise ValueError(f"schema {data.get('schema')!r} != {SCHEMA!r}")
  81. if not isinstance(data.get("packages"), dict) or not data["packages"]:
  82. raise ValueError("'packages' must be a non-empty object")
  83. for name, info in data["packages"].items():
  84. if not isinstance(info, dict) or "documented_major" not in info:
  85. raise ValueError(f"package {name!r} missing documented_major")
  86. if not isinstance(info.get("prose"), list) or not info["prose"]:
  87. raise ValueError(f"package {name!r} missing prose tokens")
  88. return data
  89. except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
  90. print(f"error: could not parse facts {path}: {exc}", file=sys.stderr)
  91. raise SystemExit(EX_UNPARSEABLE)
  92. def read_corpus(skill_dir: Path) -> tuple[str, str]:
  93. """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md."""
  94. doc = skill_dir / "SKILL.md"
  95. if not doc.is_file():
  96. print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
  97. raise SystemExit(EX_NOTFOUND)
  98. skill_md = doc.read_text(encoding="utf-8", errors="replace")
  99. parts = [skill_md]
  100. for ref in sorted((skill_dir / "references").glob("*.md")):
  101. parts.append(ref.read_text(encoding="utf-8", errors="replace"))
  102. return skill_md, "\n".join(parts)
  103. def check_offline(facts: dict, skill_dir: Path) -> list[dict]:
  104. skill_md, corpus = read_corpus(skill_dir)
  105. findings: list[dict] = []
  106. for name, info in facts["packages"].items():
  107. for token in info["prose"]:
  108. if token not in corpus:
  109. findings.append({"package": name, "issue": f"prose token {token!r} not named in skill"})
  110. for key, token in facts.get("version_gates", {}).items():
  111. if key == "_comment":
  112. continue
  113. if str(token) not in corpus:
  114. findings.append({"package": "(gate)", "issue": f"version gate {key}={token!r} not stated in skill prose"})
  115. if not CURRENCY_RE.search(skill_md):
  116. findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of <year>' currency note"})
  117. return findings
  118. def npm_latest(name: str, timeout: float) -> tuple[str, object]:
  119. """Return (resolved|notfound|unavailable, version-string-or-status)."""
  120. url = f"{REGISTRY}/{urllib.parse.quote(name, safe='')}/latest"
  121. req = urllib.request.Request(url, method="GET",
  122. headers={"User-Agent": "claude-mods-react-ops-check/1",
  123. "Accept": "application/json"})
  124. try:
  125. with urllib.request.urlopen(req, timeout=timeout) as resp:
  126. manifest = json.loads(resp.read().decode("utf-8"))
  127. return ("resolved", manifest.get("version", ""))
  128. except urllib.error.HTTPError as exc:
  129. if exc.code in (404, 410):
  130. return ("notfound", exc.code)
  131. return ("unavailable", exc.code)
  132. except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
  133. return ("unavailable", str(getattr(exc, "reason", exc)))
  134. def major_of(version: str) -> int | None:
  135. m = re.match(r"\d+", version.strip())
  136. return int(m.group(0)) if m else None
  137. def check_live(facts: dict, timeout: float) -> tuple[list[dict], list[dict]]:
  138. drift: list[dict] = []
  139. unreachable: list[dict] = []
  140. for name, info in facts["packages"].items():
  141. documented = info["documented_major"]
  142. status, info2 = npm_latest(name, timeout)
  143. if status == "notfound":
  144. drift.append({"package": name, "issue": "no longer resolves on npm (404) — renamed/removed"})
  145. elif status == "unavailable":
  146. unreachable.append({"package": name, "issue": f"registry unreachable: {info2}"})
  147. else:
  148. live = major_of(str(info2))
  149. if live is None:
  150. unreachable.append({"package": name, "issue": f"could not parse version {info2!r}"})
  151. elif live > documented:
  152. drift.append({"package": name,
  153. "issue": f"live major {live} ({info2}) ahead of documented major {documented}"})
  154. return drift, unreachable
  155. def main(argv: list[str]) -> int:
  156. p = argparse.ArgumentParser(
  157. prog="check-react-facts.py",
  158. description="Verify react-ops' React 19 facts stay named (offline) and current on npm (live).",
  159. )
  160. mode = p.add_mutually_exclusive_group()
  161. mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
  162. mode.add_argument("--live", action="store_true", help="check each package's npm major vs documented")
  163. p.add_argument("--facts", default=str(DEFAULT_FACTS), help="facts catalog JSON")
  164. p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
  165. p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
  166. p.add_argument("--json", action="store_true", help="emit a JSON envelope")
  167. try:
  168. args = p.parse_args(argv)
  169. except SystemExit as exc:
  170. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  171. facts = load_facts(Path(args.facts))
  172. live = args.live and not args.offline
  173. t = Term(sys.stderr)
  174. if live:
  175. drift, unreachable = check_live(facts, args.timeout)
  176. findings = drift + unreachable
  177. if args.json:
  178. print(json.dumps({
  179. "data": findings,
  180. "meta": {"mode": "live", "packages_checked": len(facts["packages"]),
  181. "drift": len(drift), "unreachable": len(unreachable),
  182. "registry": REGISTRY, "schema": SCHEMA},
  183. }, indent=2))
  184. else:
  185. for f in findings:
  186. kind = "DRIFT" if f in drift else "UNREACH"
  187. print(f"{kind} {f['package']}: {f['issue']}")
  188. if drift:
  189. print(f"{t.mark(False)} react-facts/live: {len(drift)} package(s) drifted "
  190. f"{t.c('dim', '(' + REGISTRY + ')')}", file=sys.stderr)
  191. return EX_DRIFT
  192. if unreachable:
  193. print(f"{t.mark(False)} react-facts/live: npm unreachable for "
  194. f"{len(unreachable)}/{len(facts['packages'])} {t.c('dim', '(advisory - retry next run)')}",
  195. file=sys.stderr)
  196. return EX_UNAVAILABLE
  197. print(f"{t.mark(True)} react-facts/live: all {len(facts['packages'])} package(s) "
  198. f"at or below documented major", file=sys.stderr)
  199. return EX_OK
  200. # offline (default)
  201. findings = check_offline(facts, Path(args.skill))
  202. if args.json:
  203. print(json.dumps({
  204. "data": findings,
  205. "meta": {"mode": "offline", "packages_checked": len(facts["packages"]),
  206. "drift": len(findings), "consistent": not findings, "schema": SCHEMA},
  207. }, indent=2))
  208. else:
  209. for f in findings:
  210. print(f"DRIFT {f['package']}: {f['issue']}")
  211. ok = not findings
  212. print(f"{t.mark(ok)} react-facts/offline: {len(facts['packages'])} package(s) + "
  213. f"{sum(1 for k in facts.get('version_gates', {}) if k != '_comment')} gate(s) checked, "
  214. f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}", file=sys.stderr)
  215. return EX_DRIFT if findings else EX_OK
  216. if __name__ == "__main__":
  217. sys.exit(main(sys.argv[1:]))