feat: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Scaffold a BMad module setup skill from template.
|
||||
|
||||
Copies the setup-skill-template into the target directory as bmad-{code}-setup/,
|
||||
then writes the generated module.yaml and module-help.csv into the assets folder
|
||||
and updates the SKILL.md frontmatter with the module's identity.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scaffold a BMad module setup skill from template"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-dir",
|
||||
required=True,
|
||||
help="Directory to create the setup skill in (the user's skills folder)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module-code",
|
||||
required=True,
|
||||
help="Module code (2-4 letter abbreviation, e.g. 'cis')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module-name",
|
||||
required=True,
|
||||
help="Module display name (e.g. 'Creative Intelligence Suite')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module-yaml",
|
||||
required=True,
|
||||
help="Path to the generated module.yaml content file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module-csv",
|
||||
required=True,
|
||||
help="Path to the generated module-help.csv content file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="Print progress to stderr"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
template_dir = Path(__file__).resolve().parent.parent / "assets" / "setup-skill-template"
|
||||
setup_skill_name = f"bmad-{args.module_code}-setup"
|
||||
target = Path(args.target_dir) / setup_skill_name
|
||||
|
||||
if not template_dir.is_dir():
|
||||
print(
|
||||
json.dumps({"status": "error", "message": f"Template not found: {template_dir}"}),
|
||||
file=sys.stdout,
|
||||
)
|
||||
return 2
|
||||
|
||||
for source_path in [args.module_yaml, args.module_csv]:
|
||||
if not Path(source_path).is_file():
|
||||
print(
|
||||
json.dumps({"status": "error", "message": f"Source file not found: {source_path}"}),
|
||||
file=sys.stdout,
|
||||
)
|
||||
return 2
|
||||
|
||||
target_dir = Path(args.target_dir)
|
||||
if not target_dir.is_dir():
|
||||
print(
|
||||
json.dumps({"status": "error", "message": f"Target directory not found: {target_dir}"}),
|
||||
file=sys.stdout,
|
||||
)
|
||||
return 2
|
||||
|
||||
# Remove existing setup skill if present (anti-zombie)
|
||||
if target.exists():
|
||||
if args.verbose:
|
||||
print(f"Removing existing {setup_skill_name}/", file=sys.stderr)
|
||||
shutil.rmtree(target)
|
||||
|
||||
# Copy template
|
||||
if args.verbose:
|
||||
print(f"Copying template to {target}", file=sys.stderr)
|
||||
shutil.copytree(template_dir, target)
|
||||
|
||||
# Update SKILL.md frontmatter placeholders
|
||||
skill_md = target / "SKILL.md"
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
content = content.replace("{setup-skill-name}", setup_skill_name)
|
||||
content = content.replace("{module-name}", args.module_name)
|
||||
content = content.replace("{module-code}", args.module_code)
|
||||
skill_md.write_text(content, encoding="utf-8")
|
||||
|
||||
# Write generated module.yaml
|
||||
yaml_content = Path(args.module_yaml).read_text(encoding="utf-8")
|
||||
(target / "assets" / "module.yaml").write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
# Write generated module-help.csv
|
||||
csv_content = Path(args.module_csv).read_text(encoding="utf-8")
|
||||
(target / "assets" / "module-help.csv").write_text(csv_content, encoding="utf-8")
|
||||
|
||||
# Collect file list
|
||||
files_created = sorted(
|
||||
str(p.relative_to(target)) for p in target.rglob("*") if p.is_file()
|
||||
)
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"setup_skill": setup_skill_name,
|
||||
"location": str(target),
|
||||
"files_created": files_created,
|
||||
"files_count": len(files_created),
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Tests for scaffold-setup-skill.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "scaffold-setup-skill.py"
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "setup-skill-template"
|
||||
|
||||
|
||||
def run_scaffold(tmp: Path, **kwargs) -> tuple[int, dict]:
|
||||
"""Run the scaffold script and return (exit_code, parsed_json)."""
|
||||
target_dir = kwargs.get("target_dir", str(tmp / "output"))
|
||||
Path(target_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
module_code = kwargs.get("module_code", "tst")
|
||||
module_name = kwargs.get("module_name", "Test Module")
|
||||
|
||||
yaml_path = tmp / "module.yaml"
|
||||
csv_path = tmp / "module-help.csv"
|
||||
yaml_path.write_text(kwargs.get("yaml_content", f'code: {module_code}\nname: "{module_name}"\n'))
|
||||
csv_path.write_text(
|
||||
kwargs.get(
|
||||
"csv_content",
|
||||
"module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
|
||||
f'{module_name},bmad-{module_code}-example,Example,EX,An example skill,do-thing,,anytime,,,false,output_folder,artifact\n',
|
||||
)
|
||||
)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--target-dir", target_dir,
|
||||
"--module-code", module_code,
|
||||
"--module-name", module_name,
|
||||
"--module-yaml", str(yaml_path),
|
||||
"--module-csv", str(csv_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, data
|
||||
|
||||
|
||||
def test_basic_scaffold():
|
||||
"""Test that scaffolding creates the expected structure."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
code, data = run_scaffold(tmp, target_dir=str(target_dir))
|
||||
assert code == 0, f"Script failed: {data}"
|
||||
assert data["status"] == "success"
|
||||
assert data["setup_skill"] == "bmad-tst-setup"
|
||||
|
||||
setup_dir = target_dir / "bmad-tst-setup"
|
||||
assert setup_dir.is_dir()
|
||||
assert (setup_dir / "SKILL.md").is_file()
|
||||
assert (setup_dir / "scripts" / "merge-config.py").is_file()
|
||||
assert (setup_dir / "scripts" / "merge-help-csv.py").is_file()
|
||||
assert (setup_dir / "scripts" / "cleanup-legacy.py").is_file()
|
||||
assert (setup_dir / "assets" / "module.yaml").is_file()
|
||||
assert (setup_dir / "assets" / "module-help.csv").is_file()
|
||||
|
||||
|
||||
def test_skill_md_frontmatter_substitution():
|
||||
"""Test that SKILL.md placeholders are replaced."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
code, data = run_scaffold(
|
||||
tmp,
|
||||
target_dir=str(target_dir),
|
||||
module_code="xyz",
|
||||
module_name="XYZ Studio",
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
skill_md = (target_dir / "bmad-xyz-setup" / "SKILL.md").read_text()
|
||||
assert "bmad-xyz-setup" in skill_md
|
||||
assert "XYZ Studio" in skill_md
|
||||
assert "{setup-skill-name}" not in skill_md
|
||||
assert "{module-name}" not in skill_md
|
||||
assert "{module-code}" not in skill_md
|
||||
|
||||
|
||||
def test_generated_files_written():
|
||||
"""Test that module.yaml and module-help.csv contain generated content."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
custom_yaml = 'code: abc\nname: "ABC Module"\ndescription: "Custom desc"\n'
|
||||
custom_csv = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\nABC Module,bmad-abc-thing,Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,report\n"
|
||||
|
||||
code, data = run_scaffold(
|
||||
tmp,
|
||||
target_dir=str(target_dir),
|
||||
module_code="abc",
|
||||
module_name="ABC Module",
|
||||
yaml_content=custom_yaml,
|
||||
csv_content=custom_csv,
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
yaml_content = (target_dir / "bmad-abc-setup" / "assets" / "module.yaml").read_text()
|
||||
assert "ABC Module" in yaml_content
|
||||
assert "Custom desc" in yaml_content
|
||||
|
||||
csv_content = (target_dir / "bmad-abc-setup" / "assets" / "module-help.csv").read_text()
|
||||
assert "bmad-abc-thing" in csv_content
|
||||
assert "DT" in csv_content
|
||||
|
||||
|
||||
def test_anti_zombie_replaces_existing():
|
||||
"""Test that an existing setup skill is replaced cleanly."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
# First scaffold
|
||||
run_scaffold(tmp, target_dir=str(target_dir))
|
||||
stale_file = target_dir / "bmad-tst-setup" / "stale-marker.txt"
|
||||
stale_file.write_text("should be removed")
|
||||
|
||||
# Second scaffold should remove stale file
|
||||
code, data = run_scaffold(tmp, target_dir=str(target_dir))
|
||||
assert code == 0
|
||||
assert not stale_file.exists()
|
||||
|
||||
|
||||
def test_missing_target_dir():
|
||||
"""Test error when target directory doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
nonexistent = tmp / "nonexistent"
|
||||
|
||||
# Write valid source files
|
||||
yaml_path = tmp / "module.yaml"
|
||||
csv_path = tmp / "module-help.csv"
|
||||
yaml_path.write_text('code: tst\nname: "Test"\n')
|
||||
csv_path.write_text("header\n")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--target-dir", str(nonexistent),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
"--module-yaml", str(yaml_path),
|
||||
"--module-csv", str(csv_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
def test_missing_source_file():
|
||||
"""Test error when module.yaml source doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
# Remove the yaml after creation to simulate missing file
|
||||
yaml_path = tmp / "module.yaml"
|
||||
csv_path = tmp / "module-help.csv"
|
||||
csv_path.write_text("header\n")
|
||||
# Don't create yaml_path
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--target-dir", str(target_dir),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
"--module-yaml", str(yaml_path),
|
||||
"--module-csv", str(csv_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
test_basic_scaffold,
|
||||
test_skill_md_frontmatter_substitution,
|
||||
test_generated_files_written,
|
||||
test_anti_zombie_replaces_existing,
|
||||
test_missing_target_dir,
|
||||
test_missing_source_file,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Tests for validate-module.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "validate-module.py"
|
||||
|
||||
CSV_HEADER = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
|
||||
|
||||
|
||||
def create_module(tmp: Path, skills: list[str] | None = None, csv_rows: str = "",
|
||||
yaml_content: str = "", setup_name: str = "bmad-tst-setup") -> Path:
|
||||
"""Create a minimal module structure for testing."""
|
||||
module_dir = tmp / "module"
|
||||
module_dir.mkdir()
|
||||
|
||||
# Setup skill
|
||||
setup = module_dir / setup_name
|
||||
setup.mkdir()
|
||||
(setup / "SKILL.md").write_text("---\nname: " + setup_name + "\n---\n# Setup\n")
|
||||
(setup / "assets").mkdir()
|
||||
(setup / "assets" / "module.yaml").write_text(
|
||||
yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A test module"\n'
|
||||
)
|
||||
(setup / "assets" / "module-help.csv").write_text(CSV_HEADER + csv_rows)
|
||||
|
||||
# Other skills
|
||||
for skill in (skills or []):
|
||||
skill_dir = module_dir / skill
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(f"---\nname: {skill}\n---\n# {skill}\n")
|
||||
|
||||
return module_dir
|
||||
|
||||
|
||||
def run_validate(module_dir: Path) -> tuple[int, dict]:
|
||||
"""Run the validation script and return (exit_code, parsed_json)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(module_dir)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, data
|
||||
|
||||
|
||||
def test_valid_module():
|
||||
"""A well-formed module should pass."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,bmad-tst-foo,Do Foo,DF,Does the foo thing,run,,anytime,,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=["bmad-tst-foo"], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 0, f"Expected pass: {data}"
|
||||
assert data["status"] == "pass"
|
||||
assert data["summary"]["total_findings"] == 0
|
||||
|
||||
|
||||
def test_missing_setup_skill():
|
||||
"""Module with no setup skill should fail critically."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = tmp / "module"
|
||||
module_dir.mkdir()
|
||||
skill = module_dir / "bmad-tst-foo"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("---\nname: bmad-tst-foo\n---\n")
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
assert any(f["category"] == "structure" for f in data["findings"])
|
||||
|
||||
|
||||
def test_missing_csv_entry():
|
||||
"""Skill without a CSV entry should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_module(tmp, skills=["bmad-tst-foo", "bmad-tst-bar"],
|
||||
csv_rows='Test Module,bmad-tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n')
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
missing = [f for f in data["findings"] if f["category"] == "missing-entry"]
|
||||
assert len(missing) == 1
|
||||
assert "bmad-tst-bar" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_orphan_csv_entry():
|
||||
"""CSV entry for nonexistent skill should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,bmad-tst-ghost,Ghost,GH,Does not exist,run,,anytime,,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=[], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
|
||||
assert len(orphans) == 1
|
||||
assert "bmad-tst-ghost" in orphans[0]["message"]
|
||||
|
||||
|
||||
def test_duplicate_menu_codes():
|
||||
"""Duplicate menu codes should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = (
|
||||
'Test Module,bmad-tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
|
||||
'Test Module,bmad-tst-foo,Also Foo,DF,Also does foo,other,,anytime,,,false,output_folder,report\n'
|
||||
)
|
||||
module_dir = create_module(tmp, skills=["bmad-tst-foo"], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
|
||||
assert len(dupes) == 1
|
||||
assert "DF" in dupes[0]["message"]
|
||||
|
||||
|
||||
def test_invalid_before_after_ref():
|
||||
"""Before/after references to nonexistent capabilities should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,bmad-tst-foo,Do Foo,DF,Does foo,run,,anytime,bmad-tst-ghost:phantom,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=["bmad-tst-foo"], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
|
||||
assert len(refs) == 1
|
||||
assert "bmad-tst-ghost:phantom" in refs[0]["message"]
|
||||
|
||||
|
||||
def test_missing_yaml_fields():
|
||||
"""module.yaml with missing required fields should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,bmad-tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=["bmad-tst-foo"], csv_rows=csv_rows,
|
||||
yaml_content='code: tst\n')
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
yaml_findings = [f for f in data["findings"] if f["category"] == "yaml"]
|
||||
assert len(yaml_findings) >= 1 # at least name or description missing
|
||||
|
||||
|
||||
def test_empty_csv():
|
||||
"""CSV with header but no rows should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_module(tmp, skills=["bmad-tst-foo"], csv_rows="")
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
empty = [f for f in data["findings"] if f["category"] == "csv-empty"]
|
||||
assert len(empty) == 1
|
||||
|
||||
|
||||
def test_nonexistent_directory():
|
||||
"""Nonexistent path should return error."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "/nonexistent/path"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
test_valid_module,
|
||||
test_missing_setup_skill,
|
||||
test_missing_csv_entry,
|
||||
test_orphan_csv_entry,
|
||||
test_duplicate_menu_codes,
|
||||
test_invalid_before_after_ref,
|
||||
test_missing_yaml_fields,
|
||||
test_empty_csv,
|
||||
test_nonexistent_directory,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
242
.agents/skills/bmad-module-builder/scripts/validate-module.py
Normal file
242
.agents/skills/bmad-module-builder/scripts/validate-module.py
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Validate a BMad module's setup skill structure and help CSV integrity.
|
||||
|
||||
Performs deterministic structural checks:
|
||||
- Setup skill exists with required files (SKILL.md, assets/module.yaml, assets/module-help.csv)
|
||||
- All skill folders have at least one capability entry in the CSV
|
||||
- No orphan CSV entries pointing to nonexistent skills
|
||||
- Menu codes are unique
|
||||
- Before/after 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", "after", "before", "required",
|
||||
"output-location", "outputs",
|
||||
]
|
||||
|
||||
|
||||
def find_setup_skill(module_dir: Path) -> Path | None:
|
||||
"""Find the setup skill folder (bmad-*-setup)."""
|
||||
for d in module_dir.iterdir():
|
||||
if d.is_dir() and d.name.startswith("bmad-") and d.name.endswith("-setup"):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def find_skill_folders(module_dir: Path, setup_name: str) -> list[str]:
|
||||
"""Find all skill folders (directories with SKILL.md), excluding the setup skill."""
|
||||
skills = []
|
||||
for d in module_dir.iterdir():
|
||||
if d.is_dir() and d.name != setup_name and (d / "SKILL.md").is_file():
|
||||
skills.append(d.name)
|
||||
return sorted(skills)
|
||||
|
||||
|
||||
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]]]:
|
||||
"""Parse CSV text into header and list of row dicts."""
|
||||
reader = csv.DictReader(StringIO(csv_text))
|
||||
header = reader.fieldnames or []
|
||||
rows = list(reader)
|
||||
return header, rows
|
||||
|
||||
|
||||
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
|
||||
setup_dir = find_setup_skill(module_dir)
|
||||
if not setup_dir:
|
||||
finding("critical", "structure", "No setup skill found (bmad-*-setup directory)")
|
||||
return {"status": "fail", "findings": findings, "info": info}
|
||||
|
||||
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}
|
||||
|
||||
# 3. Validate module.yaml
|
||||
yaml_text = (setup_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 = (setup_dir / "assets" / "module-help.csv").read_text(encoding="utf-8")
|
||||
header, rows = 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
|
||||
expected_cols = len(CSV_HEADER)
|
||||
for i, row in enumerate(rows):
|
||||
if len(row) != expected_cols:
|
||||
finding("medium", "csv-columns", f"Row {i + 2} has {len(row)} 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}
|
||||
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
|
||||
for skill in csv_skills:
|
||||
if skill not in skill_folders and skill != setup_dir.name:
|
||||
# Check if it's the setup skill itself (valid)
|
||||
if 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. Before/after 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 ("after", "before"):
|
||||
value = row.get(field, "").strip()
|
||||
if not value:
|
||||
continue
|
||||
# Can be comma-separated
|
||||
for ref in value.split(","):
|
||||
ref = ref.strip()
|
||||
if ref and 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())
|
||||
Reference in New Issue
Block a user