Skip to content

Commit fdda74f

Browse files
armelhbobdadclaude
andauthored
fix(validate-module): harden standalone module validation (#97)
* fix(validate-module): harden standalone module validation Three robustness fixes to validate-module.py for standalone single-skill modules: - Accept importable underscore-named merge scripts (merge_config.py) in addition to the scaffolder's dash form (merge-config.py). Either is valid; a module may rename them to be importable from module-setup.md without that being a structural defect. - Treat colon-less preceded-by/followed-by refs as cross-module positional references (bare sibling-module skill names) and skip them. Other installed modules aren't visible to the validator, so they can't be resolved and aren't defects; intra-module skill:action refs are still validated. - Detect a standalone module when handed the skill directory itself, not only its parent folder (as the reference doc already promised). Skill-folder and orphan-entry checks now resolve in both cases. Adds 3 regression tests (20 pass total); updates the reference doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(validate-module): scope standalone orphan check to the module Addresses CodeRabbit review on #97: in direct skill-dir mode the orphan check resolved CSV skills against the parent folder, so an unrelated sibling skill directory could mask a true orphan entry. For a standalone module, skill_folders already enumerates every valid skill (just the standalone skill), so any other CSV skill is an orphan — drop the parent-folder filesystem lookup entirely. The multi-skill path still re-checks the filesystem for the setup skill. Adds a regression test (sibling dir must not mask the orphan); 21 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(validate-module): assert exit code in orphan regression test Addresses CodeRabbit nit on #97: the orphan regression test unpacked `code` without using it. Assert `code == 1` (the orphan makes validation fail), which also strengthens the test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d97d675 commit fdda74f

3 files changed

Lines changed: 138 additions & 16 deletions

File tree

skills/bmad-module-builder/references/validate-module.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ You are a module quality reviewer. Your job is to verify that a BMad module's st
1313
Ask the user for the path to their module's skills folder (or a single skill folder for standalone modules). The validation script auto-detects the module type:
1414

1515
- **Multi-skill module:** Identifies the setup skill (`*-setup`) and all other skill folders
16-
- **Standalone module:** Detected when no setup skill exists and the folder contains a single skill with `assets/module.yaml`. Validates: `assets/module-setup.md`, `assets/module.yaml`, `assets/module-help.csv`, `scripts/merge-config.py`, `scripts/merge-help-csv.py`
16+
- **Standalone module:** Detected when no setup skill exists and either the folder contains a single skill with `assets/module.yaml`, or the path given _is_ the standalone skill directory itself. Validates: `assets/module-setup.md`, `assets/module.yaml`, `assets/module-help.csv`, and the two merge scripts — either the scaffolder's dash form (`scripts/merge-config.py`, `scripts/merge-help-csv.py`) or the importable underscore form (`scripts/merge_config.py`, `scripts/merge_help_csv.py`) is accepted.
1717

1818
### 2. Run Structural Validation
1919

skills/bmad-module-builder/scripts/tests/test-validate-module.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,14 @@ def test_short_row_does_not_crash():
235235
def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
236236
csv_rows: str = "", yaml_content: str = "",
237237
include_setup_md: bool = True,
238-
include_merge_scripts: bool = True) -> Path:
239-
"""Create a minimal standalone module structure for testing."""
238+
include_merge_scripts: bool = True,
239+
merge_script_style: str = "dash") -> Path:
240+
"""Create a minimal standalone module structure for testing.
241+
242+
``merge_script_style`` selects the merge-script naming form: "dash" for the
243+
scaffolder default (merge-config.py) or "underscore" for the importable form
244+
(merge_config.py). Both are valid.
245+
"""
240246
module_dir = tmp / "module"
241247
module_dir.mkdir()
242248

