← Files AkinatorARCHIVED FILE

tests/test_plugin_structure.py

22.3 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

"""Structural tests: the plugin satisfies the platform contracts it claims.

These verify the contracts documented in `docs/compatibility.md`. When a
platform contract moves, one of these fails - which is the point. A silent
divergence between what the plugin ships and what the platform reads is the
failure this file exists to prevent.
"""

from __future__ import annotations

import json
import re
import struct
from pathlib import Path

import pytest

from conftest import frontmatter

KEBAB = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")

# The twelve loop stations, and the station reference each one routes to
# inside the one skill. Station 2 (RESOLVE) and station 5 (IMPLEMENT) are
# carried by the creed reference, `akinator`.
LOOP_STATIONS = {
    "ASK": "akinator-intake",
    "RESOLVE": "akinator",
    "AUDIT": "akinator-audit",
    "PLAN": "akinator-plan",
    "IMPLEMENT": "akinator",
    "DOCUMENT": "akinator-document-change",
    "SKILLIFY": "akinator-skillify",
    "RULE": "akinator-rule-forge",
    "CONTEXTIFY": "akinator-contextify",
    "MEMOIZE": "akinator-memoize",
    "INDEX+SYNC": "akinator-index-sync",
    "VERIFY": "akinator-gate-economy",
}

REQUIRED_SKILL_SECTIONS = (
    "when to use",
    "when not to use",
    "procedure",
    "definition of done",
)


# --------------------------------------------------------------------------
# Claude Code plugin contract
# --------------------------------------------------------------------------

def test_plugin_manifest_is_valid(plugin_manifest: dict) -> None:
    assert plugin_manifest["name"] == "akinator"
    assert KEBAB.match(plugin_manifest["name"])
    assert re.match(r"^\d+\.\d+\.\d+$", plugin_manifest["version"])
    assert plugin_manifest["description"].strip()
    assert plugin_manifest["author"]["name"].strip()


def test_manifest_lives_in_claude_plugin_dir(repo: Path) -> None:
    """The manifest must be in .claude-plugin/, components at plugin root."""
    assert (repo / ".claude-plugin" / "plugin.json").is_file()
    # No commands/: the one skill is the one command. See
    # test_there_are_no_command_files.
    for component in ("skills", "agents", "hooks"):
        assert (repo / component).is_dir(), f"{component}/ must be at plugin root"
        assert not (repo / ".claude-plugin" / component).exists(), (
            f"{component}/ must not be nested inside .claude-plugin/"
        )


def test_marketplace_manifest_is_valid(marketplace_manifest: dict) -> None:
    assert marketplace_manifest["name"]
    assert marketplace_manifest["owner"]["name"]
    plugins = marketplace_manifest["plugins"]
    assert len(plugins) == 1
    assert plugins[0]["name"] == "akinator"
    assert plugins[0]["source"] == "./"


def test_marketplace_version_matches_plugin_version(
    marketplace_manifest: dict, plugin_manifest: dict
) -> None:
    assert marketplace_manifest["plugins"][0]["version"] == plugin_manifest["version"]


def test_hooks_json_uses_the_plugin_format(repo: Path) -> None:
    """Plugin hooks.json wraps events in a `hooks` key; settings format does not."""
    data = json.loads((repo / "hooks" / "hooks.json").read_text("utf-8"))
    assert "hooks" in data, "plugin hooks.json requires the `hooks` wrapper"
    assert "SessionStart" in data["hooks"]

    hook = data["hooks"]["SessionStart"][0]["hooks"][0]
    # Exec form - `command` plus `args` - not shell form. The shell form
    # `sh "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"` exits 126 on Claude
    # Code 2.1.154 under Git Bash ("cannot execute binary file"), so the
    # always-on contract silently never reached CLI sessions on Windows.
    assert hook.get("command") == "sh", "the hook runs sh directly (exec form)"
    args = hook.get("args") or []
    assert args and "${CLAUDE_PLUGIN_ROOT}" in args[0], (
        "the script path must be an exec-form argument built on ${CLAUDE_PLUGIN_ROOT}"
    )
    assert "${CLAUDE_PLUGIN_ROOT}" not in hook["command"], (
        "a placeholder inside a shell-form command string is the form that broke"
    )

    script = repo / "hooks" / "session-start.sh"
    assert script.is_file(), "the hook command must point at a script that exists"


