basic BMAD setup
This commit is contained in:
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user