fix(validate-plugin): kind-aware content check for code-class plugins #38

Merged
core-devops merged 3 commits from fix/validate-plugin-kind-aware-content into main 2026-06-22 05:58:22 +00:00
3 changed files with 252 additions and 8 deletions
+49 -4
View File
@@ -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)}")
+154
View File
@@ -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
+49 -4
View File
@@ -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)}")