Browse Source

feat(skills): Add staleness verifier to react-ops

Adds the §7 staleness-verifier split (offline structural / live npm) to the
react-ops skill, cloned from the threejs-ops / r-ops house pattern.

Files:
- assets/react-facts.json — catalogs the React 19 facts the skill encodes:
  react/react-dom at major 19, the React 19 API gates (use, Actions,
  useActionState, useFormStatus, useOptimistic, React Compiler), and the named
  ecosystem stack (next, zustand, jotai, @reduxjs/toolkit, @tanstack/react-query,
  react-hook-form, zod, @testing-library/react, vitest).
- scripts/check-react-facts.py — stdlib-only Python 3.
    --offline: every catalogued package + React 19 gate is still named in the
               skill prose, and SKILL.md carries a dated 'as of <year>' note.
               Exit 0 clean / 10 drift.
    --live:    queries registry.npmjs.org/<pkg>/latest; exit 10 if the live
               major is newer than the documented major (or the package is gone),
               7 if the registry is unreachable. Never modifies files.
- SKILL.md — adds the "> React 19 ecosystem facts verified as of 2026-07."
  currency note near the top and a Staleness Verifier section citing the script.
- tests/run.sh — offline self-test; exits nonzero if --offline fails.

Results:
- --offline: PASS (exit 0; 11 packages + 6 gates, 0 inconsistencies)
- --live: PASS (exit 0; all packages at or below documented major)

Live drift note: the first --live run flagged next (16), zod (4), and vitest (4)
ahead of my initial documented_major guesses (15/3/3). The react-ops prose does
not pin those majors (it says "Next.js App Router", "Zod", "Vitest" with no
number) — only react/react-dom are pinned, via "React 19", and those are current.
So this was a stale catalog, not stale prose: documented_major for the un-pinned
ecosystem libs now tracks the current major so future bumps are caught, rather
than locking in a guess that would report a permanent false drift. No skill-prose
rewrites.

Co-Authored-By: Claude <noreply@anthropic.com>
0xDarkMatter 6 days ago
parent
commit
e66a1fc5c6

+ 21 - 0
skills/react-ops/SKILL.md

@@ -12,6 +12,8 @@ metadata:
 
 Comprehensive React skill covering hooks, component architecture, state management, Server Components, and performance optimization.
 
