mirror of
https://github.com/trailofbits/skills.git
synced 2026-09-14 14:28:48 +08:00
4b1b74b181
* Give differential-review a trigger, and name every component in its README differential-review's description listed what it does and never named a situation, so it competed on capability wording alone. It now closes with the triggers its own README already documents — reviewing a PR, commit, or diff; checking whether a change re-introduces a fixed bug; asking what else a change could break; finding modified code with no test. The same plugin's README never mentioned adversarial-modeler, which is what Phase 5 dispatches for HIGH RISK changes. Checking whether that was isolated turned up more of it, and the sweep found three kinds of gap: zeroize-audit's agent table was missing three of its eleven agents — 0-preflight, which gates the entire run, plus 5b-poc-validator and 5c-poc-verifier. All three appear in the phase diagram directly above the table, which is why they read as present. constant-time-analysis documents the ct-analyzer CLI end to end and never says the plugin also ships a skill and a command. entry-point-analyzer lists phrases that trigger its skill but never names the skill or its command. Three more READMEs describe their skill without naming it. That matters most where the skill name is not the plugin name and a user cannot guess it: chrome-mcp-troubleshooting and interpreting-culture-index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix review findings and make the README sweep a gate The two PoC rows I added to zeroize-audit said Phase 4. The diagram three lines above them, SKILL.md, and workflows/phase-5-poc-validation.md all say Phase 5, steps 5a and 5b. "Wave 5a" is a label that exists nowhere. A debugger consulting the table — the artifact this branch designates as what runs when — would have opened phase-4-poc-generation.md and found no validation in it. Also corrected the sentence introducing that table, which still said 10 agents across 8 phases against 11 across 9, and the Phase 0 diagram line, which still credited the orchestrator for a gate the new row credits to 0-preflight. differential-review's README claimed the agent is "dispatched", and named it bare in a column whose other rows are namespaced. Nothing dispatches it: the only instruction is prose in SKILL.md, and a bare subagent_type fails at runtime. Namespaced both, and corrected the five stale line counts in the same file — reporting.md is 369 lines, not the ~120 the token-efficiency section budgets for. Drop the dead `name: trailofbits:<cmd>` key from five command files. The three newest command files carry no name: at all, #275 namespaced 22 bare invocations, and this branch documents the `/<plugin>:<cmd>` form — so the key contradicts the docs it sits next to. Then make the sweep repeatable. Doing this by hand three times found eight gaps and missed two more, both of the same shape: a workflow ships under meta.name, not its filename, so a README citing the filename never writes the name a reader types. The validator now checks that a README names every skill, agent, command, and workflow its plugin ships, reading meta.name for workflows. It refuses a run that inspected zero components, and six self-test assertions hold it to known-bad fixtures. It found git-cleanup on its first run: ships as /git-cleanup:git-cleanup-analysis, README cites workflows/analyze-branches.js four times and that name never. static-analysis had the same gap for codeql-build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix both P2s: the gate was a substring test, and the dispatch was still bare The README gate ran `name not in text`. That reads as thorough and could not fail for a large share of what it counted: `draw` was satisfied by "(draw cards instead)", `semgrep-rule` by the plugin's own name in the install line, `burp-search` by a `scripts/burp-search.sh` path that is a different thing, and `audit` by the prose "shared-state struct audit". Match by kind instead. Commands and workflows are reachable only as `/<plugin>:<name>`, so require that literal — it is the only string a user can type. Agents are dispatched by identifier and never typed as prose, so require an identifier-shaped mention. Skills are genuinely referred to by bare name, so require only a delimited occurrence, which is what stops "draws" counting as `draw`. That surfaced seven real gaps, the four above plus insecure-defaults' audit-pipeline workflow, mutation-testing's skill, and trailmark's code-slice-worker. All seven fixed. adversarial-modeler was still bare at SKILL.md:96. Line 77 was the decision-tree mention; line 96 is the "Delegate to this agent" instruction a model actually acts on, so the runtime failure the last commit claimed to fix survived it. Namespaced, and it now says why. Also from the review: a per-kind floor, since a single total stays healthy while skill_files() — 63% of coverage — silently stops matching; workflow_names anchored to the meta block, because a bare search takes any earlier `name:` in a comment, and .mjs was invisible; and AGENTS.md documents the new hard failure. Self-test 88 -> 96, each new rule with a negative control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2073 lines
84 KiB
Python
2073 lines
84 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
|
|
SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
|
|
|
|
# An absolute path into one developer's home directory. The lookbehind anchors to a
|
|
# path-segment boundary. Both branches accept either case: macOS account names are
|
|
# conventionally lowercase, and a /Users/[A-Z]-only match misses nearly all of them.
|
|
HARDCODED_PATH_PATTERN = re.compile(r"(?<![a-zA-Z])(?:/home/[A-Za-z]|/Users/[A-Za-z])")
|
|
# Test fixtures and install scripts are where absolute paths hide.
|
|
HARDCODED_PATH_SUFFIXES = (".md", ".py", ".json", ".sh", ".bats", ".yml", ".toml")
|
|
# The shim suites need literal /home/user paths.
|
|
HARDCODED_PATH_EXEMPT_SUFFIX = "-shim.bats"
|
|
# Placeholders, illustrative examples (`/Users/me/` in c-review's docs) and shared system
|
|
# paths — keep this list to forms that cannot be somebody's actual account.
|
|
HARDCODED_PATH_PLACEHOLDERS = ("/path/to", "/home/vscode", "/Users/Shared", "/Users/me/")
|
|
|
|
# Documented commands the modern-python plugin's PATH shims refuse — a skill carrying one
|
|
# cannot run for anyone with that plugin installed. Unanchored, because violations hide
|
|
# mid-line (markdown table cells, `run_logged pip install …`); the guards below carve out
|
|
# the legitimate uses explicitly rather than under-detecting via an anchor.
|
|
# `uv pip` first: its lines also contain `pip <sub>`, and first-hit-wins would otherwise
|
|
# report them with pip's advice.
|
|
LEGACY_PYTHON_PATTERNS = (
|
|
(
|
|
# Every `uv pip` subcommand is refused without a tool-managed flag, not just
|
|
# install; the allowed-flags guard below carves those out.
|
|
re.compile(r"\buv\s+pip\s+[a-z]"),
|
|
"uses the legacy `uv pip` interface; use `uv add`, `uv sync`, or `uv tool install`",
|
|
),
|
|
(
|
|
# Leading flags are stepped over — long, short, and value-taking alike
|
|
# (`python3 -W ignore foo.py` is refused too) — except -c/-m, whose
|
|
# argument is not a script path. Values may not start with `-`, and
|
|
# backtracking releases a swallowed script name.
|
|
re.compile(
|
|
r"\bpython3?\s+(?:--?(?![cm]\b)[A-Za-z][\w-]*(?:[= ](?!-)\S+)?\s+)*(?!-)\S*\.py\b"
|
|
),
|
|
"runs a script through the bare interpreter; use `uv run --no-project <script>`",
|
|
),
|
|
(
|
|
# A script named by variable or path (`python3 "$MERGE"`, `python3 ./tool`) is
|
|
# refused just the same, with no `.py` token for the pattern above to see.
|
|
re.compile(r"""\bpython3?\s+(?:--?(?![cm]\b)[A-Za-z][\w-]*(?:[= ](?!-)\S+)?\s+)*["'$./]"""),
|
|
"runs a script through the bare interpreter; use `uv run --no-project <script>`",
|
|
),
|
|
(
|
|
# The shims refuse every pip/pipx subcommand via a catch-all arm. Deliberately a
|
|
# named subset here, to keep prose false positives down; extend as instances appear.
|
|
re.compile(
|
|
r"\bpip3?\s+(install|uninstall|freeze|download|list|show|check|wheel|cache|config)\b"
|
|
),
|
|
"uses pip; use `uv run --with <pkg>` for a one-off, `uv add` in your own project, "
|
|
"or `uv tool install <pkg>` for a CLI",
|
|
),
|
|
(
|
|
re.compile(r"\bpipx\s+(install|run|upgrade|uninstall|inject|list|ensurepath|reinstall)\b"),
|
|
"uses pipx; use `uv tool install <pkg>` or `uvx <pkg>`",
|
|
),
|
|
(
|
|
re.compile(r"\bpython3?\s+-m\s+pip\b"),
|
|
"uses python -m pip; use `uv run --with <pkg>`, `uv add`, or `uv tool install`",
|
|
),
|
|
(
|
|
# A bare interpreter with only flags (`python3 --version`) is refused as well:
|
|
# the shim finds no -c/-m/- selector and falls through to the refusal arm.
|
|
re.compile(r"\bpython3?\s+--?[A-Za-z][\w-]*\s*$"),
|
|
"invokes the bare interpreter; use `uv run python <flags>`",
|
|
),
|
|
)
|
|
# Prose that names a command in order to forbid it ("Do NOT run `pip install`"). Tested
|
|
# against the text BEFORE the match, so "Use `pip install x` instead of the tarball" — a
|
|
# real instruction — is not exempted by its own trailing "instead of".
|
|
LEGACY_PYTHON_PROHIBITIONS = ("do not run", "don't run", "do not use", "never run", "instead of")
|
|
# `uv run --no-project python fuzz.py` contains a literal `python fuzz.py`; text already
|
|
# introduced by a compliant `uv run` is the fix, not a violation.
|
|
LEGACY_PYTHON_COMPLIANT_PREFIX = re.compile(r"uv\s+run\s+(?:--?[A-Za-z-]+(?:[= ]\S+)?\s+)*$")
|
|
# A tool managing an environment it owns (prek installs hooks this way); the shim permits
|
|
# it. Matched as whole flags after the command so `--target-dir` does not count.
|
|
LEGACY_PYTHON_UV_PIP_ALLOWED = re.compile(r"\s(--project|--directory|--target|-t)([= ]|$)")
|
|
# Exempts its code block up to the next blank line or fence, for commands that run where
|
|
# the shims are absent (a container, a target project's own build). A bare marker with no
|
|
# reason does not exempt.
|
|
LEGACY_PYTHON_ALLOW_MARKER = "allow-legacy-python:"
|
|
LEGACY_PYTHON_ALLOW_RE = re.compile(r"allow-legacy-python:\s*\S")
|
|
# Local trees accumulate these; CI never has them, so skipping keeps the two runs equal.
|
|
SCAN_SKIP_DIRS = frozenset({".venv", "venv", "node_modules", "__pycache__", ".git", ".ruff_cache"})
|
|
|
|
# Floor for --self-test, set to the exact number of assertions the fixtures run. There is
|
|
# no slack on purpose: dropping one has to be a deliberate edit here, not a silent loss.
|
|
SELF_TEST_MINIMUM = 96
|
|
|
|
|
|
@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
|
|
paths_scanned: int = 0
|
|
python_docs_scanned: int = 0
|
|
components_checked: int = 0
|
|
components_by_kind: dict[str, int] = field(default_factory=dict)
|
|
|
|
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"))
|
|
|
|
|
|
def command_files(plugin_path: Path) -> list[Path]:
|
|
"""Markdown slash-command definitions at the plugin's own commands/ directory."""
|
|
commands_dir = plugin_path / "commands"
|
|
if not commands_dir.is_dir():
|
|
return []
|
|
return sorted(p for p in commands_dir.rglob("*.md") if p.is_file())
|
|
|
|
|
|
# ---------------------------------------------------------------------------- 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_tools_frontmatter(plugin_path: Path) -> list[str]:
|
|
"""Agent files declare tools with `tools:`; skills and commands use `allowed-tools:`.
|
|
|
|
The keys are inverted between the 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}:'")
|
|
|
|
for command in command_files(plugin_path):
|
|
block = extract_frontmatter(command.read_text(encoding="utf-8", errors="replace"))
|
|
if block is None:
|
|
continue
|
|
if frontmatter_has_key(block, AGENT_TOOLS_KEY):
|
|
rel = command.relative_to(plugin_path)
|
|
errors.append(
|
|
f"{rel} uses '{AGENT_TOOLS_KEY}:'; commands must use '{SKILL_TOOLS_KEY}:'"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def validate_command_frontmatter(plugin_path: Path) -> list[str]:
|
|
"""Command files need parseable frontmatter with a description, and `allowed-tools:`.
|
|
|
|
Nothing else checks commands. A plugin whose only entry point is a command had its
|
|
frontmatter validated by no tool at all, and the loadability checks count skills, so
|
|
a malformed command shipped green.
|
|
"""
|
|
errors = []
|
|
|
|
for command in command_files(plugin_path):
|
|
rel = command.relative_to(plugin_path)
|
|
block = extract_frontmatter(command.read_text(encoding="utf-8", errors="replace"))
|
|
if block is None:
|
|
errors.append(f"{rel}: command file has no YAML frontmatter")
|
|
continue
|
|
if not frontmatter_has_key(block, "description"):
|
|
errors.append(f"{rel}: command frontmatter has no 'description:'")
|
|
if frontmatter_has_key(block, AGENT_TOOLS_KEY):
|
|
errors.append(
|
|
f"{rel} uses '{AGENT_TOOLS_KEY}:'; commands must use '{SKILL_TOOLS_KEY}:' "
|
|
"(the loader ignores the other key silently)"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def validate_skill_frontmatter(plugin_path: Path) -> list[str]:
|
|
"""Top-level frontmatter values must survive a YAML parse.
|
|
|
|
A plain (unquoted) YAML scalar cannot contain ": " — that parses as a nested
|
|
mapping — nor " #", which starts a comment. Either one makes the whole block
|
|
unparseable, and the loader then drops *every* field and loads the skill with
|
|
empty metadata. Nothing about the file looks wrong: a presence check still sees
|
|
`description:` on line 3 and reports it clean, so the skill ships with no
|
|
description and simply never triggers.
|
|
"""
|
|
errors = []
|
|
|
|
for skill in skill_files(plugin_path):
|
|
block = extract_frontmatter(skill.read_text(encoding="utf-8", errors="replace"))
|
|
if block is None:
|
|
errors.append(f"{skill.relative_to(plugin_path)}: has no YAML frontmatter")
|
|
continue
|
|
|
|
for line in block.splitlines():
|
|
match = re.match(r"^([A-Za-z0-9_-]+)\s*:\s*(\S.*?)\s*$", line)
|
|
if not match:
|
|
continue
|
|
key, value = match.group(1), match.group(2)
|
|
# Quotes, block scalars, flow collections and YAML indicators are all
|
|
# parsed by rules other than the plain-scalar ones below.
|
|
if value[0] in "\"'|>[{&*!%@`":
|
|
continue
|
|
for token, human in ((": ", "': '"), (" #", "' #'")):
|
|
if token in value:
|
|
errors.append(
|
|
f"{skill.relative_to(plugin_path)}: '{key}' is an unquoted YAML "
|
|
f"scalar containing {human}, so the frontmatter does not parse and "
|
|
f"the skill loads with no metadata at all — wrap the value in quotes"
|
|
)
|
|
break
|
|
|
|
return errors
|
|
|
|
|
|
def validate_entry_points(plugin_path: Path) -> list[str]:
|
|
"""A plugin must expose something a user or model can actually invoke.
|
|
|
|
The loadability checks count `skills/**/SKILL.md` and MCP servers, so a plugin with
|
|
neither passes them at 0 == 0 while shipping nothing runnable. Requiring at least one
|
|
entry point here means that vacuous pass cannot be the whole story.
|
|
"""
|
|
if skill_files(plugin_path) or command_files(plugin_path):
|
|
return []
|
|
if (plugin_path / "agents").is_dir() and agent_files(plugin_path):
|
|
return []
|
|
if (plugin_path / ".mcp.json").is_file() or (plugin_path / "hooks" / "hooks.json").is_file():
|
|
return []
|
|
return ["exposes no entry point: no skills/, commands/, agents/, hooks, or .mcp.json"]
|
|
|
|
|
|
def workflow_names(plugin_path: Path) -> list[tuple[str, str]]:
|
|
"""Dynamic workflows a plugin ships, as (display path, invocable name) pairs.
|
|
|
|
A workflow ships as `/<plugin>:<meta.name>`, and `meta.name` is frequently not the
|
|
filename — so a scan that globbed filenames would look thorough and still miss the
|
|
name a reader has to type. Falls back to the stem only when `meta.name` is absent.
|
|
"""
|
|
workflows_dir = plugin_path / "workflows"
|
|
if not workflows_dir.is_dir():
|
|
return []
|
|
found = []
|
|
for path in sorted(workflows_dir.rglob("*.[mc]js")) + sorted(workflows_dir.rglob("*.js")):
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
# Anchor to the meta block. A bare search takes the first `name:` in the file,
|
|
# and these scripts carry `name:` inside comments and inside agent option
|
|
# objects — code-improver's improve.js already has one in a comment. A file
|
|
# with no `export const meta` is a helper, not a shippable workflow.
|
|
start = text.find("export const meta")
|
|
if start == -1:
|
|
continue
|
|
match = re.search(r"""\bname:\s*['"]([^'"]+)['"]""", text[start:])
|
|
if not match:
|
|
continue # A computed or templated name is not something a README can quote.
|
|
found.append((path.name, match.group(1)))
|
|
return sorted(set(found))
|
|
|
|
|
|
def _readme_names(text: str, kind: str, name: str, plugin: str) -> bool:
|
|
"""Whether a README names one component, as opposed to merely containing its letters.
|
|
|
|
A plain `name in text` looks thorough and cannot fail for a large share of what it
|
|
counts: `draw` is satisfied by "draws Tarot cards", `semgrep-rule` by the plugin's
|
|
own name in the install line, and `burp-search` by a `scripts/burp-search.sh` path
|
|
that is a different thing entirely.
|
|
|
|
Commands and workflows are reachable only as `/<plugin>:<name>`, so that literal is
|
|
the only mention that helps a reader — nothing else tells them what to type.
|
|
|
|
Agents are dispatched by identifier and never typed as prose, so a bare word in a
|
|
sentence does not name one: `let-fate-decide`'s `draw` agent was satisfied by
|
|
"(draw cards instead)". They need an identifier-shaped mention — backticked, or as
|
|
an `agents/<name>` path, or namespaced.
|
|
|
|
Skills are the one kind genuinely referred to by bare name in prose and tables, so
|
|
they need only a delimited occurrence — one not glued to a longer identifier, which
|
|
is what stops "draws" counting as `draw` and `semgrep-rule-creator` as `semgrep-rule`.
|
|
"""
|
|
if kind in ("command", "workflow"):
|
|
return f"/{plugin}:{name}" in text
|
|
if kind == "agent":
|
|
return any(form in text for form in (f"`{name}`", f"agents/{name}", f"{plugin}:{name}"))
|
|
delimited = re.compile(rf"(?<![A-Za-z0-9_-]){re.escape(name)}(?![A-Za-z0-9_-])")
|
|
return delimited.search(text) is not None
|
|
|
|
|
|
def validate_readme_names_components(plugin_path: Path) -> tuple[list[str], int]:
|
|
"""A plugin's README must name every component the plugin ships.
|
|
|
|
A README that describes a skill without naming it leaves the reader unable to invoke
|
|
it, and the gap is worst exactly where it is least guessable — when the skill name is
|
|
not the plugin name. The same applies to agents, commands, and workflows: an agent
|
|
missing from a pipeline table reads as a pipeline that does not have it.
|
|
|
|
Returns the findings, the number of components inspected, and that count broken down
|
|
by kind. The counts are returned so the caller can refuse a run that inspected
|
|
nothing — a sweep over zero components reports "all clean" exactly like a sweep over
|
|
all of them — and per-kind so the loss of one discovery helper cannot hide inside a
|
|
healthy total.
|
|
"""
|
|
readme = plugin_path / "README.md"
|
|
if not readme.is_file():
|
|
return [], 0, {} # A missing README is already an error elsewhere.
|
|
text = readme.read_text(encoding="utf-8", errors="replace")
|
|
|
|
components: list[tuple[str, str]] = []
|
|
components += [("skill", p.parent.name) for p in skill_files(plugin_path)]
|
|
components += [("agent", p.stem) for p in agent_files(plugin_path)]
|
|
components += [("command", p.stem) for p in command_files(plugin_path)]
|
|
components += [("workflow", name) for _, name in workflow_names(plugin_path)]
|
|
|
|
findings = []
|
|
for kind, name in components:
|
|
if _readme_names(text, kind, name, plugin_path.name):
|
|
continue
|
|
wanted = f"/{plugin_path.name}:{name}" if kind in ("command", "workflow") else f"'{name}'"
|
|
findings.append(
|
|
f"README.md does not name the {kind} {wanted} — "
|
|
f"a reader cannot invoke what is not named"
|
|
)
|
|
|
|
by_kind: dict[str, int] = {}
|
|
for kind, _ in components:
|
|
by_kind[kind] = by_kind.get(kind, 0) + 1
|
|
return findings, len(components), by_kind
|
|
|
|
|
|
def validate_subagent_dispatch(
|
|
plugin_path: Path,
|
|
plugin_name: str,
|
|
agent_owners: dict[str, list[str]],
|
|
) -> list[str]:
|
|
"""Every bare subagent_type is a dispatch that fails at runtime.
|
|
|
|
`agent_owners` maps an agent filename stem to the plugins defining it, across the
|
|
whole repo. Scoping this to the plugin's own agents would miss the two cases most
|
|
likely to ship: a bare name borrowed from another plugin, and a bare name for an
|
|
agent that no longer exists anywhere.
|
|
"""
|
|
own_agents = {p.stem for p in agent_files(plugin_path)}
|
|
|
|
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
|
|
rel = path.relative_to(plugin_path)
|
|
if value in own_agents:
|
|
errors.append(
|
|
f"{rel}: subagent_type '{value}' is not namespaced; "
|
|
f"use '{plugin_name}:{value}'"
|
|
)
|
|
elif value in agent_owners:
|
|
suggestion = " or ".join(
|
|
f"'{owner}:{value}'" for owner in sorted(agent_owners[value])
|
|
)
|
|
errors.append(
|
|
f"{rel}: subagent_type '{value}' is not namespaced; use {suggestion}"
|
|
)
|
|
else:
|
|
errors.append(
|
|
f"{rel}: subagent_type '{value}' names no agent in this repo and is "
|
|
f"not a builtin, so the dispatch fails at runtime"
|
|
)
|
|
return errors
|
|
|
|
|
|
def find_legacy_python_invocations(repo_root: Path) -> tuple[list[str], int]:
|
|
"""Documented commands the modern-python shims refuse, so the skill cannot run.
|
|
|
|
Returns the findings and the number of files scanned; zero scanned is the caller's
|
|
anti-vacuity guard. `plugins/modern-python/` is exempt wholesale so it can keep
|
|
documenting the commands it intercepts.
|
|
"""
|
|
errors: list[str] = []
|
|
scanned = 0
|
|
|
|
plugins_dir = repo_root / "plugins"
|
|
if not plugins_dir.is_dir():
|
|
return errors, scanned
|
|
|
|
# .sh and .py as well as .md: shell suites carried ten live invocations a docs-only
|
|
# sweep missed, and .py usage strings tell users to run refused commands.
|
|
candidates = (
|
|
sorted(plugins_dir.rglob("*.md"))
|
|
+ sorted(plugins_dir.rglob("*.sh"))
|
|
+ sorted(plugins_dir.rglob("*.py"))
|
|
)
|
|
for path in candidates:
|
|
if not path.is_file():
|
|
continue
|
|
if path.is_relative_to(plugins_dir / "modern-python"):
|
|
continue
|
|
if SCAN_SKIP_DIRS.intersection(path.parts):
|
|
continue
|
|
# .md and .py under evals*/tests quote commands as expectations under test;
|
|
# .sh there are commands, so shell keeps no exemption.
|
|
if path.suffix in (".md", ".py") and any(
|
|
part.startswith("evals") or part == "tests"
|
|
for part in path.relative_to(plugins_dir).parts
|
|
):
|
|
continue
|
|
scanned += 1
|
|
|
|
# A `dockerfile` fence runs in a container, where the PATH shims are absent.
|
|
in_dockerfile = False
|
|
block_exempt = False
|
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
for lineno, line in enumerate(lines, 1):
|
|
fence = line.strip()
|
|
if fence.startswith("```"):
|
|
language = fence[3:].strip().lower()
|
|
in_dockerfile = language == "dockerfile" if language else False
|
|
block_exempt = False
|
|
continue
|
|
if not line.strip():
|
|
block_exempt = False
|
|
continue
|
|
if LEGACY_PYTHON_ALLOW_MARKER in line:
|
|
block_exempt = bool(LEGACY_PYTHON_ALLOW_RE.search(line))
|
|
continue
|
|
if in_dockerfile or block_exempt:
|
|
continue
|
|
|
|
# Every match is examined, not just the first: a compliant `uv run …` earlier
|
|
# on a line must not mask a refused command later on the same line.
|
|
hit = None
|
|
for pattern, advice in LEGACY_PYTHON_PATTERNS:
|
|
for match in pattern.finditer(line):
|
|
prefix = line[: match.start()]
|
|
if LEGACY_PYTHON_COMPLIANT_PREFIX.search(prefix):
|
|
continue
|
|
# A `pip …` directly after `uv ` is part of a `uv pip` command,
|
|
# whose verdict the uv-pip pattern above already delivered.
|
|
if not match.group(0).startswith("uv") and re.search(r"\buv\s+$", prefix):
|
|
continue
|
|
if any(p in prefix.lower() for p in LEGACY_PYTHON_PROHIBITIONS):
|
|
continue
|
|
if "uv pip" in match.group(0) and LEGACY_PYTHON_UV_PIP_ALLOWED.search(
|
|
line[match.start() :]
|
|
):
|
|
continue
|
|
hit = advice
|
|
break
|
|
if hit:
|
|
break
|
|
if hit:
|
|
rel = path.relative_to(repo_root)
|
|
errors.append(f"{rel}:{lineno} {hit} — found: {line.strip()[:70]}")
|
|
|
|
return errors, scanned
|
|
|
|
|
|
def find_hardcoded_paths(repo_root: Path) -> tuple[list[str], int]:
|
|
"""Absolute paths into one developer's home directory, which nobody else has.
|
|
|
|
Returns the findings and the number of files scanned. The count is the caller's
|
|
anti-vacuity guard: a scan that inspected nothing must not report clean.
|
|
"""
|
|
errors = []
|
|
scanned = 0
|
|
|
|
plugins_dir = repo_root / "plugins"
|
|
if not plugins_dir.is_dir():
|
|
return errors, scanned
|
|
|
|
for path in sorted(plugins_dir.rglob("*")):
|
|
if not path.is_file() or path.suffix not in HARDCODED_PATH_SUFFIXES:
|
|
continue
|
|
if path.name.endswith(HARDCODED_PATH_EXEMPT_SUFFIX):
|
|
continue
|
|
if SCAN_SKIP_DIRS.intersection(path.parts):
|
|
continue
|
|
scanned += 1
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
if not HARDCODED_PATH_PATTERN.search(line):
|
|
continue
|
|
if any(placeholder in line for placeholder in HARDCODED_PATH_PLACEHOLDERS):
|
|
continue
|
|
rel = path.relative_to(repo_root)
|
|
errors.append(f"{rel}:{lineno} hardcodes an absolute user path: {line.strip()[:80]}")
|
|
|
|
return errors, scanned
|
|
|
|
|
|
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 check_dependabot_lockfiles(repo_root: Path) -> list[str]:
|
|
"""Every uv directory in dependabot.yml must carry a committed uv.lock.
|
|
|
|
Without one there is nothing to pin, so Dependabot's only available action is
|
|
raising the lower bound of an already-open range — which changes nothing about
|
|
what installs and only drops support for older versions. That produced five
|
|
no-op PRs the first time this config ran. Documenting the rule in a comment
|
|
left it unenforced; this makes it real.
|
|
"""
|
|
config = repo_root / ".github" / "dependabot.yml"
|
|
if not config.exists():
|
|
return []
|
|
|
|
text = config.read_text()
|
|
# Read the `directories:` list belonging to the uv ecosystem block only. A bare
|
|
# search for `- /plugins/...` would also pick up any future ecosystem's paths.
|
|
uv_block = re.search(
|
|
r"^\s*-\s*package-ecosystem:\s*uv\s*$(.*?)(?=^\s*-\s*package-ecosystem:|\Z)",
|
|
text,
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
if not uv_block:
|
|
return []
|
|
|
|
listed = re.findall(r"^\s+-\s+(/plugins/\S+)\s*$", uv_block.group(1), re.MULTILINE)
|
|
if not listed:
|
|
# The block exists but no directories parsed out — the regex broke, or the
|
|
# block was emptied. Either way this checker just inspected zero items.
|
|
return [
|
|
"dependabot.yml has a uv ecosystem block but no directories parsed from it "
|
|
"— the lockfile check inspected nothing"
|
|
]
|
|
|
|
errors = []
|
|
for rel in listed:
|
|
directory = repo_root / rel.lstrip("/")
|
|
if not directory.is_dir():
|
|
errors.append(f"dependabot.yml lists {rel}, which does not exist")
|
|
elif not (directory / "uv.lock").is_file():
|
|
errors.append(
|
|
f"dependabot.yml lists {rel} but it has no committed uv.lock; "
|
|
f"without one Dependabot can only raise version floors (run "
|
|
f"`cd {rel.lstrip('/')} && uv lock`)"
|
|
)
|
|
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_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
|
|
|
|
# Any evals* directory is skipped: its .md files are labelled eval queries, not
|
|
# documentation, and they carry frontmatter rather than references. The prefix match
|
|
# covers "evals-extra" (harnesses invoked by hand rather than by `make check`) as
|
|
# well as "evals" — a bare equality check silently starts scanning them.
|
|
files = [
|
|
p
|
|
for p in plugin_path.rglob("*.md")
|
|
if p.is_file() and not any(part.startswith("evals") for part 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")
|
|
|
|
# Agents are addressed as `<plugin>:<agent>` from anywhere, so the registry has to be
|
|
# repo-wide and built before any single plugin is checked against it.
|
|
agent_owners: dict[str, list[str]] = {}
|
|
if plugins_dir.is_dir():
|
|
for plugin in sorted(plugins_dir.iterdir()):
|
|
if not plugin.is_dir():
|
|
continue
|
|
for agent in agent_files(plugin):
|
|
agent_owners.setdefault(agent.stem, []).append(plugin.name)
|
|
|
|
for msg in find_forbidden_sidecars(repo_root):
|
|
result.add("<repo>", msg)
|
|
for msg in check_dependabot_lockfiles(repo_root):
|
|
result.add("<repo>", msg)
|
|
|
|
legacy_errors, result.python_docs_scanned = find_legacy_python_invocations(repo_root)
|
|
for msg in legacy_errors:
|
|
result.add("<repo>", msg)
|
|
|
|
path_errors, result.paths_scanned = find_hardcoded_paths(repo_root)
|
|
for msg in path_errors:
|
|
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_tools_frontmatter(plugin_path):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_command_frontmatter(plugin_path):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_skill_frontmatter(plugin_path):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_entry_points(plugin_path):
|
|
result.add(plugin_name, msg)
|
|
for msg in validate_subagent_dispatch(plugin_path, plugin_name, agent_owners):
|
|
result.add(plugin_name, msg)
|
|
|
|
readme_findings, components, by_kind = validate_readme_names_components(plugin_path)
|
|
result.components_checked += components
|
|
for kind, count in by_kind.items():
|
|
result.components_by_kind[kind] = result.components_by_kind.get(kind, 0) + count
|
|
for msg in readme_findings:
|
|
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_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
|
|
|
|
# Same failure, different scan: a clean result here means nothing if the walk found
|
|
# no files to read, so prove discovery worked before trusting it.
|
|
if result.paths_scanned == 0:
|
|
print("\n✗ hardcoded-path scan matched no files at all — discovery is broken")
|
|
return 1
|
|
|
|
if result.python_docs_scanned == 0:
|
|
print("\n✗ legacy-python scan read no files at all — discovery is broken")
|
|
return 1
|
|
|
|
# Four independent discovery sources feed this. A single total would stay comfortably
|
|
# non-zero if skill_files() — 81 of 129 components — stopped matching, so each kind
|
|
# carries its own floor: this repo ships all four, so a zero anywhere is a broken
|
|
# glob, not an empty category.
|
|
seen = result.components_by_kind
|
|
empty_kinds = [kind for kind, count in sorted(seen.items()) if count == 0]
|
|
missing_kinds = sorted({"skill", "agent", "command", "workflow"} - set(seen))
|
|
if empty_kinds or missing_kinds:
|
|
broken = ", ".join(empty_kinds + missing_kinds)
|
|
print(
|
|
f"\n✗ README component scan found no {broken} components at all — discovery 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, "
|
|
f"{result.paths_scanned} files scanned for hardcoded paths, "
|
|
f"{result.python_docs_scanned} for legacy python invocations, "
|
|
f"{result.components_checked} components named in their README)"
|
|
)
|
|
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.",
|
|
}
|
|
),
|
|
)
|
|
# Names the component vocabulary the other fixtures use, so each of them stays
|
|
# isolated to the checker it targets instead of also tripping the
|
|
# README-names-its-components check. _self_test_readme_components overwrites
|
|
# this when a missing name is the thing under test.
|
|
_write(
|
|
plugin / "README.md",
|
|
f"# {name}\n\nCommands and workflows: `/{name}:go`, `/{name}:run-it`, "
|
|
f"`/{name}:audit`, `/{name}:demo-analysis`.\n\n"
|
|
"Agents: `worker`, `helper`, `w`.\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 / "commands" / "go.md", "# no frontmatter\n")
|
|
_check(
|
|
ran,
|
|
"command without frontmatter",
|
|
any("no YAML frontmatter" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "commands" / "go.md", "---\nargument-hint: x\n---\n")
|
|
_check(
|
|
ran,
|
|
"command without description",
|
|
any("no 'description:'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "commands" / "go.md", "---\ndescription: Go.\ntools:\n - Read\n---\n")
|
|
_check(
|
|
ran,
|
|
"command using tools:",
|
|
any("commands must use 'allowed-tools:'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "commands" / "go.md", "---\ndescription: Go.\nallowed-tools: Read\n---\n")
|
|
_check(ran, "a valid command is accepted", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
shutil.rmtree(plugin / "skills")
|
|
_check(
|
|
ran,
|
|
"plugin with no entry point",
|
|
any("exposes no entry point" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
shutil.rmtree(plugin / "skills")
|
|
_write(plugin / "commands" / "go.md", "---\ndescription: Go.\nallowed-tools: Read\n---\n")
|
|
_check(
|
|
ran,
|
|
"commands alone satisfy the entry-point rule",
|
|
not any("exposes no entry point" 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:
|
|
# `other` is built first so the second _build_demo leaves the marketplace, README
|
|
# and CODEOWNERS describing `demo`, which is the only plugin validated here.
|
|
root = Path(tmp)
|
|
other = _build_demo(root, "other")
|
|
_write(other / "agents" / "helper.md", "---\nname: helper\ntools:\n - Read\n---\n")
|
|
plugin = _build_demo(root)
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
skill.write_text(skill.read_text() + '\nUse subagent_type="helper" here.\n')
|
|
_check(
|
|
ran,
|
|
"bare subagent_type borrowed from another plugin",
|
|
any("other:helper" in e for e in _errors_for(root)),
|
|
)
|
|
skill.write_text(skill.read_text().replace('"helper"', '"other:helper"'))
|
|
_check(ran, "cross-plugin namespaced subagent_type accepted", 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() + '\nUse subagent_type="ghost" here.\n')
|
|
_check(
|
|
ran,
|
|
"subagent_type naming no agent at all",
|
|
any("names no agent in this repo" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(
|
|
plugin / "commands" / "audit.md",
|
|
"---\nname: audit\ndescription: Audit it\ntools: Read\n---\n\nRun it.\n",
|
|
)
|
|
_check(
|
|
ran,
|
|
"command using tools:",
|
|
any("commands must use 'allowed-tools:'" in e for e in _errors_for(root)),
|
|
)
|
|
_write(plugin / "commands" / "audit.md", "Run it.\n")
|
|
_check(
|
|
ran,
|
|
"command with no frontmatter",
|
|
any("command file has no YAML frontmatter" in e for e in _errors_for(root)),
|
|
)
|
|
_write(
|
|
plugin / "commands" / "audit.md",
|
|
"---\nname: audit\nallowed-tools: Read\n---\n\nRun it.\n",
|
|
)
|
|
_check(
|
|
ran,
|
|
"command with no description",
|
|
any("command frontmatter has no 'description:'" in e for e in _errors_for(root)),
|
|
)
|
|
_write(
|
|
plugin / "commands" / "audit.md",
|
|
"---\nname: audit\ndescription: Audit it\nallowed-tools: Read\n---\n\nRun it.\n",
|
|
)
|
|
_check(ran, "command using allowed-tools: accepted", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "scripts" / "run.sh", "#!/usr/bin/env bash\ncd /Users/Someone/cc/skills\n")
|
|
_check(
|
|
ran,
|
|
"hardcoded /Users path",
|
|
any("hardcodes an absolute user path" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
# The case the inherited pattern missed. macOS account names are lowercase by
|
|
# convention, so this is the form a real leaked path almost always takes.
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "scripts" / "run.sh", "#!/usr/bin/env bash\ncd /Users/alice/cc/skills\n")
|
|
_check(
|
|
ran,
|
|
"hardcoded lowercase /Users path",
|
|
any("hardcodes an absolute user path" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "skills" / "demo" / "shared.md", "Drop it in /Users/Shared/build.\n")
|
|
_check(ran, "macOS shared directory is not a personal path", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "skills" / "demo" / "setup.md", "Run from /home/alice/work.\n")
|
|
_check(
|
|
ran,
|
|
"hardcoded /home path",
|
|
any("hardcodes an absolute user path" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
shim_suite = plugin / "hooks" / "shims" / "gh-shim.bats"
|
|
_write(shim_suite, "@test 'x' {\n cd /home/user/repo\n}\n")
|
|
_check(ran, "shim suite exempt from path scan", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "skills" / "demo" / "devcontainer.md", "Workspace is /home/vscode/app.\n")
|
|
_check(ran, "container image path is not a personal path", not _errors_for(root))
|
|
|
|
# The refused forms must fire; the compliant and exempt forms must not.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
doc = plugin / "skills" / "demo" / "howto.md"
|
|
|
|
for label, body in (
|
|
("bare interpreter on a script", "Run `python3 tools/x.py` first.\n"),
|
|
("bare interpreter behind a flag", "```bash\npython3 -u tools/x.py\n```\n"),
|
|
("bare interpreter, flags only", "```bash\npython3 --version\n```\n"),
|
|
("pip install", "```bash\npip install requests\n```\n"),
|
|
("uv pip install", "```bash\nuv pip install requests\n```\n"),
|
|
("uv pip non-install subcommand", "```bash\nuv pip list\n```\n"),
|
|
("python -m pip", "```bash\npython -m pip download requests\n```\n"),
|
|
("pip non-install subcommand", "```bash\npip freeze\n```\n"),
|
|
("pipx", "```bash\npipx install detect-secrets\n```\n"),
|
|
("value-taking flag before the script", "```bash\npython3 -W ignore harness.py\n```\n"),
|
|
("long flag before the script", "```bash\npython3 --verbose tool.py\n```\n"),
|
|
(
|
|
"allow-marker with no reason",
|
|
"```bash\n# allow-legacy-python:\npip install requests\n```\n",
|
|
),
|
|
("script named by a variable", '```bash\npython3 "$MERGE" out.sarif\n```\n'),
|
|
(
|
|
"compliant match masking a later violation",
|
|
"| a | `uv run python a.py` | `python3 b.py` |\n",
|
|
),
|
|
(
|
|
"prohibition after the command, not before",
|
|
"Use `pip install semgrep` instead of the tarball.\n",
|
|
),
|
|
(
|
|
"uv pip with a flag that is not tool-managed",
|
|
"```bash\nuv pip install --target-dir /x foo\n```\n",
|
|
),
|
|
("usage string in a .py file", None),
|
|
(
|
|
"marker scope ends at a blank line",
|
|
"```bash\n# allow-legacy-python: x\n\npip install requests\n```\n",
|
|
),
|
|
):
|
|
if body is None:
|
|
_write(doc, "clean\n")
|
|
script = plugin / "skills" / "demo" / "scripts" / "t.py"
|
|
_write(script, '"""Usage: python3 t.py"""\n')
|
|
_check(
|
|
ran,
|
|
f"legacy python invocation: {label}",
|
|
any("uv run" in e for e in _errors_for(root)),
|
|
)
|
|
script.unlink()
|
|
continue
|
|
_write(doc, body)
|
|
_check(
|
|
ran,
|
|
f"legacy python invocation: {label}",
|
|
any("uv add" in e or "uv run" in e or "uv tool" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
for label, body in (
|
|
("uv run --no-project", "```bash\nuv run --no-project tools/x.py\n```\n"),
|
|
(
|
|
"uv run --no-project python",
|
|
"```bash\nuv run --no-project python infra/helper.py b\n```\n",
|
|
),
|
|
("python3 -c", "```bash\npython3 -c 'print(1)'\n```\n"),
|
|
("python3 -m with a module", "```bash\npython3 -m json.tool data.json\n```\n"),
|
|
("uv pip with --directory", "```bash\nuv pip install --directory /tmp requests\n```\n"),
|
|
("uv pip with short -t", "```bash\nuv pip install -t /tmp requests\n```\n"),
|
|
(
|
|
"uv pip with the flag after the package",
|
|
"```bash\nuv pip install foo -t /tmp\n```\n",
|
|
),
|
|
("prohibition before the command", "Do NOT use `pip freeze` here.\n"),
|
|
("prose forbidding the command", "Do NOT run `pip install` here.\n"),
|
|
("dockerfile fence", "```dockerfile\nRUN pip install requests\n```\n"),
|
|
(
|
|
"allow-marker",
|
|
"```bash\n# allow-legacy-python: in a container\npip install requests\n```\n",
|
|
),
|
|
):
|
|
_write(doc, body)
|
|
_check(ran, f"legal python invocation accepted: {label}", not _errors_for(root))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
skill = plugin / "skills" / "demo" / "SKILL.md"
|
|
_write(skill, "---\nname: demo\ndescription: Use when: parsing files\n---\n\nBody\n")
|
|
_check(
|
|
ran,
|
|
"unquoted description with a colon",
|
|
any("does not parse" in e for e in _errors_for(root)),
|
|
)
|
|
_write(skill, "---\nname: demo\ndescription: Handles a # sign\n---\n\nBody\n")
|
|
_check(
|
|
ran,
|
|
"unquoted description with a comment marker",
|
|
any("does not parse" in e for e in _errors_for(root)),
|
|
)
|
|
_write(skill, '---\nname: demo\ndescription: "Use when: parsing files"\n---\n\nBody\n')
|
|
_check(ran, "quoted description with a colon 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_readme_components(ran: list[str]) -> None:
|
|
"""The README-names-its-components checker fires, and does not over-fire."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
_build_demo(root)
|
|
_check(
|
|
ran,
|
|
"README naming its skill is accepted",
|
|
not any("does not name the skill" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "README.md", "# A plugin\n\nIt reviews things.\n")
|
|
_check(
|
|
ran,
|
|
"skill absent from README",
|
|
any("does not name the skill 'demo'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(
|
|
plugin / "agents" / "helper.md",
|
|
"---\nname: helper\ndescription: x\ntools:\n - Read\n---\n",
|
|
)
|
|
_write(plugin / "README.md", "# demo\n\nIt does things.\n")
|
|
_check(
|
|
ran,
|
|
"agent absent from README",
|
|
any("does not name the agent 'helper'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "commands" / "run-it.md", "---\ndescription: x\n---\n\n# Run\n")
|
|
_write(plugin / "README.md", "# demo\n\nIt does things.\n")
|
|
_check(
|
|
ran,
|
|
"command absent from README",
|
|
any("does not name the command /demo:run-it" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "commands" / "go.md", "---\ndescription: x\nallowed-tools: Read\n---\n")
|
|
_write(plugin / "README.md", "# demo\n\nRun `scripts/go.sh` to go.\n")
|
|
_check(
|
|
ran,
|
|
"command satisfied only by a same-named script path",
|
|
any("does not name the command /demo:go" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "commands" / "go.md", "---\ndescription: x\nallowed-tools: Read\n---\n")
|
|
_write(plugin / "README.md", "# demo\n\nInvoke `/demo:go` to go.\n")
|
|
_check(
|
|
ran,
|
|
"command named by its slash form is accepted",
|
|
not any("does not name the command" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
# A bare word in a sentence is not a dispatchable identifier. This is the shape that
|
|
# let let-fate-decide's `draw` agent pass on "(draw cards instead)".
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "agents" / "w.md", "---\nname: w\ndescription: x\ntools:\n - Read\n---\n")
|
|
_write(plugin / "README.md", "# demo\n\nIt goes w places, w times over.\n")
|
|
_check(
|
|
ran,
|
|
"agent satisfied only by prose",
|
|
any("does not name the agent 'w'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "agents" / "w.md", "---\nname: w\ndescription: x\ntools:\n - Read\n---\n")
|
|
_write(plugin / "README.md", "# demo\n\nDispatch `w` for that.\n")
|
|
_check(
|
|
ran,
|
|
"agent named in identifier form is accepted",
|
|
not any("does not name the agent" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
# A skill name that is only ever a prefix of a longer identifier is not named.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root, name="demo")
|
|
(plugin / "skills" / "demo").rename(plugin / "skills" / "demo-thing")
|
|
_write(plugin / "README.md", "# demo\n\nSee the demo-thingamajig docs.\n")
|
|
_check(
|
|
ran,
|
|
"skill satisfied only as a prefix of a longer word",
|
|
any("does not name the skill 'demo-thing'" in e for e in _errors_for(root)),
|
|
)
|
|
|
|
# The case a filename glob cannot see: a workflow ships under meta.name, so a README
|
|
# citing only the filename leaves the invocable name unwritten anywhere. Both real
|
|
# instances in this repo (git-cleanup, static-analysis) were exactly this shape.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(
|
|
plugin / "workflows" / "analyze.js",
|
|
"export const meta = {\n name: 'demo-analysis',\n description: 'x',\n}\n",
|
|
)
|
|
_write(plugin / "README.md", "# demo\n\nSee `workflows/analyze.js`.\n")
|
|
errors = _errors_for(root)
|
|
_check(
|
|
ran,
|
|
"workflow named only by filename",
|
|
any("does not name the workflow /demo:demo-analysis" in e for e in errors),
|
|
)
|
|
_write(plugin / "README.md", "# demo\n\nShips as `/demo:demo-analysis`.\n")
|
|
_check(
|
|
ran,
|
|
"workflow named by meta.name is accepted",
|
|
not any("does not name the workflow" 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() + "\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)),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(plugin / "workflows" / "helper.js", "// no meta block here\nexport const x = 1\n")
|
|
names = workflow_names(plugin)
|
|
_check(ran, "helper .js without a meta block is not a workflow", names == [])
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_write(
|
|
plugin / "workflows" / "w.js",
|
|
"// name: 'from-a-comment'\nexport const meta = {\n name: 'real-name',\n}\n",
|
|
)
|
|
_check(
|
|
ran,
|
|
"workflow name read from meta, not an earlier comment",
|
|
workflow_names(plugin) == [("w.js", "real-name")],
|
|
)
|
|
|
|
# The per-kind floor: losing one discovery helper must go red, not hide in the total.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
plugin = _build_demo(root)
|
|
_, _, by_kind = validate_readme_names_components(plugin)
|
|
_check(ran, "component scan reports a per-kind breakdown", by_kind.get("skill") == 1)
|
|
|
|
|
|
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 dependabot lockfile rule, including the case where the checker itself
|
|
# parses nothing — the failure mode the rule exists to prevent, one level up.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
cfg = root / ".github" / "dependabot.yml"
|
|
body = (
|
|
"version: 2\nupdates:\n - package-ecosystem: uv\n directories:\n"
|
|
" - /plugins/locked\n - /plugins/unlocked\n"
|
|
" schedule:\n interval: weekly\n"
|
|
" - package-ecosystem: github-actions\n directory: /\n"
|
|
" schedule:\n interval: weekly\n"
|
|
)
|
|
_write(cfg, body)
|
|
_write(root / "plugins" / "locked" / "uv.lock", "version = 1\n")
|
|
(root / "plugins" / "unlocked").mkdir(parents=True)
|
|
errs = check_dependabot_lockfiles(root)
|
|
_check(ran, "missing uv.lock is reported", any("unlocked" in e for e in errs))
|
|
_check(ran, "present uv.lock is accepted", not any("/plugins/locked" in e for e in errs))
|
|
|
|
_write(cfg, body.replace(" - /plugins/locked\n - /plugins/unlocked\n", ""))
|
|
_check(
|
|
ran,
|
|
"uv block parsing nothing is an error",
|
|
any("inspected nothing" in e for e in check_dependabot_lockfiles(root)),
|
|
)
|
|
|
|
# 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_readme_components(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())
|