| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- #!/usr/bin/env python3
- """Staleness verifier for typescript-ops: the version-bearing facts the skill
- encodes must stay real and cited.
- typescript-ops anchors its currency to a few version-bearing facts — the
- TypeScript major the prose assumes (feature floors like "TS 4.4+",
- "TypeScript 4.7+"), and the runtime-validation stack (zod, valibot). That is
- exactly the fact that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): a
- package leaves its documented major upstream, or the prose stops naming a
- package the catalog still commits to, and nobody notices for months. Two
- modes guard it:
- --offline (default, safe for PR CI): structural consistency, no network.
- * assets/typescript-facts.json parses; every entry has name + documented_major
- * every catalogued package is still 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 20XX" currency note
- --live (scheduled freshness.yml, never a PR gate): does each package's
- latest published major on npm still match the documented major? A newer
- major = the skill is behind reality (drift). Exit 7 if npm is unreachable.
- Usage: check-typescript-facts.py [--offline | --live] [--catalog 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 catalog/skill missing, 4 catalog unparseable,
- 7 npm unreachable (live, advisory — never a real failure),
- 10 drift found (offline: uncited/undocumented/no currency note;
- live: published major newer than documented major)
- Examples:
- check-typescript-facts.py --offline # PR CI: catalog ⇆ prose consistency
- check-typescript-facts.py --live # weekly: every package's major still matches npm
- check-typescript-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
- HERE = Path(__file__).resolve().parent
- DEFAULT_CATALOG = HERE.parent / "assets" / "typescript-facts.json"
- DEFAULT_SKILL = HERE.parent
- DEFAULT_REGISTRY = "https://registry.npmjs.org"
- SCHEMA = "claude-mods.typescript-ops.facts/v1"
- CURRENCY_RE = re.compile(r"as of 20\d\d")
- def eprint(*a) -> None:
- print(*a, file=sys.stderr)
- class Term:
- """Minimal ANSI helper (term.sh is bash-only; per TERMINAL-DESIGN.md §9 the
- Python port is inline). 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) -> None:
- 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: str, text: str) -> str:
- return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
- def mark(self, ok: bool) -> str:
- g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
- return self.c("green" if ok else "red", g)
- def load_catalog(path: Path) -> tuple[list[dict], str]:
- """Returns (packages, registry). Each package has name + documented_major."""
- if not path.is_file():
- eprint(f"error: package catalog not found: {path}")
- raise SystemExit(EX_NOTFOUND)
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- pkgs = data["packages"]
- if not isinstance(pkgs, list) or not pkgs:
- raise ValueError("'packages' must be a non-empty array")
- for p in pkgs:
- if not isinstance(p, dict) or "name" not in p or "documented_major" not in p:
- raise ValueError(f"package entry missing name/documented_major: {p!r}")
- dm = p["documented_major"]
- if not isinstance(dm, int) or isinstance(dm, bool) or dm < 0:
- raise ValueError(f"documented_major must be a non-negative int: {p!r}")
- registry = data.get("registry") or DEFAULT_REGISTRY
- return pkgs, registry
- except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
- eprint(f"error: could not parse catalog {path}: {exc}")
- 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():
- eprint(f"error: SKILL.md not found under {skill_dir}")
- raise SystemExit(EX_NOTFOUND)
- skill_md = doc.read_text(encoding="utf-8")
- parts = [skill_md]
- for ref in sorted((skill_dir / "references").glob("*.md")):
- parts.append(ref.read_text(encoding="utf-8"))
- return skill_md, "\n".join(parts)
- def check_offline(pkgs: list[dict], skill_dir: Path) -> list[dict]:
- skill_md, corpus = read_corpus(skill_dir)
- findings: list[dict] = []
- for p in pkgs:
- name = p["name"]
- # case-sensitive exact substring: npm names are case-sensitive (zod != Zod)
- if name not in corpus:
- findings.append({"package": name, "issue": "catalogued but not named in skill prose"})
- if not CURRENCY_RE.search(skill_md):
- findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of 20XX' currency note"})
- return findings
- def npm_latest(registry: str, name: str, timeout: float) -> tuple[str, object]:
- """Return ('ok', version_str) | ('gone', None) | ('unreachable', info)."""
- url = registry.rstrip("/") + "/" + urllib.parse.quote(name, safe="") + "/latest"
- req = urllib.request.Request(url, method="GET",
- headers={"User-Agent": "claude-mods-typescript-ops-check/1",
- "Accept": "application/json"})
- try:
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- data = json.loads(resp.read().decode("utf-8"))
- return ("ok", data.get("version", ""))
- except urllib.error.HTTPError as exc:
- if exc.code in (404, 410):
- return ("gone", None)
- return ("unreachable", exc.code) # 5xx etc: transient, not a content finding
- except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
- return ("unreachable", str(getattr(exc, "reason", exc)))
- def major_of(version: str) -> int | None:
- m = re.match(r"\D*(\d+)", version or "")
- return int(m.group(1)) if m else None
- def check_live(pkgs: list[dict], registry: str, timeout: float) -> tuple[list[dict], list[dict]]:
- drift: list[dict] = []
- unreachable: list[dict] = []
- for p in pkgs:
- name = p["name"]
- doc = p["documented_major"]
- status, info = npm_latest(registry, name, timeout)
- if status == "gone":
- drift.append({"package": name, "issue": "no longer resolves on npm (404)"})
- elif status != "ok":
- unreachable.append({"package": name, "issue": f"unreachable: {info}"})
- else:
- live_major = major_of(str(info))
- if live_major is None:
- unreachable.append({"package": name, "issue": f"could not parse version {info!r}"})
- elif live_major > doc:
- drift.append({"package": name,
- "issue": f"npm@{info} major {live_major} > documented major {doc}"})
- return drift, unreachable
- def main(argv: list[str]) -> int:
- p = argparse.ArgumentParser(
- prog="check-typescript-facts.py",
- description="Verify typescript-ops' version-bearing facts stay cited (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 every package's latest major still matches npm")
- p.add_argument("--catalog", default=str(DEFAULT_CATALOG), 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)
- pkgs, registry = load_catalog(Path(args.catalog))
- live = args.live and not args.offline
- t = Term(sys.stderr)
- if live:
- drift, unreachable = check_live(pkgs, registry, args.timeout)
- findings = drift + unreachable
- if args.json:
- print(json.dumps({
- "data": findings,
- "meta": {"mode": "live", "packages_checked": len(pkgs),
- "drift": len(drift), "unreachable": len(unreachable),
- "registry": registry, "schema": SCHEMA},
- }, indent=2))
- else:
- for f in drift:
- print(f"DRIFT {f['package']}: {f['issue']}")
- for f in unreachable:
- print(f"UNREACH {f['package']}: {f['issue']}")
- # §7: confirmed drift -> 10; else transient/unreachable -> 7 (advisory); else 0.
- if drift:
- eprint(f"{t.mark(False)} ts-facts/live: {len(drift)} package(s) drifted from documented major "
- f"{t.c('dim', '(' + registry + ')')}")
- return EX_DRIFT
- if unreachable:
- eprint(f"{t.mark(False)} ts-facts/live: npm unreachable for "
- f"{len(unreachable)}/{len(pkgs)} {t.c('dim', '(advisory - retry next run)')}")
- return EX_UNAVAILABLE
- eprint(f"{t.mark(True)} ts-facts/live: all {len(pkgs)} package(s) match documented major on npm")
- return EX_OK
- # offline (default)
- findings = check_offline(pkgs, Path(args.skill))
- if args.json:
- print(json.dumps({
- "data": findings,
- "meta": {"mode": "offline", "packages_checked": len(pkgs),
- "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
- eprint(f"{t.mark(ok)} ts-facts/offline: {len(pkgs)} package(s) checked, "
- f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}")
- return EX_DRIFT if findings else EX_OK
- if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
|