Skip to content

Commit fc8b8ca

Browse files
geledekclaude
andcommitted
scripts: add validate.py consistency check + GitHub Action
Validates on every push/PR: SKILL.md reference pointers resolve, _index.md consumer columns match actual citations, plugin.json/marketplace.json skill lists match skills/ directories, versions agree, frontmatter well-formed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4486037 commit fc8b8ca

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

.github/workflows/validate.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: validate
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Validate skill pointers, index, and manifests
14+
run: python3 scripts/validate.py

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to this plugin are documented here.
44

5+
## [Unreleased]
6+
7+
### Added
8+
- `scripts/validate.py` + GitHub Action (`.github/workflows/validate.yml`) — CI consistency check on every push/PR: every reference pointer in a SKILL.md resolves, `_index.md` consumer columns match actual citations, plugin manifests match the `skills/` directories, versions agree across manifests, and skill frontmatter is well-formed.
9+
10+
### Fixed
11+
- **Broken reference pointer.** Three skills (`tech-buy-vs-build`, `tech-stack-diagnostic`, `general-peer-cases`) and `references/_index.md` cited `nanda-tech-buy-vs-build.md`, but the file on disk was named `nanda-buy-vs-build.md`. The file has been renamed to match the citations.
12+
- **Stale consumer columns in `references/_index.md`.** The "Also consulted by" column predated the 0.3.0 skill renames and cross-linking; regenerated from actual SKILL.md citations. `pwc-six-step-roadmap.md` and `wef-strategic-positions.md` are flagged as not currently cited by any skill.
13+
514
## [0.3.0] — 2026-06-16
615

716
### Added

scripts/validate.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)