577 lines
22 KiB
Python
577 lines
22 KiB
Python
#!/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<T>, 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()
|