From bf2576067f1d01ef663938e0223a1dcc4ec05656 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 12 Jun 2026 12:36:04 +0200 Subject: [PATCH 1/3] Add CI structure checks for plugin marketplace - scripts/check-structure.py: single self-contained python3 checker for JSON validity, version sync, SKILL.md frontmatter + description word budget, internal ${CLAUDE_PLUGIN_ROOT} references, and shell syntax - .github/workflows/structure-checks.yml: runs on PR + push to main - Document the checks in README and CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/structure-checks.yml | 19 ++ CLAUDE.md | 10 +- README.md | 10 + scripts/check-structure.py | 242 +++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/structure-checks.yml create mode 100755 scripts/check-structure.py diff --git a/.github/workflows/structure-checks.yml b/.github/workflows/structure-checks.yml new file mode 100644 index 0000000..1b0ac53 --- /dev/null +++ b/.github/workflows/structure-checks.yml @@ -0,0 +1,19 @@ +name: Structure Checks + +# Mechanically verify repository structure invariants on every change. +# The plugins are declarative Markdown + JSON with no build step, so this is +# the only automated guard against broken JSON, version drift, and dangling +# references. See scripts/check-structure.py. + +on: + pull_request: + push: + branches: [main] + +jobs: + structure: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run structure checks + run: python3 scripts/check-structure.py diff --git a/CLAUDE.md b/CLAUDE.md index e36c94d..e1fc345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,15 @@ Plugin versions are tracked in two places that must stay in sync: 1. `plugins//.claude-plugin/plugin.json` — canonical version 2. `.claude-plugin/marketplace.json` — marketplace registry version -When bumping a plugin version, update both files. +When bumping a plugin version, update both files. CI (`scripts/check-structure.py`) +fails on version drift, so both must match before a PR can merge. + +## Structure Checks + +`scripts/check-structure.py` mechanically verifies repo invariants (JSON +validity, version sync, SKILL.md frontmatter + description word budget, internal +`${CLAUDE_PLUGIN_ROOT}` references, shell syntax). It runs in CI on every PR and +push to main, and can be run locally before pushing. Keep it green. ## Project Knowledge System - **Rules** (`.claude/rules/`): Always active — coding style, patterns, dos/don'ts diff --git a/README.md b/README.md index d3ac636..06adc67 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,16 @@ Plugins update automatically when the marketplace is refreshed. To manually upda /reload-plugins ``` +## Development + +Structure invariants (JSON validity, version sync, skill frontmatter, internal +references, shell syntax) are enforced by CI on every PR. Run the same checks +locally before pushing: + +``` +python3 scripts/check-structure.py +``` + ## License MIT diff --git a/scripts/check-structure.py b/scripts/check-structure.py new file mode 100755 index 0000000..64096ed --- /dev/null +++ b/scripts/check-structure.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Structure invariant checks for the gering-plugins marketplace. + +The plugins are declarative Markdown + JSON with no build step, so structural +regressions (broken JSON, version drift, dangling references) are otherwise only +caught in live use. This script mechanically verifies the invariants documented +in CLAUDE.md. + +Run locally before pushing: + + python3 scripts/check-structure.py + +Exit code 0 = no errors (warnings allowed), 1 = at least one error. +Dependencies: python3 stdlib + bash only. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Word budget for skill `description` frontmatter (loaded into every session). +DESC_WORDS_ERROR = 40 +DESC_WORDS_WARN = 30 + +errors: list[str] = [] +warnings: list[str] = [] + + +def err(msg: str) -> None: + errors.append(msg) + + +def warn(msg: str) -> None: + warnings.append(msg) + + +def rel(path: Path) -> str: + try: + return str(path.relative_to(REPO)) + except ValueError: + return str(path) + + +def load_json(path: Path): + """Parse JSON, recording an error on failure. Returns data or None.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + err(f"{rel(path)}: file not found") + except json.JSONDecodeError as e: + err(f"{rel(path)}: invalid JSON — {e}") + return None + + +def parse_frontmatter(text: str): + """Minimal YAML frontmatter parser (no PyYAML dependency). + + Handles the subset used by SKILL.md: top-level `key: value` scalars and + `key: |` block scalars. Block-scalar lines are joined with single spaces. + Returns a dict, or None if no frontmatter block is present. + """ + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return None + end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) + if end is None: + return None + + body = lines[1:end] + data: dict[str, str] = {} + i = 0 + while i < len(body): + line = body[i] + if not line.strip(): + i += 1 + continue + m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line) + if not m: + i += 1 + continue + key, val = m.group(1), m.group(2) + if val in ("|", ">", "|-", ">-", "|+", ">+"): + block: list[str] = [] + i += 1 + while i < len(body): + nxt = body[i] + if nxt.strip() == "": + i += 1 + continue + if len(nxt) - len(nxt.lstrip()) == 0: # de-dented = new key + break + block.append(nxt.strip()) + i += 1 + data[key] = " ".join(block) + else: + data[key] = val.strip().strip('"').strip("'") + i += 1 + return data + + +def check_json_and_versions(): + """JSON validity for all manifests + version/name sync across both sources.""" + market = load_json(REPO / ".claude-plugin" / "marketplace.json") + + plugin_jsons = sorted((REPO / "plugins").glob("*/.claude-plugin/plugin.json")) + plugins_by_name: dict[str, dict] = {} + for pj in plugin_jsons: + data = load_json(pj) + if data is None: + continue + plugin_dir = pj.parent.parent # plugins// + name = data.get("name") + if name != plugin_dir.name: + err(f"{rel(pj)}: name '{name}' does not match directory '{plugin_dir.name}'") + if name: + plugins_by_name[name] = data + + if not isinstance(market, dict): + return + entries = market.get("plugins", []) + if not isinstance(entries, list): + err(".claude-plugin/marketplace.json: 'plugins' must be a list") + return + + registered = set() + for entry in entries: + name = entry.get("name") + registered.add(name) + source = entry.get("source", "") + src_dir = (REPO / source).resolve() + pj = src_dir / ".claude-plugin" / "plugin.json" + if not pj.exists(): + err(f"marketplace.json: plugin '{name}' source '{source}' has no plugin.json") + continue + plugin_data = plugins_by_name.get(name) + if plugin_data is None: + continue + mv, pv = entry.get("version"), plugin_data.get("version") + if mv != pv: + err( + f"version drift for '{name}': marketplace.json={mv} " + f"!= plugin.json={pv}" + ) + + # Every plugins/ dir must be registered in the marketplace. + for pj in plugin_jsons: + name = pj.parent.parent.name + if name not in registered: + err(f"plugin '{name}' is not registered in marketplace.json") + + +def check_skill_frontmatter(): + """Required fields, name==dirname, and description word budget for SKILL.md.""" + for skill in sorted((REPO / "plugins").glob("*/skills/*/SKILL.md")): + fm = parse_frontmatter(skill.read_text(encoding="utf-8")) + if fm is None: + err(f"{rel(skill)}: missing or malformed frontmatter block") + continue + + name = fm.get("name", "").strip() + if not name: + err(f"{rel(skill)}: frontmatter missing required field 'name'") + elif name != skill.parent.name: + err(f"{rel(skill)}: name '{name}' does not match directory '{skill.parent.name}'") + + desc = fm.get("description", "").strip() + if not desc: + err(f"{rel(skill)}: frontmatter missing required field 'description'") + continue + + words = len(desc.split()) + if words > DESC_WORDS_ERROR: + err(f"{rel(skill)}: description is {words} words (max {DESC_WORDS_ERROR})") + elif words > DESC_WORDS_WARN: + warn(f"{rel(skill)}: description is {words} words (aim <= {DESC_WORDS_WARN})") + + +def check_internal_refs(): + """Every ${CLAUDE_PLUGIN_ROOT}/ referenced in a plugin must exist.""" + pattern = re.compile(r"\$\{CLAUDE_PLUGIN_ROOT\}(/[^\s\"'`)>,]+)") + for md in sorted((REPO / "plugins").rglob("*.md")): + # Plugin root = plugins// — first two path components under plugins/. + parts = md.relative_to(REPO / "plugins").parts + plugin_root = REPO / "plugins" / parts[0] + text = md.read_text(encoding="utf-8") + for lineno, line in enumerate(text.splitlines(), 1): + for m in pattern.finditer(line): + ref = m.group(1).lstrip("/") + target = plugin_root / ref + if not target.exists(): + err( + f"{rel(md)}:{lineno}: references " + f"${{CLAUDE_PLUGIN_ROOT}}/{ref} which does not exist" + ) + + +def check_shell_scripts(): + """bash -n syntax check on every *.sh in the repo.""" + for script in sorted(REPO.rglob("*.sh")): + if ".git" in script.parts: + continue + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + err(f"{rel(script)}: bash syntax error — {detail}") + + +def main() -> int: + checks = [ + ("JSON validity + version sync", check_json_and_versions), + ("SKILL.md frontmatter", check_skill_frontmatter), + ("internal ${CLAUDE_PLUGIN_ROOT} references", check_internal_refs), + ("shell script syntax", check_shell_scripts), + ] + for label, fn in checks: + fn() + print(f" ran: {label}") + + print() + for w in warnings: + print(f"WARN {w}") + for e in errors: + print(f"ERROR {e}") + + print() + if errors: + print(f"FAILED — {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 + print(f"OK — 0 errors, {len(warnings)} warning(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 4f601a5a87754d336d11b472c5b10902e27f28dd Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 12 Jun 2026 13:01:41 +0200 Subject: [PATCH 2/3] Harden structure checker against false positives Review follow-up: the validator rejected several valid inputs. - Strip trailing sentence punctuation (.,;:]) from ${CLAUDE_PLUGIN_ROOT} references so a bare ref ending a prose sentence no longer reports a dangling path - Skip templated/example ref paths containing placeholders or globs - Tolerate a leading UTF-8 BOM or blank lines before SKILL.md frontmatter - Catch missing bash with a clean error instead of an unhandled traceback - Add 'from __future__ import annotations' so the list[str] annotation runs on Python 3.7+ Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check-structure.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/scripts/check-structure.py b/scripts/check-structure.py index 64096ed..7dc7cd3 100755 --- a/scripts/check-structure.py +++ b/scripts/check-structure.py @@ -11,9 +11,11 @@ python3 scripts/check-structure.py Exit code 0 = no errors (warnings allowed), 1 = at least one error. -Dependencies: python3 stdlib + bash only. +Dependencies: python3 (3.7+) stdlib + bash only. """ +from __future__ import annotations + import json import re import subprocess @@ -63,14 +65,17 @@ def parse_frontmatter(text: str): `key: |` block scalars. Block-scalar lines are joined with single spaces. Returns a dict, or None if no frontmatter block is present. """ - lines = text.splitlines() - if not lines or lines[0].strip() != "---": + lines = text.lstrip("\ufeff").splitlines() # tolerate a leading UTF-8 BOM + start = next((i for i, ln in enumerate(lines) if ln.strip()), None) + if start is None or lines[start].strip() != "---": return None - end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) + end = next( + (i for i in range(start + 1, len(lines)) if lines[i].strip() == "---"), None + ) if end is None: return None - body = lines[1:end] + body = lines[start + 1 : end] data: dict[str, str] = {} i = 0 while i < len(body): @@ -189,7 +194,13 @@ def check_internal_refs(): text = md.read_text(encoding="utf-8") for lineno, line in enumerate(text.splitlines(), 1): for m in pattern.finditer(line): - ref = m.group(1).lstrip("/") + # Drop trailing sentence punctuation the char class can't exclude + # (a bare ref ending a prose sentence: "... foo.sh."). + ref = m.group(1).lstrip("/").rstrip(".,;:]") + # Skip templated/example paths — placeholders and globs are never + # literal files (e.g. ${CLAUDE_PLUGIN_ROOT}/skills//SKILL.md). + if not ref or any(c in ref for c in "<>{}*?"): + continue target = plugin_root / ref if not target.exists(): err( @@ -203,11 +214,15 @@ def check_shell_scripts(): for script in sorted(REPO.rglob("*.sh")): if ".git" in script.parts: continue - result = subprocess.run( - ["bash", "-n", str(script)], - capture_output=True, - text=True, - ) + try: + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + except FileNotFoundError: + err("bash not found on PATH — cannot syntax-check shell scripts") + return if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() err(f"{rel(script)}: bash syntax error — {detail}") From ec750ac8065c8ac7805813e9dec5e36db274e239 Mon Sep 17 00:00:00 2001 From: Robert Gering Date: Fri, 12 Jun 2026 14:59:45 +0200 Subject: [PATCH 3/3] Validate marketplace source against named plugin Codex review follow-up: a marketplace entry with the correct name and version but a 'source' pointing at a different plugin directory passed all checks, because the version lookup matched the manifest by name while the source path was only checked for the presence of any plugin.json. Load the manifest at the actual 'source' path and verify its name matches the entry before comparing versions, so a mispointed source is caught. Drops the now-unused plugins_by_name lookup in favour of a path-keyed map. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check-structure.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/check-structure.py b/scripts/check-structure.py index 7dc7cd3..b56041e 100755 --- a/scripts/check-structure.py +++ b/scripts/check-structure.py @@ -112,17 +112,16 @@ def check_json_and_versions(): market = load_json(REPO / ".claude-plugin" / "marketplace.json") plugin_jsons = sorted((REPO / "plugins").glob("*/.claude-plugin/plugin.json")) - plugins_by_name: dict[str, dict] = {} + manifests: dict[Path, dict] = {} # resolved path -> parsed plugin.json for pj in plugin_jsons: data = load_json(pj) if data is None: continue + manifests[pj.resolve()] = data plugin_dir = pj.parent.parent # plugins// name = data.get("name") if name != plugin_dir.name: err(f"{rel(pj)}: name '{name}' does not match directory '{plugin_dir.name}'") - if name: - plugins_by_name[name] = data if not isinstance(market, dict): return @@ -141,9 +140,20 @@ def check_json_and_versions(): if not pj.exists(): err(f"marketplace.json: plugin '{name}' source '{source}' has no plugin.json") continue - plugin_data = plugins_by_name.get(name) + # Validate the manifest at the actual `source` — this is what gets + # installed. Looking it up by name instead would hide a `source` that + # points at the wrong plugin directory. + key = pj.resolve() + plugin_data = manifests[key] if key in manifests else load_json(pj) if plugin_data is None: continue + src_name = plugin_data.get("name") + if src_name != name: + err( + f"marketplace.json: plugin '{name}' source '{source}' resolves to " + f"a manifest named '{src_name}'" + ) + continue mv, pv = entry.get("version"), plugin_data.get("version") if mv != pv: err(