Files changed: - AGENTS.md - CHANGES.md - VERSION - docs/explanation/language-boundaries.md - docs/reference/commands/types-instructions-and-docs.md - instructions/CONTRACT.md - instructions/dev/stack-build/SKILL.md - instructions/dev/stack-close/SKILL.md - instructions/dev/stack-dev/SKILL.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - tools/CONTRACT.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/instructions_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_instructions_cmd.py Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
878 lines
36 KiB
Python
878 lines
36 KiB
Python
"""Tests for the instruction layer: discovery, publication by copy, and verify.
|
|
|
|
Publication is the interesting half. `instructions/<name>/SKILL.md` is the
|
|
source; `.agents/skills/` and `.claude/skills/` are gitignored copies. A copy
|
|
can go stale where a symlink could not, so the drift check is what pays for
|
|
choosing copies - these tests hold it in place.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from chemenu import config, conventions
|
|
from chemenu.tests.conftest import use_shipped_type_specs
|
|
from chemenu.commands import instructions_cmd
|
|
|
|
|
|
@pytest.fixture
|
|
def layer(tmp_path: Path, monkeypatch):
|
|
"""A self-contained instructions/ layer plus its two publish targets."""
|
|
root = tmp_path
|
|
instructions = root / "instructions"
|
|
(instructions / "wiki-demo").mkdir(parents=True)
|
|
(instructions / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n\nSee gates.md.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(instructions / "CONTRACT.md").write_text("# instructions/ - Contract\n", encoding="utf-8")
|
|
(instructions / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\ndescription: What to do when a gate refuses.\n---\n\n# Gates\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "AGENTS.md").write_text("# AGENTS\n", encoding="utf-8")
|
|
(root / "kb").mkdir()
|
|
|
|
monkeypatch.setattr(config, "ROOT", root)
|
|
monkeypatch.setattr(config, "KB_DIR", root / "kb")
|
|
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
|
|
monkeypatch.setattr(config, "AGENTS_SKILLS_DIR", root / ".agents" / "skills")
|
|
monkeypatch.setattr(config, "CLAUDE_SKILLS_DIR", root / ".claude" / "skills")
|
|
# `verify` resolves each instruction's `type: types/instruction.md`, and the
|
|
# resolver's root follows `ROOT` now - so the shipped specs have to be named
|
|
# rather than inherited. See `use_shipped_type_specs`.
|
|
use_shipped_type_specs(monkeypatch)
|
|
return root
|
|
|
|
|
|
def _skill_copy(root: Path, harness: str, name: str = "wiki-demo") -> Path:
|
|
return root / harness / "skills" / name
|
|
|
|
|
|
# --- discovery --------------------------------------------------------------
|
|
|
|
|
|
def test_a_directory_with_a_skill_md_is_a_skill(layer):
|
|
assert [p.name for p in instructions_cmd.skill_dirs()] == ["wiki-demo"]
|
|
|
|
|
|
def test_a_flat_file_is_an_instruction_and_the_contract_is_not(layer):
|
|
assert [p.name for p in instructions_cmd.instruction_files()] == ["gates.md"]
|
|
|
|
|
|
def test_the_real_repo_publishes_every_skill():
|
|
"""Guards the actual layout, not a fixture: these are the skills the
|
|
harness is expected to offer, one per naming family - `wiki-` for the
|
|
knowledge pipeline, `gtd-` for the commitment layer, `stack-` for the
|
|
stack's own development. The `stack-` trio is nested under
|
|
instructions/dev/, discovered the same way as the top-level ones."""
|
|
names = {p.name for p in instructions_cmd.skill_dirs()}
|
|
assert {
|
|
"wiki-ingest",
|
|
"wiki-query",
|
|
"wiki-lint",
|
|
"wiki-manage",
|
|
"wiki-status",
|
|
"gtd-weekly-review",
|
|
"stack-dev",
|
|
"stack-build",
|
|
"stack-close",
|
|
} <= names
|
|
|
|
|
|
def test_only_the_operator_starts_the_build_and_close_phases():
|
|
"""#168: the phase changes are the operator's slash command, so
|
|
`stack-build` and `stack-close` carry `disable-model-invocation: true`,
|
|
while `stack-dev` - the entry point the harness has to find on its own -
|
|
does not. `sync` copies the frontmatter byte for byte, which the drift
|
|
check in `verify` already holds the published copies to."""
|
|
skills = {p.name: p for p in instructions_cmd.skill_dirs()}
|
|
flags = {}
|
|
for name in ("stack-dev", "stack-build", "stack-close"):
|
|
frontmatter, error = instructions_cmd._read_frontmatter(skills[name] / "SKILL.md")
|
|
assert frontmatter is not None, error
|
|
flags[name] = frontmatter.get("disable-model-invocation", False)
|
|
assert flags == {"stack-dev": False, "stack-build": True, "stack-close": True}
|
|
|
|
|
|
_NO_PROVIDER_FORBIDDEN_TERMS = [
|
|
"super productivity",
|
|
"azure devops",
|
|
"superproductivity",
|
|
"db.json",
|
|
"rest api",
|
|
".wikitool-tasks.json",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("skill_name", ["gtd-weekly-review", "wiki-ingest"])
|
|
def test_task_tracker_skills_name_no_provider(skill_name):
|
|
"""#119 D25/#127 AC1, extended by #132: any skill that can reach the task
|
|
tracker - the weekly review, and now the ingest skill's own commitment
|
|
step (`task new`) - is meant to read identically in every instance,
|
|
whichever provider it runs against, so its body must name none of them,
|
|
and none of a provider's file or API shape either. Reads the real repo's
|
|
files, not a fixture, because the claim is about what ships, not about
|
|
the discovery logic."""
|
|
text = (config.INSTRUCTIONS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8").lower()
|
|
hits = [term for term in _NO_PROVIDER_FORBIDDEN_TERMS if term in text]
|
|
assert not hits, f"{skill_name}/SKILL.md names a provider or its shape: {hits}"
|
|
|
|
|
|
def test_instructions_dev_flat_file_is_discovered(layer):
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert "compiler-notes.md" in {p.name for p in instructions_cmd.instruction_files()}
|
|
|
|
|
|
def test_instructions_dev_nested_skill_is_discovered(layer):
|
|
skill_dir = layer / "instructions" / "dev" / "stack-dev"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text("---\nname: stack-dev\ndescription: x\n---\n", encoding="utf-8")
|
|
assert "stack-dev" in {p.name for p in instructions_cmd.skill_dirs()}
|
|
|
|
|
|
def test_is_dev_only_distinguishes_the_boundary(layer):
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
dev_file = dev_dir / "compiler-notes.md"
|
|
dev_file.write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert instructions_cmd.is_dev_only(dev_file)
|
|
assert not instructions_cmd.is_dev_only(layer / "instructions" / "gates.md")
|
|
|
|
|
|
# --- sync -------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_publishes_copies_not_symlinks(layer):
|
|
instructions_cmd.sync(force=False)
|
|
for harness in (".agents", ".claude"):
|
|
published = _skill_copy(layer, harness)
|
|
assert (published / "SKILL.md").is_file()
|
|
assert not published.is_symlink()
|
|
assert "Demo skill." in (published / "SKILL.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_sync_replaces_a_leftover_symlink_mirror(layer):
|
|
"""The previous design symlinked; an existing checkout still has those."""
|
|
target = _skill_copy(layer, ".claude")
|
|
target.parent.mkdir(parents=True)
|
|
target.symlink_to(layer / "instructions" / "wiki-demo", target_is_directory=True)
|
|
|
|
instructions_cmd.sync(force=False)
|
|
assert not target.is_symlink()
|
|
assert (target / "SKILL.md").is_file()
|
|
|
|
|
|
def test_sync_is_idempotent(layer):
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.sync(force=False)
|
|
assert instructions_cmd.drift(
|
|
layer / "instructions" / "wiki-demo", _skill_copy(layer, ".agents")
|
|
) is None
|
|
|
|
|
|
def test_sync_removes_a_published_skill_whose_source_is_gone(layer):
|
|
instructions_cmd.sync(force=False)
|
|
orphan = _skill_copy(layer, ".agents", "wiki-gone")
|
|
orphan.mkdir(parents=True)
|
|
(orphan / "SKILL.md").write_text("---\nname: wiki-gone\n---\n", encoding="utf-8")
|
|
|
|
instructions_cmd.sync(force=False)
|
|
assert not orphan.exists()
|
|
|
|
|
|
def test_sync_refuses_to_delete_content_it_did_not_generate(layer):
|
|
target = _skill_copy(layer, ".agents")
|
|
target.mkdir(parents=True)
|
|
(target / "hand-written.md").write_text("keep me\n", encoding="utf-8")
|
|
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.sync(force=False)
|
|
assert (target / "hand-written.md").exists()
|
|
|
|
instructions_cmd.sync(force=True)
|
|
assert not (target / "hand-written.md").exists()
|
|
|
|
|
|
def test_sync_fails_when_there_is_nothing_to_publish(layer):
|
|
import shutil
|
|
|
|
shutil.rmtree(layer / "instructions" / "wiki-demo")
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.sync(force=False)
|
|
|
|
|
|
# --- drift ------------------------------------------------------------------
|
|
|
|
|
|
def test_drift_detects_missing_edited_and_extra(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
target = _skill_copy(layer, ".agents")
|
|
|
|
assert instructions_cmd.drift(source, target) == "missing"
|
|
|
|
instructions_cmd.sync(force=False)
|
|
assert instructions_cmd.drift(source, target) is None
|
|
|
|
(target / "SKILL.md").write_text("---\nname: wiki-demo\n---\n# edited\n", encoding="utf-8")
|
|
assert "differs" in instructions_cmd.drift(source, target)
|
|
|
|
instructions_cmd.sync(force=False)
|
|
(target / "extra.md").write_text("x\n", encoding="utf-8")
|
|
assert "extra" in instructions_cmd.drift(source, target)
|
|
|
|
|
|
# --- conversation language block --------------------------------------------
|
|
|
|
|
|
def _declare_language(root: Path, tag: str | None) -> None:
|
|
path = root / "kb" / "CONVENTIONS.md"
|
|
if tag is None:
|
|
path.unlink(missing_ok=True)
|
|
else:
|
|
path.write_text(f"---\nlanguage: {tag}\nprofile: none\n---\n\n# conventions\n", encoding="utf-8")
|
|
conventions.reset_cache()
|
|
|
|
|
|
def _rich_skill(layer) -> Path:
|
|
"""A skill whose frontmatter has a flag and a value holding `---`, plus a sibling file."""
|
|
source = layer / "instructions" / "wiki-demo"
|
|
(source / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo --- skill.\ndisable-model-invocation: true\n---\n\n# Demo\n\nSee gates.md.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(source / "reference.md").write_text("# reference\n", encoding="utf-8")
|
|
return source
|
|
|
|
|
|
def _block_lines(text: str) -> list[str]:
|
|
return [line for line in text.splitlines() if line.startswith("> **Conversation language:")]
|
|
|
|
|
|
def test_sync_writes_the_language_block_directly_under_the_frontmatter_in_both_targets(layer):
|
|
source = _rich_skill(layer)
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
|
|
source_text = (source / "SKILL.md").read_text(encoding="utf-8")
|
|
frontmatter_end = source_text.index("---\n\n# Demo") + len("---\n")
|
|
for harness in (".agents", ".claude"):
|
|
copy = _skill_copy(layer, harness)
|
|
text = (copy / "SKILL.md").read_text(encoding="utf-8")
|
|
assert len(_block_lines(text)) == 1
|
|
assert text[:frontmatter_end] == source_text[:frontmatter_end]
|
|
after = text[frontmatter_end:].split("\n")
|
|
assert after[0] == "" and after[1].startswith("> **Conversation language: `de`**")
|
|
assert "`AGENTS.md` § Conversation language" in after[1]
|
|
assert text.replace(after[1] + "\n\n", "", 1) == source_text
|
|
assert (copy / "reference.md").read_bytes() == (source / "reference.md").read_bytes()
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) is None
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".claude")) is None
|
|
|
|
|
|
def test_the_block_is_the_tag_and_never_a_language_name(layer):
|
|
_declare_language(layer, "pt-BR")
|
|
instructions_cmd.sync(force=False)
|
|
text = (_skill_copy(layer, ".claude") / "SKILL.md").read_text(encoding="utf-8")
|
|
assert "`pt-BR`" in _block_lines(text)[0]
|
|
assert "Portuguese" not in text
|
|
|
|
|
|
def test_a_second_sync_is_byte_identical_and_adds_no_second_block(layer):
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
first = (_skill_copy(layer, ".agents") / "SKILL.md").read_bytes()
|
|
instructions_cmd.sync(force=False)
|
|
second = (_skill_copy(layer, ".agents") / "SKILL.md").read_bytes()
|
|
assert first == second
|
|
assert len(_block_lines(second.decode("utf-8"))) == 1
|
|
|
|
|
|
def test_without_a_declared_language_every_copy_is_the_source(layer, capsys):
|
|
source = _rich_skill(layer)
|
|
_declare_language(layer, None)
|
|
instructions_cmd.sync(force=False)
|
|
assert "no language declared" in " ".join(capsys.readouterr().out.split())
|
|
for harness in (".agents", ".claude"):
|
|
copy = _skill_copy(layer, harness)
|
|
assert (copy / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes()
|
|
assert instructions_cmd.drift(source, copy) is None
|
|
|
|
|
|
def test_sync_names_the_language_it_stamped(layer, capsys):
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
assert "conversation language `de`" in " ".join(capsys.readouterr().out.split())
|
|
|
|
|
|
def test_a_skill_without_closed_frontmatter_is_copied_unchanged(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
(source / "SKILL.md").write_text("---\nname: wiki-demo\n# never closed\n", encoding="utf-8")
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
assert (_skill_copy(layer, ".claude") / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes()
|
|
|
|
|
|
def test_a_crlf_skill_keeps_its_line_endings(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
(source / "SKILL.md").write_bytes(b"---\r\nname: wiki-demo\r\ndescription: Demo.\r\n---\r\n\r\n# Demo\r\n")
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
data = (_skill_copy(layer, ".claude") / "SKILL.md").read_bytes()
|
|
assert data.count(b"\n") == data.count(b"\r\n")
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".claude")) is None
|
|
|
|
|
|
def test_changing_the_language_after_sync_is_drift_that_names_the_block(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
_declare_language(layer, "en")
|
|
instructions_cmd.sync(force=False)
|
|
_declare_language(layer, "de")
|
|
for harness in (".agents", ".claude"):
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, harness)) == (
|
|
"language block is for `en`, conventions say `de`"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) is None
|
|
|
|
|
|
def test_declaring_a_language_after_a_plain_sync_is_drift_that_names_the_block(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
instructions_cmd.sync(force=False)
|
|
_declare_language(layer, "de")
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) == (
|
|
"language block missing, conventions say `de`"
|
|
)
|
|
|
|
|
|
def test_dropping_the_language_after_sync_is_drift_that_names_the_block(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
_declare_language(layer, None)
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) == (
|
|
"language block is for `de`, conventions declare no language"
|
|
)
|
|
|
|
|
|
def test_a_hand_edit_next_to_the_block_is_still_reported_as_a_difference(layer):
|
|
source = layer / "instructions" / "wiki-demo"
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
target = _skill_copy(layer, ".agents") / "SKILL.md"
|
|
target.write_text(target.read_text(encoding="utf-8") + "extra\n", encoding="utf-8")
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".agents")) == "differs in SKILL.md"
|
|
|
|
|
|
def test_a_hand_edit_of_another_file_is_still_reported(layer):
|
|
source = _rich_skill(layer)
|
|
_declare_language(layer, "de")
|
|
instructions_cmd.sync(force=False)
|
|
(_skill_copy(layer, ".claude") / "reference.md").write_text("# edited\n", encoding="utf-8")
|
|
assert instructions_cmd.drift(source, _skill_copy(layer, ".claude")) == "differs in reference.md"
|
|
|
|
|
|
def test_verify_reports_a_language_change_per_copy_and_sync_repairs_it(layer, capsys):
|
|
_declare_language(layer, "en")
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
capsys.readouterr()
|
|
|
|
_declare_language(layer, "de")
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
out = capsys.readouterr().out
|
|
for harness in (".agents", ".claude"):
|
|
assert f"{harness}/skills/wiki-demo: language block is for `en`, conventions say `de`" in out
|
|
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "match their source" in " ".join(capsys.readouterr().out.split())
|
|
|
|
|
|
def test_every_real_skill_takes_the_block():
|
|
"""A source whose frontmatter the insertion cannot find would be published
|
|
without the language and nothing would say so."""
|
|
for source in instructions_cmd.skill_dirs():
|
|
text = (source / "SKILL.md").read_text(encoding="utf-8")
|
|
rendered = instructions_cmd.render_skill_md(text, "de")
|
|
assert rendered != text, source.name
|
|
assert len(_block_lines(rendered)) == 1, source.name
|
|
|
|
|
|
# --- verify -----------------------------------------------------------------
|
|
|
|
|
|
def _verify_error(layer) -> str:
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
return ""
|
|
|
|
|
|
def test_verify_passes_on_a_healthy_layer(layer, capsys):
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "valid" in capsys.readouterr().out
|
|
|
|
|
|
def test_verify_treats_a_clean_checkout_as_bootstrap_not_drift(layer, capsys):
|
|
"""Nothing published at all is the expected state after `git clone`, so it
|
|
must point at the bootstrap procedure rather than report five failures."""
|
|
instructions_cmd.verify()
|
|
out = capsys.readouterr().out
|
|
assert "instructions sync" in out
|
|
assert "bootstrap" in out
|
|
|
|
|
|
def test_verify_reports_a_drifted_copy(layer):
|
|
instructions_cmd.sync(force=False)
|
|
(_skill_copy(layer, ".claude") / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: tampered\n---\n", encoding="utf-8"
|
|
)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_verify_reports_a_partially_published_layer(layer):
|
|
"""One copy missing is drift; all copies missing is a fresh clone."""
|
|
instructions_cmd.sync(force=False)
|
|
import shutil
|
|
|
|
shutil.rmtree(_skill_copy(layer, ".claude"))
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_verify_rejects_an_instruction_that_fails_its_schema(layer):
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\n---\n\n# Gates\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_verify_rejects_a_name_that_does_not_match_the_filename(layer):
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: not-gates\ndescription: x\n---\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_verify_rejects_a_skill_whose_name_does_not_match_its_folder(layer):
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: something-else\ndescription: Demo.\n---\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_verify_reports_an_instruction_nothing_references(layer):
|
|
"""An instruction nothing loads is inert - it deploys to no one, and
|
|
nothing else in the stack would ever say so."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_manual_instruction_passes_verify_without_any_reference(layer, capsys):
|
|
"""A `manual: true` instruction is exempt from the reference requirement -
|
|
the opposite of every other instruction, deliberately."""
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\ndescription: x\nmanual: true\n---\n\n# Gates\n",
|
|
encoding="utf-8",
|
|
)
|
|
# The fixture's own SKILL.md says "See gates.md." by default - that would
|
|
# make this a `referenced` case, the opposite of what this test checks.
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "valid" in capsys.readouterr().out
|
|
|
|
|
|
def test_manual_instruction_fails_verify_if_it_is_referenced(layer):
|
|
"""The inverse invariant: a `manual` instruction being loadable from
|
|
somewhere defeats the entire point of marking it manual."""
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\ndescription: x\nmanual: true\n---\n\n# Gates\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n\nSee gates.md.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "AGENTS.md").write_text("# AGENTS\n\nSee gates.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_a_reference_from_claude_md_satisfies_the_requirement(layer, capsys):
|
|
"""CLAUDE.md is Claude Code's own auto-loaded file - a Claude-Code-only
|
|
instruction is linked from there instead of AGENTS.md, and that must
|
|
count exactly like an AGENTS.md reference does."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
(layer / "CLAUDE.md").write_text("# CLAUDE\n\nSee gates.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "valid" in capsys.readouterr().out
|
|
|
|
|
|
def test_manual_instruction_fails_verify_if_referenced_from_claude_md(layer):
|
|
"""The CLAUDE.md mirror of test_manual_instruction_fails_verify_if_it_is_referenced:
|
|
CLAUDE.md is loaded automatically too, just by a different harness."""
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\ndescription: x\nmanual: true\n---\n\n# Gates\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
(layer / "CLAUDE.md").write_text("# CLAUDE\n\nSee gates.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_a_self_mention_does_not_count_as_a_reference(layer):
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\ndescription: x\n---\n\n# gates.md\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
assert "gates.md" not in instructions_cmd.referenced_names()
|
|
|
|
|
|
# --- skill reference paths ---------------------------------------------------
|
|
|
|
|
|
def test_the_fixture_skill_carries_no_relative_link(layer):
|
|
"""Forward direction: the fixture's own `wiki-demo/SKILL.md` mentions
|
|
`gates.md` as a bare word (`referenced_names()` relies on exactly that
|
|
substring match), never as a markdown link - so it must not trip the ban."""
|
|
assert instructions_cmd.check_skill_reference_paths() == []
|
|
|
|
|
|
def test_a_relative_markdown_link_in_a_skill_is_reported(layer):
|
|
"""`sync` copies `wiki-demo/SKILL.md` to `.claude/skills/wiki-demo/SKILL.md`
|
|
and `.agents/skills/wiki-demo/SKILL.md` - a different depth than the
|
|
source - so a link written `../gates.md` here would resolve to a
|
|
different, usually nonexistent, file once published."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
|
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
|
encoding="utf-8",
|
|
)
|
|
issues = instructions_cmd.check_skill_reference_paths()
|
|
assert len(issues) == 1
|
|
assert "wiki-demo" in issues[0] and "../gates.md" in issues[0]
|
|
|
|
|
|
def test_a_plain_root_relative_path_is_not_a_reported_link(layer):
|
|
"""The decided fix: a `SKILL.md` names its target as a repo-root-relative
|
|
plain path, not a link - even one written correctly with brackets and
|
|
parens. `instructions/gates.md` on its own, with no `[...]`, must pass."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
|
"# Demo\n\nSee `instructions/gates.md`.\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert instructions_cmd.check_skill_reference_paths() == []
|
|
|
|
|
|
def test_an_absolute_url_in_a_skill_is_not_reported(layer):
|
|
"""The ban is about relative paths breaking under the copy - an external
|
|
link is not affected by where the file sits, so it stays a normal link."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
|
"# Demo\n\nSee [the spec](https://example.com/spec).\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert instructions_cmd.check_skill_reference_paths() == []
|
|
|
|
|
|
def test_a_pure_anchor_link_in_a_skill_is_not_reported(layer):
|
|
"""A same-page `#anchor` link is not a filesystem reference and does not
|
|
move when the file is copied."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
|
"# Demo\n\n## Steps\n\nSee [Steps](#steps) above.\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert instructions_cmd.check_skill_reference_paths() == []
|
|
|
|
|
|
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(layer):
|
|
"""A skill documenting the banned syntax as an example (rather than using
|
|
it) must not trip its own ban - code fences are masked before scanning,
|
|
the same way `toc.py` masks them before hunting for headings."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
|
"# Demo\n\nDo not write it like this:\n\n"
|
|
"```markdown\n[gates.md](../gates.md)\n```\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert instructions_cmd.check_skill_reference_paths() == []
|
|
|
|
|
|
def test_verify_rejects_a_skill_with_a_relative_markdown_link(layer):
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
|
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
|
encoding="utf-8",
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_a_plain_path_resolves_the_same_regardless_of_which_copy_reads_it(layer):
|
|
"""The property the whole fix rests on. A repo-root-relative plain path
|
|
(`instructions/gates.md`) names the same file whether it is read from the
|
|
source (`instructions/wiki-demo/SKILL.md`) or from either published copy
|
|
(`.claude/skills/wiki-demo/SKILL.md`, `.agents/skills/wiki-demo/SKILL.md`),
|
|
because it is resolved against the instance root, never against the
|
|
reading file's own directory.
|
|
|
|
A relative link has no such property, which is the defect this whole
|
|
check exists to prevent: the same `../gates.md` means
|
|
`instructions/gates.md` from the source but a nonexistent
|
|
`.claude/skills/gates.md` / `.agents/skills/gates.md` from either
|
|
published copy - one directory short of the real file, because `sync`
|
|
copies the skill one level shallower than `instructions/<name>/` sits."""
|
|
instructions_cmd.sync(force=False)
|
|
target = layer / "instructions" / "gates.md"
|
|
assert target.is_file()
|
|
|
|
source_dir = layer / "instructions" / "wiki-demo"
|
|
claude_copy_dir = _skill_copy(layer, ".claude")
|
|
agents_copy_dir = _skill_copy(layer, ".agents")
|
|
|
|
# The plain path resolves against the instance root, regardless of which
|
|
# of the three directories above is doing the reading.
|
|
for _ in (source_dir, claude_copy_dir, agents_copy_dir):
|
|
assert (layer / "instructions" / "gates.md").is_file()
|
|
|
|
# The equivalent relative link would not: correct from the source, wrong
|
|
# from both copies.
|
|
assert (source_dir / ".." / "gates.md").resolve() == target.resolve()
|
|
assert not (claude_copy_dir / ".." / "gates.md").resolve().is_file()
|
|
assert not (agents_copy_dir / ".." / "gates.md").resolve().is_file()
|
|
|
|
|
|
# --- instructions/dev/ boundary ----------------------------------------------
|
|
|
|
|
|
def test_dev_only_instruction_referenced_from_outside_is_reported(layer):
|
|
"""instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
|
so a reference from outside would dangle in a distributed instance."""
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "instructions" / "gates.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: gates\ndescription: x\n---\n\n"
|
|
"See compiler-notes.md.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "AGENTS.md").write_text("# AGENTS\n\nSee gates.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_dev_only_instruction_referenced_from_claude_md_is_reported(layer):
|
|
"""The CLAUDE.md mirror of test_dev_only_instruction_referenced_from_outside_is_reported.
|
|
CLAUDE.md carries the dev-only mention itself here - routing it through
|
|
gates.md instead would make this pass with or without CLAUDE.md in the
|
|
haystack, since gates.md was always scanned."""
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "CLAUDE.md").write_text(
|
|
"# CLAUDE\n\nSee gates.md, and compiler-notes.md.\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
assert "compiler-notes.md" in instructions_cmd.dev_only_forbidden_references()
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_a_superstring_mention_is_not_a_reference(layer):
|
|
"""`stack-dev` is a real skill name, but a heading "Where stack
|
|
development happens" renders a TOC anchor `#where-stack-development-happens`
|
|
that contains "stack-dev" as a raw substring without mentioning the skill
|
|
at all. The check has to be word-bounded, not `name in text`, or every
|
|
coincidental superstring becomes a false boundary violation."""
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
dev_skill = dev_dir / "stack-dev"
|
|
dev_skill.mkdir()
|
|
(dev_skill / "SKILL.md").write_text(
|
|
"---\nname: stack-dev\ndescription: x\n---\n\n# Stack Dev\n", encoding="utf-8"
|
|
)
|
|
(layer / "AGENTS.md").write_text(
|
|
"# AGENTS\n\nSee gates.md.\n"
|
|
"- [Where stack development happens](#where-stack-development-happens)\n",
|
|
encoding="utf-8",
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
assert "stack-dev" not in instructions_cmd.dev_only_forbidden_references()
|
|
|
|
|
|
def test_a_readme_mention_alone_does_not_keep_an_instruction_alive(layer):
|
|
"""README.md is 'never by an agent as instruction' (AGENTS.md's file-naming
|
|
table), so a mention there documents an instruction without deploying it.
|
|
Counting it would let `verify` stay green over an unreachable instruction."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
(layer / "README.md").write_text("# README\n\nSee gates.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
assert "gates.md" not in instructions_cmd.referenced_names()
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_a_changelog_mention_alone_does_not_keep_an_instruction_alive(layer):
|
|
"""Same for CHANGES.md - and it is not even shipped: `dist export` replaces
|
|
it wholesale, so the mention does not survive into a distributed instance."""
|
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n# Demo\n", encoding="utf-8"
|
|
)
|
|
(layer / "CHANGES.md").write_text("# Changelog\n\nAdded gates.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
assert "gates.md" not in instructions_cmd.referenced_names()
|
|
with pytest.raises(typer.Exit):
|
|
instructions_cmd.verify()
|
|
|
|
|
|
def test_dev_only_instruction_referenced_from_the_readme_is_reported(layer):
|
|
"""README.md does not count as a *reference*, but it is still scanned for
|
|
the dev boundary: `dist export` copies it verbatim, so a dev-only path
|
|
mentioned there would dangle in a distributed instance."""
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "README.md").write_text("# README\n\nSee compiler-notes.md.\n", encoding="utf-8")
|
|
instructions_cmd.sync(force=False)
|
|
assert "compiler-notes.md" in instructions_cmd.dev_only_forbidden_references()
|
|
|
|
|
|
def test_dev_only_reference_inside_dist_strip_block_is_exempt(layer, capsys):
|
|
"""The one sanctioned crossing: AGENTS.md's routing line to the dev
|
|
skill lives inside a dist:strip block, so it is stripped from the scan
|
|
before the boundary check runs - `dist export` removes both together,
|
|
so nothing is left dangling."""
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(layer / "AGENTS.md").write_text(
|
|
"# AGENTS\n\n<!-- dist:strip-start -->\nSee compiler-notes.md.\n<!-- dist:strip-end -->\n",
|
|
encoding="utf-8",
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "valid" in capsys.readouterr().out
|
|
|
|
|
|
def test_dev_only_mention_in_changelog_is_exempt(layer, capsys):
|
|
"""CHANGES.md is exempt from the boundary check: `dist export` always
|
|
replaces it wholesale with a template regardless of its content, so a
|
|
historical mention of a dev-only name there never reaches a distributed
|
|
instance - unlike AGENTS.md/README.md, which are copied (marker-stripped)."""
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
# Its real reference lives inside dev/, where it is allowed: the changelog
|
|
# mention below must be exempt from the boundary check, not a substitute
|
|
# for a reference (CHANGES.md does not deploy an instruction to anyone).
|
|
dev_skill = dev_dir / "stack-dev"
|
|
dev_skill.mkdir()
|
|
(dev_skill / "SKILL.md").write_text(
|
|
"---\nname: stack-dev\ndescription: x\n---\n\nSee compiler-notes.md.\n", encoding="utf-8"
|
|
)
|
|
(layer / "AGENTS.md").write_text("# AGENTS\n\nSee gates.md.\n", encoding="utf-8")
|
|
(layer / "CHANGES.md").write_text(
|
|
"# Changelog\n\n## Entry\n\nMentions compiler-notes.md in passing.\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "valid" in capsys.readouterr().out
|
|
|
|
|
|
def test_dev_instruction_referenced_only_from_within_dev_passes(layer, capsys):
|
|
dev_dir = layer / "instructions" / "dev"
|
|
dev_dir.mkdir()
|
|
(dev_dir / "compiler-notes.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: compiler-notes\ndescription: x\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
skill_dir = dev_dir / "stack-dev"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\nname: stack-dev\ndescription: x\n---\n\nSee compiler-notes.md.\n", encoding="utf-8"
|
|
)
|
|
instructions_cmd.sync(force=False)
|
|
instructions_cmd.verify()
|
|
assert "valid" in capsys.readouterr().out
|
|
|
|
|
|
# --- list -------------------------------------------------------------------
|
|
|
|
|
|
def test_list_reports_each_instruction_with_its_description(layer, capsys):
|
|
instructions_cmd.list_instructions(json_out=False)
|
|
out = capsys.readouterr().out
|
|
assert "gates" in out
|
|
assert "What to do when a gate refuses." in out
|
|
assert "CONTRACT" not in out
|
|
|
|
|
|
def test_list_json_is_machine_readable(layer, capsys):
|
|
import json
|
|
|
|
instructions_cmd.list_instructions(json_out=True)
|
|
rows = json.loads(capsys.readouterr().out)
|
|
assert rows == [
|
|
{
|
|
"name": "gates",
|
|
"path": "instructions/gates.md",
|
|
"description": "What to do when a gate refuses.",
|
|
}
|
|
]
|