| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- #!/usr/bin/env python3
- """Staleness verifier for react-ops: the React 19 facts the skill encodes must
- stay real and named in the prose.
- react-ops centers on React 19 (use(), Actions, useActionState, useFormStatus,
- useOptimistic, React Compiler) and names an ecosystem stack (Zustand, Jotai,
- Redux Toolkit, TanStack Query, React Hook Form, Zod). That is exactly the fact
- that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): a package moves a major
- version upstream, or the prose stops mentioning a package the catalog lists, and
- nobody notices for months. Two modes guard it:
- --offline (default, safe for PR CI): structural consistency, no network.
- * assets/react-facts.json parses and every package + version gate is named
- somewhere in the skill prose (SKILL.md / references/*.md) — the catalog
- can't drift from the docs
- * SKILL.md still carries a dated "as of <year>" currency note
- --live (scheduled freshness.yml, never a PR gate): query the npm registry for
- each package's latest dist-tag; flag DRIFT when the live major is newer than
- the documented major (the skill is now behind), or when a package is gone
- (404). Transient registry failure is UNAVAILABLE (exit 7), never a failure.
- Usage: check-react-facts.py [--offline | --live] [--facts FILE] [--skill DIR] [--json] [--timeout S]
- Input: argv flags only (no stdin).
- Output: stdout = findings (plain rows, or a --json envelope). Data only.
- Stderr: the verdict line, notices, errors.
- Exit: 0 ok, 2 usage, 3 facts/skill missing, 4 facts unparseable,
- 7 npm registry unreachable (live, advisory — never a real failure),
- 10 drift (offline: uncited/undocumented/missing note; live: major ahead or gone)
- Examples:
- check-react-facts.py --offline # PR CI: catalog ⇆ prose consistency
- check-react-facts.py --live # weekly: is any documented major behind npm?
- check-react-facts.py --offline --json | jq '.data[]'
- """
- from __future__ import annotations
- import argparse
- import json
- import os
- import re
- import sys
- import urllib.error
- import urllib.parse
- import urllib.request
- from pathlib import Path
- EX_OK = 0
- EX_USAGE = 2
- EX_NOTFOUND = 3
- EX_UNPARSEABLE = 4
- EX_UNAVAILABLE = 7
- EX_DRIFT = 10
- SCHEMA = "claude-mods.react-ops.facts/v1"
- HERE = Path(__file__).resolve().parent
- DEFAULT_FACTS = HERE.parent / "assets" / "react-facts.json"
- DEFAULT_SKILL = HERE.parent
- REGISTRY = "https://registry.npmjs.org"
- CURRENCY_RE = re.compile(r"as of 20\d\d")
- class Term:
- """Minimal ANSI helper. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
- bound stream's TTY + encoding so piped data stays plain ASCII."""
- _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"}
- def __init__(self, stream=sys.stderr):
- enc = (getattr(stream, "encoding", "") or "").lower()
- self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
- if os.environ.get("FORCE_COLOR"):
- self.color = True
- elif (os.environ.get("NO_COLOR") is not None
- or os.environ.get("TERM") == "dumb"
- or not getattr(stream, "isatty", lambda: False)()):
- self.color = False
- else:
- self.color = True
- def c(self, name, text):
- return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
- def mark(self, ok):
- g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
- return self.c("green" if ok else "red", g)
- def load_facts(path: Path) -> dict:
- if not path.is_file():
- print(f"error: facts catalog not found: {path}", file=sys.stderr)
- raise SystemExit(EX_NOTFOUND)
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- if data.get("schema") != SCHEMA:
- raise ValueError(f"schema {data.get('schema')!r} != {SCHEMA!r}")
- if not isinstance(data.get("packages"), dict) or not data["packages"]:
- raise ValueError("'packages' must be a non-empty object")
- for name, info in data["packages"].items():
- if not isinstance(info, dict) or "documented_major" not in info:
- raise ValueError(f"package {name!r} missing documented_major")
- if not isinstance(info.get("prose"), list) or not info["prose"]:
- raise ValueError(f"package {name!r} missing prose tokens")
- return data
- except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
- print(f"error: could not parse facts {path}: {exc}", file=sys.stderr)
- raise SystemExit(EX_UNPARSEABLE)
- def read_corpus(skill_dir: Path) -> tuple[str, str]:
- """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md."""
- doc = skill_dir / "SKILL.md"
- if not doc.is_file():
- print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
- raise SystemExit(EX_NOTFOUND)
- skill_md = doc.read_text(encoding="utf-8", errors="replace")
- parts = [skill_md]
- for ref in sorted((skill_dir / "references").glob("*.md")):
- parts.append(ref.read_text(encoding="utf-8", errors="replace"))
- return skill_md, "\n".join(parts)
- def check_offline(facts: dict, skill_dir: Path) -> list[dict]:
- skill_md, corpus = read_corpus(skill_dir)
- findings: list[dict] = []
- for name, info in facts["packages"].items():
- for token in info["prose"]:
- if token not in corpus:
- findings.append({"package": name, "issue": f"prose token {token!r} not named in skill"})
- for key, token in facts.get("version_gates", {}).items():
- if key == "_comment":
- continue
- if str(token) not in corpus:
- findings.append({"package": "(gate)", "issue": f"version gate {key}={token!r} not stated in skill prose"})
- if not CURRENCY_RE.search(skill_md):
- findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of <year>' currency note"})
- return findings
- def npm_latest(name: str, timeout: float) -> tuple[str, object]:
- """Return (resolved|notfound|unavailable, version-string-or-status)."""
- url = f"{REGISTRY}/{urllib.parse.quote(name, safe='')}/latest"
- req = urllib.request.Request(url, method="GET",
- headers={"User-Agent": "claude-mods-react-ops-check/1",
- "Accept": "application/json"})
- try:
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- manifest = json.loads(resp.read().decode("utf-8"))
- return ("resolved", manifest.get("version", ""))
- except urllib.error.HTTPError as exc:
- if exc.code in (404, 410):
- return ("notfound", exc.code)
- return ("unavailable", exc.code)
- except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
- return ("unavailable", str(getattr(exc, "reason", exc)))
- def major_of(version: str) -> int | None:
- m = re.match(r"\d+", version.strip())
- return int(m.group(0)) if m else None
- def check_live(facts: dict, timeout: float) -> tuple[list[dict], list[dict]]:
- drift: list[dict] = []
- unreachable: list[dict] = []
- for name, info in facts["packages"].items():
- documented = info["documented_major"]
- status, info2 = npm_latest(name, timeout)
- if status == "notfound":
- drift.append({"package": name, "issue": "no longer resolves on npm (404) — renamed/removed"})
- elif status == "unavailable":
- unreachable.append({"package": name, "issue": f"registry unreachable: {info2}"})
- else:
- live = major_of(str(info2))
- if live is None:
- unreachable.append({"package": name, "issue": f"could not parse version {info2!r}"})
- elif live > documented:
- drift.append({"package": name,
- "issue": f"live major {live} ({info2}) ahead of documented major {documented}"})
- return drift, unreachable
- def main(argv: list[str]) -> int:
- p = argparse.ArgumentParser(
- prog="check-react-facts.py",
- description="Verify react-ops' React 19 facts stay named (offline) and current on npm (live).",
- )
- mode = p.add_mutually_exclusive_group()
- mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
- mode.add_argument("--live", action="store_true", help="check each package's npm major vs documented")
- p.add_argument("--facts", default=str(DEFAULT_FACTS), help="facts catalog JSON")
- p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
- p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
- p.add_argument("--json", action="store_true", help="emit a JSON envelope")
- try:
- args = p.parse_args(argv)
- except SystemExit as exc:
- return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
- facts = load_facts(Path(args.facts))
- live = args.live and not args.offline
- t = Term(sys.stderr)
- if live:
- drift, unreachable = check_live(facts, args.timeout)
- findings = drift + unreachable
- if args.json:
- print(json.dumps({
- "data": findings,
- "meta": {"mode": "live", "packages_checked": len(facts["packages"]),
- "drift": len(drift), "unreachable": len(unreachable),
- "registry": REGISTRY, "schema": SCHEMA},
- }, indent=2))
- else:
- for f in findings:
- kind = "DRIFT" if f in drift else "UNREACH"
- print(f"{kind} {f['package']}: {f['issue']}")
- if drift:
- print(f"{t.mark(False)} react-facts/live: {len(drift)} package(s) drifted "
- f"{t.c('dim', '(' + REGISTRY + ')')}", file=sys.stderr)
- return EX_DRIFT
- if unreachable:
- print(f"{t.mark(False)} react-facts/live: npm unreachable for "
- f"{len(unreachable)}/{len(facts['packages'])} {t.c('dim', '(advisory - retry next run)')}",
- file=sys.stderr)
- return EX_UNAVAILABLE
- print(f"{t.mark(True)} react-facts/live: all {len(facts['packages'])} package(s) "
- f"at or below documented major", file=sys.stderr)
- return EX_OK
- # offline (default)
- findings = check_offline(facts, Path(args.skill))
- if args.json:
- print(json.dumps({
- "data": findings,
- "meta": {"mode": "offline", "packages_checked": len(facts["packages"]),
- "drift": len(findings), "consistent": not findings, "schema": SCHEMA},
- }, indent=2))
- else:
- for f in findings:
- print(f"DRIFT {f['package']}: {f['issue']}")
- ok = not findings
- print(f"{t.mark(ok)} react-facts/offline: {len(facts['packages'])} package(s) + "
- f"{sum(1 for k in facts.get('version_gates', {}) if k != '_comment')} gate(s) checked, "
- f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}", file=sys.stderr)
- return EX_DRIFT if findings else EX_OK
- if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
|