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