Files
Atay-Makhzan/.agents/skills/bmad-module-builder/scripts/validate-module.py
T

349 lines
14 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Validate a BMad module's structure and help CSV integrity.
Supports two module types:
- Multi-skill modules with a dedicated setup skill (*-setup directory)
- Standalone single-skill modules with self-registration (assets/module-setup.md)
Performs deterministic structural checks:
- Required files exist (setup skill or standalone structure)
- All skill folders have at least one capability entry in the CSV
- No orphan CSV entries pointing to nonexistent skills
- Menu codes are unique
- preceded-by/followed-by references point to real capability entries
- Required module.yaml fields are present
- CSV column count is consistent
"""
import argparse
import csv
import json
import sys
from io import StringIO
from pathlib import Path
REQUIRED_YAML_FIELDS = {"code", "name", "description"}
CSV_HEADER = [
"module", "skill", "display-name", "menu-code", "description",
"action", "args", "phase", "preceded-by", "followed-by", "required",
"output-location", "outputs",
]
def find_setup_skill(module_dir: Path) -> Path | None:
"""Find the setup skill folder (*-setup)."""
for d in module_dir.iterdir():
if d.is_dir() and d.name.endswith("-setup"):
return d
return None
def find_skill_folders(module_dir: Path, exclude_name: str = "") -> list[str]:
"""Find all skill folders (directories with SKILL.md), optionally excluding one."""
skills = []
for d in module_dir.iterdir():
if d.is_dir() and d.name != exclude_name and (d / "SKILL.md").is_file():
skills.append(d.name)
return sorted(skills)
def detect_standalone_module(module_dir: Path) -> Path | None:
"""Detect a standalone module: a single skill folder with assets/module.yaml.
Works whether ``module_dir`` is the parent folder that contains the skill, or
the standalone skill directory itself (the path a user is most likely to hand
over for a single-skill module).
"""
# Given the skill directory directly.
if (module_dir / "SKILL.md").is_file() and (module_dir / "assets" / "module.yaml").is_file():
return module_dir
# Given the parent folder containing exactly one skill.
skill_dirs = [
d for d in module_dir.iterdir()
if d.is_dir() and (d / "SKILL.md").is_file()
]
if len(skill_dirs) == 1:
candidate = skill_dirs[0]
if (candidate / "assets" / "module.yaml").is_file():
return candidate
return None
def parse_yaml_minimal(text: str) -> dict[str, str]:
"""Parse top-level YAML key-value pairs (no nested structures)."""
result = {}
for line in text.splitlines():
line = line.strip()
if ":" in line and not line.startswith("#") and not line.startswith("-"):
key, _, value = line.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
if value and not value.startswith(">"):
result[key] = value
return result
def parse_csv_rows(csv_text: str) -> tuple[list[str], list[dict[str, str]], list[int]]:
"""Parse CSV text into (header, row dicts, raw column count per data row).
``restval=""`` fills missing trailing fields in a short row with empty strings
instead of ``None``, so downstream ``.strip()`` calls stay safe on malformed
rows. DictReader pads short rows to the header width, so ``len(row)`` cannot
reveal a field shortfall; the raw per-row column counts from ``csv.reader``
(blank lines skipped, to stay aligned with DictReader) are returned separately
for the column-count consistency check.
"""
reader = csv.DictReader(StringIO(csv_text), restval="")
header = reader.fieldnames or []
rows = list(reader)
raw_rows = list(csv.reader(StringIO(csv_text)))
col_counts = [len(r) for r in raw_rows[1:] if r != []]
return header, rows, col_counts
def validate(module_dir: Path, verbose: bool = False) -> dict:
"""Run all structural validations. Returns JSON-serializable result."""
findings: list[dict] = []
info: dict = {}
def finding(severity: str, category: str, message: str, detail: str = ""):
findings.append({
"severity": severity,
"category": category,
"message": message,
"detail": detail,
})
# 1. Find setup skill or detect standalone module
setup_dir = find_setup_skill(module_dir)
standalone_dir = None
if not setup_dir:
standalone_dir = detect_standalone_module(module_dir)
if not standalone_dir:
finding("critical", "structure",
"No setup skill found (*-setup directory) and no standalone module detected")
return {"status": "fail", "findings": findings, "info": info}
# Branch: standalone vs multi-skill
if standalone_dir:
info["standalone"] = True
info["skill_dir"] = standalone_dir.name
skill_dir = standalone_dir
# 2s. Check required files for standalone module
required_files = {
"assets/module.yaml": skill_dir / "assets" / "module.yaml",
"assets/module-help.csv": skill_dir / "assets" / "module-help.csv",
"assets/module-setup.md": skill_dir / "assets" / "module-setup.md",
}
# Merge scripts: accept either the dash form the scaffolder emits
# (merge-config.py) or the importable underscore form (merge_config.py).
# Both are valid — a module may rename them to be importable from
# module-setup.md without that being a structural defect.
required_any = {
"scripts/merge-config.py (or merge_config.py)": [
skill_dir / "scripts" / "merge-config.py",
skill_dir / "scripts" / "merge_config.py",
],
"scripts/merge-help-csv.py (or merge_help_csv.py)": [
skill_dir / "scripts" / "merge-help-csv.py",
skill_dir / "scripts" / "merge_help_csv.py",
],
}
ok = True
for label, path in required_files.items():
if not path.is_file():
finding("critical", "structure", f"Missing required file: {label}")
ok = False
for label, candidates in required_any.items():
if not any(p.is_file() for p in candidates):
finding("critical", "structure", f"Missing required file: {label}")
ok = False
if not ok:
return {"status": "fail", "findings": findings, "info": info}
yaml_dir = skill_dir
csv_dir = skill_dir
else:
info["setup_skill"] = setup_dir.name
# 2. Check required files in setup skill
required_files = {
"SKILL.md": setup_dir / "SKILL.md",
"assets/module.yaml": setup_dir / "assets" / "module.yaml",
"assets/module-help.csv": setup_dir / "assets" / "module-help.csv",
}
for label, path in required_files.items():
if not path.is_file():
finding("critical", "structure", f"Missing required file: {label}")
if not all(p.is_file() for p in required_files.values()):
return {"status": "fail", "findings": findings, "info": info}
yaml_dir = setup_dir
csv_dir = setup_dir
# 3. Validate module.yaml
yaml_text = (yaml_dir / "assets" / "module.yaml").read_text(encoding="utf-8")
yaml_data = parse_yaml_minimal(yaml_text)
info["module_code"] = yaml_data.get("code", "")
info["module_name"] = yaml_data.get("name", "")
for field in REQUIRED_YAML_FIELDS:
if not yaml_data.get(field):
finding("high", "yaml", f"module.yaml missing or empty required field: {field}")
# 4. Parse and validate CSV
csv_text = (csv_dir / "assets" / "module-help.csv").read_text(encoding="utf-8")
header, rows, col_counts = parse_csv_rows(csv_text)
# Check header
if header != CSV_HEADER:
missing = set(CSV_HEADER) - set(header)
extra = set(header) - set(CSV_HEADER)
detail_parts = []
if missing:
detail_parts.append(f"missing: {', '.join(sorted(missing))}")
if extra:
detail_parts.append(f"extra: {', '.join(sorted(extra))}")
finding("high", "csv-header", f"CSV header mismatch: {'; '.join(detail_parts)}")
if not rows:
finding("high", "csv-empty", "module-help.csv has no capability entries")
return {"status": "fail", "findings": findings, "info": info}
info["csv_entries"] = len(rows)
# 5. Check column count consistency (using raw field counts: DictReader pads
# short rows to the header width, so len(row) alone can't detect a shortfall)
expected_cols = len(CSV_HEADER)
for i, (row, n_cols) in enumerate(zip(rows, col_counts)):
if n_cols != expected_cols:
finding("medium", "csv-columns", f"Row {i + 2} has {n_cols} columns, expected {expected_cols}",
f"skill={row.get('skill', '?')}")
# 6. Collect skills from CSV and filesystem
csv_skills = {row.get("skill", "") for row in rows}
if standalone_dir:
# The only valid skill is the standalone skill itself, whether we were
# handed the module's parent folder or the skill directory directly.
skill_folders = [standalone_dir.name]
else:
skill_folders = find_skill_folders(module_dir, setup_dir.name)
info["skill_folders"] = skill_folders
info["csv_skills"] = sorted(csv_skills)
# 7. Skills without CSV entries
for skill in skill_folders:
if skill not in csv_skills:
finding("high", "missing-entry", f"Skill '{skill}' has no capability entries in the CSV")
# 8. Orphan CSV entries
setup_name = setup_dir.name if setup_dir else ""
for skill in csv_skills:
if skill in skill_folders or skill == setup_name:
continue
# For a standalone module, skill_folders already enumerates every valid
# skill, so any other CSV skill is an orphan — never look at the parent
# folder (which may hold unrelated sibling skills when validating a skill
# dir directly). For a multi-skill module, re-check the filesystem: the
# setup skill lives alongside the others and is excluded from skill_folders.
if standalone_dir or not (module_dir / skill / "SKILL.md").is_file():
finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
# 9. Unique menu codes
menu_codes: dict[str, list[str]] = {}
for row in rows:
code = row.get("menu-code", "").strip()
if code:
menu_codes.setdefault(code, []).append(row.get("display-name", "?"))
for code, names in menu_codes.items():
if len(names) > 1:
finding("high", "duplicate-menu-code", f"Menu code '{code}' used by multiple entries: {', '.join(names)}")
# 10. preceded-by/followed-by reference validation
# Build set of valid capability references (skill:action)
valid_refs = set()
for row in rows:
skill = row.get("skill", "").strip()
action = row.get("action", "").strip()
if skill and action:
valid_refs.add(f"{skill}:{action}")
for row in rows:
display = row.get("display-name", "?")
for field in ("preceded-by", "followed-by"):
value = row.get(field, "").strip()
if not value:
continue
# Can be comma-separated
for ref in value.split(","):
ref = ref.strip()
if not ref:
continue
# A colon-less ref is a cross-module positional reference (a bare
# sibling-module skill name, e.g. "bmad-sprint-planning"). Other
# installed modules aren't visible here, so it can't be resolved
# and isn't a defect — only validate intra-module skill:action refs.
if ":" not in ref:
continue
if ref not in valid_refs:
finding("medium", "invalid-ref",
f"'{display}' {field} references '{ref}' which is not a valid capability",
"Expected format: skill-name:action-name")
# 11. Required fields in each row
for row in rows:
display = row.get("display-name", "?")
for field in ("skill", "display-name", "menu-code", "description"):
if not row.get(field, "").strip():
finding("high", "missing-field", f"Entry '{display}' is missing required field: {field}")
# Summary
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
status = "pass" if severity_counts["critical"] == 0 and severity_counts["high"] == 0 else "fail"
return {
"status": status,
"info": info,
"findings": findings,
"summary": {
"total_findings": len(findings),
"by_severity": severity_counts,
},
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate a BMad module's setup skill structure and help CSV integrity"
)
parser.add_argument(
"module_dir",
help="Path to the module's skills folder (containing the setup skill and other skills)",
)
parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
args = parser.parse_args()
module_path = Path(args.module_dir)
if not module_path.is_dir():
print(json.dumps({"status": "error", "message": f"Not a directory: {module_path}"}))
return 2
result = validate(module_path, verbose=args.verbose)
print(json.dumps(result, indent=2))
return 0 if result["status"] == "pass" else 1
if __name__ == "__main__":
sys.exit(main())