"""Issue #42 stages 1-2: the SKILL.md code-block inventory is data, and the docs follow it. tests/data/skill_code_blocks.json lists every fenced block of the eight SKILL.md files with its class - (b) a procedure no script offers yet, (c) an example, call or diagram - and the (a) blocks removed because the entry script already does them. These tests fail when a SKILL.md block changes without the inventory, when a deleted block's body comes back (by exact sha or by a whitespace-insensitive fingerprint, whatever the inventory says about it), or when an exit-code table drifts from the entry script. A (c) block folded into another call is recorded under "superseded". """ import hashlib import json import re from pathlib import Path import pytest REPO = Path(__file__).resolve().parent.parent SKILLS = REPO / ".agents" / "skills" DATA = json.loads((Path(__file__).parent / "data" / "skill_code_blocks.json").read_text(encoding="utf-8")) SKILL_NAMES = sorted(p.parent.name for p in SKILLS.glob("multi-agent-mux-*/SKILL.md")) KINDS = {"invocation", "example", "diagram", "procedure", "verification"} _OPEN = re.compile(r"^(\s*)(`{3,}|~{3,})\s*([^`\s]*)") def parse_blocks(text): """Fenced blocks as (start, end, lang, sha, body); 1-based fence lines. Indented fences (inside list items) count: they render as code blocks too. A fence closes on a line of the same character, at least as long.""" lines = text.split("\n") out, i = [], 0 while i < len(lines): m = _OPEN.match(lines[i]) if not m: i += 1 continue fence = m.group(2) close = re.compile(r"^\s*" + re.escape(fence[0]) + "{%d,}\\s*$" % len(fence)) j = i + 1 while j < len(lines) and not close.match(lines[j]): j += 1 body = "\n".join(lines[i + 1:j]) out.append((i + 1, j + 1, m.group(3) or "-", hashlib.sha256(body.encode()).hexdigest()[:12], body)) i = j + 1 return out def skill_text(skill): return (SKILLS / skill / "SKILL.md").read_text(encoding="utf-8") _HEREDOC = re.compile(r"(? {link.resolve()} is not {skills_dir}"] problems = [] for skill in names: entry = link / skill if not entry.exists(): problems.append(f"{skill} is missing or dangling") elif entry.resolve() != (skills_dir / skill).resolve(): problems.append(f"{skill} -> {entry.resolve()}") return problems def test_claude_skills_link_resolves_to_agents_skills(): problems = claude_skills_problems(REPO / ".claude" / "skills", SKILLS, SKILL_NAMES) if problems is None: pytest.skip(".claude/skills is untracked; absent in a worktree") assert problems == [] @pytest.mark.parametrize("layout,ok", [("absent", None), ("single", True), ("per_skill", True), ("wrong_skill", False), ("broken_top", False), ("broken_entry", False)]) def test_claude_skills_layouts(tmp_path, layout, ok): """The check on synthetic layouts, so it is exercised in a worktree too.""" skills = tmp_path / ".agents" / "skills" names = ["multi-agent-mux-a", "multi-agent-mux-b"] for n in names: (skills / n).mkdir(parents=True) link = tmp_path / ".claude" / "skills" link.parent.mkdir() if layout == "single": link.symlink_to("../.agents/skills") elif layout == "broken_top": link.symlink_to("../.agents/no-such-dir") elif layout in ("per_skill", "wrong_skill", "broken_entry"): link.mkdir() for n in names: (link / n).symlink_to(f"../../.agents/skills/{n}") if layout == "wrong_skill": (link / names[1]).unlink() (link / names[1]).symlink_to(f"../../.agents/skills/{names[0]}") if layout == "broken_entry": (link / names[1]).unlink() (link / names[1]).symlink_to("../../.agents/skills/gone") problems = claude_skills_problems(link, skills, names) if ok is None: assert problems is None elif ok: assert problems == [] else: assert problems, layout