test-validate-module.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.10"
  4. # ///
  5. """Tests for validate-module.py"""
  6. import json
  7. import subprocess
  8. import sys
  9. import tempfile
  10. from pathlib import Path
  11. SCRIPT = Path(__file__).resolve().parent.parent / "validate-module.py"
  12. CSV_HEADER = "module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n"
  13. LEGACY_CSV_HEADER = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
  14. def create_module(tmp: Path, skills: list[str] | None = None, csv_rows: str = "",
  15. yaml_content: str = "", setup_name: str = "tst-setup") -> Path:
  16. """Create a minimal module structure for testing."""
  17. module_dir = tmp / "module"
  18. module_dir.mkdir()
  19. # Setup skill
  20. setup = module_dir / setup_name
  21. setup.mkdir()
  22. (setup / "SKILL.md").write_text("---\nname: " + setup_name + "\n---\n# Setup\n")
  23. (setup / "assets").mkdir()
  24. (setup / "assets" / "module.yaml").write_text(
  25. yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A test module"\n'
  26. )
  27. (setup / "assets" / "module-help.csv").write_text(CSV_HEADER + csv_rows)
  28. # Other skills
  29. for skill in (skills or []):
  30. skill_dir = module_dir / skill
  31. skill_dir.mkdir()
  32. (skill_dir / "SKILL.md").write_text(f"---\nname: {skill}\n---\n# {skill}\n")
  33. return module_dir
  34. def run_validate(module_dir: Path) -> tuple[int, dict]:
  35. """Run the validation script and return (exit_code, parsed_json)."""
  36. result = subprocess.run(
  37. [sys.executable, str(SCRIPT), str(module_dir)],
  38. capture_output=True, text=True,
  39. )
  40. try:
  41. data = json.loads(result.stdout)
  42. except json.JSONDecodeError:
  43. data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
  44. return result.returncode, data
  45. def test_valid_module():
  46. """A well-formed module should pass."""
  47. with tempfile.TemporaryDirectory() as tmp:
  48. tmp = Path(tmp)
  49. csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does the foo thing,run,,anytime,,,false,output_folder,report\n'
  50. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
  51. code, data = run_validate(module_dir)
  52. assert code == 0, f"Expected pass: {data}"
  53. assert data["status"] == "pass"
  54. assert data["summary"]["total_findings"] == 0
  55. def test_missing_setup_skill():
  56. """Module with no setup skill should fail critically."""
  57. with tempfile.TemporaryDirectory() as tmp:
  58. tmp = Path(tmp)
  59. module_dir = tmp / "module"
  60. module_dir.mkdir()
  61. skill = module_dir / "tst-foo"
  62. skill.mkdir()
  63. (skill / "SKILL.md").write_text("---\nname: tst-foo\n---\n")
  64. code, data = run_validate(module_dir)
  65. assert code == 1
  66. assert any(f["category"] == "structure" for f in data["findings"])
  67. def test_missing_csv_entry():
  68. """Skill without a CSV entry should be flagged."""
  69. with tempfile.TemporaryDirectory() as tmp:
  70. tmp = Path(tmp)
  71. module_dir = create_module(tmp, skills=["tst-foo", "tst-bar"],
  72. csv_rows='Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n')
  73. code, data = run_validate(module_dir)
  74. assert code == 1
  75. missing = [f for f in data["findings"] if f["category"] == "missing-entry"]
  76. assert len(missing) == 1
  77. assert "tst-bar" in missing[0]["message"]
  78. def test_orphan_csv_entry():
  79. """CSV entry for nonexistent skill should be flagged."""
  80. with tempfile.TemporaryDirectory() as tmp:
  81. tmp = Path(tmp)
  82. csv_rows = 'Test Module,tst-ghost,Ghost,GH,Does not exist,run,,anytime,,,false,output_folder,report\n'
  83. module_dir = create_module(tmp, skills=[], csv_rows=csv_rows)
  84. code, data = run_validate(module_dir)
  85. orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
  86. assert len(orphans) == 1
  87. assert "tst-ghost" in orphans[0]["message"]
  88. def test_duplicate_menu_codes():
  89. """Duplicate menu codes should be flagged."""
  90. with tempfile.TemporaryDirectory() as tmp:
  91. tmp = Path(tmp)
  92. csv_rows = (
  93. 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
  94. 'Test Module,tst-foo,Also Foo,DF,Also does foo,other,,anytime,,,false,output_folder,report\n'
  95. )
  96. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
  97. code, data = run_validate(module_dir)
  98. dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
  99. assert len(dupes) == 1
  100. assert "DF" in dupes[0]["message"]
  101. def test_invalid_before_after_ref():
  102. """Before/after references to nonexistent capabilities should be flagged."""
  103. with tempfile.TemporaryDirectory() as tmp:
  104. tmp = Path(tmp)
  105. csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,tst-ghost:phantom,,false,output_folder,report\n'
  106. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
  107. code, data = run_validate(module_dir)
  108. refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
  109. assert len(refs) == 1
  110. assert "tst-ghost:phantom" in refs[0]["message"]
  111. def test_missing_yaml_fields():
  112. """module.yaml with missing required fields should be flagged."""
  113. with tempfile.TemporaryDirectory() as tmp:
  114. tmp = Path(tmp)
  115. csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
  116. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows,
  117. yaml_content='code: tst\n')
  118. code, data = run_validate(module_dir)
  119. yaml_findings = [f for f in data["findings"] if f["category"] == "yaml"]
  120. assert len(yaml_findings) >= 1 # at least name or description missing
  121. def test_empty_csv():
  122. """CSV with header but no rows should be flagged."""
  123. with tempfile.TemporaryDirectory() as tmp:
  124. tmp = Path(tmp)
  125. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows="")
  126. code, data = run_validate(module_dir)
  127. assert code == 1
  128. empty = [f for f in data["findings"] if f["category"] == "csv-empty"]
  129. assert len(empty) == 1
  130. def test_canonical_header_accepted():
  131. """The canonical preceded-by/followed-by header must NOT produce a header finding."""
  132. with tempfile.TemporaryDirectory() as tmp:
  133. tmp = Path(tmp)
  134. csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
  135. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
  136. code, data = run_validate(module_dir)
  137. assert code == 0, f"expected a clean pass: {data}"
  138. assert data["status"] == "pass"
  139. header_findings = [f for f in data["findings"] if f["category"] == "csv-header"]
  140. assert header_findings == [], f"unexpected header findings: {header_findings}"
  141. def test_legacy_after_before_header_flagged():
  142. """A module-help.csv using the old after/before column names must be flagged as
  143. a header mismatch — canonical is preceded-by/followed-by (matches the templates
  144. and bmad-help). Regression for the CSV_HEADER drift in validate-module.py."""
  145. with tempfile.TemporaryDirectory() as tmp:
  146. tmp = Path(tmp)
  147. module_dir = tmp / "module"
  148. module_dir.mkdir()
  149. setup = module_dir / "tst-setup"
  150. setup.mkdir()
  151. (setup / "SKILL.md").write_text("---\nname: tst-setup\n---\n# Setup\n")
  152. (setup / "assets").mkdir()
  153. (setup / "assets" / "module.yaml").write_text(
  154. 'code: tst\nname: "Test Module"\ndescription: "A test module"\n'
  155. )
  156. (setup / "assets" / "module-help.csv").write_text(
  157. LEGACY_CSV_HEADER
  158. + 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
  159. )
  160. (module_dir / "tst-foo").mkdir()
  161. (module_dir / "tst-foo" / "SKILL.md").write_text("---\nname: tst-foo\n---\n# tst-foo\n")
  162. code, data = run_validate(module_dir)
  163. assert code == 1, f"expected fail (high-severity header finding): {data}"
  164. assert data["status"] == "fail"
  165. header_findings = [f for f in data["findings"] if f["category"] == "csv-header"]
  166. assert len(header_findings) == 1, f"expected a csv-header finding: {data['findings']}"
  167. msg = header_findings[0]["message"]
  168. # missing the new names, has the legacy ones
  169. assert "preceded-by" in msg and "followed-by" in msg
  170. assert "after" in msg and "before" in msg
  171. def test_short_row_does_not_crash():
  172. """A CSV row with fewer fields than the header must not crash the validator and
  173. must be reported as a column-count mismatch. DictReader fills the missing
  174. columns with None by default, so the validator's `.strip()` calls would raise
  175. AttributeError on a short row — restval="" keeps them safe. Regression test."""
  176. with tempfile.TemporaryDirectory() as tmp:
  177. tmp = Path(tmp)
  178. # Only 5 of the 13 columns present (the remaining 8 are missing entirely).
  179. csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo\n'
  180. module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
  181. code, data = run_validate(module_dir)
  182. # Valid JSON with findings means the script completed instead of crashing
  183. # with an uncaught traceback (which run_validate would surface as raw_*).
  184. assert "findings" in data, f"validator crashed instead of reporting: {data}"
  185. # A short row is a medium-severity finding: reported, but non-fatal.
  186. assert code == 0 and data["status"] == "pass", f"expected non-fatal pass: {data}"
  187. col_findings = [f for f in data["findings"] if f["category"] == "csv-columns"]
  188. assert len(col_findings) == 1, f"expected a csv-columns finding: {data['findings']}"
  189. assert "5 columns" in col_findings[0]["message"]
  190. def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
  191. csv_rows: str = "", yaml_content: str = "",
  192. include_setup_md: bool = True,
  193. include_merge_scripts: bool = True,
  194. merge_script_style: str = "dash") -> Path:
  195. """Create a minimal standalone module structure for testing.
  196. ``merge_script_style`` selects the merge-script naming form: "dash" for the
  197. scaffolder default (merge-config.py) or "underscore" for the importable form
  198. (merge_config.py). Both are valid.
  199. """
  200. module_dir = tmp / "module"
  201. module_dir.mkdir()
  202. skill = module_dir / skill_name
  203. skill.mkdir()
  204. (skill / "SKILL.md").write_text(f"---\nname: {skill_name}\n---\n# {skill_name}\n")
  205. assets = skill / "assets"
  206. assets.mkdir()
  207. (assets / "module.yaml").write_text(
  208. yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A standalone test module"\n'
  209. )
  210. if not csv_rows:
  211. csv_rows = f'Test Module,{skill_name},Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,artifact\n'
  212. (assets / "module-help.csv").write_text(CSV_HEADER + csv_rows)
  213. if include_setup_md:
  214. (assets / "module-setup.md").write_text("# Module Setup\nStandalone registration.\n")
  215. if include_merge_scripts:
  216. scripts = skill / "scripts"
  217. scripts.mkdir()
  218. if merge_script_style == "underscore":
  219. (scripts / "merge_config.py").write_text("# merge_config\n")
  220. (scripts / "merge_help_csv.py").write_text("# merge_help_csv\n")
  221. else:
  222. (scripts / "merge-config.py").write_text("# merge-config\n")
  223. (scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
  224. return module_dir
  225. def test_valid_standalone_module():
  226. """A well-formed standalone module should pass with standalone=true in info."""
  227. with tempfile.TemporaryDirectory() as tmp:
  228. tmp = Path(tmp)
  229. module_dir = create_standalone_module(tmp)
  230. code, data = run_validate(module_dir)
  231. assert code == 0, f"Expected pass: {data}"
  232. assert data["status"] == "pass"
  233. assert data["info"].get("standalone") is True
  234. assert data["summary"]["total_findings"] == 0
  235. def test_standalone_missing_module_setup_md():
  236. """Standalone module without assets/module-setup.md should fail."""
  237. with tempfile.TemporaryDirectory() as tmp:
  238. tmp = Path(tmp)
  239. module_dir = create_standalone_module(tmp, include_setup_md=False)
  240. code, data = run_validate(module_dir)
  241. assert code == 1
  242. structure_findings = [f for f in data["findings"] if f["category"] == "structure"]
  243. assert any("module-setup.md" in f["message"] for f in structure_findings)
  244. def test_standalone_missing_merge_scripts():
  245. """Standalone module without merge scripts should fail."""
  246. with tempfile.TemporaryDirectory() as tmp:
  247. tmp = Path(tmp)
  248. module_dir = create_standalone_module(tmp, include_merge_scripts=False)
  249. code, data = run_validate(module_dir)
  250. assert code == 1
  251. structure_findings = [f for f in data["findings"] if f["category"] == "structure"]
  252. assert any("merge-config.py" in f["message"] for f in structure_findings)
  253. def test_standalone_csv_validation():
  254. """Standalone module CSV should be validated the same as multi-skill."""
  255. with tempfile.TemporaryDirectory() as tmp:
  256. tmp = Path(tmp)
  257. # Duplicate menu codes
  258. csv_rows = (
  259. 'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
  260. 'Test Module,my-skill,Also Thing,DT,Also does thing,other,,anytime,,,false,output_folder,report\n'
  261. )
  262. module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
  263. code, data = run_validate(module_dir)
  264. dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
  265. assert len(dupes) == 1
  266. assert "DT" in dupes[0]["message"]
  267. def test_standalone_underscore_merge_scripts():
  268. """Importable underscore-named merge scripts (merge_config.py) should pass."""
  269. with tempfile.TemporaryDirectory() as tmp:
  270. tmp = Path(tmp)
  271. module_dir = create_standalone_module(tmp, merge_script_style="underscore")
  272. code, data = run_validate(module_dir)
  273. assert code == 0, f"Expected pass: {data}"
  274. assert data["status"] == "pass"
  275. assert data["info"].get("standalone") is True
  276. assert data["summary"]["total_findings"] == 0
  277. def test_standalone_cross_module_before_after_ref():
  278. """Bare (colon-less) preceded-by/followed-by refs are cross-module positional, not flagged."""
  279. with tempfile.TemporaryDirectory() as tmp:
  280. tmp = Path(tmp)
  281. csv_rows = ('Test Module,my-skill,Do Thing,DT,Does thing,,,anytime,'
  282. 'bmad-sprint-planning,bmad-retrospective,false,output_folder,artifact\n')
  283. module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
  284. code, data = run_validate(module_dir)
  285. assert code == 0, f"Expected pass: {data}"
  286. refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
  287. assert refs == [], f"Cross-module bare refs should not be flagged: {refs}"
  288. def test_standalone_given_skill_dir_directly():
  289. """Passing the standalone skill directory itself (not its parent) should work."""
  290. with tempfile.TemporaryDirectory() as tmp:
  291. tmp = Path(tmp)
  292. module_dir = create_standalone_module(tmp, skill_name="my-skill")
  293. skill_dir = module_dir / "my-skill"
  294. code, data = run_validate(skill_dir)
  295. assert code == 0, f"Expected pass: {data}"
  296. assert data["status"] == "pass"
  297. assert data["info"].get("standalone") is True
  298. assert data["info"].get("skill_dir") == "my-skill"
  299. def test_standalone_skill_dir_orphan_not_masked_by_sibling():
  300. """Validating a skill dir directly must still flag a CSV skill that only
  301. exists as an unrelated sibling directory (not part of this standalone module)."""
  302. with tempfile.TemporaryDirectory() as tmp:
  303. tmp = Path(tmp)
  304. csv_rows = (
  305. 'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
  306. 'Test Module,other-skill,Other,OT,Other thing,run,,anytime,,,false,output_folder,report\n'
  307. )
  308. module_dir = create_standalone_module(tmp, skill_name="my-skill", csv_rows=csv_rows)
  309. # A sibling skill dir next to the standalone skill (a different module).
  310. sibling = module_dir / "other-skill"
  311. sibling.mkdir()
  312. (sibling / "SKILL.md").write_text("---\nname: other-skill\n---\n# other-skill\n")
  313. skill_dir = module_dir / "my-skill"
  314. code, data = run_validate(skill_dir)
  315. assert code == 1, f"Orphan entry should fail validation: {data}"
  316. orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
  317. assert any("other-skill" in f["message"] for f in orphans), \
  318. f"Sibling skill must not mask the orphan: {data['findings']}"
  319. def test_multi_skill_not_detected_as_standalone():
  320. """A folder with two skills and no setup skill should fail (not detected as standalone)."""
  321. with tempfile.TemporaryDirectory() as tmp:
  322. tmp = Path(tmp)
  323. module_dir = tmp / "module"
  324. module_dir.mkdir()
  325. for name in ("skill-a", "skill-b"):
  326. skill = module_dir / name
  327. skill.mkdir()
  328. (skill / "SKILL.md").write_text(f"---\nname: {name}\n---\n")
  329. (skill / "assets").mkdir()
  330. (skill / "assets" / "module.yaml").write_text(f'code: tst\nname: "Test"\ndescription: "Test"\n')
  331. code, data = run_validate(module_dir)
  332. assert code == 1
  333. # Should fail because it's neither a setup-skill module nor a single-skill standalone
  334. assert any("No setup skill found" in f["message"] for f in data["findings"])
  335. def test_nonexistent_directory():
  336. """Nonexistent path should return error."""
  337. result = subprocess.run(
  338. [sys.executable, str(SCRIPT), "/nonexistent/path"],
  339. capture_output=True, text=True,
  340. )
  341. assert result.returncode == 2
  342. data = json.loads(result.stdout)
  343. assert data["status"] == "error"
  344. if __name__ == "__main__":
  345. tests = [
  346. test_valid_module,
  347. test_missing_setup_skill,
  348. test_missing_csv_entry,
  349. test_orphan_csv_entry,
  350. test_duplicate_menu_codes,
  351. test_invalid_before_after_ref,
  352. test_missing_yaml_fields,
  353. test_empty_csv,
  354. test_canonical_header_accepted,
  355. test_legacy_after_before_header_flagged,
  356. test_short_row_does_not_crash,
  357. test_valid_standalone_module,
  358. test_standalone_missing_module_setup_md,
  359. test_standalone_missing_merge_scripts,
  360. test_standalone_csv_validation,
  361. test_standalone_underscore_merge_scripts,
  362. test_standalone_cross_module_before_after_ref,
  363. test_standalone_given_skill_dir_directly,
  364. test_standalone_skill_dir_orphan_not_masked_by_sibling,
  365. test_multi_skill_not_detected_as_standalone,
  366. test_nonexistent_directory,
  367. ]
  368. passed = 0
  369. failed = 0
  370. for test in tests:
  371. try:
  372. test()
  373. print(f" PASS: {test.__name__}")
  374. passed += 1
  375. except AssertionError as e:
  376. print(f" FAIL: {test.__name__}: {e}")
  377. failed += 1
  378. except Exception as e:
  379. print(f" ERROR: {test.__name__}: {e}")
  380. failed += 1
  381. print(f"\n{passed} passed, {failed} failed")
  382. sys.exit(1 if failed else 0)