check-typescript-facts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. #!/usr/bin/env python3
  2. """Staleness verifier for typescript-ops: the version-bearing facts the skill
  3. encodes must stay real and cited.
  4. typescript-ops anchors its currency to a few version-bearing facts — the
  5. TypeScript major the prose assumes (feature floors like "TS 4.4+",
  6. "TypeScript 4.7+"), and the runtime-validation stack (zod, valibot). That is
  7. exactly the fact that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): a
  8. package leaves its documented major upstream, or the prose stops naming a
  9. package the catalog still commits to, and nobody notices for months. Two
  10. modes guard it:
  11. --offline (default, safe for PR CI): structural consistency, no network.
  12. * assets/typescript-facts.json parses; every entry has name + documented_major
  13. * every catalogued package is still named somewhere in the skill prose
  14. (SKILL.md / references/*.md) — the catalog can't drift from the docs
  15. * SKILL.md still carries a dated "as of 20XX" currency note
  16. --live (scheduled freshness.yml, never a PR gate): does each package's
  17. latest published major on npm still match the documented major? A newer
  18. major = the skill is behind reality (drift). Exit 7 if npm is unreachable.
  19. Usage: check-typescript-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 npm 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-typescript-facts.py --offline # PR CI: catalog ⇆ prose consistency
  29. check-typescript-facts.py --live # weekly: every package's major still matches npm
  30. check-typescript-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" / "typescript-facts.json"
  50. DEFAULT_SKILL = HERE.parent
  51. DEFAULT_REGISTRY = "https://registry.npmjs.org"
  52. SCHEMA = "claude-mods.typescript-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: npm names are case-sensitive (zod != Zod)
  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 npm_latest(registry: str, name: str, timeout: float) -> tuple[str, object]:
  121. """Return ('ok', version_str) | ('gone', None) | ('unreachable', info)."""
  122. url = registry.rstrip("/") + "/" + urllib.parse.quote(name, safe="") + "/latest"
  123. req = urllib.request.Request(url, method="GET",
  124. headers={"User-Agent": "claude-mods-typescript-ops-check/1",
  125. "Accept": "application/json"})
  126. try:
  127. with urllib.request.urlopen(req, timeout=timeout) as resp:
  128. data = json.loads(resp.read().decode("utf-8"))
  129. return ("ok", data.get("version", ""))
  130. except urllib.error.HTTPError as exc:
  131. if exc.code in (404, 410):
  132. return ("gone", None)
  133. return ("unreachable", exc.code) # 5xx etc: transient, not a content finding
  134. except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
  135. return ("unreachable", str(getattr(exc, "reason", exc)))
  136. def major_of(version: str) -> int | None:
  137. m = re.match(r"\D*(\d+)", version or "")
  138. return int(m.group(1)) if m else None
  139. def check_live(pkgs: list[dict], registry: str, timeout: float) -> tuple[list[dict], list[dict]]:
  140. drift: list[dict] = []
  141. unreachable: list[dict] = []
  142. for p in pkgs:
  143. name = p["name"]
  144. doc = p["documented_major"]
  145. status, info = npm_latest(registry, name, timeout)
  146. if status == "gone":
  147. drift.append({"package": name, "issue": "no longer resolves on npm (404)"})
  148. elif status != "ok":
  149. unreachable.append({"package": name, "issue": f"unreachable: {info}"})
  150. else:
  151. live_major = major_of(str(info))
  152. if live_major is None:
  153. unreachable.append({"package": name, "issue": f"could not parse version {info!r}"})
  154. elif live_major > doc:
  155. drift.append({"package": name,
  156. "issue": f"npm@{info} major {live_major} > documented major {doc}"})
  157. return drift, unreachable
  158. def main(argv: list[str]) -> int:
  159. p = argparse.ArgumentParser(
  160. prog="check-typescript-facts.py",
  161. description="Verify typescript-ops' version-bearing facts stay cited (offline) and current on npm (live).",
  162. )
  163. mode = p.add_mutually_exclusive_group()
  164. mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
  165. mode.add_argument("--live", action="store_true", help="check every package's latest major still matches npm")
  166. p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
  167. p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
  168. p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
  169. p.add_argument("--json", action="store_true", help="emit a JSON envelope")
  170. try:
  171. args = p.parse_args(argv)
  172. except SystemExit as exc:
  173. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  174. pkgs, registry = load_catalog(Path(args.catalog))
  175. live = args.live and not args.offline
  176. t = Term(sys.stderr)
  177. if live:
  178. drift, unreachable = check_live(pkgs, registry, args.timeout)
  179. findings = drift + unreachable
  180. if args.json:
  181. print(json.dumps({
  182. "data": findings,
  183. "meta": {"mode": "live", "packages_checked": len(pkgs),
  184. "drift": len(drift), "unreachable": len(unreachable),
  185. "registry": registry, "schema": SCHEMA},
  186. }, indent=2))
  187. else:
  188. for f in drift:
  189. print(f"DRIFT {f['package']}: {f['issue']}")
  190. for f in unreachable:
  191. print(f"UNREACH {f['package']}: {f['issue']}")
  192. # §7: confirmed drift -> 10; else transient/unreachable -> 7 (advisory); else 0.
  193. if drift:
  194. eprint(f"{t.mark(False)} ts-facts/live: {len(drift)} package(s) drifted from documented major "
  195. f"{t.c('dim', '(' + registry + ')')}")
  196. return EX_DRIFT
  197. if unreachable:
  198. eprint(f"{t.mark(False)} ts-facts/live: npm unreachable for "
  199. f"{len(unreachable)}/{len(pkgs)} {t.c('dim', '(advisory - retry next run)')}")
  200. return EX_UNAVAILABLE
  201. eprint(f"{t.mark(True)} ts-facts/live: all {len(pkgs)} package(s) match documented major on npm")
  202. return EX_OK
  203. # offline (default)
  204. findings = check_offline(pkgs, Path(args.skill))
  205. if args.json:
  206. print(json.dumps({
  207. "data": findings,
  208. "meta": {"mode": "offline", "packages_checked": len(pkgs),
  209. "drift": len(findings), "consistent": not findings, "schema": SCHEMA},
  210. }, indent=2))
  211. else:
  212. for f in findings:
  213. print(f"DRIFT {f['package']}: {f['issue']}")
  214. ok = not findings
  215. eprint(f"{t.mark(ok)} ts-facts/offline: {len(pkgs)} package(s) checked, "
  216. f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}")
  217. return EX_DRIFT if findings else EX_OK
  218. if __name__ == "__main__":
  219. sys.exit(main(sys.argv[1:]))