0fb8fd6122
Files changed: - CHANGES.md - VERSION - instructions/CONTRACT.md - instructions/dev/doc-pull-through.md - instructions/dev/stack-close/SKILL.md - instructions/dev/stack-dev/SKILL.md - instructions/wiki-ingest/SKILL.md - instructions/wiki-lint/SKILL.md - instructions/wiki-manage/SKILL.md - instructions/wiki-query/SKILL.md - instructions/wiki-status/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/instructions_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_instructions_cmd.py
656 lines
27 KiB
Python
656 lines
27 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
|
|
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_the_six_wiki_skills():
|
|
"""Guards the actual layout, not a fixture: these are the skills the
|
|
harness is expected to offer. `stack-dev` is nested under
|
|
instructions/dev/, discovered the same way as the five top-level ones."""
|
|
names = {p.name for p in instructions_cmd.skill_dirs()}
|
|
assert {
|
|
"wiki-ingest",
|
|
"wiki-query",
|
|
"wiki-lint",
|
|
"wiki-manage",
|
|
"wiki-status",
|
|
"stack-dev",
|
|
} <= names
|
|
|
|
|
|
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)
|
|
|
|
|
|
# --- 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.",
|
|
}
|
|
]
|