|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Repo consistency validator. Run from the repo root: python3 scripts/validate.py |
| 3 | +
|
| 4 | +Checks: |
| 5 | + 1. Every backticked `*.md` pointer in a SKILL.md resolves to a file in |
| 6 | + references/ or in the skill's own directory (e.g. cases/). |
| 7 | + 2. references/_index.md main-table consumer columns (Primary for skill + |
| 8 | + Also consulted by) match the actual citations in SKILL.md files. |
| 9 | + 3. Every .md file in references/ (except _index.md, queries/) appears in |
| 10 | + _index.md, and _index.md lists no phantom files. |
| 11 | + 4. Skill lists in .claude-plugin/plugin.json and marketplace.json match the |
| 12 | + skills/ directories on disk, and each other. |
| 13 | + 5. plugin.json version == marketplace.json metadata.version. |
| 14 | + 6. Every skill has SKILL.md + README.md; frontmatter `name` matches the |
| 15 | + directory name; `description` present and <= 1024 chars. |
| 16 | +
|
| 17 | +Exits non-zero with a list of failures if anything is inconsistent. |
| 18 | +""" |
| 19 | + |
| 20 | +import json |
| 21 | +import os |
| 22 | +import re |
| 23 | +import sys |
| 24 | + |
| 25 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 26 | +errors = [] |
| 27 | + |
| 28 | + |
| 29 | +def err(msg: str) -> None: |
| 30 | + errors.append(msg) |
| 31 | + |
| 32 | + |
| 33 | +def frontmatter(text: str) -> dict: |
| 34 | + m = re.match(r"\A---\n(.*?)\n---\n", text, re.DOTALL) |
| 35 | + fields = {} |
| 36 | + if m: |
| 37 | + for line in m.group(1).splitlines(): |
| 38 | + fm = re.match(r"([a-z-]+):\s*(.*)", line) |
| 39 | + if fm: |
| 40 | + fields[fm.group(1)] = fm.group(2).strip() |
| 41 | + return fields |
| 42 | + |
| 43 | + |
| 44 | +# ---- collect skills and their reference citations ---------------------- |
| 45 | +skills_dir = os.path.join(ROOT, "skills") |
| 46 | +skill_names = sorted( |
| 47 | + d for d in os.listdir(skills_dir) |
| 48 | + if os.path.isdir(os.path.join(skills_dir, d)) |
| 49 | +) |
| 50 | + |
| 51 | +cites: dict[str, set[str]] = {} # reference filename -> set of skill slugs |
| 52 | +for skill in skill_names: |
| 53 | + sdir = os.path.join(skills_dir, skill) |
| 54 | + skill_md = os.path.join(sdir, "SKILL.md") |
| 55 | + readme = os.path.join(sdir, "README.md") |
| 56 | + |
| 57 | + # check 6: required files + frontmatter |
| 58 | + if not os.path.isfile(readme): |
| 59 | + err(f"skills/{skill}: missing README.md") |
| 60 | + if not os.path.isfile(skill_md): |
| 61 | + err(f"skills/{skill}: missing SKILL.md") |
| 62 | + continue |
| 63 | + body = open(skill_md).read() |
| 64 | + fm = frontmatter(body) |
| 65 | + if fm.get("name") != skill: |
| 66 | + err(f"skills/{skill}/SKILL.md: frontmatter name {fm.get('name')!r} != directory name") |
| 67 | + desc = fm.get("description", "") |
| 68 | + if not desc: |
| 69 | + err(f"skills/{skill}/SKILL.md: missing frontmatter description") |
| 70 | + elif len(desc) > 1024: |
| 71 | + err(f"skills/{skill}/SKILL.md: description is {len(desc)} chars (limit 1024)") |
| 72 | + |
| 73 | + # check 1: every backticked .md pointer resolves |
| 74 | + for ref in set(re.findall(r"`([a-z0-9-]+\.md)`", body)): |
| 75 | + in_references = os.path.isfile(os.path.join(ROOT, "references", ref)) |
| 76 | + in_skill_dir = any( |
| 77 | + ref in files for _, _, files in os.walk(sdir) |
| 78 | + ) |
| 79 | + if in_references: |
| 80 | + cites.setdefault(ref, set()).add(skill) |
| 81 | + elif not in_skill_dir: |
| 82 | + err(f"skills/{skill}/SKILL.md: cites `{ref}` — not found in references/ or the skill directory") |
| 83 | + |
| 84 | +# ---- check 2 + 3: _index.md accuracy ------------------------------------ |
| 85 | +index_path = os.path.join(ROOT, "references", "_index.md") |
| 86 | +indexed_files: set[str] = set() |
| 87 | +for line in open(index_path): |
| 88 | + m = re.match(r"\| `([a-z0-9-]+\.md)` \|", line) |
| 89 | + if not m: |
| 90 | + continue |
| 91 | + fname = m.group(1) |
| 92 | + indexed_files.add(fname) |
| 93 | + cells = [c.strip() for c in line.rstrip().split("|")] |
| 94 | + if len(cells) != 6: # additional-references table has no consumer columns |
| 95 | + continue |
| 96 | + primary, also = cells[3], cells[4] |
| 97 | + claimed = set() if primary == "—" else {primary} |
| 98 | + if not also.startswith("—"): |
| 99 | + claimed |= {s.strip() for s in also.split(",") if s.strip()} |
| 100 | + unknown = claimed - set(skill_names) |
| 101 | + if unknown: |
| 102 | + err(f"_index.md: `{fname}` row names unknown skills: {sorted(unknown)}") |
| 103 | + actual = cites.get(fname, set()) |
| 104 | + if claimed != actual: |
| 105 | + err( |
| 106 | + f"_index.md: `{fname}` consumer drift — " |
| 107 | + f"index says {sorted(claimed) or '—'}, SKILL.md files cite {sorted(actual) or '—'}" |
| 108 | + ) |
| 109 | + |
| 110 | +disk_files = { |
| 111 | + f for f in os.listdir(os.path.join(ROOT, "references")) |
| 112 | + if f.endswith(".md") and f != "_index.md" |
| 113 | +} |
| 114 | +for f in sorted(disk_files - indexed_files): |
| 115 | + err(f"_index.md: references/{f} exists on disk but is not listed in the index") |
| 116 | +for f in sorted(indexed_files - disk_files): |
| 117 | + err(f"_index.md: lists `{f}` but no such file exists in references/") |
| 118 | + |
| 119 | +# ---- check 4 + 5: plugin manifests --------------------------------------- |
| 120 | +plugin = json.load(open(os.path.join(ROOT, ".claude-plugin", "plugin.json"))) |
| 121 | +market = json.load(open(os.path.join(ROOT, ".claude-plugin", "marketplace.json"))) |
| 122 | + |
| 123 | +plugin_skills = {os.path.basename(p.rstrip("/")) for p in plugin.get("skills", [])} |
| 124 | +market_skills = { |
| 125 | + os.path.basename(p.rstrip("/")) |
| 126 | + for entry in market.get("plugins", []) |
| 127 | + for p in entry.get("skills", []) |
| 128 | +} |
| 129 | +if plugin_skills != set(skill_names): |
| 130 | + err( |
| 131 | + f"plugin.json skill list drift — missing {sorted(set(skill_names) - plugin_skills) or 'none'}, " |
| 132 | + f"phantom {sorted(plugin_skills - set(skill_names)) or 'none'}" |
| 133 | + ) |
| 134 | +if market_skills != set(skill_names): |
| 135 | + err( |
| 136 | + f"marketplace.json skill list drift — missing {sorted(set(skill_names) - market_skills) or 'none'}, " |
| 137 | + f"phantom {sorted(market_skills - set(skill_names)) or 'none'}" |
| 138 | + ) |
| 139 | + |
| 140 | +pv = plugin.get("version") |
| 141 | +mv = market.get("metadata", {}).get("version") |
| 142 | +if pv != mv: |
| 143 | + err(f"version drift — plugin.json has {pv!r}, marketplace.json has {mv!r}") |
| 144 | + |
| 145 | +# ---- report --------------------------------------------------------------- |
| 146 | +if errors: |
| 147 | + print(f"FAIL — {len(errors)} issue(s):\n") |
| 148 | + for e in errors: |
| 149 | + print(f" - {e}") |
| 150 | + sys.exit(1) |
| 151 | +print(f"OK — {len(skill_names)} skills, {len(disk_files)} references, all pointers and manifests consistent") |
0 commit comments