@@ -259,8 +265,12 @@ def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
259265
if include_merge_scripts:
260266
scripts = skill / "scripts"
261267
scripts.mkdir()
262-
(scripts / "merge-config.py").write_text("# merge-config\n")
263-
(scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
268+
if merge_script_style == "underscore":
269+
(scripts / "merge_config.py").write_text("# merge_config\n")
270+
(scripts / "merge_help_csv.py").write_text("# merge_help_csv\n")
271+
else:
272+
(scripts / "merge-config.py").write_text("# merge-config\n")
273+
(scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
264274

265275
return module_dir
266276

@@ -319,6 +329,70 @@ def test_standalone_csv_validation():
319329
assert "DT" in dupes[0]["message"]
320330

321331

332+
def test_standalone_underscore_merge_scripts():
333+
"""Importable underscore-named merge scripts (merge_config.py) should pass."""
334+
with tempfile.TemporaryDirectory() as tmp:
335+
tmp = Path(tmp)
336+
module_dir = create_standalone_module(tmp, merge_script_style="underscore")
337+
338+
code, data = run_validate(module_dir)
339+
assert code == 0, f"Expected pass: {data}"
340+
assert data["status"] == "pass"
341+
assert data["info"].get("standalone") is True
342+
assert data["summary"]["total_findings"] == 0
343+
344+
345+
def test_standalone_cross_module_before_after_ref():
346+
"""Bare (colon-less) preceded-by/followed-by refs are cross-module positional, not flagged."""
347+
with tempfile.TemporaryDirectory() as tmp:
348+
tmp = Path(tmp)
349+
csv_rows = ('Test Module,my-skill,Do Thing,DT,Does thing,,,anytime,'
350+
'bmad-sprint-planning,bmad-retrospective,false,output_folder,artifact\n')
351+
module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
352+
353+
code, data = run_validate(module_dir)
354+
assert code == 0, f"Expected pass: {data}"
355+
refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
356+
assert refs == [], f"Cross-module bare refs should not be flagged: {refs}"
357+
358+
359+
def test_standalone_given_skill_dir_directly():
360+
"""Passing the standalone skill directory itself (not its parent) should work."""
361+
with tempfile.TemporaryDirectory() as tmp:
362+
tmp = Path(tmp)
363+
module_dir = create_standalone_module(tmp, skill_name="my-skill")
364+
skill_dir = module_dir / "my-skill"
365+
366+
code, data = run_validate(skill_dir)
367+
assert code == 0, f"Expected pass: {data}"
368+
assert data["status"] == "pass"
369+
assert data["info"].get("standalone") is True
370+
assert data["info"].get("skill_dir") == "my-skill"
371+
372+
373+
def test_standalone_skill_dir_orphan_not_masked_by_sibling():
374+
"""Validating a skill dir directly must still flag a CSV skill that only
375+
exists as an unrelated sibling directory (not part of this standalone module)."""
376+
with tempfile.TemporaryDirectory() as tmp:
377+
tmp = Path(tmp)
378+
csv_rows = (
379+
'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
380+
'Test Module,other-skill,Other,OT,Other thing,run,,anytime,,,false,output_folder,report\n'
381+
)
382+
module_dir = create_standalone_module(tmp, skill_name="my-skill", csv_rows=csv_rows)
383+
# A sibling skill dir next to the standalone skill (a different module).
384+
sibling = module_dir / "other-skill"
385+
sibling.mkdir()
386+
(sibling / "SKILL.md").write_text("---\nname: other-skill\n---\n# other-skill\n")
387+
388+
skill_dir = module_dir / "my-skill"
389+
code, data = run_validate(skill_dir)
390+
assert code == 1, f"Orphan entry should fail validation: {data}"
391+
orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
392+
assert any("other-skill" in f["message"] for f in orphans), \
393+
f"Sibling skill must not mask the orphan: {data['findings']}"
394+
395+
322396
def test_multi_skill_not_detected_as_standalone():
323397
"""A folder with two skills and no setup skill should fail (not detected as standalone)."""
324398
with tempfile.TemporaryDirectory() as tmp:
@@ -367,6 +441,10 @@ def test_nonexistent_directory():
367441
test_standalone_missing_module_setup_md,
368442
test_standalone_missing_merge_scripts,
369443
test_standalone_csv_validation,
444+
test_standalone_underscore_merge_scripts,
445+
test_standalone_cross_module_before_after_ref,
446+
test_standalone_given_skill_dir_directly,
447+
test_standalone_skill_dir_orphan_not_masked_by_sibling,
370448
test_multi_skill_not_detected_as_standalone,
371449
test_nonexistent_directory,
372450
]

skills/bmad-module-builder/scripts/validate-module.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,16 @@ def find_skill_folders(module_dir: Path, exclude_name: str = "") -> list[str]:
5151

5252

5353
def detect_standalone_module(module_dir: Path) -> Path | None:
54-
"""Detect a standalone module: single skill folder with assets/module.yaml."""
54+
"""Detect a standalone module: a single skill folder with assets/module.yaml.
55+
56+
Works whether ``module_dir`` is the parent folder that contains the skill, or
57+
the standalone skill directory itself (the path a user is most likely to hand
58+
over for a single-skill module).
59+
"""
60+
# Given the skill directory directly.
61+
if (module_dir / "SKILL.md").is_file() and (module_dir / "assets" / "module.yaml").is_file():
62+
return module_dir
63+
# Given the parent folder containing exactly one skill.
5564
skill_dirs = [
5665
d for d in module_dir.iterdir()
5766
if d.is_dir() and (d / "SKILL.md").is_file()
@@ -130,14 +139,32 @@ def finding(severity: str, category: str, message: str, detail: str = ""):
130139
"assets/module.yaml": skill_dir / "assets" / "module.yaml",
131140
"assets/module-help.csv": skill_dir / "assets" / "module-help.csv",
132141
"assets/module-setup.md": skill_dir / "assets" / "module-setup.md",
133-
"scripts/merge-config.py": skill_dir / "scripts" / "merge-config.py",
134-
"scripts/merge-help-csv.py": skill_dir / "scripts" / "merge-help-csv.py",
135142
}
143+
# Merge scripts: accept either the dash form the scaffolder emits
144+
# (merge-config.py) or the importable underscore form (merge_config.py).
145+
# Both are valid — a module may rename them to be importable from
146+
# module-setup.md without that being a structural defect.
147+
required_any = {
148+
"scripts/merge-config.py (or merge_config.py)": [
149+
skill_dir / "scripts" / "merge-config.py",
150+
skill_dir / "scripts" / "merge_config.py",
151+
],
152+
"scripts/merge-help-csv.py (or merge_help_csv.py)": [
153+
skill_dir / "scripts" / "merge-help-csv.py",
154+
skill_dir / "scripts" / "merge_help_csv.py",
155+
],
156+
}
157+
ok = True
136158
for label, path in required_files.items():
137159
if not path.is_file():
138160
finding("critical", "structure", f"Missing required file: {label}")
161+
ok = False
162+
for label, candidates in required_any.items():
163+
if not any(p.is_file() for p in candidates):
164+
finding("critical", "structure", f"Missing required file: {label}")
165+
ok = False
139166

140-
if not all(p.is_file() for p in required_files.values()):
167+
if not ok:
141168
return {"status": "fail", "findings": findings, "info": info}
142169

143170
yaml_dir = skill_dir
@@ -202,8 +229,12 @@ def finding(severity: str, category: str, message: str, detail: str = ""):
202229

203230
# 6. Collect skills from CSV and filesystem
204231
csv_skills = {row.get("skill", "") for row in rows}
205-
exclude_name = setup_dir.name if setup_dir else ""
206-
skill_folders = find_skill_folders(module_dir, exclude_name)
232+
if standalone_dir:
233+
# The only valid skill is the standalone skill itself, whether we were
234+
# handed the module's parent folder or the skill directory directly.
235+
skill_folders = [standalone_dir.name]
236+
else:
237+
skill_folders = find_skill_folders(module_dir, setup_dir.name)
207238
info["skill_folders"] = skill_folders
208239
info["csv_skills"] = sorted(csv_skills)
209240

@@ -215,10 +246,15 @@ def finding(severity: str, category: str, message: str, detail: str = ""):
215246
# 8. Orphan CSV entries
216247
setup_name = setup_dir.name if setup_dir else ""
217248
for skill in csv_skills:
218-
if skill not in skill_folders and skill != setup_name:
219-
# Check if it's the setup skill itself (valid)
220-
if not (module_dir / skill / "SKILL.md").is_file():
221-
finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
249+
if skill in skill_folders or skill == setup_name:
250+
continue
251+
# For a standalone module, skill_folders already enumerates every valid
252+
# skill, so any other CSV skill is an orphan — never look at the parent
253+
# folder (which may hold unrelated sibling skills when validating a skill
254+
# dir directly). For a multi-skill module, re-check the filesystem: the
255+
# setup skill lives alongside the others and is excluded from skill_folders.
256+
if standalone_dir or not (module_dir / skill / "SKILL.md").is_file():
257+
finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
222258

223259
# 9. Unique menu codes
224260
menu_codes: dict[str, list[str]] = {}
@@ -249,7 +285,15 @@ def finding(severity: str, category: str, message: str, detail: str = ""):
249285
# Can be comma-separated
250286
for ref in value.split(","):
251287
ref = ref.strip()
252-
if ref and ref not in valid_refs:
288+
if not ref:
289+
continue
290+
# A colon-less ref is a cross-module positional reference (a bare
291+
# sibling-module skill name, e.g. "bmad-sprint-planning"). Other
292+
# installed modules aren't visible here, so it can't be resolved
293+
# and isn't a defect — only validate intra-module skill:action refs.
294+
if ":" not in ref:
295+
continue
296+
if ref not in valid_refs:
253297
finding("medium", "invalid-ref",
254298
f"'{display}' {field} references '{ref}' which is not a valid capability",
255299
"Expected format: skill-name:action-name")

0 commit comments

Comments
 (0)