+> React 19 ecosystem facts verified as of 2026-07.
+
 ## Hook Selection Decision Tree
 
 ```
@@ -265,6 +267,25 @@ Does this component need...?
 | `./references/performance.md` | React.memo, code splitting, virtualization, React Compiler, Web Vitals, profiling |
 | `./references/testing.md` | RTL queries, user-event, MSW, renderHook, Vitest setup, accessibility testing |
 
+## Staleness Verifier
+
+This skill encodes fast-moving facts (the React 19 API surface, the ecosystem
+package stack). [`scripts/check-react-facts.py`](scripts/check-react-facts.py)
+guards them against silent drift — internal consistency in PR CI, live
+major-version drift in the scheduled freshness job:
+
+```bash
+# Structural (PR CI, no network): every catalogued package + React 19 gate is
+# still named in this skill's prose, and the currency note still carries a year.
+python3 skills/react-ops/scripts/check-react-facts.py --offline        # exit 0 consistent, 10 drift
+
+# Live (weekly freshness job, never blocks a PR): is any documented major
+# now behind npm's latest dist-tag?
+python3 skills/react-ops/scripts/check-react-facts.py --live           # exit 10 a major moved ahead, 7 npm unreachable
+```
+
+The canonical fact list lives in [`assets/react-facts.json`](assets/react-facts.json); when you add or drop a recommendation or the prose stops naming one, update it to match or `--offline` fails CI.
+
 ## See Also
 
 | Skill | When to Combine |

+ 28 - 0
skills/react-ops/assets/react-facts.json

@@ -0,0 +1,28 @@
+{
+  "_comment": "Canonical fast-moving facts the react-ops skill encodes. scripts/check-react-facts.py asserts SKILL.md + references name these consistently (--offline) and probes the npm registry for major-version drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping. documented_major is the major the skill's prose commits to (react/react-dom/next) or the current tracked major for ecosystem libs the prose names without pinning a version.",
+  "schema": "claude-mods.react-ops.facts/v1",
+  "as_of": "2026-07-05",
+  "react_major": 19,
+  "version_gates": {
+    "_comment": "React 19 APIs the skill centers on. --offline asserts each token is named in SKILL.md/references prose; a missing token means the skill stopped teaching a React 19 feature it claims.",
+    "use": "use(",
+    "actions": "Actions",
+    "use_action_state": "useActionState",
+    "use_form_status": "useFormStatus",
+    "use_optimistic": "useOptimistic",
+    "react_compiler": "React Compiler"
+  },
+  "packages": {
+    "react":              { "documented_major": 19, "prose": ["react"],              "role": "core (React 19)" },
+    "react-dom":          { "documented_major": 19, "prose": ["react-dom"],          "role": "DOM host (useFormStatus, createRoot)" },
+    "next":               { "documented_major": 16, "prose": ["Next.js"],            "role": "App Router / RSC host" },
+    "zustand":            { "documented_major": 5,  "prose": ["Zustand"],            "role": "client state" },
+    "jotai":              { "documented_major": 2,  "prose": ["Jotai"],              "role": "atomic client state" },
+    "@reduxjs/toolkit":   { "documented_major": 2,  "prose": ["Redux Toolkit"],      "role": "complex client state" },
+    "@tanstack/react-query": { "documented_major": 5, "prose": ["TanStack Query"],   "role": "server state" },
+    "react-hook-form":    { "documented_major": 7,  "prose": ["React Hook Form"],    "role": "form state" },
+    "zod":                { "documented_major": 4,  "prose": ["Zod"],                "role": "schema validation" },
+    "@testing-library/react": { "documented_major": 16, "prose": ["@testing-library/react"], "role": "component testing" },
+    "vitest":             { "documented_major": 4,  "prose": ["vitest"],             "role": "test runner" }
+  }
+}

+ 249 - 0
skills/react-ops/scripts/check-react-facts.py

@@ -0,0 +1,249 @@
+#!/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:]))

+ 67 - 0
skills/react-ops/tests/run.sh

@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Offline self-test for the react-ops skill — structure, frontmatter, and the
+# staleness-verifier contract (SKILL-RESOURCE-PROTOCOL.md §7, §10).
+#
+# Offline-deterministic (no network, no React install). Resolves paths relative
+# to itself so it works in the repo and once installed to ~/.claude/skills/.
+#
+# Usage:   bash tests/run.sh
+# Input:   none (self-contained; no network)
+# Output:  TAP-ish progress on stderr; final PASS/FAIL line.
+# Exit:    0 all pass, 1 any failure
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+DOC="$SKILL/SKILL.md"
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1" >&2; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1" >&2; }
+
+# Resolve a *working* python (python3, else python). The bare `command -v` is
+# not enough on Windows, where `python3` is a Microsoft Store stub that exits
+# nonzero. Skip the whole verifier block if none works.
+PY=""
+for c in python3 python py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi
+done
+
+echo "=== react-ops self-test ===" >&2
+
+# ── SKILL.md frontmatter ───────────────────────────────────────────────────
+[[ -f "$DOC" ]] && ok "SKILL.md present" || { no "SKILL.md missing"; echo "=== $PASS passed, $FAIL failed ===" >&2; exit 1; }
+doc="$(cat "$DOC")"
+case "$doc" in *"name: react-ops"*) ok "frontmatter declares name: react-ops";; *) no "frontmatter name != react-ops";; esac
+case "$doc" in *"license: MIT"*) ok "frontmatter declares license: MIT";; *) no "missing license: MIT";; esac
+case "$doc" in *"as of 20"*) ok "currency note carries a year";; *) no "no dated 'as of <year>' currency note";; esac
+
+# ── resources present + cited ──────────────────────────────────────────────
+for res in assets/react-facts.json scripts/check-react-facts.py; do
+  [[ -f "$SKILL/$res" ]] && ok "resource present: $res" || no "missing resource: $res"
+done
+case "$doc" in *"scripts/check-react-facts.py"*) ok "verifier cited from SKILL.md";; *) no "verifier uncited";; esac
+
+# ── staleness verifier: offline contract (§7) ───────────────────────────────
+if [[ -n "$PY" ]]; then
+  V="$SKILL/scripts/check-react-facts.py"
+  F="$SKILL/assets/react-facts.json"
+  ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
+         [[ "$got" == "$want" ]] && ok "$lbl (exit $got)" || no "$lbl (want $want got $got)"; }
+  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+  ec 0 "py_compile"            "$PY" -m py_compile "$V"
+  ec 0 "--help"                "$PY" "$V" --help
+  ec 0 "--offline consistent"  "$PY" "$V" --offline
+  ec 2 "bad flag -> 2"         "$PY" "$V" --bogus
+  ec 2 "conflicting modes -> 2" "$PY" "$V" --offline --live
+  jout="$("$PY" "$V" --offline --json 2>/dev/null)"
+  case "$jout" in *"claude-mods.react-ops.facts/v1"*) ok "--json envelope schema";; *) no "--json envelope schema missing";; esac
+  ec 3 "missing facts -> 3"    "$PY" "$V" --offline --facts "$TMP/nope.json"
+  printf '{"schema":"claude-mods.react-ops.facts/v1","packages":{"zzz":{"documented_major":1,"prose":["zzznotreal"]}}}' > "$TMP/drift.json"
+  ec 10 "uncited package -> 10" "$PY" "$V" --offline --facts "$TMP/drift.json"
+else
+  no "no working python to exercise the verifier"
+fi
+
+echo "=== $PASS passed, $FAIL failed ===" >&2
+[[ "$FAIL" -eq 0 ]] || exit 1