#!/usr/bin/env python3 """ Relationship Validator Validates alignment between database schema, API types, component props, and page data. Ensures the entire data flow chain is consistent. Usage: python3 validate_relations.py [--relations PATH] [--design-doc PATH] [--project-dir PATH] Validates: 1. Database → API: API responses match database schema fields 2. API → Component: Component props match API response types 3. Component → Page: Pages correctly use component props 4. Reference integrity: All dependencies exist 5. Type consistency: Types align across the chain Exit codes: 0 = All validations passed 1 = Warnings found (non-blocking) 2 = Errors found (blocking) """ import os import sys import re import json from pathlib import Path from typing import Dict, List, Any, Set, Optional, Tuple from dataclasses import dataclass, field from enum import Enum try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False class Severity(Enum): ERROR = "error" WARNING = "warning" INFO = "info" @dataclass class ValidationIssue: """Represents a validation issue.""" severity: Severity category: str # 'db_api', 'api_component', 'component_page', 'reference', 'type', 'circular' source_id: str target_id: str message: str suggestion: str = "" details: Dict[str, Any] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: return { 'severity': self.severity.value, 'category': self.category, 'source': self.source_id, 'target': self.target_id, 'message': self.message, 'suggestion': self.suggestion, 'details': self.details, } class RelationshipValidator: """Validates entity relationships and type alignment.""" # Type compatibility mappings TYPE_COMPATIBILITY = { # Database types → API types 'uuid': ['string', 'uuid', 'id'], 'string': ['string', 'text'], 'text': ['string', 'text'], 'integer': ['number', 'integer', 'int'], 'float': ['number', 'float', 'decimal'], 'decimal': ['number', 'float', 'decimal'], 'boolean': ['boolean', 'bool'], 'datetime': ['string', 'datetime', 'date', 'Date'], 'json': ['object', 'any', 'Record', 'JSON'], 'enum': ['string', 'enum'], 'array': ['array', 'Array', 'list'], } def __init__(self, project_dir: Path, relations_path: Optional[Path] = None, design_doc_path: Optional[Path] = None): self.project_dir = project_dir self.relations_path = relations_path self.design_doc_path = design_doc_path self.relations_data: Dict[str, Any] = {} self.design_data: Dict[str, Any] = {} self.issues: List[ValidationIssue] = [] # Entity caches self.entities: Dict[str, Dict[str, Any]] = {} self.type_mappings: Dict[str, Dict[str, str]] = {} def load_relations(self) -> bool: """Load relations.yml file.""" if not self.relations_path: # Try default location self.relations_path = self.project_dir / ".workflow" / "relations.yml" if not self.relations_path or not self.relations_path.exists(): # Try to build relations on the fly return self._build_relations_on_fly() try: content = self.relations_path.read_text() if HAS_YAML: self.relations_data = yaml.safe_load(content) or {} else: self.relations_data = json.loads(content) # Build entity cache for entity_type in ['database', 'api', 'component', 'page']: for entity in self.relations_data.get('entities', {}).get(entity_type, []): self.entities[entity['id']] = entity self.type_mappings = self.relations_data.get('type_mappings', {}) return True except Exception as e: print(f"Error loading relations: {e}", file=sys.stderr) return False def _build_relations_on_fly(self) -> bool: """Build relations if file doesn't exist.""" try: # Import and use the builder from build_relations import RelationshipBuilder builder = RelationshipBuilder(self.project_dir, self.design_doc_path) self.relations_data = builder.build() # Build entity cache for entity_type in ['database', 'api', 'component', 'page']: for entity in self.relations_data.get('entities', {}).get(entity_type, []): self.entities[entity['id']] = entity self.type_mappings = self.relations_data.get('type_mappings', {}) return True except ImportError: print("Warning: Could not import build_relations module", file=sys.stderr) return False except Exception as e: print(f"Warning: Could not build relations: {e}", file=sys.stderr) return False def load_design_document(self) -> bool: """Load design document for additional validation.""" if not self.design_doc_path: # Try to find it workflow_dir = self.project_dir / ".workflow" / "versions" if workflow_dir.exists(): versions = sorted( [d for d in workflow_dir.iterdir() if d.is_dir() and d.name.startswith('v')], reverse=True ) for version_dir in versions: design_doc = version_dir / "design" / "design_document.yml" if design_doc.exists(): self.design_doc_path = design_doc break if not self.design_doc_path or not self.design_doc_path.exists(): return False try: content = self.design_doc_path.read_text() if HAS_YAML: self.design_data = yaml.safe_load(content) or {} else: # Basic fallback self.design_data = {} return True except Exception: return False def validate_reference_integrity(self): """Check that all referenced entities exist.""" for entity_id, entity in self.entities.items(): depends_on = entity.get('depends_on', []) for dep_id in depends_on: if dep_id not in self.entities: # Check if it's an external dependency if self._is_external_dependency(dep_id): continue self.issues.append(ValidationIssue( severity=Severity.ERROR, category='reference', source_id=entity_id, target_id=dep_id, message=f"Missing dependency: '{dep_id}' referenced by '{entity_id}' does not exist", suggestion=f"Add '{dep_id}' to design document or external_dependencies", )) def _is_external_dependency(self, entity_id: str) -> bool: """Check if entity is declared as external dependency.""" external = self.design_data.get('external_dependencies', {}) for category in ['models', 'api_endpoints', 'components']: items = external.get(category, []) for item in items: if isinstance(item, str) and item == entity_id: return True if isinstance(item, dict) and item.get('id') == entity_id: return True return False def validate_db_api_alignment(self): """Validate that API responses align with database schema.""" api_entities = self.relations_data.get('entities', {}).get('api', []) for api in api_entities: api_id = api.get('id', '') depends_on = api.get('depends_on', []) # Get API response types api_types = self.type_mappings.get(api_id, {}) for dep_id in depends_on: if dep_id not in self.entities: continue dep_entity = self.entities[dep_id] if dep_entity.get('type') != 'database': continue # Get database field types db_types = self.type_mappings.get(dep_id, {}) # Check if API response fields match database fields for api_field, api_type in api_types.items(): # Skip common non-DB fields if api_field in ['error', 'message', 'status', 'success']: continue # Look for matching DB field matching_db_field = None for db_field, db_type in db_types.items(): if self._fields_match(api_field, db_field): matching_db_field = db_field break if matching_db_field: db_type = db_types[matching_db_field] if not self._types_compatible(db_type, api_type): self.issues.append(ValidationIssue( severity=Severity.WARNING, category='db_api', source_id=api_id, target_id=dep_id, message=f"Type mismatch: API field '{api_field}' ({api_type}) vs DB field '{matching_db_field}' ({db_type})", suggestion=f"Ensure API response type matches database schema", details={'api_field': api_field, 'api_type': api_type, 'db_field': matching_db_field, 'db_type': db_type}, )) def _fields_match(self, field1: str, field2: str) -> bool: """Check if two field names match (case-insensitive, underscore-insensitive).""" def normalize(s: str) -> str: return s.lower().replace('_', '').replace('-', '') return normalize(field1) == normalize(field2) def _types_compatible(self, type1: str, type2: str) -> bool: """Check if two types are compatible.""" type1_lower = type1.lower() type2_lower = type2.lower() # Direct match if type1_lower == type2_lower: return True # Check compatibility mapping for base_type, compatible_types in self.TYPE_COMPATIBILITY.items(): if type1_lower == base_type or type1_lower in compatible_types: if type2_lower == base_type or type2_lower in compatible_types: return True # Handle TypeScript generics (Array, etc.) if 'array' in type1_lower or '[]' in type1: if 'array' in type2_lower or '[]' in type2: return True return False def validate_api_component_alignment(self): """Validate that component props align with API response types.""" component_entities = self.relations_data.get('entities', {}).get('component', []) for comp in component_entities: comp_id = comp.get('id', '') depends_on = comp.get('depends_on', []) # Get component prop types comp_types = self.type_mappings.get(comp_id, {}) for dep_id in depends_on: if dep_id not in self.entities: continue dep_entity = self.entities[dep_id] if dep_entity.get('type') != 'api': continue # Get API response types api_types = self.type_mappings.get(dep_id, {}) # Check alignment - component should be able to receive API response for comp_prop, comp_type in comp_types.items(): # Look for matching API field for api_field, api_type in api_types.items(): if self._fields_match(comp_prop, api_field): if not self._types_compatible(api_type, comp_type): self.issues.append(ValidationIssue( severity=Severity.WARNING, category='api_component', source_id=comp_id, target_id=dep_id, message=f"Type mismatch: Component prop '{comp_prop}' ({comp_type}) vs API field '{api_field}' ({api_type})", suggestion=f"Update component prop type to match API response", details={'comp_prop': comp_prop, 'comp_type': comp_type, 'api_field': api_field, 'api_type': api_type}, )) def validate_component_page_alignment(self): """Validate that pages use components correctly.""" page_entities = self.relations_data.get('entities', {}).get('page', []) for page in page_entities: page_id = page.get('id', '') depends_on = page.get('depends_on', []) # Separate API and component dependencies page_apis = [] page_components = [] for dep_id in depends_on: if dep_id not in self.entities: continue dep_entity = self.entities[dep_id] if dep_entity.get('type') == 'api': page_apis.append(dep_id) elif dep_entity.get('type') == 'component': page_components.append(dep_id) # Check that components have compatible data from page's APIs for comp_id in page_components: comp_deps = self.entities.get(comp_id, {}).get('depends_on', []) comp_types = self.type_mappings.get(comp_id, {}) # If component needs API data, page should provide it for comp_dep in comp_deps: if comp_dep in self.entities and self.entities[comp_dep].get('type') == 'api': if comp_dep not in page_apis: self.issues.append(ValidationIssue( severity=Severity.WARNING, category='component_page', source_id=page_id, target_id=comp_id, message=f"Page '{page_id}' uses component '{comp_id}' which needs API '{comp_dep}', but page doesn't fetch it", suggestion=f"Add '{comp_dep}' to page's data_needs", )) def validate_circular_dependencies(self): """Check for circular dependencies.""" cycles = self.relations_data.get('issues', {}).get('circular_dependencies', []) for cycle in cycles: self.issues.append(ValidationIssue( severity=Severity.ERROR, category='circular', source_id=cycle[0] if cycle else '', target_id=cycle[-1] if cycle else '', message=f"Circular dependency detected: {' → '.join(cycle)}", suggestion="Break the circular dependency by restructuring the entities", details={'cycle': cycle}, )) def validate_layer_violations(self): """Check for layer violations (e.g., database depending on component).""" layer_order = { 'database': 1, 'api': 2, 'component': 3, 'page': 4, } for entity_id, entity in self.entities.items(): entity_type = entity.get('type', '') entity_layer = layer_order.get(entity_type, 0) for dep_id in entity.get('depends_on', []): if dep_id not in self.entities: continue dep_entity = self.entities[dep_id] dep_type = dep_entity.get('type', '') dep_layer = layer_order.get(dep_type, 0) # Lower layers shouldn't depend on higher layers if entity_layer < dep_layer: self.issues.append(ValidationIssue( severity=Severity.ERROR, category='layer', source_id=entity_id, target_id=dep_id, message=f"Layer violation: {entity_type} '{entity_id}' depends on {dep_type} '{dep_id}'", suggestion=f"{entity_type}s should not depend on {dep_type}s", )) def validate_data_flow_chain(self): """Validate complete data flow chains from DB to Page.""" page_entities = self.relations_data.get('entities', {}).get('page', []) for page in page_entities: page_id = page.get('id', '') # Get the full dependency chain chain = self.relations_data.get('dependency_chains', {}).get(page_id, []) # Check that chain includes all layers chain_types = set() for dep_id in chain: if dep_id in self.entities: chain_types.add(self.entities[dep_id].get('type')) # Page should have a complete chain if it uses APIs page_deps = page.get('depends_on', []) has_api = any( dep_id in self.entities and self.entities[dep_id].get('type') == 'api' for dep_id in page_deps ) if has_api and 'database' not in chain_types: self.issues.append(ValidationIssue( severity=Severity.INFO, category='data_flow', source_id=page_id, target_id='', message=f"Page '{page_id}' calls APIs but no database model in chain", suggestion="Verify API endpoints have database dependencies defined", )) def validate(self) -> List[ValidationIssue]: """Run all validations.""" if not self.load_relations(): print("Error: Could not load or build relations", file=sys.stderr) sys.exit(1) self.load_design_document() # Run validations self.validate_reference_integrity() self.validate_db_api_alignment() self.validate_api_component_alignment() self.validate_component_page_alignment() self.validate_circular_dependencies() self.validate_layer_violations() self.validate_data_flow_chain() return self.issues def get_error_count(self) -> int: return len([i for i in self.issues if i.severity == Severity.ERROR]) def get_warning_count(self) -> int: return len([i for i in self.issues if i.severity == Severity.WARNING]) def print_report(self): """Print human-readable validation report.""" print("\n" + "=" * 70) print("RELATIONSHIP VALIDATION REPORT") print("=" * 70) # Group issues by category categories = {} for issue in self.issues: cat = issue.category if cat not in categories: categories[cat] = [] categories[cat].append(issue) category_labels = { 'reference': '🔗 REFERENCE INTEGRITY', 'db_api': '🗄️ DATABASE → API ALIGNMENT', 'api_component': '📦 API → COMPONENT ALIGNMENT', 'component_page': '📄 COMPONENT → PAGE ALIGNMENT', 'circular': '🔄 CIRCULAR DEPENDENCIES', 'layer': '📊 LAYER VIOLATIONS', 'data_flow': '🌊 DATA FLOW CHAINS', } for cat, label in category_labels.items(): if cat in categories: print(f"\n{label}") print("-" * 50) for issue in categories[cat]: icon = "❌" if issue.severity == Severity.ERROR else "⚠️" if issue.severity == Severity.WARNING else "ℹ️" print(f"\n {icon} {issue.source_id}") print(f" {issue.message}") if issue.suggestion: print(f" 💡 {issue.suggestion}") # Summary print("\n" + "=" * 70) print("SUMMARY") print("=" * 70) print(f" Errors: {self.get_error_count()}") print(f" Warnings: {self.get_warning_count()}") print(f" Info: {len([i for i in self.issues if i.severity == Severity.INFO])}") if self.get_error_count() > 0: print("\n❌ VALIDATION FAILED - Fix errors before proceeding") elif self.get_warning_count() > 0: print("\n⚠️ VALIDATION PASSED WITH WARNINGS - Review recommended") else: print("\n✅ VALIDATION PASSED - All relationships valid") print("") def main(): import argparse parser = argparse.ArgumentParser(description="Validate entity relationships") parser.add_argument('--relations', '-r', type=Path, help='Path to relations.yml') parser.add_argument('--design-doc', '-d', type=Path, help='Path to design document') parser.add_argument('--project-dir', '-p', type=Path, default=Path('.'), help='Project directory') parser.add_argument('--json', action='store_true', help='Output as JSON') parser.add_argument('--strict', action='store_true', help='Treat warnings as errors') args = parser.parse_args() project_dir = args.project_dir.resolve() validator = RelationshipValidator( project_dir, args.relations, args.design_doc ) issues = validator.validate() if args.json: output = { 'issues': [i.to_dict() for i in issues], 'summary': { 'errors': validator.get_error_count(), 'warnings': validator.get_warning_count(), }, 'passed': validator.get_error_count() == 0 and (not args.strict or validator.get_warning_count() == 0), } print(json.dumps(output, indent=2)) else: validator.print_report() # Exit codes if validator.get_error_count() > 0: sys.exit(2) elif args.strict and validator.get_warning_count() > 0: sys.exit(1) elif validator.get_warning_count() > 0: sys.exit(1) else: sys.exit(0) if __name__ == '__main__': main()