#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [ # "python-dotenv", # ] # /// """ BMAD Document Validator Utility Validates BMAD documents match required structure and completeness. Commands: brief Validate product brief prd Validate PRD architecture Validate architecture epic Validate epic all Validate all documents in backlog Examples: uv run bmad_validator.py prd bmad-backlog/prd/prd.md uv run bmad_validator.py all bmad-backlog/ """ import json import sys import re from pathlib import Path from typing import Dict, List def validate_brief(file_path: str) -> Dict: """ Validate Product Brief has all required sections. Args: file_path: Path to product-brief.md Returns: Validation results dict """ try: with open(file_path, 'r') as f: content = f.read() except Exception as e: return { "valid": False, "errors": [f"Cannot read file: {e}"], "warnings": [], "missing_sections": [] } required_sections = [ "Executive Summary", "Problem Statement", "Proposed Solution", "Target Users", "Goals & Success Metrics", "MVP Scope", "Post-MVP Vision", "Technical Considerations", "Constraints & Assumptions", "Risks & Open Questions", "Next Steps" ] results = { "valid": True, "errors": [], "warnings": [], "missing_sections": [] } # Check for required sections for section in required_sections: if section not in content: results["valid"] = False results["missing_sections"].append(section) # Check for header if not re.search(r'#\s+Product Brief:', content): results["errors"].append("Missing main header: # Product Brief: {Name}") # Check for version info if "**Version:**" not in content: results["warnings"].append("Missing version field") if "**Date:**" not in content: results["warnings"].append("Missing date field") return results def validate_prd(file_path: str) -> Dict: """ Validate PRD has all required sections. Args: file_path: Path to prd.md Returns: Validation results dict """ try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: return { "valid": False, "errors": [f"Cannot read file: {e}"], "warnings": [], "missing_sections": [] } required_sections = [ "Executive Summary", "Product Overview", "Success Metrics", "Feature Requirements", "User Stories", "Technical Requirements", "Data Requirements", "AI/ML Requirements", "Design Requirements", "Go-to-Market Strategy", "Risks & Mitigation", "Open Questions", "Appendix" ] results = { "valid": True, "errors": [], "warnings": [], "missing_sections": [] } # Check for required sections for section in required_sections: if section not in content: results["valid"] = False results["missing_sections"].append(section) # Check for header if not re.search(r'#\s+Product Requirements Document', content): results["errors"].append("Missing main header") # Check for metadata if "**Document Version:**" not in content: results["warnings"].append("Missing document version") if "**Last Updated:**" not in content: results["warnings"].append("Missing last updated date") # Check for user stories format if "User Stories" in content: # Should have "As a" pattern if "As a" not in content: results["warnings"].append("User stories missing 'As a... I want... so that' format") # Check for acceptance criteria if "Feature Requirements" in content or "User Stories" in content: if "Acceptance Criteria:" not in content and "- [ ]" not in content: results["warnings"].append("Missing acceptance criteria checkboxes") return results def validate_architecture(file_path: str) -> Dict: """ Validate Architecture document completeness. Args: file_path: Path to architecture.md Returns: Validation results dict """ try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: return { "valid": False, "errors": [f"Cannot read file: {e}"], "warnings": [], "missing_sections": [] } required_sections = [ "System Overview", "Architecture Principles", "High-Level Architecture", "Component Details", "Data Architecture", "Infrastructure", "Security Architecture", "Deployment Strategy", "Monitoring & Observability", "Appendix" ] results = { "valid": True, "errors": [], "warnings": [], "missing_sections": [] } # Check for required sections for section in required_sections: if section not in content: results["valid"] = False results["missing_sections"].append(section) # Check for code examples if "```sql" not in content and "```python" not in content and "```typescript" not in content: results["warnings"].append("Missing code examples (SQL, Python, or TypeScript)") # Check for cost estimates if "Cost" not in content: results["warnings"].append("Missing cost estimates") # Check for technology decisions if "Technology Decisions" not in content: results["warnings"].append("Missing technology decisions table") return results def validate_epic(file_path: str) -> Dict: """ Validate Epic file structure. Args: file_path: Path to EPIC-*.md Returns: Validation results dict """ try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: return { "valid": False, "errors": [f"Cannot read file: {e}"], "warnings": [], "missing_sections": [] } required_fields = [ "**Epic Owner:**", "**Priority:**", "**Status:**", "**Estimated Effort:**" ] required_sections = [ "Epic Description", "Business Value", "Success Criteria", "User Stories", "Dependencies", "Definition of Done" ] results = { "valid": True, "errors": [], "warnings": [], "missing_sections": [], "missing_fields": [] } # Check for required fields for field in required_fields: if field not in content: results["valid"] = False results["missing_fields"].append(field) # Check for required sections for section in required_sections: if section not in content: results["valid"] = False results["missing_sections"].append(section) # Check for story format story_matches = re.findall(r'### STORY-(\d+)-(\d+):', content) if not story_matches: results["errors"].append("No stories found (expecting STORY-XXX-YY format)") # Check stories have acceptance criteria if story_matches: has_criteria = "Acceptance Criteria:" in content or "**Acceptance Criteria:**" in content if not has_criteria: results["warnings"].append("Stories missing acceptance criteria") # Check for "As a... I want... so that" format has_user_story_format = "As a" in content and "I want" in content and "so that" in content if not has_user_story_format: results["warnings"].append("Stories missing user story format (As a... I want... so that...)") return results def validate_all(bmad_dir: str) -> Dict: """ Validate all documents in BMAD backlog. Args: bmad_dir: Path to bmad-backlog directory Returns: Combined validation results """ bmad_path = Path(bmad_dir) results = { "brief": None, "prd": None, "architecture": None, "epics": [], "overall_valid": True } # Validate brief (optional) brief_path = bmad_path / "product-brief.md" if brief_path.exists(): results["brief"] = validate_brief(str(brief_path)) if not results["brief"]["valid"]: results["overall_valid"] = False # Validate PRD (required) prd_path = bmad_path / "prd" / "prd.md" if prd_path.exists(): results["prd"] = validate_prd(str(prd_path)) if not results["prd"]["valid"]: results["overall_valid"] = False else: results["overall_valid"] = False results["prd"] = {"valid": False, "errors": ["PRD not found"]} # Validate architecture (required) arch_path = bmad_path / "architecture" / "architecture.md" if arch_path.exists(): results["architecture"] = validate_architecture(str(arch_path)) if not results["architecture"]["valid"]: results["overall_valid"] = False else: results["overall_valid"] = False results["architecture"] = {"valid": False, "errors": ["Architecture not found"]} # Validate epics (required) epics_dir = bmad_path / "epics" if epics_dir.exists(): epic_files = sorted(epics_dir.glob("EPIC-*.md")) for epic_file in epic_files: epic_result = validate_epic(str(epic_file)) epic_result["file"] = epic_file.name results["epics"].append(epic_result) if not epic_result["valid"]: results["overall_valid"] = False else: results["overall_valid"] = False return results def print_validation_results(results: Dict, document_type: str): """Print validation results in readable format.""" print(f"\n{'='*60}") print(f"Validation Results: {document_type}") print(f"{'='*60}\n") if results["valid"]: print("✅ VALID - All required sections present") else: print("❌ INVALID - Missing required content") if results.get("missing_sections"): print("\n❌ Missing Required Sections:") for section in results["missing_sections"]: print(f" - {section}") if results.get("missing_fields"): print("\n❌ Missing Required Fields:") for field in results["missing_fields"]: print(f" - {field}") if results.get("errors"): print("\n❌ Errors:") for error in results["errors"]: print(f" - {error}") if results.get("warnings"): print("\n⚠️ Warnings:") for warning in results["warnings"]: print(f" - {warning}") print() def main(): """CLI interface for validation.""" if len(sys.argv) < 3: print("Usage: bmad_validator.py ", file=sys.stderr) print("\nCommands:", file=sys.stderr) print(" brief ", file=sys.stderr) print(" prd ", file=sys.stderr) print(" architecture ", file=sys.stderr) print(" epic ", file=sys.stderr) print(" all ", file=sys.stderr) sys.exit(1) command = sys.argv[1] path = sys.argv[2] try: if command == "brief": results = validate_brief(path) print_validation_results(results, "Product Brief") sys.exit(0 if results["valid"] else 1) elif command == "prd": results = validate_prd(path) print_validation_results(results, "PRD") sys.exit(0 if results["valid"] else 1) elif command == "architecture": results = validate_architecture(path) print_validation_results(results, "Architecture") sys.exit(0 if results["valid"] else 1) elif command == "epic": results = validate_epic(path) print_validation_results(results, f"Epic ({Path(path).name})") sys.exit(0 if results["valid"] else 1) elif command == "all": results = validate_all(path) print(f"\n{'='*60}") print(f"Complete Backlog Validation: {path}") print(f"{'='*60}\n") if results["overall_valid"]: print("✅ ALL DOCUMENTS VALID\n") else: print("❌ VALIDATION FAILED\n") # Print individual results if results["brief"]: print("Product Brief:", "✅ Valid" if results["brief"]["valid"] else "❌ Invalid") else: print("Product Brief: (not found - optional)") if results["prd"]: print("PRD:", "✅ Valid" if results["prd"]["valid"] else "❌ Invalid") else: print("PRD: ❌ Not found (required)") if results["architecture"]: print("Architecture:", "✅ Valid" if results["architecture"]["valid"] else "❌ Invalid") else: print("Architecture: ❌ Not found (required)") print(f"Epics: {len(results['epics'])} found") for epic in results["epics"]: status = "✅" if epic["valid"] else "❌" print(f" {status} {epic['file']}") print(f"\n{'='*60}\n") # Print details if invalid if not results["overall_valid"]: if results["prd"] and not results["prd"]["valid"]: print_validation_results(results["prd"], "PRD") if results["architecture"] and not results["architecture"]["valid"]: print_validation_results(results["architecture"], "Architecture") for epic in results["epics"]: if not epic["valid"]: print_validation_results(epic, f"Epic {epic['file']}") sys.exit(0 if results["overall_valid"] else 1) else: print(f"Error: Unknown command: {command}", file=sys.stderr) sys.exit(1) except Exception as e: print(f"Error: {e!s}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()