Просмотр исходного кода

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

Adds the SKILL-RESOURCE-PROTOCOL.md §7 --offline/--live verifier split to
typescript-ops so the version-bearing facts it encodes (TypeScript major,
zod, valibot) can't drift silently.

- assets/typescript-facts.json: catalog of package + documented_major +
  where-asserted (TS major 4 anchored by "TS 4.4+" useUnknownInCatchVariables
  and "TypeScript 4.7+" variance; zod 3; valibot 0).
- scripts/check-typescript-facts.py: --offline asserts every catalogued
  package is named in the prose and SKILL.md carries a dated currency note
  (exit 0 clean / 10 drift, no network); --live queries the npm registry for
  each package's latest and exits 10 if the live major is newer than the
  documented major, 7 if unreachable. Stdlib-only Python 3; stdout data-only.
- SKILL.md: dated currency note ("> Ecosystem facts verified as of
  2026-07.") + a worked verifier citation.
- tests/run.sh: offline self-test exercising the verifier contract.

--offline passes (3/3 packages cited, currency note present, exit 0).

--live drift recorded (NOT fixed — prose majors reflect what the skill
documents, per the task instruction not to rewrite prose on live lag):
  typescript  npm@6.0.3  major 6 > documented 4
  zod         npm@4.4.3  major 4 > documented 3
  valibot     npm@1.4.2  major 1 > documented 0
All three have moved a major ahead of what the skill's prose anchors to; a
follow-up content pass should refresh the TS/zod/valibot version notes.

Co-Authored-By: Claude <noreply@anthropic.com>
0xDarkMatter 1 неделя назад
Родитель
Сommit
a07d116fe7

+ 7 - 0
skills/typescript-ops/SKILL.md

@@ -12,6 +12,13 @@ metadata:
 
 
 Comprehensive TypeScript skill covering the type system, generics, and production patterns.
 Comprehensive TypeScript skill covering the type system, generics, and production patterns.
 
 
+> Ecosystem facts verified as of 2026-07.
+
+**Staleness check:** `python scripts/check-typescript-facts.py --offline` asserts the
+catalogued version-bearing facts (TypeScript major, zod, valibot) are still named in the
+prose and the dated currency note above is present; run `--live` to confirm each package's
+npm major still matches the documented major. Catalog: `assets/typescript-facts.json`.
+
 ## Type Narrowing Decision Tree
 ## Type Narrowing Decision Tree
 
 
 ```
 ```

+ 0 - 0
skills/typescript-ops/assets/.gitkeep


+ 27 - 0
skills/typescript-ops/assets/typescript-facts.json

@@ -0,0 +1,27 @@
+{
+  "_comment": "Canonical fast-moving facts the typescript-ops skill encodes. scripts/check-typescript-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.",
+  "schema": "claude-mods.typescript-ops.facts/v1",
+  "as_of": "2026-07-05",
+  "registry": "https://registry.npmjs.org",
+  "currency_note": "> Ecosystem facts verified as of 2026-07.",
+  "packages": [
+    {
+      "name": "typescript",
+      "documented_major": 4,
+      "role": "language",
+      "where": "references/config-strict.md (useUnknownInCatchVariables, TS 4.4+); references/type-system.md (in/out variance annotations, TypeScript 4.7+). The skill never anchors a TS 5 feature, so its newest documented floor is the 4.x line."
+    },
+    {
+      "name": "zod",
+      "documented_major": 3,
+      "role": "runtime validation (default choice)",
+      "where": "SKILL.md (Runtime Validation); references/ecosystem.md (z.infer, z.discriminatedUnion, z.coerce). z.object/.infer/.discriminatedUnion/.coerce are the Zod 3+ surface."
+    },
+    {
+      "name": "valibot",
+      "documented_major": 0,
+      "role": "runtime validation (tree-shakeable alt)",
+      "where": "references/ecosystem.md (v.pipe, v.InferOutput, v.safeParse, v.picklist). Valibot shipped as 0.x through the period this skill was written."
+    }
+  ]
+}

+ 0 - 0
skills/typescript-ops/scripts/.gitkeep


+ 253 - 0
skills/typescript-ops/scripts/check-typescript-facts.py

@@ -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:]))

+ 98 - 0
skills/typescript-ops/tests/run.sh

