153 lines
6.6 KiB
Python
153 lines
6.6 KiB
Python
"""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()
|