mirror of
https://github.com/trailofbits/skills.git
synced 2026-09-14 14:28:48 +08:00
8ea3b6a700
* Add validator self-test, structural checks, and make check The repo documented ~53 rules in AGENTS.md and machine-enforced 6 of them. This closes the gap for the ones a machine can decide, and adds the guard that keeps the checkers honest. New error-level checks (all currently pass, so none of this blocks anyone today): agent files must use `tools:` while skills use `allowed-tools:` (the loader silently ignores the wrong key, so the restriction just does not apply); subagent_type must be namespaced or the dispatch fails at runtime; plugin dir names kebab-case and <=64 chars; plugin README present, listed rather than stat'd so `Readme.md` fails on Linux CI the way it should; semver format; the forbidden runtime sidecars AGENTS.md already banned but nothing checked; and version-increment against the base branch, which is the gap that letdebfb29ship an allowed-tools fix across 25 plugins that no installed user received. New warning-level checks, reported but not blocking: the two required SKILL.md sections, the 500-line limit, and unresolved relative references. 55 warnings across 40 plugins today, concentrated in testing-handbook-skills and building-secure-contracts. The point of the exercise is the three anti-vacuity guards. A checker that has silently stopped matching reports a clean repo forever, and that failure mode has shipped repeatedly: `--self-test` builds fixtures and asserts every checker rejects a known-bad one; a full scan that resolves zero references exits 1 rather than declaring everything clean; and SELF_TEST_MINIMUM fails the self-test if it runs fewer than 20 assertions, because the self-test is itself a checker. It currently runs 26. The reference extractor skips fenced and inline code so that skill-authoring docs citing example paths do not generate warnings nobody reads. Makefile mirrors CI as one `make check`. Its RUFF_VERSION must match the ruff-pre-commit rev, and the self-test asserts that — verified by breaking it. * Fix CI checks that could pass or fail without inspecting anything Four of these are the same bug in different places: a check whose empty case is indistinguishable from success. - python-tests ran `python3 <file>` per file. A test file with no `if __name__ == "__main__"` block exits 0 having run nothing, which reads as a pass. All 10 current files happen to comply; nothing enforced it. Now pytest per directory, run via `python3 -m` from inside each one to preserve the sibling imports these suites rely on, with --import-mode=importlib because c-review and rust-review both ship scripts/test_split.py and the default import mode collides on the basename. 278 tests now run where the count was previously unknown. - bats used --no-run-if-empty, so a broken glob was a silent pass. This repo ships bats suites; finding none is a failure. - The SKILL.md frontmatter walk printed "All 0 SKILL.md files have valid frontmatter" if discovery broke. Now fails on zero. - zeroize-audit's shell regression suites matched no CI glob and had never run. Also: the validator self-test runs first, before validation. The hardcoded-path grep now covers .sh, .bats, .yml and .toml — test fixtures and install scripts are where absolute paths hide. The personal-email exclusion is anchored; an unanchored '.git' also dropped any line containing '/github'. The two npm CLI installs are pinned rather than @latest, which zizmor flagged and which made CI able to break with no commit. check_codex_loadability.py: tempdir cleanup raced the codex app-server and threw `OSError: [Errno 39] Directory not empty: '.git'`, failing the job after every loadability check had passed (seen on PR #148 today). A teardown race must not be reported as a validation failure. * Wire validators into pre-commit; fix two dead Dependabot entries pre-commit: the three .github/scripts validators were CI-only, so the first signal a contributor got was a red check after pushing — AGENTS.md asked people to remember to run them by hand. Now they run locally, with the validator self-test scoped to fire only when the validator itself changes. Adds actionlint, zizmor, check-toml, check-merge-conflict, and detect-private-key. detect-private-key earns its place: there is no secret scanning here at all, which is how a live API key sat in an untracked config file in a working tree this morning without anything noticing. Markdown linting is deliberately absent, with the measurement recorded in the config so the next person does not have to redo it: markdownlint reports ~12,400 violations across this repo (7,282 MD013 line-length alone). It would land either permanently red or with so many rules disabled that it checks nothing. Dependabot had two defects that made it quieter than it looked. The `pip` entry at / had no manifest to resolve — there is no root pyproject.toml and uv.lock is gitignored — so it reported nothing, indefinitely. And two script directories with real dependencies were uncovered: trailmark's slicing-code-context (trailmark>=0.5,<0.6) and yara-authoring's rule scripts (yara-x>=0.10.0). Switched to the uv ecosystem per the house standard, grouped minor/patch so majors still get their own CI run. * Add automated PR review, inert until a key is configured Two tiers: fast (effort low, every push, sticky comment) and deep (effort xhigh, on a deep-review label). Per-job permissions with an empty workflow-level grant; concurrency keyed per tier so a routine push cannot cancel an in-flight deep review — GitHub will not re-fire `labeled` for a label already present, so that would leave a PR sitting labeled with no review, looking reviewed. The prompt is the substance. It forbids pre-filtering: current models follow "only report high-severity issues" literally, investigating fully and then declining to report what they judge below the bar, which reads as a capability regression but is a prompt bug. It asks for everything ranked P1-P4 with a concrete failure scenario each, and filters downstream. It also names the five defect classes that actually reach main in a repo of markdown that instructs a model, rather than asking for generic code review. There is no ANTHROPIC_API_KEY secret on this repo, so every review step is gated on the secret being present. Until someone adds it these workflows check out the code and do nothing — they do not fail. A review workflow that goes red on every PR for want of a credential teaches people to ignore red checks. Fork review is a separate file with a separate decision attached. It needs pull_request_target, because under `pull_request` a fork PR gets a read-only token and the job could not post its comment at all. The usual exploit path is closed by a maintainer-only label gate, a checkout pinned to the SHA as of the labeling event (so a later force-push does not change what is reviewed), and a tool allowlist with no general Bash, so fork code is read and never executed. The residual prompt-injection risk is documented in the file header. Deleting that one file costs nothing but fork review. * Replace the PR checklist with make check Deletes the 20-item PR checklist. Current model guidance is explicit that verification scaffolding of that shape degrades output rather than improving it, and that removing it costs no capability — so the response is to move each check into machinery, not to restate it louder. Every item that could be mechanized landed in the validator first, with a fixture, before this prose was allowed to go. What replaces the checklist is two lists: what the validator enforces so you do not have to, and what no tool can check. The second list is the one that matters — whether the description actually triggers, whether the version bump is the right size, whether the README row is in the right section (the validator only checks the plugin appears somewhere in that file). Also corrects the frontmatter example, which showed `allowed-tools` as a YAML list. Every SKILL.md in the repo uses the space-delimited form;debfb29converted them deliberately. The doc was the outlier, and it caused a false finding during review of #192. Agent files genuinely do use a YAML list, under the `tools` key — both forms are now shown side by side with the reason the distinction matters. Two new sections. "Scripts a plugin ships" leads with the rule this whole change is built around: a checker that inspects zero items must fail, not pass, with the three worked examples. "Working effectively in this repo" covers effort sweeps, a subagent cap (current models over-delegate, which is a reversal from the previous generation), not adding verification scaffolding to prompts, and not telling a reviewer to pre-filter — that last one reads as a capability regression and is a prompt bug. Scopes the claude-code-guide line to questions that cannot be answered by reading this repo, and adds .opencode/ to the banned sidecar list now that the validator enforces it. * Fix three bugs CI caught in the guardrails themselves All three were mine, and the first is the one that mattered. 1. The version-increment check ran against every plugin, not just the ones the branch touched — so it demanded a version bump from all 42 plugins on a PR that changed no plugin at all. Now scoped to plugins with file changes between the base ref and HEAD. Added three self-test assertions covering it (touched plugin errors, untouched plugin does not), and verified by reverting the fix and confirming the self-test goes red. The check had no coverage before, which is exactly why it shipped broken. 2. The new pre-commit hooks invoked `uv run`, which is not installed in the Pre-commit CI job. The validator declares no dependencies, so plain python3 is correct and one less thing to install. 3. The review workflows called `claude` without installing it. Added a pinned install step, matching how validate.yml installs the same CLI. Also corrects a factual claim in both review workflow headers. I wrote that they were inert for want of an ANTHROPIC_API_KEY, based on `gh secret list` returning empty — but that only lists repository secrets. An organization-level key is visible here, so the fast tier is live on merge. The headers now say so. The deep and fork tiers still cannot fire until their labels are created. * Fix the findings from this PR's own automated review The review posted on #206 found eight issues. Seven were real. Working through them in severity order: P1 — deleted claude-review-fork.yml. It checked out the fork tree and then ran this repo's review script *from that tree*, so the script itself, and any CLAUDE.md or .claude/hooks sitting beside it, was fork-authored and executed with the org API key in the environment. My header claimed "fork code is read, never executed" and that was simply wrong; the SHA pin and the tool allowlist do not touch that path. Doing it safely means checking out base into the workspace and the fork commit into a subdirectory, which is deliberate work rather than a footnote to this change. Fork PRs get no automated review for now, and claude-review.yml explains why. P2 — the prompts instructed `gh pr comment --body-file <file>` while the tool allowlist has no Write and no general Bash, so there was nowhere to put a file. The reviewer hit this and fell back to `--body-file -`; since a give-up does not fail the step, the failure mode was a green check with no review. Now uses a heredoc on stdin. The deep tier also told the model to run the scripts a PR adds, which its allowlist cannot do; it now says so explicitly. While fixing that: both tier heredocs were unquoted so `${PR_NUMBER}` would interpolate, which means backticks in the prompt body were command substitution. Quoted them and moved substitution out to parameter expansion afterwards. shellcheck caught this. P2 — `no-version-bump` was a phantom feature: named in the validator's error message and in AGENTS.md, read by nothing. Implemented via --allow-no-bump, wired to the label through validate.yml. P2 — AGENTS.md claimed both loadability checks run in `make check`. They do not and cannot; they need two CLIs installed. It now says what runs locally, what is CI-only, and how to run the loadability checks by hand. P3 — the new shell-suites discovery reintroduced the exact bug this PR exists to remove: it printed "No shell regression suites found" and exited 0, and used `**` without globstar so it only ever matched one directory level. Now find-based and fails on empty. It is a Makefile target but not part of `make check`, because zeroize-audit's suite pipes to `python3 -` which the modern-python shim rejects — filed as #207. P3 — a failed `git diff` returned an empty changed-plugin set, silently disarming the version check for every plugin. Now raises. P3 — the `/home/user/` exclusion was a content filter applied across all of plugins/, so a skill legitimately documenting that path would be dropped from the results. Scoped to the shim bats fixtures that need it. Not fixed, deliberately: the reviewer noted `_check_ruff_parity` covers one pinned version. True, and it is the only version both files pin. * Fix the second review pass: the review job could not detect its own silence The marquee one: claude_review.sh ended with `claude --print` and nothing else, so if the model finished without calling `gh pr comment` — a denied tool, a hit timeout, or it simply summarising instead of posting — the script exited 0 and the job went green with no review attached. That is defect class 1 from the prompt this very script ships, in the script that ships it. It now timestamps before the run and fails if no comment was created or updated since. _check_ruff_parity returned None (= pass) when the Makefile or pre-commit config was missing, while still counting toward SELF_TEST_MINIMUM. A vacuous pass inside the anti-vacuity harness. Now returns an error string. The scoping claim in AGENTS.md and the Makefile was backwards. I wrote that CI scopes the validator to changed plugins while local scans everything, making local a strict superset. Both scan everything; only the version-increment check is scoped, and it is the one check CI runs that local cannot — so local is not a superset at all. Corrected in both places. README said `make check` "runs everything CI runs" and the Makefile echoed "this is what CI will run". Neither is true: it omits both loadability checks, the version-increment check, and every pre-commit hook except ruff/shellcheck/shfmt. Both now say what they actually cover. AGENTS.md already had this right. Stale count in a pre-commit comment ("these three" preceding two hooks). Accepted without change, with reasoning: the reference resolver matches any file in the plugin, so a link to a file that exists under a different skill resolves when it should not — pinning a base directory produces a flood of false positives, and the looser check is still worth having. The subagent-dispatch check only knows its own plugin's agents, so a bare cross-plugin reference is missed; catching that needs a repo-wide agent map, which is a larger change than belongs here. * Fix the third review pass, including two more silent-pass checks The hardcoded-path step had the defect this PR exists to remove. It was written as `if grep A | grep -v B | grep -v C`, so the exit status came from the last `grep -v`. A first-stage failure — plugins/ renamed, a grep built without -P — produced no output, exited 2, and the step printed "No hardcoded user paths found" while inspecting nothing. Now counts the files it scanned, fails if that is zero, and checks grep's own status via PIPESTATUS rather than the pipe's. The self-test fixture named "empty scan returns non-zero" was passing for the wrong reason: it deleted the plugin's files but left the directory, so scan_plugins_directory still returned {"demo"} and the non-zero came from the missing-README error. The guard it claimed to cover was never exercised. It now rmtree's the directory and asserts the fixture actually emptied plugins/ before relying on it. Same class of bug as everything else here, one level up. Concurrency: every `labeled` event that was not `deep-review` resolved to the `fast` key with cancel-in-progress. Adding `dependencies` mid-review cancelled the running review and then skipped both jobs, leaving a cancelled check, no review, and no event that would re-trigger one until the next push. Each label now gets its own key. The version-increment check read the old version at `base.sha` while changed_plugins diffed `base...HEAD`. Those disagree once anything lands on main after a branch forks, so a PR could fail for not out-bumping a sibling it never saw. Both halves now use the merge base, and the error message says to rebase. Smaller: the sidecar ban only matched `.agents/plugins/marketplace.json` while AGENTS.md bans `.agents/` — widened, with fixtures for it and for `plugins/*/.codex-plugin`, neither of which the harness covered. `make shell` globbed only plugins/, so the one new shell script in this PR went unchecked locally. The deep job's fork exclusion is now explicit rather than relying on secrets being unavailable to fork events. changed_plugins' docstring said working tree; it compares commits.
1109 lines
41 KiB
Python
1109 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = []
|
|
# ///
|
|
"""Validate plugin metadata, structure, and cross-references.
|
|
|
|
Two severities. **Errors** fail the build; they are things a machine can decide
|
|
without judgement. **Warnings** are reported and do not fail; they are real
|
|
problems the repo has not finished paying down, and blocking on them would only
|
|
teach people to ignore the output.
|
|
|
|
Run `--self-test` to prove the checkers still detect what they exist to detect.
|
|
A checker that has silently stopped matching reports a clean repo forever.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
ERROR = "error"
|
|
WARNING = "warning"
|
|
|
|
# Agent definitions and skills read their tool restrictions from *different* keys.
|
|
# Getting this wrong is silent: the frontmatter still parses, the restriction is
|
|
# simply ignored and the agent inherits everything.
|
|
AGENT_TOOLS_KEY = "tools"
|
|
SKILL_TOOLS_KEY = "allowed-tools"
|
|
|
|
# subagent_type values that are not plugin agents and are correctly unnamespaced.
|
|
BUILTIN_SUBAGENT_TYPES = frozenset(
|
|
{
|
|
"general-purpose",
|
|
"Explore",
|
|
"Plan",
|
|
"claude",
|
|
"statusline-setup",
|
|
"output-style-setup",
|
|
"fork",
|
|
}
|
|
)
|
|
|
|
# `subagent_type="x"`, `subagent_type: "x"`, and the prose form `subagent_type` to `x`.
|
|
SUBAGENT_TYPE_PATTERNS = (
|
|
re.compile(r"""subagent_type\s*[=:]\s*["'`]([^"'`\n]+)["'`]"""),
|
|
re.compile(r"""`subagent_type`\s+to\s+`([^`\n]+)`"""),
|
|
)
|
|
|
|
# Relative references from a skill or command file to another file in the same plugin.
|
|
# The lookbehind anchors the alternation to a path-segment boundary: without it, prose
|
|
# citing `.github/workflows/ci.yml` yields the phantom reference `workflows/ci.yml`.
|
|
REFERENCE_PATTERN = re.compile(
|
|
r"(?<![A-Za-z0-9_./-])"
|
|
r"(?:\.\./|references/|workflows/|scripts/|examples/|agents/|assets/)"
|
|
r"[A-Za-z0-9_./-]+\.(?:md|sh|py|json|yaml|yml|html|csv|toml)"
|
|
)
|
|
|
|
# Paths this repo deliberately does not carry. Claude marketplace metadata is the
|
|
# single canonical source; Codex and other runtimes read it through that compatibility.
|
|
FORBIDDEN_SIDECAR_PATHS = (
|
|
".codex",
|
|
".opencode",
|
|
".agents",
|
|
)
|
|
FORBIDDEN_PLUGIN_SIDECARS = (".codex-plugin", ".opencode-plugin")
|
|
|
|
SKILL_LINE_LIMIT = 500
|
|
KEBAB_CASE_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
PLUGIN_NAME_MAX_LENGTH = 64
|
|
REQUIRED_SKILL_SECTIONS = ("## When to Use", "## When NOT to Use")
|
|
SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
|
|
|
|
# Floor for --self-test. Raise it when you add fixtures; see the check in self_test().
|
|
SELF_TEST_MINIMUM = 20
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
"""A single validation finding."""
|
|
|
|
plugin: str
|
|
message: str
|
|
severity: str = ERROR
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.plugin}: {self.message}"
|
|
|
|
|
|
@dataclass
|
|
class ScanResult:
|
|
"""Findings plus the counters the anti-vacuity guards read."""
|
|
|
|
findings: list[Finding] = field(default_factory=list)
|
|
refs_checked: int = 0
|
|
|
|
def add(self, plugin: str, message: str, severity: str = ERROR) -> None:
|
|
self.findings.append(Finding(plugin, message, severity))
|
|
|
|
|
|
# --------------------------------------------------------------------------- parsing
|
|
|
|
|
|
def scan_plugins_directory(plugins_dir: Path) -> set[str]:
|
|
"""Scan plugins/ directory and return all plugin directory names."""
|
|
if not plugins_dir.is_dir():
|
|
return set()
|
|
|
|
return {p.name for p in plugins_dir.iterdir() if p.is_dir() and not p.name.startswith(".")}
|
|
|
|
|
|
def parse_marketplace(marketplace_path: Path) -> dict[str, dict]:
|
|
"""Parse marketplace.json and return plugin_name -> plugin_data mapping."""
|
|
if not marketplace_path.exists():
|
|
return {}
|
|
|
|
data = json.loads(marketplace_path.read_text())
|
|
return {p["name"]: p for p in data.get("plugins", []) if p.get("name")}
|
|
|
|
|
|
def parse_codeowners(codeowners_path: Path) -> set[str]:
|
|
"""Parse CODEOWNERS and return set of plugin names with entries."""
|
|
if not codeowners_path.exists():
|
|
return set()
|
|
|
|
plugins = set()
|
|
pattern = re.compile(r"^/plugins/([^/]+)/")
|
|
|
|
for line in codeowners_path.read_text().splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and (match := pattern.match(line)):
|
|
plugins.add(match.group(1))
|
|
|
|
return plugins
|
|
|
|
|
|
def parse_readme(readme_path: Path) -> set[str]:
|
|
"""Parse README.md and return set of plugin names mentioned in tables."""
|
|
if not readme_path.exists():
|
|
return set()
|
|
|
|
plugins = set()
|
|
pattern = re.compile(r"\[[^\]]+\]\(\.?/?plugins/([^/)]+)")
|
|
|
|
for line in readme_path.read_text().splitlines():
|
|
for match in pattern.finditer(line):
|
|
plugins.add(match.group(1))
|
|
|
|
return plugins
|
|
|
|
|
|
def parse_plugin_json(plugin_path: Path) -> dict | None:
|
|
"""Parse plugin.json and return data, or None if missing/invalid."""
|
|
json_path = plugin_path / ".claude-plugin" / "plugin.json"
|
|
if not json_path.exists():
|
|
return None
|
|
|
|
try:
|
|
return json.loads(json_path.read_text())
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def extract_frontmatter(text: str) -> str | None:
|
|
"""Return the raw YAML frontmatter block, or None when absent."""
|
|
if not text.startswith("---"):
|
|
return None
|
|
|
|
match = re.match(r"^---\r?\n(.*?)\r?\n---\s*(?:\r?\n|$)", text, re.DOTALL)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def frontmatter_has_key(block: str, key: str) -> bool:
|
|
"""True when the frontmatter declares `key` at the top level."""
|
|
return re.search(rf"^{re.escape(key)}\s*:", block, re.MULTILINE) is not None
|
|
|
|
|
|
def agent_files(plugin_path: Path) -> list[Path]:
|
|
"""Markdown agent definitions at the plugin's own agents/ directory.
|
|
|
|
Scoped to `plugins/<name>/agents/`. A `skills/<skill>/agents/` directory holds
|
|
per-runtime presentation metadata (icons, brand colors), not agent definitions —
|
|
matching any path containing `/agents/` would flag those as broken agents.
|
|
"""
|
|
agents_dir = plugin_path / "agents"
|
|
if not agents_dir.is_dir():
|
|
return []
|
|
return sorted(p for p in agents_dir.rglob("*.md") if p.is_file())
|
|
|
|
|
|
def skill_files(plugin_path: Path) -> list[Path]:
|
|
"""Every SKILL.md under the plugin."""
|
|
skills_dir = plugin_path / "skills"
|
|
if not skills_dir.is_dir():
|
|
return []
|
|
return sorted(skills_dir.rglob("SKILL.md"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------- errors
|
|
|
|
|
|
def validate_plugin_json(
|
|
plugin_data: dict | None,
|
|
plugin_path: Path,
|
|
plugin_name: str,
|
|
) -> list[str]:
|
|
"""Validate plugin.json, the directory name, and the plugin README."""
|
|
errors = []
|
|
|
|
# These run before the early returns below, so a plugin with an unparseable
|
|
# plugin.json still gets told about its name and its missing README.
|
|
if not KEBAB_CASE_PATTERN.match(plugin_name):
|
|
errors.append(f"directory name '{plugin_name}' is not kebab-case")
|
|
if len(plugin_name) > PLUGIN_NAME_MAX_LENGTH:
|
|
errors.append(
|
|
f"directory name is {len(plugin_name)} characters, "
|
|
f"over the {PLUGIN_NAME_MAX_LENGTH} limit"
|
|
)
|
|
|
|
# Listed rather than stat'd: `Readme.md` satisfies is_file() on case-insensitive
|
|
# macOS and then fails on Linux CI.
|
|
if "README.md" not in {p.name for p in plugin_path.iterdir()}:
|
|
errors.append("missing README.md")
|
|
|
|
json_path = plugin_path / ".claude-plugin" / "plugin.json"
|
|
if not json_path.exists():
|
|
errors.append("missing .claude-plugin/plugin.json")
|
|
return errors
|
|
|
|
if plugin_data is None:
|
|
errors.append(".claude-plugin/plugin.json is invalid JSON")
|
|
return errors
|
|
|
|
if "name" not in plugin_data:
|
|
errors.append(".claude-plugin/plugin.json missing 'name' field")
|
|
elif plugin_data["name"] != plugin_name:
|
|
errors.append(
|
|
f".claude-plugin/plugin.json name '{plugin_data['name']}' "
|
|
f"doesn't match directory name '{plugin_name}'"
|
|
)
|
|
|
|
if "description" not in plugin_data:
|
|
errors.append(".claude-plugin/plugin.json missing 'description' field")
|
|
|
|
if "version" not in plugin_data:
|
|
errors.append(".claude-plugin/plugin.json missing 'version' field")
|
|
elif not SEMVER_PATTERN.match(str(plugin_data["version"])):
|
|
errors.append(f"version '{plugin_data['version']}' is not MAJOR.MINOR.PATCH")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_marketplace_entry(
|
|
marketplace_plugins: dict[str, dict],
|
|
plugin_data: dict | None,
|
|
plugin_name: str,
|
|
) -> list[str]:
|
|
"""Validate plugin has matching entry in marketplace.json."""
|
|
if plugin_name not in marketplace_plugins:
|
|
return ["not found in .claude-plugin/marketplace.json"]
|
|
|
|
if plugin_data is None:
|
|
return []
|
|
|
|
errors = []
|
|
marketplace_entry = marketplace_plugins[plugin_name]
|
|
|
|
if plugin_data.get("name") != marketplace_entry.get("name"):
|
|
errors.append(
|
|
f"name mismatch: plugin.json has '{plugin_data.get('name')}', "
|
|
f"marketplace.json has '{marketplace_entry.get('name')}'"
|
|
)
|
|
|
|
# A bump to one file only ships nothing: clients read marketplace.json.
|
|
if plugin_data.get("version") != marketplace_entry.get("version"):
|
|
errors.append(
|
|
f"version mismatch: plugin.json has '{plugin_data.get('version')}', "
|
|
f"marketplace.json has '{marketplace_entry.get('version')}'"
|
|
)
|
|
|
|
if plugin_data.get("description") != marketplace_entry.get("description"):
|
|
errors.append("description mismatch between plugin.json and marketplace.json")
|
|
|
|
expected_source = f"./plugins/{plugin_name}"
|
|
actual_source = marketplace_entry.get("source", "")
|
|
if actual_source != expected_source:
|
|
errors.append(f"marketplace.json source '{actual_source}' should be '{expected_source}'")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_agent_frontmatter(plugin_path: Path) -> list[str]:
|
|
"""Agent files declare tools with `tools:`; skills use `allowed-tools:`.
|
|
|
|
The keys are inverted between the two file types and the loader silently ignores
|
|
the wrong one, so a restriction written the wrong way is not a restriction at all.
|
|
"""
|
|
errors = []
|
|
|
|
for agent in agent_files(plugin_path):
|
|
block = extract_frontmatter(agent.read_text(encoding="utf-8", errors="replace"))
|
|
if block is None:
|
|
errors.append(f"{agent.name}: agent file has no YAML frontmatter")
|
|
continue
|
|
if frontmatter_has_key(block, SKILL_TOOLS_KEY):
|
|
errors.append(
|
|
f"agents/{agent.name} uses '{SKILL_TOOLS_KEY}:'; agent files must use "
|
|
f"'{AGENT_TOOLS_KEY}:' (the loader ignores the other key silently)"
|
|
)
|
|
|
|
for skill in skill_files(plugin_path):
|
|
block = extract_frontmatter(skill.read_text(encoding="utf-8", errors="replace"))
|
|
if block is None:
|
|
continue
|
|
if frontmatter_has_key(block, AGENT_TOOLS_KEY):
|
|
rel = skill.relative_to(plugin_path)
|
|
errors.append(f"{rel} uses '{AGENT_TOOLS_KEY}:'; skills must use '{SKILL_TOOLS_KEY}:'")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_subagent_dispatch(plugin_path: Path, plugin_name: str) -> list[str]:
|
|
"""subagent_type values referring to this plugin's agents must be namespaced.
|
|
|
|
A bare name is unregistered and the dispatch fails at runtime.
|
|
"""
|
|
own_agents = {p.stem for p in agent_files(plugin_path)}
|
|
if not own_agents:
|
|
return []
|
|
|
|
errors = []
|
|
for path in sorted(plugin_path.rglob("*.md")) + sorted(plugin_path.rglob("*.sh")):
|
|
if not path.is_file():
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for pattern in SUBAGENT_TYPE_PATTERNS:
|
|
for match in pattern.finditer(text):
|
|
value = match.group(1).strip()
|
|
if ":" in value or value in BUILTIN_SUBAGENT_TYPES:
|
|
continue
|
|
if value.startswith(("{", "$")):
|
|
continue
|
|
if value in own_agents:
|
|
rel = path.relative_to(plugin_path)
|
|
errors.append(
|
|
f"{rel}: subagent_type '{value}' is not namespaced; "
|
|
f"use '{plugin_name}:{value}'"
|
|
)
|
|
return errors
|
|
|
|
|
|
def find_forbidden_sidecars(repo_root: Path) -> list[str]:
|
|
"""Runtime sidecar directories this repo does not carry."""
|
|
errors = []
|
|
|
|
for rel in FORBIDDEN_SIDECAR_PATHS:
|
|
if (repo_root / rel).exists():
|
|
errors.append(
|
|
f"{rel} exists; Claude marketplace metadata is the single canonical "
|
|
f"source and other runtimes read it through that compatibility"
|
|
)
|
|
|
|
plugins_dir = repo_root / "plugins"
|
|
if plugins_dir.is_dir():
|
|
for plugin in sorted(plugins_dir.iterdir()):
|
|
for sidecar in FORBIDDEN_PLUGIN_SIDECARS:
|
|
if (plugin / sidecar).exists():
|
|
errors.append(f"plugins/{plugin.name}/{sidecar} exists; not supported")
|
|
|
|
return errors
|
|
|
|
|
|
def _git_show(repo_root: Path, ref: str, rel_path: str) -> str | None:
|
|
"""Contents of `rel_path` at `ref`, or None when it did not exist there."""
|
|
result = subprocess.run(
|
|
["git", "show", f"{ref}:{rel_path}"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return result.stdout if result.returncode == 0 else None
|
|
|
|
|
|
def _merge_base(repo_root: Path, base_ref: str) -> str:
|
|
"""The commit this branch forked from, falling back to base_ref itself."""
|
|
result = subprocess.run(
|
|
["git", "merge-base", base_ref, "HEAD"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return result.stdout.strip() if result.returncode == 0 else base_ref
|
|
|
|
|
|
def _semver_tuple(value: str) -> tuple[int, int, int] | None:
|
|
match = SEMVER_PATTERN.match(str(value))
|
|
return tuple(int(g) for g in match.groups()) if match else None # type: ignore[return-value]
|
|
|
|
|
|
def changed_plugins(repo_root: Path, base_ref: str) -> set[str]:
|
|
"""Plugins with file changes between the merge base and HEAD.
|
|
|
|
Compares commits, so uncommitted local changes are invisible to a local run.
|
|
|
|
The version-increment check applies only to these. Running it over every plugin
|
|
would demand a bump from all 40+ on every PR, including PRs that touch no plugin
|
|
at all — which is exactly what it did the first time it ran in CI.
|
|
"""
|
|
result = subprocess.run(
|
|
["git", "diff", "--name-only", f"{base_ref}...HEAD"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
# Returning an empty set here would disarm the version check for every plugin
|
|
# with no message — a force-pushed base, a gc'd commit or a shallow clone would
|
|
# read as "everything is fine".
|
|
raise RuntimeError(
|
|
f"git diff against {base_ref} failed, so changed plugins cannot be "
|
|
f"determined: {result.stderr.strip()}"
|
|
)
|
|
|
|
changed = set()
|
|
for line in result.stdout.splitlines():
|
|
parts = line.strip().split("/")
|
|
if len(parts) >= 2 and parts[0] == "plugins":
|
|
changed.add(parts[1])
|
|
return changed
|
|
|
|
|
|
def validate_version_increment(
|
|
repo_root: Path,
|
|
plugin_name: str,
|
|
plugin_data: dict | None,
|
|
base_ref: str,
|
|
) -> list[str]:
|
|
"""A substantive change to a plugin must raise its version above the base ref.
|
|
|
|
Clients only see an update when the number increases, so a fix shipped without a
|
|
bump reaches nobody.
|
|
"""
|
|
if plugin_data is None or "version" not in plugin_data:
|
|
return []
|
|
|
|
rel = f"plugins/{plugin_name}/.claude-plugin/plugin.json"
|
|
# Merge base, not the base branch head. `changed_plugins` diffs `base...HEAD`, so
|
|
# reading the old version at `base_ref` directly would compare against whatever
|
|
# landed on main after this branch forked — failing a PR for not out-bumping a
|
|
# sibling it never saw.
|
|
base_raw = _git_show(repo_root, _merge_base(repo_root, base_ref), rel)
|
|
if base_raw is None:
|
|
return [] # new plugin on this branch
|
|
|
|
try:
|
|
base_version = json.loads(base_raw).get("version")
|
|
except json.JSONDecodeError:
|
|
return []
|
|
|
|
new = _semver_tuple(plugin_data["version"])
|
|
old = _semver_tuple(base_version) if base_version else None
|
|
if new is None or old is None or new > old:
|
|
return []
|
|
|
|
return [
|
|
f"version {plugin_data['version']} is not greater than {base_version} at "
|
|
f"the merge base; clients only pull an update when the number increases. "
|
|
f"Bump it, or apply the 'no-version-bump' label for a typo-only change. "
|
|
f"If another PR bumped this plugin after you branched, rebase first"
|
|
]
|
|
|
|
|
|
# -------------------------------------------------------------------------- warnings
|
|
|
|
|
|
def check_required_sections(plugin_path: Path) -> list[str]:
|
|
"""Skills should say when to use them and when not to."""
|
|
warnings = []
|
|
for skill in skill_files(plugin_path):
|
|
text = skill.read_text(encoding="utf-8", errors="replace")
|
|
rel = skill.relative_to(plugin_path)
|
|
for section in REQUIRED_SKILL_SECTIONS:
|
|
# Tolerate trailing words, e.g. "## When to Use This Skill".
|
|
if not re.search(rf"^{re.escape(section)}\b", text, re.MULTILINE):
|
|
warnings.append(f"{rel} missing '{section}' section")
|
|
return warnings
|
|
|
|
|
|
def check_skill_length(plugin_path: Path) -> list[str]:
|
|
"""Long skills should be split into references/."""
|
|
warnings = []
|
|
for skill in skill_files(plugin_path):
|
|
lines = len(skill.read_text(encoding="utf-8", errors="replace").splitlines())
|
|
if lines > SKILL_LINE_LIMIT:
|
|
rel = skill.relative_to(plugin_path)
|
|
warnings.append(f"{rel} is {lines} lines, over the {SKILL_LINE_LIMIT} limit")
|
|
return warnings
|
|
|
|
|
|
def strip_code_blocks(text: str) -> str:
|
|
"""Blank out fenced and inline code so illustrative paths are not read as links.
|
|
|
|
Skill-authoring docs are full of example paths (`references/patterns.md`) that
|
|
describe a shape rather than point at a file. Counting those produces a warning
|
|
list nobody reads. Lines are preserved so reported positions stay meaningful.
|
|
"""
|
|
out, in_fence = [], False
|
|
for line in text.splitlines():
|
|
if re.match(r"^\s*(```|~~~)", line):
|
|
in_fence = not in_fence
|
|
out.append("")
|
|
continue
|
|
out.append("" if in_fence else re.sub(r"`[^`\n]*`", "``", line))
|
|
return "\n".join(out)
|
|
|
|
|
|
def validate_reference_links(plugin_path: Path) -> tuple[list[str], int]:
|
|
"""Relative references should resolve to a file somewhere in the plugin.
|
|
|
|
Deliberately not pinned to one base directory: authors write pointers relative to
|
|
the containing file, the skill root, and the plugin root interchangeably, and
|
|
pinning produces a flood of false positives. Returns the count of references
|
|
examined so callers can detect an extractor that matched nothing.
|
|
"""
|
|
warnings: list[str] = []
|
|
checked = 0
|
|
|
|
files = [p for p in plugin_path.rglob("*.md") if p.is_file() and "evals" not in p.parts]
|
|
if not files:
|
|
return warnings, checked
|
|
|
|
suffixes = {str(p.relative_to(plugin_path)) for p in plugin_path.rglob("*") if p.is_file()}
|
|
|
|
def resolves(md: Path, ref: str) -> bool:
|
|
# A `../` reference may legitimately point outside the plugin (repo-root
|
|
# AGENTS.md, a sibling plugin), so resolve those literally first.
|
|
if ref.startswith("../") and (md.parent / ref).resolve().is_file():
|
|
return True
|
|
normalized = ref.lstrip("./")
|
|
while normalized.startswith("../"):
|
|
normalized = normalized[3:]
|
|
return any(s == normalized or s.endswith("/" + normalized) for s in suffixes)
|
|
|
|
for md in files:
|
|
text = strip_code_blocks(md.read_text(encoding="utf-8", errors="replace"))
|
|
for match in REFERENCE_PATTERN.finditer(text):
|
|
ref = match.group(0)
|
|
checked += 1
|
|
if not resolves(md, ref):
|
|
warnings.append(
|
|
f"{md.relative_to(plugin_path)}: reference '{ref}' does not resolve"
|
|
)
|
|
|
|
return warnings, checked
|
|
|
|
|
|
# ------------------------------------------------------------------------ the driver
|
|
|
|
|
|
def validate_plugins(
|
|
plugins_to_check: set[str],
|
|
repo_root: Path,
|
|
base_ref: str | None = None,
|
|
) -> ScanResult:
|
|
"""Validate all specified plugins."""
|
|
result = ScanResult()
|
|
|
|
plugins_dir = repo_root / "plugins"
|
|
marketplace_plugins = parse_marketplace(repo_root / ".claude-plugin" / "marketplace.json")
|
|
codeowners_plugins = parse_codeowners(repo_root / "CODEOWNERS")
|
|
readme_plugins = parse_readme(repo_root / "README.md")
|
|
|
|
for msg in find_forbidden_sidecars(repo_root):
|
|
result.add("<repo>", msg)
|
|
|
|
# Scoped to plugins this branch actually touched. Empty when there is no base ref
|
|
# (a push to main, or a local run), which switches the version check off entirely.
|
|
version_check_scope = changed_plugins(repo_root, base_ref) if base_ref else set()
|
|
|
|
for plugin_name in sorted(plugins_to_check):
|
|
plugin_path = plugins_dir / plugin_name
|
|
|
|
if not plugin_path.is_dir():
|
|
if plugin_name in marketplace_plugins:
|
|
result.add(plugin_name, "deleted but still in .claude-plugin/marketplace.json")
|
|
if plugin_name in codeowners_plugins:
|
|
result.add(plugin_name, "deleted but still in CODEOWNERS")
|
|
if plugin_name in readme_plugins:
|
|
result.add(plugin_name, "deleted but still in README.md")
|
|
continue
|
|
|
|
plugin_data = parse_plugin_json(plugin_path)
|
|
|
|
for msg in validate_plugin_json(plugin_data, plugin_path, plugin_name):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_marketplace_entry(marketplace_plugins, plugin_data, plugin_name):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_agent_frontmatter(plugin_path):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_subagent_dispatch(plugin_path, plugin_name):
|
|
result.add(plugin_name, msg)
|
|
|
|
if base_ref and plugin_name in version_check_scope:
|
|
for msg in validate_version_increment(repo_root, plugin_name, plugin_data, base_ref):
|
|
result.add(plugin_name, msg)
|
|
|
|
if plugin_name not in codeowners_plugins:
|
|
result.add(plugin_name, "not found in CODEOWNERS")
|
|
if plugin_name not in readme_plugins:
|
|
result.add(plugin_name, "not found in README.md")
|
|
|
|
for msg in check_required_sections(plugin_path):
|
|
result.add(plugin_name, msg, WARNING)
|
|
for msg in check_skill_length(plugin_path):
|
|
result.add(plugin_name, msg, WARNING)
|
|
|
|
ref_warnings, checked = validate_reference_links(plugin_path)
|
|
result.refs_checked += checked
|
|
for msg in ref_warnings:
|
|
result.add(plugin_name, msg, WARNING)
|
|
|
|
return result
|
|
|
|
|
|
def report(result: ScanResult) -> None:
|
|
"""Print findings grouped by severity."""
|
|
errors = [f for f in result.findings if f.severity == ERROR]
|
|
warnings = [f for f in result.findings if f.severity == WARNING]
|
|
|
|
if warnings:
|
|
print(f"\n{len(warnings)} warning(s) — these do not fail the build:\n")
|
|
for finding in warnings:
|
|
print(f" ! {finding}")
|
|
|
|
if errors:
|
|
print(f"\n{len(errors)} error(s):\n")
|
|
for finding in errors:
|
|
print(f" ✗ {finding}")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Validate plugin metadata consistency."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("repo_root", nargs="?", default=None)
|
|
parser.add_argument(
|
|
"--base-ref",
|
|
default=None,
|
|
help="git ref to compare versions against (enables the version-increment check)",
|
|
)
|
|
parser.add_argument(
|
|
"--allow-no-bump",
|
|
action="store_true",
|
|
help="skip the version-increment check (set by CI from the no-version-bump label)",
|
|
)
|
|
parser.add_argument(
|
|
"--self-test",
|
|
action="store_true",
|
|
help="prove each checker still detects what it exists to detect, then exit",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.self_test:
|
|
return self_test()
|
|
|
|
repo_root = Path(args.repo_root) if args.repo_root else Path(__file__).parent.parent.parent
|
|
|
|
plugins_to_check = scan_plugins_directory(repo_root / "plugins")
|
|
if not plugins_to_check:
|
|
print(f"No plugins found in {repo_root / 'plugins'}")
|
|
return 1
|
|
|
|
print(f"Checking {len(plugins_to_check)} plugin(s)")
|
|
|
|
base_ref = None if args.allow_no_bump else args.base_ref
|
|
if args.allow_no_bump and args.base_ref:
|
|
print("version-increment check skipped: no-version-bump label is applied")
|
|
result = validate_plugins(plugins_to_check, repo_root, base_ref)
|
|
report(result)
|
|
|
|
# A full scan that resolved zero references means the extractor broke, not that
|
|
# every plugin is clean. Reporting success there is the exact failure this guards.
|
|
if result.refs_checked == 0:
|
|
print("\n✗ reference extractor matched nothing across the whole repo — it is broken")
|
|
return 1
|
|
|
|
errors = [f for f in result.findings if f.severity == ERROR]
|
|
if errors:
|
|
return 1
|
|
|
|
print(f"\n✓ no errors ({result.refs_checked} references resolved)")
|
|
return 0
|
|
|
|
|
|
# ----------------------------------------------------------------------- self-test
|
|
|
|
|
|
def _write(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _build_demo(root: Path, name: str = "demo") -> Path:
|
|
"""A minimal well-formed plugin that every checker should accept."""
|
|
plugin = root / "plugins" / name
|
|
_write(
|
|
plugin / ".claude-plugin" / "plugin.json",
|
|
json.dumps(
|
|
{
|
|
"name": name,
|
|
"version": "1.0.0",
|
|
"description": "A demo plugin.",
|
|
}
|
|
),
|
|
)
|
|
_write(plugin / "README.md", f"# {name}\n")
|
|
_write(
|
|
plugin / "skills" / name / "SKILL.md",
|
|
"---\nname: demo\ndescription: Demo.\nallowed-tools: Read Grep\n---\n\n"
|
|
"## When to Use\n\nAlways.\n\n## When NOT to Use\n\nNever.\n\n"
|
|
"See [detail](references/detail.md).\n",
|
|
)
|
|
_write(plugin / "skills" / name / "references" / "detail.md", "# detail\n")
|
|
_write(
|
|
root / ".claude-plugin" / "marketplace.json",
|
|
json.dumps(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": name,
|
|
"version": "1.0.0",
|
|
"description": "A demo plugin.",
|
|
"source": f"./plugins/{name}",
|
|
}
|
|
]
|
|
}
|
|
),
|
|
)
|
|
_write(root / "README.md", f"| [{name}](plugins/{name}/) | demo |\n")
|
|
_write(root / "CODEOWNERS", f"/plugins/{name}/ @someone @dguido\n")
|
|
return plugin
|
|
|
|
|
|
def _errors_for(root: Path, name: str = "demo") -> list[str]:
|
|
result = validate_plugins({name}, root)
|
|
return [str(f) for f in result.findings if f.severity == ERROR]
|
|
|
|
|
|
def _warnings_for(root: Path, name: str = "demo") -> list[str]:
|
|
result = validate_plugins({name}, root)
|
|
return [str(f) for f in result.findings if f.severity == WARNING]
|
|
|
|
|
|
def _check(ran: list[str], label: str, condition: bool) -> None:
|
|
ran.append(label)
|
|
if not condition:
|
|
raise AssertionError(f"self-test failed: {label}")
|
|
|
|
|
|
def _self_test_errors(ran: list[str]) -> None:
|
|
"""Each error-level checker rejects a known-bad fixture."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_check(ran, "clean fixture produces no errors", not _errors_for(root))
|
|
_check(ran, "clean fixture produces no warnings", not _warnings_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
(plugin / "README.md").unlink()
|
|
_check(ran, "missing plugin README", any("README.md" in e for e in _errors_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "agents" / "worker.md", "---\nname: worker\nallowed-tools: Read\n---\n")
|
|
_check(
|
|
ran,
|
|
"agent using allowed-tools",
|
|
any("must use 'tools:'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "agents" / "worker.md", "---\nname: worker\ntools:\n - Read\n---\n")
|
|
_check(ran, "agent using tools: accepted", not _errors_for(root))
|
|
# A presentation sidecar under skills/*/agents/ must not be mistaken for one.
|
|
_write(plugin / "skills" / "demo" / "agents" / "openai.yaml", "color: blue\n")
|
|
_check(ran, "skills/*/agents sidecar ignored", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "agents" / "worker.md", "---\nname: worker\ntools:\n - Read\n---\n")
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(skill.read_text() + '\nUse subagent_type="worker" here.\n')
|
|
_check(
|
|
ran,
|
|
"bare subagent_type",
|
|
any("not namespaced" in e for e in _errors_for(root)),
|
|
)
|
|
skill.write_text(skill.read_text().replace('"worker"', '"demo:worker"'))
|
|
_check(ran, "namespaced subagent_type accepted", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "agents" / "w.md", "---\nname: w\ntools:\n - Read\n---\n")
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(skill.read_text() + '\nsubagent_type="Explore" is builtin.\n')
|
|
_check(ran, "builtin subagent_type accepted", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root, "Not_Kebab")
|
|
_check(
|
|
ran,
|
|
"non-kebab plugin name",
|
|
any("kebab-case" in e for e in _errors_for(root, "Not_Kebab")),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
data = json.loads((plugin / ".claude-plugin" / "plugin.json").read_text())
|
|
data["version"] = "2.0.0"
|
|
_write(plugin / ".claude-plugin" / "plugin.json", json.dumps(data))
|
|
_check(
|
|
ran,
|
|
"version parity mismatch",
|
|
any("version mismatch" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
data = json.loads((plugin / ".claude-plugin" / "plugin.json").read_text())
|
|
data["version"] = "not-a-version"
|
|
_write(plugin / ".claude-plugin" / "plugin.json", json.dumps(data))
|
|
_check(ran, "non-semver version", any("MAJOR.MINOR.PATCH" in e for e in _errors_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_write(root / ".codex" / "skills" / "demo", "symlink stand-in\n")
|
|
_check(ran, "forbidden .codex sidecar", any(".codex" in e for e in _errors_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_write(root / ".opencode" / "plugin.json", "{}\n")
|
|
_check(ran, "forbidden .opencode sidecar", any(".opencode" in e for e in _errors_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_write(root / "CODEOWNERS", "# nobody\n")
|
|
_check(ran, "missing CODEOWNERS entry", any("CODEOWNERS" in e for e in _errors_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_write(root / "README.md", "# nothing here\n")
|
|
_check(ran, "missing README row", any("README.md" in e for e in _errors_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_write(root / ".claude-plugin" / "marketplace.json", json.dumps({"plugins": []}))
|
|
_check(
|
|
ran,
|
|
"missing marketplace entry",
|
|
any("marketplace.json" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
|
|
def _self_test_warnings(ran: list[str]) -> None:
|
|
"""Each warning-level checker fires on a known-bad fixture, and does not block."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(skill.read_text().replace("## When NOT to Use", "## Notes"))
|
|
warnings = _warnings_for(root)
|
|
_check(ran, "missing When NOT to Use", any("When NOT to Use" in w for w in warnings))
|
|
_check(ran, "required sections are warnings only", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(skill.read_text().replace("## When to Use", "## When to Use This Skill"))
|
|
_check(
|
|
ran,
|
|
"trailing words on section heading tolerated",
|
|
not any("When to Use" in w for w in _warnings_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(skill.read_text() + "\n" + ("filler\n" * (SKILL_LINE_LIMIT + 5)))
|
|
_check(ran, "oversize SKILL.md", any("over the 500" in w for w in _warnings_for(root)))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
(plugin / "skills" / "demo" / "references" / "detail.md").unlink()
|
|
_check(
|
|
ran,
|
|
"dangling reference",
|
|
any("does not resolve" in w for w in _warnings_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(
|
|
skill.read_text()
|
|
+ "\nExample layout:\n\n```\nreferences/imaginary.md\n```\n"
|
|
+ "\nInline `references/also-imaginary.md` too.\n"
|
|
)
|
|
_check(
|
|
ran,
|
|
"illustrative paths in code blocks ignored",
|
|
not any("imaginary" in w for w in _warnings_for(root)),
|
|
)
|
|
|
|
|
|
def _self_test_guards(ran: list[str]) -> None:
|
|
"""The anti-vacuity guards themselves."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_, checked = validate_reference_links(plugin)
|
|
_check(ran, "reference extractor counts a real reference", checked > 0)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
# Remove the plugin directory itself, not just its files: leaving the
|
|
# directory means scan_plugins_directory still returns {"demo"} and main()
|
|
# exits non-zero for a missing README, so the guard this names would go
|
|
# untested while the assertion passed.
|
|
shutil.rmtree(root / "plugins" / "demo")
|
|
assert not scan_plugins_directory(root / "plugins"), "fixture did not empty plugins/"
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
exit_code = main([str(root)])
|
|
_check(ran, "scan finding no plugins returns non-zero", exit_code != 0)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
(plugin / ".codex-plugin").mkdir()
|
|
_check(
|
|
ran,
|
|
"per-plugin .codex-plugin sidecar",
|
|
any(".codex-plugin" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_write(root / ".agents" / "skills" / "demo.md", "sidecar\n")
|
|
_check(ran, "forbidden .agents tree", any(".agents" in e for e in _errors_for(root)))
|
|
|
|
_check(ran, "makefile and CI pin the same ruff", _check_ruff_parity() is None)
|
|
|
|
# The version check must apply only to plugins the branch touched. Without this
|
|
# scoping it demanded a bump from every plugin in the repo on every PR — which is
|
|
# how it behaved the first time it ran in CI, before this assertion existed.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root, "touched")
|
|
_build_demo(root, "untouched")
|
|
_write(
|
|
root / ".claude-plugin" / "marketplace.json",
|
|
json.dumps(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": n,
|
|
"version": "1.0.0",
|
|
"description": "A demo plugin.",
|
|
"source": f"./plugins/{n}",
|
|
}
|
|
for n in ("touched", "untouched")
|
|
]
|
|
}
|
|
),
|
|
)
|
|
_write(
|
|
root / "README.md",
|
|
"| [touched](plugins/touched/) | x |\n| [untouched](plugins/untouched/) | x |\n",
|
|
)
|
|
_write(
|
|
root / "CODEOWNERS",
|
|
"/plugins/touched/ @a @dguido\n/plugins/untouched/ @a @dguido\n",
|
|
)
|
|
for cmd in (
|
|
["git", "init", "-q"],
|
|
["git", "config", "user.email", "t@example.com"],
|
|
["git", "config", "user.name", "t"],
|
|
["git", "add", "-A"],
|
|
["git", "commit", "-q", "-m", "base"],
|
|
):
|
|
subprocess.run(cmd, cwd=root, check=True, capture_output=True)
|
|
base = subprocess.run(
|
|
["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=True
|
|
).stdout.strip()
|
|
|
|
skill = root / "plugins" / "touched" / "skills" / "touched" / "SKILL.md"
|
|
skill.write_text(skill.read_text() + "\nA substantive change.\n")
|
|
subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
|
|
subprocess.run(
|
|
["git", "commit", "-q", "-m", "change"], cwd=root, check=True, capture_output=True
|
|
)
|
|
|
|
scope = changed_plugins(root, base)
|
|
_check(ran, "changed-plugin scope finds the touched plugin", scope == {"touched"})
|
|
|
|
res = validate_plugins({"touched", "untouched"}, root, base)
|
|
msgs = [str(f) for f in res.findings if f.severity == ERROR]
|
|
_check(ran, "unbumped touched plugin errors", any("not greater than" in m for m in msgs))
|
|
_check(
|
|
ran,
|
|
"untouched plugin is not asked to bump",
|
|
not any(m.startswith("untouched") and "not greater" in m for m in msgs),
|
|
)
|
|
|
|
|
|
def _check_ruff_parity() -> str | None:
|
|
"""The Makefile claims to run what CI runs; nothing else enforces that.
|
|
|
|
CI reaches ruff through pre-commit, so the authority is the `ruff-pre-commit`
|
|
rev, not a `ruff-action` input. The rev is matched off its own repo line: a bare
|
|
`rev:` search picks up whichever hook repo happens to be listed first, which is
|
|
how this check passed against the wrong version the first time it ran.
|
|
"""
|
|
repo_root = Path(__file__).parent.parent.parent
|
|
makefile = repo_root / "Makefile"
|
|
precommit = repo_root / ".pre-commit-config.yaml"
|
|
if not makefile.exists():
|
|
return "Makefile is missing, so ruff parity cannot be checked"
|
|
if not precommit.exists():
|
|
return ".pre-commit-config.yaml is missing, so ruff parity cannot be checked"
|
|
|
|
mk = re.search(r"^RUFF_VERSION\s*:?=\s*(\S+)", makefile.read_text(), re.MULTILINE)
|
|
if not mk:
|
|
return "Makefile has no RUFF_VERSION"
|
|
|
|
pinned = re.search(
|
|
r"repo:\s*https://github\.com/astral-sh/ruff-pre-commit\s*\n\s*rev:\s*v?([\d.]+)",
|
|
precommit.read_text(),
|
|
)
|
|
if not pinned:
|
|
return ".pre-commit-config.yaml has no pinned astral-sh/ruff-pre-commit rev"
|
|
if pinned.group(1) != mk.group(1):
|
|
return (
|
|
f"Makefile pins ruff {mk.group(1)}, .pre-commit-config.yaml pins "
|
|
f"{pinned.group(1)} — `make lint` would grade against a different version "
|
|
f"than CI"
|
|
)
|
|
return None
|
|
|
|
|
|
def self_test() -> int:
|
|
"""Prove each checker still detects what it exists to detect."""
|
|
ran: list[str] = []
|
|
try:
|
|
_self_test_errors(ran)
|
|
_self_test_warnings(ran)
|
|
_self_test_guards(ran)
|
|
except AssertionError as exc:
|
|
print(f"✗ {exc}")
|
|
print(f" ({len(ran)} assertions ran before the failure)")
|
|
return 1
|
|
|
|
# The self-test is itself a checker. An early return or a bad merge that drops the
|
|
# fixture block must fail here rather than print success having run nothing.
|
|
if len(ran) < SELF_TEST_MINIMUM:
|
|
print(
|
|
f"✗ self-test ran only {len(ran)} assertions, below the floor of "
|
|
f"{SELF_TEST_MINIMUM} — the fixture block was probably truncated"
|
|
)
|
|
return 1
|
|
|
|
print(f"✓ validator self-test passed ({len(ran)} assertions)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|