fix(validate-plugin): kind-aware content check for code-class plugins #38
@@ -29,10 +29,53 @@ runtimes = plugin.get("runtimes")
|
||||
if runtimes is not None and not isinstance(runtimes, list):
|
||||
errors.append(f"runtimes must be a list, got {type(runtimes).__name__}")
|
||||
|
||||
# 5. Has content
|
||||
content_paths = ["SKILL.md", "hooks", "skills", "rules"]
|
||||
found = [p for p in content_paths if os.path.exists(p)]
|
||||
if not found:
|
||||
# 5. Has content — kind-aware.
|
||||
#
|
||||
# Two plugin content classes exist in the ecosystem:
|
||||
#
|
||||
# * Skill-class plugins (the default, kind unset or one of the skill
|
||||
# kinds below): their content is declarative — SKILL.md / hooks/ /
|
||||
# skills/ / rules/. At least one MUST be present or the plugin is
|
||||
# empty.
|
||||
#
|
||||
# * Code-class plugins (e.g. kind: env-mutator): their content is
|
||||
# compiled source — a Go module (go.mod) wired through a declared
|
||||
# `entrypoint` (e.g. pluginloader.BuildRegistry) — not any of the
|
||||
# skill markers. Requiring SKILL.md/hooks/skills/rules of these is a
|
||||
# false positive (the validator previously red-flagged the
|
||||
# legitimate molecule-gh-identity env-mutator plugin, which has no
|
||||
# skill markers by design). For these, the Go content + entrypoint
|
||||
# IS the content.
|
||||
#
|
||||
# We recognize a code-class plugin by an explicit `kind` that is not a
|
||||
# skill kind. Such a plugin satisfies the content requirement when it
|
||||
# ships compiled-source content (a go.mod) and declares an entrypoint —
|
||||
# both required so an empty repo can't escape the check just by setting
|
||||
# `kind:`.
|
||||
SKILL_KINDS = {"", "skill", "agent-skill", "claude-skill"}
|
||||
SKILL_CONTENT_PATHS = ["SKILL.md", "hooks", "skills", "rules"]
|
||||
|
||||
kind = str(plugin.get("kind", "") or "").strip().lower()
|
||||
found = [p for p in SKILL_CONTENT_PATHS if os.path.exists(p)]
|
||||
|
||||
if found:
|
||||
# Skill-class content present — always accepted (any plugin may ship it).
|
||||
pass
|
||||
elif kind not in SKILL_KINDS:
|
||||
# Code-class plugin (e.g. env-mutator). Content = Go module + entrypoint.
|
||||
has_go = os.path.isfile("go.mod")
|
||||
has_entrypoint = bool(str(plugin.get("entrypoint", "") or "").strip())
|
||||
if not has_go or not has_entrypoint:
|
||||
missing = []
|
||||
if not has_go:
|
||||
missing.append("go.mod")
|
||||
if not has_entrypoint:
|
||||
missing.append("entrypoint")
|
||||
errors.append(
|
||||
f"Code-class plugin (kind: {kind}) must ship its content as "
|
||||
f"go.mod + an entrypoint; missing: {', '.join(missing)}"
|
||||
)
|
||||
else:
|
||||
errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/")
|
||||
|
||||
# 6. SKILL.md formatting check
|
||||
@@ -50,5 +93,7 @@ if errors:
|
||||
print(f"✓ plugin.yaml valid: {plugin['name']} v{plugin['version']}")
|
||||
if found:
|
||||
print(f" Content: {', '.join(found)}")
|
||||
elif kind not in SKILL_KINDS:
|
||||
print(f" Content: go.mod + entrypoint ({plugin.get('entrypoint')}) [kind: {kind}]")
|
||||
if runtimes:
|
||||
print(f" Runtimes: {', '.join(runtimes)}")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for validate-plugin.py — pin the plugin base-contract gate.
|
||||
|
||||
validate-plugin.py runs all its checks at module top level and calls
|
||||
sys.exit(), so (unlike the import-safe workspace/org validators) it is
|
||||
exercised as a subprocess against a materialised plugin dir — which
|
||||
also tests the exact entrypoint CI invokes (`python3 validate-plugin.py`
|
||||
with cwd = the plugin repo root).
|
||||
|
||||
Contract pinned here, with the kind-aware content check (RFC internal#476
|
||||
P1 — recognise code-class plugins like kind: env-mutator whose content is
|
||||
go.mod + entrypoint, not SKILL.md/hooks/skills/rules). Regression guard
|
||||
for the false positive that red-flagged molecule-gh-identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
VALIDATOR_PATH = Path(__file__).resolve().parent / "validate-plugin.py"
|
||||
|
||||
|
||||
def _run(plugin_dir: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(VALIDATOR_PATH)],
|
||||
cwd=plugin_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _write_plugin_yaml(plugin_dir: Path, data: dict) -> None:
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(data))
|
||||
|
||||
|
||||
def _base_manifest(**overrides) -> dict:
|
||||
data = {
|
||||
"name": "test-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "a test plugin",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
# --- skill-class plugins -------------------------------------------------
|
||||
|
||||
def test_skill_plugin_with_skill_md_passes(tmp_path):
|
||||
_write_plugin_yaml(tmp_path, _base_manifest())
|
||||
(tmp_path / "SKILL.md").write_text("# Test Plugin\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
|
||||
|
||||
def test_skill_plugin_with_skills_dir_passes(tmp_path):
|
||||
_write_plugin_yaml(tmp_path, _base_manifest())
|
||||
(tmp_path / "skills").mkdir()
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
|
||||
|
||||
def test_skill_plugin_with_no_content_fails(tmp_path):
|
||||
_write_plugin_yaml(tmp_path, _base_manifest())
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "at least one of: SKILL.md" in r.stdout
|
||||
|
||||
|
||||
# --- code-class plugins (kind: env-mutator) ------------------------------
|
||||
|
||||
def test_env_mutator_with_go_and_entrypoint_passes(tmp_path):
|
||||
"""The molecule-gh-identity shape: a Go env-mutator with no skill
|
||||
markers must validate via go.mod + entrypoint, not be red-flagged."""
|
||||
_write_plugin_yaml(
|
||||
tmp_path,
|
||||
_base_manifest(kind="env-mutator", entrypoint="pluginloader.BuildRegistry"),
|
||||
)
|
||||
(tmp_path / "go.mod").write_text("module example.com/test\n\ngo 1.25\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
|
||||
|
||||
def test_env_mutator_missing_go_mod_fails(tmp_path):
|
||||
"""`kind:` alone must not let an empty repo pass — code content
|
||||
(go.mod) is still required."""
|
||||
_write_plugin_yaml(
|
||||
tmp_path,
|
||||
_base_manifest(kind="env-mutator", entrypoint="pluginloader.BuildRegistry"),
|
||||
)
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "go.mod" in r.stdout
|
||||
|
||||
|
||||
def test_env_mutator_missing_entrypoint_fails(tmp_path):
|
||||
_write_plugin_yaml(tmp_path, _base_manifest(kind="env-mutator"))
|
||||
(tmp_path / "go.mod").write_text("module example.com/test\n\ngo 1.25\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "entrypoint" in r.stdout
|
||||
|
||||
|
||||
def test_env_mutator_with_skill_md_also_passes(tmp_path):
|
||||
"""A code-class plugin that also ships a SKILL.md is fine."""
|
||||
_write_plugin_yaml(tmp_path, _base_manifest(kind="env-mutator"))
|
||||
(tmp_path / "SKILL.md").write_text("# Test\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
|
||||
|
||||
# --- required-field / shape checks (unchanged contract) ------------------
|
||||
|
||||
def test_missing_plugin_yaml_fails(tmp_path):
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "plugin.yaml not found" in r.stdout
|
||||
|
||||
|
||||
def test_missing_required_field_fails(tmp_path):
|
||||
data = _base_manifest()
|
||||
del data["description"]
|
||||
data["kind"] = "env-mutator"
|
||||
data["entrypoint"] = "x"
|
||||
_write_plugin_yaml(tmp_path, data)
|
||||
(tmp_path / "go.mod").write_text("module x\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "Missing required field: description" in r.stdout
|
||||
|
||||
|
||||
def test_invalid_version_fails(tmp_path):
|
||||
_write_plugin_yaml(
|
||||
tmp_path, _base_manifest(version="1.0.0-beta", kind="env-mutator", entrypoint="x")
|
||||
)
|
||||
(tmp_path / "go.mod").write_text("module x\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "Invalid version format" in r.stdout
|
||||
|
||||
|
||||
def test_runtimes_must_be_list(tmp_path):
|
||||
_write_plugin_yaml(
|
||||
tmp_path,
|
||||
_base_manifest(kind="env-mutator", entrypoint="x", runtimes="claude_code"),
|
||||
)
|
||||
(tmp_path / "go.mod").write_text("module x\n")
|
||||
r = _run(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "runtimes must be a list" in r.stdout
|
||||
@@ -29,10 +29,53 @@ runtimes = plugin.get("runtimes")
|
||||
if runtimes is not None and not isinstance(runtimes, list):
|
||||
errors.append(f"runtimes must be a list, got {type(runtimes).__name__}")
|
||||
|
||||
# 5. Has content
|
||||
content_paths = ["SKILL.md", "hooks", "skills", "rules"]
|
||||
found = [p for p in content_paths if os.path.exists(p)]
|
||||
if not found:
|
||||
# 5. Has content — kind-aware.
|
||||
#
|
||||
# Two plugin content classes exist in the ecosystem:
|
||||
#
|
||||
# * Skill-class plugins (the default, kind unset or one of the skill
|
||||
# kinds below): their content is declarative — SKILL.md / hooks/ /
|
||||
# skills/ / rules/. At least one MUST be present or the plugin is
|
||||
# empty.
|
||||
#
|
||||
# * Code-class plugins (e.g. kind: env-mutator): their content is
|
||||
# compiled source — a Go module (go.mod) wired through a declared
|
||||
# `entrypoint` (e.g. pluginloader.BuildRegistry) — not any of the
|
||||
# skill markers. Requiring SKILL.md/hooks/skills/rules of these is a
|
||||
# false positive (the validator previously red-flagged the
|
||||
# legitimate molecule-gh-identity env-mutator plugin, which has no
|
||||
# skill markers by design). For these, the Go content + entrypoint
|
||||
# IS the content.
|
||||
#
|
||||
# We recognize a code-class plugin by an explicit `kind` that is not a
|
||||
# skill kind. Such a plugin satisfies the content requirement when it
|
||||
# ships compiled-source content (a go.mod) and declares an entrypoint —
|
||||
# both required so an empty repo can't escape the check just by setting
|
||||
# `kind:`.
|
||||
SKILL_KINDS = {"", "skill", "agent-skill", "claude-skill"}
|
||||
SKILL_CONTENT_PATHS = ["SKILL.md", "hooks", "skills", "rules"]
|
||||
|
||||
kind = str(plugin.get("kind", "") or "").strip().lower()
|
||||
found = [p for p in SKILL_CONTENT_PATHS if os.path.exists(p)]
|
||||
|
||||
if found:
|
||||
# Skill-class content present — always accepted (any plugin may ship it).
|
||||
pass
|
||||
elif kind not in SKILL_KINDS:
|
||||
# Code-class plugin (e.g. env-mutator). Content = Go module + entrypoint.
|
||||
has_go = os.path.isfile("go.mod")
|
||||
has_entrypoint = bool(str(plugin.get("entrypoint", "") or "").strip())
|
||||
if not has_go or not has_entrypoint:
|
||||
missing = []
|
||||
if not has_go:
|
||||
missing.append("go.mod")
|
||||
if not has_entrypoint:
|
||||
missing.append("entrypoint")
|
||||
errors.append(
|
||||
f"Code-class plugin (kind: {kind}) must ship its content as "
|
||||
f"go.mod + an entrypoint; missing: {', '.join(missing)}"
|
||||
)
|
||||
else:
|
||||
errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/")
|
||||
|
||||
# 6. SKILL.md formatting check
|
||||
@@ -50,5 +93,7 @@ if errors:
|
||||
print(f"✓ plugin.yaml valid: {plugin['name']} v{plugin['version']}")
|
||||
if found:
|
||||
print(f" Content: {', '.join(found)}")
|
||||
elif kind not in SKILL_KINDS:
|
||||
print(f" Content: go.mod + entrypoint ({plugin.get('entrypoint')}) [kind: {kind}]")
|
||||
if runtimes:
|
||||
print(f" Runtimes: {', '.join(runtimes)}")
|
||||
|
||||
Reference in New Issue
Block a user