basic BMAD setup
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Tests for count_tokens.py: output schema, the forced fallback, files, directories and stdin."""
|
||||
|
||||
import builtins
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "count_tokens.py"
|
||||
|
||||
SAMPLE = (
|
||||
"The builder is platform-agnostic. Nothing assumes a single runtime, and no "
|
||||
"model list is ever hardcoded. Token counts replace line counts as the one "
|
||||
"length metric, with a chars-over-four fallback when tiktoken is absent.\n"
|
||||
) * 8
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("count_tokens", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def run(*args, stdin=None):
|
||||
return subprocess.run([sys.executable, str(SCRIPT), *args], input=stdin, capture_output=True, text=True)
|
||||
|
||||
|
||||
class CountTokensTest(unittest.TestCase):
|
||||
def test_count_returns_int_and_known_method(self):
|
||||
tokens, method = load_module().count_tokens(SAMPLE)
|
||||
self.assertIn(method, ("tiktoken", "fallback"))
|
||||
self.assertIsInstance(tokens, int)
|
||||
self.assertGreater(tokens, 0)
|
||||
if method == "fallback":
|
||||
self.assertEqual(tokens, len(SAMPLE) // 4)
|
||||
|
||||
def test_fallback_when_tiktoken_import_blocked(self):
|
||||
mod = load_module()
|
||||
real_import = builtins.__import__
|
||||
|
||||
def blocked(name, *args, **kwargs):
|
||||
if name == "tiktoken" or name.startswith("tiktoken."):
|
||||
raise ImportError("blocked for test")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
builtins.__import__ = blocked
|
||||
try:
|
||||
tokens, method = mod.count_tokens(SAMPLE)
|
||||
finally:
|
||||
builtins.__import__ = real_import
|
||||
self.assertEqual(method, "fallback")
|
||||
self.assertEqual(tokens, len(SAMPLE) // 4)
|
||||
|
||||
def test_fallback_within_tolerance_of_tiktoken(self):
|
||||
mod = load_module()
|
||||
real_tokens, method = mod.count_tokens(SAMPLE)
|
||||
if method != "tiktoken":
|
||||
self.skipTest("tiktoken not installed")
|
||||
fallback = len(SAMPLE) // 4
|
||||
self.assertTrue(real_tokens * 0.5 <= fallback <= real_tokens * 1.5)
|
||||
|
||||
def test_cli_file_schema(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Path(tmp) / "sample.md"
|
||||
f.write_text(SAMPLE, encoding="utf-8")
|
||||
res = run(str(f))
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
data = json.loads(res.stdout)
|
||||
self.assertEqual(set(data), {"files", "total", "method"})
|
||||
self.assertEqual(data["files"][0]["path"], str(f))
|
||||
self.assertEqual(data["total"], data["files"][0]["tokens"])
|
||||
self.assertGreater(data["total"], 0)
|
||||
|
||||
def test_cli_directory_recurses_and_skips_hidden(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "SKILL.md").write_text(SAMPLE, encoding="utf-8")
|
||||
(root / "references").mkdir()
|
||||
(root / "references" / "a.md").write_text(SAMPLE, encoding="utf-8")
|
||||
(root / ".memlog.md").write_text(SAMPLE, encoding="utf-8")
|
||||
(root / "logo.png").write_bytes(b"\x89PNG")
|
||||
data = json.loads(run(str(root)).stdout)
|
||||
paths = [Path(f["path"]).name for f in data["files"]]
|
||||
self.assertEqual(paths, ["SKILL.md", "a.md"])
|
||||
self.assertEqual(data["total"], sum(f["tokens"] for f in data["files"]))
|
||||
|
||||
def test_cli_stdin_matches_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Path(tmp) / "sample.md"
|
||||
f.write_text(SAMPLE, encoding="utf-8")
|
||||
from_file = json.loads(run(str(f)).stdout)
|
||||
from_stdin = json.loads(run("--stdin", stdin=SAMPLE).stdout)
|
||||
self.assertEqual(from_stdin["files"][0]["path"], "<stdin>")
|
||||
self.assertEqual(from_stdin["total"], from_file["total"])
|
||||
|
||||
def test_cli_requires_input(self):
|
||||
self.assertNotEqual(run().returncode, 0)
|
||||
self.assertNotEqual(run("/nonexistent/path/x.md").returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for init_skill.py: scaffolding each shape and checking skills for the frontmatter and bmod rules."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "init_skill.py"
|
||||
|
||||
GOOD_DESCRIPTION = "Counts things in a folder. Use when the user asks how big a skill is."
|
||||
|
||||
|
||||
def run(*args):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), *[str(a) for a in args]], capture_output=True, text=True)
|
||||
data = json.loads(res.stdout) if res.stdout.strip().startswith("{") else None
|
||||
return res.returncode, data, res.stderr
|
||||
|
||||
|
||||
def rules(data):
|
||||
return sorted(f["rule"] for f in data["findings"])
|
||||
|
||||
|
||||
class CreateTest(unittest.TestCase):
|
||||
def test_plain_skill_with_dirs_and_skill_table(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, data, err = run(
|
||||
"--name",
|
||||
"bmad-demo",
|
||||
"--dest",
|
||||
tmp,
|
||||
"--shape",
|
||||
"plain-skill",
|
||||
"--dirs",
|
||||
"references,scripts",
|
||||
"--bmod",
|
||||
"bmod-example",
|
||||
"--source",
|
||||
"github:acme/example/skills",
|
||||
"--description",
|
||||
GOOD_DESCRIPTION,
|
||||
)
|
||||
self.assertEqual(code, 0, err)
|
||||
skill = Path(tmp) / "bmad-demo"
|
||||
self.assertEqual(data["created"], ["SKILL.md", "bmod.toml", "references/", "scripts/"])
|
||||
self.assertEqual(data["shape"], "plain-skill")
|
||||
text = (skill / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertTrue(text.startswith("---\nname: bmad-demo\ndescription: '"))
|
||||
self.assertIn("# bmad-demo", text)
|
||||
bmod = tomllib.loads((skill / "bmod.toml").read_text(encoding="utf-8"))
|
||||
self.assertEqual(bmod, {"skill": {"bmod": "bmod-example", "source": "github:acme/example/skills"}})
|
||||
self.assertTrue((skill / "references").is_dir())
|
||||
# The new skill passes its own check.
|
||||
code, data, _ = run("--check", skill)
|
||||
self.assertEqual(code, 0, data)
|
||||
self.assertEqual(data["bmod"], "ok")
|
||||
|
||||
def test_no_bmod_without_flag(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / "bmad-demo"
|
||||
code, data, err = run(
|
||||
"--name", "bmad-demo", "--dest", tmp, "--shape", "plain-skill", "--description", GOOD_DESCRIPTION
|
||||
)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(data["created"], ["SKILL.md"])
|
||||
self.assertFalse((target / "bmod.toml").exists())
|
||||
code, data, _ = run("--check", target)
|
||||
self.assertEqual(code, 0, data)
|
||||
self.assertEqual(data["bmod"], "absent")
|
||||
|
||||
def test_placeholder_description_fails_check(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, _, _ = run("--name", "bmad-demo", "--dest", tmp, "--shape", "script-utility")
|
||||
self.assertEqual(code, 0)
|
||||
code, data, _ = run("--check", Path(tmp) / "bmad-demo")
|
||||
self.assertEqual(code, 1)
|
||||
# The placeholder carries "Use when", so only the TODO marker fails the check.
|
||||
self.assertEqual(rules(data), ["todo-left"])
|
||||
|
||||
def test_single_skill_module(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, _, _ = run(
|
||||
"--name",
|
||||
"bmad-changelog",
|
||||
"--dest",
|
||||
tmp,
|
||||
"--shape",
|
||||
"single-skill-module",
|
||||
"--source",
|
||||
"github:acme/changelog",
|
||||
"--description",
|
||||
GOOD_DESCRIPTION,
|
||||
)
|
||||
self.assertEqual(code, 0)
|
||||
bmod = tomllib.loads((Path(tmp) / "bmad-changelog" / "bmod.toml").read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
bmod["bmod"], {"code": "changelog", "version": "0.1.0", "update_source": "github:acme/changelog"}
|
||||
)
|
||||
self.assertEqual(bmod["skill"], {})
|
||||
code, data, _ = run("--check", Path(tmp) / "bmad-changelog")
|
||||
self.assertEqual(code, 0, data)
|
||||
|
||||
def test_multi_skill_module_record(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, _, _ = run(
|
||||
"--name",
|
||||
"bmod-acme",
|
||||
"--dest",
|
||||
tmp,
|
||||
"--shape",
|
||||
"multi-skill-module",
|
||||
"--source",
|
||||
"github:acme/x/skills",
|
||||
)
|
||||
self.assertEqual(code, 0)
|
||||
skill = Path(tmp) / "bmod-acme"
|
||||
bmod = tomllib.loads((skill / "bmod.toml").read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
bmod,
|
||||
{"bmod": {"code": "acme", "version": "0.1.0", "update_source": "github:acme/x/skills", "skills": []}},
|
||||
)
|
||||
self.assertIn(
|
||||
"Required bmod metadata. Never invoke this skill.", (skill / "SKILL.md").read_text(encoding="utf-8")
|
||||
)
|
||||
code, data, _ = run("--check", skill)
|
||||
self.assertEqual(code, 0, data)
|
||||
code, _, _ = run("--name", "bmad-acme", "--dest", tmp, "--shape", "multi-skill-module")
|
||||
self.assertEqual(code, 1)
|
||||
|
||||
def test_refuses_bad_name_existing_folder_and_unknown_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, data, _ = run("--name", "Bad_Name", "--dest", tmp, "--shape", "plain-skill")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertFalse(data["ok"])
|
||||
(Path(tmp) / "bmad-taken").mkdir()
|
||||
code, _, _ = run("--name", "bmad-taken", "--dest", tmp, "--shape", "plain-skill")
|
||||
self.assertEqual(code, 1)
|
||||
code, _, _ = run("--name", "bmad-new", "--dest", tmp, "--shape", "plain-skill", "--dirs", "steps")
|
||||
self.assertEqual(code, 1)
|
||||
code, _, _ = run("--name", "bmad-new", "--dest", tmp)
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
|
||||
class CheckTest(unittest.TestCase):
|
||||
def write_skill(self, tmp, name, skill_md, bmod="[skill]\n", extra=None):
|
||||
skill = Path(tmp) / name
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text(skill_md, encoding="utf-8")
|
||||
if bmod is not None:
|
||||
(skill / "bmod.toml").write_text(bmod, encoding="utf-8")
|
||||
for rel, text in (extra or {}).items():
|
||||
path = skill / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return skill
|
||||
|
||||
def test_good_skill_with_multiline_description(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = self.write_skill(
|
||||
tmp,
|
||||
"bmad-good",
|
||||
"---\nname: bmad-good\ndescription: >-\n Does a thing.\n Use when asked.\n---\n\nBody.\n",
|
||||
extra={".memlog.md": "- (note) [TODO: hidden files are not scanned]\n"},
|
||||
)
|
||||
code, data, _ = run("--check", skill)
|
||||
self.assertEqual(code, 0, data)
|
||||
self.assertEqual(data, {"ok": True, "skill": "bmad-good", "bmod": "ok", "findings": []})
|
||||
|
||||
def test_every_rule(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = self.write_skill(
|
||||
tmp,
|
||||
"my-skill",
|
||||
"---\nname: other-name\ndescription: '" + "x" * 1025 + "'\nversion: 1\n---\n\n \n",
|
||||
bmod="[skill\nbroken",
|
||||
extra={"references/a.md": "[TODO: write]\n"},
|
||||
)
|
||||
code, data, _ = run("--check", skill)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(
|
||||
rules(data),
|
||||
[
|
||||
"bmod-invalid",
|
||||
"body-empty",
|
||||
"description-length",
|
||||
"description-trigger",
|
||||
"frontmatter-keys",
|
||||
"name-folder-mismatch",
|
||||
"name-format",
|
||||
"todo-left",
|
||||
],
|
||||
)
|
||||
todo = next(f for f in data["findings"] if f["rule"] == "todo-left")
|
||||
self.assertEqual((todo["path"], todo["line"]), ("references/a.md", 1))
|
||||
|
||||
def test_any_name_and_missing_pieces(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = self.write_skill(
|
||||
tmp,
|
||||
"acme-notes",
|
||||
"---\nname: acme-notes\ndescription: 'Notes. Use when writing.'\n---\n\nBody.\n",
|
||||
bmod=None,
|
||||
)
|
||||
code, data, _ = run("--check", skill)
|
||||
self.assertEqual(rules(data), ["name-format"])
|
||||
self.assertEqual(data["bmod"], "absent")
|
||||
code, data, _ = run("--check", skill, "--any-name")
|
||||
self.assertEqual(code, 0, data)
|
||||
empty = Path(tmp) / "bmad-empty"
|
||||
empty.mkdir()
|
||||
code, data, _ = run("--check", empty)
|
||||
self.assertEqual(rules(data), ["skill-md-missing"])
|
||||
no_fm = self.write_skill(tmp, "bmad-nofm", "# Just a heading\n", bmod="[other]\n")
|
||||
code, data, _ = run("--check", no_fm)
|
||||
self.assertEqual(rules(data), ["bmod-tables", "frontmatter-missing"])
|
||||
self.assertEqual(data["bmod"], "invalid")
|
||||
|
||||
def test_record_rules(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
record = self.write_skill(
|
||||
tmp,
|
||||
"bmod-acme",
|
||||
"---\nname: bmod-acme\ndescription: 'Acme tools. Use when needed.'\n---\n\nBody.\n",
|
||||
bmod='[bmod]\ncode = "acme"\nversion = "1.0.0"\nupdate_source = "github:acme/x"\n',
|
||||
)
|
||||
code, data, _ = run("--check", record)
|
||||
self.assertEqual(rules(data), ["description-trigger"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tests for the memory-agent shape's wake and init-sanctum templates: birth, resume after a cut-off birth, and the waking map."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS = Path(__file__).resolve().parents[2] / "shapes" / "memory-agent" / "assets"
|
||||
TEMPLATES = [
|
||||
"PERSONA-template.md",
|
||||
"CREED-template.md",
|
||||
"BOND-template.md",
|
||||
"HOW-I-REMEMBER-template.md",
|
||||
"CAPABILITIES-template.md",
|
||||
]
|
||||
|
||||
|
||||
def make_skill(root: Path) -> tuple[Path, Path]:
|
||||
"""A built memory agent: scripts from the templates, sanctum seeds in assets/, one capability."""
|
||||
project = root / "project"
|
||||
skill = project / ".claude" / "skills" / "acme-agent-sage"
|
||||
(skill / "scripts").mkdir(parents=True)
|
||||
(skill / "assets").mkdir()
|
||||
(skill / "references").mkdir()
|
||||
shutil.copy(ASSETS / "wake-template.py", skill / "scripts" / "wake.py")
|
||||
shutil.copy(ASSETS / "init-sanctum-template.py", skill / "scripts" / "init-sanctum.py")
|
||||
for name in TEMPLATES:
|
||||
shutil.copy(ASSETS / name, skill / "assets" / name)
|
||||
(skill / "references" / "first-breath.md").write_text("birth\n", encoding="utf-8")
|
||||
(skill / "references" / "intake.md").write_text(
|
||||
"---\nname: Intake\ndescription: Keep and distill material\ncode: INTAKE\n---\n\nbody\n", encoding="utf-8"
|
||||
)
|
||||
(project / "_bmad").mkdir()
|
||||
return project, skill
|
||||
|
||||
|
||||
def run(script: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run([sys.executable, str(script), *map(str, args)], capture_output=True, text=True)
|
||||
|
||||
|
||||
class MemoryAgentScriptsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.project, self.skill = make_skill(Path(self._tmp.name))
|
||||
self.sanctum = self.project / "_bmad" / "memory" / "acme-agent-sage"
|
||||
self.wake = self.skill / "scripts" / "wake.py"
|
||||
self.init = self.skill / "scripts" / "init-sanctum.py"
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_no_sanctum_routes_to_first_breath(self):
|
||||
res = run(self.wake, self.project)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertIn("MODE: FIRST_BREATH", res.stdout)
|
||||
self.assertIn("NO SANCTUM", res.stdout)
|
||||
|
||||
def test_birth_writes_the_layout_without_config(self):
|
||||
res = run(self.init, self.project, self.skill)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
for name in ("PERSONA.md", "CREED.md", "BOND.md", "HOW-I-REMEMBER.md", "CAPABILITIES.md"):
|
||||
self.assertTrue((self.sanctum / name).is_file(), name)
|
||||
for rel in ("memory/sessions", "raw", "capabilities", "references"):
|
||||
self.assertTrue((self.sanctum / rel).is_dir(), rel)
|
||||
self.assertFalse((self.sanctum / "INDEX.md").exists())
|
||||
self.assertFalse((self.sanctum / "references" / "first-breath.md").exists())
|
||||
bond = (self.sanctum / "BOND.md").read_text(encoding="utf-8")
|
||||
self.assertNotIn("friend", bond)
|
||||
self.assertIn("learned at First Breath", bond)
|
||||
self.assertIn("| [INTAKE] | Intake |", (self.sanctum / "CAPABILITIES.md").read_text(encoding="utf-8"))
|
||||
|
||||
def test_cut_off_birth_is_finished_by_the_next_run(self):
|
||||
self.sanctum.mkdir(parents=True)
|
||||
(self.sanctum / "PERSONA.md").write_text("# Persona\n\nName: Sage\n", encoding="utf-8")
|
||||
res = run(self.wake, self.project)
|
||||
self.assertIn("MODE: FIRST_BREATH", res.stdout)
|
||||
self.assertIn("INCOMPLETE SANCTUM", res.stdout)
|
||||
res = run(self.init, self.project, self.skill)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertIn("Finished sanctum", res.stdout)
|
||||
self.assertEqual((self.sanctum / "PERSONA.md").read_text(encoding="utf-8"), "# Persona\n\nName: Sage\n")
|
||||
self.assertTrue((self.sanctum / "CREED.md").is_file())
|
||||
res = run(self.wake, self.project)
|
||||
self.assertIn("MODE: WAKING", res.stdout)
|
||||
|
||||
def test_finished_sanctum_is_left_alone(self):
|
||||
run(self.init, self.project, self.skill)
|
||||
(self.sanctum / "BOND.md").write_text("# Bond\n\nCall them: Brian\n", encoding="utf-8")
|
||||
res = run(self.init, self.project, self.skill)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertIn("already been born", res.stdout)
|
||||
self.assertIn("Call them: Brian", (self.sanctum / "BOND.md").read_text(encoding="utf-8"))
|
||||
|
||||
def test_waking_prints_self_and_map_not_memory(self):
|
||||
run(self.init, self.project, self.skill)
|
||||
people = self.sanctum / "memory" / "people"
|
||||
people.mkdir()
|
||||
(people / "ruben.md").write_text("Partner. SECRET-PEOPLE-BODY\n", encoding="utf-8")
|
||||
sessions = self.sanctum / "memory" / "sessions"
|
||||
(sessions / "2026-10-01-first-breath.md").write_text("born\n", encoding="utf-8")
|
||||
(sessions / "2026-10-03-pricing.md").write_text("talked\n", encoding="utf-8")
|
||||
(self.sanctum / "memory" / "pending.md").write_text("Ask about the launch date.\n", encoding="utf-8")
|
||||
raw = self.sanctum / "raw"
|
||||
(raw / "2026-10-02-oss-call.md").write_text(
|
||||
"---\nstatus: raw\ndistilled_to: []\n---\nwords\n", encoding="utf-8"
|
||||
)
|
||||
(raw / "2026-09-30-oss-notes.md").write_text("---\nstatus: distilled\n---\nwords\n", encoding="utf-8")
|
||||
res = run(self.wake, self.project)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
out = res.stdout
|
||||
self.assertIn("MODE: WAKING", out)
|
||||
for name in ("PERSONA.md", "CREED.md", "BOND.md", "HOW-I-REMEMBER.md", "CAPABILITIES.md"):
|
||||
self.assertIn(f"===== {name} =====", out)
|
||||
self.assertIn("memory/people/ (1 files)", out)
|
||||
self.assertIn("memory/sessions/ (2 files)", out)
|
||||
self.assertIn("raw/ (2 files)", out)
|
||||
self.assertLess(
|
||||
out.index("memory/sessions/2026-10-03-pricing.md"), out.index("memory/sessions/2026-10-01-first-breath.md")
|
||||
)
|
||||
self.assertIn("Never tended; session notes: 2", out)
|
||||
(self.sanctum / "memory" / ".tended").write_text("2026-10-02\n", encoding="utf-8")
|
||||
again = run(self.wake, self.project).stdout
|
||||
self.assertIn("Tended: 2026-10-02; session notes since: 1", again)
|
||||
self.assertIn("memory/sessions/ (2 files)", again)
|
||||
self.assertNotIn(".tended", again.split("memory map")[1].split("Tended:")[0])
|
||||
self.assertIn("Undistilled raw (1):", out)
|
||||
self.assertIn("raw/2026-10-02-oss-call.md", out)
|
||||
self.assertNotIn("2026-09-30-oss-notes.md", out.split("Undistilled raw")[1])
|
||||
self.assertIn("===== pending.md =====", out)
|
||||
self.assertIn("Ask about the launch date.", out)
|
||||
self.assertNotIn("SECRET-PEOPLE-BODY", out)
|
||||
|
||||
def test_pulse_appends_pulse_file_when_present(self):
|
||||
run(self.init, self.project, self.skill)
|
||||
res = run(self.wake, self.project, "--pulse")
|
||||
self.assertIn("MODE: PULSE", res.stdout)
|
||||
self.assertNotIn("===== PULSE.md =====", res.stdout)
|
||||
(self.sanctum / "PULSE.md").write_text("# Pulse\n\ntend\n", encoding="utf-8")
|
||||
res = run(self.wake, self.project, "--pulse")
|
||||
self.assertIn("===== PULSE.md =====", res.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for prepass.py: the JSON contract and the shape inference."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "prepass.py"
|
||||
|
||||
KEYS = {
|
||||
"skill",
|
||||
"shape_hint",
|
||||
"files",
|
||||
"skill_md_tokens",
|
||||
"total_tokens",
|
||||
"has_customize",
|
||||
"has_scripts",
|
||||
"scripts",
|
||||
"description_chars",
|
||||
"has_use_when",
|
||||
"frontmatter_ok",
|
||||
"bmod_kind",
|
||||
"path_findings",
|
||||
"script_findings",
|
||||
}
|
||||
SKILL_MD = "---\nname: {name}\ndescription: 'Does a thing. Use when asked.'\n---\n\n# Body\n\n{body}\n"
|
||||
GOOD_SCRIPT = '# /// script\n# requires-python = ">=3.11"\n# ///\nprint(1)\n'
|
||||
|
||||
|
||||
def make_skill(root: Path, name="bmad-demo", body="Read `references/a.md`.", files=None, skill_md=True) -> Path:
|
||||
skill = root / name
|
||||
skill.mkdir(parents=True)
|
||||
if skill_md:
|
||||
(skill / "SKILL.md").write_text(SKILL_MD.format(name=name, body=body), encoding="utf-8")
|
||||
for rel, text in (files or {}).items():
|
||||
path = skill / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return skill
|
||||
|
||||
|
||||
def prepass(skill: Path):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), str(skill)], capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
return json.loads(res.stdout)
|
||||
|
||||
|
||||
class PrepassTest(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp),
|
||||
body="Read `references/a.md` and `references/missing.md`. Run `uv run scripts/go.py`.",
|
||||
files={
|
||||
"references/a.md": "A reference.\n",
|
||||
"scripts/go.py": GOOD_SCRIPT,
|
||||
"scripts/bare.py": "print(2)\n",
|
||||
"scripts/tests/test_go.py": "import unittest\n",
|
||||
"assets/SKILL-template.md": "template\n",
|
||||
"customize.toml": "[workflow]\nx = 1\n",
|
||||
".memlog.md": "- (note) hidden\n",
|
||||
},
|
||||
)
|
||||
data = prepass(skill)
|
||||
self.assertEqual(set(data), KEYS)
|
||||
self.assertEqual(data["skill"], "bmad-demo")
|
||||
kinds = {f["path"]: f["kind"] for f in data["files"]}
|
||||
self.assertEqual(
|
||||
kinds,
|
||||
{
|
||||
"SKILL.md": "entry",
|
||||
"references/a.md": "prompt",
|
||||
"scripts/go.py": "script",
|
||||
"scripts/bare.py": "script",
|
||||
"scripts/tests/test_go.py": "test",
|
||||
"assets/SKILL-template.md": "asset",
|
||||
"customize.toml": "config",
|
||||
},
|
||||
)
|
||||
self.assertTrue(all(f["tokens"] > 0 for f in data["files"]))
|
||||
self.assertEqual(data["total_tokens"], sum(f["tokens"] for f in data["files"]))
|
||||
self.assertEqual(
|
||||
data["skill_md_tokens"], kinds and next(f["tokens"] for f in data["files"] if f["path"] == "SKILL.md")
|
||||
)
|
||||
self.assertTrue(data["has_customize"])
|
||||
self.assertTrue(data["has_scripts"])
|
||||
self.assertEqual(
|
||||
data["scripts"],
|
||||
[
|
||||
{"path": "scripts/bare.py", "has_pep723": False, "has_test": False},
|
||||
{"path": "scripts/go.py", "has_pep723": True, "has_test": True},
|
||||
],
|
||||
)
|
||||
self.assertEqual(data["description_chars"], len("Does a thing. Use when asked."))
|
||||
self.assertTrue(data["has_use_when"])
|
||||
self.assertTrue(data["frontmatter_ok"])
|
||||
self.assertEqual(sorted(f["rule"] for f in data["path_findings"]), ["bare-script-call", "missing-file"])
|
||||
self.assertEqual(sorted(f["rule"] for f in data["script_findings"]), ["pep723-missing", "test-missing"])
|
||||
self.assertEqual(data["shape_hint"], "script-utility")
|
||||
|
||||
def test_shape_hints(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
cases = {
|
||||
"bmad-plain": ({}, "plain-skill"),
|
||||
"bmad-long": ({"scripts/x.py": GOOD_SCRIPT}, "script-utility"),
|
||||
"bmad-agent": ({"customize.toml": "[agent]\nname = 'Ann'\n"}, "agent"),
|
||||
"bmad-memory": ({"customize.toml": "[agent]\n", "scripts/wake.py": GOOD_SCRIPT}, "memory-agent"),
|
||||
"bmad-single": ({"bmod.toml": "[bmod]\ncode = 'x'\n[skill]\n"}, "single-skill-module"),
|
||||
"bmod-multi": ({"bmod.toml": "[bmod]\ncode = 'multi'\n"}, "multi-skill-module"),
|
||||
}
|
||||
for name, (files, expected) in cases.items():
|
||||
skill = make_skill(root, name=name, files=files)
|
||||
self.assertEqual(prepass(skill)["shape_hint"], expected, name)
|
||||
|
||||
rendered = make_skill(
|
||||
root, name="bmad-rendered", body='Run `uv run "{project-root}/_bmad/scripts/render_skill.py"`.'
|
||||
)
|
||||
self.assertEqual(prepass(rendered)["shape_hint"], "rendered-skill")
|
||||
|
||||
memory_text = make_skill(root, name="bmad-mem2", body="Memory at `{project-root}/_bmad/memory/bmad-mem2/`.")
|
||||
self.assertEqual(prepass(memory_text)["shape_hint"], "memory-agent")
|
||||
|
||||
long_body = "\n".join(f"Line {i}." for i in range(70))
|
||||
long_skill = make_skill(root, name="bmad-long2", body=long_body, files={"scripts/x.py": GOOD_SCRIPT})
|
||||
self.assertEqual(prepass(long_skill)["shape_hint"], "plain-skill")
|
||||
|
||||
def test_no_skill_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(Path(tmp), skill_md=False, files={"notes.md": "x\n"})
|
||||
data = prepass(skill)
|
||||
self.assertEqual(data["shape_hint"], "unknown")
|
||||
self.assertEqual(data["skill_md_tokens"], 0)
|
||||
self.assertFalse(data["frontmatter_ok"])
|
||||
self.assertEqual(data["description_chars"], 0)
|
||||
self.assertFalse(data["has_use_when"])
|
||||
|
||||
def test_bad_frontmatter_is_not_ok(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp), files={"SKILL.md": "---\nname: wrong\ndescription: 'x'\nextra: 1\n---\n\nBody\n"}
|
||||
)
|
||||
data = prepass(skill)
|
||||
self.assertFalse(data["frontmatter_ok"])
|
||||
self.assertFalse(data["has_use_when"])
|
||||
self.assertEqual(data["description_chars"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for process_template.py: variables, conditionals, nesting, leftover markers and metadata."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "process_template.py"
|
||||
|
||||
TEMPLATE = """---
|
||||
name: {skillName}
|
||||
---
|
||||
|
||||
# {displayName}
|
||||
|
||||
{if-memory}
|
||||
Memory lives at {project-root}/_bmad/memory/{skillName}/.
|
||||
{if-pulse}
|
||||
Pulse is on.
|
||||
{/if-pulse}
|
||||
{/if-memory}
|
||||
|
||||
Always: {agent.name}
|
||||
"""
|
||||
|
||||
|
||||
def run(*args, template=TEMPLATE):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "t.md"
|
||||
path.write_text(template, encoding="utf-8")
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), str(path), *args], capture_output=True, text=True)
|
||||
out_file = Path(tmp) / "out.md"
|
||||
output = out_file.read_text(encoding="utf-8") if out_file.exists() else None
|
||||
return res, output
|
||||
|
||||
|
||||
class ProcessTemplateTest(unittest.TestCase):
|
||||
def test_variables_and_false_block_removed(self):
|
||||
res, _ = run("--var", "skillName=bmad-x", "--var", "displayName=X", "--json")
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertIn("name: bmad-x", res.stdout)
|
||||
self.assertIn("# X", res.stdout)
|
||||
self.assertNotIn("Memory lives", res.stdout)
|
||||
self.assertNotIn("{if-", res.stdout)
|
||||
self.assertNotIn("\n\n\n", res.stdout)
|
||||
meta = json.loads(res.stderr)
|
||||
self.assertEqual(meta["vars_substituted"], ["skillName", "displayName"])
|
||||
self.assertEqual(meta["conditions_false"], ["memory"])
|
||||
self.assertEqual(meta["conditions_true"], [])
|
||||
self.assertEqual(meta["tokens_remaining"], ["{agent.name}"])
|
||||
|
||||
def test_nested_true_blocks_kept(self):
|
||||
res, _ = run("--var", "skillName=bmad-x", "--true", "memory", "--true", "pulse", "--json")
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertIn("Memory lives at {project-root}/_bmad/memory/bmad-x/.", res.stdout)
|
||||
self.assertIn("Pulse is on.", res.stdout)
|
||||
meta = json.loads(res.stderr)
|
||||
self.assertEqual(sorted(meta["conditions_true"]), ["memory", "pulse"])
|
||||
self.assertIn("{project-root}", meta["tokens_remaining"])
|
||||
|
||||
def test_outer_true_inner_false(self):
|
||||
res, _ = run("--true", "memory")
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertIn("Memory lives", res.stdout)
|
||||
self.assertNotIn("Pulse is on.", res.stdout)
|
||||
|
||||
def test_output_file_prints_metadata_on_stdout(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "t.md"
|
||||
out = Path(tmp) / "out.md"
|
||||
path.write_text(TEMPLATE, encoding="utf-8")
|
||||
res = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(path), "-o", str(out), "--var", "skillName=s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
meta = json.loads(res.stdout)
|
||||
self.assertEqual(meta["output_file"], str(out))
|
||||
self.assertIn("name: s", out.read_text(encoding="utf-8"))
|
||||
|
||||
def test_plain_substitution_leaves_jinja_anchors_and_guidance_alone(self):
|
||||
template = (
|
||||
'{% raw %}{{epic_number}}{% endraw %} {{ workflow.{selector} }} {% if workflow.route == "x" %}on{% endif %}\n'
|
||||
"{project-root}/{skill-root}/{slug}/{output_folder}/{active_initiative} {workflow.lenses}\n"
|
||||
"{purpose: one paragraph on what the skill is for}\n"
|
||||
"name: {skillName}\n"
|
||||
)
|
||||
res, _ = run("--var", "selector=route", "--var", "skillName=bmad-x", "--json", template=template)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
self.assertEqual(
|
||||
res.stdout,
|
||||
'{% raw %}{{epic_number}}{% endraw %} {{ workflow.route }} {% if workflow.route == "x" %}on{% endif %}\n'
|
||||
"{project-root}/{skill-root}/{slug}/{output_folder}/{active_initiative} {workflow.lenses}\n"
|
||||
"{purpose: one paragraph on what the skill is for}\n"
|
||||
"name: bmad-x\n",
|
||||
)
|
||||
meta = json.loads(res.stderr)
|
||||
self.assertEqual(meta["vars_substituted"], ["selector", "skillName"])
|
||||
|
||||
def test_leftover_marker_exits_3(self):
|
||||
res, _ = run(template="start {if-a} unclosed")
|
||||
self.assertEqual(res.returncode, 3)
|
||||
self.assertIn("{if-a}", res.stderr)
|
||||
|
||||
def test_bad_var_format_is_usage_error(self):
|
||||
res, _ = run("--var", "novalue")
|
||||
self.assertEqual(res.returncode, 2)
|
||||
|
||||
def test_missing_template(self):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent/t.md"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for read_session_log.py over a synthetic Claude Code transcript and a synthetic memlog."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "read_session_log.py"
|
||||
|
||||
|
||||
def user(text, **extra):
|
||||
return {"type": "user", "message": {"role": "user", "content": text}, **extra}
|
||||
|
||||
|
||||
def tool_result():
|
||||
return {
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]},
|
||||
}
|
||||
|
||||
|
||||
def assistant(*tool_uses):
|
||||
blocks = [{"type": "text", "text": "assistant prose never copied"}]
|
||||
blocks += [
|
||||
{"type": "tool_use", "id": f"t{i}", "name": name, "input": inp} for i, (name, inp) in enumerate(tool_uses)
|
||||
]
|
||||
return {"type": "assistant", "message": {"role": "assistant", "content": blocks}}
|
||||
|
||||
|
||||
TRANSCRIPT = [
|
||||
{"type": "last-prompt", "lastPrompt": "noise record without a message"},
|
||||
user("Build me a changelog skill that reads git history. " + "x" * 400),
|
||||
assistant(
|
||||
("Read", {"file_path": "/repo/skills/bmad-x/SKILL.md"}),
|
||||
("Bash", {"command": "ls"}),
|
||||
("Bash", {"command": "pwd"}),
|
||||
),
|
||||
tool_result(),
|
||||
assistant(("Edit", {"file_path": "/repo/skills/bmad-x/SKILL.md", "old_string": "a", "new_string": "b"})),
|
||||
user("No, don't touch SKILL.md, put it in references instead"),
|
||||
assistant(("Write", {"file_path": "/repo/skills/bmad-x/references/history.md"})),
|
||||
user("[Request interrupted by user for tool use]"),
|
||||
user("<command-name>/bmad-eval</command-name>\n<command-args></command-args>"),
|
||||
assistant(("Skill", {"skill": "bmad-eval"}), ("Read", {"file_path": "/repo/evals/cases.json"})),
|
||||
user("build me a changelog skill that reads git history. " + "x" * 400),
|
||||
user("secret meta content", isMeta=True),
|
||||
user("sidechain content", isSidechain=True),
|
||||
assistant(("Read", {"file_path": "/repo/skills/bmad-x/SKILL.md"}), ("Bash", {"command": "ls"})),
|
||||
"this line is not json",
|
||||
]
|
||||
|
||||
MEMLOG = """---
|
||||
topic: bmad-changelog
|
||||
updated: 2026-06-07T14:22
|
||||
---
|
||||
|
||||
- (note) user picked the lean approach
|
||||
- (direction) keep the skill to one file
|
||||
- (decision by user) name it bmad-changelog
|
||||
- (gap) no eval cases yet
|
||||
- (assumption) git is installed
|
||||
- (idea by user) also read tags
|
||||
- (note) wait, the folder was wrong
|
||||
"""
|
||||
|
||||
|
||||
def write_transcript(folder: Path, name="s1.jsonl"):
|
||||
path = folder / name
|
||||
path.write_text("\n".join(r if isinstance(r, str) else json.dumps(r) for r in TRANSCRIPT) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def run(*args, env=None):
|
||||
res = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *[str(a) for a in args]], capture_output=True, text=True, env=env
|
||||
)
|
||||
return res.returncode, json.loads(res.stdout) if res.stdout.strip() else None, res.stderr
|
||||
|
||||
|
||||
class ReadSessionLogTest(unittest.TestCase):
|
||||
def test_claude_code_digest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_transcript(Path(tmp))
|
||||
code, data, err = run(path)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(
|
||||
set(data),
|
||||
{"sources", "user_requests", "tool_sequences", "corrections", "files_touched", "skills_invoked"},
|
||||
)
|
||||
self.assertEqual(data["sources"], [{"path": str(path), "format": "claude-code", "entries": 14}])
|
||||
|
||||
requests = data["user_requests"]
|
||||
self.assertEqual(requests[0]["count"], 2)
|
||||
self.assertTrue(requests[0]["text"].startswith("Build me a changelog skill"))
|
||||
self.assertEqual(len(requests[0]["text"]), 300)
|
||||
self.assertTrue(requests[0]["text"].endswith("..."))
|
||||
texts = " ".join(r["text"] for r in requests)
|
||||
self.assertNotIn("secret meta", texts)
|
||||
self.assertNotIn("sidechain", texts)
|
||||
self.assertNotIn("assistant prose", json.dumps(data))
|
||||
self.assertNotIn("<command-name>", texts)
|
||||
|
||||
self.assertEqual(
|
||||
data["corrections"],
|
||||
[
|
||||
"No, don't touch SKILL.md, put it in references instead",
|
||||
"[Request interrupted by user for tool use]",
|
||||
],
|
||||
)
|
||||
self.assertEqual(data["skills_invoked"], [{"name": "bmad-eval", "count": 2}])
|
||||
self.assertEqual(
|
||||
data["files_touched"],
|
||||
["/repo/evals/cases.json", "/repo/skills/bmad-x/SKILL.md", "/repo/skills/bmad-x/references/history.md"],
|
||||
)
|
||||
sequences = {tuple(s["tools"]): s["count"] for s in data["tool_sequences"]}
|
||||
self.assertEqual(sequences[("Read", "Bash")], 1)
|
||||
self.assertEqual(sequences[("Read", "Bash", "Edit")], 1)
|
||||
self.assertEqual(sequences[("Write",)], 1)
|
||||
self.assertEqual(sequences[("Skill", "Read")], 1)
|
||||
|
||||
def test_memlog_digest_and_max_items(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / ".memlog.md"
|
||||
path.write_text(MEMLOG, encoding="utf-8")
|
||||
code, data, err = run(path, "--format", "memlog")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(data["sources"][0]["entries"], 7)
|
||||
self.assertEqual(
|
||||
[r["text"] for r in data["user_requests"]],
|
||||
["keep the skill to one file", "name it bmad-changelog", "also read tags"],
|
||||
)
|
||||
self.assertEqual(data["corrections"], ["no eval cases yet", "wait, the folder was wrong"])
|
||||
code, data, _ = run(path, "--max-items", "1")
|
||||
self.assertEqual(len(data["user_requests"]), 1)
|
||||
self.assertEqual(len(data["corrections"]), 1)
|
||||
|
||||
def test_folder_and_project_resolution(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Path(tmp) / "config"
|
||||
project_dir = Path(tmp) / "work" / "my.repo"
|
||||
project_dir.mkdir(parents=True)
|
||||
encoded = str(project_dir.resolve()).replace("/", "-").replace(".", "-")
|
||||
folder = config / "projects" / encoded
|
||||
folder.mkdir(parents=True)
|
||||
write_transcript(folder, "a.jsonl")
|
||||
write_transcript(folder, "b.jsonl")
|
||||
(folder / ".memlog.md").write_text(MEMLOG, encoding="utf-8")
|
||||
env = dict(os.environ, CLAUDE_CONFIG_DIR=str(config))
|
||||
code, data, err = run("--project", project_dir, env=env)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual([s["format"] for s in data["sources"]], ["claude-code", "claude-code", "memlog"])
|
||||
self.assertEqual(data["skills_invoked"], [{"name": "bmad-eval", "count": 4}])
|
||||
code, _, err = run("--project", Path(tmp) / "nowhere", env=env)
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
def test_unknown_format_is_skipped_and_missing_path_errors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
other = Path(tmp) / "notes.txt"
|
||||
other.write_text("hello", encoding="utf-8")
|
||||
code, data, err = run(other)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(data["sources"], [])
|
||||
self.assertIn("unknown format", err)
|
||||
code, _, _ = run(Path(tmp) / "missing.jsonl")
|
||||
self.assertEqual(code, 2)
|
||||
code, _, _ = run()
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for registry.py: records, members, single-skill modules, unregistered skills, symlink dedupe."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "registry.py"
|
||||
|
||||
|
||||
def write(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def make_project(root: Path) -> Path:
|
||||
project = root / "project"
|
||||
agents = project / ".agents" / "skills"
|
||||
(project / "_bmad").mkdir(parents=True)
|
||||
# core record
|
||||
write(agents / "bmod-core-tools" / "SKILL.md", "---\nname: bmod-core-tools\ndescription: x\n---\n")
|
||||
write(
|
||||
agents / "bmod-core-tools" / "bmod.toml",
|
||||
'[bmod]\ncode = "core-tools"\nversion = "6.13.0"\nupdate_source = "github:bmad-code-org/BMAD-METHOD/skills"\nskills = ["bmad"]\n',
|
||||
)
|
||||
write(agents / "bmad" / "SKILL.md", "---\nname: bmad\ndescription: x\n---\n")
|
||||
write(
|
||||
agents / "bmad" / "bmod.toml",
|
||||
'[skill]\nbmod = "bmod-core-tools"\nsource = "github:bmad-code-org/BMAD-METHOD/skills"\n',
|
||||
)
|
||||
# an org module with an agent member and help
|
||||
write(agents / "bmod-acme" / "SKILL.md", "---\nname: bmod-acme\ndescription: x\n---\n")
|
||||
write(
|
||||
agents / "bmod-acme" / "bmod.toml",
|
||||
'[bmod]\ncode = "acme"\nversion = "1.0.0"\nupdate_source = "github:acme/skills"\nskills = ["acme-release-notes"]\n',
|
||||
)
|
||||
write(agents / "bmod-acme" / "help" / "help.md", "# Acme\n")
|
||||
write(agents / "bmod-acme" / "roster.toml", "[[members]]\ncode = 'acme-agent-scout'\n")
|
||||
write(agents / "acme-release-notes" / "SKILL.md", "---\nname: acme-release-notes\ndescription: x\n---\n")
|
||||
write(agents / "acme-release-notes" / "bmod.toml", '[skill]\nbmod = "bmod-acme"\nsource = "github:acme/skills"\n')
|
||||
write(agents / "acme-agent-scout" / "SKILL.md", "---\nname: acme-agent-scout\ndescription: x\n---\n")
|
||||
write(agents / "acme-agent-scout" / "bmod.toml", '[skill]\nbmod = "bmod-acme"\nsource = "github:acme/skills"\n')
|
||||
# a single-skill module
|
||||
write(agents / "doc-standards" / "SKILL.md", "---\nname: doc-standards\ndescription: x\n---\n")
|
||||
write(
|
||||
agents / "doc-standards" / "bmod.toml",
|
||||
'[bmod]\ncode = "docstd"\nversion = "0.1.0"\nupdate_source = "github:me/doc-standards"\n\n[skill]\n',
|
||||
)
|
||||
write(agents / "doc-standards" / "help" / "help.md", "# Doc standards\n")
|
||||
# wired to BMad, registered nowhere
|
||||
write(agents / "random-generator" / "SKILL.md", "---\nname: random-generator\ndescription: x\n---\n")
|
||||
write(agents / "random-generator" / "customize.toml", "[workflow]\n")
|
||||
write(
|
||||
agents / "joke" / "SKILL.md",
|
||||
"---\nname: joke\ndescription: x\n---\nRun `uv run {project-root}/_bmad/scripts/resolve_config.py`.\n",
|
||||
)
|
||||
# plain, outside BMad
|
||||
write(agents / "plain" / "SKILL.md", "---\nname: plain\ndescription: x\n---\nJust do it.\n")
|
||||
# .claude/skills mirrors .agents/skills through symlinks
|
||||
claude = project / ".claude" / "skills"
|
||||
claude.mkdir(parents=True)
|
||||
for folder in agents.iterdir():
|
||||
os.symlink(Path("..") / ".." / ".agents" / "skills" / folder.name, claude / folder.name)
|
||||
return project
|
||||
|
||||
|
||||
def run(project: Path, *extra: str) -> dict:
|
||||
res = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--project-root", str(project), *extra], capture_output=True, text=True
|
||||
)
|
||||
assert res.returncode == 0, res.stderr
|
||||
return json.loads(res.stdout)
|
||||
|
||||
|
||||
class RegistryTest(unittest.TestCase):
|
||||
def test_records_members_and_unregistered(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project = make_project(Path(tmp))
|
||||
roots = [str(project / ".agents" / "skills"), str(project / ".claude" / "skills")]
|
||||
data = run(project, "--root", roots[0], "--root", roots[1])
|
||||
self.assertEqual(data["bmad"], {"present": True, "version": "6.13.0"})
|
||||
self.assertFalse(data["skills_repo"])
|
||||
by_code = {r["code"]: r for r in data["records"]}
|
||||
self.assertEqual(sorted(by_code), ["acme", "core-tools", "docstd"])
|
||||
acme = by_code["acme"]
|
||||
self.assertEqual(acme["skills"], ["acme-agent-scout", "acme-release-notes"])
|
||||
self.assertEqual(acme["prefix"], "acme-")
|
||||
self.assertTrue(acme["has_help"])
|
||||
self.assertTrue(acme["has_roster"])
|
||||
self.assertFalse(acme["single_skill"])
|
||||
self.assertEqual(acme["scope"], "project")
|
||||
doc = by_code["docstd"]
|
||||
self.assertTrue(doc["single_skill"])
|
||||
self.assertEqual(doc["skills"], [])
|
||||
self.assertEqual(doc["prefix"], "docstd-")
|
||||
self.assertEqual(by_code["core-tools"]["prefix"], "bmad-")
|
||||
self.assertEqual(sorted(u["skill"] for u in data["unregistered"]), ["joke", "random-generator"])
|
||||
# the symlinked copies did not double anything
|
||||
self.assertEqual(len(data["records"]), 3)
|
||||
self.assertEqual(len(data["unregistered"]), 2)
|
||||
|
||||
def test_empty_project(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project = Path(tmp) / "empty"
|
||||
project.mkdir()
|
||||
data = run(project, "--root", str(project / "nowhere"))
|
||||
self.assertEqual(data["bmad"], {"present": False, "version": None})
|
||||
self.assertEqual(data["records"], [])
|
||||
self.assertEqual(data["unregistered"], [])
|
||||
|
||||
def test_skills_repo_detected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project = Path(tmp) / "repo"
|
||||
write(project / "skills" / "bmod-x" / "SKILL.md", "---\nname: bmod-x\ndescription: x\n---\n")
|
||||
write(
|
||||
project / "skills" / "bmod-x" / "bmod.toml",
|
||||
'[bmod]\ncode = "x"\nversion = "1"\nupdate_source = "github:a/b"\n',
|
||||
)
|
||||
data = run(project, "--root", str(project / "skills"))
|
||||
self.assertTrue(data["skills_repo"])
|
||||
self.assertEqual(data["records"][0]["code"], "x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for scan_legacy_module.py on fixtures shaped like the old multi-skill and single-skill modules."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scan_legacy_module.py"
|
||||
|
||||
MODULE_YAML = """code: sam
|
||||
name: "BMad Samples"
|
||||
description: "Demo module"
|
||||
module_version: 1.2.0
|
||||
default_selected: false
|
||||
module_greeting: >
|
||||
The BMad Samples module is ready!
|
||||
Enjoy.
|
||||
|
||||
agents:
|
||||
- code: dw-agent
|
||||
name: Oneira
|
||||
title: Dream Guide
|
||||
|
||||
sample_output_folder:
|
||||
prompt: "Where should sample output be saved?"
|
||||
default: "{project-root}/_bmad-output"
|
||||
result: "{project-root}/{value}"
|
||||
|
||||
tone:
|
||||
prompt: "Which tone?"
|
||||
default: warm
|
||||
user_setting: true
|
||||
single-select:
|
||||
- value: warm
|
||||
label: Warm
|
||||
- value: dry
|
||||
label: Dry
|
||||
|
||||
verbose:
|
||||
prompt: "Verbose output?"
|
||||
default: false
|
||||
"""
|
||||
|
||||
HELP_CSV = (
|
||||
"module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n"
|
||||
'BMad Samples,sample-module-setup,Setup Samples Module,SS,"Install or update config.",configure,"{-H: headless}",anytime,,,false,{project-root}/_bmad,config.yaml\n'
|
||||
'BMad Samples,bmad-agent-dream-weaver,Dream Capture,DL,"Capture a dream.",dream-log,,anytime,,,false,,journal entry\n'
|
||||
'BMad Samples,bmad-excalidraw,Create Diagram,XD,"Create diagrams.",diagram-generation,,anytime,,,false,sample_output_folder,excalidraw file\n'
|
||||
)
|
||||
|
||||
SETUP_SKILL_MD = "---\nname: sample-module-setup\ndescription: 'Setup. Use when installing.'\n---\n\nRead `./assets/module.yaml`; write `{project-root}/_bmad/config.yaml`.\n"
|
||||
DREAM_SKILL_MD = (
|
||||
"---\nname: bmad-agent-dream-weaver\ndescription: 'Dreams. Use when dreaming.'\n---\n\n"
|
||||
"If `{project-root}/_bmad/config.yaml` has no `sam` section, run the `sample-module-setup` skill.\n"
|
||||
"Personal settings live in `config.user.yaml`.\n"
|
||||
)
|
||||
EXCALIDRAW_SKILL_MD = "---\nname: bmad-excalidraw\ndescription: 'Diagrams. Use when drawing.'\n---\n\nRead `{project-root}/_bmad/config.toml`.\n"
|
||||
|
||||
|
||||
def write(root: Path, files: dict[str, str]) -> None:
|
||||
for relative, text in files.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def run(folder: Path):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), str(folder)], capture_output=True, text=True)
|
||||
return res.returncode, json.loads(res.stdout) if res.stdout.strip() else None, res.stderr
|
||||
|
||||
|
||||
class ScanLegacyModuleTest(unittest.TestCase):
|
||||
def test_multi_skill_module_with_setup_skill(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "samples"
|
||||
write(
|
||||
root,
|
||||
{
|
||||
"sample-module-setup/SKILL.md": SETUP_SKILL_MD,
|
||||
"sample-module-setup/assets/module.yaml": MODULE_YAML,
|
||||
"sample-module-setup/assets/module-help.csv": HELP_CSV,
|
||||
"sample-module-setup/scripts/merge-config.py": "print(1)\n",
|
||||
"sample-module-setup/scripts/merge-help-csv.py": "print(1)\n",
|
||||
"sample-module-setup/scripts/cleanup-legacy.py": "print(1)\n",
|
||||
"bmad-agent-dream-weaver/SKILL.md": DREAM_SKILL_MD,
|
||||
"bmad-agent-dream-weaver/references/memory.md": "Nothing legacy here.\n",
|
||||
"bmad-excalidraw/SKILL.md": EXCALIDRAW_SKILL_MD,
|
||||
"node_modules/x/module.yaml": "code: ignored\n",
|
||||
},
|
||||
)
|
||||
code, data, err = run(root)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(
|
||||
set(data),
|
||||
{"module", "config_keys", "help_rows", "skills", "legacy_reads", "setup_skill", "files_to_delete"},
|
||||
)
|
||||
self.assertEqual(
|
||||
data["module"],
|
||||
{
|
||||
"code": "sam",
|
||||
"name": "BMad Samples",
|
||||
"version": "1.2.0",
|
||||
"greeting": "The BMad Samples module is ready! Enjoy.",
|
||||
"agents": [{"code": "dw-agent", "name": "Oneira", "title": "Dream Guide"}],
|
||||
},
|
||||
)
|
||||
keys = {k["key"]: k for k in data["config_keys"]}
|
||||
self.assertEqual(list(keys), ["sample_output_folder", "tone", "verbose"])
|
||||
self.assertEqual(keys["sample_output_folder"]["kind"], "text")
|
||||
self.assertEqual(keys["sample_output_folder"]["default"], "{project-root}/_bmad-output")
|
||||
self.assertFalse(keys["sample_output_folder"]["user_setting"])
|
||||
self.assertEqual(len(keys["sample_output_folder"]["unconvertible"]), 1)
|
||||
self.assertIn("result template", keys["sample_output_folder"]["unconvertible"][0])
|
||||
self.assertEqual(keys["tone"]["kind"], "single-select")
|
||||
self.assertTrue(keys["tone"]["user_setting"])
|
||||
self.assertEqual(keys["tone"]["prompt"], "Which tone?")
|
||||
self.assertEqual(keys["verbose"]["kind"], "confirm")
|
||||
self.assertEqual(keys["verbose"]["default"], False)
|
||||
self.assertEqual(len(data["help_rows"]), 3)
|
||||
self.assertEqual(data["help_rows"][1]["skill"], "bmad-agent-dream-weaver")
|
||||
self.assertEqual(data["help_rows"][1]["menu-code"], "DL")
|
||||
self.assertEqual(data["help_rows"][2]["output-location"], "sample_output_folder")
|
||||
self.assertEqual(data["skills"], ["bmad-agent-dream-weaver", "bmad-excalidraw"])
|
||||
self.assertEqual(data["setup_skill"], "sample-module-setup")
|
||||
self.assertEqual(data["files_to_delete"], ["sample-module-setup/"])
|
||||
self.assertEqual(
|
||||
[(r["skill"], r["path"], r["line"]) for r in data["legacy_reads"]],
|
||||
[
|
||||
("bmad-agent-dream-weaver", "bmad-agent-dream-weaver/SKILL.md", 6),
|
||||
("bmad-agent-dream-weaver", "bmad-agent-dream-weaver/SKILL.md", 7),
|
||||
],
|
||||
)
|
||||
self.assertIn("sample-module-setup", data["legacy_reads"][0]["text"])
|
||||
|
||||
def test_single_skill_module_with_module_setup(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "bmad-agent-dream-weaver"
|
||||
write(
|
||||
root,
|
||||
{
|
||||
"SKILL.md": DREAM_SKILL_MD.replace(
|
||||
"run the `sample-module-setup` skill", "load `assets/module-setup.md`"
|
||||
),
|
||||
"assets/module.yaml": "code: dw\nname: Dream Weaver\nmodule_version: 1.0.0\nmodule_greeting: Hi.\n",
|
||||
"assets/module-help.csv": HELP_CSV.splitlines()[0] + "\n" + HELP_CSV.splitlines()[2] + "\n",
|
||||
"assets/module-setup.md": "Writes `{project-root}/_bmad/config.yaml`.\n",
|
||||
"scripts/merge-config.py": "print(1)\n",
|
||||
"scripts/merge-help-csv.py": "print(1)\n",
|
||||
"scripts/seed_tracker.py": "print(1)\n",
|
||||
},
|
||||
)
|
||||
code, data, err = run(root)
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(data["module"]["code"], "dw")
|
||||
self.assertEqual(data["module"]["agents"], [])
|
||||
self.assertEqual(data["config_keys"], [])
|
||||
self.assertIsNone(data["setup_skill"])
|
||||
self.assertEqual(data["skills"], ["bmad-agent-dream-weaver"])
|
||||
self.assertEqual(
|
||||
data["files_to_delete"],
|
||||
[
|
||||
"assets/module-help.csv",
|
||||
"assets/module-setup.md",
|
||||
"assets/module.yaml",
|
||||
"scripts/merge-config.py",
|
||||
"scripts/merge-help-csv.py",
|
||||
],
|
||||
)
|
||||
self.assertEqual([r["line"] for r in data["legacy_reads"]], [6, 7])
|
||||
self.assertEqual(data["legacy_reads"][0]["skill"], "bmad-agent-dream-weaver")
|
||||
|
||||
def test_no_module_yaml_and_bad_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, data, err = run(Path(tmp))
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("no module.yaml", err)
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for scan_paths.py: each rule fires on its case and stays quiet on the conventions it enforces."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scan_paths.py"
|
||||
|
||||
|
||||
def make_skill(root: Path, files: dict[str, str]) -> Path:
|
||||
skill = root / "bmad-demo"
|
||||
for rel, text in files.items():
|
||||
path = skill / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return skill
|
||||
|
||||
|
||||
def scan(skill: Path):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), str(skill)], capture_output=True, text=True)
|
||||
return res.returncode, json.loads(res.stdout)
|
||||
|
||||
|
||||
def rules(data):
|
||||
return sorted((f["path"], f["rule"]) for f in data["findings"])
|
||||
|
||||
|
||||
class ScanPathsTest(unittest.TestCase):
|
||||
def test_python_call_fires_and_uv_run_does_not(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp),
|
||||
{
|
||||
"SKILL.md": (
|
||||
"---\nname: bmad-demo\ndescription: 'Demo. Use when testing.'\n---\n\n"
|
||||
"Run `python3 scripts/go.py` then `python -m json.tool out.json`.\n"
|
||||
"```\npip install rich\n```\n"
|
||||
"Written in Python with argparse. Fine: `uv run {skill-root}/scripts/go.py`.\n"
|
||||
),
|
||||
"scripts/go.py": "print(1)\n",
|
||||
},
|
||||
)
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 1)
|
||||
hits = [f for f in data["findings"] if f["rule"] == "python-call"]
|
||||
self.assertEqual([f["text"] for f in hits], ["python3 scripts/go.py", "python -m json.tool", "pip install"])
|
||||
self.assertEqual(len(data["findings"]), 3, data["findings"])
|
||||
|
||||
def test_clean_skill_has_no_findings(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp),
|
||||
{
|
||||
"SKILL.md": (
|
||||
"---\nname: bmad-demo\ndescription: 'Demo. Use when testing.'\n---\n\n"
|
||||
"Run `uv run {skill-root}/scripts/go.py` then read `references/notes.md`.\n"
|
||||
"Runtime: `uv run {project-root}/_bmad/scripts/memlog.py`.\n"
|
||||
"Invoke the `bmad-eval` skill. Output goes to `{output_folder}/report.md`.\n"
|
||||
),
|
||||
"references/notes.md": "Back to `../SKILL.md` and `scripts/go.py`.\n",
|
||||
"scripts/go.py": "print(1)\n",
|
||||
"assets/SKILL-template.md": "Emits `references/made-up.md`.\n",
|
||||
"sample-output.md": "Shows `references/other-made-up.md`.\n",
|
||||
},
|
||||
)
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 0, data)
|
||||
self.assertEqual(data["findings"], [])
|
||||
self.assertEqual(data["skill"], "bmad-demo")
|
||||
self.assertEqual(data["files_scanned"], 4)
|
||||
|
||||
def test_each_rule_fires(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp),
|
||||
{
|
||||
"SKILL.md": (
|
||||
"---\nname: bmad-demo\n---\n\n"
|
||||
"Run `uv run scripts/go.py`.\n"
|
||||
"Also `uv run --no-cache ./scripts/go.py`.\n"
|
||||
"Old: {installed_path}/x.md\n"
|
||||
"Mine: /Users/someone/skills/x.md and ~/.claude/skills/y.md\n"
|
||||
"Read `references/missing.md`.\n"
|
||||
"Borrow `skills/bmad-other/references/canon.md`.\n"
|
||||
"Also `{project-root}/_bmad/bmad-other/SKILL.md` but not `{project-root}/_bmad/scripts/memlog.py`.\n"
|
||||
"Describe it in module.yaml and module-help.csv.\n"
|
||||
"```\nuv run scripts/in-fence.py\n```\n"
|
||||
),
|
||||
"references/a.md": "Sibling `../../bmad-other/SKILL.md`. Fine: `../SKILL.md`.\n",
|
||||
"scripts/go.py": "print(1)\n",
|
||||
},
|
||||
)
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 1)
|
||||
found = rules(data)
|
||||
self.assertEqual(found.count(("SKILL.md", "bare-script-call")), 3)
|
||||
self.assertIn(("SKILL.md", "installed-path"), found)
|
||||
self.assertEqual(found.count(("SKILL.md", "absolute-path")), 2)
|
||||
self.assertEqual(found.count(("SKILL.md", "missing-file")), 1)
|
||||
self.assertEqual(found.count(("SKILL.md", "cross-skill-ref")), 2)
|
||||
self.assertEqual(found.count(("SKILL.md", "old-module-format")), 2)
|
||||
self.assertEqual(found.count(("references/a.md", "cross-skill-ref")), 1)
|
||||
for f in data["findings"]:
|
||||
self.assertEqual(set(f), {"path", "line", "rule", "text", "fix"})
|
||||
self.assertGreater(f["line"], 0)
|
||||
|
||||
def test_allow_suppresses_a_rule(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp), {"SKILL.md": "Migrate module.yaml and module-help.csv; never `uv run scripts/x.py`.\n"}
|
||||
)
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(
|
||||
sorted(f["rule"] for f in data["findings"]),
|
||||
["bare-script-call", "old-module-format", "old-module-format"],
|
||||
)
|
||||
res = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(skill),
|
||||
"--allow",
|
||||
"old-module-format",
|
||||
"--allow",
|
||||
"bare-script-call",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(res.returncode, 0)
|
||||
self.assertEqual(json.loads(res.stdout)["findings"], [])
|
||||
res = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(skill), "--allow", "nope"], capture_output=True, text=True
|
||||
)
|
||||
self.assertEqual(res.returncode, 2)
|
||||
|
||||
def test_prose_path_without_existing_folder_is_not_flagged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(Path(tmp), {"SKILL.md": "Writes `docs/plan.md` for the user.\n"})
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 0, data)
|
||||
|
||||
def test_not_a_directory(self):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for scan_scripts.py: header, floor, test presence, network and model-id rules."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scan_scripts.py"
|
||||
|
||||
GOOD = '# /// script\n# requires-python = ">=3.11"\n# ///\n"""Good."""\nimport json\nprint(json.dumps({}))\n'
|
||||
OLD_FLOOR = '# /// script\n# requires-python = ">=3.9"\n# ///\nprint(1)\n'
|
||||
NO_FLOOR = "# /// script\n# dependencies = []\n# ///\nprint(1)\n"
|
||||
NO_HEADER = '#!/usr/bin/env python3\nimport requests\nMODEL = "claude-opus-4-1"\nprint(requests, MODEL)\n'
|
||||
BROKEN = '# /// script\n# requires-python = ">=3.11"\n# ///\ndef (:\n'
|
||||
CUSTOM_IO = '# /// script\n# requires-python = ">=3.11"\n# ///\nfrom pathlib import Path\np = Path("_bmad/custom") / "demo.user.toml"\nprint(p)\n'
|
||||
|
||||
|
||||
def make_skill(root: Path, scripts: dict[str, str], tests: tuple[str, ...] = ()) -> Path:
|
||||
skill = root / "bmad-demo"
|
||||
(skill / "scripts" / "tests").mkdir(parents=True)
|
||||
for name, text in scripts.items():
|
||||
(skill / "scripts" / name).write_text(text, encoding="utf-8")
|
||||
for name in tests:
|
||||
(skill / "scripts" / "tests" / name).write_text("import unittest\n", encoding="utf-8")
|
||||
return skill
|
||||
|
||||
|
||||
def scan(skill: Path):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), str(skill)], capture_output=True, text=True)
|
||||
return res.returncode, json.loads(res.stdout)
|
||||
|
||||
|
||||
class ScanScriptsTest(unittest.TestCase):
|
||||
def test_custom_io_fires_on_override_paths(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(Path(tmp), {"formats.py": CUSTOM_IO}, ("test_formats.py",))
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 1)
|
||||
hits = sorted((f["rule"], f["text"]) for f in data["findings"])
|
||||
self.assertEqual(hits, [("custom-io", "_bmad/custom"), ("custom-io", "demo.user.toml")])
|
||||
|
||||
def test_clean_script(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(Path(tmp), {"good.py": GOOD}, ("test_good.py",))
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 0, data)
|
||||
self.assertEqual(data["findings"], [])
|
||||
self.assertEqual(
|
||||
data["scripts"], [{"path": "scripts/good.py", "has_pep723": True, "floor": ">=3.11", "has_test": True}]
|
||||
)
|
||||
|
||||
def test_rules_fire(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = make_skill(
|
||||
Path(tmp),
|
||||
{"old.py": OLD_FLOOR, "nofloor.py": NO_FLOOR, "bare.py": NO_HEADER, "broken.py": BROKEN},
|
||||
("test_old.py", "test_nofloor.py", "test_broken.py"),
|
||||
)
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 1)
|
||||
found = sorted((f["path"], f["rule"]) for f in data["findings"])
|
||||
self.assertEqual(
|
||||
found,
|
||||
[
|
||||
("scripts/bare.py", "model-id"),
|
||||
("scripts/bare.py", "network-call"),
|
||||
("scripts/bare.py", "pep723-missing"),
|
||||
("scripts/bare.py", "test-missing"),
|
||||
("scripts/broken.py", "syntax-error"),
|
||||
("scripts/nofloor.py", "pep723-floor"),
|
||||
("scripts/old.py", "pep723-floor"),
|
||||
],
|
||||
)
|
||||
by_path = {s["path"]: s for s in data["scripts"]}
|
||||
self.assertFalse(by_path["scripts/bare.py"]["has_pep723"])
|
||||
self.assertEqual(by_path["scripts/old.py"]["floor"], ">=3.9")
|
||||
self.assertIsNone(by_path["scripts/nofloor.py"]["floor"])
|
||||
|
||||
def test_no_scripts_folder_is_clean(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skill = Path(tmp) / "bmad-demo"
|
||||
skill.mkdir()
|
||||
code, data = scan(skill)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(data, {"skill": "bmad-demo", "scripts": [], "findings": []})
|
||||
|
||||
def test_not_a_directory(self):
|
||||
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent"], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user