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 b7dc27f2a5
786 changed files with 118783 additions and 316 deletions

View File

@@ -0,0 +1,100 @@
---
name: bmad-init
description: "Initialize BMad project configuration and load config variables. Use when any skill needs module-specific configuration values, or when setting up a new BMad project."
argument-hint: "[--module=module_code] [--vars=var1:default1,var2] [--skill-path=/path/to/calling/skill]"
---
## Overview
This skill is the configuration entry point for all BMad skills. It has two modes:
- **Fast path**: Config exists for the requested module — returns vars as JSON. Done.
- **Init path**: Config is missing — walks the user through configuration, writes config files, then returns vars.
Every BMad skill should call this on activation to get its config vars. The caller never needs to know whether init happened — they just get their config back.
The script `bmad_init.py` is located in this skill's `scripts/` directory. Locate and run it using python for all commands below.
## On Activation — Fast Path
Run the `bmad_init.py` script with the `load` subcommand. Pass `--project-root` set to the project root directory.
- If a module code was provided by the calling skill, include `--module {module_code}`
- To load all vars, include `--all`
- To request specific variables with defaults, use `--vars var1:default1,var2`
- If no module was specified, omit `--module` to get core vars only
**If the script returns JSON vars** — store them as `{var-name}` and return to the calling skill. Done.
**If the script returns an error or `init_required`** — proceed to the Init Path below.
## Init Path — First-Time Setup
When the fast path fails (config missing for a module), run this init flow.
### Step 1: Check what needs setup
Run `bmad_init.py` with the `check` subcommand, passing `--module {module_code}`, `--skill-path {calling_skill_path}`, and `--project-root`.
The response tells you what's needed:
- `"status": "ready"` — Config is fine. Re-run load.
- `"status": "no_project"` — Can't find project root. Ask user to confirm the project path.
- `"status": "core_missing"` — Core config doesn't exist. Must ask core questions first.
- `"status": "module_missing"` — Core exists but module config doesn't. Ask module questions.
The response includes:
- `core_module` — Core module.yaml questions (when core setup needed)
- `target_module` — Target module.yaml questions (when module setup needed, discovered from `--skill-path` or `_bmad/{module}/`)
- `core_vars` — Existing core config values (when core exists but module doesn't)
### Step 2: Ask core questions (if `core_missing`)
The check response includes `core_module` with header, subheader, and variable definitions.
1. Show the `header` and `subheader` to the user
2. For each variable, present the `prompt` and `default`
3. For variables with `single-select`, show the options as a numbered list
4. For variables with multi-line `prompt` (array), show all lines
5. Let the user accept defaults or provide values
### Step 3: Ask module questions (if module was requested)
The check response includes `target_module` with the module's questions. Variables may reference core answers in their defaults (e.g., `{output_folder}`).
1. Resolve defaults by running `bmad_init.py` with the `resolve-defaults` subcommand, passing `--module {module_code}`, `--core-answers '{core_answers_json}'`, and `--project-root`
2. Show the module's `header` and `subheader`
3. For each variable, present the prompt with resolved default
4. For `single-select` variables, show options as a numbered list
### Step 4: Write config
Collect all answers and run `bmad_init.py` with the `write` subcommand, passing `--answers '{all_answers_json}'` and `--project-root`.
The `--answers` JSON format:
```json
{
"core": {
"user_name": "BMad",
"communication_language": "English",
"document_output_language": "English",
"output_folder": "_bmad-output"
},
"bmb": {
"bmad_builder_output_folder": "_bmad-output/skills",
"bmad_builder_reports": "_bmad-output/reports"
}
}
```
Note: Pass the **raw user answers** (before result template expansion). The script applies result templates and `{project-root}` expansion when writing.
The script:
- Creates `_bmad/core/config.yaml` with core values (if core answers provided)
- Creates `_bmad/{module}/config.yaml` with core values + module values (result-expanded)
- Creates any directories listed in the module.yaml `directories` array
### Step 5: Return vars
After writing, re-run `bmad_init.py` with the `load` subcommand (same as the fast path) to return resolved vars. Store returned vars as `{var-name}` and return them to the calling skill.

View File

@@ -0,0 +1,25 @@
code: core
name: "BMad Core Module"
header: "BMad Core Configuration"
subheader: "Configure the core settings for your BMad installation.\nThese settings will be used across all installed bmad skills, workflows, and agents."
user_name:
prompt: "What should agents call you? (Use your name or a team name)"
default: "BMad"
result: "{value}"
communication_language:
prompt: "What language should agents use when chatting with you?"
default: "English"
result: "{value}"
document_output_language:
prompt: "Preferred document output language?"
default: "English"
result: "{value}"
output_folder:
prompt: "Where should output files be saved?"
default: "_bmad-output"
result: "{project-root}/{value}"

View File

@@ -0,0 +1,593 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml"]
# ///
#!/usr/bin/env python3
"""
BMad Init — Project configuration bootstrap and config loader.
Config files (flat YAML per module):
- _bmad/core/config.yaml (core settings — user_name, language, output_folder, etc.)
- _bmad/{module}/config.yaml (module settings + core values merged in)
Usage:
# Fast path — load all vars for a module (includes core vars)
python bmad_init.py load --module bmb --all --project-root /path
# Load specific vars with optional defaults
python bmad_init.py load --module bmb --vars var1:default1,var2 --project-root /path
# Load core only
python bmad_init.py load --all --project-root /path
# Check if init is needed
python bmad_init.py check --project-root /path
python bmad_init.py check --module bmb --skill-path /path/to/skill --project-root /path
# Resolve module defaults given core answers
python bmad_init.py resolve-defaults --module bmb --core-answers '{"output_folder":"..."}' --project-root /path
# Write config from answered questions
python bmad_init.py write --answers '{"core": {...}, "bmb": {...}}' --project-root /path
"""
import argparse
import json
import os
import sys
from pathlib import Path
import yaml
# =============================================================================
# Project Root Detection
# =============================================================================
def find_project_root(llm_provided=None):
"""
Find project root by looking for _bmad folder.
Args:
llm_provided: Path explicitly provided via --project-root.
Returns:
Path to project root, or None if not found.
"""
if llm_provided:
candidate = Path(llm_provided)
if (candidate / '_bmad').exists():
return candidate
# First run — _bmad won't exist yet but LLM path is still valid
if candidate.is_dir():
return candidate
for start_dir in [Path.cwd(), Path(__file__).resolve().parent]:
current_dir = start_dir
while current_dir != current_dir.parent:
if (current_dir / '_bmad').exists():
return current_dir
current_dir = current_dir.parent
return None
# =============================================================================
# Module YAML Loading
# =============================================================================
def load_module_yaml(path):
"""
Load and parse a module.yaml file, separating metadata from variable definitions.
Returns:
Dict with 'meta' (code, name, etc.) and 'variables' (var definitions)
and 'directories' (list of dir templates), or None on failure.
"""
try:
with open(path, 'r', encoding='utf-8') as f:
raw = yaml.safe_load(f)
except Exception:
return None
if not raw or not isinstance(raw, dict):
return None
meta_keys = {'code', 'name', 'description', 'default_selected', 'header', 'subheader'}
meta = {}
variables = {}
directories = []
for key, value in raw.items():
if key == 'directories':
directories = value if isinstance(value, list) else []
elif key in meta_keys:
meta[key] = value
elif isinstance(value, dict) and 'prompt' in value:
variables[key] = value
# Skip comment-only entries (## var_name lines become None values)
return {'meta': meta, 'variables': variables, 'directories': directories}
def find_core_module_yaml():
"""Find the core module.yaml bundled with this skill."""
return Path(__file__).resolve().parent.parent / 'resources' / 'core-module.yaml'
def find_target_module_yaml(module_code, project_root, skill_path=None):
"""
Find module.yaml for a given module code.
Search order:
1. skill_path/assets/module.yaml (calling skill's assets)
2. skill_path/module.yaml (calling skill's root)
3. _bmad/{module_code}/module.yaml (installed module location)
"""
search_paths = []
if skill_path:
sp = Path(skill_path)
search_paths.append(sp / 'assets' / 'module.yaml')
search_paths.append(sp / 'module.yaml')
if project_root and module_code:
search_paths.append(Path(project_root) / '_bmad' / module_code / 'module.yaml')
for path in search_paths:
if path.exists():
return path
return None
# =============================================================================
# Config Loading (Flat per-module files)
# =============================================================================
def load_config_file(path):
"""Load a flat YAML config file. Returns dict or None."""
try:
with open(path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
def load_module_config(module_code, project_root):
"""Load config for a specific module from _bmad/{module}/config.yaml."""
config_path = Path(project_root) / '_bmad' / module_code / 'config.yaml'
return load_config_file(config_path)
def resolve_project_root_placeholder(value, project_root):
"""Replace {project-root} placeholder with actual path."""
if not value or not isinstance(value, str):
return value
if '{project-root}' in value:
return value.replace('{project-root}', str(project_root))
return value
def parse_var_specs(vars_string):
"""
Parse variable specs: var_name:default_value,var_name2:default_value2
No default = returns null if missing.
"""
if not vars_string:
return []
specs = []
for spec in vars_string.split(','):
spec = spec.strip()
if not spec:
continue
if ':' in spec:
parts = spec.split(':', 1)
specs.append({'name': parts[0].strip(), 'default': parts[1].strip()})
else:
specs.append({'name': spec, 'default': None})
return specs
# =============================================================================
# Template Expansion
# =============================================================================
def expand_template(value, context):
"""
Expand {placeholder} references in a string using context dict.
Supports: {project-root}, {value}, {output_folder}, {directory_name}, etc.
"""
if not value or not isinstance(value, str):
return value
result = value
for key, val in context.items():
placeholder = '{' + key + '}'
if placeholder in result and val is not None:
result = result.replace(placeholder, str(val))
return result
def apply_result_template(var_def, raw_value, context):
"""
Apply a variable's result template to transform the raw user answer.
E.g., result: "{project-root}/{value}" with value="_bmad-output"
becomes "/Users/foo/project/_bmad-output"
"""
result_template = var_def.get('result')
if not result_template:
return raw_value
ctx = dict(context)
ctx['value'] = raw_value
return expand_template(result_template, ctx)
# =============================================================================
# Load Command (Fast Path)
# =============================================================================
def cmd_load(args):
"""Load config vars — the fast path."""
project_root = find_project_root(llm_provided=args.project_root)
if not project_root:
print(json.dumps({'error': 'Project root not found (_bmad folder not detected)'}),
file=sys.stderr)
sys.exit(1)
module_code = args.module or 'core'
# Load the module's config (which includes core vars)
config = load_module_config(module_code, project_root)
if config is None:
print(json.dumps({
'init_required': True,
'missing_module': module_code,
}), file=sys.stderr)
sys.exit(1)
# Resolve {project-root} in all values
for key in config:
config[key] = resolve_project_root_placeholder(config[key], project_root)
if args.all:
print(json.dumps(config, indent=2))
else:
var_specs = parse_var_specs(args.vars)
if not var_specs:
print(json.dumps({'error': 'Either --vars or --all must be specified'}),
file=sys.stderr)
sys.exit(1)
result = {}
for spec in var_specs:
val = config.get(spec['name'])
if val is not None and val != '':
result[spec['name']] = val
elif spec['default'] is not None:
result[spec['name']] = spec['default']
else:
result[spec['name']] = None
print(json.dumps(result, indent=2))
# =============================================================================
# Check Command
# =============================================================================
def cmd_check(args):
"""Check if config exists and return status with module.yaml questions if needed."""
project_root = find_project_root(llm_provided=args.project_root)
if not project_root:
print(json.dumps({
'status': 'no_project',
'message': 'No project root found. Provide --project-root to bootstrap.',
}, indent=2))
return
project_root = Path(project_root)
module_code = args.module
# Check core config
core_config = load_module_config('core', project_root)
core_exists = core_config is not None
# If no module requested, just check core
if not module_code or module_code == 'core':
if core_exists:
print(json.dumps({'status': 'ready', 'project_root': str(project_root)}, indent=2))
else:
core_yaml_path = find_core_module_yaml()
core_module = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
print(json.dumps({
'status': 'core_missing',
'project_root': str(project_root),
'core_module': core_module,
}, indent=2))
return
# Module requested — check if its config exists
module_config = load_module_config(module_code, project_root)
if module_config is not None:
print(json.dumps({'status': 'ready', 'project_root': str(project_root)}, indent=2))
return
# Module config missing — find its module.yaml for questions
target_yaml_path = find_target_module_yaml(
module_code, project_root, skill_path=args.skill_path
)
target_module = load_module_yaml(target_yaml_path) if target_yaml_path else None
result = {
'project_root': str(project_root),
}
if not core_exists:
result['status'] = 'core_missing'
core_yaml_path = find_core_module_yaml()
result['core_module'] = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
else:
result['status'] = 'module_missing'
result['core_vars'] = core_config
result['target_module'] = target_module
if target_yaml_path:
result['target_module_yaml_path'] = str(target_yaml_path)
print(json.dumps(result, indent=2))
# =============================================================================
# Resolve Defaults Command
# =============================================================================
def cmd_resolve_defaults(args):
"""Given core answers, resolve a module's variable defaults."""
project_root = find_project_root(llm_provided=args.project_root)
if not project_root:
print(json.dumps({'error': 'Project root not found'}), file=sys.stderr)
sys.exit(1)
try:
core_answers = json.loads(args.core_answers)
except json.JSONDecodeError as e:
print(json.dumps({'error': f'Invalid JSON in --core-answers: {e}'}),
file=sys.stderr)
sys.exit(1)
# Build context for template expansion
context = {
'project-root': str(project_root),
'directory_name': Path(project_root).name,
}
context.update(core_answers)
# Find and load the module's module.yaml
module_code = args.module
target_yaml_path = find_target_module_yaml(
module_code, project_root, skill_path=args.skill_path
)
if not target_yaml_path:
print(json.dumps({'error': f'No module.yaml found for module: {module_code}'}),
file=sys.stderr)
sys.exit(1)
module_def = load_module_yaml(target_yaml_path)
if not module_def:
print(json.dumps({'error': f'Failed to parse module.yaml at: {target_yaml_path}'}),
file=sys.stderr)
sys.exit(1)
# Resolve defaults in each variable
resolved_vars = {}
for var_name, var_def in module_def['variables'].items():
default = var_def.get('default', '')
resolved_default = expand_template(str(default), context)
resolved_vars[var_name] = dict(var_def)
resolved_vars[var_name]['default'] = resolved_default
result = {
'module_code': module_code,
'meta': module_def['meta'],
'variables': resolved_vars,
'directories': module_def['directories'],
}
print(json.dumps(result, indent=2))
# =============================================================================
# Write Command
# =============================================================================
def cmd_write(args):
"""Write config files from answered questions."""
project_root = find_project_root(llm_provided=args.project_root)
if not project_root:
if args.project_root:
project_root = Path(args.project_root)
else:
print(json.dumps({'error': 'Project root not found and --project-root not provided'}),
file=sys.stderr)
sys.exit(1)
project_root = Path(project_root)
try:
answers = json.loads(args.answers)
except json.JSONDecodeError as e:
print(json.dumps({'error': f'Invalid JSON in --answers: {e}'}),
file=sys.stderr)
sys.exit(1)
context = {
'project-root': str(project_root),
'directory_name': project_root.name,
}
# Load module.yaml definitions to get result templates
core_yaml_path = find_core_module_yaml()
core_def = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
files_written = []
dirs_created = []
# Process core answers first (needed for module config expansion)
core_answers_raw = answers.get('core', {})
core_config = {}
if core_answers_raw and core_def:
for var_name, raw_value in core_answers_raw.items():
var_def = core_def['variables'].get(var_name, {})
expanded = apply_result_template(var_def, raw_value, context)
core_config[var_name] = expanded
# Write core config
core_dir = project_root / '_bmad' / 'core'
core_dir.mkdir(parents=True, exist_ok=True)
core_config_path = core_dir / 'config.yaml'
# Merge with existing if present
existing = load_config_file(core_config_path) or {}
existing.update(core_config)
_write_config_file(core_config_path, existing, 'CORE')
files_written.append(str(core_config_path))
elif core_answers_raw:
# No core_def available — write raw values
core_config = dict(core_answers_raw)
core_dir = project_root / '_bmad' / 'core'
core_dir.mkdir(parents=True, exist_ok=True)
core_config_path = core_dir / 'config.yaml'
existing = load_config_file(core_config_path) or {}
existing.update(core_config)
_write_config_file(core_config_path, existing, 'CORE')
files_written.append(str(core_config_path))
# Update context with resolved core values for module expansion
context.update(core_config)
# Process module answers
for module_code, module_answers_raw in answers.items():
if module_code == 'core':
continue
# Find module.yaml for result templates
target_yaml_path = find_target_module_yaml(
module_code, project_root, skill_path=args.skill_path
)
module_def = load_module_yaml(target_yaml_path) if target_yaml_path else None
# Build module config: start with core values, then add module values
# Re-read core config to get the latest (may have been updated above)
latest_core = load_module_config('core', project_root) or core_config
module_config = dict(latest_core)
for var_name, raw_value in module_answers_raw.items():
if module_def:
var_def = module_def['variables'].get(var_name, {})
expanded = apply_result_template(var_def, raw_value, context)
else:
expanded = raw_value
module_config[var_name] = expanded
context[var_name] = expanded # Available for subsequent template expansion
# Write module config
module_dir = project_root / '_bmad' / module_code
module_dir.mkdir(parents=True, exist_ok=True)
module_config_path = module_dir / 'config.yaml'
existing = load_config_file(module_config_path) or {}
existing.update(module_config)
module_name = module_def['meta'].get('name', module_code.upper()) if module_def else module_code.upper()
_write_config_file(module_config_path, existing, module_name)
files_written.append(str(module_config_path))
# Create directories declared in module.yaml
if module_def and module_def.get('directories'):
for dir_template in module_def['directories']:
dir_path = expand_template(dir_template, context)
if dir_path:
Path(dir_path).mkdir(parents=True, exist_ok=True)
dirs_created.append(dir_path)
result = {
'status': 'written',
'files_written': files_written,
'dirs_created': dirs_created,
}
print(json.dumps(result, indent=2))
def _write_config_file(path, data, module_label):
"""Write a config YAML file with a header comment."""
from datetime import datetime, timezone
with open(path, 'w', encoding='utf-8') as f:
f.write(f'# {module_label} Module Configuration\n')
f.write(f'# Generated by bmad-init\n')
f.write(f'# Date: {datetime.now(timezone.utc).isoformat()}\n\n')
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
# =============================================================================
# CLI Entry Point
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description='BMad Init — Project configuration bootstrap and config loader.'
)
subparsers = parser.add_subparsers(dest='command')
# --- load ---
load_parser = subparsers.add_parser('load', help='Load config vars (fast path)')
load_parser.add_argument('--module', help='Module code (omit for core only)')
load_parser.add_argument('--vars', help='Comma-separated vars with optional defaults')
load_parser.add_argument('--all', action='store_true', help='Return all config vars')
load_parser.add_argument('--project-root', help='Project root path')
# --- check ---
check_parser = subparsers.add_parser('check', help='Check if init is needed')
check_parser.add_argument('--module', help='Module code to check (optional)')
check_parser.add_argument('--skill-path', help='Path to the calling skill folder')
check_parser.add_argument('--project-root', help='Project root path')
# --- resolve-defaults ---
resolve_parser = subparsers.add_parser('resolve-defaults',
help='Resolve module defaults given core answers')
resolve_parser.add_argument('--module', required=True, help='Module code')
resolve_parser.add_argument('--core-answers', required=True, help='JSON string of core answers')
resolve_parser.add_argument('--skill-path', help='Path to calling skill folder')
resolve_parser.add_argument('--project-root', help='Project root path')
# --- write ---
write_parser = subparsers.add_parser('write', help='Write config files')
write_parser.add_argument('--answers', required=True, help='JSON string of all answers')
write_parser.add_argument('--skill-path', help='Path to calling skill (for module.yaml lookup)')
write_parser.add_argument('--project-root', help='Project root path')
args = parser.parse_args()
if args.command is None:
parser.print_help()
sys.exit(1)
commands = {
'load': cmd_load,
'check': cmd_check,
'resolve-defaults': cmd_resolve_defaults,
'write': cmd_write,
}
handler = commands.get(args.command)
if handler:
handler(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,329 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml"]
# ///
#!/usr/bin/env python3
"""Unit tests for bmad_init.py"""
import json
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from bmad_init import (
find_project_root,
parse_var_specs,
resolve_project_root_placeholder,
expand_template,
apply_result_template,
load_module_yaml,
find_core_module_yaml,
find_target_module_yaml,
load_config_file,
load_module_config,
)
class TestFindProjectRoot(unittest.TestCase):
def test_finds_bmad_folder(self):
temp_dir = tempfile.mkdtemp()
try:
(Path(temp_dir) / '_bmad').mkdir()
original_cwd = os.getcwd()
try:
os.chdir(temp_dir)
result = find_project_root()
self.assertEqual(result.resolve(), Path(temp_dir).resolve())
finally:
os.chdir(original_cwd)
finally:
shutil.rmtree(temp_dir)
def test_llm_provided_with_bmad(self):
temp_dir = tempfile.mkdtemp()
try:
(Path(temp_dir) / '_bmad').mkdir()
result = find_project_root(llm_provided=temp_dir)
self.assertEqual(result.resolve(), Path(temp_dir).resolve())
finally:
shutil.rmtree(temp_dir)
def test_llm_provided_without_bmad_still_returns_dir(self):
"""First-run case: LLM provides path but _bmad doesn't exist yet."""
temp_dir = tempfile.mkdtemp()
try:
result = find_project_root(llm_provided=temp_dir)
self.assertEqual(result.resolve(), Path(temp_dir).resolve())
finally:
shutil.rmtree(temp_dir)
class TestParseVarSpecs(unittest.TestCase):
def test_vars_with_defaults(self):
specs = parse_var_specs('var1:value1,var2:value2')
self.assertEqual(len(specs), 2)
self.assertEqual(specs[0]['name'], 'var1')
self.assertEqual(specs[0]['default'], 'value1')
def test_vars_without_defaults(self):
specs = parse_var_specs('var1,var2')
self.assertEqual(len(specs), 2)
self.assertIsNone(specs[0]['default'])
def test_mixed_vars(self):
specs = parse_var_specs('required_var,var2:default2')
self.assertIsNone(specs[0]['default'])
self.assertEqual(specs[1]['default'], 'default2')
def test_colon_in_default(self):
specs = parse_var_specs('path:{project-root}/some/path')
self.assertEqual(specs[0]['default'], '{project-root}/some/path')
def test_empty_string(self):
self.assertEqual(parse_var_specs(''), [])
def test_none(self):
self.assertEqual(parse_var_specs(None), [])
class TestResolveProjectRootPlaceholder(unittest.TestCase):
def test_resolve_placeholder(self):
result = resolve_project_root_placeholder('{project-root}/output', Path('/test'))
self.assertEqual(result, '/test/output')
def test_no_placeholder(self):
result = resolve_project_root_placeholder('/absolute/path', Path('/test'))
self.assertEqual(result, '/absolute/path')
def test_none(self):
self.assertIsNone(resolve_project_root_placeholder(None, Path('/test')))
def test_non_string(self):
self.assertEqual(resolve_project_root_placeholder(42, Path('/test')), 42)
class TestExpandTemplate(unittest.TestCase):
def test_basic_expansion(self):
result = expand_template('{project-root}/output', {'project-root': '/test'})
self.assertEqual(result, '/test/output')
def test_multiple_placeholders(self):
result = expand_template(
'{output_folder}/planning',
{'output_folder': '_bmad-output', 'project-root': '/test'}
)
self.assertEqual(result, '_bmad-output/planning')
def test_none_value(self):
self.assertIsNone(expand_template(None, {}))
def test_non_string(self):
self.assertEqual(expand_template(42, {}), 42)
class TestApplyResultTemplate(unittest.TestCase):
def test_with_result_template(self):
var_def = {'result': '{project-root}/{value}'}
result = apply_result_template(var_def, '_bmad-output', {'project-root': '/test'})
self.assertEqual(result, '/test/_bmad-output')
def test_without_result_template(self):
result = apply_result_template({}, 'raw_value', {})
self.assertEqual(result, 'raw_value')
def test_value_only_template(self):
var_def = {'result': '{value}'}
result = apply_result_template(var_def, 'English', {})
self.assertEqual(result, 'English')
class TestLoadModuleYaml(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_loads_core_module_yaml(self):
path = Path(self.temp_dir) / 'module.yaml'
path.write_text(
'code: core\n'
'name: "BMad Core Module"\n'
'header: "Core Config"\n'
'user_name:\n'
' prompt: "What should agents call you?"\n'
' default: "BMad"\n'
' result: "{value}"\n'
)
result = load_module_yaml(path)
self.assertIsNotNone(result)
self.assertEqual(result['meta']['code'], 'core')
self.assertEqual(result['meta']['name'], 'BMad Core Module')
self.assertIn('user_name', result['variables'])
self.assertEqual(result['variables']['user_name']['prompt'], 'What should agents call you?')
def test_loads_module_with_directories(self):
path = Path(self.temp_dir) / 'module.yaml'
path.write_text(
'code: bmm\n'
'name: "BMad Method"\n'
'project_name:\n'
' prompt: "Project name?"\n'
' default: "{directory_name}"\n'
' result: "{value}"\n'
'directories:\n'
' - "{planning_artifacts}"\n'
)
result = load_module_yaml(path)
self.assertEqual(result['directories'], ['{planning_artifacts}'])
def test_returns_none_for_missing(self):
result = load_module_yaml(Path(self.temp_dir) / 'nonexistent.yaml')
self.assertIsNone(result)
def test_returns_none_for_empty(self):
path = Path(self.temp_dir) / 'empty.yaml'
path.write_text('')
result = load_module_yaml(path)
self.assertIsNone(result)
class TestFindCoreModuleYaml(unittest.TestCase):
def test_returns_path_to_resources(self):
path = find_core_module_yaml()
self.assertTrue(str(path).endswith('resources/core-module.yaml'))
class TestFindTargetModuleYaml(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.project_root = Path(self.temp_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_finds_in_skill_assets(self):
skill_path = self.project_root / 'skills' / 'test-skill'
assets = skill_path / 'assets'
assets.mkdir(parents=True)
(assets / 'module.yaml').write_text('code: test\n')
result = find_target_module_yaml('test', self.project_root, str(skill_path))
self.assertIsNotNone(result)
self.assertTrue(str(result).endswith('assets/module.yaml'))
def test_finds_in_skill_root(self):
skill_path = self.project_root / 'skills' / 'test-skill'
skill_path.mkdir(parents=True)
(skill_path / 'module.yaml').write_text('code: test\n')
result = find_target_module_yaml('test', self.project_root, str(skill_path))
self.assertIsNotNone(result)
def test_finds_in_bmad_module_dir(self):
module_dir = self.project_root / '_bmad' / 'mymod'
module_dir.mkdir(parents=True)
(module_dir / 'module.yaml').write_text('code: mymod\n')
result = find_target_module_yaml('mymod', self.project_root)
self.assertIsNotNone(result)
def test_returns_none_when_not_found(self):
result = find_target_module_yaml('missing', self.project_root)
self.assertIsNone(result)
def test_skill_path_takes_priority(self):
"""Skill assets module.yaml takes priority over _bmad/{module}/."""
skill_path = self.project_root / 'skills' / 'test-skill'
assets = skill_path / 'assets'
assets.mkdir(parents=True)
(assets / 'module.yaml').write_text('code: test\nname: from-skill\n')
module_dir = self.project_root / '_bmad' / 'test'
module_dir.mkdir(parents=True)
(module_dir / 'module.yaml').write_text('code: test\nname: from-bmad\n')
result = find_target_module_yaml('test', self.project_root, str(skill_path))
self.assertTrue('assets' in str(result))
class TestLoadConfigFile(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_loads_flat_yaml(self):
path = Path(self.temp_dir) / 'config.yaml'
path.write_text('user_name: Test\ncommunication_language: English\n')
result = load_config_file(path)
self.assertEqual(result['user_name'], 'Test')
def test_returns_none_for_missing(self):
result = load_config_file(Path(self.temp_dir) / 'missing.yaml')
self.assertIsNone(result)
class TestLoadModuleConfig(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.project_root = Path(self.temp_dir)
bmad_core = self.project_root / '_bmad' / 'core'
bmad_core.mkdir(parents=True)
(bmad_core / 'config.yaml').write_text(
'user_name: TestUser\n'
'communication_language: English\n'
'document_output_language: English\n'
'output_folder: "{project-root}/_bmad-output"\n'
)
bmad_bmb = self.project_root / '_bmad' / 'bmb'
bmad_bmb.mkdir(parents=True)
(bmad_bmb / 'config.yaml').write_text(
'user_name: TestUser\n'
'communication_language: English\n'
'document_output_language: English\n'
'output_folder: "{project-root}/_bmad-output"\n'
'bmad_builder_output_folder: "{project-root}/_bmad-output/skills"\n'
'bmad_builder_reports: "{project-root}/_bmad-output/reports"\n'
)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_load_core(self):
result = load_module_config('core', self.project_root)
self.assertIsNotNone(result)
self.assertEqual(result['user_name'], 'TestUser')
def test_load_module_includes_core_vars(self):
result = load_module_config('bmb', self.project_root)
self.assertIsNotNone(result)
# Module-specific var
self.assertIn('bmad_builder_output_folder', result)
# Core vars also present
self.assertEqual(result['user_name'], 'TestUser')
def test_missing_module(self):
result = load_module_config('nonexistent', self.project_root)
self.assertIsNone(result)
if __name__ == '__main__':
unittest.main()