check-rust-facts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. #!/usr/bin/env python3
  2. """Staleness verifier for rust-ops: the version-bearing facts the skill
  3. encodes must stay real and cited.
  4. rust-ops anchors its currency to a few version-bearing facts — the major
  5. versions of its core ecosystem crates (tokio, axum, serde). That is exactly
  6. the fact that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): a crate leaves
  7. its documented major upstream (axum 0.x → 1.0, say), or the prose stops naming
  8. a crate the catalog still commits to, and nobody notices for months. Two
  9. modes guard it:
  10. --offline (default, safe for PR CI): structural consistency, no network.
  11. * assets/rust-facts.json parses; every entry has name + documented_major
  12. * every catalogued crate is still named somewhere in the skill prose
  13. (SKILL.md / references/*.md) — the catalog can't drift from the docs
  14. * SKILL.md still carries a dated "as of 20XX" currency note
  15. --live (scheduled freshness.yml, never a PR gate): does each crate's
  16. latest stable major on crates.io still match the documented major? A
  17. newer major = the skill is behind reality (drift). Exit 7 if crates.io
  18. is unreachable. (crates.io requires a User-Agent header or it 403s.)
  19. Usage: check-rust-facts.py [--offline | --live] [--catalog 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 catalog/skill missing, 4 catalog unparseable,
  24. 7 crates.io unreachable (live, advisory — never a real failure),
  25. 10 drift found (offline: uncited/undocumented/no currency note;
  26. live: published major newer than documented major)
  27. Examples:
  28. check-rust-facts.py --offline # PR CI: catalog ⇆ prose consistency
  29. check-rust-facts.py --live # weekly: every crate's major still matches crates.io
  30. check-rust-facts.py --offline --json | jq '.data[]'
  31. """
  32. from __future__ import annotations
  33. import argparse
  34. import json
  35. import os
  36. import re
  37. import sys
  38. import urllib.error
  39. import urllib.parse
  40. import urllib.request
  41. from pathlib import Path
  42. EX_OK = 0
  43. EX_USAGE = 2
  44. EX_NOTFOUND = 3
  45. EX_UNPARSEABLE = 4
  46. EX_UNAVAILABLE = 7
  47. EX_DRIFT = 10
  48. HERE = Path(__file__).resolve().parent
  49. DEFAULT_CATALOG = HERE.parent / "assets" / "rust-facts.json"
  50. DEFAULT_SKILL = HERE.parent
  51. DEFAULT_REGISTRY = "https://crates.io/api/v1/crates"
  52. SCHEMA = "claude-mods.rust-ops.facts/v1"
  53. CURRENCY_RE = re.compile(r"as of 20\d\d")
  54. def eprint(*a) -> None:
  55. print(*a, file=sys.stderr)
  56. class Term:
  57. """Minimal ANSI helper (term.sh is bash-only; per TERMINAL-DESIGN.md §9 the
  58. Python port is inline). Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
  59. bound stream's TTY + encoding so piped data stays plain ASCII."""
  60. _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"}
  61. def __init__(self, stream=sys.stderr) -> None:
  62. enc = (getattr(stream, "encoding", "") or "").lower()
  63. self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
  64. if os.environ.get("FORCE_COLOR"):
  65. self.color = True
  66. elif (os.environ.get("NO_COLOR") is not None
  67. or os.environ.get("TERM") == "dumb"
  68. or not getattr(stream, "isatty", lambda: False)()):
  69. self.color = False
  70. else:
  71. self.color = True
  72. def c(self, name: str, text: str) -> str:
  73. return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
  74. def mark(self, ok: bool) -> str:
  75. g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
  76. return self.c("green" if ok else "red", g)
  77. def load_catalog(path: Path) -> tuple[list[dict], str]:
  78. """Returns (packages, registry). Each package has name + documented_major."""
  79. if not path.is_file():
  80. eprint(f"error: package catalog not found: {path}")
  81. raise SystemExit(EX_NOTFOUND)
  82. try:
  83. data = json.loads(path.read_text(encoding="utf-8"))
  84. pkgs = data["packages"]
  85. if not isinstance(pkgs, list) or not pkgs:
  86. raise ValueError("'packages' must be a non-empty array")
  87. for p in pkgs:
  88. if not isinstance(p, dict) or "name" not in p or "documented_major" not in p:
  89. raise ValueError(f"package entry missing name/documented_major: {p!r}")
  90. dm = p["documented_major"]
  91. if not isinstance(dm, int) or isinstance(dm, bool) or dm < 0:
  92. raise ValueError(f"documented_major must be a non-negative int: {p!r}")
  93. registry = data.get("registry") or DEFAULT_REGISTRY
  94. return pkgs, registry
  95. except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
  96. eprint(f"error: could not parse catalog {path}: {exc}")
  97. raise SystemExit(EX_UNPARSEABLE)
  98. def read_corpus(skill_dir: Path) -> tuple[str, str]:
  99. """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md."""
  100. doc = skill_dir / "SKILL.md"
  101. if not doc.is_file():
  102. eprint(f"error: SKILL.md not found under {skill_dir}")
  103. raise SystemExit(EX_NOTFOUND)
  104. skill_md = doc.read_text(encoding="utf-8")
  105. parts = [skill_md]
  106. for ref in sorted((skill_dir / "references").glob("*.md")):
  107. parts.append(ref.read_text(encoding="utf-8"))
  108. return skill_md, "\n".join(parts)
  109. def check_offline(pkgs: list[dict], skill_dir: Path) -> list[dict]:
  110. skill_md, corpus = read_corpus(skill_dir)
  111. findings: list[dict] = []
  112. for p in pkgs:
  113. name = p["name"]
  114. # case-sensitive exact substring: crate names are case-sensitive (serde != Serde)
  115. if name not in corpus:
  116. findings.append({"package": name, "issue": "catalogued but not named in skill prose"})
  117. if not CURRENCY_RE.search(skill_md):
  118. findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of 20XX' currency note"})
  119. return findings
  120. def crates_latest(registry: str, name: str, timeout: float) -> tuple[str, object]:
  121. """Return ('ok', version_str) | ('gone', None) | ('unreachable', info).
  122. crates.io 403s requests without a User-Agent header, so one is mandatory."""
  123. url = registry.rstrip("/") + "/" + urllib.parse.quote(name, safe="")
  124. req = urllib.request.Request(url, method="GET",
  125. headers={"User-Agent": "claude-mods-rust-ops-check/1",
  126. "Accept": "application/json"})
  127. try:
  128. with urllib.request.urlopen(req, timeout=timeout) as resp:
  129. data = json.loads(resp.read().decode("utf-8"))
  130. crate = data.get("crate", {}) if isinstance(data, dict) else {}
  131. # max_stable_version skips pre-releases (0.8.0-beta.1); fall back to max_version.
  132. ver = crate.get("max_stable_version") or crate.get("max_version", "")
  133. return ("ok", ver)
  134. except urllib.error.HTTPError as exc:
  135. if exc.code in (404, 410):
  136. return ("gone", None)
  137. return ("unreachable", exc.code) # 403/5xx etc: transient or auth, not a content finding
  138. except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
  139. return ("unreachable", str(getattr(exc, "reason", exc)))
  140. def major_of(version: str) -> int | None:
  141. m = re.match(r"\D*(\d+)", version or "")
  142. return int(m.group(1)) if m else None
  143. def check_live(pkgs: list[dict], registry: str, timeout: float) -> tuple[list[dict], list[dict]]:
  144. drift: list[dict] = []
  145. unreachable: list[dict] = []
  146. for p in pkgs:
  147. name = p["name"]
  148. doc = p["documented_major"]
  149. status, info = crates_latest(registry, name, timeout)
  150. if status == "gone":
  151. drift.append({"package": name, "issue": "no longer resolves on crates.io (404)"})
  152. elif status != "ok":
  153. unreachable.append({"package": name, "issue": f"unreachable: {info}"})
  154. else:
  155. live_major = major_of(str(info))
  156. if live_major is None:
  157. unreachable.append({"package": name, "issue": f"could not parse version {info!r}"})
  158. elif live_major > doc:
  159. drift.append({"package": name,
  160. "issue": f"crates.io@{info} major {live_major} > documented major {doc}"})
  161. return drift, unreachable
  162. def main(argv: list[str]) -> int:
  163. p = argparse.ArgumentParser(
  164. prog="check-rust-facts.py",
  165. description="Verify rust-ops' version-bearing facts stay cited (offline) and current on crates.io (live).",
  166. )
  167. mode = p.add_mutually_exclusive_group()
  168. mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
  169. mode.add_argument("--live", action="store_true", help="check every crate's latest major still matches crates.io")
  170. p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
  171. p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
  172. p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
  173. p.add_argument("--json", action="store_true", help="emit a JSON envelope")
  174. try:
  175. args = p.parse_args(argv)
  176. except SystemExit as exc:
  177. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  178. pkgs, registry = load_catalog(Path(args.catalog))
  179. live = args.live and not args.offline
  180. t = Term(sys.stderr)
  181. if live:
  182. drift, unreachable = check_live(pkgs, registry, args.timeout)
  183. findings = drift + unreachable
  184. if args.json:
  185. print(json.dumps({
  186. "data": findings,
  187. "meta": {"mode": "live", "packages_checked": len(pkgs),
  188. "drift": len(drift), "unreachable": len(unreachable),
  189. "registry": registry, "schema": SCHEMA},
  190. }, indent=2))
  191. else:
  192. for f in drift:
  193. print(f"DRIFT {f['package']}: {f['issue']}")
  194. for f in unreachable:
  195. print(f"UNREACH {f['package']}: {f['issue']}")
  196. # §7: confirmed drift -> 10; else transient/unreachable -> 7 (advisory); else 0.
  197. if drift:
  198. eprint(f"{t.mark(False)} rust-facts/live: {len(drift)} crate(s) drifted from documented major "
  199. f"{t.c('dim', '(' + registry + ')')}")
  200. return EX_DRIFT
  201. if unreachable:
  202. eprint(f"{t.mark(False)} rust-facts/live: crates.io unreachable for "
  203. f"{len(unreachable)}/{len(pkgs)} {t.c('dim', '(advisory - retry next run)')}")
  204. return EX_UNAVAILABLE
  205. eprint(f"{t.mark(True)} rust-facts/live: all {len(pkgs)} crate(s) match documented major on crates.io")
  206. return EX_OK
  207. # offline (default)
  208. findings = check_offline(pkgs, Path(args.skill))
  209. if args.json:
  210. print(json.dumps({
  211. "data": findings,
  212. "meta": {"mode": "offline", "packages_checked": len(pkgs),
  213. "drift": len(findings), "consistent": not findings, "schema": SCHEMA},
  214. }, indent=2))
  215. else:
  216. for f in findings:
  217. print(f"DRIFT {f['package']}: {f['issue']}")
  218. ok = not findings
  219. eprint(f"{t.mark(ok)} rust-facts/offline: {len(pkgs)} crate(s) checked, "
  220. f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}")
  221. return EX_DRIFT if findings else EX_OK
  222. if __name__ == "__main__":
  223. sys.exit(main(sys.argv[1:]))