def test_no_hardcoded_paths_in_hooks(repo: Path) -> None:
    text = (repo / "hooks" / "hooks.json").read_text("utf-8")
    for forbidden in ("/Users/", "/home/", "C:\\", "~/"):
        assert forbidden not in text


# --------------------------------------------------------------------------
# One skill, one command
#
# The owner's requirement, stated three times: exactly one Akinator entry in the
# "/" menu of Claude Code, Codex and Cursor. It was broken while commands/ held
# one file, because Claude lists every skill in "/" too - 21 skills plus the
# command made 22 entries. Codex cannot hide a skill from its "$" picker at all,
# and Cursor lists every folder in .agents/skills. So the only design that gives
# one entry everywhere is one skill: the stations are reference files inside it.
# See docs/adr/0009-one-skill-one-command-one-installer.md.
# --------------------------------------------------------------------------

SKILL_DIR = Path("skills") / "everything"

# Codex injects an explicitly invoked skill's SKILL.md truncated at this many
# bytes (codex-rs ext/skills render.rs MAX_SKILL_PROMPT_BYTES). A bigger file
# is cut off mid-procedure on `$akinator`.
CODEX_SKILL_PROMPT_BYTES = 8000


def test_there_is_exactly_one_skill(repo: Path, skill_paths: list[Path]) -> None:
    rel = [p.relative_to(repo).as_posix() for p in skill_paths]
    assert rel == ["skills/everything/SKILL.md"], (
        f"Akinator ships exactly one skill; found {rel}. Every SKILL.md is an "
        "entry in the / menu on Claude Code and in the $ picker on Codex."
    )
    assert frontmatter(skill_paths[0]).get("name") == "everything", (
        "the skill must be named 'everything' - the plugin namespace supplies "
        "'akinator', making it /akinator:everything"
    )


def test_there_are_no_command_files(repo: Path) -> None:
    """The skill IS the command. A commands/ file named `everything` would be a
    second /akinator:everything entry; any other name would be a second command."""
    commands = repo / "commands"
    assert not (commands.is_dir() and any(commands.glob("*.md"))), (
        "commands/ must not exist - the one skill is the one command"
    )


def test_the_one_skill_has_six_parts(skill_paths: list[Path]) -> None:
    """Enforcement for rules/02 - the plugin must not ship a skill it would
    reject in a target repository."""
    failures: list[str] = []
    for path in skill_paths:
        meta = frontmatter(path)
        text = path.read_text(encoding="utf-8").lower()
        if not meta.get("name") or not KEBAB.match(meta["name"]):
            failures.append(f"{path}: name missing or not kebab-case")
        elif meta["name"] != path.parent.name:
            failures.append(f"{path}: name != directory '{path.parent.name}'")
        description = meta.get("description", "")
        if not description.lower().startswith("use "):
            failures.append(f"{path}: description must be a 'Use ...' trigger")
        if len(description) > 1024:
            failures.append(f"{path}: description is {len(description)} chars; "
                            "Codex and the Agent Skills spec cap it at 1024")
        for section in REQUIRED_SKILL_SECTIONS:
            if section not in text:
                failures.append(f"{path}: missing the '{section}' section")
        if "failure mode" not in text:
            failures.append(f"{path}: missing a failure-modes section")
    assert not failures, "\n".join(failures)