@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+# Self-test for the typescript-ops skill.
+#
+# Offline-deterministic (no network, no TypeScript compiler required). Asserts
+# structural integrity (frontmatter, references present + linked) and — the
+# load-bearing check — the staleness verifier contract (SKILL-RESOURCE-PROTOCOL
+# §7, §10): the catalogued version-bearing facts (TypeScript major, zod,
+# valibot) stay named in the prose and the dated currency note stays present.
+# Resolves paths relative to itself so it works in the repo and once installed
+# to ~/.claude/skills/typescript-ops/.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+DOC="$SKILL/SKILL.md"
+REF="$SKILL/references"
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+has() { case "$2" in *"$1"*) ok "$3";; *) no "$3 (missing '$1')";; esac; }
+
+echo "=== typescript-ops self-test ==="
+
+# ── SKILL.md frontmatter ───────────────────────────────────────────────────
+echo "-- frontmatter --"
+[[ -f "$DOC" ]] && ok "SKILL.md present" || { no "SKILL.md missing"; echo "=== $PASS passed, $FAIL failed ==="; exit 1; }
+[[ "$(sed -n '1p' "$DOC")" == "---" ]] && ok "frontmatter fence opens at line 1" || no "no opening frontmatter fence"
+doc="$(cat "$DOC")"
+has 'name: typescript-ops' "$doc" "frontmatter declares name: typescript-ops"
+has 'description:'        "$doc" "frontmatter has description"
+has 'license: MIT'        "$doc" "frontmatter declares license"
+has 'author: claude-mods' "$doc" "frontmatter declares metadata.author"
+
+# ── references: the 5 documented files exist ───────────────────────────────
+echo "-- references present --"
+EXPECT=(type-system utility-types generics-patterns config-strict ecosystem)
+for r in "${EXPECT[@]}"; do
+  f="$REF/$r.md"
+  [[ -f "$f" ]] && ok "$r.md present" || no "$r.md missing"
+done
+
+# ── every references/ citation in SKILL.md resolves (no ghost refs) ────────
+# typescript-ops cites its references as inline code spans (`./references/x.md`),
+# not markdown links — extract those spans and confirm each file exists.
+echo "-- cited references resolve --"
+linked=0; broken=0
+while IFS= read -r rel; do
+  [[ -z "$rel" ]] && continue
+  linked=$((linked+1))
+  [[ -f "$SKILL/$rel" ]] || { no "SKILL.md cites missing file: $rel"; broken=$((broken+1)); }
+done < <(grep -oE '`(\./)?references/[^`#]+\.md`' "$DOC" | sed -E 's/^`//; s/`$//; s/^\.\///' | sort -u)
+[[ "$linked" -gt 0 ]] && ok "SKILL.md cites its references ($linked unique)" || no "SKILL.md cites no references"
+[[ "$broken" -eq 0 ]] && ok "all cited reference files resolve" || no "$broken reference citation(s) broken"
+
+# ── dated currency note present (verifier depends on it) ───────────────────
+echo "-- currency note --"
+grep -qE 'as of 20[0-9]{2}' "$DOC" && ok "dated 'as of 20XX' currency note present" || no "no dated currency note"
+
+# ── staleness verifier: offline contract (SKILL-RESOURCE-PROTOCOL §7) ───────
+echo "-- check-typescript-facts.py (offline) --"
+VERIFIER="$SKILL/scripts/check-typescript-facts.py"
+CATALOG="$SKILL/assets/typescript-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)"; }
+# Pick a python that actually executes — skips the Windows Store python3 stub.
+PY=""
+for c in python python3 py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi
+done
+[[ -f "$VERIFIER" ]] && ok "verifier present" || no "verifier missing"
+[[ -f "$CATALOG"  ]] && ok "facts catalog present" || no "catalog missing"
+has "scripts/check-typescript-facts.py" "$doc" "verifier cited from SKILL.md"
+if [[ -n "$PY" ]]; then
+  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+  ec 0 "py_compile"            "$PY" -m py_compile "$VERIFIER"
+  ec 0 "--help"                "$PY" "$VERIFIER" --help
+  ec 0 "--offline consistent"  "$PY" "$VERIFIER" --offline
+  ec 2 "bad flag -> 2"         "$PY" "$VERIFIER" --bogus
+  ec 2 "conflicting modes -> 2" "$PY" "$VERIFIER" --offline --live
+  jout="$("$PY" "$VERIFIER" --offline --json 2>/dev/null)"
+  has 'claude-mods.typescript-ops.facts/v1' "$jout" "--json envelope schema"
+  ec 3 "missing catalog -> 3"  "$PY" "$VERIFIER" --offline --catalog "$TMP/nope.json"
+  printf '{"packages":"x"}' > "$TMP/bad.json"
+  ec 4 "malformed catalog -> 4" "$PY" "$VERIFIER" --offline --catalog "$TMP/bad.json"
+  printf '{"packages":[{"name":"zzznotreal","documented_major":3}]}' > "$TMP/drift.json"
+  ec 10 "uncited package -> 10" "$PY" "$VERIFIER" --offline --catalog "$TMP/drift.json"
+else
+  no "no working python to exercise the verifier"
+fi
+
+# ── summary ────────────────────────────────────────────────────────────────
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1