project-standalo-note-to-app/skills/guardrail-orchestrator/scripts/validate_relations.py

577 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()