check-tailwind-facts.py 11 KB

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