#!/usr/bin/env python3 """ Architecture Validator ====================== Validates code against architectural rules defined in .architecture-rules.yaml This script checks that the codebase follows key architectural decisions: - Separation of concerns (routes vs services) - Proper exception handling (domain exceptions vs HTTPException) - Correct use of Pydantic vs SQLAlchemy models - Service layer patterns - API endpoint patterns Usage: python scripts/validate_architecture.py # Check all files in current directory python scripts/validate_architecture.py -d app/api/ # Check specific directory python scripts/validate_architecture.py -f app/api/v1/vendors.py # Check single file python scripts/validate_architecture.py -o company # Check all company-related files python scripts/validate_architecture.py -o vendor --verbose # Check vendor files with details python scripts/validate_architecture.py --json # JSON output Options: -f, --file PATH Validate a single file (.py, .js, or .html) -d, --folder PATH Validate all files in a directory (recursive) -o, --object NAME Validate all files related to an entity (e.g., company, vendor, order) -c, --config PATH Path to architecture rules config -v, --verbose Show detailed output including context --errors-only Only show errors, suppress warnings --json Output results as JSON """ import argparse import ast import re import sys from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Any import yaml class Severity(Enum): """Validation severity levels""" ERROR = "error" WARNING = "warning" INFO = "info" @dataclass class Violation: """Represents an architectural rule violation""" rule_id: str rule_name: str severity: Severity file_path: Path line_number: int message: str context: str = "" suggestion: str = "" @dataclass class FileResult: """Results for a single file validation""" file_path: Path errors: int = 0 warnings: int = 0 @property def passed(self) -> bool: return self.errors == 0 @property def status(self) -> str: if self.errors > 0: return "FAILED" elif self.warnings > 0: return "PASSED*" return "PASSED" @property def status_icon(self) -> str: if self.errors > 0: return "❌" elif self.warnings > 0: return "⚠️" return "✅" @dataclass class ValidationResult: """Results of architecture validation""" violations: list[Violation] = field(default_factory=list) files_checked: int = 0 rules_applied: int = 0 file_results: list[FileResult] = field(default_factory=list) def has_errors(self) -> bool: """Check if there are any error-level violations""" return any(v.severity == Severity.ERROR for v in self.violations) def has_warnings(self) -> bool: """Check if there are any warning-level violations""" return any(v.severity == Severity.WARNING for v in self.violations) class ArchitectureValidator: """Main validator class""" def __init__(self, config_path: Path, verbose: bool = False): """Initialize validator with configuration""" self.config_path = config_path self.verbose = verbose self.config = self._load_config() self.result = ValidationResult() self.project_root = Path.cwd() def _load_config(self) -> dict[str, Any]: """Load validation rules from YAML config""" if not self.config_path.exists(): print(f"❌ Configuration file not found: {self.config_path}") sys.exit(1) with open(self.config_path) as f: config = yaml.safe_load(f) print(f"📋 Loaded architecture rules: {config.get('project', 'unknown')}") return config def validate_all(self, target_path: Path = None) -> ValidationResult: """Validate all files in a directory""" print("\n🔍 Starting architecture validation...\n") target = target_path or self.project_root # Validate API endpoints self._validate_api_endpoints(target) # Validate service layer self._validate_service_layer(target) # Validate models self._validate_models(target) # Validate exception handling self._validate_exceptions(target) # Validate JavaScript self._validate_javascript(target) # Validate templates self._validate_templates(target) return self.result def validate_file( self, file_path: Path, quiet: bool = False ) -> ValidationResult: """Validate a single file""" if not file_path.exists(): if not quiet: print(f"❌ File not found: {file_path}") return self.result if not file_path.is_file(): if not quiet: print(f"❌ Not a file: {file_path}") return self.result if not quiet: print(f"\n🔍 Validating single file: {file_path}\n") # Resolve file path to absolute file_path = file_path.resolve() file_path_str = str(file_path) if self._should_ignore_file(file_path): if not quiet: print("⏭️ File is in ignore list, skipping") return self.result self.result.files_checked += 1 # Track violations before this file violations_before = len(self.result.violations) content = file_path.read_text() lines = content.split("\n") # Determine file type and run appropriate validators if file_path.suffix == ".py": self._validate_python_file(file_path, content, lines, file_path_str) elif file_path.suffix == ".js": self._validate_js_file(file_path, content, lines) elif file_path.suffix == ".html": self._validate_html_file(file_path, content, lines) else: if not quiet: print(f"⚠️ Unsupported file type: {file_path.suffix}") # Calculate violations for this file file_violations = self.result.violations[violations_before:] errors = sum(1 for v in file_violations if v.severity == Severity.ERROR) warnings = sum(1 for v in file_violations if v.severity == Severity.WARNING) # Track file result self.result.file_results.append( FileResult(file_path=file_path, errors=errors, warnings=warnings) ) return self.result def validate_object(self, object_name: str) -> ValidationResult: """Validate all files related to an entity (e.g., company, vendor, order)""" print(f"\n🔍 Searching for '{object_name}'-related files...\n") # Generate name variants (singular/plural forms) name = object_name.lower() variants = {name} # Handle common plural patterns if name.endswith("ies"): # companies -> company variants.add(name[:-3] + "y") elif name.endswith("s"): # vendors -> vendor variants.add(name[:-1]) else: # company -> companies, vendor -> vendors if name.endswith("y"): variants.add(name[:-1] + "ies") variants.add(name + "s") # Search patterns for different file types patterns = [] for variant in variants: patterns.extend([ f"app/api/**/*{variant}*.py", f"app/services/*{variant}*.py", f"app/exceptions/*{variant}*.py", f"models/database/*{variant}*.py", f"models/schema/*{variant}*.py", f"static/admin/js/*{variant}*.js", f"app/templates/admin/*{variant}*.html", ]) # Find all matching files found_files: set[Path] = set() for pattern in patterns: matches = list(self.project_root.glob(pattern)) for match in matches: if match.is_file() and not self._should_ignore_file(match): found_files.add(match) if not found_files: print(f"❌ No files found matching '{object_name}'") return self.result # Sort files by type for better readability sorted_files = sorted(found_files, key=lambda p: (p.suffix, str(p))) print(f"📁 Found {len(sorted_files)} files:\n") for f in sorted_files: rel_path = f.relative_to(self.project_root) print(f" • {rel_path}") print("\n" + "-" * 60 + "\n") # Validate each file for file_path in sorted_files: rel_path = file_path.relative_to(self.project_root) print(f"📄 {rel_path}") self.validate_file(file_path, quiet=True) return self.result def _validate_python_file( self, file_path: Path, content: str, lines: list[str], file_path_str: str ): """Validate a single Python file based on its location""" # API endpoints if "/app/api/" in file_path_str or "\\app\\api\\" in file_path_str: print("📡 Validating as API endpoint...") self._check_pydantic_usage(file_path, content, lines) self._check_no_business_logic_in_endpoints(file_path, content, lines) self._check_endpoint_exception_handling(file_path, content, lines) self._check_endpoint_authentication(file_path, content, lines) # Service layer elif "/app/services/" in file_path_str or "\\app\\services\\" in file_path_str: print("🔧 Validating as service layer...") self._check_no_http_exception_in_services(file_path, content, lines) self._check_service_exceptions(file_path, content, lines) self._check_db_session_parameter(file_path, content, lines) self._check_no_commit_in_services(file_path, content, lines) # Models elif "/app/models/" in file_path_str or "\\app\\models\\" in file_path_str: print("📦 Validating as model...") for i, line in enumerate(lines, 1): if re.search(r"class.*\(Base.*,.*BaseModel.*\)", line): self._add_violation( rule_id="MDL-002", rule_name="Separate SQLAlchemy and Pydantic models", severity=Severity.ERROR, file_path=file_path, line_number=i, message="Model mixes SQLAlchemy Base and Pydantic BaseModel", context=line.strip(), suggestion="Keep SQLAlchemy models and Pydantic models separate", ) # Generic Python file - check exception handling print("⚠️ Validating exception handling...") for i, line in enumerate(lines, 1): if re.match(r"\s*except\s*:", line): self._add_violation( rule_id="EXC-002", rule_name="No bare except clauses", severity=Severity.ERROR, file_path=file_path, line_number=i, message="Bare except clause catches all exceptions including system exits", context=line.strip(), suggestion="Specify exception type: except ValueError: or except Exception:", ) def _validate_js_file(self, file_path: Path, content: str, lines: list[str]): """Validate a single JavaScript file""" print("🟨 Validating JavaScript...") # JS-001: Check for console usage (must use centralized logger) # Skip init-*.js files - they run before logger is available if not file_path.name.startswith("init-"): for i, line in enumerate(lines, 1): if re.search(r"console\.(log|warn|error)", line): if "//" in line or "✅" in line or "eslint-disable" in line: continue self._add_violation( rule_id="JS-001", rule_name="Use centralized logger", severity=Severity.WARNING, file_path=file_path, line_number=i, message="Use centralized logger instead of console", context=line.strip()[:80], suggestion="Use window.LogConfig.createLogger('moduleName')", ) # JS-002: Check for window.apiClient (must use lowercase apiClient) for i, line in enumerate(lines, 1): if "window.apiClient" in line: before_occurrence = line[: line.find("window.apiClient")] if "//" not in before_occurrence: self._add_violation( rule_id="JS-002", rule_name="Use lowercase apiClient", severity=Severity.WARNING, file_path=file_path, line_number=i, message="Use apiClient directly instead of window.apiClient", context=line.strip(), suggestion="Replace window.apiClient with apiClient", ) def _validate_html_file(self, file_path: Path, content: str, lines: list[str]): """Validate a single HTML template file""" print("📄 Validating template...") file_path_str = str(file_path) # Skip base template and partials for extends check is_base_or_partial = "base.html" in file_path.name or "partials" in file_path_str # Skip macros directory for FE rules is_macro = "shared/macros/" in file_path_str or "shared\\macros\\" in file_path_str # Skip components showcase page is_components_page = "components.html" in file_path.name if is_base_or_partial: print("⏭️ Skipping base/partial template") elif is_macro: print("⏭️ Skipping macro file") else: # FE-001: Check for inline pagination (should use macro) if not is_components_page: self._check_pagination_macro_usage(file_path, content, lines) # FE-002: Check for inline SVGs (should use $icon()) if not is_components_page and not is_macro: self._check_icon_helper_usage(file_path, content, lines) # Only check admin templates for extends if "/admin/" not in file_path_str and "\\admin\\" not in file_path_str: return if is_base_or_partial: return # Check for standalone marker in template (first 5 lines) # Supports: {# standalone #}, {# noqa: TPL-001 #}, first_lines = "\n".join(lines[:5]).lower() if "standalone" in first_lines or "noqa: tpl-001" in first_lines: print("⏭️ Template marked as standalone, skipping extends check") return # Check exclusion patterns for TPL-001 # These are templates that intentionally don't extend admin/base.html tpl_001_exclusions = [ "login.html", # Standalone login page "errors/", # Error pages extend errors/base.html "test-", # Test templates ] for exclusion in tpl_001_exclusions: if exclusion in file_path_str: print(f"⏭️ Template matches exclusion pattern '{exclusion}', skipping") return # TPL-001: Check for extends has_extends = any( "{% extends" in line and "admin/base.html" in line for line in lines ) if not has_extends: self._add_violation( rule_id="TPL-001", rule_name="Templates must extend base", severity=Severity.ERROR, file_path=file_path, line_number=1, message="Admin template does not extend admin/base.html", context=file_path.name, suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional", ) def _check_pagination_macro_usage(self, file_path: Path, content: str, lines: list[str]): """FE-001: Check for inline pagination that should use macro""" # Look for signs of inline pagination pagination_indicators = [ ('aria-label="Table navigation"', "Inline table navigation found"), ("previousPage()" , "Inline pagination controls found"), ("nextPage()" , "Inline pagination controls found"), ("goToPage(" , "Inline pagination controls found"), ] # Check if already using the pagination macro uses_macro = any("from 'shared/macros/pagination.html'" in line for line in lines) if uses_macro: return for i, line in enumerate(lines, 1): for pattern, message in pagination_indicators: if pattern in line: # Skip if it's in a comment stripped = line.strip() if stripped.startswith("{#") or stripped.startswith("