def test_the_skill_fits_codex_explicit_invocation(repo: Path) -> None:
    """Codex truncates an explicitly invoked SKILL.md at 8,000 bytes. The full
    procedure lives in a reference for exactly this reason."""
    import build_codex_pack as pack

    for label, text in (
        ("canonical", (repo / SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")),
        ("projected", pack.plan(repo)[f"{pack.TARGET}/SKILL.md"]),
    ):
        size = len(text.encode("utf-8"))
        assert size <= CODEX_SKILL_PROMPT_BYTES, (
            f"the {label} SKILL.md is {size} bytes; Codex would cut it at "
            f"{CODEX_SKILL_PROMPT_BYTES}. Move detail into references/."
        )


@pytest.mark.parametrize("station,station_id", sorted(LOOP_STATIONS.items()))
def test_every_loop_station_has_a_reference(
    repo: Path, station: str, station_id: str
) -> None:
    """Enforcement for rules/01: the knowledge delta always has somewhere to go,
    and the skill can reach it."""
    reference = repo / SKILL_DIR / "references" / f"{station_id}.md"
    assert reference.is_file(), f"station {station} -> {station_id} has no reference"
    skill = (repo / SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
    assert f"(references/{station_id}.md)" in skill, (
        f"the skill never links {station_id}; an unlinked station is never opened"
    )


def test_every_reference_is_reachable_from_the_skill(repo: Path) -> None:
    """A reference nothing links to is a station nobody will ever open."""
    base = repo / SKILL_DIR
    linked = (base / "SKILL.md").read_text(encoding="utf-8")
    missing = sorted(p.name for p in (base / "references").glob("*.md")
                     if f"(references/{p.name})" not in linked)
    assert not missing, f"references not linked from SKILL.md: {missing}"


def test_no_reference_can_be_discovered_as_a_skill(repo: Path) -> None:
    """A reference with skill frontmatter, or a stray SKILL.md below the skill,
    is one step from becoming a second / entry again."""
    base = repo / SKILL_DIR
    nested = sorted(p.relative_to(base).as_posix() for p in base.rglob("SKILL.md")
                    if p != base / "SKILL.md")
    assert not nested, f"SKILL.md files inside the one skill: {nested}"
    fronted = sorted(p.name for p in (base / "references").glob("*.md")
                     if p.read_text(encoding="utf-8").startswith("---"))
    assert not fronted, f"references carrying skill frontmatter: {fronted}"


def test_the_skill_runs_its_tools_from_its_own_folder(repo: Path) -> None:
    """Every tool the skill tells an agent to run must travel with it.

    The pre-consolidation skill said `python scripts/akinator_ledger.py ...` -
    a path that exists only in Akinator's own checkout, so in every repository
    Akinator was installed into, the procedure's commands failed.
    """
    base = repo / SKILL_DIR
    texts = [base / "SKILL.md", *sorted((base / "references").glob("*.md"))]
    bad: list[str] = []
    used: set[str] = set()
    for path in texts:
        text = path.read_text(encoding="utf-8")
        for match in re.finditer(r"python3? (\S+\.py)", text):
            command = match.group(1)
            if not command.startswith("<skill>/scripts/"):
                bad.append(f"{path.name}: python {command}")
            else:
                used.add(command.split("/")[-1])
    assert not bad, "tools named outside <skill>/scripts/:\n" + "\n".join(bad)
    absent = sorted(t for t in used if not (base / "scripts" / t).is_file())
    assert not absent, f"the skill runs tools it does not ship: {absent}"


# --------------------------------------------------------------------------
# Agents
# --------------------------------------------------------------------------

def test_boardroom_agents_exist(repo: Path) -> None:
    expected = {
        "akinator-business-owner", "akinator-cto", "akinator-product-owner",
        "akinator-ops", "akinator-analyst", "akinator-librarian", "akinator-pm",
    }
    found = {p.stem for p in (repo / "agents").glob("*.md")}
    assert expected <= found, f"missing agents: {sorted(expected - found)}"


def test_every_agent_has_name_and_description(agent_paths: list[Path]) -> None:
    for path in agent_paths:
        meta = frontmatter(path)
        assert meta.get("name") == path.stem, f"{path}: name must match filename"
        assert meta.get("description"), f"{path}: no description"


# --------------------------------------------------------------------------
# Rules
# --------------------------------------------------------------------------

def test_rules_are_numbered_and_unique(rule_paths: list[Path]) -> None:
    numbers = []
    for path in rule_paths:
        match = re.match(r"^(\d{2})-", path.name)
        assert match, f"{path}: rules are numbered NN-name.md"
        numbers.append(match.group(1))
    assert len(numbers) == len(set(numbers)), "duplicate rule numbers"


def test_every_rule_has_the_required_sections(rule_paths: list[Path]) -> None:
    required = ("purpose", "applies to", "mandatory rules", "enforcement")
    failures = []
    for path in rule_paths:
        text = path.read_text(encoding="utf-8").lower()
        for section in required:
            if section not in text:
                failures.append(f"{path}: missing '{section}'")
    assert not failures, "\n".join(failures)


# --------------------------------------------------------------------------
# Routers
# --------------------------------------------------------------------------

ROUTERS = ("CLAUDE.md", "AGENTS.md", "CODEX.md")


def test_routers_exist(repo: Path) -> None:
    for name in ROUTERS:
        assert (repo / name).is_file(), f"{name} is missing"


def test_routers_agree(repo: Path) -> None:
    """Enforcement for rules/04: no router omits knowledge the others carry.

    Compares the knowledge-layer paths each router references. A router carrying
    an intentional-divergence marker is exempt, which is how genuinely
    tool-specific content is distinguished from rot.
    """
    import akinator_coverage as cov

    knowledge = re.compile(r"`((?:rules|skills|context|memory|docs)/[^`]+)`")

    referenced: dict[str, set[str]] = {}
    for name in ROUTERS:
        text = (repo / name).read_text(encoding="utf-8")
        shared, _ = cov.strip_tool_specific(text)
        referenced[name] = {
            t for t in knowledge.findall(shared)
            if "*" not in t and (repo / t).exists()
        }

    union: set[str] = set().union(*referenced.values())
    for name, targets in referenced.items():
        missing = sorted(union - targets)
        assert not missing, f"{name} omits knowledge other routers carry: {missing}"


def test_routers_stay_thin(repo: Path) -> None:
    """A router that has become a document has started to fork from its source."""
    for name in ROUTERS:
        lines = (repo / name).read_text(encoding="utf-8").splitlines()
        assert len(lines) < 200, (
            f"{name} is {len(lines)} lines - routers are indexes, not documents. "
            "Move the content to docs/ and link it."
        )


# --------------------------------------------------------------------------
# Templates
# --------------------------------------------------------------------------

EXPECTED_TEMPLATES = (
    "rule.md", "skill.md", "context-map.md", "memory.md", "adr.md",
    "business-logic.md", "product-feature.md", "ops-runbook.md", "router.md",
    "onboarding-mapping.md", "library-page.md", "requirement.md",
    "business-drift.md",
)


@pytest.mark.parametrize("name", EXPECTED_TEMPLATES)
def test_template_exists_with_a_filled_example(repo: Path, name: str) -> None:
    assert (repo / "templates" / name).is_file(), f"templates/{name} missing"
    assert (repo / "templates" / "examples" / name).is_file(), (
        f"templates/examples/{name} missing - every template ships a filled example"
    )


FENCE = re.compile(r"^[ \t]*(?:```|~~~).*?^[ \t]*(?:```|~~~)[ \t]*$",
                   re.MULTILINE | re.DOTALL)


@pytest.mark.parametrize("name", EXPECTED_TEMPLATES)
def test_filled_examples_have_no_placeholders(repo: Path, name: str) -> None:
    """A filled example with template placeholders left in it is not filled.

    Two exclusions, both deliberate:
      - blockquote lines carry the "this is an example" preamble;
      - fenced code may legitimately contain shell placeholders such as
        <backup-id-from-step-2>, which are part of the illustrated command, not
        an unfilled template section.
    """
    text = (repo / "templates" / "examples" / name).read_text(encoding="utf-8")
    prose = FENCE.sub("\n", text)
    body = "\n".join(
        line for line in prose.splitlines() if not line.lstrip().startswith(">")
    )
    leftovers = re.findall(r"<[a-z][a-z0-9 _/-]{2,}>", body)
    assert not leftovers, f"templates/examples/{name} has placeholders: {leftovers}"


@pytest.mark.parametrize("name", EXPECTED_TEMPLATES)
def test_templates_still_have_placeholders(repo: Path, name: str) -> None:
    """The converse: a template with no placeholders is an example, not a template."""
    text = (repo / "templates" / name).read_text(encoding="utf-8")
    assert re.search(r"<[a-z][a-z0-9 _/-]{2,}>", text), (
        f"templates/{name} has no placeholders - it reads as a filled example"
    )


# --------------------------------------------------------------------------
# Codex contract
# --------------------------------------------------------------------------

def test_codex_manifest_is_valid(codex_manifest: dict) -> None:
    for key in ("name", "version", "description", "author", "interface"):
        assert key in codex_manifest, f"missing required field: {key}"
    assert codex_manifest["author"]["name"]

    interface = codex_manifest["interface"]
    for key in ("displayName", "shortDescription", "longDescription",
                "developerName", "category", "capabilities"):
        assert interface.get(key), f"missing required interface field: {key}"


def test_codex_manifest_rejects_unsupported_fields(codex_manifest: dict) -> None:
    """Codex validation rejects `hooks` in the plugin manifest."""
    assert "hooks" not in codex_manifest


def test_codex_manifest_has_no_placeholders(codex_manifest: dict) -> None:
    assert "TODO" not in json.dumps(codex_manifest)


def test_codex_manifest_urls_are_absolute_https(codex_manifest: dict) -> None:
    for key, value in codex_manifest.get("interface", {}).items():
        if key.endswith("URL"):
            assert value.startswith("https://"), f"{key} must be absolute https"


def test_codex_manifest_assets_exist(repo: Path, codex_manifest: dict) -> None:
    """Asset paths must point at real files inside the plugin."""
    for key in ("composerIcon", "logo", "logoDark"):
        value = codex_manifest.get("interface", {}).get(key)
        if value and not value.startswith("http"):
            assert (repo / value.lstrip("./")).is_file(), (
                f"interface.{key} points at a file that does not exist: {value}"
            )


def test_versions_agree_across_manifests(
    plugin_manifest: dict, codex_manifest: dict
) -> None:
    assert plugin_manifest["version"] == codex_manifest["version"]


# --------------------------------------------------------------------------
# Plugin packaging
# --------------------------------------------------------------------------

def test_skills_dir_holds_only_skill_directories(repo: Path) -> None:
    """Enforcement for rules/08.

    Both platforms import skills by scanning `skills/` for subdirectories
    containing SKILL.md. A loose file there is not imported, and Codex
    validation rejects the plugin for it. The usual offender is a README index,
    which is the right instinct in a normal repo and the wrong one in a plugin.
    """
    loose = sorted(p.name for p in (repo / "skills").iterdir() if p.is_file())
    assert not loose, (
        f"files directly under skills/ are not imported as skills: {loose}. "
        "Move an index to docs/skills.md, or a support file into its skill's "
        "own directory (rules/08-skills-dir-holds-only-skill-directories.md)."
    )

    empty = sorted(
        p.name for p in (repo / "skills").iterdir()
        if p.is_dir() and not (p / "SKILL.md").is_file()
    )
    assert not empty, f"skill directories without a SKILL.md: {empty}"


def test_skills_index_lives_outside_skills_dir(repo: Path) -> None:
    assert (repo / "docs" / "skills.md").is_file()
    assert not (repo / "skills" / "README.md").exists()


# --------------------------------------------------------------------------
# Brand assets - required by Codex validation
# --------------------------------------------------------------------------

def _png_dimensions(path: Path) -> tuple[int, int, int, int]:
    """(width, height, bit_depth, colour_type) from a PNG's IHDR."""
    raw = path.read_bytes()
    assert raw[:8] == b"\x89PNG\r\n\x1a\n", f"{path} is not a PNG"
    assert raw[12:16] == b"IHDR", f"{path} has no IHDR first"
    width, height, depth, colour = struct.unpack(">IIBB", raw[16:26])
    return width, height, depth, colour


REQUIRED_ASSET_FIELDS = ("composerIcon", "logo")


@pytest.mark.parametrize("field", REQUIRED_ASSET_FIELDS)
def test_codex_manifest_declares_required_asset(
    codex_manifest: dict, field: str
) -> None:
    """Codex validation requires both, and requires them to be square images."""
    value = codex_manifest["interface"].get(field)
    assert value, f"interface.{field} is required by Codex plugin validation"
    assert value.startswith("./"), f"interface.{field} must be a plugin-relative path"
    assert value.lower().endswith(".png")


@pytest.mark.parametrize("field", REQUIRED_ASSET_FIELDS)
def test_required_asset_is_a_square_png(
    repo: Path, codex_manifest: dict, field: str
) -> None:
    value = codex_manifest["interface"][field]
    path = repo / value[2:]
    assert path.is_file(), f"interface.{field} points at a missing file: {value}"

    width, height, depth, colour = _png_dimensions(path)
    assert width == height, f"{value} must be square, got {width}x{height}"
    assert width >= 256, f"{value} is {width}px - too small for a plugin icon"
    assert depth == 8 and colour == 6, f"{value} must be 8-bit RGBA"


def test_adr_numbers_are_unique(repo: Path) -> None:
    """Two records once shared 0006: the always-on ADR was filed under a number
    `0006-index-completeness-...` already held, by an agent that never listed the
    directory. Nothing noticed - both files were reachable, both were indexed
    (one as a loose bullet under the table), and every check stayed green.

    ADR numbers are cited by number alone ("the tier ADR 0006 put CI on"), so a
    collision makes every such citation ambiguous.
    """
    from collections import Counter

    numbers = Counter(
        path.name.split("-", 1)[0]
        for path in (repo / "docs" / "adr").glob("[0-9][0-9][0-9][0-9]-*.md")
    )
    duplicated = sorted(n for n, count in numbers.items() if count > 1)
    assert not duplicated, f"ADR numbers used more than once: {duplicated}"

SHA-256: 57db496d4f465f6235e57a2fd1f1c9020a60b62ae09980c1f78cfbcae400cf87