| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- #!/usr/bin/env python3
- # Staleness verifier for the fast-moving facts the threejs-ops skill encodes.
- #
- # Two modes (SKILL-RESOURCE-PROTOCOL.md §7):
- # --offline (default): NO network. Asserts the skill is internally consistent —
- # assets/three-facts.json parses, the version gates
- # (examples/js removed r148, UMD builds removed r160) are
- # stated in SKILL.md, the npm 0.<release> scheme example is
- # arithmetically coherent, every package the facts file
- # commits to is documented somewhere in the skill, and the
- # importmap-starter.html import map parses with both "three"
- # entries pinned to the same version. Runs in PR CI, MAY block.
- # --live: network. Probes the npm registry: every committed package
- # must still resolve (a 404 means renamed/removed = drift),
- # and three's latest dist-tag must still use major 0
- # (a 1.x would be the API-break signal that the whole skill
- # needs a review pass). Runs in the scheduled freshness
- # workflow and NEVER blocks a PR: transient network failure
- # is UNAVAILABLE (exit 7); only confirmed change is DRIFT (10).
- #
- # Usage: check-three-facts.py [--offline|--live] [--json] [-q] [--timeout SEC]
- # Input: none (reads the skill's own assets/ + references/ relative to this file)
- # Output: stdout = data only (TSV findings, or the --json envelope)
- # Stderr: headers, progress, warnings, errors
- # Exit: 0 ok, 2 usage, 3 not-found (skill files missing), 4 validation
- # (offline inconsistency), 7 unavailable (live network), 10 drift
- #
- # Examples:
- # check-three-facts.py --offline
- # check-three-facts.py --offline --json | jq '.data[] | select(.status!="ok")'
- # check-three-facts.py --live --timeout 15
- """Staleness verifier for threejs-ops (see header comment)."""
- from __future__ import annotations
- import argparse
- import json
- import re
- import sys
- from pathlib import Path
- from urllib.parse import quote
- EXIT_OK = 0
- EXIT_USAGE = 2
- EXIT_NOT_FOUND = 3
- EXIT_VALIDATION = 4
- EXIT_UNAVAILABLE = 7
- EXIT_DRIFT = 10
- SCHEMA = "claude-mods.threejs-ops.facts/v1"
- SKILL_ROOT = Path(__file__).resolve().parent.parent
- FACTS = SKILL_ROOT / "assets" / "three-facts.json"
- STARTER = SKILL_ROOT / "assets" / "importmap-starter.html"
- REFS = SKILL_ROOT / "references"
- SKILL_MD = SKILL_ROOT / "SKILL.md"
- REGISTRY = "https://registry.npmjs.org"
- class Finding:
- __slots__ = ("check", "status", "detail")
- def __init__(self, check: str, status: str, detail: str) -> None:
- self.check = check
- self.status = status # ok | fail | drift | unavailable
- self.detail = detail
- def as_dict(self) -> dict:
- return {"check": self.check, "status": self.status, "detail": self.detail}
- class _NotFound(Exception):
- pass
- def read_text(path: Path) -> str:
- return path.read_text(encoding="utf-8", errors="replace")
- def load_facts(findings: list[Finding]) -> dict | None:
- try:
- facts = json.loads(read_text(FACTS))
- findings.append(Finding("facts-json", "ok", "three-facts.json parses"))
- return facts
- except json.JSONDecodeError as exc:
- findings.append(Finding("facts-json", "fail", f"invalid JSON: {exc}"))
- return None
- # --------------------------------------------------------------------------- #
- # Offline checks #
- # --------------------------------------------------------------------------- #
- def run_offline(findings: list[Finding]) -> None:
- missing = [p for p in (FACTS, STARTER, SKILL_MD, REFS) if not p.exists()]
- if missing:
- for p in missing:
- findings.append(Finding("files-present", "fail", f"missing: {p}"))
- raise _NotFound()
- facts = load_facts(findings)
- if facts is None:
- return # nothing else is checkable
- skill_md = read_text(SKILL_MD)
- all_docs = skill_md + "".join(read_text(p) for p in sorted(REFS.glob("*.md")))
- # O1 — schema + as_of stamped.
- if facts.get("schema") == SCHEMA and re.match(r"\d{4}-\d{2}-\d{2}$", facts.get("as_of", "")):
- findings.append(Finding("facts-meta", "ok", f"schema {SCHEMA}, as_of {facts['as_of']}"))
- else:
- findings.append(Finding("facts-meta", "fail",
- f"schema={facts.get('schema')!r} as_of={facts.get('as_of')!r}"))
- # O2 — every version gate the facts commit to is stated in SKILL.md.
- gates = facts.get("version_gates", {})
- gate_items = [(k, v) for k, v in gates.items() if k != "_comment"]
- if len(gate_items) < 2:
- findings.append(Finding("gates", "fail", f"expected >= 2 version gates, got {len(gate_items)}"))
- for key, gate in gate_items:
- if not gate or not re.match(r"r\d{3}$", str(gate)):
- findings.append(Finding(f"gate:{key}", "fail", f"malformed gate {gate!r} in facts"))
- elif re.search(rf"\*\*{gate}\*\*|\b{gate}\b", skill_md):
- findings.append(Finding(f"gate:{key}", "ok", f"{gate} stated in SKILL.md"))
- else:
- findings.append(Finding(f"gate:{key}", "fail", f"{gate} not stated in SKILL.md"))
- # O3 — npm scheme example arithmetic: "three@0.NNN.p is rNNN".
- scheme = facts.get("npm_scheme", {})
- m = re.match(r"three@0\.(\d+)\.\d+ is r(\d+)$", scheme.get("example", ""))
- if m and m.group(1) == m.group(2) and scheme.get("major") == 0:
- findings.append(Finding("npm-scheme", "ok", f"example coherent ({scheme['example']})"))
- else:
- findings.append(Finding("npm-scheme", "fail",
- f"example {scheme.get('example')!r} incoherent or major != 0"))
- # O4 — every committed package is documented somewhere in the skill prose.
- pkgs = facts.get("packages", {})
- if not pkgs:
- findings.append(Finding("packages", "fail", "facts commit to zero packages"))
- for name in pkgs:
- if name in all_docs:
- findings.append(Finding(f"pkg-documented:{name}", "ok", "mentioned in SKILL.md/references"))
- else:
- findings.append(Finding(f"pkg-documented:{name}", "fail",
- "in facts but never mentioned in skill prose"))
- # O5 — importmap starter: the import map parses, has both entries, pins one version.
- starter = read_text(STARTER)
- im = re.search(r'<script type="importmap">\s*(\{.*?\})\s*</script>', starter, re.S)
- if not im:
- findings.append(Finding("importmap", "fail", "no importmap block in starter"))
- else:
- try:
- imports = json.loads(im.group(1))["imports"]
- three_url = imports.get("three", "")
- addons_url = imports.get("three/addons/", "")
- v_three = re.search(r"three@(0\.\d+\.\d+)/", three_url)
- v_addons = re.search(r"three@(0\.\d+\.\d+)/", addons_url)
- if not (v_three and v_addons):
- findings.append(Finding("importmap", "fail",
- "starter import map missing pinned three/addons entries"))
- elif v_three.group(1) != v_addons.group(1):
- findings.append(Finding("importmap", "fail",
- f"version mismatch: three@{v_three.group(1)} vs addons@{v_addons.group(1)}"))
- elif not addons_url.endswith("/"):
- findings.append(Finding("importmap", "fail", "three/addons/ URL missing trailing slash"))
- else:
- findings.append(Finding("importmap", "ok",
- f"both entries pinned to three@{v_three.group(1)}"))
- except (json.JSONDecodeError, KeyError) as exc:
- findings.append(Finding("importmap", "fail", f"import map does not parse: {exc}"))
- # O6 — every cc0 source / reference repo has an https url.
- for group in ("cc0_sources", "reference_repos"):
- bad = [e.get("id", "?") for e in facts.get(group, [])
- if not str(e.get("url", "")).startswith("https://")]
- if bad:
- findings.append(Finding(group, "fail", "no https url: " + ", ".join(bad)))
- else:
- findings.append(Finding(group, "ok", f"{len(facts.get(group, []))} entries addressable"))
- # --------------------------------------------------------------------------- #
- # Live checks #
- # --------------------------------------------------------------------------- #
- def run_live(findings: list[Finding], timeout: float) -> None:
- import urllib.error
- import urllib.request
- facts = load_facts(findings)
- if facts is None:
- raise _NotFound()
- def fetch_latest(pkg: str) -> tuple[str, dict | None]:
- """Return (resolved|notfound|unavailable, latest-manifest-or-None)."""
- url = f"{REGISTRY}/{quote(pkg, safe='')}/latest"
- req = urllib.request.Request(url, headers={"User-Agent": "threejs-ops-staleness/1",
- "Accept": "application/json"})
- try:
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- if resp.status >= 400:
- return "unavailable", None
- return "resolved", json.loads(resp.read().decode("utf-8"))
- except urllib.error.HTTPError as e:
- if e.code in (404, 410):
- return "notfound", None
- return "unavailable", None
- except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
- return "unavailable", None
- # L1 — every committed package still resolves on npm.
- for name in facts.get("packages", {}):
- res, manifest = fetch_latest(name)
- if res == "resolved":
- findings.append(Finding(f"npm:{name}", "ok",
- f"latest {(manifest or {}).get('version', '?')}"))
- elif res == "notfound":
- findings.append(Finding(f"npm:{name}", "drift",
- "package gone from npm — renamed/removed, review skill"))
- else:
- findings.append(Finding(f"npm:{name}", "unavailable", "registry unreachable"))
- # L2 — three's versioning scheme still major-0 (a 1.x = API-break signal).
- res, manifest = fetch_latest("three")
- if res == "resolved":
- ver = (manifest or {}).get("version", "")
- if ver.startswith("0."):
- findings.append(Finding("three-major", "ok", f"three@{ver} still 0.<release> scheme"))
- else:
- findings.append(Finding("three-major", "drift",
- f"three@{ver} left major 0 — review the whole skill"))
- elif res == "notfound":
- findings.append(Finding("three-major", "drift", "npm has no 'three' package (!)"))
- else:
- findings.append(Finding("three-major", "unavailable", "registry unreachable"))
- # --------------------------------------------------------------------------- #
- # Main #
- # --------------------------------------------------------------------------- #
- def main(argv: list[str]) -> int:
- ap = argparse.ArgumentParser(add_help=True, description="threejs-ops staleness verifier")
- mode = ap.add_mutually_exclusive_group()
- mode.add_argument("--offline", action="store_true", help="structural/internal-consistency only (default)")
- mode.add_argument("--live", action="store_true", help="probe the npm registry (network)")
- ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
- ap.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress")
- ap.add_argument("--timeout", type=float, default=10.0, help="per-request timeout for --live (seconds)")
- try:
- args = ap.parse_args(argv)
- except SystemExit as e:
- # argparse exits 2 on bad args (matches USAGE); 0 on --help.
- return EXIT_USAGE if e.code not in (0, None) else EXIT_OK
- mode_name = "live" if args.live else "offline"
- def emit(msg: str) -> None:
- if not args.quiet:
- print(msg, file=sys.stderr)
- findings: list[Finding] = []
- emit(f"== check-three-facts ({mode_name}) ==")
- try:
- if args.live:
- run_live(findings, args.timeout)
- else:
- run_offline(findings)
- except _NotFound:
- if args.json:
- print(json.dumps({"error": {"code": "NOT_FOUND",
- "message": "skill files missing",
- "details": [f.as_dict() for f in findings]}}))
- for f in findings:
- emit(f" [{f.status.upper()}] {f.check}: {f.detail}")
- return EXIT_NOT_FOUND
- n_fail = sum(1 for f in findings if f.status == "fail")
- n_drift = sum(1 for f in findings if f.status == "drift")
- n_unavail = sum(1 for f in findings if f.status == "unavailable")
- # Output: stdout is data only.
- if args.json:
- print(json.dumps({
- "data": [f.as_dict() for f in findings],
- "meta": {"mode": mode_name, "count": len(findings),
- "fail": n_fail, "drift": n_drift, "unavailable": n_unavail,
- "schema": SCHEMA},
- }, indent=2))
- else:
- for f in findings:
- print(f"{f.check}\t{f.status}\t{f.detail}")
- # Progress summary to stderr.
- for f in findings:
- if f.status != "ok":
- emit(f" [{f.status.upper()}] {f.check}: {f.detail}")
- emit(f"-- {len(findings)} checks: {n_fail} fail, {n_drift} drift, {n_unavail} unavailable")
- # Exit precedence: inconsistency (offline) beats drift beats unavailable;
- # if the ONLY non-ok results are unavailable, exit 7, never 0.
- if n_fail:
- return EXIT_VALIDATION
- if n_drift:
- return EXIT_DRIFT
- if n_unavail:
- return EXIT_UNAVAILABLE
- return EXIT_OK
- if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
|