Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/structure-checks.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,15 @@ Plugin versions are tracked in two places that must stay in sync:
1. `plugins/<name>/.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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
267 changes: 267 additions & 0 deletions scripts/check-structure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
#!/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 (3.7+) stdlib + bash only.
"""

from __future__ import annotations

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.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(start + 1, len(lines)) if lines[i].strip() == "---"), None
)
if end is None:
return None

body = lines[start + 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"))
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>/
name = data.get("name")
if name != plugin_dir.name:
err(f"{rel(pj)}: name '{name}' does not match directory '{plugin_dir.name}'")

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
# 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(
f"version drift for '{name}': marketplace.json={mv} "
f"!= plugin.json={pv}"
)

# Every plugins/<name> 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}/<path> 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/<name>/ — 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):
# 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/<name>/SKILL.md).
if not ref or any(c in ref for c in "<>{}*?"):
continue
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
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}")


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())
Loading