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

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

Catalog the framework/language target versions migrate-ops hardcodes (React
19, Laravel 11, Python 3.12, Node 22, TypeScript 5, Go 1.22, Rust 2024,
PHP 8.4) in assets/migrate-facts.json, recording where each appears
(description vs body), and guard them with scripts/check-migrate-facts.py
following the §7 offline/live split:

- --offline (PR CI): assets/migrate-facts.json parses + carries schema/as_of;
  every claim's regex still matches in each location it is recorded as
  appearing (description vs body); SKILL.md still carries a dated currency
  note. Exit 0 consistent / 10 drift.
- --live (freshness job, never blocks a PR): resolves current stable versions
  via endoflife.date (python, nodejs, laravel, php, go) and npm (react,
  typescript); flags any documented target no longer the latest stable
  major/line. Rust 2024 is an edition, not registry-tracked -> offline only.
  Exit 10 a target lags / 7 source unreachable.

SKILL.md gains a "> Ecosystem facts verified as of 2026-07." currency note
and a staleness-verifier citation block; tests/run.sh exercises the offline
contract (frontmatter, citations, exit-code semantics, --json envelope, and
a negative missing-claim case).

--offline: PASS (exit 0, 17 checks — 8 targets x 2 locations + currency note).
--live (recorded, prose intentionally NOT rewritten per task):
  - React       npm          latest 19.2.7   (major 19 == documented)   OK
  - TypeScript  npm          latest 6.0.3    major 6 != documented 5    DRIFT
  - Rust        (edition)    not live-tracked                            OK
  - Laravel/Python/Node/Go/PHP  endoflife.date  unreachable from run env  UNAVAILABLE
Confirmed drift: TypeScript has shipped a 6.x major (skill documents 5) — the
review signal this verifier exists to catch. The endoflife.date products
could not be reached from this environment (npm was reachable, endoflife.date
was not; treated as advisory/unavailable, not confirmed dead) — the scheduled
freshness job in a fully-connected environment will resolve them.

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

+ 18 - 0
skills/migrate-ops/SKILL.md

@@ -12,6 +12,8 @@ metadata:
 
 Comprehensive migration skill covering framework upgrades, language version bumps, dependency auditing, breaking change detection, codemods, and rollback strategies.
 
