validate-module.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.10"
  4. # ///
  5. """Validate a BMad module's structure and help CSV integrity.
  6. Supports two module types:
  7. - Multi-skill modules with a dedicated setup skill (*-setup directory)
  8. - Standalone single-skill modules with self-registration (assets/module-setup.md)
  9. Performs deterministic structural checks:
  10. - Required files exist (setup skill or standalone structure)
  11. - All skill folders have at least one capability entry in the CSV
  12. - No orphan CSV entries pointing to nonexistent skills
  13. - Menu codes are unique
  14. - preceded-by/followed-by references point to real capability entries
  15. - Required module.yaml fields are present
  16. - CSV column count is consistent
  17. """
  18. import argparse
  19. import csv
  20. import json
  21. import sys
  22. from io import StringIO
  23. from pathlib import Path
  24. REQUIRED_YAML_FIELDS = {"code", "name", "description"}
  25. CSV_HEADER = [
  26. "module", "skill", "display-name", "menu-code", "description",
  27. "action", "args", "phase", "preceded-by", "followed-by", "required",
  28. "output-location", "outputs",
  29. ]
  30. def find_setup_skill(module_dir: Path) -> Path | None:
  31. """Find the setup skill folder (*-setup)."""
  32. for d in module_dir.iterdir():
  33. if d.is_dir() and d.name.endswith("-setup"):
  34. return d
  35. return None
  36. def find_skill_folders(module_dir: Path, exclude_name: str = "") -> list[str]:
  37. """Find all skill folders (directories with SKILL.md), optionally excluding one."""
  38. skills = []
  39. for d in module_dir.iterdir():
  40. if d.is_dir() and d.name != exclude_name and (d / "SKILL.md").is_file():
  41. skills.append(d.name)
  42. return sorted(skills)
  43. def detect_standalone_module(module_dir: Path) -> Path | None:
  44. """Detect a standalone module: a single skill folder with assets/module.yaml.
  45. Works whether ``module_dir`` is the parent folder that contains the skill, or
  46. the standalone skill directory itself (the path a user is most likely to hand
  47. over for a single-skill module).
  48. """
  49. # Given the skill directory directly.
  50. if (module_dir / "SKILL.md").is_file() and (module_dir / "assets" / "module.yaml").is_file():
  51. return module_dir
  52. # Given the parent folder containing exactly one skill.
  53. skill_dirs = [
  54. d for d in module_dir.iterdir()
  55. if d.is_dir() and (d / "SKILL.md").is_file()
  56. ]
  57. if len(skill_dirs) == 1:
  58. candidate = skill_dirs[0]
  59. if (candidate / "assets" / "module.yaml").is_file():
  60. return candidate
  61. return None
  62. def parse_yaml_minimal(text: str) -> dict[str, str]:
  63. """Parse top-level YAML key-value pairs (no nested structures)."""
  64. result = {}
  65. for line in text.splitlines():
  66. line = line.strip()
  67. if ":" in line and not line.startswith("#") and not line.startswith("-"):
  68. key, _, value = line.partition(":")
  69. key = key.strip()
  70. value = value.strip().strip('"').strip("'")
  71. if value and not value.startswith(">"):
  72. result[key] = value
  73. return result
  74. def parse_csv_rows(csv_text: str) -> tuple[list[str], list[dict[str, str]], list[int]]:
  75. """Parse CSV text into (header, row dicts, raw column count per data row).
  76. ``restval=""`` fills missing trailing fields in a short row with empty strings
  77. instead of ``None``, so downstream ``.strip()`` calls stay safe on malformed
  78. rows. DictReader pads short rows to the header width, so ``len(row)`` cannot
  79. reveal a field shortfall; the raw per-row column counts from ``csv.reader``
  80. (blank lines skipped, to stay aligned with DictReader) are returned separately
  81. for the column-count consistency check.
  82. """
  83. reader = csv.DictReader(StringIO(csv_text), restval="")
  84. header = reader.fieldnames or []
  85. rows = list(reader)
  86. raw_rows = list(csv.reader(StringIO(csv_text)))
  87. col_counts = [len(r) for r in raw_rows[1:] if r != []]
  88. return header, rows, col_counts
  89. def validate(module_dir: Path, verbose: bool = False) -> dict:
  90. """Run all structural validations. Returns JSON-serializable result."""
  91. findings: list[dict] = []
  92. info: dict = {}
  93. def finding(severity: str, category: str, message: str, detail: str = ""):
  94. findings.append({
  95. "severity": severity,
  96. "category": category,
  97. "message": message,
  98. "detail": detail,
  99. })
  100. # 1. Find setup skill or detect standalone module
  101. setup_dir = find_setup_skill(module_dir)
  102. standalone_dir = None
  103. if not setup_dir:
  104. standalone_dir = detect_standalone_module(module_dir)
  105. if not standalone_dir:
  106. finding("critical", "structure",
  107. "No setup skill found (*-setup directory) and no standalone module detected")
  108. return {"status": "fail", "findings": findings, "info": info}
  109. # Branch: standalone vs multi-skill
  110. if standalone_dir:
  111. info["standalone"] = True
  112. info["skill_dir"] = standalone_dir.name
  113. skill_dir = standalone_dir
  114. # 2s. Check required files for standalone module
  115. required_files = {
  116. "assets/module.yaml": skill_dir / "assets" / "module.yaml",
  117. "assets/module-help.csv": skill_dir / "assets" / "module-help.csv",
  118. "assets/module-setup.md": skill_dir / "assets" / "module-setup.md",
  119. }
  120. # Merge scripts: accept either the dash form the scaffolder emits
  121. # (merge-config.py) or the importable underscore form (merge_config.py).
  122. # Both are valid — a module may rename them to be importable from
  123. # module-setup.md without that being a structural defect.
  124. required_any = {
  125. "scripts/merge-config.py (or merge_config.py)": [
  126. skill_dir / "scripts" / "merge-config.py",
  127. skill_dir / "scripts" / "merge_config.py",
  128. ],
  129. "scripts/merge-help-csv.py (or merge_help_csv.py)": [
  130. skill_dir / "scripts" / "merge-help-csv.py",
  131. skill_dir / "scripts" / "merge_help_csv.py",
  132. ],
  133. }
  134. ok = True
  135. for label, path in required_files.items():
  136. if not path.is_file():
  137. finding("critical", "structure", f"Missing required file: {label}")
  138. ok = False
  139. for label, candidates in required_any.items():
  140. if not any(p.is_file() for p in candidates):
  141. finding("critical", "structure", f"Missing required file: {label}")
  142. ok = False
  143. if not ok:
  144. return {"status": "fail", "findings": findings, "info": info}
  145. yaml_dir = skill_dir
  146. csv_dir = skill_dir
  147. else:
  148. info["setup_skill"] = setup_dir.name
  149. # 2. Check required files in setup skill
  150. required_files = {
  151. "SKILL.md": setup_dir / "SKILL.md",
  152. "assets/module.yaml": setup_dir / "assets" / "module.yaml",
  153. "assets/module-help.csv": setup_dir / "assets" / "module-help.csv",
  154. }
  155. for label, path in required_files.items():
  156. if not path.is_file():
  157. finding("critical", "structure", f"Missing required file: {label}")
  158. if not all(p.is_file() for p in required_files.values()):
  159. return {"status": "fail", "findings": findings, "info": info}
  160. yaml_dir = setup_dir
  161. csv_dir = setup_dir
  162. # 3. Validate module.yaml
  163. yaml_text = (yaml_dir / "assets" / "module.yaml").read_text(encoding="utf-8")
  164. yaml_data = parse_yaml_minimal(yaml_text)
  165. info["module_code"] = yaml_data.get("code", "")
  166. info["module_name"] = yaml_data.get("name", "")
  167. for field in REQUIRED_YAML_FIELDS:
  168. if not yaml_data.get(field):
  169. finding("high", "yaml", f"module.yaml missing or empty required field: {field}")
  170. # 4. Parse and validate CSV
  171. csv_text = (csv_dir / "assets" / "module-help.csv").read_text(encoding="utf-8")
  172. header, rows, col_counts = parse_csv_rows(csv_text)
  173. # Check header
  174. if header != CSV_HEADER:
  175. missing = set(CSV_HEADER) - set(header)
  176. extra = set(header) - set(CSV_HEADER)
  177. detail_parts = []
  178. if missing:
  179. detail_parts.append(f"missing: {', '.join(sorted(missing))}")
  180. if extra:
  181. detail_parts.append(f"extra: {', '.join(sorted(extra))}")
  182. finding("high", "csv-header", f"CSV header mismatch: {'; '.join(detail_parts)}")
  183. if not rows:
  184. finding("high", "csv-empty", "module-help.csv has no capability entries")
  185. return {"status": "fail", "findings": findings, "info": info}
  186. info["csv_entries"] = len(rows)
  187. # 5. Check column count consistency (using raw field counts: DictReader pads
  188. # short rows to the header width, so len(row) alone can't detect a shortfall)
  189. expected_cols = len(CSV_HEADER)
  190. for i, (row, n_cols) in enumerate(zip(rows, col_counts)):
  191. if n_cols != expected_cols:
  192. finding("medium", "csv-columns", f"Row {i + 2} has {n_cols} columns, expected {expected_cols}",
  193. f"skill={row.get('skill', '?')}")
  194. # 6. Collect skills from CSV and filesystem
  195. csv_skills = {row.get("skill", "") for row in rows}
  196. if standalone_dir:
  197. # The only valid skill is the standalone skill itself, whether we were
  198. # handed the module's parent folder or the skill directory directly.
  199. skill_folders = [standalone_dir.name]
  200. else:
  201. skill_folders = find_skill_folders(module_dir, setup_dir.name)
  202. info["skill_folders"] = skill_folders
  203. info["csv_skills"] = sorted(csv_skills)
  204. # 7. Skills without CSV entries
  205. for skill in skill_folders:
  206. if skill not in csv_skills:
  207. finding("high", "missing-entry", f"Skill '{skill}' has no capability entries in the CSV")
  208. # 8. Orphan CSV entries
  209. setup_name = setup_dir.name if setup_dir else ""
  210. for skill in csv_skills:
  211. if skill in skill_folders or skill == setup_name:
  212. continue
  213. # For a standalone module, skill_folders already enumerates every valid
  214. # skill, so any other CSV skill is an orphan — never look at the parent
  215. # folder (which may hold unrelated sibling skills when validating a skill
  216. # dir directly). For a multi-skill module, re-check the filesystem: the
  217. # setup skill lives alongside the others and is excluded from skill_folders.
  218. if standalone_dir or not (module_dir / skill / "SKILL.md").is_file():
  219. finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
  220. # 9. Unique menu codes
  221. menu_codes: dict[str, list[str]] = {}
  222. for row in rows:
  223. code = row.get("menu-code", "").strip()
  224. if code:
  225. menu_codes.setdefault(code, []).append(row.get("display-name", "?"))
  226. for code, names in menu_codes.items():
  227. if len(names) > 1:
  228. finding("high", "duplicate-menu-code", f"Menu code '{code}' used by multiple entries: {', '.join(names)}")
  229. # 10. preceded-by/followed-by reference validation
  230. # Build set of valid capability references (skill:action)
  231. valid_refs = set()
  232. for row in rows:
  233. skill = row.get("skill", "").strip()
  234. action = row.get("action", "").strip()
  235. if skill and action:
  236. valid_refs.add(f"{skill}:{action}")
  237. for row in rows:
  238. display = row.get("display-name", "?")
  239. for field in ("preceded-by", "followed-by"):
  240. value = row.get(field, "").strip()
  241. if not value:
  242. continue
  243. # Can be comma-separated
  244. for ref in value.split(","):
  245. ref = ref.strip()
  246. if not ref:
  247. continue
  248. # A colon-less ref is a cross-module positional reference (a bare
  249. # sibling-module skill name, e.g. "bmad-sprint-planning"). Other
  250. # installed modules aren't visible here, so it can't be resolved
  251. # and isn't a defect — only validate intra-module skill:action refs.
  252. if ":" not in ref:
  253. continue
  254. if ref not in valid_refs:
  255. finding("medium", "invalid-ref",
  256. f"'{display}' {field} references '{ref}' which is not a valid capability",
  257. "Expected format: skill-name:action-name")
  258. # 11. Required fields in each row
  259. for row in rows:
  260. display = row.get("display-name", "?")
  261. for field in ("skill", "display-name", "menu-code", "description"):
  262. if not row.get(field, "").strip():
  263. finding("high", "missing-field", f"Entry '{display}' is missing required field: {field}")
  264. # Summary
  265. severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
  266. for f in findings:
  267. severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
  268. status = "pass" if severity_counts["critical"] == 0 and severity_counts["high"] == 0 else "fail"
  269. return {
  270. "status": status,
  271. "info": info,
  272. "findings": findings,
  273. "summary": {
  274. "total_findings": len(findings),
  275. "by_severity": severity_counts,
  276. },
  277. }
  278. def main() -> int:
  279. parser = argparse.ArgumentParser(
  280. description="Validate a BMad module's setup skill structure and help CSV integrity"
  281. )
  282. parser.add_argument(
  283. "module_dir",
  284. help="Path to the module's skills folder (containing the setup skill and other skills)",
  285. )
  286. parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
  287. args = parser.parse_args()
  288. module_path = Path(args.module_dir)
  289. if not module_path.is_dir():
  290. print(json.dumps({"status": "error", "message": f"Not a directory: {module_path}"}))
  291. return 2
  292. result = validate(module_path, verbose=args.verbose)
  293. print(json.dumps(result, indent=2))
  294. return 0 if result["status"] == "pass" else 1
  295. if __name__ == "__main__":
  296. sys.exit(main())