1
0

check-iso-facts.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. #!/usr/bin/env python3
  2. """Staleness verifier for isometric-ops's canonical constants and citation graph.
  3. Guards the fast-moving/easy-to-typo facts this skill depends on:
  4. - the canonical projection-math constants (26.565, 35.264, 81.65, 86.602,
  5. 57.735/57.74, 1.22474, 54.736) actually appear in references/projection-math.md
  6. - every references/*.md file is cited (linked or path-mentioned) from SKILL.md
  7. - the third-party packages named in prose (@elchininet/isometric, isometric-css,
  8. svgo) still exist on the npm registry
  9. Two modes (protocol SKILL-RESOURCE-PROTOCOL.md Section 7):
  10. --offline (default): parse the shipped markdown, assert the constants are present
  11. and every reference is cited. No network. May block CI.
  12. --live: query the npm registry for the named packages. Advisory
  13. only — exits 7 on any network/registry unavailability,
  14. never blocks a PR.
  15. SKILL.md special-case: while the skill is mid-build its body is a short
  16. "BUILD IN PROGRESS" placeholder with no reference citations yet. --offline
  17. detects this placeholder (via the literal marker text) and SKIPS the citation
  18. check gracefully (noted on stderr, not a failure) rather than failing on an
  19. incomplete router. The constants check still runs regardless, since the
  20. reference files it inspects are independent of SKILL.md's state.
  21. Usage: check-iso-facts.py [--offline | --live] [--json] [--skill-dir DIR] [-q]
  22. Input: reads SKILL.md and every references/*.md (resolved relative to this
  23. script's parent directory, or --skill-dir)
  24. Output: stdout = data only (JSON envelope under --json, else a plain summary)
  25. Stderr: headers, progress, notes, errors
  26. Exit: 0 ok/consistent, 2 usage, 3 not-found, 4 validation (missing constant /
  27. uncited reference / unquotable package), 5 missing-dep (curl, --live
  28. only), 7 unavailable (registry unreachable, --live only),
  29. 10 drift (a named package is gone/renamed on the live registry)
  30. Examples:
  31. check-iso-facts.py --offline
  32. check-iso-facts.py --offline --json | python -m json.tool
  33. check-iso-facts.py --live
  34. check-iso-facts.py --live -q # exits 7 (advisory) when npm is unreachable
  35. """
  36. from __future__ import annotations
  37. import argparse
  38. import json
  39. import os
  40. import re
  41. import subprocess
  42. import sys
  43. from pathlib import Path
  44. from typing import NoReturn
  45. # Windows consoles default to cp1252; force UTF-8 so em-dashes/degree signs in
  46. # notes don't raise UnicodeEncodeError or print mojibake (repo-standard fix).
  47. for _stream in (sys.stdout, sys.stderr):
  48. try:
  49. _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
  50. except (AttributeError, ValueError):
  51. pass
  52. class Term:
  53. """Tiny ANSI helper mirroring skills/_lib/term.sh (bash-only per
  54. TERMINAL-DESIGN.md Section 9; this is the matching Python inline port).
  55. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII; glyphs fall back to ASCII on
  56. TERM_ASCII or a non-UTF stream encoding."""
  57. _C = {"green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
  58. "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m"}
  59. _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—",
  60. "na": "—", "unknown": "?"}
  61. _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
  62. _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
  63. "na": "dim", "unknown": "yellow"}
  64. def __init__(self, stream=sys.stderr):
  65. enc = (getattr(stream, "encoding", "") or "").lower()
  66. self.ascii = (os.environ.get("TERM_ASCII") == "1"
  67. or os.environ.get("FLEET_ASCII") == "1" or "utf" not in enc)
  68. if os.environ.get("FORCE_COLOR"):
  69. self.color = True
  70. elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb"
  71. or not getattr(stream, "isatty", lambda: False)()):
  72. self.color = False
  73. else:
  74. self.color = True
  75. def c(self, name, text):
  76. return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
  77. def mark(self, state):
  78. return self.c(self._MARK_COLOR.get(state, ""),
  79. (self._ASCII if self.ascii else self._GLYPH).get(state, "."))
  80. def hdr(self, text):
  81. return self.c("cyan", f"=== {text} ===")
  82. TERM = Term(sys.stderr)
  83. EXIT_OK = 0
  84. EXIT_USAGE = 2
  85. EXIT_NOT_FOUND = 3
  86. EXIT_VALIDATION = 4
  87. EXIT_MISSING_DEP = 5
  88. EXIT_UNAVAILABLE = 7
  89. EXIT_DRIFT = 10
  90. SCHEMA = "claude-mods.isometric-ops.iso-facts/v1"
  91. # The literal placeholder marker check-iso-facts.py uses to detect that
  92. # SKILL.md's body has not been synthesized yet (see build brief: SKILL.md
  93. # body is [SYNTHESIS — after everything]).
  94. BUILD_PLACEHOLDER_MARKER = "BUILD IN PROGRESS"
  95. # Canonical constants (brief's "CANONICAL CONSTANTS" table) that MUST appear,
  96. # verbatim as substrings, somewhere in references/projection-math.md. Each
  97. # entry is (label, substring). Substring matching (not full-precision floats)
  98. # because the source docs cite these to varying decimal places (e.g. "26.565"
  99. # and "26.5651" both legitimately appear) — the invariant we guard is that the
  100. # canonical short-form figure is present at least once, not a specific
  101. # formatting.
  102. CANONICAL_CONSTANTS = [
  103. ("2:1 dimetric ground-axis angle (arctan(1/2))", "26.565"),
  104. ("cube tilt / magic angle (arctan(1/sqrt2))", "35.264"),
  105. ("axonometric foreshortening (cos(35.264deg))", "81.65"),
  106. ("SSR vertical scale (cos(30deg))", "86.602"),
  107. ("Figma-hack height scale (tan(30deg))", "57.735"),
  108. ("CSS scale3d un-foreshorten (sqrt(3/2))", "1.22474"),
  109. ("CSS rotateX back-tip (90 - 35.264deg)", "54.736"),
  110. ]
  111. # 86.062 is a documented typo (SRC-B misprint) for 86.602 — it must NOT be
  112. # asserted as canonical anywhere without the correction being present nearby.
  113. KNOWN_TYPO = "86.062"
  114. # Packages named in prose across references/*.md that this skill treats as
  115. # facts subject to drift (existence / rename on the npm registry).
  116. NPM_PACKAGES = ["@elchininet/isometric", "isometric-css", "svgo"]
  117. CONSTANTS_FILE = "projection-math.md"
  118. def note(msg: str, quiet: bool) -> None:
  119. if not quiet:
  120. print(msg, file=sys.stderr)
  121. def fail_validation(message: str, details: dict, json_mode: bool) -> NoReturn:
  122. if json_mode:
  123. print(json.dumps({"error": {"code": "VALIDATION", "message": message,
  124. "details": details}}))
  125. print(f"{TERM.mark('bad')} ERROR: {message}", file=sys.stderr)
  126. for k, v in details.items():
  127. print(f" {k}: {v}", file=sys.stderr)
  128. sys.exit(EXIT_VALIDATION)
  129. def _have(tool: str) -> bool:
  130. from shutil import which
  131. return which(tool) is not None
  132. # ---------------------------------------------------------------------------
  133. # Offline checks
  134. # ---------------------------------------------------------------------------
  135. def check_constants(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
  136. """Assert every canonical constant appears in references/projection-math.md."""
  137. const_path = skill_dir / "references" / CONSTANTS_FILE
  138. if not const_path.is_file():
  139. if json_mode:
  140. print(json.dumps({"error": {"code": "NOT_FOUND",
  141. "message": f"missing file: {const_path}",
  142. "details": {}}}))
  143. print(f"ERROR: required file not found: {const_path}", file=sys.stderr)
  144. sys.exit(EXIT_NOT_FOUND)
  145. text = const_path.read_text(encoding="utf-8")
  146. missing = [{"label": label, "expected": needle}
  147. for label, needle in CANONICAL_CONSTANTS if needle not in text]
  148. if missing:
  149. fail_validation(
  150. f"{const_path.name} is missing canonical constant(s)",
  151. {"missing": ", ".join(m["expected"] for m in missing),
  152. "hint": "see the CANONICAL CONSTANTS table in the build brief"},
  153. json_mode)
  154. # The typo is allowed to appear ONLY as an explicitly-flagged correction
  155. # (i.e. the canonical 86.602 figure must also be present — already
  156. # asserted above — so a bare typo-only file would already have failed).
  157. typo_hits = text.count(KNOWN_TYPO)
  158. found = [{"label": label, "value": needle} for label, needle in CANONICAL_CONSTANTS]
  159. note(f" {len(found)}/{len(CANONICAL_CONSTANTS)} canonical constants present in "
  160. f"{const_path.name}", quiet)
  161. if typo_hits:
  162. note(f" note: {KNOWN_TYPO!r} (documented SRC-B typo) appears {typo_hits}x "
  163. f"alongside the corrected 86.602 figure", quiet)
  164. return {"file": str(const_path), "constants": found, "typo_mentions": typo_hits}
  165. REF_LINK_RE = re.compile(
  166. r"\[(?:[^\]]*)\]\(\s*(?:references/)?([a-z0-9-]+\.md)\s*\)" # [text](references/x.md) or [text](x.md)
  167. )
  168. REF_PATH_RE = re.compile(r"references/([a-z0-9-]+\.md)")
  169. REF_BACKTICK_RE = re.compile(r"`([a-z0-9-]+\.md)`")
  170. def _cited_basenames(text: str) -> set[str]:
  171. """Collect every reference basename cited in a markdown text, across the
  172. three citation styles seen in this repo's SKILL.md files: markdown links
  173. (relative or references/-prefixed), bare `references/x.md` paths, and
  174. backtick-quoted bare filenames (`x.md`)."""
  175. names: set[str] = set()
  176. for m in REF_LINK_RE.finditer(text):
  177. names.add(m.group(1))
  178. for m in REF_PATH_RE.finditer(text):
  179. names.add(m.group(1))
  180. for m in REF_BACKTICK_RE.finditer(text):
  181. names.add(m.group(1))
  182. return names
  183. def check_citations(skill_dir: Path, json_mode: bool, quiet: bool) -> dict | None:
  184. """Assert every references/*.md is cited from SKILL.md.
  185. Returns None (and notes a graceful skip) while SKILL.md's body is still
  186. the BUILD IN PROGRESS placeholder — per the brief, the router is
  187. synthesized last, after every reference lands, so there is nothing
  188. meaningful to check yet.
  189. """
  190. skill_md = skill_dir / "SKILL.md"
  191. refs_dir = skill_dir / "references"
  192. if not skill_md.is_file():
  193. if json_mode:
  194. print(json.dumps({"error": {"code": "NOT_FOUND",
  195. "message": f"missing file: {skill_md}",
  196. "details": {}}}))
  197. print(f"ERROR: required file not found: {skill_md}", file=sys.stderr)
  198. sys.exit(EXIT_NOT_FOUND)
  199. skill_text = skill_md.read_text(encoding="utf-8")
  200. if BUILD_PLACEHOLDER_MARKER in skill_text:
  201. note(f" SKILL.md body is still the {BUILD_PLACEHOLDER_MARKER!r} placeholder "
  202. "- skipping citation check (graceful, not a failure)", quiet)
  203. return None
  204. ref_files = sorted(p.name for p in refs_dir.glob("*.md")) if refs_dir.is_dir() else []
  205. if not ref_files:
  206. note(" no references/*.md files to check", quiet)
  207. return {"references": [], "uncited": []}
  208. cited = _cited_basenames(skill_text)
  209. uncited = [name for name in ref_files if name not in cited]
  210. if uncited:
  211. fail_validation(
  212. "reference file(s) not cited from SKILL.md",
  213. {"uncited": ", ".join(uncited),
  214. "hint": "every references/*.md must be linked or path-mentioned in SKILL.md"},
  215. json_mode)
  216. note(f" {len(ref_files)}/{len(ref_files)} references/*.md cited from SKILL.md", quiet)
  217. return {"references": ref_files, "uncited": []}
  218. def check_packages_named(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
  219. """Assert the packages this skill discusses are actually named somewhere
  220. under references/ — i.e. the prose hasn't drifted to omit/rename them
  221. without the docs being updated to match."""
  222. refs_dir = skill_dir / "references"
  223. if not refs_dir.is_dir():
  224. if json_mode:
  225. print(json.dumps({"error": {"code": "NOT_FOUND",
  226. "message": f"missing dir: {refs_dir}",
  227. "details": {}}}))
  228. print(f"ERROR: required directory not found: {refs_dir}", file=sys.stderr)
  229. sys.exit(EXIT_NOT_FOUND)
  230. blob = ""
  231. for p in sorted(refs_dir.glob("*.md")):
  232. blob += p.read_text(encoding="utf-8") + "\n"
  233. missing = [pkg for pkg in NPM_PACKAGES if pkg not in blob]
  234. if missing:
  235. fail_validation(
  236. "package(s) expected in prose are absent from references/*.md",
  237. {"missing": ", ".join(missing),
  238. "hint": "see the brief's contested-fact list (svg-vector-generation.md)"},
  239. json_mode)
  240. note(f" {len(NPM_PACKAGES)}/{len(NPM_PACKAGES)} named packages found in prose",
  241. quiet)
  242. return {"packages": list(NPM_PACKAGES)}
  243. def validate_offline(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
  244. note(TERM.hdr("offline iso-facts consistency check"), quiet)
  245. constants_result = check_constants(skill_dir, json_mode, quiet)
  246. citations_result = check_citations(skill_dir, json_mode, quiet)
  247. packages_result = check_packages_named(skill_dir, json_mode, quiet)
  248. note(f"{TERM.mark('ok')} OK: facts internally consistent.", quiet)
  249. return {
  250. "mode": "offline",
  251. "constants": constants_result,
  252. "citations": citations_result,
  253. "citations_skipped": citations_result is None,
  254. "packages": packages_result,
  255. "consistent": True,
  256. }
  257. # ---------------------------------------------------------------------------
  258. # Live checks
  259. # ---------------------------------------------------------------------------
  260. def fetch_npm_package(pkg: str, quiet: bool) -> dict | None:
  261. """Return the npm registry metadata dict for pkg, or None if unavailable
  262. (advisory — a network blip must never look like a real drift)."""
  263. url = f"https://registry.npmjs.org/{pkg.replace('/', '%2f')}"
  264. cmd = ["curl", "-fsS", "--max-time", "20", url]
  265. try:
  266. proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
  267. except (subprocess.TimeoutExpired, OSError) as exc:
  268. note(f"NOTE: registry lookup for {pkg} failed ({exc}) - advisory, not a failure.",
  269. quiet)
  270. return None
  271. if proc.returncode != 0:
  272. note(f"NOTE: registry unreachable for {pkg} (curl exit {proc.returncode}) "
  273. "- advisory.", quiet)
  274. return None
  275. try:
  276. payload = json.loads(proc.stdout)
  277. except json.JSONDecodeError:
  278. note(f"NOTE: registry returned non-JSON for {pkg} - advisory.", quiet)
  279. return None
  280. if not isinstance(payload, dict):
  281. return None
  282. return payload
  283. def validate_live(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
  284. if not _have("curl"):
  285. if json_mode:
  286. print(json.dumps({"error": {"code": "PRECONDITION",
  287. "message": "curl required for --live",
  288. "details": {}}}))
  289. print("ERROR: curl is required for --live", file=sys.stderr)
  290. sys.exit(EXIT_MISSING_DEP)
  291. note(TERM.hdr("live npm package-existence check"), quiet)
  292. results: dict[str, dict] = {}
  293. any_reachable = False
  294. drifted: list[str] = []
  295. for pkg in NPM_PACKAGES:
  296. payload = fetch_npm_package(pkg, quiet)
  297. if payload is None:
  298. results[pkg] = {"status": "unavailable"}
  299. continue
  300. any_reachable = True
  301. if payload.get("error") == "Not found" or "name" not in payload:
  302. results[pkg] = {"status": "drift", "reason": "not found on registry"}
  303. drifted.append(pkg)
  304. note(f"{TERM.mark('bad')} {TERM.c('red', 'DRIFT:')} {pkg} not found on npm",
  305. quiet)
  306. continue
  307. latest = (payload.get("dist-tags") or {}).get("latest")
  308. results[pkg] = {"status": "ok", "latest": latest}
  309. note(f" {pkg}: latest={latest}", quiet)
  310. if not any_reachable:
  311. # Every lookup failed to reach the network at all — advisory, exit 7.
  312. result = {"mode": "live", "status": "unavailable", "packages": results}
  313. if json_mode:
  314. print(json.dumps({"data": result,
  315. "meta": {"schema": SCHEMA, "status": "unavailable"}}))
  316. sys.exit(EXIT_UNAVAILABLE)
  317. result = {
  318. "mode": "live",
  319. "status": "drift" if drifted else "ok",
  320. "packages": results,
  321. "drifted": drifted,
  322. }
  323. if drifted:
  324. if json_mode:
  325. print(json.dumps({"data": result, "meta": {"schema": SCHEMA,
  326. "status": "drift"}}))
  327. else:
  328. print(f"DRIFT: package(s) gone/renamed on npm: {', '.join(drifted)}")
  329. sys.exit(EXIT_DRIFT)
  330. note(f"{TERM.mark('ok')} OK: all named packages resolve on the npm registry.", quiet)
  331. return result
  332. # ---------------------------------------------------------------------------
  333. # Main
  334. # ---------------------------------------------------------------------------
  335. def main(argv: list[str]) -> int:
  336. parser = argparse.ArgumentParser(
  337. prog="check-iso-facts.py", add_help=True,
  338. description="Staleness verifier for isometric-ops canonical constants + citations.",
  339. epilog=(
  340. "EXAMPLES:\n"
  341. " check-iso-facts.py --offline\n"
  342. " check-iso-facts.py --offline --json | python -m json.tool\n"
  343. " check-iso-facts.py --live\n"
  344. " check-iso-facts.py --live -q # exits 7 (advisory) when npm unreachable\n"
  345. ),
  346. formatter_class=argparse.RawDescriptionHelpFormatter,
  347. )
  348. mode = parser.add_mutually_exclusive_group()
  349. mode.add_argument("--offline", action="store_true",
  350. help="parse + assert internal consistency, no network (default)")
  351. mode.add_argument("--live", action="store_true",
  352. help="check named packages against the live npm registry (advisory)")
  353. parser.add_argument("--json", action="store_true",
  354. help="emit the JSON envelope on stdout")
  355. parser.add_argument("--skill-dir", default=None,
  356. help="skill root (default: parent of this script's dir)")
  357. parser.add_argument("-q", "--quiet", action="store_true",
  358. help="suppress stderr progress/notes")
  359. args = parser.parse_args(argv)
  360. if args.skill_dir:
  361. skill_dir = Path(args.skill_dir).resolve()
  362. else:
  363. skill_dir = Path(__file__).resolve().parent.parent
  364. if not skill_dir.is_dir():
  365. print(f"ERROR: skill dir not found: {skill_dir}", file=sys.stderr)
  366. return EXIT_NOT_FOUND
  367. if args.live:
  368. result = validate_live(skill_dir, args.json, args.quiet)
  369. else:
  370. result = validate_offline(skill_dir, args.json, args.quiet)
  371. if args.json:
  372. print(json.dumps({"data": result,
  373. "meta": {"schema": SCHEMA, "status": "ok"}}))
  374. return EXIT_OK
  375. if __name__ == "__main__":
  376. try:
  377. sys.exit(main(sys.argv[1:]))
  378. except KeyboardInterrupt:
  379. sys.exit(EXIT_USAGE)