Add BMAD planning artifacts: brief, PRD, architecture, UX, spec, tickets

Includes the BMAD install config, the initiative ticket tree, the portal
context-resources scripts, and the .gitignore change that tracks BMAD files
(generated _bmad/render/ is ignored).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 13:33:02 +02:00
co-authored by Claude Sonnet 5.5
parent 97b2a95062
commit 24de5878ed
60 changed files with 13647 additions and 6 deletions
+90
View File
@@ -0,0 +1,90 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from config_utils import ( # noqa: E402
ConfigError,
load_central_config,
load_customization,
load_toml,
structural_merge,
)
class ConfigUtilsTests(unittest.TestCase):
def test_structural_merge_recurses_appends_and_replaces_keyed_tables(self):
base = {
"nested": {"keep": True, "replace": "old"},
"plain": ["base"],
"items": [{"id": "one", "value": "old"}],
}
override = {
"nested": {"replace": "new"},
"plain": ["override"],
"items": [
{"id": "one", "value": "new"},
{"id": "two", "value": "added"},
],
}
merged = structural_merge(base, override)
self.assertEqual(merged["nested"], {"keep": True, "replace": "new"})
self.assertEqual(merged["plain"], ["base", "override"])
self.assertEqual(
merged["items"],
[
{"id": "one", "value": "new"},
{"id": "two", "value": "added"},
],
)
def test_non_string_keyed_identifier_is_rejected(self):
with self.assertRaisesRegex(ConfigError, "identifier `id` must be a string"):
structural_merge([{"id": "valid"}], [{"id": 42}])
def test_present_malformed_optional_layer_is_rejected(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "optional.toml"
path.write_text("[broken\n", encoding="utf-8")
with self.assertRaisesRegex(ConfigError, "failed to parse"):
load_toml(path)
def test_missing_optional_layer_is_empty(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "optional.toml"
self.assertEqual(load_toml(path), {})
def test_filesystem_layer_precedence(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
bmad = root / "_bmad"
custom = bmad / "custom"
skill = bmad / "bmm" / "sample-skill"
custom.mkdir(parents=True)
skill.mkdir(parents=True)
(bmad / "config.toml").write_text('[value]\norder = "base-team"\n', encoding="utf-8")
(bmad / "config.user.toml").write_text(
'[value]\norder = "base-user"\nstray = "ignored"\n', encoding="utf-8"
)
(custom / "config.toml").write_text('[value]\norder = "custom-team"\n', encoding="utf-8")
(custom / "config.user.toml").write_text('[value]\norder = "custom-user"\n', encoding="utf-8")
(skill / "customize.toml").write_text('[value]\norder = "default"\n', encoding="utf-8")
(custom / "sample-skill.toml").write_text('[value]\norder = "team"\n', encoding="utf-8")
(custom / "sample-skill.user.toml").write_text('[value]\norder = "user"\n', encoding="utf-8")
merged = load_central_config(root)
self.assertEqual(merged["value"]["order"], "custom-user")
# _bmad/config.user.toml is old-installer debris (setup.py's
# LEGACY_LEFTOVERS), not a layer. Nothing writes it; nothing reads it.
self.assertNotIn("stray", merged["value"])
self.assertEqual(load_customization(root, skill)["value"]["order"], "user")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,151 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import knowledge # noqa: E402
SOURCE = "github:acme/tools/skills"
MIGRATION = """
[migration]
module = "demo"
from = "1"
to = "2"
title = "Move demo artifacts to the v2 layout"
summary = "v1 to v2"
detect = "a v1 folder"
guide = "move it"
checklist = ["it moved"]
"""
def write_module(root: Path, skills: tuple[str, ...] = (), *, code: str = "demo") -> Path:
record = root / f"bmod-{code}"
record.mkdir(parents=True)
names = ", ".join(f'"{name}"' for name in skills)
lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"]
(record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
for name in skills:
folder = root / name
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{record.name}"\nsource = "{SOURCE}"\n', encoding="utf-8")
return record
class MigrationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.skills = Path(self.temp.name) / ".claude" / "skills"
def test_a_migration_file_is_listed_by_its_table_without_its_text(self):
record = write_module(self.skills, ("demo-one",))
(record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(
report["migrations"],
[
{
"module": "demo",
"path": "migration-1.toml",
"file": str(record / "migration-1.toml"),
"from": "1",
"to": "2",
"title": "Move demo artifacts to the v2 layout",
}
],
)
self.assertEqual(report["problems"], [])
def test_other_toml_files_beside_the_record_are_not_migrations_and_a_bad_key_is_named(self):
record = write_module(self.skills)
(record / "roster.toml").write_text("[[members]\nnot even toml\n", encoding="utf-8")
(record / "notes.toml").write_text('migration = "not a table"\n', encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"])
self.assertIn("'migration' is not a table", report["problems"][0]["problem"])
def test_a_migration_without_its_fields_is_a_problem_not_a_listing(self):
record = write_module(self.skills)
(record / "migration-2.toml").write_text('[migration]\nmodule = "demo"\nfrom = 1\nto = "2"\n', encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual(len(report["problems"]), 1)
self.assertEqual(report["problems"][0]["kind"], "migration")
self.assertIn(
"needs non-empty from, title, summary, detect, guide, checklist", report["problems"][0]["problem"]
)
def test_whitespace_only_fields_and_blank_checklist_items_are_missing(self):
record = write_module(self.skills)
text = MIGRATION.replace('guide = "move it"', 'guide = " "').replace(
'checklist = ["it moved"]', 'checklist = [" "]'
)
(record / "migration-3.toml").write_text(text, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertIn("needs non-empty guide, checklist", report["problems"][0]["problem"])
def test_a_migration_for_another_module_is_a_problem(self):
record = write_module(self.skills)
text = MIGRATION.replace('module = "demo"', 'module = "other"')
(record / "migration-4.toml").write_text(text, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertIn("is not this record's 'demo'", report["problems"][0]["problem"])
def test_migrations_are_listed_by_number_not_by_text(self):
record = write_module(self.skills)
for number in (10, 2, 1):
text = MIGRATION.replace('from = "1"', f'from = "{number}"').replace('to = "2"', f'to = "{number + 1}"')
(record / f"migration-{number}.toml").write_text(text, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(
[m["path"] for m in report["migrations"]], ["migration-1.toml", "migration-2.toml", "migration-10.toml"]
)
self.assertEqual(report["problems"], [])
def test_a_migration_outside_the_naming_rule_is_a_problem(self):
record = write_module(self.skills)
for name in ("v1-v2-migration.toml", "migration-one.toml", "migration-1-2.toml"):
(record / name).write_text(MIGRATION, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual(len(report["problems"]), 3)
for problem in report["problems"]:
self.assertIn("must be named migration-<n>.toml", problem["problem"])
def test_two_migrations_of_one_module_with_the_same_number_are_problems(self):
record = write_module(self.skills)
other = write_module(self.skills, code="other")
(record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8")
(record / "migration-01.toml").write_text(MIGRATION, encoding="utf-8")
(record / "migration-2.toml").write_text(MIGRATION, encoding="utf-8")
(other / "migration-1.toml").write_text(
MIGRATION.replace('module = "demo"', 'module = "other"'), encoding="utf-8"
)
report = knowledge.collect([self.skills])
self.assertEqual(
[(m["module"], m["path"]) for m in report["migrations"]],
[("demo", "migration-2.toml"), ("other", "migration-1.toml")],
)
self.assertEqual(
sorted(Path(problem["document"]).name for problem in report["problems"]),
["migration-01.toml", "migration-1.toml"],
)
for problem in report["problems"]:
self.assertIn("same number", problem["problem"])
def test_a_migration_that_is_not_toml_is_a_problem(self):
record = write_module(self.skills)
(record / "migration-5.toml").write_text('[migration\nfrom = "1"\n', encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"])
if __name__ == "__main__":
unittest.main()
+365
View File
@@ -0,0 +1,365 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py
The spine under test is the flat, append-only, chronological invariant: every entry is
one line recorded at the end in the order it happened — no sections, no grouping, and no
lifecycle status the log would have to mutate.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "memlog.py"
sys.path.insert(0, str(SCRIPT.parent))
import memlog # noqa: E402
MEMLOG = ".memlog.md"
@pytest.fixture
def ws(tmp_path):
return str(tmp_path)
def read(ws):
return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
def body_of(ws):
return memlog.split(read(ws))[1]
def entries(ws):
return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
def init(ws, **fields):
fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
argv = ["init", "--workspace", ws]
for k, v in fields.items():
argv += ["--field", f"{k}={v}"]
assert memlog.main(argv) == 0
def append(ws, text, entry_type=None, by=None):
argv = ["append", "--workspace", ws, "--text", text]
if entry_type:
argv += ["--type", entry_type]
if by:
argv += ["--by", by]
assert memlog.main(argv) == 0
# --- init ---------------------------------------------------------------
def test_init_writes_frontmatter_fields(ws):
init(ws)
meta, body = memlog.split(read(ws))
assert meta["topic"] == "Reinvent the lunchbox"
assert meta["goal"] == "ideas for a pitch"
assert "updated" in meta
assert body.strip() == ""
def test_init_has_no_lifecycle_status(ws):
# A memory log carries no "status" flag; completion is an appended entry, not frontmatter.
init(ws)
meta, _ = memlog.split(read(ws))
assert "status" not in meta
def test_init_arbitrary_fields(ws):
init(ws, topic="T", audience="board")
meta, _ = memlog.split(read(ws))
assert meta["audience"] == "board"
def test_init_refuses_overwrite(ws):
init(ws)
assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
def test_init_creates_missing_workspace(tmp_path):
nested = str(tmp_path / "a" / "b")
assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
assert (Path(nested) / MEMLOG).is_file()
def test_init_rejects_malformed_field(ws):
assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
# --- addressing: --workspace and --path are interchangeable --------------
def test_path_addressing_targets_the_file_directly(tmp_path):
target = tmp_path / "run" / ".memlog.md"
assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0
assert target.is_file()
assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0
body = memlog.split(target.read_text(encoding="utf-8"))[1]
assert "- (idea) an idea" in body
def test_workspace_and_path_resolve_to_same_file(ws):
init(ws)
via_path = str(Path(ws) / MEMLOG)
assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0
assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0
assert entries(ws) == ["- from path", "- from workspace"]
def test_target_is_required(ws):
with pytest.raises(SystemExit):
memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path
# --- append: flat chronological order is the whole point -----------------
def test_append_lands_at_end_in_order(ws):
init(ws)
append(ws, "first")
append(ws, "second")
append(ws, "third")
assert entries(ws) == ["- first", "- second", "- third"]
def test_no_sections_or_headings_ever(ws):
init(ws)
append(ws, "started foo", entry_type="technique")
append(ws, "an idea", entry_type="idea")
append(ws, "started bar", entry_type="technique")
assert "## " not in body_of(ws) # the flat log never grows headings
def test_type_renders_as_inline_tag(ws):
init(ws)
append(ws, "the earth revolves around the sun", entry_type="idea")
append(ws, "how do we handle stampede?", entry_type="question")
body = body_of(ws)
assert "- (idea) the earth revolves around the sun" in body
assert "- (question) how do we handle stampede?" in body
def test_append_without_type_is_plain_note(ws):
init(ws)
append(ws, "bare entry")
assert entries(ws) == ["- bare entry"]
def test_completion_is_an_entry_not_a_status(ws):
# The documented way to mark a session done: append it. Frontmatter never gains a status.
init(ws)
append(ws, "session complete", entry_type="event")
meta, _ = memlog.split(read(ws))
assert "status" not in meta
assert entries(ws)[-1] == "- (event) session complete"
def test_append_collapses_newlines_into_one_line(ws):
init(ws)
append(ws, "line one\nline two\n spaced out")
assert entries(ws) == ["- line one line two spaced out"]
def test_revisited_technique_is_just_a_later_entry(ws):
# the user's model: switching techniques is an entry, not a section to return to
init(ws)
append(ws, "started SCAMPER", entry_type="technique")
append(ws, "magnetic latch", entry_type="idea")
append(ws, "started Six Hats", entry_type="technique")
append(ws, "stale data risk", entry_type="idea")
append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
append(ws, "stackable tiers", entry_type="idea")
assert entries(ws) == [
"- (technique) started SCAMPER",
"- (idea) magnetic latch",
"- (technique) started Six Hats",
"- (idea) stale data risk",
"- (technique) started SCAMPER",
"- (idea) stackable tiers",
]
def test_by_renders_attribution_in_tag(ws):
# Creative Partner mode must record whose idea each one was
init(ws)
append(ws, "magnetic latch lid", entry_type="idea", by="user")
append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
body = body_of(ws)
assert "- (idea by user) magnetic latch lid" in body
assert "- (idea by coach) lid doubles as a plate" in body
def test_by_without_type_renders_alone(ws):
init(ws)
append(ws, "off-the-cuff thought", by="coach")
assert entries(ws) == ["- (by coach) off-the-cuff thought"]
def test_heterogeneous_entry_types_coexist(ws):
init(ws)
append(ws, "an idea", entry_type="idea")
append(ws, "an open question", entry_type="question")
append(ws, "a decision we made", entry_type="decision")
append(ws, "user wants mobile-first", entry_type="direction")
body = body_of(ws)
for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
assert tag in body
def test_free_vocabulary_is_not_enforced(ws):
# The tool is neutral: any --type the host skill names renders verbatim.
init(ws)
append(ws, "a custom kind", entry_type="crack")
append(ws, "another", entry_type="lock")
body = body_of(ws)
assert "- (crack) a custom kind" in body
assert "- (lock) another" in body
# --- set: generic descriptive frontmatter, no lifecycle semantics --------
def test_set_adds_field(ws):
init(ws)
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
assert memlog.split(read(ws))[0]["mode"] == "partner"
def test_set_replaces_field(ws):
init(ws, topic="T", mode="facilitator")
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
assert memlog.split(read(ws))[0]["mode"] == "partner"
def test_set_preserves_body(ws):
init(ws)
append(ws, "keep me", entry_type="idea")
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
meta, body = memlog.split(read(ws))
assert meta["mode"] == "partner"
assert "- (idea) keep me" in body
def test_updated_stays_last(ws):
init(ws)
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
meta = memlog.split(read(ws))[0]
assert list(meta)[-1] == "updated"
# --- robustness ---------------------------------------------------------
def test_roundtrip_render_is_stable(ws):
init(ws)
append(ws, "one", entry_type="idea")
meta, body = memlog.split(read(ws))
assert memlog.split(memlog.render(meta, body)) == (meta, body)
def test_commas_in_field_survive(ws):
init(ws, topic="cars, trains, and planes")
append(ws, "z", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert meta["topic"] == "cars, trains, and planes"
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
# A `---` inside a value must NOT be read as the closing fence: topic stays intact
# and the body never leaks frontmatter text.
init(ws, topic="Pricing --- tiers --- and add-ons")
append(ws, "an idea", entry_type="idea")
meta, body = memlog.split(read(ws))
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
assert entries(ws) == ["- (idea) an idea"]
assert "topic:" not in body # frontmatter never bled into the body
def test_newline_in_field_is_neutralized(ws):
# A value carrying a newline can't break the fence on the next round-trip.
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
append(ws, "x", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert "\n" not in meta["topic"]
def test_append_emits_json_ack(ws, capsys):
init(ws)
append(ws, "x", entry_type="idea")
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out["ok"] is True
assert out["entries"] == 1
assert out["memlog"].endswith(MEMLOG)
assert "status" not in out # no lifecycle status
assert "section" not in out # sections are gone
def test_ack_entry_count_climbs(ws, capsys):
init(ws)
append(ws, "a")
append(ws, "b")
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out["entries"] == 2
# --- append writes only its own line ------------------------------------
def test_first_entry_after_init(ws):
init(ws)
append(ws, "first", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert meta["topic"] == "Reinvent the lunchbox"
assert entries(ws) == ["- (idea) first"]
def test_append_does_not_restamp_updated(ws):
init(ws)
path = Path(ws) / MEMLOG
path.write_text(read(ws).replace(memlog.split(read(ws))[0]["updated"], "2000-01-01T00:00"), encoding="utf-8")
append(ws, "a")
append(ws, "b")
assert memlog.split(read(ws))[0]["updated"] == "2000-01-01T00:00"
assert entries(ws) == ["- a", "- b"]
def test_append_to_missing_log_fails_and_creates_nothing(ws):
with pytest.raises(FileNotFoundError):
memlog.main(["append", "--workspace", ws, "--text", "orphan"])
assert not (Path(ws) / MEMLOG).exists()
def test_append_after_missing_trailing_newline_starts_new_line(ws):
init(ws)
append(ws, "first")
path = Path(ws) / MEMLOG
path.write_text(read(ws).rstrip("\n"), encoding="utf-8")
append(ws, "second")
assert entries(ws) == ["- first", "- second"]
def test_parallel_appends_all_land(ws):
init(ws)
n = 40
procs = [
subprocess.Popen(
[sys.executable, str(SCRIPT), "append", "--workspace", ws, "--text", f"entry {i}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
for i in range(n)
]
failures = [p.stderr.read().decode() for p in procs if p.wait() != 0]
assert failures == []
assert sorted(entries(ws)) == sorted(f"- entry {i}" for i in range(n))
+976
View File
@@ -0,0 +1,976 @@
"""Snapshot renderer tests against the current install layout and shipped skills.
Host skills live outside `_bmad/`. `_bmad/` is the project runtime setup
materializes: shared scripts, team config, custom overlays, and published
snapshots. Call `render()` for the success path. Use the installed CLI for
the agent-facing dispatch/HALT contract and for anything that needs a
separate process.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from helpers import chmod
SCRIPTS_SRC = Path(__file__).resolve().parents[1]
REPO = SCRIPTS_SRC.parents[2]
SKILLS_SRC = REPO / "skills"
CONFIG_TEMPLATE = (SCRIPTS_SRC.parent / "assets" / "config.template.toml").read_text(encoding="utf-8")
SHARED_SCRIPTS = (
"config_utils.py",
"memlog.py",
"render_skill.py",
"resolve_config.py",
"resolve_customization.py",
)
SHIPPED_SKILLS = ("bmad-build-auto", "bmad-build", "bmad-code-review")
RENDERED_SKILLS = (*SHIPPED_SKILLS, "bmad-walkthrough", "bmad-retrospective", "bmad-qa-generate-e2e-tests")
COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*(?:rendered\(|initiative_folder)|\{%")
DISPATCH_PREFIX = "read and follow "
sys.path.insert(0, str(SCRIPTS_SRC))
import render_skill as rs # noqa: E402
def _team_config(project: Path) -> str:
return CONFIG_TEMPLATE.replace("{directory_name}", project.name)
def _copy_skill(dest: Path, name: str) -> Path:
shutil.copytree(
SKILLS_SRC / name,
dest,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
return dest
def _files(directory: Path) -> dict[str, bytes]:
files = {
path.relative_to(directory).as_posix(): path.read_bytes() for path in directory.rglob("*") if path.is_file()
}
return dict(sorted(files.items()))
def _markdown(directory: Path) -> str:
return "\n".join(content.decode("utf-8") for name, content in _files(directory).items() if name.endswith(".md"))
def _namespace_dir(project: Path, skill_name: str) -> Path:
root = str(project.resolve())
slug = re.sub(r"[^a-z0-9]+", "-", project.name.lower()).strip("-") or "project"
slug = slug[:80].rstrip("-") or "project"
root_hash = hashlib.sha256(root.encode("utf-8")).hexdigest()[:12]
return project / "_bmad" / "render" / skill_name / f"{slug}-{root_hash}"
class PublishInternalsTests(unittest.TestCase):
"""Corruption and reuse branches of `_publish` without rendering a whole skill."""
def test_identical_publish_reuses_and_rejects_each_corruption_mode(self):
with tempfile.TemporaryDirectory() as temp_dir:
dest = Path(temp_dir) / "generation"
outputs = {"workflow.md": b"hello\n"}
manifest = {
"schema_version": 1,
"outputs": {"workflow.md": rs._hash_bytes(b"hello\n")},
}
rs._publish(dest, outputs, manifest)
rs._publish(dest, outputs, manifest)
self.assertEqual((dest / "workflow.md").read_bytes(), b"hello\n")
with self.assertRaisesRegex(rs.RenderError, "collision or corruption"):
rs._publish(dest, outputs, {**manifest, "extra": True})
(dest / "Thumbs.db").write_bytes(b"stray")
rs._publish(dest, outputs, manifest)
self.assertEqual((dest / "Thumbs.db").read_bytes(), b"stray")
(dest / "workflow.md").unlink()
with self.assertRaisesRegex(
rs.RenderError, rf"missing rendered files: workflow\.md in {re.escape(str(dest))}"
):
rs._publish(dest, outputs, manifest)
(dest / "workflow.md").write_bytes(b"hello\ncorrupt")
with self.assertRaisesRegex(rs.RenderError, "hash mismatch"):
rs._publish(dest, outputs, manifest)
(dest / "workflow.md").write_bytes(b"hello\n")
(dest / "manifest.json").write_text("{", encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, "corrupt existing"):
rs._publish(dest, outputs, manifest)
class RenderSkillTests(unittest.TestCase):
def _workspace(
self,
*,
name: str = "project",
shared_bmad: Path | None = None,
config: str | None = None,
) -> SimpleNamespace:
outer = Path(tempfile.mkdtemp(prefix="bmad-render-"))
self.addCleanup(shutil.rmtree, outer, True)
project = outer / name
project.mkdir(parents=True)
(project / "nested" / "cwd").mkdir(parents=True)
if shared_bmad is None:
bmad = project / "_bmad"
scripts = bmad / "scripts"
scripts.mkdir(parents=True)
for script in SHARED_SCRIPTS:
shutil.copy2(SCRIPTS_SRC / script, scripts / script)
(bmad / "custom").mkdir()
(bmad / "config.toml").write_text(
config if config is not None else _team_config(project),
encoding="utf-8",
)
else:
(project / "_bmad").symlink_to(shared_bmad)
bmad = shared_bmad
return SimpleNamespace(outer=outer, project=project, bmad=bmad)
def _skill(self, ws: SimpleNamespace, name: str) -> Path:
return _copy_skill(ws.outer / "skills" / name, name)
def _cli(
self,
project: Path,
skill: Path,
*,
cwd: Path | None = None,
args: tuple[str, ...] = (),
timeout: float | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
sys.executable,
str(project / "_bmad" / "scripts" / "render_skill.py"),
"--project-root",
str(project),
"--skill",
str(skill),
*args,
],
cwd=cwd or project,
text=True,
capture_output=True,
check=False,
timeout=timeout,
)
def _entry(self, result: subprocess.CompletedProcess[str]) -> Path:
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
lines = result.stdout.strip().split("\n")
self.assertEqual(len(lines), 1, msg=result.stdout)
self.assertTrue(lines[0].startswith(DISPATCH_PREFIX), msg=result.stdout)
output = Path(lines[0][len(DISPATCH_PREFIX) :])
self.assertTrue(output.is_absolute())
return output
def _assert_rendered(self, workflow: Path, project: Path, skill_name: str) -> Path:
snap = workflow.parent
self.assertEqual(workflow.name, "workflow.md")
self.assertIn(f"{os.sep}render{os.sep}{skill_name}{os.sep}", str(workflow))
self.assertFalse((snap / "SKILL.md").exists())
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["project_root"], str(project.resolve()))
self.assertEqual(manifest["skill"], skill_name)
actual = _files(snap)
expected = [*manifest["outputs"], "manifest.json"]
self.assertEqual(sorted(actual), sorted(expected))
for name, digest in manifest["outputs"].items():
self.assertEqual(rs._hash_bytes(actual[name]), digest, name)
markdown = _markdown(snap)
self.assertIsNone(COMPILE_TOKEN.search(markdown), markdown)
self.assertNotIn("{skill-root}", markdown)
if skill_name not in ("bmad-retrospective", "bmad-qa-generate-e2e-tests"):
artifacts = (project.resolve() / "_bmad-output").as_posix()
self.assertIn(artifacts, markdown)
return snap
def _fixture_skill(self, ws: SimpleNamespace, defaults: str, workflow: str, **sources: str) -> Path:
skill = ws.outer / "skills" / "fixture"
skill.mkdir(parents=True)
(skill / "customize.toml").write_text(defaults, encoding="utf-8")
for name, content in {"workflow.md": workflow, **sources}.items():
(skill / name).write_text(content, encoding="utf-8")
return skill
def _assert_halt(self, result: subprocess.CompletedProcess[str], ws: SimpleNamespace) -> None:
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout)
self.assertNotIn("Traceback", result.stdout + result.stderr)
self.assertFalse((ws.bmad / "render").exists())
def test_invocation_precedence_flag_order_and_persistent_isolation(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
project_file = ws.bmad / "custom" / "fixture.toml"
user_file = ws.bmad / "custom" / "fixture.user.toml"
override_file = ws.project / "nested" / "cwd" / "invocation.toml"
project_file.write_text('[workflow]\nmessage = "project"\n', encoding="utf-8")
user_file.write_text('[workflow]\nmessage = "user"\n', encoding="utf-8")
override_file.write_text('[workflow]\nmessage = "file"\n', encoding="utf-8")
original = {
path: path.read_bytes() for path in (project_file, user_file, override_file, skill / "customize.toml")
}
baseline = rs.render(ws.project, skill)
self.assertEqual(baseline.read_text(), "user\n")
from_file = rs.render(ws.project, skill, overrides=override_file)
self.assertEqual(from_file.read_text(), "file\n")
expected = None
for args in (
(
"--set",
"workflow.message=command = wins",
"--overrides",
"invocation.toml",
),
(
"--overrides",
"invocation.toml",
"--set",
"workflow.message=command = wins",
),
):
entry = self._entry(self._cli(ws.project, skill, cwd=override_file.parent, args=args))
self.assertEqual(entry.read_text(), "command = wins\n")
if expected is not None:
self.assertEqual(entry, expected)
expected = entry
self.assertEqual(rs.render(ws.project, skill), baseline)
for path, content in original.items():
self.assertEqual(path.read_bytes(), content)
user_file.unlink()
self.assertEqual(rs.render(ws.project, skill).read_text(), "project\n")
project_file.unlink()
self.assertEqual(rs.render(ws.project, skill).read_text(), "shipped\n")
def test_equivalent_invocation_forms_merge_structures_and_reuse_rendered(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nfacts = ["base"]\n[workflow.details]\nlabel = "base"\nkept = "kept"\n'
'[[workflow.layers]]\nid = "a"\nname = "A"\ninstruction = "original"\n',
"{{ workflow.facts }}\n{{ workflow.details.label }} {{ workflow.details.kept }}\n{{ workflow.layers }}\n",
)
override = ws.project / "overrides.toml"
override.write_text(
'[workflow]\nfacts = ["added"]\ndetails = { label = "changed" }\n'
'layers = [{ id = "a", name = "Replaced", instruction = "replacement" }, '
'{ id = "b", instruction = "new" }]\n',
encoding="utf-8",
)
file_entry = rs.render(ws.project, skill, overrides=override)
command_entry = rs.render(
ws.project,
skill,
assignments=[
'workflow.facts=["added"]',
'workflow.details={ label = "changed" }',
'workflow.layers=[{ id = "a", name = "Replaced", instruction = "replacement" }, '
'{ id = "b", instruction = "new" }]',
],
)
self.assertEqual(file_entry, command_entry)
self.assertEqual(_files(file_entry.parent), _files(command_entry.parent))
content = command_entry.read_text()
self.assertIn("- base\n- added", content)
self.assertIn("changed kept", content)
self.assertIn("Replaced (`a`)", content)
self.assertIn("b (`b`)", content)
self.assertNotIn("original", content)
combined = rs.render(ws.project, skill, overrides=override, assignments=['workflow.facts=["command"]'])
self.assertIn("- base\n- added\n- command", combined.read_text())
def test_string_assignment_syntax_and_conflicting_paths_halt(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\n', "{{ workflow.message }}")
for assignment, expected in (
("workflow.message=words = more words", "words = more words"),
('workflow.message="line\\nnext"', "line\nnext"),
("workflow.message='literal = text'", "literal = text"),
("workflow.message=", ""),
):
with self.subTest(assignment=assignment):
# The nonempty shipped default deliberately rejects empty prose.
if not expected:
with self.assertRaisesRegex(rs.RenderError, "must not be empty"):
rs.render(ws.project, skill, assignments=[assignment])
else:
self.assertEqual(rs.render(ws.project, skill, assignments=[assignment]).read_text(), expected)
for assignments in (
["workflow.message=first", "workflow.message=second"],
['workflow={ message = "table" }', "workflow.message=child"],
["workflow.message=child", 'workflow={ message = "table" }'],
):
with self.subTest(assignments=assignments):
with self.assertRaisesRegex(rs.RenderError, "conflicts with earlier --set"):
rs.render(ws.project, skill, assignments=assignments)
def test_undeclared_persistent_key_halts_and_unread_declared_key_renders(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\nunread = "base"\n', "{{ workflow.message }}")
team = ws.bmad / "custom" / f"{skill.name}.toml"
user = ws.bmad / "custom" / f"{skill.name}.user.toml"
# A declared key this render never reads is still a valid persistent override.
user.write_text('[workflow]\nunread = "changed"\n', encoding="utf-8")
self.assertEqual(rs.render(ws.project, skill).read_text(), "base")
generations = set((ws.bmad / "render" / skill.name).rglob("manifest.json"))
for layer, content, expected in (
(
user,
'[workflow]\nmessage = "ok"\nmesage = "typo"\n',
r"user\.toml sets keys fixture does not declare: workflow\.mesage$",
),
(
team,
'[[workflow.review_layers]]\nid = "stale"\nname = "Stale"\ninstruction = "x"\n',
r"workflow\.review_layers$",
),
(team, "[workflow.message]\nnested = true\n", r"workflow\.message\.nested$"),
(team, 'message = "top level"\n', r"does not declare: message$"),
):
with self.subTest(layer=layer.name, content=content):
layer.write_text(content, encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, expected):
rs.render(ws.project, skill)
self.assertEqual(set((ws.bmad / "render" / skill.name).rglob("manifest.json")), generations)
layer.unlink()
@unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses file permission bits")
def test_render_folder_that_refuses_new_files_halts(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
namespace = _namespace_dir(ws.project, "fixture")
namespace.mkdir(parents=True)
chmod(namespace, 0o555, deny="WD,AD")
self.addCleanup(chmod, namespace, 0o755)
# The timeout: on Windows, older Pythons' mkdtemp retried here some two
# billion times, and a hang must fail this test, not the job.
result = self._cli(ws.project, skill, timeout=60)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertIn("denied", result.stdout.lower())
self.assertNotIn("Traceback", result.stdout + result.stderr)
self.assertEqual(list(namespace.iterdir()), [])
def test_invalid_invocation_halts_before_publication(self):
invalid = (
("--set", "workflow.message"),
("--set", ".workflow.message=x"),
("--set", "workflow.unknown=x"),
("--set", 'workflow.message="unterminated'),
("--set", "workflow.count=words"),
# Declared but never reaches a token or condition in this render.
("--set", "workflow.count=7"),
# Consumed, so the consumer rejects the type.
("--set", "workflow.items=7"),
("--set", "workflow.items=[true]"),
("--set", "workflow.count=2\nextra=3"),
("--overrides", "missing.toml"),
("--overrides",),
("--set",),
)
for args in invalid:
with self.subTest(args=args):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nmessage = "base"\ncount = 1\nitems = ["base"]\n',
"{{ workflow.message }} {{ workflow.items }}\n",
)
self._assert_halt(self._cli(ws.project, skill, args=args), ws)
for content in ("[workflow", '[workflow]\nunknown="x"', "[workflow]\ncount=7", "[workflow]\nitems=7"):
with self.subTest(content=content):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\ncount = 1\nitems = ["base"]\n', "{{ workflow.items }}\n")
override = ws.project / "bad.toml"
override.write_text(content, encoding="utf-8")
self._assert_halt(self._cli(ws.project, skill, args=("--overrides", str(override))), ws)
def test_nested_conditions_omit_files_and_do_not_resolve_excluded_expressions(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nchoice = "left"\nenabled = true\n',
'{% if workflow.choice == "left" %}\nLeft {{ rendered("left.md") }}\n'
"{% if workflow.enabled %}\nEnabled\n{% else %}\n"
'{{ config.missing }} {{ workflow.missing }} {{ rendered("missing.md") }}\n{% endif %}\n'
'{% else %}\nRight {{ rendered("right.md") }}\n{% endif %}\n',
**{
"left.md": '{% if workflow.choice == "left" %}\nLeft detail\n{% endif %}\n',
"right.md": '\n{% if workflow.choice != "left" %}\nRight detail\n{% endif %}\n',
},
)
left = rs.render(ws.project, skill)
before = _files(left.parent)
# Standalone tag lines leave no blank lines behind.
self.assertEqual(left.read_text(), f"Left {(left.parent / 'left.md').as_posix()}\nEnabled\n")
self.assertEqual((left.parent / "left.md").read_text(), "Left detail\n")
self.assertFalse((left.parent / "right.md").exists())
right = rs.render(ws.project, skill, assignments=["workflow.choice=right"])
self.assertNotEqual(left, right)
self.assertEqual(right.read_text(), f"Right {(right.parent / 'right.md').as_posix()}\n")
self.assertFalse((right.parent / "left.md").exists())
self.assertTrue((right.parent / "right.md").exists())
self.assertEqual(before, _files(left.parent))
self.assertEqual(left, rs.render(ws.project, skill))
manifest = json.loads((left.parent / "manifest.json").read_text())
self.assertEqual(
manifest["inputs"]["resolved_values"],
{
"customization.workflow.choice": "left",
"customization.workflow.enabled": True,
},
)
self.assertIn("right.md", manifest["inputs"]["source_sha256"])
self.assertNotIn("right.md", manifest["outputs"])
(skill / "right.md").write_text((skill / "right.md").read_text().replace("Right detail", "Changed detail"))
self.assertNotEqual(left, rs.render(ws.project, skill))
def test_typed_scalar_condition_inputs_identify_generations_even_for_identical_output(self):
for default, condition, override in (
("true", "workflow.value == true", "false"),
("1", "workflow.value == 1", "2"),
("1.5", "workflow.value == 1.5", "2.5"),
('"one"', 'workflow.value == "one"', '"two"'),
("2026-09-07", 'workflow.value|string == "2026-09-07"', "2026-09-08"),
):
with self.subTest(default=default):
ws = self._workspace()
skill = self._fixture_skill(
ws,
f"[workflow]\nvalue = {default}\n",
f"{{% if {condition} %}}\nSame\n{{% else %}}\nSame\n{{% endif %}}\n",
)
before = rs.render(ws.project, skill)
after = rs.render(ws.project, skill, assignments=[f"workflow.value={override}"])
self.assertEqual(before.read_bytes(), after.read_bytes())
self.assertNotEqual(before, after)
self.assertEqual(after, rs.render(ws.project, skill, assignments=[f"workflow.value={override}"]))
def test_loops_iterate_list_values_and_direct_insertion_keeps_markdown_forms(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nfacts = ["one", "two"]\n[[workflow.layers]]\nid = "a"\nname = "A"\n'
'instruction = "Read {skill-root}/a.md"\n[[workflow.layers]]\nid = "b"\ninstruction = ""\n',
"{% for fact in workflow.facts %}\n* {{ fact }}\n{% endfor %}\n{{ workflow.facts }}\n"
"{% for layer in workflow.layers if layer.instruction %}\n{{ layer.id }}: {{ layer.instruction }}\n"
"{% endfor %}\n{{ workflow.layers }}\n",
)
entry = rs.render(ws.project, skill)
snap = entry.parent
self.assertEqual(
entry.read_text(),
f"* one\n* two\n- one\n- two\na: Read {snap.as_posix()}/a.md\n#### A (`a`)\n\nRead {snap.as_posix()}/a.md\n",
)
manifest = json.loads((snap / "manifest.json").read_text())
self.assertEqual(
manifest["inputs"]["resolved_values"],
{
"customization.workflow.facts": ["one", "two"],
"customization.workflow.layers": [
{"id": "a", "name": "A", "instruction": "Read {skill-root}/a.md"},
{"id": "b", "name": "b", "instruction": ""},
],
},
)
def test_template_errors_report_source_location_and_halt(self):
templates = (
'{% if workflow.value == "one" %}\ntext\n',
"{% else %}\n",
"{% endif %}\n",
"{% if %}\n{% endif %}\n",
"{{ workflow.value\n",
"{{ workflow.unknown }}\n",
"{{ nothing }}\n",
"{{ workflow }}\n",
"{{ config }}\n",
"{{ config.nothing }}\n",
"{{ config.core.nothing }}\n",
"{{ workflow.items.missing }}\n",
"{% for item in workflow.value %}{{ item }}{% endfor %}\n",
"{% for item in workflow.count %}{{ item }}{% endfor %}\n",
'{{ rendered("missing.md") }}\n',
"{{ rendered(workflow.items) }}\n",
'{{ halt("rejected") }}\n',
)
for template in templates:
with self.subTest(template=template):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nvalue = "one"\ncount = 1\nitems = []\n', f"ok\n{template}")
result = self._cli(ws.project, skill)
self._assert_halt(result, ws)
self.assertRegex(result.stdout, r"^HALT: workflow\.md:\d+: ")
ws = self._workspace()
skill = self._fixture_skill(
ws, '[workflow]\nvalue = "one"\n', "entry\n", **{"detail.md": "\n\n{{ nothing }}\n"}
)
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:3: 'nothing' is undefined$"):
rs.render(ws.project, skill)
(skill / "detail.md").write_text("{{ workflow.missing }}\n", encoding="utf-8")
with self.assertRaisesRegex(
rs.RenderError, r"^detail\.md:1: missing customization parameter `workflow\.missing`$"
):
rs.render(ws.project, skill)
guard = '{% if workflow.value not in ("two", "three") %}{{ halt("value must be two or three, not " ~ workflow.value) }}{% endif %}\n'
(skill / "detail.md").write_text(guard + "kept\n", encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:1: value must be two or three, not one$"):
rs.render(ws.project, skill)
self.assertEqual(
_files(rs.render(ws.project, skill, assignments=["workflow.value=two"]).parent)["detail.md"], b"kept\n"
)
def test_excluded_entry_and_surviving_reference_to_excluded_file_halt(self):
for workflow in (
"{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n",
'Read {{ rendered("detail.md") }}\n',
):
with self.subTest(workflow=workflow):
ws = self._workspace()
skill = self._fixture_skill(
ws,
"[workflow]\nenabled = true\n",
workflow,
**{"detail.md": "{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n"},
)
self._assert_halt(self._cli(ws.project, skill), ws)
def test_raw_blocks_keep_agent_placeholders_verbatim(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
"[workflow]\n",
"epic: {% raw %}{{epic_number}}{% endraw +%}\nnext {% raw %}{{prev}}{% endraw %} {{ '{%' }} done\n",
)
self.assertEqual(rs.render(ws.project, skill).read_text(), "epic: {{epic_number}}\nnext {{prev}} {% done\n")
def test_invocation_prose_keeps_template_syntax_opaque(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = ""\n', "{{ workflow.message }}\n")
literal = "{% if workflow.missing %}\n{{ workflow.missing }} {{ config.missing }} {# note #}\n{% endif %}"
literal += '\n{{ rendered("missing.md") }} {skill-root}/detail.md'
entry = rs.render(ws.project, skill, assignments=[f"workflow.message={literal}"])
self.assertEqual(entry.read_text(), literal.replace("{skill-root}", entry.parent.as_posix()) + "\n")
def test_jinja2_version_is_part_of_the_generation_identity(self):
ws = self._workspace()
skill = self._fixture_skill(ws, "[workflow]\n", "stable\n")
original = rs.render(ws.project, skill)
manifest = json.loads((original.parent / "manifest.json").read_text())
self.assertEqual(manifest["inputs"]["jinja2_version"], rs.jinja2.__version__)
with mock.patch.object(rs.jinja2, "__version__", "0.0.0"):
changed = rs.render(ws.project, skill)
self.assertNotEqual(changed, original)
self.assertEqual(changed.read_bytes(), original.read_bytes())
self.assertTrue(original.exists())
def test_shipped_skills_publish_root_bound_snapshots(self):
for name in SHIPPED_SKILLS:
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill, assignments=["workflow.review=thorough"])
snap = self._assert_rendered(workflow, ws.project, name)
self.assertIn("{plan_file}", _markdown(snap))
hunter = snap / "review-prompts" / "edge-case-hunter.md"
self.assertTrue(hunter.is_file())
self.assertIn(hunter.as_posix(), _markdown(snap))
def test_rendered_skills_publish_snapshots_without_skill_root(self):
for name in RENDERED_SKILLS:
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill)
self._assert_rendered(workflow, ws.project, name)
def test_build_skills_render_the_active_initiative_branch(self):
for name in ("bmad-build", "bmad-build-auto"):
with self.subTest(name):
ws = self._workspace()
(ws.bmad / "custom" / "config.user.toml").write_text(
'[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8"
)
skill = self._skill(ws, name)
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, name)
markdown = _markdown(snap)
folder = (ws.project.resolve() / "_bmad-output" / "initiative-checkout").as_posix()
self.assertIn(f"{folder}/plan-{{slug}}.md", markdown)
self.assertNotIn("No initiative is active", markdown)
def test_build_skills_render_each_pinned_route(self):
for name in ("bmad-build", "bmad-build-auto"):
for route in ("oneshot", "full"):
with self.subTest(name=name, route=route):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill, assignments=[f"workflow.route={route}"])
self._assert_rendered(workflow, ws.project, name)
def test_build_skills_default_to_quick_review(self):
for name in ("bmad-build", "bmad-build-auto"):
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
snap = rs.render(ws.project, skill).parent
markdown = _markdown(snap)
self.assertIn("Write `review: 'quick'` and `review_source: 'pinned'`", markdown)
self.assertIn("Quick (`quick`)", markdown)
self.assertNotIn((snap / "review-prompts" / "edge-case-hunter.md").as_posix(), markdown)
def test_skill_root_binds_bundled_scripts_to_the_installed_skill(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-retrospective")
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, "bmad-retrospective")
markdown = _markdown(snap)
self.assertIn((skill / "scripts" / "git_evidence.py").as_posix(), markdown)
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["inputs"]["skill_root"], str(skill.resolve()))
elsewhere = _copy_skill(ws.outer / "elsewhere" / "bmad-retrospective", "bmad-retrospective")
other = rs.render(ws.project, elsewhere)
self.assertNotEqual(other.parent, snap)
self.assertIn((elsewhere / "scripts" / "git_evidence.py").as_posix(), _markdown(other.parent))
def test_cli_from_nested_cwd_dispatches_one_absolute_workflow(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = self._entry(self._cli(ws.project, skill, cwd=ws.project / "nested" / "cwd"))
self._assert_rendered(workflow, ws.project, "bmad-build")
self.assertFalse((ws.bmad / "scripts" / "__pycache__").exists())
self.assertFalse((skill / "__pycache__").exists())
def test_identical_input_and_unreferenced_config_reuse_bytes(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
first = rs.render(ws.project, skill)
first_files = _files(first.parent)
self.assertEqual(rs.render(ws.project, skill), first)
with (ws.bmad / "config.toml").open("a", encoding="utf-8") as handle:
handle.write('\nunreferenced_value = "ignored"\n')
self.assertEqual(rs.render(ws.project, skill), first)
current = _files(first.parent)
for name, content in first_files.items():
self.assertEqual(current[name], content, name)
def test_referenced_config_and_source_changes_publish_new_generations(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build-auto")
before = rs.render(ws.project, skill)
before_files = _files(before.parent)
(ws.bmad / "custom" / "config.toml").write_text(
'[core]\noutput_folder = "{project-root}/impl-v2"\n',
encoding="utf-8",
)
after_config = rs.render(ws.project, skill)
self.assertNotEqual(after_config, before)
self.assertIn("/impl-v2/", after_config.read_text(encoding="utf-8"))
self.assertTrue(before.exists())
(skill / "plan-template.md").write_text(
(skill / "plan-template.md").read_text(encoding="utf-8") + "\n<!-- effective change -->\n",
encoding="utf-8",
)
after_source = rs.render(ws.project, skill)
self.assertNotEqual(after_source, after_config)
current = _files(before.parent)
for name, content in before_files.items():
self.assertEqual(current[name], content, name)
def test_initiative_folder_follows_the_active_initiative_where_a_template_reaches_it(self):
ws = self._workspace()
skill = self._fixture_skill(ws, "[workflow]\n", "{{ initiative_folder }}\n")
user_config = ws.bmad / "custom" / "config.user.toml"
output_folder = (ws.project.resolve() / "_bmad-output").as_posix()
unset = rs.render(ws.project, skill)
self.assertEqual(unset.read_text(encoding="utf-8"), f"{output_folder}\n")
user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8")
active = rs.render(ws.project, skill)
self.assertNotEqual(active, unset)
self.assertEqual(active.read_text(encoding="utf-8"), f"{output_folder}/initiative-checkout\n")
for value, message in (("42", "must be a string"), ('""', "must not be empty")):
with self.subTest(value=value):
user_config.write_text(f"[core]\nactive_initiative = {value}\n", encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, f"config.core.active_initiative {message}"):
rs.render(ws.project, skill)
(skill / "workflow.md").write_text("plain\n", encoding="utf-8")
user_config.unlink()
unreferenced = rs.render(ws.project, skill)
user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8")
self.assertEqual(rs.render(ws.project, skill), unreferenced)
def test_shared_runtime_keeps_distinct_root_bound_snapshots(self):
first = self._workspace()
skill = self._skill(first, "bmad-build")
second = self._workspace(name="other", shared_bmad=first.bmad)
one = rs.render(first.project, skill)
two = rs.render(second.project, skill)
self.assertNotEqual(one, two)
self.assertIn(first.project.resolve().as_posix(), one.read_text(encoding="utf-8"))
self.assertIn(second.project.resolve().as_posix(), two.read_text(encoding="utf-8"))
def test_concurrent_cli_renderers_reuse_one_complete_generation(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: self._cli(ws.project, skill), range(2)))
entries = [self._entry(result) for result in results]
self.assertEqual(entries[0], entries[1])
self.assertTrue((entries[0].parent / "manifest.json").is_file())
def test_malformed_config_and_customization_halt_without_traceback(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / "config.toml").write_text("[core\nbad", encoding="utf-8")
result = self._cli(ws.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertNotIn(DISPATCH_PREFIX, result.stdout)
self.assertNotIn("Traceback", result.stdout + result.stderr)
(ws.bmad / "custom" / "config.toml").unlink()
(ws.bmad / "custom" / f"{skill.name}.toml").write_text("[workflow\nbad", encoding="utf-8")
result = self._cli(ws.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertIn("failed to parse", result.stdout)
def test_missing_wrong_type_and_non_string_layer_id_halt(self):
template = _team_config(Path("project"))
missing = template.replace(
'output_folder = "{project-root}/_bmad-output"\n',
"",
)
ws = self._workspace(config=missing)
skill = self._skill(ws, "bmad-build")
result = self._cli(ws.project, skill)
self.assertIn("missing config value", result.stdout)
wrong = template.replace(
'output_folder = "{project-root}/_bmad-output"',
"output_folder = 42",
)
ws = self._workspace(config=wrong)
skill = self._skill(ws, "bmad-build")
result = self._cli(ws.project, skill)
self.assertIn("must be a string", result.stdout)
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(
'[[workflow.thorough_lenses]]\nid = 42\nname = "bad"\ninstruction = "bad"\n',
encoding="utf-8",
)
result = self._cli(ws.project, skill)
self.assertIn("identifier `id` must be a string", result.stdout)
def test_customization_prose_is_not_rescanned_as_source_tokens(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
literal = '{{ rendered("step-04-review.md") }}'
compile_literal = "{{ workflow.implementation_handoff }}"
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
f"[workflow]\non_complete = 'Preserve {literal} and {compile_literal} as prose'\n",
encoding="utf-8",
)
markdown = _markdown(rs.render(ws.project, skill).parent)
self.assertIn(literal, markdown)
self.assertIn(compile_literal, markdown)
def test_review_layer_override_guard_and_empty_layer_halt(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(
"\n".join(
[
"[workflow]",
'review = "thorough"',
"[[workflow.thorough_lenses]]",
'id = "blind-hunter"',
'name = "Replacement"',
'instruction = "Run replacement review."',
'when = "the replacement condition holds"',
"",
]
),
encoding="utf-8",
)
review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8")
self.assertIn("Replacement (`blind-hunter`)", review)
self.assertIn("Run only when: the replacement condition holds", review)
self.assertIn("Run replacement review.", review)
defaults = tomllib.loads((skill / "customize.toml").read_text(encoding="utf-8"))
disabled = '[workflow]\nreview = "thorough"\n' + "\n".join(
f'[[workflow.thorough_lenses]]\nid = "{layer["id"]}"\nname = "disabled"\ninstruction = ""\n'
for layer in defaults["workflow"]["thorough_lenses"]
)
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(disabled, encoding="utf-8")
review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8")
self.assertIn("No active review layers. HALT", review)
def test_non_empty_open_plan_override_reaches_both_terminal_routes(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
'[workflow]\nopen_plan = "OPEN-PLAN-SENTINEL {project-root} {plan_file}"\n',
encoding="utf-8",
)
snap = rs.render(ws.project, skill).parent
for name in ("step-05-present.md", "step-oneshot.md"):
rendered = (snap / name).read_text(encoding="utf-8")
self.assertIn("OPEN-PLAN-SENTINEL {project-root} {plan_file}", rendered)
def test_installed_renderer_identity_change_publishes_a_new_generation(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
original = self._entry(self._cli(ws.project, skill))
with (ws.bmad / "scripts" / "render_skill.py").open("a", encoding="utf-8") as handle:
handle.write("\n# renderer identity change\n")
changed = self._entry(self._cli(ws.project, skill))
self.assertNotEqual(changed, original)
self.assertTrue(original.exists())
def test_convention_only_skill_renders_without_customization(self):
ws = self._workspace()
skill = ws.outer / "skills" / "plain-workflow"
skill.mkdir(parents=True)
(skill / "workflow.md").write_text('Read `{{ rendered("step.md") }}`.\n', encoding="utf-8")
(skill / "step.md").write_text("No rendered values required.\n", encoding="utf-8")
workflow = rs.render(ws.project, skill)
self.assertIn(f"{os.sep}render{os.sep}plain-workflow{os.sep}", str(workflow))
self.assertTrue((workflow.parent / "step.md").is_file())
self.assertIn((workflow.parent / "step.md").as_posix(), workflow.read_text(encoding="utf-8"))
def test_ambiguous_shorthand_and_source_symlink_escape_halt(self):
config = _team_config(Path("project")) + '\n[modules.bmm]\noutput_folder = "{project-root}/dup"\n'
ws = self._workspace(config=config)
skill = self._skill(ws, "bmad-build")
result = self._cli(ws.project, skill)
self.assertIn("ambiguous config value", result.stdout)
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
outside = ws.outer / "outside.md"
outside.write_text("outside\n", encoding="utf-8")
(skill / "workflow.md").unlink()
(skill / "workflow.md").symlink_to(outside)
result = self._cli(ws.project, skill)
self.assertIn("escapes skill directory", result.stdout)
def test_long_project_basename_is_bounded_in_the_snapshot_namespace(self):
ws = self._workspace(name="project-" + ("x" * 220))
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
self.assertLessEqual(len(workflow.parent.parent.name), 93)
def test_snapshot_paths_stay_opaque_when_the_project_name_looks_like_tokens(self):
ws = self._workspace(name="{{ workflow.on_complete }}-{{ config.output_folder }}")
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
text = workflow.read_text(encoding="utf-8")
match = re.search(r"`([^`]*step-01-clarify-and-route\.md)`", text)
self.assertIsNotNone(match, text)
self.assertTrue(match.group(1).startswith(ws.project.resolve().as_posix()))
self.assertTrue(Path(match.group(1)).is_file())
def test_publication_failure_does_not_dispatch_or_alter_another_root(self):
stable = self._workspace()
skill = self._skill(stable, "bmad-build")
original = rs.render(stable.project, skill)
original_files = _files(original.parent)
broken = self._workspace(name="broken", shared_bmad=stable.bmad)
namespace = _namespace_dir(broken.project, skill.name)
namespace.parent.mkdir(parents=True, exist_ok=True)
namespace.write_text("not a directory\n", encoding="utf-8")
result = self._cli(broken.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertNotIn(DISPATCH_PREFIX, result.stdout)
current = _files(original.parent)
for name, content in original_files.items():
self.assertEqual(current[name], content, name)
def test_corrupt_existing_destination_is_never_overwritten(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
workflow.write_text(workflow.read_text(encoding="utf-8") + "corrupt", encoding="utf-8")
result = self._cli(ws.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertIn("hash mismatch", result.stdout)
self.assertTrue(workflow.read_text(encoding="utf-8").endswith("corrupt"))
def test_stray_file_in_generation_is_ignored_and_missing_output_is_named(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
stray = workflow.parent / "Thumbs.db"
stray.write_bytes(b"explorer")
result = self._cli(ws.project, skill)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout, f"{DISPATCH_PREFIX}{workflow}\n")
self.assertEqual(stray.read_bytes(), b"explorer")
workflow.unlink()
result = self._cli(ws.project, skill)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout)
self.assertNotIn("Traceback", result.stdout + result.stderr)
self.assertIn(f"missing rendered files: workflow.md in {workflow.parent}", result.stdout)
self.assertIn("deleting that folder is safe", result.stdout)
self.assertFalse(workflow.exists())
def test_skill_md_command_dispatches_for_every_rendered_skill(self):
for name in RENDERED_SKILLS:
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
text = (skill / "SKILL.md").read_text(encoding="utf-8")
fenced = re.search(r"```bash\n(.*?)```", text, re.S)
self.assertIsNotNone(fenced, f"{name}: SKILL.md ships no bash command")
command = (
fenced.group(1)
.strip()
.replace("{project-root}", str(ws.project))
.replace("{skill-root}", str(skill))
)
self.assertNotIn("{", command)
dispatched = self._entry(
subprocess.run(
command,
cwd=ws.project / "nested" / "cwd",
shell=True,
text=True,
capture_output=True,
check=False,
)
)
self.assertEqual(dispatched.name, "workflow.md")
self.assertTrue(dispatched.is_file())
if __name__ == "__main__":
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py"
class ResolveConfigCliTests(unittest.TestCase):
def test_missing_tomllib_exits_with_actionable_version_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
scripts = Path(temp_dir)
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py")
(scripts / "tomllib.py").write_text(
'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n',
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, str(scripts / SCRIPT.name), "--help"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 3)
self.assertEqual(
result.stderr,
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n",
)
self.assertNotIn("Traceback", result.stderr)
def test_full_and_repeated_key_output_follow_layer_precedence(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
custom = root / "_bmad" / "custom"
custom.mkdir(parents=True)
(root / "_bmad" / "config.toml").write_text('[core]\nname = "base"\nkeep = "yes"\n', encoding="utf-8")
(root / "_bmad" / "config.user.toml").write_text(
'[core]\nname = "base-user"\nstray = "ignored"\n', encoding="utf-8"
)
(custom / "config.toml").write_text('[core]\nname = "team"\n', encoding="utf-8")
(custom / "config.user.toml").write_text('[core]\nname = "user"\n', encoding="utf-8")
full = self._run(root)
self.assertEqual(full.returncode, 0, msg=full.stderr)
# _bmad/config.user.toml is old-installer debris, not a layer:
# its `stray` key never reaches the merge.
self.assertEqual(json.loads(full.stdout)["core"], {"name": "user", "keep": "yes"})
keyed = self._run(root, "--key", "core.name", "--key", "missing")
self.assertEqual(keyed.returncode, 0, msg=keyed.stderr)
self.assertEqual(json.loads(keyed.stdout), {"core.name": "user"})
def test_malformed_present_layer_fails(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
custom = root / "_bmad" / "custom"
custom.mkdir(parents=True)
(root / "_bmad" / "config.toml").write_text("[core]\nvalid = true\n", encoding="utf-8")
(custom / "config.toml").write_text("[broken\n", encoding="utf-8")
result = self._run(root)
self.assertNotEqual(result.returncode, 0)
self.assertIn("failed to parse", result.stderr)
def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
(root / "_bmad").mkdir(parents=True)
(root / "_bmad" / "config.toml").write_text(
'[agents]\nname = "Analyst"\nicon = "📊"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp1252"
result = subprocess.run(
[sys.executable, str(SCRIPT), "--project-root", str(root)],
capture_output=True,
env=env,
check=False,
)
stderr = result.stderr.decode("utf-8", errors="replace")
self.assertEqual(result.returncode, 0, msg=stderr)
output = result.stdout.decode("utf-8")
self.assertIn("📊", output)
resolved = json.loads(output)
self.assertEqual(resolved["agents"]["icon"], "📊")
@staticmethod
def _run(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), "--project-root", str(root), *args],
text=True,
capture_output=True,
check=False,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,175 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py"
class ResolveCustomizationStdoutTests(unittest.TestCase):
def test_missing_tomllib_exits_with_actionable_version_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
scripts = Path(temp_dir)
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py")
(scripts / "tomllib.py").write_text(
'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n',
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, str(scripts / SCRIPT.name), "--help"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 3)
self.assertEqual(
result.stderr,
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n",
)
self.assertNotIn("Traceback", result.stderr)
def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "emoji-agent"
skill_dir.mkdir()
(skill_dir / "customize.toml").write_text(
'[agent]\nname = "Emoji Agent"\nicon = "🧭"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp1252"
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--skill",
str(skill_dir),
"--key",
"agent",
],
capture_output=True,
cwd=temp_dir,
env=env,
check=False,
)
stderr = result.stderr.decode("utf-8", errors="replace")
self.assertEqual(result.returncode, 0, msg=stderr)
output = result.stdout.decode("utf-8")
self.assertIn("🧭", output)
resolved = json.loads(output)
self.assertEqual(resolved["agent"]["icon"], "🧭")
def write(path: Path, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
def facts(*entries: str) -> str:
listed = ", ".join(f'"{entry}"' for entry in entries)
return f"[workflow]\npersistent_facts = [{listed}]\n"
def resolve(skill_dir: Path, cwd: Path, *extra: str):
return subprocess.run(
[sys.executable, str(SCRIPT), "--skill", str(skill_dir), "--key", "workflow", *extra],
text=True,
capture_output=True,
cwd=str(cwd),
check=False,
)
class ProjectRootResolutionTests(unittest.TestCase):
"""Regression cover for #2796 — the project is where the user works, not
where the skill is installed."""
def test_home_installed_skill_reads_project_override_not_home_bmad(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir) / "home"
project = Path(temp_dir) / "project"
skill = home / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
(home / "_bmad" / "custom").mkdir(parents=True)
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
result = resolve(skill, project)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "team override"])
def test_project_installed_skill_still_resolves(self):
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
skill = project / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
result = resolve(skill, project)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "team override"])
def test_walk_prefers_bmad_over_a_nearer_git_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
submodule = project / "vendor" / "sub"
skill = Path(temp_dir) / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
(submodule / ".git").mkdir(parents=True)
result = resolve(skill, submodule)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "team override"])
def test_notes_when_a_rejected_root_holds_the_only_override(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir) / "home"
project = Path(temp_dir) / "project"
skill = home / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override"))
(project / "_bmad" / "custom").mkdir(parents=True)
result = resolve(skill, project)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default"])
self.assertIn("demo-skill", result.stderr)
self.assertIn("--project-root", result.stderr)
def test_explicit_project_root_wins_and_stays_quiet(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir) / "home"
project = Path(temp_dir) / "project"
skill = home / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override"))
(project / "_bmad" / "custom").mkdir(parents=True)
result = resolve(skill, project, "--project-root", str(home))
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "home override"])
self.assertEqual(result.stderr, "")
if __name__ == "__main__":
unittest.main()
+226
View File
@@ -0,0 +1,226 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import roster # noqa: E402
SOURCE = "github:acme/tools/skills"
ROSTER = """
[[members]]
code = "demo-agent-ann"
skill = "demo-agent-ann"
name = "Ann"
icon = "A"
title = "Analyst"
persona = "Asks for evidence."
[[members]]
code = "demo-agent-bob"
skill = "demo-agent-bob"
name = "Bob"
persona = "Builds it."
[[members]]
code = "guest"
name = "Guest"
persona = "Only in groups."
[[groups]]
id = "demo-room"
name = "Demo Room"
members = ["demo-agent-ann", "demo-agent-bob", "guest"]
"""
def write_skill(root: Path, name: str, *, bmod: str = "bmod-demo") -> Path:
folder = root / name
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{SOURCE}"\n', encoding="utf-8")
return folder
def write_module(
root: Path,
skills: tuple[str, ...] = (),
*,
code: str = "demo",
folder: str | None = None,
listed: tuple[str, ...] | None = None,
roster_text: str | None = ROSTER,
) -> Path:
"""Write a module record and the member skill folders in `skills`; `listed` is what the record names."""
record = root / (folder or f"bmod-{code}")
record.mkdir(parents=True)
names = ", ".join(f'"{name}"' for name in (skills if listed is None else listed))
lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"]
if roster_text is not None:
(record / "roster.toml").write_text(roster_text, encoding="utf-8")
(record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
for name in skills:
write_skill(root, name, bmod=record.name)
return record
class RosterTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.project = Path(self.temp.name)
self.skills = self.project / ".claude" / "skills"
def test_only_members_whose_skill_is_installed_are_agents(self):
write_module(self.skills, ("demo-agent-ann",), listed=("demo-agent-ann", "demo-agent-bob"))
(self.skills / "demo-agent-ann" / "customize.toml").write_text('[agent]\nname = "Ann"\n', encoding="utf-8")
report = roster.collect([self.skills])
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"})
self.assertNotIn("installed", report["members"]["guest"])
def test_an_absent_agent_keeps_its_persona_and_names_its_install_command(self):
write_module(self.skills, ("demo-workflow",))
bob = roster.collect([self.skills])["members"]["demo-agent-bob"]
self.assertFalse(bob["installed"])
self.assertEqual(bob["persona"], "Builds it.")
self.assertEqual(bob["install"], "npx skills add acme/tools --skill demo-agent-bob")
def test_an_installed_agent_answers_to_its_customized_name(self):
write_module(self.skills, ("demo-agent-ann",))
(self.skills / "demo-agent-ann" / "customize.toml").write_text(
'[agent]\nname = "Ann"\ntitle = "Analyst"\n', encoding="utf-8"
)
custom = self.project / "_bmad" / "custom"
custom.mkdir(parents=True)
(custom / "demo-agent-ann.toml").write_text('[agent]\nname = "Annika"\n', encoding="utf-8")
agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"]
self.assertEqual((agent["name"], agent["title"], agent["persona"]), ("Annika", "Analyst", "Asks for evidence."))
def test_a_roster_is_read_from_the_record_and_lists_the_installed_member_skills(self):
write_module(self.skills, ("demo-one", "demo-two"), listed=("demo-one", "demo-two", "demo-absent"))
report = roster.collect([self.skills])
self.assertEqual(
report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": ["demo-one", "demo-two"]}]
)
self.assertEqual([group["id"] for group in report["groups"]], ["demo-room"])
self.assertEqual(report["problems"], [])
def test_a_module_with_no_skills_still_offers_its_roster(self):
write_module(self.skills)
report = roster.collect([self.skills])
self.assertEqual(report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": []}])
self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"})
self.assertEqual((report["agents"], report["problems"]), ({}, []))
def test_a_single_skill_module_is_its_own_member(self):
folder = self.skills / "demo-agent-ann"
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(
f'[bmod]\ncode = "solo"\nversion = "1.0.0"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
encoding="utf-8",
)
(folder / "roster.toml").write_text(ROSTER, encoding="utf-8")
report = roster.collect([self.skills])
self.assertEqual(report["rosters"], [{"module": "solo", "path": "roster.toml", "skills": ["demo-agent-ann"]}])
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
self.assertEqual(report["problems"], [])
def test_a_record_is_found_whatever_its_folder_is_called(self):
write_module(self.skills, ("demo-agent-ann",), folder="demo-record")
report = roster.collect([self.skills])
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
self.assertEqual(report["problems"], [])
def test_a_second_record_for_a_code_is_a_problem_and_the_first_wins(self):
write_module(self.skills, ("demo-one",), folder="a-record")
write_module(self.skills, ("demo-two",), folder="b-record", roster_text='[[members]]\ncode = "late"\n')
report = roster.collect([self.skills])
self.assertNotIn("late", report["members"])
self.assertEqual([found["skills"] for found in report["rosters"]], [["demo-one"]])
self.assertEqual(
[(problem["kind"], problem["skill"]) for problem in report["problems"]], [("module", "b-record")]
)
def test_a_member_skill_whose_record_is_absent_is_a_problem(self):
write_skill(self.skills, "demo-agent-ann")
report = roster.collect([self.skills])
self.assertEqual((report["agents"], report["members"], report["rosters"]), ({}, {}, []))
(problem,) = report["problems"]
self.assertEqual(
(problem["kind"], problem["bmod"], problem["skills"]), ("module", "bmod-demo", ["demo-agent-ann"])
)
self.assertEqual(problem["install"], "npx skills add acme/tools --skill bmod-demo")
def test_a_second_module_cannot_redefine_a_member_or_group(self):
write_module(self.skills, ("demo-one",))
write_module(self.skills, ("other-one",), code="other")
report = roster.collect([self.skills])
self.assertEqual(report["members"]["guest"]["module"], "demo")
self.assertEqual(
sorted(problem["kind"] for problem in report["problems"]), ["group", "member", "member", "member"]
)
def test_central_config_agents_are_laid_over_the_scan(self):
write_module(self.skills, ("demo-workflow",))
(self.project / "_bmad").mkdir()
(self.project / "_bmad" / "config.toml").write_text(
'[agents.my-agent]\nname = "Mine"\ndescription = "From before rosters."\n', encoding="utf-8"
)
agent = roster.collect([self.skills], self.project)["agents"]["my-agent"]
self.assertEqual((agent["name"], agent["persona"], agent["source"]), ("Mine", "From before rosters.", "config"))
def test_a_recorded_agent_whose_skill_was_removed_stays_out_of_the_default_room(self):
write_module(self.skills, ("demo-agent-ann",))
(self.project / "_bmad").mkdir()
(self.project / "_bmad" / "config.toml").write_text(
'[agents.demo-agent-ann]\nteam = "crew"\n\n[agents.demo-agent-bob]\nname = "Bob"\n', encoding="utf-8"
)
agents = roster.collect([self.skills], self.project)["agents"]
self.assertEqual(sorted(agents), ["demo-agent-ann"])
self.assertEqual(agents["demo-agent-ann"]["team"], "crew")
def test_config_does_not_replace_a_roster_agents_name_or_module(self):
write_module(self.skills, ("demo-agent-ann",))
(self.project / "_bmad").mkdir()
(self.project / "_bmad" / "config.toml").write_text(
'[agents.demo-agent-ann]\nmodule = "old"\nname = "Recorded"\nteam = "crew"\n', encoding="utf-8"
)
agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"]
self.assertEqual((agent["module"], agent["team"]), ("demo", "crew"))
self.assertNotEqual(agent["name"], "Recorded")
def test_a_roster_key_in_the_record_is_ignored(self):
record = write_module(self.skills, ("demo-one",), roster_text=None)
text = (record / "bmod.toml").read_text(encoding="utf-8")
(record / "bmod.toml").write_text(text + 'roster = ["other.toml"]\n', encoding="utf-8")
(record / "other.toml").write_text(ROSTER, encoding="utf-8")
report = roster.collect([self.skills])
self.assertEqual((report["members"], report["rosters"], report["problems"]), ({}, [], []))
def test_a_roster_that_links_outside_the_record_folder_is_refused(self):
record = write_module(self.skills, ("demo-one",))
(self.skills / "demo-one" / "elsewhere.toml").write_text(ROSTER, encoding="utf-8")
(record / "roster.toml").unlink()
try:
(record / "roster.toml").symlink_to(self.skills / "demo-one" / "elsewhere.toml")
except OSError:
self.skipTest("symlinks are not available")
report = roster.collect([self.skills])
self.assertEqual(report["members"], {})
self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster"])
def test_members_or_groups_that_are_not_lists_are_problems(self):
write_module(self.skills, ("demo-one",), roster_text='members = "ann"\n\n[groups]\nid = "room"\n')
report = roster.collect([self.skills])
self.assertEqual((report["members"], report["groups"]), ({}, []))
self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster", "roster"])
def test_a_module_without_a_roster_offers_nothing(self):
write_module(self.skills, ("demo-one",), roster_text=None)
report = roster.collect([self.skills])
self.assertEqual((report["agents"], report["groups"], report["problems"]), ({}, [], []))
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
import io
import sys
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import setup_check # noqa: E402
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
REQUIRES_BMAD = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n'
def write_bmod(folder: Path, *, code: str, version: str, skills: list[str], extra: str = "") -> None:
folder.mkdir(parents=True, exist_ok=True)
listed = ", ".join(f'"{name}"' for name in skills)
(folder / "bmod.toml").write_text(
f'[bmod]\ncode = "{code}"\nversion = "{version}"\nupdate_source = "{SOURCE}"\nskills = [{listed}]\n{extra}',
encoding="utf-8",
)
def write_skill(folder: Path, *, bmod: str, source: str = SOURCE, extra: str = "") -> None:
folder.mkdir(parents=True, exist_ok=True)
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{source}"\n{extra}', encoding="utf-8")
class OwedSetupTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.project = Path(self.temp.name) / "project"
self.skills = self.project / ".claude" / "skills"
(self.project / "_bmad").mkdir(parents=True)
(self.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8")
def hub(self, version: str) -> None:
write_bmod(self.skills / "bmod-core-tools", code="core-tools", version=version, skills=["bmad"])
write_skill(self.skills / "bmad", bmod="bmod-core-tools")
def skill(self, *, module_extra: str = "", skill_extra: str = "") -> Path:
write_bmod(self.skills / "bmod-demo", code="demo", version="1.0.0", skills=["demo-skill"], extra=module_extra)
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo", extra=skill_extra)
return folder
def test_nothing_is_owed_when_requirements_are_met(self):
self.hub("6.13.0")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_skill_without_a_bmod_file_owes_nothing(self):
folder = self.skills / "plain-skill"
folder.mkdir(parents=True)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_module_record_owes_nothing(self):
self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(self.skills / "bmod-demo", self.project), [])
def test_a_missing_module_record_is_named_with_its_install_command(self):
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo", source="github:acme/demo/skills")
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`bmod-demo`", note)
self.assertIn("`npx skills add acme/demo --skill bmod-demo`", note)
def test_a_skill_without_its_record_still_reports_its_own_requirements(self):
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo", extra='required_skills = ["other-skill"]\n')
record, requirement = setup_check.owed(folder, self.project)
self.assertIn("`bmod-demo`", record)
self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill other-skill`", requirement)
def test_an_older_required_skill_is_reported_with_both_versions(self):
self.hub("6.12.0")
folder = self.skill(module_extra=REQUIRES_BMAD)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`bmad` 6.13.0 or later", note)
self.assertIn("6.12.0 is installed", note)
def test_the_next_build_of_the_required_version_meets_it(self):
self.hub("6.13.0-next")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_the_next_build_of_an_earlier_version_does_not(self):
self.hub("6.12.0-next")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
def test_a_required_skill_without_its_module_record_is_not_called_outdated(self):
write_skill(self.skills / "bmad", bmod="bmod-core-tools")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_required_skill_with_no_bmod_file_is_reported_with_an_unreadable_version(self):
(self.skills / "bmad").mkdir(parents=True)
folder = self.skill(module_extra=REQUIRES_BMAD)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("version cannot be read", note)
self.assertIn("npx skills update", note)
def test_a_requirement_may_carry_no_version(self):
(self.skills / "other-skill").mkdir(parents=True)
folder = self.skill(skill_extra='required_skills = ["other-skill"]\n')
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_the_modules_requirements_and_the_skills_own_are_both_checked(self):
folder = self.skill(
module_extra='required_skills = ["module-need"]\n',
skill_extra='required_skills = ["skill-need", "module-need"]\n',
)
notes = setup_check.owed(folder, self.project)
self.assertEqual(len(notes), 2)
self.assertIn("`module-need`", notes[0])
self.assertIn("`skill-need`", notes[1])
def test_a_skill_required_by_both_lists_is_held_to_the_higher_minimum(self):
self.hub("6.13.0")
higher = f'required_skills = [{{ skill = "bmad", version = "6.14.0", source = "{SOURCE}" }}]\n'
for module_extra, skill_extra in (
(REQUIRES_BMAD, higher),
(higher, REQUIRES_BMAD),
('required_skills = ["bmad"]\n', higher),
):
with self.subTest(module=module_extra):
folder = self.skill(module_extra=module_extra, skill_extra=skill_extra)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`bmad` 6.14.0 or later", note)
def test_a_requirement_that_names_the_skill_itself_is_skipped(self):
folder = self.skill(module_extra='required_skills = ["demo-skill"]\n')
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_missing_required_skill_names_its_install_command(self):
folder = self.skill(
skill_extra='required_skills = [{ skill = "other-skill", version = "2.0.0", source = "github:acme/tools/skills" }]\n'
)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`npx skills add acme/tools --skill other-skill`", note)
def test_a_missing_required_skill_defaults_to_the_declaring_files_source(self):
folder = self.skill(skill_extra='required_skills = ["bmad"]\n')
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill bmad`", note)
def test_recommended_skills_are_never_reported(self):
folder = self.skill(
module_extra='recommended_skills = ["module-extra"]\n',
skill_extra='recommended_skills = ["other-skill"]\n',
)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_an_unanswered_question_is_reported_and_an_empty_answer_is_not(self):
question = (
'\n[[bmod.config_questions]]\nkey = "active_initiative"\nprompt = "Active initiative?"\ndefault = ""\n'
)
folder = self.skill(module_extra=question)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("active_initiative", note)
self.assertIn("`bmad setup`", note)
(self.project / "_bmad" / "config.toml").write_text(
'[core]\n\n[modules.demo]\nactive_initiative = ""\n', encoding="utf-8"
)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_user_question_is_answered_by_the_users_own_file(self):
question = (
'\n[[bmod.config_questions]]\nkey = "note_style"\nscope = "user"\nprompt = "Style?"\ndefault = "bullets"\n'
)
folder = self.skill(module_extra=question)
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
custom = self.project / "_bmad" / "custom"
custom.mkdir()
(custom / "config.user.toml").write_text('[modules.demo]\nnote_style = "prose"\n', encoding="utf-8")
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_missing_or_stale_placed_script_is_reported(self):
folder = self.skill(skill_extra='scripts = ["scripts/tool.py"]\n')
(folder / "scripts").mkdir()
(folder / "scripts" / "tool.py").write_bytes(b"new\n")
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`_bmad/demo/scripts/`", note)
self.assertIn("`bmad setup`", note)
placed = self.project / "_bmad" / "demo" / "scripts" / "tool.py"
placed.parent.mkdir(parents=True)
placed.write_bytes(b"old\n")
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
placed.write_bytes(b"new\n")
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_single_skill_module_reads_both_tables_from_its_own_file(self):
folder = self.skills / "release-notes"
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(
'[bmod]\ncode = "notes"\nversion = "1.0.0"\nupdate_source = "github:acme/release-notes"\n'
'required_skills = ["other-skill"]\n\n'
'[[bmod.config_questions]]\nkey = "path"\nprompt = "Path?"\ndefault = "x"\n\n[skill]\n',
encoding="utf-8",
)
requirement, question = setup_check.owed(folder, self.project)
self.assertIn("`npx skills add acme/release-notes --skill other-skill`", requirement)
self.assertIn("module `notes`", question)
def test_report_writes_each_note_as_an_instruction(self):
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo")
stream = io.StringIO()
with redirect_stderr(stream):
setup_check.report(folder, self.project)
self.assertTrue(stream.getvalue().startswith("setup: before continuing, tell the user that `demo-skill` "))
def test_report_stays_silent_when_a_bmod_file_cannot_be_parsed(self):
folder = self.skills / "broken-skill"
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text("not toml [", encoding="utf-8")
stream = io.StringIO()
with redirect_stderr(stream):
setup_check.report(folder, self.project)
self.assertEqual(stream.getvalue(), "")
write_skill(folder, bmod="bmod-demo")
(self.skills / "bmod-demo").mkdir()
(self.skills / "bmod-demo" / "bmod.toml").write_text("not toml [", encoding="utf-8")
with redirect_stderr(stream):
setup_check.report(folder, self.project)
self.assertEqual(stream.getvalue(), "")
if __name__ == "__main__":
unittest.main()
+357
View File
@@ -0,0 +1,357 @@
import contextlib
import importlib.util
import io
import subprocess
import sys
import tempfile
import tomllib
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1]
STAMPER = SCRIPTS / "stamp_release.py"
SETUP_PY = SCRIPTS / "setup.py"
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
RECORD = (
"[bmod]\n"
'code = "{code}"\n'
'version = "{version}"\n'
f'update_source = "{SOURCE}"\n'
"skills = [{skills}]\n"
'pre_install_message = ""\n'
'post_install_message = ""\n'
"\n"
"[[bmod.knowledge]]\n"
'path = "extra.md"\n'
)
SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n'
MEMBERS = {"method": ("bmad-build", "bmad-spec"), "core-tools": ("bmad", "bmad-flow")}
def load_module(name: str, path: Path):
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
sr = load_module("stamp_release", STAMPER)
setup = load_module("bmad_setup_stamp_contract", SETUP_PY)
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def record_text(code: str, version: str) -> str:
skills = ", ".join(f'"{skill}"' for skill in MEMBERS[code])
return RECORD.format(code=code, version=version, skills=skills)
def make_tree(root: Path, version: str = "6.11.0-next") -> None:
for code, members in MEMBERS.items():
folder = root / "skills" / f"bmod-{code}"
write(folder / "bmod.toml", record_text(code, version))
write(folder / "help" / "help.md", "# help\n")
write(folder / "extra.md", "# extra\n")
write(folder / "SKILL.md", "# record\n")
for skill in members:
write(root / "skills" / skill / "bmod.toml", SKILL.format(bmod=f"bmod-{code}"))
def snapshot(root: Path) -> dict[str, bytes]:
return {path.relative_to(root).as_posix(): path.read_bytes() for path in sorted(root.rglob("*")) if path.is_file()}
def run_stamper(root: Path, version: str) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = sr.run(root, version)
return code, out.getvalue(), err.getvalue()
class StampReleaseTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name).resolve()
self.method = self.root / "skills" / "bmod-method" / "bmod.toml"
def assert_refused(self, version: str, *expected: str) -> None:
before = snapshot(self.root)
code, _, err = run_stamper(self.root, version)
self.assertEqual(code, 1)
for text in expected:
self.assertIn(text, err)
self.assertEqual(snapshot(self.root), before)
def test_happy_path_stamps_every_record_and_no_member_skill(self):
make_tree(self.root)
before = snapshot(self.root)
code, out, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
after = snapshot(self.root)
changed = sorted(name for name in after if after[name] != before[name])
self.assertEqual(changed, ["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"])
self.assertEqual(self.method.read_text(encoding="utf-8"), record_text("method", "1.2.0"))
self.assertIn("Stamped version 1.2.0 into 2 files", out)
self.assertIn("skills/bmod-method/bmod.toml", out)
self.assertIn("skills/bmod-core-tools/bmod.toml", out)
self.assertNotIn("skills/bmad-build/bmod.toml", out)
def test_single_skill_module_is_stamped(self):
make_tree(self.root)
notes = self.root / "skills" / "release-notes" / "bmod.toml"
write(
notes,
f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n'
'pre_install_message = ""\npost_install_message = ""\n\n'
f'[skill]\nrequired_skills = [{{ skill = "x", version = "1.0.0", source = "{SOURCE}" }}]\n',
)
write(notes.parent / "SKILL.md", "# notes\n")
code, out, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
data = tomllib.loads(notes.read_text(encoding="utf-8"))
self.assertEqual(data["bmod"]["version"], "1.2.0")
self.assertEqual(data["skill"]["required_skills"][0]["version"], "1.0.0")
self.assertIn("into 3 files", out)
def test_non_semver_version_touches_nothing(self):
make_tree(self.root)
self.assert_refused("banana", "SemVer")
def test_dev_prerelease_rejected_with_explanation(self):
make_tree(self.root)
self.assert_refused("1.2.0-dev", "-dev", "current")
def test_build_metadata_rejected_with_explanation(self):
make_tree(self.root)
self.assert_refused("1.2.0+hotfix", "build metadata", "'1.2.0'")
def test_build_metadata_on_prerelease_rejected(self):
make_tree(self.root)
self.assert_refused("1.2.0-rc.1+build.5", "build metadata")
def test_orderable_prerelease_is_accepted(self):
make_tree(self.root)
code, _, err = run_stamper(self.root, "6.12.0-next.1")
self.assertEqual(code, 0, err)
self.assertEqual(tomllib.loads(self.method.read_text(encoding="utf-8"))["bmod"]["version"], "6.12.0-next.1")
def test_repository_check_failure_names_the_file_and_touches_nothing(self):
make_tree(self.root)
write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n")
self.assert_refused("1.2.0", "skills/bmad-orphan: missing bmod.toml")
def test_record_missing_version_key_names_file_and_touches_nothing(self):
make_tree(self.root)
write(self.method, record_text("method", "x").replace('version = "x"\n', ""))
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "'bmod.version'")
def test_every_problem_is_reported_in_one_run(self):
make_tree(self.root)
write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n")
(self.root / "skills" / "bmod-method" / "help" / "help.md").unlink()
self.assert_refused("1.2.0", "skills/bmad-orphan", "skills/bmod-method/help/help.md")
def test_empty_skills_tree_reports_error(self):
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 1)
self.assertIn("bmod.toml", err)
def test_tree_with_no_module_record_reports_error(self):
write(self.root / "skills" / "plain" / "bmod.toml", '[skill]\nbmod = "bmod-x"\nsource = "github:o/r"\n')
self.assert_refused("1.2.0", "[skill] bmod names 'bmod-x', which is not a module record")
def test_keys_and_tables_the_stamper_does_not_know_are_left_alone(self):
make_tree(self.root)
text = record_text("method", "6.11.0-next").replace(
'code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n'
)
extra = '\n[bmod.builder]\nversion = "9.9.9"\n\n[other]\nversion = "8.8.8"\nnote = "anything"\n'
write(self.method, text + extra)
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
self.assertEqual(
self.method.read_text(encoding="utf-8"),
text.replace('version = "6.11.0-next"', 'version = "1.2.0"') + extra,
)
def test_version_in_a_table_before_bmod_is_left_alone(self):
make_tree(self.root)
text = '[other]\nversion = "8.8.8"\n\n' + record_text("method", "6.11.0-next")
write(self.method, text)
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
data = tomllib.loads(self.method.read_text(encoding="utf-8"))
self.assertEqual((data["other"]["version"], data["bmod"]["version"]), ("8.8.8", "1.2.0"))
def test_requirement_versions_are_left_alone(self):
make_tree(self.root)
line = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n'
text = record_text("method", "6.11.0-next").replace("\n[[bmod.knowledge]]", line + "\n[[bmod.knowledge]]")
write(self.method, text)
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
stamped = self.method.read_text(encoding="utf-8")
self.assertIn(line, stamped)
self.assertIn('version = "1.2.0"\n', stamped)
def test_indentation_comment_and_line_endings_are_preserved(self):
make_tree(self.root)
text = record_text("method", "6.11.0-next").replace(
'version = "6.11.0-next"\n', ' version = "6.11.0-next" # stamped\n'
)
self.method.write_bytes(text.replace("\n", "\r\n").encode("utf-8"))
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
expected = text.replace('"6.11.0-next"', '"1.2.0"').replace("\n", "\r\n")
self.assertEqual(self.method.read_bytes(), expected.encode("utf-8"))
def test_version_the_stamper_cannot_find_as_a_line_touches_nothing(self):
make_tree(self.root)
write(self.method, record_text("method", "x").replace('version = "x"', "version = '6.11.0-next'"))
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one 'version = \"...\"' line")
def test_version_lookalike_inside_a_multi_line_string_touches_nothing(self):
make_tree(self.root)
note = 'note = """\nversion = "x"\n"""\n'
text = record_text("method", "6.11.0-next").replace('code = "method"\n', 'code = "method"\n' + note)
write(self.method, text)
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "line inside [bmod], found 2")
def test_only_version_line_being_inside_a_string_touches_nothing(self):
make_tree(self.root)
note = 'note = """\nversion = "x"\n"""\n'
text = record_text("method", "x").replace('version = "x"\n', "version = '6.11.0-next'\n" + note)
write(self.method, text)
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "would change something other than")
def test_bmod_written_without_a_table_header_touches_nothing(self):
make_tree(self.root)
write(
self.method,
f'bmod = {{ code = "method", version = "6.11.0-next", update_source = "{SOURCE}" }}\n',
)
for skill in MEMBERS["method"]:
(self.root / "skills" / skill / "bmod.toml").unlink()
(self.root / "skills" / skill).rmdir()
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one '[bmod]' table header")
class CheckModeTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name).resolve()
make_tree(self.root)
def check(self, *argv: str) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = sr.main([*argv, "--project-root", str(self.root)])
return code, out.getvalue(), err.getvalue()
def test_check_validates_and_writes_nothing(self):
before = snapshot(self.root)
code, out, err = self.check("--check")
self.assertEqual(code, 0, err)
self.assertIn("bmod files valid:", out)
self.assertEqual(snapshot(self.root), before)
def test_check_fails_on_a_broken_tree(self):
(self.root / "skills" / "bmod-method" / "SKILL.md").unlink()
before = snapshot(self.root)
code, _, err = self.check("--check")
self.assertEqual(code, 1)
self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", err)
self.assertEqual(snapshot(self.root), before)
def test_version_and_check_together_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
sr.main(["1.2.0", "--check"])
self.assertEqual(raised.exception.code, 2)
def test_neither_version_nor_check_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
sr.main([])
self.assertEqual(raised.exception.code, 2)
def test_run_from_the_repository_root_checks_the_current_directory(self):
result = subprocess.run(
[sys.executable, "-B", str(STAMPER), "--check"], cwd=self.root, capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("bmod files valid:", result.stdout)
class StampedContentTests(unittest.TestCase):
def test_only_the_bmod_table_version_changes(self):
original = '[a]\nversion = "1"\n[bmod]\ncode = "x"\nversion = "2"\n[[bmod.knowledge]]\nversion = "3"\n'
stamped = sr.validator.stamp_text(original, "9.9.9")
self.assertEqual(stamped, original.replace('version = "2"', 'version = "9.9.9"'))
def test_two_version_lines_inside_bmod_are_refused(self):
with self.assertRaises(ValueError):
sr.validator.stamp_text('[bmod]\nversion = "1"\nversion = "2"\n', "9.9.9")
class InstallerContractTests(unittest.TestCase):
"""Pin the version rules the stamper adds on top of skills/bmad/scripts/setup.py.
If setup.py's rules drift, these fail instead of shipping a release an
installed module cannot order.
"""
def test_stamper_uses_the_runtime_semver(self):
self.assertEqual(sr.setup.SEMVER.pattern, setup.SEMVER.pattern)
def test_validate_version_accepts_exactly_what_setup_can_distinguish(self):
"""Accept a version only if setup.py can both order it and tell it apart.
Build metadata is orderable but not distinguishing: setup.py drops it,
so `1.2.0+hotfix` compares equal to `1.2.0` and a release stamped that
way is invisible to an installed module. The stamper is stricter than
orderability by exactly that much.
"""
candidates = (
"1.2.0",
"0.0.1",
"6.12.0-next.1",
"1.2.0-rc.1+build.5",
"1.2.0+hotfix",
"banana",
"1.2",
"01.2.0",
"1.2.0-dev",
"1.2.0-DEV",
"1.2.0-dev.1",
"",
)
for version in candidates:
parsed = setup.parse_orderable_semver(version)
distinguishable = parsed is not None and "+" not in version
try:
sr.validate_version(version)
accepted = True
except sr.StampError:
accepted = False
self.assertEqual(accepted, distinguishable, version)
def test_setup_orders_build_metadata_as_equal(self):
"""The premise of the rejection above, pinned against setup.py itself."""
self.assertEqual(
setup.parse_orderable_semver("1.2.0+hotfix"),
setup.parse_orderable_semver("1.2.0"),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,553 @@
import contextlib
import importlib.util
import io
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
VALIDATOR = Path(__file__).resolve().parents[1] / "validate_manifests.py"
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
MESSAGES = 'pre_install_message = ""\npost_install_message = ""\n'
METHOD_RECORD = (
"[bmod]\n"
'code = "method"\n'
'version = "{version}"\n'
f'update_source = "{SOURCE}"\n'
'skills = ["bmad-build", "bmad-spec"]\n'
'required_skills = ["bmad"]\n'
f"{MESSAGES}"
"\n"
"[[bmod.knowledge]]\n"
'path = "delivery-help.md"\n'
'skills = ["bmad-build"]\n'
)
CORE_TOOLS_RECORD = (
"[bmod]\n"
'code = "core-tools"\n'
'version = "{version}"\n'
f'update_source = "{SOURCE}"\n'
'skills = ["bmad", "bmad-flow"]\n'
f"{MESSAGES}"
)
SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n'
ROSTER = (
"[[members]]\n"
'code = "bmad-build"\n'
'skill = "bmad-build"\n'
'name = "Amelia"\n'
"\n"
"[[members]]\n"
'code = "guest"\n'
'name = "Guest"\n'
"\n"
"[[groups]]\n"
'id = "team"\n'
'members = ["bmad-build", "guest"]\n'
)
MEMBERS = {"bmod-method": ("bmad-build", "bmad-spec"), "bmod-core-tools": ("bmad", "bmad-flow")}
def load_module(name: str, path: Path):
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
vm = load_module("validate_manifests", VALIDATOR)
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def make_tree(root: Path, version: str = "6.11.0-next") -> None:
skills = root / "skills"
write(skills / "bmod-method" / "bmod.toml", METHOD_RECORD.format(version=version))
write(skills / "bmod-method" / "help" / "help.md", "# help\n")
write(skills / "bmod-method" / "delivery-help.md", "# delivery\n")
write(skills / "bmod-method" / "roster.toml", ROSTER)
write(skills / "bmod-core-tools" / "bmod.toml", CORE_TOOLS_RECORD.format(version=version))
write(skills / "bmod-core-tools" / "help" / "help.md", "# help\n")
for bmod, members in MEMBERS.items():
write(skills / bmod / "SKILL.md", "# record\n")
for skill in members:
write(skills / skill / "bmod.toml", SKILL.format(bmod=bmod))
write(skills / skill / "SKILL.md", "# skill\n")
class ValidatorCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name).resolve()
self.skills = self.root / "skills"
make_tree(self.root)
def problems(self) -> str:
return "\n".join(vm.check_repo(self.root).problems)
def method_record(self, old: str, new: str) -> None:
path = self.skills / "bmod-method" / "bmod.toml"
text = path.read_text(encoding="utf-8")
self.assertIn(old, text)
write(path, text.replace(old, new))
class CleanTreeTests(ValidatorCase):
def test_clean_tree_has_no_problems_and_reports_its_records(self):
report = vm.check_repo(self.root)
self.assertEqual(report.problems, ())
self.assertEqual(
[path.relative_to(self.root).as_posix() for path in report.records],
["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"],
)
self.assertEqual((report.skills, report.documents), (4, 3))
def test_unknown_keys_and_tables_are_left_alone(self):
self.method_record('code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n')
path = self.skills / "bmod-method" / "bmod.toml"
write(path, path.read_text(encoding="utf-8") + '\n[builder]\nversion = "9.9.9"\n')
write(
self.skills / "bmad-spec" / "bmod.toml",
SKILL.format(bmod="bmod-method") + "later = true\n\n[extra]\nx = 1\n",
)
self.assertEqual(self.problems(), "")
def test_single_skill_module_is_valid_under_any_folder_name(self):
write(
self.skills / "release-notes" / "bmod.toml",
'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "github:acme/notes"\n'
f"{MESSAGES}\n[skill]\n",
)
write(self.skills / "release-notes" / "help" / "help.md", "# help\n")
write(self.skills / "release-notes" / "SKILL.md", "# notes\n")
self.assertEqual(self.problems(), "")
def test_module_with_no_skills_is_valid(self):
write(
self.skills / "bmod-rooms" / "bmod.toml",
f'[bmod]\ncode = "rooms"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}',
)
write(self.skills / "bmod-rooms" / "help" / "help.md", "# help\n")
write(self.skills / "bmod-rooms" / "SKILL.md", "# rooms\n")
self.assertEqual(self.problems(), "")
def test_empty_skills_tree_is_a_problem(self):
empty = self.root / "empty"
empty.mkdir()
self.assertIn("no skills/*/bmod.toml found", "\n".join(vm.check_repo(empty).problems))
def test_main_exit_codes(self):
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = vm.main(["--project-root", str(self.root)])
self.assertEqual(code, 0, err.getvalue())
self.assertIn("4 skills, 2 module records, 3 knowledge documents", out.getvalue())
(self.skills / "bmad-flow" / "bmod.toml").unlink()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = vm.main(["--project-root", str(self.root)])
self.assertEqual(code, 1)
self.assertIn("skills/bmad-flow: missing bmod.toml", err.getvalue())
def test_run_from_outside_the_bmad_scripts_checks_the_current_directory(self):
result = subprocess.run(
[sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("bmod files valid: 4 skills, 2 module records", result.stdout)
(self.skills / "bmad-flow" / "bmod.toml").unlink()
result = subprocess.run(
[sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 1)
self.assertIn("skills/bmad-flow: missing bmod.toml", result.stderr)
class FileRuleTests(ValidatorCase):
def test_skill_folder_without_a_bmod_file(self):
write(self.skills / "bmad-orphan" / "SKILL.md", "# orphan\n")
self.assertIn("skills/bmad-orphan: missing bmod.toml", self.problems())
def test_file_the_runtime_parser_rejects(self):
write(self.skills / "bmad-spec" / "bmod.toml", 'note = "neither table"\n')
problems = self.problems()
self.assertIn("skills/bmad-spec/bmod.toml: the runtime parser rejects this file", problems)
self.assertIn("[bmod] table, a [skill] table, or both", problems)
def test_record_missing_a_required_key(self):
self.method_record('version = "6.11.0-next"\n', "")
problems = self.problems()
self.assertIn("skills/bmod-method/bmod.toml", problems)
self.assertIn("'bmod.version'", problems)
def test_record_missing_an_install_message_key(self):
for key in ("pre_install_message", "post_install_message"):
with self.subTest(key=key):
make_tree(self.root)
self.method_record(f'{key} = ""\n', "")
self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] is missing {key!r}", self.problems())
def test_install_message_that_is_not_a_string(self):
self.method_record('pre_install_message = ""\n', "pre_install_message = 3\n")
problems = self.problems()
self.assertIn("skills/bmod-method/bmod.toml: the runtime parser rejects this file", problems)
self.assertIn("'bmod.pre_install_message' must be a string", problems)
def test_skill_missing_a_required_key(self):
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\n')
problems = self.problems()
self.assertIn("skills/bmad-spec/bmod.toml", problems)
self.assertIn("'skill.source'", problems)
def test_github_source_with_two_parts_is_accepted(self):
self.method_record(f'update_source = "{SOURCE}"', 'update_source = "github:o/r"')
for skill in MEMBERS["bmod-method"]:
write(self.skills / skill / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
self.assertEqual(self.problems(), "")
def test_github_source_with_one_part_is_rejected(self):
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o"\n')
self.assertIn("github source must name owner/repo", self.problems())
class RecordRuleTests(ValidatorCase):
def test_record_folder_not_named_after_its_code(self):
(self.skills / "bmod-method").rename(self.skills / "bmod-bmad-method")
for skill in MEMBERS["bmod-method"]:
write(self.skills / skill / "bmod.toml", SKILL.format(bmod="bmod-bmad-method"))
self.assertIn(
"skills/bmod-bmad-method/bmod.toml: a module record folder is named 'bmod-method'", self.problems()
)
def test_second_record_for_one_code(self):
write(
self.skills / "bmod-zeta" / "bmod.toml",
f'[bmod]\ncode = "method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
)
self.assertIn(
"skills/bmod-zeta/bmod.toml: module code 'method' is already declared by skills/bmod-method/bmod.toml",
self.problems(),
)
def test_codes_differing_only_by_case(self):
write(
self.skills / "upper" / "bmod.toml",
f'[bmod]\ncode = "Method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
)
self.assertIn("module code 'Method' is already declared by", self.problems())
def test_records_with_different_versions(self):
self.method_record('version = "6.11.0-next"', 'version = "6.12.0"')
problems = self.problems()
self.assertIn("every module record carries one version", problems)
self.assertIn("bmod-method has '6.12.0'", problems)
def test_bmod_folder_without_a_bmod_table(self):
write(self.skills / "bmod-x" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
self.assertIn("skills/bmod-x/bmod.toml: a bmod-* folder holds a module record", self.problems())
def test_record_version_that_is_not_semver(self):
self.method_record('version = "6.11.0-next"', 'version = "1.0"')
self.assertIn("skills/bmod-method/bmod.toml: [bmod] invalid version '1.0': must be SemVer", self.problems())
def test_record_version_the_runtime_cannot_order(self):
for version in ("6.11.0-dev", "6.11.0+build.1"):
with self.subTest(version=version):
self.method_record('version = "6.11.0-next"', f'version = "{version}"')
self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] invalid version {version!r}", self.problems())
self.method_record(f'version = "{version}"', 'version = "6.11.0-next"')
def test_record_without_skill_md(self):
(self.skills / "bmod-method" / "SKILL.md").unlink()
self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", self.problems())
class RetiredRuleTests(ValidatorCase):
def retire(self, record: str, lines: str) -> None:
write(self.skills / record / "retired.toml", lines)
def test_retired_names_that_no_longer_ship_are_valid(self):
self.retire(
"bmod-method", 'renamed = [{ from = "bmad-old-build", to = "bmad-build" }]\nremoved = ["bmad-gone"]\n'
)
self.assertEqual(self.problems(), "")
def test_a_retired_name_that_still_ships_is_a_problem(self):
self.retire("bmod-method", 'removed = ["bmad-spec"]\n')
self.assertIn("retires 'bmad-spec', but skills/bmad-spec still ships", self.problems())
def test_a_rename_to_a_skill_the_repository_does_not_ship_is_a_problem(self):
self.retire("bmod-method", 'renamed = [{ from = "bmad-old", to = "bmad-missing" }]\n')
self.assertIn("renames 'bmad-old' to 'bmad-missing', which this repository does not ship", self.problems())
def test_a_name_retired_by_two_records_is_a_problem(self):
self.retire("bmod-method", 'removed = ["bmad-gone"]\n')
self.retire("bmod-core-tools", 'removed = ["bmad-gone"]\n')
self.assertIn("retires 'bmad-gone', which skills/bmod-", self.problems())
def test_two_renames_to_one_skill_are_a_problem(self):
self.retire(
"bmod-method",
'renamed = [{ from = "bmad-a", to = "bmad-build" }, { from = "bmad-b", to = "bmad-build" }]\n',
)
self.assertIn("renames more than one skill to 'bmad-build'", self.problems())
def test_a_retired_file_the_runtime_rejects_is_a_problem(self):
self.retire("bmod-method", 'removed = ["bmad-gone", "bmad-gone"]\n')
self.assertIn("skills/bmod-method/retired.toml: the runtime parser rejects this file", self.problems())
class StampRuleTests(ValidatorCase):
def test_record_the_stamper_cannot_stamp(self):
self.method_record('version = "6.11.0-next"', "version = '6.11.0-next'")
self.assertIn(
"skills/bmod-method/bmod.toml: stamp_release.py cannot stamp this file: expected exactly one "
"'version = \"...\"' line inside [bmod], found 0",
self.problems(),
)
def test_version_lookalike_inside_a_multi_line_string(self):
self.method_record('code = "method"\n', 'code = "method"\nnote = """\nversion = "x"\n"""\n')
self.assertIn("cannot stamp this file", self.problems())
def test_the_check_writes_nothing(self):
path = self.skills / "bmod-method" / "bmod.toml"
before = path.read_bytes()
self.assertEqual(self.problems(), "")
self.assertEqual(path.read_bytes(), before)
class MembershipRuleTests(ValidatorCase):
def test_skill_naming_a_record_the_repo_lacks(self):
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-absent"))
self.assertIn(
"skills/bmad-spec/bmod.toml: [skill] bmod names 'bmod-absent', which is not a module record",
self.problems(),
)
def test_skill_naming_a_folder_that_is_not_a_record(self):
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmad-build"))
self.assertIn("[skill] bmod names 'bmad-build', which is not a module record", self.problems())
def test_skill_its_record_does_not_list(self):
write(self.skills / "bmad-extra" / "bmod.toml", SKILL.format(bmod="bmod-method"))
self.assertIn(
"skills/bmad-extra/bmod.toml: [skill] bmod names 'bmod-method', but skills/bmod-method/bmod.toml "
"does not list 'bmad-extra'",
self.problems(),
)
def test_listed_skill_the_repo_does_not_ship(self):
self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmad-typo"]')
self.assertIn(
"skills/bmod-method/bmod.toml: lists the skill 'bmad-typo', which this repository does not ship",
self.problems(),
)
def test_listed_skill_that_names_another_record(self):
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-core-tools"))
self.assertIn(
"skills/bmod-method/bmod.toml: lists the skill 'bmad-spec', but skills/bmad-spec/bmod.toml names "
"'bmod-core-tools' as its bmod",
self.problems(),
)
def test_listed_folder_that_is_a_record(self):
self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmod-core-tools"]')
self.assertIn("lists 'bmod-core-tools', which is a module record", self.problems())
def test_member_source_differs_from_its_record(self):
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
self.assertIn(
f"skills/bmad-spec/bmod.toml: [skill] source 'github:o/r' differs from skills/bmod-method/bmod.toml "
f"update_source {SOURCE!r}",
self.problems(),
)
def test_record_with_skill_table_left_out_of_its_own_list(self):
write(
self.skills / "notes" / "bmod.toml",
f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\nskills = []\n\n[skill]\n',
)
self.assertIn("skills/notes/bmod.toml: holds [skill], but its own [bmod] skills list leaves", self.problems())
class RequirementRuleTests(ValidatorCase):
def add(self, folder: str, line: str) -> None:
path = self.skills / folder / "bmod.toml"
write(path, path.read_text(encoding="utf-8") + line)
def test_plain_names_in_this_repo_are_accepted_in_both_tables(self):
self.add("bmad-build", 'required_skills = ["bmad"]\nrecommended_skills = ["bmad-spec"]\n')
self.assertEqual(self.problems(), "")
def test_required_plain_name_the_repo_lacks(self):
self.add("bmad-build", 'required_skills = ["bmad-typo"]\n')
self.assertIn(
"skills/bmad-build/bmod.toml: skill.required_skills entry 'bmad-typo' names no skill in this repository",
self.problems(),
)
def test_recommended_plain_name_the_repo_lacks(self):
self.add("bmad-build", 'recommended_skills = ["bmad-typo"]\n')
self.assertIn("skill.recommended_skills entry 'bmad-typo' names no skill", self.problems())
def test_record_plain_name_the_repo_lacks(self):
self.method_record('required_skills = ["bmad"]', 'required_skills = ["bmad-typo"]')
self.assertIn(
"skills/bmod-method/bmod.toml: bmod.required_skills entry 'bmad-typo' names no skill", self.problems()
)
def test_table_entry_from_another_repo_is_accepted(self):
self.add(
"bmad-build", 'required_skills = [{ skill = "elsewhere", version = "1.0.0", source = "github:o/r" }]\n'
)
self.assertEqual(self.problems(), "")
def test_table_entry_without_a_source(self):
self.add("bmad-build", 'required_skills = [{ skill = "bmad", version = "6.13.0" }]\n')
self.assertIn("'skill.required_skills[0].source' must be a string", self.problems())
def test_version_with_build_metadata(self):
self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13.0+x", source = "{SOURCE}" }}]\n')
problems = self.problems()
self.assertIn("entry 'bmad' version '6.13.0+x' carries build metadata", problems)
self.assertIn("'6.13.0'", problems)
def test_unorderable_version(self):
self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13", source = "{SOURCE}" }}]\n')
self.assertIn("'skill.required_skills[0].version' must be an orderable version", self.problems())
class PathRuleTests(ValidatorCase):
def test_help_file_a_bmod_folder_does_not_ship(self):
(self.skills / "bmod-method" / "help" / "help.md").unlink()
self.assertIn(
"skills/bmod-method/help/help.md, which every bmod-* folder holds, the module record does not ship",
self.problems(),
)
def test_help_file_is_optional_outside_a_bmod_folder(self):
write(
self.skills / "solo" / "bmod.toml",
f'[bmod]\ncode = "solo"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}\n[skill]\n',
)
write(self.skills / "solo" / "SKILL.md", "# skill\n")
self.assertEqual(self.problems(), "")
def test_help_symlink(self):
target = self.skills / "bmod-method" / "help" / "help.md"
target.unlink()
target.symlink_to(self.skills / "bmod-method" / "delivery-help.md")
self.assertIn("skills/bmod-method/help/help.md, which every bmod-* folder holds, is a symlink", self.problems())
def test_topic_file_named_in_help_is_valid(self):
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n")
write(self.skills / "bmod-method" / "help" / "deep-dive.md", "# deep dive\n")
self.assertEqual(self.problems(), "")
def test_topic_file_help_never_names(self):
write(self.skills / "bmod-method" / "help" / "orphan.md", "# orphan\n")
self.assertIn("skills/bmod-method/help/orphan.md is never named in help/help.md", self.problems())
def test_help_naming_a_topic_file_that_does_not_exist(self):
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/gone.md`.\n")
self.assertIn(
"skills/bmod-method/help/help.md names skills/bmod-method/help/gone.md, which does not exist",
self.problems(),
)
def test_topic_file_naming_a_topic_that_does_not_exist(self):
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n")
write(self.skills / "bmod-method" / "help" / "deep-dive.md", "See `help/gone.md`.\n")
self.assertIn(
"skills/bmod-method/help/deep-dive.md names skills/bmod-method/help/gone.md, which does not exist",
self.problems(),
)
def test_knowledge_naming_the_help_file(self):
self.method_record('path = "delivery-help.md"', 'path = "help/help.md"')
self.assertIn("knowledge names 'help/help.md', which is always read", self.problems())
def test_knowledge_file_the_record_does_not_ship(self):
(self.skills / "bmod-method" / "delivery-help.md").unlink()
self.assertIn(
"skills/bmod-method/bmod.toml: knowledge names 'delivery-help.md', which the module record does not ship",
self.problems(),
)
def test_knowledge_url(self):
self.method_record('path = "delivery-help.md"', 'path = "https://docs.example.com/help.md"')
self.assertIn("unsafe value", self.problems())
def test_knowledge_path_leaving_the_folder(self):
self.method_record('path = "delivery-help.md"', 'path = "../bmad-build/SKILL.md"')
self.assertIn("unsafe value", self.problems())
def test_knowledge_directory(self):
(self.skills / "bmod-method" / "delivery-help.md").unlink()
(self.skills / "bmod-method" / "delivery-help.md").mkdir()
self.assertIn("knowledge names 'delivery-help.md', which is not a regular file", self.problems())
def test_knowledge_symlink(self):
target = self.skills / "bmod-method" / "delivery-help.md"
target.unlink()
target.symlink_to(self.skills / "bmod-method" / "help" / "help.md")
self.assertIn("knowledge names 'delivery-help.md', which is a symlink", self.problems())
def test_knowledge_naming_a_skill_outside_the_module(self):
self.method_record('skills = ["bmad-build"]\n', 'skills = ["bmad-build", "bmad-flow"]\n')
self.assertIn(
"knowledge 'delivery-help.md' names 'bmad-flow', which is not a skill of module 'method'",
self.problems(),
)
def test_a_module_without_a_roster_file_is_valid(self):
(self.skills / "bmod-method" / "roster.toml").unlink()
self.assertEqual(self.problems(), "")
def test_roster_symlink(self):
target = self.skills / "bmod-method" / "roster.toml"
target.unlink()
target.symlink_to(self.skills / "bmod-method" / "help" / "help.md")
self.assertIn("skills/bmod-method/roster.toml is a symlink", self.problems())
def test_roster_that_is_not_toml(self):
write(self.skills / "bmod-method" / "roster.toml", "[[members\n")
self.assertIn("skills/bmod-method/roster.toml: cannot read roster", self.problems())
class RosterContentTests(ValidatorCase):
def roster(self, old: str, new: str) -> None:
self.assertIn(old, ROSTER)
write(self.skills / "bmod-method" / "roster.toml", ROSTER.replace(old, new))
def test_member_code_defined_twice(self):
self.roster('code = "guest"', 'code = "bmad-build"')
self.assertIn("skills/bmod-method/roster.toml: member code 'bmad-build' is defined twice", self.problems())
def test_group_naming_an_undefined_member(self):
self.roster('members = ["bmad-build", "guest"]', 'members = ["bmad-build", "nobody"]')
self.assertIn("group 'team' lists 'nobody', which no member defines", self.problems())
def test_member_skill_the_repo_does_not_ship(self):
self.roster('skill = "bmad-build"', 'skill = "bmad-typo"')
self.assertIn(
"member 'bmad-build' names skill 'bmad-typo', which this repository does not ship", self.problems()
)
if __name__ == "__main__":
unittest.main()