Files
2026-07-13 12:36:35 +08:00

297 lines
14 KiB
Python

"""Real-source structural tests — run against the actual `plugins/` tree.
These complement the synthetic-fixture adapter tests (`test_adapters.py`) by catching
issues that only surface on real content: malformed frontmatter in a checked-in skill,
broken `@{path}` injections, stale marketplace entries, etc.
Designed to run in CI without API keys.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import pytest
# Make tools.* importable when pytest runs from anywhere.
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from tools.adapters.base import ( # noqa: E402
PLUGINS_DIR,
WORKTREE,
list_plugins,
load_plugin,
parse_frontmatter,
)
# Real source skips marketplace orphans (qa-orchestra is external git-subdir).
_EXTERNAL_PLUGINS: set[str] = {"qa-orchestra"}
# Trigger phrasing matcher — same regex plugin_eval/layers/static.py uses for MISSING_TRIGGER.
_TRIGGER_PATTERN = re.compile(
r"\b(?:should\s+be\s+)?used?\s+(?:this\s+skill\s+)?(?:immediately\s+)?"
r"(?:when|after|before|whenever)\b"
r"|\buse\s+proactively\b"
r"|\btrigger(?:s)?\s+(?:when|on)\b"
r"|\bauto[-\s]?loads?\s+(?:when|on)\b",
re.IGNORECASE,
)
# Codex built-in agent names that custom agents must NOT collide with.
_CODEX_BUILTIN_AGENTS = {"default", "worker", "explorer"}
# Multi-agent command workflows use phase placeholders such as
# `{phase3.agent-name.output}`. The task key must match the producing
# `subagent_type`, otherwise later phases cannot read earlier outputs.
_PHASE_HEADING_PATTERN = re.compile(r"^##\s+Phase\s+(\d+)\b")
_TASK_SUBAGENT_PATTERN = re.compile(r'^\s*subagent_type:\s*"?([^"\n]+?)"?\s*$')
_PHASE_OUTPUT_REF_PATTERN = re.compile(r"\{phase(?P<phase>\d+)\.(?P<task>[^{}.]+)\.output\}")
# ── Marketplace consistency ─────────────────────────────────────────────────
class TestMarketplaceConsistency:
def test_every_local_marketplace_entry_resolves_to_plugin_dir(self):
mp = json.loads((WORKTREE / ".claude-plugin" / "marketplace.json").read_text())
for entry in mp.get("plugins", []):
source = entry.get("source")
if isinstance(source, str) and source.startswith("./plugins/"):
plugin_path = WORKTREE / source.removeprefix("./")
assert plugin_path.is_dir(), f"{entry['name']}: source {source} does not exist"
assert (plugin_path / ".claude-plugin" / "plugin.json").is_file(), (
f"{entry['name']}: missing .claude-plugin/plugin.json"
)
def test_every_local_plugin_dir_appears_in_marketplace(self):
mp = json.loads((WORKTREE / ".claude-plugin" / "marketplace.json").read_text())
listed = {e["name"] for e in mp.get("plugins", []) if isinstance(e.get("source"), str)}
actual = set(list_plugins())
missing = actual - listed
assert not missing, f"plugins/ dirs not in marketplace.json: {sorted(missing)}"
def test_marketplace_version_matches_per_plugin_json(self):
"""Each marketplace.json entry's version must match plugins/<name>/.claude-plugin/plugin.json."""
mp = json.loads((WORKTREE / ".claude-plugin" / "marketplace.json").read_text())
mismatches = []
for entry in mp.get("plugins", []):
source = entry.get("source")
if not (isinstance(source, str) and source.startswith("./plugins/")):
continue
name = entry["name"]
mp_version = entry.get("version", "")
pj_path = WORKTREE / source.removeprefix("./") / ".claude-plugin" / "plugin.json"
if not pj_path.is_file():
continue
pj_version = json.loads(pj_path.read_text()).get("version", "")
if mp_version != pj_version:
mismatches.append(f"{name}: marketplace={mp_version} vs plugin.json={pj_version}")
assert not mismatches, (
"Version drift between marketplace.json and per-plugin plugin.json:\n "
+ "\n ".join(mismatches)
)
# ── Plugin source integrity ─────────────────────────────────────────────────
class TestPluginSourceIntegrity:
@pytest.mark.parametrize("plugin_name", list_plugins())
def test_plugin_loads_without_error(self, plugin_name: str):
plugin = load_plugin(plugin_name)
assert plugin is not None, f"{plugin_name}: load_plugin returned None"
def test_no_plugin_name_contains_double_underscore(self):
"""Plugin names with __ collide with the adapter namespace separator."""
bad = [p for p in list_plugins() if "__" in p]
assert not bad, f"plugin names with `__`: {bad}"
def test_every_agent_has_name_and_description(self):
problems = []
for plugin_name in list_plugins():
plugin = load_plugin(plugin_name)
if not plugin:
continue
for agent in plugin.agents:
if not agent.frontmatter.get("name"):
problems.append(f"{plugin_name}/agents/{agent.name}: missing name")
if not agent.description:
problems.append(f"{plugin_name}/agents/{agent.name}: missing description")
assert not problems, "Agents missing required frontmatter:\n " + "\n ".join(problems)
def test_every_skill_has_trigger_phrase(self):
"""Per plugin-eval's MISSING_TRIGGER check: every description must include a recognized phrase."""
problems = []
for plugin_name in list_plugins():
plugin = load_plugin(plugin_name)
if not plugin:
continue
for skill in plugin.skills:
if not _TRIGGER_PATTERN.search(skill.description):
problems.append(
f"{plugin_name}/skills/{skill.name}: description has no trigger phrase "
f"(saw: {skill.description[:80]!r})"
)
assert not problems, "Skills with missing trigger phrases:\n " + "\n ".join(problems[:20])
def test_no_agent_collides_with_codex_builtin(self):
"""Codex has built-in agents `default`, `worker`, `explorer`. Custom agents must not use these names."""
collisions = []
for plugin_name in list_plugins():
plugin = load_plugin(plugin_name)
if not plugin:
continue
for agent in plugin.agents:
if agent.name.lower() in _CODEX_BUILTIN_AGENTS:
collisions.append(f"{plugin_name}/agents/{agent.name}")
assert not collisions, (
f"Agent names colliding with Codex built-ins (default/worker/explorer): {collisions}"
)
def test_agent_frontmatter_names_are_unique_across_plugins(self):
"""Claude Code keys subagents by frontmatter `name`; cross-plugin duplicates collide."""
by_name: dict[str, list[str]] = {}
for plugin_name in list_plugins():
plugin = load_plugin(plugin_name)
if not plugin:
continue
for agent in plugin.agents:
name = (agent.frontmatter.get("name") or "").strip()
if name:
by_name.setdefault(name, []).append(f"{plugin_name}/agents/{agent.name}.md")
duplicates = {name: paths for name, paths in sorted(by_name.items()) if len(paths) > 1}
assert not duplicates, "Duplicate agent frontmatter names:\n " + "\n ".join(
f"{name}: {', '.join(paths)}" for name, paths in duplicates.items()
)
def test_command_phase_output_references_match_task_keys(self):
"""Phase output placeholders must match a task key from the referenced phase."""
problems = []
for command_path in sorted(PLUGINS_DIR.glob("*/commands/*.md")):
current_phase: int | None = None
phase_tasks: dict[int, set[str]] = {}
references: list[tuple[int, int, str]] = []
for lineno, line in enumerate(command_path.read_text().splitlines(), 1):
if match := _PHASE_HEADING_PATTERN.match(line):
current_phase = int(match.group(1))
phase_tasks.setdefault(current_phase, set())
if (match := _TASK_SUBAGENT_PATTERN.match(line)) and current_phase is not None:
phase_tasks.setdefault(current_phase, set()).add(match.group(1).strip())
for match in _PHASE_OUTPUT_REF_PATTERN.finditer(line):
references.append((lineno, int(match.group("phase")), match.group("task")))
for lineno, phase, task_key in references:
available = phase_tasks.get(phase, set())
if task_key not in available:
problems.append(
f"{command_path.relative_to(PLUGINS_DIR)}:{lineno}: "
f"phase{phase}.{task_key}.output has no matching subagent_type "
f"in Phase {phase} (available: {sorted(available)})"
)
assert not problems, "Broken command phase output references:\n " + "\n ".join(problems)
# ── Progressive-disclosure refactor integrity ────────────────────────────────
class TestProgressiveDisclosureIntegrity:
"""Catches regressions in the SKILL.md → references/details.md refactor."""
def _modified_skills(self) -> list[tuple[str, Path, Path]]:
"""Yield (plugin, SKILL.md, references/details.md) for every refactored skill."""
out: list[tuple[str, Path, Path]] = []
for skill_md in PLUGINS_DIR.glob("*/skills/*/SKILL.md"):
details = skill_md.parent / "references" / "details.md"
if details.is_file():
plugin_name = skill_md.parent.parent.parent.name
out.append((plugin_name, skill_md, details))
return out
def test_every_refactored_skill_has_meaningful_details(self):
"""Skills with `references/details.md` should have meaningful content there
(≥500 bytes) — not an empty stub."""
too_small = []
for plugin_name, _, details in self._modified_skills():
size = len(details.read_text().encode("utf-8"))
if size < 500:
too_small.append(f"{plugin_name}/{details.relative_to(PLUGINS_DIR)}: {size}B")
assert not too_small, (
"references/details.md files are suspiciously small (<500B), "
"suggesting failed extraction:\n " + "\n ".join(too_small)
)
def test_extracted_skills_have_pointer_to_references(self):
"""When a skill has `references/details.md`, the SKILL.md body should mention
`references/details.md` (or `references/`) so the agent knows where to look."""
missing_pointer = []
for plugin_name, skill_md, _ in self._modified_skills():
body = skill_md.read_text()
if "references/" not in body:
missing_pointer.append(f"{plugin_name}/{skill_md.relative_to(PLUGINS_DIR)}")
assert not missing_pointer, (
"Refactored skills missing pointer to references/:\n " + "\n ".join(missing_pointer)
)
def test_extracted_skills_preserve_when_to_use(self):
"""Every refactored skill should still have a "When to Use" or equivalent
navigation section in the SKILL.md body — losing it strips the model's
ability to decide when to activate."""
# Common headings that serve as the nav tier
nav_markers = [
"## When to Use",
"## Overview",
"## Purpose",
"## Core Concepts",
"## Quick Start",
"## Input",
]
missing = []
for plugin_name, skill_md, _ in self._modified_skills():
body = skill_md.read_text()
if not any(marker in body for marker in nav_markers):
missing.append(f"{plugin_name}/{skill_md.relative_to(PLUGINS_DIR)}")
assert not missing, "Refactored skills missing navigation-tier section:\n " + "\n ".join(
missing
)
def test_no_refactored_skill_is_stub_only(self):
"""A refactored skill's SKILL.md body should be at least ~600 bytes —
anything smaller is a stub that's lost the quick-start tier."""
stubs = []
for plugin_name, skill_md, _ in self._modified_skills():
fm, body = parse_frontmatter(skill_md.read_text())
if len(body.encode("utf-8")) < 600:
stubs.append(
f"{plugin_name}/{skill_md.relative_to(PLUGINS_DIR)}: body {len(body.encode())}B"
)
assert not stubs, (
"Refactored skills with stub-sized bodies (<600B) — quick-start tier likely lost:\n "
+ "\n ".join(stubs)
)
# ── plugin.json integrity ─────────────────────────────────────────────────
class TestPluginJsonIntegrity:
@pytest.mark.parametrize("plugin_name", list_plugins())
def test_plugin_json_has_name_and_version(self, plugin_name: str):
pj_path = PLUGINS_DIR / plugin_name / ".claude-plugin" / "plugin.json"
data = json.loads(pj_path.read_text())
assert data.get("name"), f"{plugin_name}: plugin.json missing 'name'"
assert data.get("version"), f"{plugin_name}: plugin.json missing 'version'"
# plugin.json `name` should match directory name
assert data["name"] == plugin_name, (
f"{plugin_name}: plugin.json name={data['name']!r} doesn't match dir name"
)