feat: Calculer automatiquement les moyennes après chaque saisie de notes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-03-30 06:22:03 +02:00
parent b70d5ec2ad
commit aedde6707e
694 changed files with 109792 additions and 75 deletions

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Remove legacy module directories from _bmad/ after config migration.
After merge-config.py and merge-help-csv.py have migrated config data and
deleted individual legacy files, this script removes the now-redundant
directory trees. These directories contain skill files that are already
installed at .claude/skills/ (or equivalent) — only the config files at
_bmad/ root need to persist.
When --skills-dir is provided, the script verifies that every skill found
in the legacy directories exists at the installed location before removing
anything. Directories without skills (like _config/) are removed directly.
Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error
"""
import argparse
import json
import shutil
import sys
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(
description="Remove legacy module directories from _bmad/ after config migration."
)
parser.add_argument(
"--bmad-dir",
required=True,
help="Path to the _bmad/ directory",
)
parser.add_argument(
"--module-code",
required=True,
help="Module code being cleaned up (e.g. 'bmb')",
)
parser.add_argument(
"--also-remove",
action="append",
default=[],
help="Additional directory names under _bmad/ to remove (repeatable)",
)
parser.add_argument(
"--skills-dir",
help="Path to .claude/skills/ — enables safety verification that skills "
"are installed before removing legacy copies",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress to stderr",
)
return parser.parse_args()
def find_skill_dirs(base_path: str) -> list:
"""Find directories that contain a SKILL.md file.
Walks the directory tree and returns the leaf directory name for each
directory containing a SKILL.md. These are considered skill directories.
Returns:
List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup'])
"""
skills = []
root = Path(base_path)
if not root.exists():
return skills
for skill_md in root.rglob("SKILL.md"):
skills.append(skill_md.parent.name)
return sorted(set(skills))
def verify_skills_installed(
bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False
) -> list:
"""Verify that skills in legacy directories exist at the installed location.
Scans each directory in dirs_to_check for skill folders (containing SKILL.md),
then checks that a matching directory exists under skills_dir. Directories
that contain no skills (like _config/) are silently skipped.
Returns:
List of verified skill names.
Raises SystemExit(1) if any skills are missing from skills_dir.
"""
all_verified = []
missing = []
for dirname in dirs_to_check:
legacy_path = Path(bmad_dir) / dirname
if not legacy_path.exists():
continue
skill_names = find_skill_dirs(str(legacy_path))
if not skill_names:
if verbose:
print(
f"No skills found in {dirname}/ — skipping verification",
file=sys.stderr,
)
continue
for skill_name in skill_names:
installed_path = Path(skills_dir) / skill_name
if installed_path.is_dir():
all_verified.append(skill_name)
if verbose:
print(
f"Verified: {skill_name} exists at {installed_path}",
file=sys.stderr,
)
else:
missing.append(skill_name)
if verbose:
print(
f"MISSING: {skill_name} not found at {installed_path}",
file=sys.stderr,
)
if missing:
error_result = {
"status": "error",
"error": "Skills not found at installed location",
"missing_skills": missing,
"skills_dir": str(Path(skills_dir).resolve()),
}
print(json.dumps(error_result, indent=2))
sys.exit(1)
return sorted(set(all_verified))
def count_files(path: Path) -> int:
"""Count all files recursively in a directory."""
count = 0
for item in path.rglob("*"):
if item.is_file():
count += 1
return count
def cleanup_directories(
bmad_dir: str, dirs_to_remove: list, verbose: bool = False
) -> tuple:
"""Remove specified directories under bmad_dir.
Returns:
(removed, not_found, total_files_removed) tuple
"""
removed = []
not_found = []
total_files = 0
for dirname in dirs_to_remove:
target = Path(bmad_dir) / dirname
if not target.exists():
not_found.append(dirname)
if verbose:
print(f"Not found (skipping): {target}", file=sys.stderr)
continue
if not target.is_dir():
if verbose:
print(f"Not a directory (skipping): {target}", file=sys.stderr)
not_found.append(dirname)
continue
file_count = count_files(target)
if verbose:
print(
f"Removing {target} ({file_count} files)",
file=sys.stderr,
)
try:
shutil.rmtree(target)
except OSError as e:
error_result = {
"status": "error",
"error": f"Failed to remove {target}: {e}",
"directories_removed": removed,
"directories_failed": dirname,
}
print(json.dumps(error_result, indent=2))
sys.exit(2)
removed.append(dirname)
total_files += file_count
return removed, not_found, total_files
def main():
args = parse_args()
bmad_dir = args.bmad_dir
module_code = args.module_code
# Build the list of directories to remove
dirs_to_remove = [module_code, "core"] + args.also_remove
# Deduplicate while preserving order
seen = set()
unique_dirs = []
for d in dirs_to_remove:
if d not in seen:
seen.add(d)
unique_dirs.append(d)
dirs_to_remove = unique_dirs
if args.verbose:
print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr)
# Safety check: verify skills are installed before removing
verified_skills = None
if args.skills_dir:
if args.verbose:
print(
f"Verifying skills installed at {args.skills_dir}",
file=sys.stderr,
)
verified_skills = verify_skills_installed(
bmad_dir, dirs_to_remove, args.skills_dir, args.verbose
)
# Remove directories
removed, not_found, total_files = cleanup_directories(
bmad_dir, dirs_to_remove, args.verbose
)
# Build result
result = {
"status": "success",
"bmad_dir": str(Path(bmad_dir).resolve()),
"directories_removed": removed,
"directories_not_found": not_found,
"files_removed_count": total_files,
}
if args.skills_dir:
result["safety_checks"] = {
"skills_verified": True,
"skills_dir": str(Path(args.skills_dir).resolve()),
"verified_skills": verified_skills,
}
else:
result["safety_checks"] = None
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = ["pyyaml"]
# ///
"""Merge module configuration into shared _bmad/config.yaml and config.user.yaml.
Reads a module.yaml definition and a JSON answers file, then writes or updates
the shared config.yaml (core values at root + module section) and config.user.yaml
(user_name, communication_language, plus any module variable with user_setting: true).
Uses an anti-zombie pattern for the module section in config.yaml.
Legacy migration: when --legacy-dir is provided, reads old per-module config files
from {legacy-dir}/{module-code}/config.yaml and {legacy-dir}/core/config.yaml.
Matching values serve as fallback defaults (answers override them). After a
successful merge, the legacy config.yaml files are deleted. Only the current
module and core directories are touched — other module directories are left alone.
Exit codes: 0=success, 1=validation error, 2=runtime error
"""
import argparse
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("Error: pyyaml is required (PEP 723 dependency)", file=sys.stderr)
sys.exit(2)
def parse_args():
parser = argparse.ArgumentParser(
description="Merge module config into shared _bmad/config.yaml with anti-zombie pattern."
)
parser.add_argument(
"--config-path",
required=True,
help="Path to the target _bmad/config.yaml file",
)
parser.add_argument(
"--module-yaml",
required=True,
help="Path to the module.yaml definition file",
)
parser.add_argument(
"--answers",
required=True,
help="Path to JSON file with collected answers",
)
parser.add_argument(
"--user-config-path",
required=True,
help="Path to the target _bmad/config.user.yaml file",
)
parser.add_argument(
"--legacy-dir",
help="Path to _bmad/ directory to check for legacy per-module config files. "
"Matching values are used as fallback defaults, then legacy files are deleted.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress to stderr",
)
return parser.parse_args()
def load_yaml_file(path: str) -> dict:
"""Load a YAML file, returning empty dict if file doesn't exist."""
file_path = Path(path)
if not file_path.exists():
return {}
with open(file_path, "r", encoding="utf-8") as f:
content = yaml.safe_load(f)
return content if content else {}
def load_json_file(path: str) -> dict:
"""Load a JSON file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
# Keys that live at config root (shared across all modules)
_CORE_KEYS = frozenset(
{"user_name", "communication_language", "document_output_language", "output_folder"}
)
def load_legacy_values(
legacy_dir: str, module_code: str, module_yaml: dict, verbose: bool = False
) -> tuple[dict, dict, list]:
"""Read legacy per-module config files and return core/module value dicts.
Reads {legacy_dir}/core/config.yaml and {legacy_dir}/{module_code}/config.yaml.
Only returns values whose keys match the current schema (core keys or module.yaml
variable definitions). Other modules' directories are not touched.
Returns:
(legacy_core, legacy_module, files_found) where files_found lists paths read.
"""
legacy_core: dict = {}
legacy_module: dict = {}
files_found: list = []
# Read core legacy config
core_path = Path(legacy_dir) / "core" / "config.yaml"
if core_path.exists():
core_data = load_yaml_file(str(core_path))
files_found.append(str(core_path))
for k, v in core_data.items():
if k in _CORE_KEYS:
legacy_core[k] = v
if verbose:
print(f"Legacy core config: {list(legacy_core.keys())}", file=sys.stderr)
# Read module legacy config
mod_path = Path(legacy_dir) / module_code / "config.yaml"
if mod_path.exists():
mod_data = load_yaml_file(str(mod_path))
files_found.append(str(mod_path))
for k, v in mod_data.items():
if k in _CORE_KEYS:
# Core keys duplicated in module config — only use if not already set
if k not in legacy_core:
legacy_core[k] = v
elif k in module_yaml and isinstance(module_yaml[k], dict):
# Module-specific key that matches a current variable definition
legacy_module[k] = v
if verbose:
print(
f"Legacy module config: {list(legacy_module.keys())}", file=sys.stderr
)
return legacy_core, legacy_module, files_found
def apply_legacy_defaults(answers: dict, legacy_core: dict, legacy_module: dict) -> dict:
"""Apply legacy values as fallback defaults under the answers.
Legacy values fill in any key not already present in answers.
Explicit answers always win.
"""
merged = dict(answers)
if legacy_core:
core = merged.get("core", {})
filled_core = dict(legacy_core) # legacy as base
filled_core.update(core) # answers override
merged["core"] = filled_core
if legacy_module:
mod = merged.get("module", {})
filled_mod = dict(legacy_module) # legacy as base
filled_mod.update(mod) # answers override
merged["module"] = filled_mod
return merged
def cleanup_legacy_configs(
legacy_dir: str, module_code: str, verbose: bool = False
) -> list:
"""Delete legacy config.yaml files for this module and core only.
Returns list of deleted file paths.
"""
deleted = []
for subdir in (module_code, "core"):
legacy_path = Path(legacy_dir) / subdir / "config.yaml"
if legacy_path.exists():
if verbose:
print(f"Deleting legacy config: {legacy_path}", file=sys.stderr)
legacy_path.unlink()
deleted.append(str(legacy_path))
return deleted
def extract_module_metadata(module_yaml: dict) -> dict:
"""Extract non-variable metadata fields from module.yaml."""
meta = {}
for k in ("name", "description"):
if k in module_yaml:
meta[k] = module_yaml[k]
meta["version"] = module_yaml.get("module_version") # null if absent
if "default_selected" in module_yaml:
meta["default_selected"] = module_yaml["default_selected"]
return meta
def apply_result_templates(
module_yaml: dict, module_answers: dict, verbose: bool = False
) -> dict:
"""Apply result templates from module.yaml to transform raw answer values.
For each answer, if the corresponding variable definition in module.yaml has
a 'result' field, replaces {value} in that template with the answer. Skips
the template if the answer already contains '{project-root}' to prevent
double-prefixing.
"""
transformed = {}
for key, value in module_answers.items():
var_def = module_yaml.get(key)
if (
isinstance(var_def, dict)
and "result" in var_def
and "{project-root}" not in str(value)
):
template = var_def["result"]
transformed[key] = template.replace("{value}", str(value))
if verbose:
print(
f"Applied result template for '{key}': {value}{transformed[key]}",
file=sys.stderr,
)
else:
transformed[key] = value
return transformed
def merge_config(
existing_config: dict,
module_yaml: dict,
answers: dict,
verbose: bool = False,
) -> dict:
"""Merge answers into config, applying anti-zombie pattern.
Args:
existing_config: Current config.yaml contents (may be empty)
module_yaml: The module definition
answers: JSON with 'core' and/or 'module' keys
verbose: Print progress to stderr
Returns:
Updated config dict ready to write
"""
config = dict(existing_config)
module_code = module_yaml.get("code")
if not module_code:
print("Error: module.yaml must have a 'code' field", file=sys.stderr)
sys.exit(1)
# Migrate legacy core: section to root
if "core" in config and isinstance(config["core"], dict):
if verbose:
print("Migrating legacy 'core' section to root", file=sys.stderr)
config.update(config.pop("core"))
# Strip user-only keys from config — they belong exclusively in config.user.yaml
for key in _CORE_USER_KEYS:
if key in config:
if verbose:
print(f"Removing user-only key '{key}' from config (belongs in config.user.yaml)", file=sys.stderr)
del config[key]
# Write core values at root (global properties, not nested under "core")
# Exclude user-only keys — those belong exclusively in config.user.yaml
core_answers = answers.get("core")
if core_answers:
shared_core = {k: v for k, v in core_answers.items() if k not in _CORE_USER_KEYS}
if shared_core:
if verbose:
print(f"Writing core config at root: {list(shared_core.keys())}", file=sys.stderr)
config.update(shared_core)
# Anti-zombie: remove existing module section
if module_code in config:
if verbose:
print(
f"Removing existing '{module_code}' section (anti-zombie)",
file=sys.stderr,
)
del config[module_code]
# Build module section: metadata + variable values
module_section = extract_module_metadata(module_yaml)
module_answers = apply_result_templates(
module_yaml, answers.get("module", {}), verbose
)
module_section.update(module_answers)
if verbose:
print(
f"Writing '{module_code}' section with keys: {list(module_section.keys())}",
file=sys.stderr,
)
config[module_code] = module_section
return config
# Core keys that are always written to config.user.yaml
_CORE_USER_KEYS = ("user_name", "communication_language")
def extract_user_settings(module_yaml: dict, answers: dict) -> dict:
"""Collect settings that belong in config.user.yaml.
Includes user_name and communication_language from core answers, plus any
module variable whose definition contains user_setting: true.
"""
user_settings = {}
core_answers = answers.get("core", {})
for key in _CORE_USER_KEYS:
if key in core_answers:
user_settings[key] = core_answers[key]
module_answers = answers.get("module", {})
for var_name, var_def in module_yaml.items():
if isinstance(var_def, dict) and var_def.get("user_setting") is True:
if var_name in module_answers:
user_settings[var_name] = module_answers[var_name]
return user_settings
def write_config(config: dict, config_path: str, verbose: bool = False) -> None:
"""Write config dict to YAML file, creating parent dirs as needed."""
path = Path(config_path)
path.parent.mkdir(parents=True, exist_ok=True)
if verbose:
print(f"Writing config to {path}", file=sys.stderr)
with open(path, "w", encoding="utf-8") as f:
yaml.dump(
config,
f,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
def main():
args = parse_args()
# Load inputs
module_yaml = load_yaml_file(args.module_yaml)
if not module_yaml:
print(f"Error: Could not load module.yaml from {args.module_yaml}", file=sys.stderr)
sys.exit(1)
answers = load_json_file(args.answers)
existing_config = load_yaml_file(args.config_path)
if args.verbose:
exists = Path(args.config_path).exists()
print(f"Config file exists: {exists}", file=sys.stderr)
if exists:
print(f"Existing sections: {list(existing_config.keys())}", file=sys.stderr)
# Legacy migration: read old per-module configs as fallback defaults
legacy_files_found = []
if args.legacy_dir:
module_code = module_yaml.get("code", "")
legacy_core, legacy_module, legacy_files_found = load_legacy_values(
args.legacy_dir, module_code, module_yaml, args.verbose
)
if legacy_core or legacy_module:
answers = apply_legacy_defaults(answers, legacy_core, legacy_module)
if args.verbose:
print("Applied legacy values as fallback defaults", file=sys.stderr)
# Merge and write config.yaml
updated_config = merge_config(existing_config, module_yaml, answers, args.verbose)
write_config(updated_config, args.config_path, args.verbose)
# Merge and write config.user.yaml
user_settings = extract_user_settings(module_yaml, answers)
existing_user_config = load_yaml_file(args.user_config_path)
updated_user_config = dict(existing_user_config)
updated_user_config.update(user_settings)
if user_settings:
write_config(updated_user_config, args.user_config_path, args.verbose)
# Legacy cleanup: delete old per-module config files
legacy_deleted = []
if args.legacy_dir:
legacy_deleted = cleanup_legacy_configs(
args.legacy_dir, module_yaml["code"], args.verbose
)
# Output result summary as JSON
module_code = module_yaml["code"]
result = {
"status": "success",
"config_path": str(Path(args.config_path).resolve()),
"user_config_path": str(Path(args.user_config_path).resolve()),
"module_code": module_code,
"core_updated": bool(answers.get("core")),
"module_keys": list(updated_config.get(module_code, {}).keys()),
"user_keys": list(user_settings.keys()),
"legacy_configs_found": legacy_files_found,
"legacy_configs_deleted": legacy_deleted,
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Merge module help entries into shared _bmad/module-help.csv.
Reads a source CSV with module help entries and merges them into a target CSV.
Uses an anti-zombie pattern: all existing rows matching the source module code
are removed before appending fresh rows.
Legacy cleanup: when --legacy-dir and --module-code are provided, deletes old
per-module module-help.csv files from {legacy-dir}/{module-code}/ and
{legacy-dir}/core/. Only the current module and core are touched.
Exit codes: 0=success, 1=validation error, 2=runtime error
"""
import argparse
import csv
import json
import sys
from io import StringIO
from pathlib import Path
# CSV header for module-help.csv
HEADER = [
"module",
"agent-name",
"skill-name",
"display-name",
"menu-code",
"capability",
"args",
"description",
"phase",
"after",
"before",
"required",
"output-location",
"outputs",
"", # trailing empty column from trailing comma
]
def parse_args():
parser = argparse.ArgumentParser(
description="Merge module help entries into shared _bmad/module-help.csv with anti-zombie pattern."
)
parser.add_argument(
"--target",
required=True,
help="Path to the target _bmad/module-help.csv file",
)
parser.add_argument(
"--source",
required=True,
help="Path to the source module-help.csv with entries to merge",
)
parser.add_argument(
"--legacy-dir",
help="Path to _bmad/ directory to check for legacy per-module CSV files.",
)
parser.add_argument(
"--module-code",
help="Module code (required with --legacy-dir for scoping cleanup).",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress to stderr",
)
return parser.parse_args()
def read_csv_rows(path: str) -> tuple[list[str], list[list[str]]]:
"""Read CSV file returning (header, data_rows).
Returns empty header and rows if file doesn't exist.
"""
file_path = Path(path)
if not file_path.exists():
return [], []
with open(file_path, "r", encoding="utf-8", newline="") as f:
content = f.read()
reader = csv.reader(StringIO(content))
rows = list(reader)
if not rows:
return [], []
return rows[0], rows[1:]
def extract_module_codes(rows: list[list[str]]) -> set[str]:
"""Extract unique module codes from data rows."""
codes = set()
for row in rows:
if row and row[0].strip():
codes.add(row[0].strip())
return codes
def filter_rows(rows: list[list[str]], module_code: str) -> list[list[str]]:
"""Remove all rows matching the given module code."""
return [row for row in rows if not row or row[0].strip() != module_code]
def write_csv(path: str, header: list[str], rows: list[list[str]], verbose: bool = False) -> None:
"""Write header + rows to CSV file, creating parent dirs as needed."""
file_path = Path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
if verbose:
print(f"Writing {len(rows)} data rows to {path}", file=sys.stderr)
with open(file_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(header)
for row in rows:
writer.writerow(row)
def cleanup_legacy_csvs(
legacy_dir: str, module_code: str, verbose: bool = False
) -> list:
"""Delete legacy per-module module-help.csv files for this module and core only.
Returns list of deleted file paths.
"""
deleted = []
for subdir in (module_code, "core"):
legacy_path = Path(legacy_dir) / subdir / "module-help.csv"
if legacy_path.exists():
if verbose:
print(f"Deleting legacy CSV: {legacy_path}", file=sys.stderr)
legacy_path.unlink()
deleted.append(str(legacy_path))
return deleted
def main():
args = parse_args()
# Read source entries
source_header, source_rows = read_csv_rows(args.source)
if not source_rows:
print(f"Error: No data rows found in source {args.source}", file=sys.stderr)
sys.exit(1)
# Determine module codes being merged
source_codes = extract_module_codes(source_rows)
if not source_codes:
print("Error: Could not determine module code from source rows", file=sys.stderr)
sys.exit(1)
if args.verbose:
print(f"Source module codes: {source_codes}", file=sys.stderr)
print(f"Source rows: {len(source_rows)}", file=sys.stderr)
# Read existing target (may not exist)
target_header, target_rows = read_csv_rows(args.target)
target_existed = Path(args.target).exists()
if args.verbose:
print(f"Target exists: {target_existed}", file=sys.stderr)
if target_existed:
print(f"Existing target rows: {len(target_rows)}", file=sys.stderr)
# Use source header if target doesn't exist or has no header
header = target_header if target_header else (source_header if source_header else HEADER)
# Anti-zombie: remove all rows for each source module code
filtered_rows = target_rows
removed_count = 0
for code in source_codes:
before_count = len(filtered_rows)
filtered_rows = filter_rows(filtered_rows, code)
removed_count += before_count - len(filtered_rows)
if args.verbose and removed_count > 0:
print(f"Removed {removed_count} existing rows (anti-zombie)", file=sys.stderr)
# Append source rows
merged_rows = filtered_rows + source_rows
# Write result
write_csv(args.target, header, merged_rows, args.verbose)
# Legacy cleanup: delete old per-module CSV files
legacy_deleted = []
if args.legacy_dir:
if not args.module_code:
print(
"Error: --module-code is required when --legacy-dir is provided",
file=sys.stderr,
)
sys.exit(1)
legacy_deleted = cleanup_legacy_csvs(
args.legacy_dir, args.module_code, args.verbose
)
# Output result summary as JSON
result = {
"status": "success",
"target_path": str(Path(args.target).resolve()),
"target_existed": target_existed,
"module_codes": sorted(source_codes),
"rows_removed": removed_count,
"rows_added": len(source_rows),
"total_rows": len(merged_rows),
"legacy_csvs_deleted": legacy_deleted,
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,429 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Unit tests for cleanup-legacy.py."""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
# Add parent directory to path so we can import the module
sys.path.insert(0, str(Path(__file__).parent.parent))
from importlib.util import spec_from_file_location, module_from_spec
# Import cleanup_legacy module
_spec = spec_from_file_location(
"cleanup_legacy",
str(Path(__file__).parent.parent / "cleanup-legacy.py"),
)
cleanup_legacy_mod = module_from_spec(_spec)
_spec.loader.exec_module(cleanup_legacy_mod)
find_skill_dirs = cleanup_legacy_mod.find_skill_dirs
verify_skills_installed = cleanup_legacy_mod.verify_skills_installed
count_files = cleanup_legacy_mod.count_files
cleanup_directories = cleanup_legacy_mod.cleanup_directories
def _make_skill_dir(base, *path_parts):
"""Create a skill directory with a SKILL.md file."""
skill_dir = os.path.join(base, *path_parts)
os.makedirs(skill_dir, exist_ok=True)
with open(os.path.join(skill_dir, "SKILL.md"), "w") as f:
f.write("---\nname: test-skill\n---\n# Test\n")
return skill_dir
def _make_file(base, *path_parts, content="placeholder"):
"""Create a file at the given path."""
file_path = os.path.join(base, *path_parts)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w") as f:
f.write(content)
return file_path
class TestFindSkillDirs(unittest.TestCase):
def test_finds_dirs_with_skill_md(self):
with tempfile.TemporaryDirectory() as tmpdir:
_make_skill_dir(tmpdir, "skills", "bmad-agent-builder")
_make_skill_dir(tmpdir, "skills", "bmad-workflow-builder")
result = find_skill_dirs(tmpdir)
self.assertEqual(result, ["bmad-agent-builder", "bmad-workflow-builder"])
def test_ignores_dirs_without_skill_md(self):
with tempfile.TemporaryDirectory() as tmpdir:
_make_skill_dir(tmpdir, "skills", "real-skill")
os.makedirs(os.path.join(tmpdir, "skills", "not-a-skill"))
_make_file(tmpdir, "skills", "not-a-skill", "README.md")
result = find_skill_dirs(tmpdir)
self.assertEqual(result, ["real-skill"])
def test_empty_directory(self):
with tempfile.TemporaryDirectory() as tmpdir:
result = find_skill_dirs(tmpdir)
self.assertEqual(result, [])
def test_nonexistent_directory(self):
result = find_skill_dirs("/nonexistent/path")
self.assertEqual(result, [])
def test_finds_nested_skills_in_phase_subdirs(self):
"""Skills nested in phase directories like bmm/1-analysis/bmad-agent-analyst/."""
with tempfile.TemporaryDirectory() as tmpdir:
_make_skill_dir(tmpdir, "1-analysis", "bmad-agent-analyst")
_make_skill_dir(tmpdir, "2-plan", "bmad-agent-pm")
_make_skill_dir(tmpdir, "4-impl", "bmad-agent-dev")
result = find_skill_dirs(tmpdir)
self.assertEqual(
result, ["bmad-agent-analyst", "bmad-agent-dev", "bmad-agent-pm"]
)
def test_deduplicates_skill_names(self):
"""If the same skill name appears in multiple locations, only listed once."""
with tempfile.TemporaryDirectory() as tmpdir:
_make_skill_dir(tmpdir, "a", "my-skill")
_make_skill_dir(tmpdir, "b", "my-skill")
result = find_skill_dirs(tmpdir)
self.assertEqual(result, ["my-skill"])
class TestVerifySkillsInstalled(unittest.TestCase):
def test_all_skills_present(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
# Legacy: bmb has two skills
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-a")
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-b")
# Installed: both exist
os.makedirs(os.path.join(skills_dir, "skill-a"))
os.makedirs(os.path.join(skills_dir, "skill-b"))
result = verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
self.assertEqual(result, ["skill-a", "skill-b"])
def test_missing_skill_exits_1(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-a")
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-missing")
# Only skill-a installed
os.makedirs(os.path.join(skills_dir, "skill-a"))
with self.assertRaises(SystemExit) as ctx:
verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
self.assertEqual(ctx.exception.code, 1)
def test_empty_legacy_dir_passes(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
os.makedirs(bmad_dir)
os.makedirs(skills_dir)
result = verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
self.assertEqual(result, [])
def test_nonexistent_legacy_dir_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
os.makedirs(skills_dir)
# bmad_dir doesn't exist — should not error
result = verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
self.assertEqual(result, [])
def test_dir_without_skills_skipped(self):
"""Directories like _config/ that have no SKILL.md are not verified."""
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
# _config has files but no SKILL.md
_make_file(bmad_dir, "_config", "manifest.yaml", content="version: 1")
_make_file(bmad_dir, "_config", "help.csv", content="a,b,c")
os.makedirs(skills_dir)
result = verify_skills_installed(bmad_dir, ["_config"], skills_dir)
self.assertEqual(result, [])
def test_verifies_across_multiple_dirs(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-a")
_make_skill_dir(bmad_dir, "core", "skills", "skill-b")
os.makedirs(os.path.join(skills_dir, "skill-a"))
os.makedirs(os.path.join(skills_dir, "skill-b"))
result = verify_skills_installed(
bmad_dir, ["bmb", "core"], skills_dir
)
self.assertEqual(result, ["skill-a", "skill-b"])
class TestCountFiles(unittest.TestCase):
def test_counts_files_recursively(self):
with tempfile.TemporaryDirectory() as tmpdir:
_make_file(tmpdir, "a.txt")
_make_file(tmpdir, "sub", "b.txt")
_make_file(tmpdir, "sub", "deep", "c.txt")
self.assertEqual(count_files(Path(tmpdir)), 3)
def test_empty_dir_returns_zero(self):
with tempfile.TemporaryDirectory() as tmpdir:
self.assertEqual(count_files(Path(tmpdir)), 0)
class TestCleanupDirectories(unittest.TestCase):
def test_removes_single_module_dir(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
os.makedirs(os.path.join(bmad_dir, "bmb", "skills"))
_make_file(bmad_dir, "bmb", "skills", "SKILL.md")
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed, ["bmb"])
self.assertEqual(not_found, [])
self.assertGreater(count, 0)
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "bmb")))
def test_removes_module_core_and_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
for dirname in ("bmb", "core", "_config"):
_make_file(bmad_dir, dirname, "some-file.txt")
removed, not_found, count = cleanup_directories(
bmad_dir, ["bmb", "core", "_config"]
)
self.assertEqual(sorted(removed), ["_config", "bmb", "core"])
self.assertEqual(not_found, [])
for dirname in ("bmb", "core", "_config"):
self.assertFalse(os.path.exists(os.path.join(bmad_dir, dirname)))
def test_nonexistent_dir_in_not_found(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
os.makedirs(bmad_dir)
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed, [])
self.assertEqual(not_found, ["bmb"])
self.assertEqual(count, 0)
def test_preserves_other_module_dirs(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
for dirname in ("bmb", "bmm", "tea"):
_make_file(bmad_dir, dirname, "file.txt")
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed, ["bmb"])
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "bmm")))
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "tea")))
def test_preserves_root_config_files(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
_make_file(bmad_dir, "config.yaml", content="key: val")
_make_file(bmad_dir, "config.user.yaml", content="user: test")
_make_file(bmad_dir, "module-help.csv", content="a,b,c")
_make_file(bmad_dir, "bmb", "stuff.txt")
cleanup_directories(bmad_dir, ["bmb"])
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "config.yaml")))
self.assertTrue(
os.path.exists(os.path.join(bmad_dir, "config.user.yaml"))
)
self.assertTrue(
os.path.exists(os.path.join(bmad_dir, "module-help.csv"))
)
def test_removes_hidden_files(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
_make_file(bmad_dir, "bmb", ".DS_Store")
_make_file(bmad_dir, "bmb", "skills", ".hidden")
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed, ["bmb"])
self.assertEqual(count, 2)
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "bmb")))
def test_idempotent_rerun(self):
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
_make_file(bmad_dir, "bmb", "file.txt")
# First run
removed1, not_found1, _ = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed1, ["bmb"])
self.assertEqual(not_found1, [])
# Second run — idempotent
removed2, not_found2, count2 = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed2, [])
self.assertEqual(not_found2, ["bmb"])
self.assertEqual(count2, 0)
class TestSafetyCheck(unittest.TestCase):
def test_no_skills_dir_skips_check(self):
"""When --skills-dir is not provided, no verification happens."""
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
_make_skill_dir(bmad_dir, "bmb", "skills", "some-skill")
# No skills_dir — cleanup should proceed without verification
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
self.assertEqual(removed, ["bmb"])
def test_missing_skill_blocks_removal(self):
"""When --skills-dir is provided and a skill is missing, exit 1."""
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
_make_skill_dir(bmad_dir, "bmb", "skills", "installed-skill")
_make_skill_dir(bmad_dir, "bmb", "skills", "missing-skill")
os.makedirs(os.path.join(skills_dir, "installed-skill"))
# missing-skill not created in skills_dir
with self.assertRaises(SystemExit) as ctx:
verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
self.assertEqual(ctx.exception.code, 1)
# Directory should NOT have been removed (verification failed before cleanup)
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "bmb")))
def test_dir_without_skills_not_checked(self):
"""Directories like _config that have no SKILL.md pass verification."""
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
_make_file(bmad_dir, "_config", "manifest.yaml")
os.makedirs(skills_dir)
# Should not raise — _config has no skills to verify
result = verify_skills_installed(bmad_dir, ["_config"], skills_dir)
self.assertEqual(result, [])
class TestEndToEnd(unittest.TestCase):
def test_full_cleanup_with_verification(self):
"""Simulate complete cleanup flow with safety check."""
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
skills_dir = os.path.join(tmpdir, "skills")
# Create legacy structure
_make_skill_dir(bmad_dir, "bmb", "skills", "bmad-agent-builder")
_make_skill_dir(bmad_dir, "bmb", "skills", "bmad-builder-setup")
_make_file(bmad_dir, "bmb", "skills", "bmad-agent-builder", "assets", "template.md")
_make_skill_dir(bmad_dir, "core", "skills", "bmad-brainstorming")
_make_file(bmad_dir, "_config", "manifest.yaml")
_make_file(bmad_dir, "_config", "bmad-help.csv")
# Create root config files that must survive
_make_file(bmad_dir, "config.yaml", content="document_output_language: English")
_make_file(bmad_dir, "config.user.yaml", content="user_name: Test")
_make_file(bmad_dir, "module-help.csv", content="module,name\nbmb,builder")
# Create other module dirs that must survive
_make_file(bmad_dir, "bmm", "config.yaml")
_make_file(bmad_dir, "tea", "config.yaml")
# Create installed skills
os.makedirs(os.path.join(skills_dir, "bmad-agent-builder"))
os.makedirs(os.path.join(skills_dir, "bmad-builder-setup"))
os.makedirs(os.path.join(skills_dir, "bmad-brainstorming"))
# Verify
verified = verify_skills_installed(
bmad_dir, ["bmb", "core", "_config"], skills_dir
)
self.assertIn("bmad-agent-builder", verified)
self.assertIn("bmad-builder-setup", verified)
self.assertIn("bmad-brainstorming", verified)
# Cleanup
removed, not_found, file_count = cleanup_directories(
bmad_dir, ["bmb", "core", "_config"]
)
self.assertEqual(sorted(removed), ["_config", "bmb", "core"])
self.assertEqual(not_found, [])
self.assertGreater(file_count, 0)
# Verify final state
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "bmb")))
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "core")))
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "_config")))
# Root config files survived
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "config.yaml")))
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "config.user.yaml")))
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "module-help.csv")))
# Other modules survived
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "bmm")))
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "tea")))
def test_simulate_post_merge_scripts(self):
"""Simulate the full flow: merge scripts run first (delete config files),
then cleanup removes directories."""
with tempfile.TemporaryDirectory() as tmpdir:
bmad_dir = os.path.join(tmpdir, "_bmad")
# Legacy state: config files already deleted by merge scripts
# but directories and skill content remain
_make_skill_dir(bmad_dir, "bmb", "skills", "bmad-agent-builder")
_make_file(bmad_dir, "bmb", "skills", "bmad-agent-builder", "refs", "doc.md")
_make_file(bmad_dir, "bmb", ".DS_Store")
# config.yaml already deleted by merge-config.py
# module-help.csv already deleted by merge-help-csv.py
_make_skill_dir(bmad_dir, "core", "skills", "bmad-help")
# core/config.yaml already deleted
# core/module-help.csv already deleted
# Root files from merge scripts
_make_file(bmad_dir, "config.yaml", content="bmb:\n name: BMad Builder")
_make_file(bmad_dir, "config.user.yaml", content="user_name: Test")
_make_file(bmad_dir, "module-help.csv", content="module,name")
# Cleanup directories
removed, not_found, file_count = cleanup_directories(
bmad_dir, ["bmb", "core"]
)
self.assertEqual(sorted(removed), ["bmb", "core"])
self.assertGreater(file_count, 0)
# Final state: only root config files
remaining = os.listdir(bmad_dir)
self.assertEqual(
sorted(remaining),
["config.user.yaml", "config.yaml", "module-help.csv"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,644 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = ["pyyaml"]
# ///
"""Unit tests for merge-config.py."""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
# Add parent directory to path so we can import the module
sys.path.insert(0, str(Path(__file__).parent.parent))
import yaml
from importlib.util import spec_from_file_location, module_from_spec
# Import merge_config module
_spec = spec_from_file_location(
"merge_config",
str(Path(__file__).parent.parent / "merge-config.py"),
)
merge_config_mod = module_from_spec(_spec)
_spec.loader.exec_module(merge_config_mod)
extract_module_metadata = merge_config_mod.extract_module_metadata
extract_user_settings = merge_config_mod.extract_user_settings
merge_config = merge_config_mod.merge_config
load_legacy_values = merge_config_mod.load_legacy_values
apply_legacy_defaults = merge_config_mod.apply_legacy_defaults
cleanup_legacy_configs = merge_config_mod.cleanup_legacy_configs
apply_result_templates = merge_config_mod.apply_result_templates
SAMPLE_MODULE_YAML = {
"code": "bmb",
"name": "BMad Builder",
"description": "Standard Skill Compliant Factory",
"default_selected": False,
"bmad_builder_output_folder": {
"prompt": "Where should skills be saved?",
"default": "_bmad-output/skills",
"result": "{project-root}/{value}",
},
"bmad_builder_reports": {
"prompt": "Output for reports?",
"default": "_bmad-output/reports",
"result": "{project-root}/{value}",
},
}
SAMPLE_MODULE_YAML_WITH_VERSION = {
**SAMPLE_MODULE_YAML,
"module_version": "1.0.0",
}
SAMPLE_MODULE_YAML_WITH_USER_SETTING = {
**SAMPLE_MODULE_YAML,
"some_pref": {
"prompt": "Your preference?",
"default": "default_val",
"user_setting": True,
},
}
class TestExtractModuleMetadata(unittest.TestCase):
def test_extracts_metadata_fields(self):
result = extract_module_metadata(SAMPLE_MODULE_YAML)
self.assertEqual(result["name"], "BMad Builder")
self.assertEqual(result["description"], "Standard Skill Compliant Factory")
self.assertFalse(result["default_selected"])
def test_excludes_variable_definitions(self):
result = extract_module_metadata(SAMPLE_MODULE_YAML)
self.assertNotIn("bmad_builder_output_folder", result)
self.assertNotIn("bmad_builder_reports", result)
self.assertNotIn("code", result)
def test_version_present(self):
result = extract_module_metadata(SAMPLE_MODULE_YAML_WITH_VERSION)
self.assertEqual(result["version"], "1.0.0")
def test_version_absent_is_none(self):
result = extract_module_metadata(SAMPLE_MODULE_YAML)
self.assertIn("version", result)
self.assertIsNone(result["version"])
def test_field_order(self):
result = extract_module_metadata(SAMPLE_MODULE_YAML_WITH_VERSION)
keys = list(result.keys())
self.assertEqual(keys, ["name", "description", "version", "default_selected"])
class TestExtractUserSettings(unittest.TestCase):
def test_core_user_keys(self):
answers = {
"core": {
"user_name": "Brian",
"communication_language": "English",
"document_output_language": "English",
"output_folder": "_bmad-output",
},
}
result = extract_user_settings(SAMPLE_MODULE_YAML, answers)
self.assertEqual(result["user_name"], "Brian")
self.assertEqual(result["communication_language"], "English")
self.assertNotIn("document_output_language", result)
self.assertNotIn("output_folder", result)
def test_module_user_setting_true(self):
answers = {
"core": {"user_name": "Brian"},
"module": {"some_pref": "custom_val"},
}
result = extract_user_settings(SAMPLE_MODULE_YAML_WITH_USER_SETTING, answers)
self.assertEqual(result["user_name"], "Brian")
self.assertEqual(result["some_pref"], "custom_val")
def test_no_core_answers(self):
answers = {"module": {"some_pref": "val"}}
result = extract_user_settings(SAMPLE_MODULE_YAML_WITH_USER_SETTING, answers)
self.assertNotIn("user_name", result)
self.assertEqual(result["some_pref"], "val")
def test_no_user_settings_in_module(self):
answers = {
"core": {"user_name": "Brian"},
"module": {"bmad_builder_output_folder": "path"},
}
result = extract_user_settings(SAMPLE_MODULE_YAML, answers)
self.assertEqual(result, {"user_name": "Brian"})
def test_empty_answers(self):
result = extract_user_settings(SAMPLE_MODULE_YAML, {})
self.assertEqual(result, {})
class TestApplyResultTemplates(unittest.TestCase):
def test_applies_template(self):
answers = {"bmad_builder_output_folder": "skills"}
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
self.assertEqual(result["bmad_builder_output_folder"], "{project-root}/skills")
def test_applies_multiple_templates(self):
answers = {
"bmad_builder_output_folder": "skills",
"bmad_builder_reports": "skills/reports",
}
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
self.assertEqual(result["bmad_builder_output_folder"], "{project-root}/skills")
self.assertEqual(result["bmad_builder_reports"], "{project-root}/skills/reports")
def test_skips_when_no_template(self):
"""Variables without a result field are stored as-is."""
yaml_no_result = {
"code": "test",
"my_var": {"prompt": "Enter value", "default": "foo"},
}
answers = {"my_var": "bar"}
result = apply_result_templates(yaml_no_result, answers)
self.assertEqual(result["my_var"], "bar")
def test_skips_when_value_already_has_project_root(self):
"""Prevent double-prefixing if value already contains {project-root}."""
answers = {"bmad_builder_output_folder": "{project-root}/skills"}
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
self.assertEqual(result["bmad_builder_output_folder"], "{project-root}/skills")
def test_empty_answers(self):
result = apply_result_templates(SAMPLE_MODULE_YAML, {})
self.assertEqual(result, {})
def test_unknown_key_passed_through(self):
"""Keys not in module.yaml are passed through unchanged."""
answers = {"unknown_key": "some_value"}
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
self.assertEqual(result["unknown_key"], "some_value")
class TestMergeConfig(unittest.TestCase):
def test_fresh_install_with_core_and_module(self):
answers = {
"core": {
"user_name": "Brian",
"communication_language": "English",
"document_output_language": "English",
"output_folder": "_bmad-output",
},
"module": {
"bmad_builder_output_folder": "_bmad-output/skills",
},
}
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
# User-only keys must NOT appear in config.yaml
self.assertNotIn("user_name", result)
self.assertNotIn("communication_language", result)
# Shared core keys do appear
self.assertEqual(result["document_output_language"], "English")
self.assertEqual(result["output_folder"], "_bmad-output")
self.assertEqual(result["bmb"]["name"], "BMad Builder")
self.assertEqual(result["bmb"]["bmad_builder_output_folder"], "{project-root}/_bmad-output/skills")
def test_update_strips_user_keys_preserves_shared(self):
existing = {
"user_name": "Brian",
"communication_language": "English",
"document_output_language": "English",
"other_module": {"name": "Other"},
}
answers = {
"module": {
"bmad_builder_output_folder": "_bmad-output/skills",
},
}
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
# User-only keys stripped from config
self.assertNotIn("user_name", result)
self.assertNotIn("communication_language", result)
# Shared core preserved at root
self.assertEqual(result["document_output_language"], "English")
# Other module preserved
self.assertIn("other_module", result)
# New module added
self.assertIn("bmb", result)
def test_anti_zombie_removes_existing_module(self):
existing = {
"user_name": "Brian",
"bmb": {
"name": "BMad Builder",
"old_variable": "should_be_removed",
"bmad_builder_output_folder": "old/path",
},
}
answers = {
"module": {
"bmad_builder_output_folder": "new/path",
},
}
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
# Old variable is gone
self.assertNotIn("old_variable", result["bmb"])
# New value is present
self.assertEqual(result["bmb"]["bmad_builder_output_folder"], "{project-root}/new/path")
# Metadata is fresh from module.yaml
self.assertEqual(result["bmb"]["name"], "BMad Builder")
def test_user_keys_never_written_to_config(self):
existing = {
"user_name": "OldName",
"communication_language": "Spanish",
"document_output_language": "French",
}
answers = {
"core": {"user_name": "NewName", "communication_language": "English"},
"module": {},
}
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
# User-only keys stripped even if they were in existing config
self.assertNotIn("user_name", result)
self.assertNotIn("communication_language", result)
# Shared core preserved
self.assertEqual(result["document_output_language"], "French")
def test_no_core_answers_still_strips_user_keys(self):
existing = {
"user_name": "Brian",
"output_folder": "/out",
}
answers = {
"module": {"bmad_builder_output_folder": "path"},
}
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
# User-only keys stripped even without core answers
self.assertNotIn("user_name", result)
# Shared core unchanged
self.assertEqual(result["output_folder"], "/out")
def test_module_metadata_always_from_yaml(self):
"""Module metadata comes from module.yaml, not answers."""
answers = {
"module": {"bmad_builder_output_folder": "path"},
}
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
self.assertEqual(result["bmb"]["name"], "BMad Builder")
self.assertEqual(result["bmb"]["description"], "Standard Skill Compliant Factory")
self.assertFalse(result["bmb"]["default_selected"])
def test_legacy_core_section_migrated_user_keys_stripped(self):
"""Old config with core: nested section — user keys stripped after migration."""
existing = {
"core": {
"user_name": "Brian",
"communication_language": "English",
"document_output_language": "English",
"output_folder": "/out",
},
"bmb": {"name": "BMad Builder"},
}
answers = {
"module": {"bmad_builder_output_folder": "path"},
}
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
# User-only keys stripped after migration
self.assertNotIn("user_name", result)
self.assertNotIn("communication_language", result)
# Shared core values hoisted to root
self.assertEqual(result["document_output_language"], "English")
self.assertEqual(result["output_folder"], "/out")
# Legacy core key removed
self.assertNotIn("core", result)
# Module still works
self.assertIn("bmb", result)
def test_legacy_core_user_keys_stripped_after_migration(self):
"""Legacy core: values get migrated, user keys stripped, shared keys kept."""
existing = {
"core": {"user_name": "OldName", "output_folder": "/old"},
}
answers = {
"core": {"user_name": "NewName", "output_folder": "/new"},
"module": {},
}
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
# User-only key not in config even after migration + override
self.assertNotIn("user_name", result)
self.assertNotIn("core", result)
# Shared core key written
self.assertEqual(result["output_folder"], "/new")
class TestEndToEnd(unittest.TestCase):
def test_write_and_read_round_trip(self):
with tempfile.TemporaryDirectory() as tmpdir:
config_path = os.path.join(tmpdir, "_bmad", "config.yaml")
# Write answers
answers = {
"core": {
"user_name": "Brian",
"communication_language": "English",
"document_output_language": "English",
"output_folder": "_bmad-output",
},
"module": {"bmad_builder_output_folder": "_bmad-output/skills"},
}
# Run merge
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
merge_config_mod.write_config(result, config_path)
# Read back
with open(config_path, "r") as f:
written = yaml.safe_load(f)
# User-only keys not written to config.yaml
self.assertNotIn("user_name", written)
self.assertNotIn("communication_language", written)
# Shared core keys written
self.assertEqual(written["document_output_language"], "English")
self.assertEqual(written["output_folder"], "_bmad-output")
self.assertEqual(written["bmb"]["bmad_builder_output_folder"], "{project-root}/_bmad-output/skills")
def test_update_round_trip(self):
"""Simulate install, then re-install with different values."""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = os.path.join(tmpdir, "config.yaml")
# First install
answers1 = {
"core": {"output_folder": "/out"},
"module": {"bmad_builder_output_folder": "old/path"},
}
result1 = merge_config({}, SAMPLE_MODULE_YAML, answers1)
merge_config_mod.write_config(result1, config_path)
# Second install (update)
existing = merge_config_mod.load_yaml_file(config_path)
answers2 = {
"module": {"bmad_builder_output_folder": "new/path"},
}
result2 = merge_config(existing, SAMPLE_MODULE_YAML, answers2)
merge_config_mod.write_config(result2, config_path)
# Verify
with open(config_path, "r") as f:
final = yaml.safe_load(f)
self.assertEqual(final["output_folder"], "/out")
self.assertNotIn("user_name", final)
self.assertEqual(final["bmb"]["bmad_builder_output_folder"], "{project-root}/new/path")
class TestLoadLegacyValues(unittest.TestCase):
def _make_legacy_dir(self, tmpdir, core_data=None, module_code=None, module_data=None):
"""Create legacy directory structure for testing."""
legacy_dir = os.path.join(tmpdir, "_bmad")
if core_data is not None:
core_dir = os.path.join(legacy_dir, "core")
os.makedirs(core_dir, exist_ok=True)
with open(os.path.join(core_dir, "config.yaml"), "w") as f:
yaml.dump(core_data, f)
if module_code and module_data is not None:
mod_dir = os.path.join(legacy_dir, module_code)
os.makedirs(mod_dir, exist_ok=True)
with open(os.path.join(mod_dir, "config.yaml"), "w") as f:
yaml.dump(module_data, f)
return legacy_dir
def test_reads_core_keys_from_core_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = self._make_legacy_dir(tmpdir, core_data={
"user_name": "Brian",
"communication_language": "English",
"document_output_language": "English",
"output_folder": "/out",
})
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
self.assertEqual(core["user_name"], "Brian")
self.assertEqual(core["communication_language"], "English")
self.assertEqual(len(files), 1)
self.assertEqual(mod, {})
def test_reads_module_keys_matching_yaml_variables(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = self._make_legacy_dir(
tmpdir,
module_code="bmb",
module_data={
"bmad_builder_output_folder": "custom/path",
"bmad_builder_reports": "custom/reports",
"user_name": "Brian", # core key duplicated
"unknown_key": "ignored", # not in module.yaml
},
)
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
self.assertEqual(mod["bmad_builder_output_folder"], "custom/path")
self.assertEqual(mod["bmad_builder_reports"], "custom/reports")
self.assertNotIn("unknown_key", mod)
# Core key from module config used as fallback
self.assertEqual(core["user_name"], "Brian")
self.assertEqual(len(files), 1)
def test_core_config_takes_priority_over_module_for_core_keys(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = self._make_legacy_dir(
tmpdir,
core_data={"user_name": "FromCore"},
module_code="bmb",
module_data={"user_name": "FromModule"},
)
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
self.assertEqual(core["user_name"], "FromCore")
self.assertEqual(len(files), 2)
def test_no_legacy_files_returns_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = os.path.join(tmpdir, "_bmad")
os.makedirs(legacy_dir)
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
self.assertEqual(core, {})
self.assertEqual(mod, {})
self.assertEqual(files, [])
def test_ignores_other_module_directories(self):
"""Only reads core and the specified module_code — not other modules."""
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = self._make_legacy_dir(
tmpdir,
module_code="bmb",
module_data={"bmad_builder_output_folder": "bmb/path"},
)
# Create another module directory that should be ignored
other_dir = os.path.join(legacy_dir, "cis")
os.makedirs(other_dir)
with open(os.path.join(other_dir, "config.yaml"), "w") as f:
yaml.dump({"visual_tools": "advanced"}, f)
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
self.assertNotIn("visual_tools", mod)
self.assertEqual(len(files), 1) # only bmb, not cis
class TestApplyLegacyDefaults(unittest.TestCase):
def test_legacy_fills_missing_core(self):
answers = {"module": {"bmad_builder_output_folder": "path"}}
result = apply_legacy_defaults(
answers,
legacy_core={"user_name": "Brian", "communication_language": "English"},
legacy_module={},
)
self.assertEqual(result["core"]["user_name"], "Brian")
self.assertEqual(result["module"]["bmad_builder_output_folder"], "path")
def test_answers_override_legacy(self):
answers = {
"core": {"user_name": "NewName"},
"module": {"bmad_builder_output_folder": "new/path"},
}
result = apply_legacy_defaults(
answers,
legacy_core={"user_name": "OldName"},
legacy_module={"bmad_builder_output_folder": "old/path"},
)
self.assertEqual(result["core"]["user_name"], "NewName")
self.assertEqual(result["module"]["bmad_builder_output_folder"], "new/path")
def test_legacy_fills_missing_module_keys(self):
answers = {"module": {}}
result = apply_legacy_defaults(
answers,
legacy_core={},
legacy_module={"bmad_builder_output_folder": "legacy/path"},
)
self.assertEqual(result["module"]["bmad_builder_output_folder"], "legacy/path")
def test_empty_legacy_is_noop(self):
answers = {"core": {"user_name": "Brian"}, "module": {"key": "val"}}
result = apply_legacy_defaults(answers, {}, {})
self.assertEqual(result, answers)
class TestCleanupLegacyConfigs(unittest.TestCase):
def test_deletes_module_and_core_configs(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = os.path.join(tmpdir, "_bmad")
for subdir in ("core", "bmb"):
d = os.path.join(legacy_dir, subdir)
os.makedirs(d)
with open(os.path.join(d, "config.yaml"), "w") as f:
f.write("key: val\n")
deleted = cleanup_legacy_configs(legacy_dir, "bmb")
self.assertEqual(len(deleted), 2)
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "core", "config.yaml")))
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "bmb", "config.yaml")))
# Directories still exist
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "core")))
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "bmb")))
def test_leaves_other_module_configs_alone(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = os.path.join(tmpdir, "_bmad")
for subdir in ("bmb", "cis"):
d = os.path.join(legacy_dir, subdir)
os.makedirs(d)
with open(os.path.join(d, "config.yaml"), "w") as f:
f.write("key: val\n")
deleted = cleanup_legacy_configs(legacy_dir, "bmb")
self.assertEqual(len(deleted), 1) # only bmb, not cis
self.assertTrue(os.path.exists(os.path.join(legacy_dir, "cis", "config.yaml")))
def test_no_legacy_files_returns_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
deleted = cleanup_legacy_configs(tmpdir, "bmb")
self.assertEqual(deleted, [])
class TestLegacyEndToEnd(unittest.TestCase):
def test_full_legacy_migration(self):
"""Simulate installing a module with legacy configs present."""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = os.path.join(tmpdir, "_bmad", "config.yaml")
legacy_dir = os.path.join(tmpdir, "_bmad")
# Create legacy core config
core_dir = os.path.join(legacy_dir, "core")
os.makedirs(core_dir)
with open(os.path.join(core_dir, "config.yaml"), "w") as f:
yaml.dump({
"user_name": "LegacyUser",
"communication_language": "Spanish",
"document_output_language": "French",
"output_folder": "/legacy/out",
}, f)
# Create legacy module config
mod_dir = os.path.join(legacy_dir, "bmb")
os.makedirs(mod_dir)
with open(os.path.join(mod_dir, "config.yaml"), "w") as f:
yaml.dump({
"bmad_builder_output_folder": "legacy/skills",
"bmad_builder_reports": "legacy/reports",
"user_name": "LegacyUser", # duplicated core key
}, f)
# Answers from the user (only partially filled — user accepted some defaults)
answers = {
"core": {"user_name": "NewUser"},
"module": {"bmad_builder_output_folder": "new/skills"},
}
# Load and apply legacy
legacy_core, legacy_module, _ = load_legacy_values(
legacy_dir, "bmb", SAMPLE_MODULE_YAML
)
answers = apply_legacy_defaults(answers, legacy_core, legacy_module)
# Core: NewUser overrides legacy, but legacy Spanish fills in communication_language
self.assertEqual(answers["core"]["user_name"], "NewUser")
self.assertEqual(answers["core"]["communication_language"], "Spanish")
# Module: new/skills overrides, but legacy/reports fills in
self.assertEqual(answers["module"]["bmad_builder_output_folder"], "new/skills")
self.assertEqual(answers["module"]["bmad_builder_reports"], "legacy/reports")
# Merge
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
merge_config_mod.write_config(result, config_path)
# Cleanup
deleted = cleanup_legacy_configs(legacy_dir, "bmb")
self.assertEqual(len(deleted), 2)
self.assertFalse(os.path.exists(os.path.join(core_dir, "config.yaml")))
self.assertFalse(os.path.exists(os.path.join(mod_dir, "config.yaml")))
# Verify final config — user-only keys NOT in config.yaml
with open(config_path, "r") as f:
final = yaml.safe_load(f)
self.assertNotIn("user_name", final)
self.assertNotIn("communication_language", final)
# Shared core keys present
self.assertEqual(final["document_output_language"], "French")
self.assertEqual(final["output_folder"], "/legacy/out")
self.assertEqual(final["bmb"]["bmad_builder_output_folder"], "{project-root}/new/skills")
self.assertEqual(final["bmb"]["bmad_builder_reports"], "{project-root}/legacy/reports")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Unit tests for merge-help-csv.py."""
import csv
import os
import sys
import tempfile
import unittest
from io import StringIO
from pathlib import Path
# Import merge_help_csv module
from importlib.util import spec_from_file_location, module_from_spec
_spec = spec_from_file_location(
"merge_help_csv",
str(Path(__file__).parent.parent / "merge-help-csv.py"),
)
merge_help_csv_mod = module_from_spec(_spec)
_spec.loader.exec_module(merge_help_csv_mod)
extract_module_codes = merge_help_csv_mod.extract_module_codes
filter_rows = merge_help_csv_mod.filter_rows
read_csv_rows = merge_help_csv_mod.read_csv_rows
write_csv = merge_help_csv_mod.write_csv
cleanup_legacy_csvs = merge_help_csv_mod.cleanup_legacy_csvs
HEADER = merge_help_csv_mod.HEADER
SAMPLE_ROWS = [
["bmb", "", "bmad-bmb-module-init", "Install Module", "IM", "install", "", "Install BMad Builder.", "anytime", "", "", "false", "", "config", ""],
["bmb", "", "bmad-agent-builder", "Build Agent", "BA", "build-process", "", "Create an agent.", "anytime", "", "", "false", "output_folder", "agent skill", ""],
]
class TestExtractModuleCodes(unittest.TestCase):
def test_extracts_codes(self):
codes = extract_module_codes(SAMPLE_ROWS)
self.assertEqual(codes, {"bmb"})
def test_multiple_codes(self):
rows = SAMPLE_ROWS + [
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
]
codes = extract_module_codes(rows)
self.assertEqual(codes, {"bmb", "cis"})
def test_empty_rows(self):
codes = extract_module_codes([])
self.assertEqual(codes, set())
class TestFilterRows(unittest.TestCase):
def test_removes_matching_rows(self):
result = filter_rows(SAMPLE_ROWS, "bmb")
self.assertEqual(len(result), 0)
def test_preserves_non_matching_rows(self):
mixed_rows = SAMPLE_ROWS + [
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
]
result = filter_rows(mixed_rows, "bmb")
self.assertEqual(len(result), 1)
self.assertEqual(result[0][0], "cis")
def test_no_match_preserves_all(self):
result = filter_rows(SAMPLE_ROWS, "xyz")
self.assertEqual(len(result), 2)
class TestReadWriteCSV(unittest.TestCase):
def test_nonexistent_file_returns_empty(self):
header, rows = read_csv_rows("/nonexistent/path/file.csv")
self.assertEqual(header, [])
self.assertEqual(rows, [])
def test_round_trip(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "test.csv")
write_csv(path, HEADER, SAMPLE_ROWS)
header, rows = read_csv_rows(path)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0][0], "bmb")
self.assertEqual(rows[0][2], "bmad-bmb-module-init")
def test_creates_parent_dirs(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "sub", "dir", "test.csv")
write_csv(path, HEADER, SAMPLE_ROWS)
self.assertTrue(os.path.exists(path))
class TestEndToEnd(unittest.TestCase):
def _write_source(self, tmpdir, rows):
path = os.path.join(tmpdir, "source.csv")
write_csv(path, HEADER, rows)
return path
def _write_target(self, tmpdir, rows):
path = os.path.join(tmpdir, "target.csv")
write_csv(path, HEADER, rows)
return path
def test_fresh_install_no_existing_target(self):
with tempfile.TemporaryDirectory() as tmpdir:
source_path = self._write_source(tmpdir, SAMPLE_ROWS)
target_path = os.path.join(tmpdir, "target.csv")
# Target doesn't exist
self.assertFalse(os.path.exists(target_path))
# Simulate merge
_, source_rows = read_csv_rows(source_path)
source_codes = extract_module_codes(source_rows)
write_csv(target_path, HEADER, source_rows)
_, result_rows = read_csv_rows(target_path)
self.assertEqual(len(result_rows), 2)
def test_merge_into_existing_with_other_module(self):
with tempfile.TemporaryDirectory() as tmpdir:
other_rows = [
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
]
target_path = self._write_target(tmpdir, other_rows)
source_path = self._write_source(tmpdir, SAMPLE_ROWS)
# Read both
_, target_rows = read_csv_rows(target_path)
_, source_rows = read_csv_rows(source_path)
source_codes = extract_module_codes(source_rows)
# Anti-zombie filter + append
filtered = target_rows
for code in source_codes:
filtered = filter_rows(filtered, code)
merged = filtered + source_rows
write_csv(target_path, HEADER, merged)
_, result_rows = read_csv_rows(target_path)
self.assertEqual(len(result_rows), 3) # 1 cis + 2 bmb
def test_anti_zombie_replaces_stale_entries(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Existing target has old bmb entries + cis entry
old_bmb_rows = [
["bmb", "", "old-skill", "Old Skill", "OS", "run", "", "Old.", "anytime", "", "", "false", "", "", ""],
["bmb", "", "another-old", "Another", "AO", "run", "", "Old too.", "anytime", "", "", "false", "", "", ""],
]
cis_rows = [
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
]
target_path = self._write_target(tmpdir, old_bmb_rows + cis_rows)
source_path = self._write_source(tmpdir, SAMPLE_ROWS)
# Read both
_, target_rows = read_csv_rows(target_path)
_, source_rows = read_csv_rows(source_path)
source_codes = extract_module_codes(source_rows)
# Anti-zombie filter + append
filtered = target_rows
for code in source_codes:
filtered = filter_rows(filtered, code)
merged = filtered + source_rows
write_csv(target_path, HEADER, merged)
_, result_rows = read_csv_rows(target_path)
# Should have 1 cis + 2 new bmb = 3 (old bmb removed)
self.assertEqual(len(result_rows), 3)
module_codes = [r[0] for r in result_rows]
self.assertEqual(module_codes.count("bmb"), 2)
self.assertEqual(module_codes.count("cis"), 1)
# Old skills should be gone
skill_names = [r[2] for r in result_rows]
self.assertNotIn("old-skill", skill_names)
self.assertNotIn("another-old", skill_names)
class TestCleanupLegacyCsvs(unittest.TestCase):
def test_deletes_module_and_core_csvs(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = os.path.join(tmpdir, "_bmad")
for subdir in ("core", "bmb"):
d = os.path.join(legacy_dir, subdir)
os.makedirs(d)
with open(os.path.join(d, "module-help.csv"), "w") as f:
f.write("header\nrow\n")
deleted = cleanup_legacy_csvs(legacy_dir, "bmb")
self.assertEqual(len(deleted), 2)
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "core", "module-help.csv")))
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "bmb", "module-help.csv")))
# Directories still exist
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "core")))
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "bmb")))
def test_leaves_other_module_csvs_alone(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = os.path.join(tmpdir, "_bmad")
for subdir in ("bmb", "cis"):
d = os.path.join(legacy_dir, subdir)
os.makedirs(d)
with open(os.path.join(d, "module-help.csv"), "w") as f:
f.write("header\nrow\n")
deleted = cleanup_legacy_csvs(legacy_dir, "bmb")
self.assertEqual(len(deleted), 1) # only bmb, not cis
self.assertTrue(os.path.exists(os.path.join(legacy_dir, "cis", "module-help.csv")))
def test_no_legacy_files_returns_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
deleted = cleanup_legacy_csvs(tmpdir, "bmb")
self.assertEqual(deleted, [])
def test_handles_only_core_no_module(self):
with tempfile.TemporaryDirectory() as tmpdir:
legacy_dir = os.path.join(tmpdir, "_bmad")
core_dir = os.path.join(legacy_dir, "core")
os.makedirs(core_dir)
with open(os.path.join(core_dir, "module-help.csv"), "w") as f:
f.write("header\nrow\n")
deleted = cleanup_legacy_csvs(legacy_dir, "bmb")
self.assertEqual(len(deleted), 1)
self.assertFalse(os.path.exists(os.path.join(core_dir, "module-help.csv")))
if __name__ == "__main__":
unittest.main()