+> Ecosystem facts verified as of 2026-07.
+
 ## Migration Strategy Decision Tree
 
 ```
@@ -268,6 +270,22 @@ Migration failed or caused issues — how to roll back?
 | `references/language-upgrades.md` | Python 3.9→3.13, Node 18→22, TypeScript 4→5, Go 1.20→1.23, Rust 2021→2024, PHP 8.1→8.4 | ~600 |
 | `references/dependency-management.md` | Audit tools, update strategies, lock files, monorepo deps, supply chain security | ~550 |
 
+## Staleness verifier
+
+This skill hardcodes specific framework/language target versions (React 19, Laravel 11, Python 3.12, Node 22, TypeScript 5, Go 1.22, Rust 2024, PHP 8.4). [`scripts/check-migrate-facts.py`](scripts/check-migrate-facts.py) guards them against silent drift:
+
+```bash
+# Structural (PR CI, no network): every catalogued target version still appears
+# where it is recorded (description vs body), and the currency note carries a year.
+python scripts/check-migrate-facts.py --offline        # exit 0 consistent, 10 drift
+
+# Live (freshness job, never blocks a PR): each target is resolved against
+# endoflife.date (python/nodejs/laravel/php/go) and npm (react/typescript).
+python scripts/check-migrate-facts.py --live            # exit 10 a target lags latest, 7 unreachable
+```
+
+The canonical target-version list lives in [`assets/migrate-facts.json`](assets/migrate-facts.json); when you change a recommended target, update it to match or `--offline` fails CI. A `--live` drift means the skill is naming an older target than the ecosystem's current stable — review, don't auto-rewrite.
+
 ## See Also
 
 | Skill | When to Combine |

+ 64 - 0
skills/migrate-ops/assets/migrate-facts.json

@@ -0,0 +1,64 @@
+{
+  "_comment": "Canonical fast-moving facts the migrate-ops skill encodes: the framework/language target versions it hardcodes in its description and body. scripts/check-migrate-facts.py asserts each claim still appears where recorded + a dated currency note (--offline), and resolves the current versions via endoflife.date + npm to flag lag (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping.",
+  "schema": "claude-mods.migrate-ops.facts/v1",
+  "as_of": "2026-07-05",
+  "claims": [
+    {
+      "label": "React",
+      "version": "19",
+      "where": ["description", "body"],
+      "pattern": "react[^\\n]*\\b19\\b",
+      "live": {"source": "npm", "product": "react", "compare": "major", "documented": "19"}
+    },
+    {
+      "label": "Laravel",
+      "version": "11",
+      "where": ["description", "body"],
+      "pattern": "laravel[^\\n]*\\b11\\b",
+      "live": {"source": "endoflife", "product": "laravel", "compare": "major", "documented": "11"}
+    },
+    {
+      "label": "Python",
+      "version": "3.12",
+      "where": ["description", "body"],
+      "pattern": "python[^\\n]*3\\.12\\b",
+      "live": {"source": "endoflife", "product": "python", "compare": "line", "documented": "3.12"}
+    },
+    {
+      "label": "Node",
+      "version": "22",
+      "where": ["description", "body"],
+      "pattern": "node[^\\n]*\\b22\\b",
+      "live": {"source": "endoflife", "product": "nodejs", "compare": "major", "documented": "22"}
+    },
+    {
+      "label": "TypeScript",
+      "version": "5",
+      "where": ["description", "body"],
+      "pattern": "typescript[^\\n]*\\b5\\b",
+      "live": {"source": "npm", "product": "typescript", "compare": "major", "documented": "5"}
+    },
+    {
+      "label": "Go",
+      "version": "1.22",
+      "where": ["description", "body"],
+      "pattern": "go[^\\n]*1\\.22\\b",
+      "live": {"source": "endoflife", "product": "go", "compare": "line", "documented": "1.22"}
+    },
+    {
+      "label": "Rust",
+      "version": "2024",
+      "where": ["description", "body"],
+      "pattern": "rust[^\\n]*2024\\b",
+      "live": null,
+      "_comment": "Rust 2024 is an EDITION (reached stable with Rust 1.85), not a semver major tracked on endoflife.date's product list. Live-checked nowhere; offline-only."
+    },
+    {
+      "label": "PHP",
+      "version": "8.4",
+      "where": ["description", "body"],
+      "pattern": "php[^\\n]*8\\.4\\b",
+      "live": {"source": "endoflife", "product": "php", "compare": "line", "documented": "8.4"}
+    }
+  ]
+}

+ 345 - 0
skills/migrate-ops/scripts/check-migrate-facts.py

@@ -0,0 +1,345 @@
+#!/usr/bin/env python3
+"""Staleness verifier for migrate-ops: the framework/language target versions
+the skill hardcodes must stay stated where it claims, and must not silently lag
+reality.
+
+migrate-ops commits to specific target versions in its description and body —
+React 19, Laravel 11, Python 3.12, Node 22, TypeScript 5, Go 1.22, Rust 2024,
+PHP 8.4. Those are exactly the facts that drift silently
+(SKILL-RESOURCE-PROTOCOL.md §7): a line gets rewritten and drops a version, or
+the world moves (Python 3.12 → 3.13, Node 22 → 24) and the skill still names
+the old target. Two modes:
+
+  --offline (default, safe for PR CI): structural consistency, no network.
+    * assets/migrate-facts.json parses and carries the schema + an as_of date
+    * every catalogued claim's regex still matches in each location it is
+      recorded as appearing (description vs body) — the catalog can't drift
+      from the docs
+    * SKILL.md still carries a dated "verified as of <year>" currency note
+  --live (scheduled freshness job, never a PR gate): resolves the current
+    stable version of each product via endoflife.date (python, nodejs, laravel,
+    php, go) and registry.npmjs.org (react, typescript), and flags any
+    documented target that is no longer the latest stable major/line.
+
+Usage:   check-migrate-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 endoflife.date/npm unreachable (live, advisory — never a real failure),
+         10 drift found (offline: claim missing from a recorded location or
+            currency note gone; live: a documented target lags the latest stable)
+
+Examples:
+  check-migrate-facts.py --offline                 # PR CI: catalog ⇆ prose consistency
+  check-migrate-facts.py --live                    # weekly: any target lagging latest?
+  check-migrate-facts.py --offline --json | jq '.data[]'
+"""
+from __future__ import annotations
+
+import argparse
+import datetime
+import json
+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.migrate-ops.facts/v1"
+
+HERE = Path(__file__).resolve().parent
+DEFAULT_CATALOG = HERE.parent / "assets" / "migrate-facts.json"
+DEFAULT_SKILL = HERE.parent
+
+ENDOFLIFE = "https://endoflife.date/api"
+NPM_REGISTRY = "https://registry.npmjs.org"
+
+CURRENCY_RE = re.compile(r"verified as of\s+(\d{4})", re.IGNORECASE)
+AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+
+
+class Finding:
+    __slots__ = ("check", "status", "detail")
+
+    def __init__(self, check: str, status: str, detail: str) -> None:
+        self.check = check
+        self.status = status  # ok | drift | unavailable
+        self.detail = detail
+
+    def as_dict(self) -> dict:
+        return {"check": self.check, "status": self.status, "detail": self.detail}
+
+
+def load_catalog(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 not isinstance(data, dict) or data.get("schema") != SCHEMA:
+            raise ValueError(f"schema must be {SCHEMA!r}")
+        if not AS_OF_RE.match(str(data.get("as_of", ""))):
+            raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}")
+        if not isinstance(data.get("claims"), list) or not data["claims"]:
+            raise ValueError("'claims' must be a non-empty array")
+        for c in data["claims"]:
+            for k in ("label", "version", "where", "pattern"):
+                if k not in c:
+                    raise ValueError(f"claim missing {k}: {c!r}")
+        return data
+    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
+        print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr)
+        raise SystemExit(EX_UNPARSEABLE)
+
+
+def split_skill(skill_md: str) -> tuple[str, str]:
+    """Split SKILL.md into (description_value, body_text).
+
+    description = the quoted value of the frontmatter `description:` field.
+    body        = everything after the closing `---` fence."""
+    lines = skill_md.splitlines()
+    if not lines or lines[0].strip() != "---":
+        return "", skill_md
+    end = None
+    for i in range(1, len(lines)):
+        if lines[i].strip() == "---":
+            end = i
+            break
+    if end is None:
+        return "", skill_md
+    desc = ""
+    for ln in lines[1:end]:
+        if ln.strip().startswith("description:"):
+            desc = ln.strip()[len("description:"):].strip()
+            if len(desc) >= 2 and desc[0] in "\"'" and desc[-1] == desc[0]:
+                desc = desc[1:-1]
+            break
+    body = "\n".join(lines[end + 1:])
+    return desc, body
+
+
+def read_corpus(skill_dir: Path) -> tuple[str, str, str]:
+    """Returns (skill_md, description, body_corpus). body_corpus = SKILL.md body
+    + references/*.md (the prose a claim can be recorded as appearing in)."""
+    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")
+    desc, body = split_skill(skill_md)
+    parts = [body]
+    ref_dir = skill_dir / "references"
+    if ref_dir.is_dir():
+        for ref in sorted(ref_dir.glob("*.md")):
+            parts.append(ref.read_text(encoding="utf-8", errors="replace"))
+    return skill_md, desc, "\n".join(parts)
+
+
+def check_offline(catalog: dict, skill_dir: Path) -> list[Finding]:
+    skill_md, desc, body_corpus = read_corpus(skill_dir)
+    findings: list[Finding] = []
+
+    if CURRENCY_RE.search(skill_md):
+        m = CURRENCY_RE.search(skill_md)
+        findings.append(Finding("currency-note", "ok", f"currency note dated {m.group(1)}"))
+    else:
+        findings.append(Finding("currency-note", "drift",
+                                "no dated 'verified as of <year>' currency note in SKILL.md"))
+
+    for claim in catalog.get("claims", []):
+        label = claim["label"]
+        regex = re.compile(claim["pattern"], re.IGNORECASE)
+        for loc in claim["where"]:
+            text = desc if loc == "description" else body_corpus
+            if regex.search(text):
+                findings.append(Finding(f"claim:{label}:{loc}", "ok",
+                                        f"{label} {claim['version']} present in {loc}"))
+            else:
+                findings.append(Finding(f"claim:{label}:{loc}", "drift",
+                                        f"{label} {claim['version']} not found in {loc} "
+                                        f"(pattern {claim['pattern']!r})"))
+    return findings
+
+
+def _fetch(url: str, timeout: float, accept: str = "application/json") -> tuple[str, object]:
+    req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-migrate-ops-check/1",
+                                               "Accept": accept})
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return "ok", resp.read().decode("utf-8", errors="replace")
+    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):
+        return "unavailable", None
+
+
+def _vtup(s: str) -> tuple[int, ...]:
+    return tuple(int(x) for x in re.findall(r"\d+", str(s))) or (0,)
+
+
+def _leading_int(s: str) -> str:
+    m = re.match(r"\s*(\d+)", str(s))
+    return m.group(1) if m else ""
+
+
+def _endoflife_latest(product: str, timeout: float) -> tuple[str, str | None]:
+    """Return (status, latest_supported_cycle_or_None). status in ok|unavailable."""
+    url = f"{ENDOFLIFE}/{urllib.parse.quote(product, safe='')}.json"
+    status, payload = _fetch(url, timeout)
+    if status != "ok":
+        return "unavailable", None
+    try:
+        cycles = json.loads(payload)
+    except json.JSONDecodeError:
+        return "unavailable", None
+    today = datetime.date.today()
+    supported: list[str] = []
+    for c in cycles:
+        cyc = str(c.get("cycle", ""))
+        if not cyc:
+            continue
+        eol = c.get("eol", False)
+        is_eol = False
+        if isinstance(eol, str):
+            try:
+                is_eol = datetime.date.fromisoformat(eol[:10]) < today
+            except ValueError:
+                is_eol = False
+        elif eol is False:
+            is_eol = False
+        else:
+            is_eol = bool(eol)
+        if not is_eol:
+            supported.append(cyc)
+    pool = supported or [str(c.get("cycle", "")) for c in cycles if c.get("cycle")]
+    if not pool:
+        return "ok", None
+    return "ok", max(pool, key=_vtup)
+
+
+def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]:
+    """Return (status, version-or-detail). status in ok|notfound|unavailable."""
+    url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='')}/latest"
+    status, payload = _fetch(url, timeout)
+    if status != "ok":
+        return status, str(payload)
+    try:
+        return "ok", json.loads(payload).get("version", "")
+    except json.JSONDecodeError:
+        return "unavailable", "bad-json"
+
+
+def check_live(catalog: dict, timeout: float) -> list[Finding]:
+    findings: list[Finding] = []
+    for claim in catalog.get("claims", []):
+        live = claim.get("live")
+        label = claim["label"]
+        if not live:
+            findings.append(Finding(f"live:{label}", "ok", "not live-tracked (edition / no registry source)"))
+            continue
+        source = live.get("source")
+        compare = live.get("compare", "major")
+        documented = str(live.get("documented", ""))
+
+        if source == "endoflife":
+            status, latest = _endoflife_latest(live["product"], timeout)
+            if status != "ok" or latest is None:
+                findings.append(Finding(f"live:{label}", "unavailable",
+                                        f"endoflife.date/{live['product']} unreachable"))
+                continue
+            if compare == "major":
+                latest_major, doc_major = _leading_int(latest), _leading_int(documented)
+                if latest_major and latest_major != doc_major:
+                    findings.append(Finding(f"live:{label}", "drift",
+                                            f"{label} documented {documented} lags latest stable {latest}"))
+                else:
+                    findings.append(Finding(f"live:{label}", "ok", f"latest stable {latest}"))
+            else:  # line: compare MAJOR.MINOR
+                if _vtup(latest)[:2] != _vtup(documented)[:2]:
+                    findings.append(Finding(f"live:{label}", "drift",
+                                            f"{label} documented {documented} lags latest stable {latest}"))
+                else:
+                    findings.append(Finding(f"live:{label}", "ok", f"latest stable {latest}"))
+        elif source == "npm":
+            status, ver = _npm_latest(live["product"], timeout)
+            if status == "notfound":
+                findings.append(Finding(f"live:{label}", "drift",
+                                        f"{live['product']} gone from npm — renamed/removed"))
+                continue
+            if status != "ok":
+                findings.append(Finding(f"live:{label}", "unavailable",
+                                        f"npm/{live['product']} unreachable"))
+                continue
+            latest_major, doc_major = _leading_int(ver), _leading_int(documented)
+            if latest_major and latest_major != doc_major:
+                findings.append(Finding(f"live:{label}", "drift",
+                                        f"{label} documented {documented} lags latest {ver}"))
+            else:
+                findings.append(Finding(f"live:{label}", "ok", f"latest {ver}"))
+        else:
+            findings.append(Finding(f"live:{label}", "drift", f"unknown live source {source!r}"))
+    return findings
+
+
+def main(argv: list[str]) -> int:
+    p = argparse.ArgumentParser(
+        prog="check-migrate-facts.py",
+        description="Verify migrate-ops' hardcoded target versions stay stated (offline) and current (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="resolve current versions via endoflife.date + 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")
+    p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary")
+    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)
+
+    catalog = load_catalog(Path(args.catalog))
+    live = args.live and not args.offline
+    mode_name = "live" if live else "offline"
+    findings = check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))
+
+    n_drift = sum(1 for f in findings if f.status == "drift")
+    n_unavail = sum(1 for f in findings if f.status == "unavailable")
+
+    if args.json:
+        print(json.dumps({
+            "data": [f.as_dict() for f in findings],
+            "meta": {"mode": mode_name, "count": len(findings),
+                     "drift": n_drift, "unavailable": n_unavail, "schema": SCHEMA},
+        }, indent=2))
+    else:
+        for f in findings:
+            print(f"{f.check}\t{f.status}\t{f.detail}")
+
+    for f in findings:
+        if f.status != "ok":
+            print(f"  [{f.status.upper()}] {f.check}: {f.detail}", file=sys.stderr)
+    if not args.quiet:
+        print(f"-- {len(findings)} checks: {n_drift} drift, {n_unavail} unavailable", file=sys.stderr)
+
+    if n_drift:
+        return EX_DRIFT
+    if n_unavail:
+        return EX_UNAVAILABLE
+    return EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 110 - 0
skills/migrate-ops/tests/run.sh

@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# Offline self-test for the migrate-ops skill — structure, frontmatter, and the
+# staleness-verifier contract (SKILL-RESOURCE-PROTOCOL §7, §10).
+#
+# Usage:   tests/run.sh
+# Input:   none (self-contained; no network)
+# Output:  TAP-ish progress on stderr; final PASS/FAIL line.
+# Exit:    0 all pass (or skipped on unsupported platform), 1 any failure.
+#
+# Examples:
+#   tests/run.sh
+#   bash skills/migrate-ops/tests/run.sh
+set -uo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+fail=0
+pass=0
+note() { printf '  %s %s\n' "$1" "$2" >&2; }
+ok()   { pass=$((pass+1)); note "ok  " "$1"; }
+bad()  { fail=$((fail+1)); note "FAIL" "$1"; }
+
+# 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.
+PY=""
+for cand in python3 python; do
+  if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then
+    PY="$cand"; break
+  fi
+done
+if [ -z "$PY" ]; then
+  echo "SKIP: no working python interpreter on this platform" >&2
+  exit 0
+fi
+
+# 1. Required directories exist
+for d in scripts references assets tests; do
+  [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/"
+done
+
+# 2. SKILL.md frontmatter house rules
+skill="$here/SKILL.md"
+if [ -f "$skill" ]; then
+  ok "SKILL.md present"
+  grep -q '^name: migrate-ops$' "$skill" && ok "name matches directory" || bad "name != migrate-ops"
+  grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT"
+  grep -q '^  author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author"
+else
+  bad "SKILL.md missing"
+fi
+
+# 3. Every reference on disk is cited from SKILL.md (no dead weight)
+for ref in "$here"/references/*.md; do
+  base="references/$(basename "$ref")"
+  grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base"
+done
+
+# 4. Every SKILL.md-cited bundled resource exists on disk
+for res in assets/migrate-facts.json scripts/check-migrate-facts.py; do
+  [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res"
+done
+
+# 5. check-migrate-facts.py — staleness verifier contract (§7, §10), offline-safe
+verifier="$here/scripts/check-migrate-facts.py"
+catalog="$here/assets/migrate-facts.json"
+ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
+       [ "$got" = "$want" ] && ok "$lbl (exit $got)" || bad "$lbl (want $want got $got)"; }
+if [ -f "$verifier" ]; then
+  "$PY" -m py_compile "$verifier" && ok "verifier: py_compile clean" || bad "verifier: py_compile failed"
+  grep -qE '^Examples:$' "$verifier" && ok "verifier: has Examples block" || bad "verifier: no Examples block (docstring)"
+  "$PY" "$verifier" --help >/dev/null 2>&1 && ok "verifier: --help exits 0" || bad "verifier: --help nonzero"
+  # Offline mode must pass on the skill's own content (internal consistency).
+  ec 0 "verifier: --offline consistent"  "$PY" "$verifier" --offline
+  # Bad flag → USAGE (exit 2); conflicting modes → USAGE.
+  ec 2 "verifier: bad flag → exit 2"     "$PY" "$verifier" --bogus
+  ec 2 "verifier: --offline --live → 2"  "$PY" "$verifier" --offline --live
+  # stdout is data-only: --offline --json must emit parseable JSON.
+  "$PY" "$verifier" --offline --json -q 2>/dev/null \
+    | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.migrate-ops.facts/v1"' \
+    && ok "verifier: --json envelope parses (stdout clean)" || bad "verifier: --json envelope broken"
+  # Error paths: missing catalog → 3, malformed catalog → 4, drift catalog → 10.
+  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+  ec 3 "verifier: missing catalog -> 3"  "$PY" "$verifier" --offline --catalog "$TMP/nope.json"
+  printf '{"packages":"x"}' > "$TMP/bad.json"
+  ec 4 "verifier: malformed catalog -> 4" "$PY" "$verifier" --offline --catalog "$TMP/bad.json"
+  # Minimal drift catalog (argv path so MSYS translates it): a claim whose
+  # pattern matches nothing in the real skill prose -> drift -> exit 10.
+  printf '%s\n' '{"schema":"claude-mods.migrate-ops.facts/v1","as_of":"2026-07-05","claims":[{"label":"Fakeweb","version":"99","where":["description","body"],"pattern":"fakeweb[^\\n]*\\b99\\b"}]}' > "$TMP/drift.json"
+  ec 10 "verifier: missing claim -> 10" "$PY" "$verifier" --offline --catalog "$TMP/drift.json"
+  # cited from SKILL.md
+  grep -qF "scripts/check-migrate-facts.py" "$skill" && ok "verifier: cited from SKILL.md" || bad "verifier: uncited"
+else
+  bad "check-migrate-facts.py missing"
+fi
+
+# 6. migrate-facts.json — parses, carries schema + the 8 catalogued targets
+"$PY" -c "
+import json, sys
+d = json.load(open(sys.argv[1], encoding='utf-8'))
+assert d['schema'] == 'claude-mods.migrate-ops.facts/v1'
+labels = {c['label'] for c in d['claims']}
+for need in ['React','Laravel','Python','Node','TypeScript','Go','Rust','PHP']:
+    assert need in labels, f'missing claim {need}'
+assert all(c['version'] for c in d['claims']), 'claim missing version'
+" "$catalog" && ok "migrate-facts.json schema + 8 targets" || bad "migrate-facts.json invalid"
+
+# 7. Currency note present near the top of the body
+grep -qE 'verified as of [0-9]{4}' "$skill" && ok "currency note present" || bad "no dated currency note"
+
+echo "migrate-ops self-test: $pass passed, $fail failed" >&2
+[ "$fail" -eq 0 ]