#!/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/validate_architecture.py # Check all files in current directory python scripts/validate/validate_architecture.py -d app/api/ # Check specific directory python scripts/validate/validate_architecture.py -f app/api/v1/stores.py # Check single file python scripts/validate/validate_architecture.py -o merchant # Check all merchant-related files python scripts/validate/validate_architecture.py -o store --verbose # Check store files with details python scripts/validate/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., merchant, store, 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 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" if self.warnings > 0: return "PASSED*" return "PASSED" @property def status_icon(self) -> str: if self.errors > 0: return "❌" if 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""" CORE_MODULES = {"contracts", "core", "tenancy", "cms", "customers", "billing", "payments", "messaging"} OPTIONAL_MODULES = {"analytics", "cart", "catalog", "checkout", "inventory", "loyalty", "marketplace", "orders"} 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. Supports two modes: 1. Split directory mode: .architecture-rules/ directory with multiple YAML files 2. Single file mode: .architecture-rules.yaml (legacy) The split directory mode takes precedence if it exists. """ # Check for split directory mode first rules_dir = self.config_path.parent / ".architecture-rules" if rules_dir.is_dir(): return self._load_config_from_directory(rules_dir) # Fall back to single file mode if not self.config_path.exists(): print(f"❌ Configuration file not found: {self.config_path}") print(f" (Also checked for directory: {rules_dir})") 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 _load_config_from_directory(self, rules_dir: Path) -> dict[str, Any]: """ Load and merge configuration from split YAML files in a directory. Reads _main.yaml first for base config, then merges all other YAML files. """ config: dict[str, Any] = {} # Load _main.yaml first (contains project info, principles, ignore patterns) main_file = rules_dir / "_main.yaml" if main_file.exists(): with open(main_file) as f: config = yaml.safe_load(f) or {} # Load all other YAML files and merge their contents yaml_files = sorted(rules_dir.glob("*.yaml")) for yaml_file in yaml_files: if yaml_file.name == "_main.yaml": continue # Already loaded with open(yaml_file) as f: file_config = yaml.safe_load(f) or {} # Merge rule sections from this file into main config for key, value in file_config.items(): if key.endswith("_rules") and isinstance(value, list): # Merge rule lists if key not in config: config[key] = [] config[key].extend(value) elif key not in config: # Add new top-level keys config[key] = value print(f"📋 Loaded architecture rules: {config.get('project', 'unknown')}") print(f" (from {len(yaml_files)} files in {rules_dir.name}/)") 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 naming conventions self._validate_naming_conventions(target) # Validate auth patterns self._validate_auth_patterns(target) # Validate middleware self._validate_middleware(target) # Validate JavaScript self._validate_javascript(target) # Validate templates self._validate_templates(target) # Validate language/i18n rules self._validate_language_rules(target) # Validate module structure self._validate_modules(target) # Validate service test coverage self._validate_service_test_coverage(target) # Validate cross-module imports (core cannot import from optional) self._validate_cross_module_imports(target) # Validate circular module dependencies self._validate_circular_dependencies(target) # Validate unused exception classes self._validate_unused_exceptions(target) # Validate legacy locations (must be in modules) self._validate_legacy_locations(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., merchant, store, 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"): # merchants -> merchant variants.add(name[:-3] + "y") elif name.endswith("s"): # stores -> store variants.add(name[:-1]) else: # merchant -> merchants, store -> stores 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", ) # Alembic migrations elif "/alembic/versions/" in file_path_str or "\\alembic\\versions\\" in file_path_str: print("🔄 Validating as Alembic migration...") self._check_migration_batch_mode(file_path, content, lines) self._check_migration_constraint_names(file_path, content, lines) # 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", ) # JS-003: Check Alpine components spread ...data() for base layout inheritance # Only check files that define Alpine component functions (function adminXxx() { return {...} }) self._check_alpine_data_spread(file_path, content, lines) # JS-004: Check Alpine components set currentPage self._check_alpine_current_page(file_path, content, lines) # JS-005: Check initialization guard self._check_init_guard(file_path, content, lines) # JS-006: Check async error handling self._check_async_error_handling(file_path, content, lines) # JS-007: Check loading state management self._check_loading_state(file_path, content, lines) # JS-008: Check for raw fetch() calls instead of apiClient self._check_fetch_vs_api_client(file_path, content, lines) # JS-009: Check for alert() or window.showToast instead of Utils.showToast() self._check_toast_usage(file_path, content, lines) # JS-015: Check for native confirm() instead of confirm_modal macros self._check_confirm_usage(file_path, content, lines) def _check_toast_usage(self, file_path: Path, content: str, lines: list[str]): """JS-009: Check for alert() or window.showToast instead of Utils.showToast()""" # Skip utils.js (where showToast is defined) if "utils.js" in file_path.name: return # Check for file-level noqa comment if "noqa: js-009" in content.lower(): return for i, line in enumerate(lines, 1): stripped = line.strip() # Skip comments if stripped.startswith(("//", "/*")): continue # Skip lines with inline noqa comment if "noqa: js-009" in line.lower(): continue # Check for alert() usage if re.search(r"\balert\s*\(", line): self._add_violation( rule_id="JS-009", rule_name="Use Utils.showToast() for notifications", severity=Severity.ERROR, file_path=file_path, line_number=i, message="Browser alert() dialog - use Utils.showToast() for consistent UX", context=stripped[:80], suggestion="Replace alert('message') with Utils.showToast('message', 'success'|'error'|'warning'|'info')", ) # Check for window.showToast usage if "window.showToast" in line: self._add_violation( rule_id="JS-009", rule_name="Use Utils.showToast() for notifications", severity=Severity.ERROR, file_path=file_path, line_number=i, message="window.showToast is undefined - use Utils.showToast()", context=stripped[:80], suggestion="Replace window.showToast('msg', 'type') with Utils.showToast('msg', 'type')", ) def _check_confirm_usage(self, file_path: Path, content: str, lines: list[str]): """JS-015: Check for native confirm() instead of confirm_modal macros.""" # Skip utility files if file_path.name in ("utils.js",): return # Skip vendor libraries if "vendor/" in str(file_path): return # Check for file-level noqa comment if "noqa: js-015" in content.lower(): return for i, line in enumerate(lines, 1): stripped = line.strip() # Skip comments if stripped.startswith(("//", "/*")): continue # Skip lines with inline noqa comment if "noqa: js-015" in line.lower(): continue # Check for confirm() usage if re.search(r"\bconfirm\s*\(", line): self._add_violation( rule_id="JS-015", rule_name="Use confirm_modal macros, not native confirm()", severity=Severity.ERROR, file_path=file_path, line_number=i, message="Native browser confirm() dialog - use confirm_modal/confirm_modal_dynamic macro", context=stripped[:80], suggestion="Add a state variable (e.g. showDeleteModal: false), set it in @click, and use confirm_modal macro in the template", ) def _check_alpine_data_spread( self, file_path: Path, content: str, lines: list[str] ): """JS-003: Check that Alpine components inherit base layout data using ...data()""" # Skip utility/init files that aren't page components skip_patterns = ["init-", "api-client", "log-config", "utils", "helpers"] if any(pattern in file_path.name for pattern in skip_patterns): return # Check for noqa comment if "noqa: js-003" in content.lower(): return # Look for Alpine component function pattern: function adminXxx(...) { return { ... } } # These are page-level components that should inherit from data() # Allow optional parameters in the function signature component_pattern = re.compile( r"function\s+(admin\w+|store\w+|shop\w+|platform\w+)\s*\([^)]*\)\s*\{", re.IGNORECASE ) for match in component_pattern.finditer(content): func_name = match.group(1) func_start = match.start() line_num = content[:func_start].count("\n") + 1 # Find the return statement with object literal # Look for "return {" within reasonable distance search_region = content[func_start : func_start + 500] if "return {" in search_region: # Check if ...data() is present in the return object # Look for pattern like "return {\n ...data()," or similar return_match = re.search( r"return\s*\{([^}]{0,200})", search_region, re.DOTALL ) if return_match: return_content = return_match.group(1) if "...data()" not in return_content: self._add_violation( rule_id="JS-003", rule_name="Alpine components must spread ...data()", severity=Severity.ERROR, file_path=file_path, line_number=line_num, message=f"Alpine component '{func_name}' does not inherit base layout data", context=f"function {func_name}() {{ return {{ ... }}", suggestion="Add '...data(),' as first property in return object to inherit dark mode, sidebar state, etc.", ) def _check_fetch_vs_api_client( self, file_path: Path, content: str, lines: list[str] ): """JS-008: Check for raw fetch() calls that should use apiClient instead""" # Skip init files and api-client itself if "init-" in file_path.name or "api-client" in file_path.name: return # Check for noqa comment if "noqa: js-008" in content.lower(): return for i, line in enumerate(lines, 1): # Look for fetch( calls that hit API endpoints if re.search(r"\bfetch\s*\(", line): # Skip if it's a comment stripped = line.strip() if stripped.startswith(("//", "/*")): continue # Check if it's calling an API endpoint (contains /api/) if "/api/" in line or "apiClient" in line: # If using fetch with /api/, should use apiClient instead if "/api/" in line and "apiClient" not in line: self._add_violation( rule_id="JS-008", rule_name="Use apiClient for API calls", severity=Severity.ERROR, file_path=file_path, line_number=i, message="Raw fetch() call to API endpoint - use apiClient for automatic auth", context=stripped[:80], suggestion="Replace fetch('/api/...') with apiClient.get('/endpoint') or apiClient.post('/endpoint', data)", ) def _check_alpine_current_page( self, file_path: Path, content: str, lines: list[str] ): """JS-004: Check that Alpine components set currentPage identifier""" # Skip utility/init files skip_patterns = ["init-", "api-client", "log-config", "utils", "helpers"] if any(pattern in file_path.name for pattern in skip_patterns): return if "noqa: js-004" in content.lower(): return # Look for Alpine component function pattern component_pattern = re.compile( r"function\s+(admin\w+|store\w+|shop\w+)\s*\(\s*\)\s*\{", re.IGNORECASE ) for match in component_pattern.finditer(content): func_name = match.group(1) func_start = match.start() line_num = content[:func_start].count("\n") + 1 # Check if currentPage is set in the return object # Use larger region to handle functions with setup code before return search_region = content[func_start : func_start + 2000] if "return {" in search_region: return_match = re.search( r"return\s*\{([^}]{0,500})", search_region, re.DOTALL ) if return_match: return_content = return_match.group(1) if ( "currentPage:" not in return_content and "currentPage :" not in return_content ): self._add_violation( rule_id="JS-004", rule_name="Alpine components must set currentPage", severity=Severity.WARNING, file_path=file_path, line_number=line_num, message=f"Alpine component '{func_name}' does not set currentPage identifier", context=f"function {func_name}()", suggestion="Add 'currentPage: \"page-name\",' in return object for sidebar highlighting", ) def _check_init_guard(self, file_path: Path, content: str, lines: list[str]): """JS-005: Check that init methods have duplicate initialization guards""" # Skip utility/init files skip_patterns = ["init-", "api-client", "log-config", "utils", "helpers"] if any(pattern in file_path.name for pattern in skip_patterns): return if "noqa: js-005" in content.lower(): return # Look for init() methods in Alpine components init_pattern = re.compile(r"async\s+init\s*\(\s*\)\s*\{|init\s*\(\s*\)\s*\{") for match in init_pattern.finditer(content): init_start = match.start() line_num = content[:init_start].count("\n") + 1 # Check next 200 chars for initialization guard pattern search_region = content[init_start : init_start + 300] guard_patterns = [ "window._", "if (this._initialized)", "if (window.", "_initialized", ] has_guard = any(pattern in search_region for pattern in guard_patterns) if not has_guard: self._add_violation( rule_id="JS-005", rule_name="Initialization methods must include guard", severity=Severity.WARNING, file_path=file_path, line_number=line_num, message="init() method lacks duplicate initialization guard", context="init() { ... }", suggestion="Add guard: if (window._pageInitialized) return; window._pageInitialized = true;", ) return # Only report once per file def _check_async_error_handling( self, file_path: Path, content: str, lines: list[str] ): """JS-006: Check that async operations have try/catch error handling""" # Skip utility/init files skip_patterns = ["init-", "api-client", "log-config"] if any(pattern in file_path.name for pattern in skip_patterns): return if "noqa: js-006" in content.lower(): return # Look for async functions/methods async_pattern = re.compile(r"async\s+\w+\s*\([^)]*\)\s*\{") for match in async_pattern.finditer(content): func_start = match.start() line_num = content[:func_start].count("\n") + 1 # Find the function body (look for matching braces) # Simplified: check next 500 chars for try/catch search_region = content[func_start : func_start + 800] # Check if there's await without try/catch if ( "await " in search_region and "try {" not in search_region and "try{" not in search_region ): # Check if it's a simple one-liner or has error handling elsewhere if ".catch(" not in search_region: self._add_violation( rule_id="JS-006", rule_name="Async operations must have error handling", severity=Severity.WARNING, file_path=file_path, line_number=line_num, message="Async function with await lacks try/catch error handling", context=match.group(0)[:50], suggestion="Wrap await calls in try/catch with proper error logging", ) return # Only report once per file def _check_loading_state(self, file_path: Path, content: str, lines: list[str]): """JS-007: Check that async operations manage loading state""" # Skip utility/init files skip_patterns = ["init-", "api-client", "log-config", "utils"] if any(pattern in file_path.name for pattern in skip_patterns): return if "noqa: js-007" in content.lower(): return # Look for Alpine component functions that have async methods with API calls component_pattern = re.compile( r"function\s+(admin\w+|store\w+|shop\w+)\s*\(\s*\)\s*\{", re.IGNORECASE ) for match in component_pattern.finditer(content): func_start = match.start() # Get the component body (rough extraction) component_region = content[func_start : func_start + 5000] # Check if component has API calls but no loading state has_api_calls = ( "apiClient." in component_region or "await " in component_region ) has_loading_state = ( "loading:" in component_region or "loading :" in component_region ) if has_api_calls and not has_loading_state: line_num = content[:func_start].count("\n") + 1 self._add_violation( rule_id="JS-007", rule_name="Set loading state for async operations", severity=Severity.INFO, file_path=file_path, line_number=line_num, message="Component has API calls but no loading state property", context=match.group(1), suggestion="Add 'loading: false,' to component state and set it before/after API calls", ) return 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 # Determine template type is_admin = "/admin/" in file_path_str or "\\admin\\" in file_path_str is_store = "/store/" in file_path_str or "\\store\\" in file_path_str is_shop = "/shop/" in file_path_str or "\\shop\\" in file_path_str 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) # FE-008: Check for raw number inputs (should use number_stepper) if not is_components_page and not is_macro: self._check_number_stepper_macro_usage(file_path, content, lines) # TPL-004: Check x-text usage for dynamic content self._check_xtext_usage(file_path, content, lines) # TPL-005: Check x-html safety self._check_xhtml_safety(file_path, content, lines) # TPL-006: Check loading state implementation self._check_template_loading_state(file_path, content, lines) # TPL-007: Check empty state implementation self._check_template_empty_state(file_path, content, lines) # TPL-008: Check for call table_header() pattern (should be table_header_custom) self._check_table_header_call_pattern(file_path, content, lines) # TPL-009: Check for invalid block names (admin and store use same blocks) if is_admin or is_store: self._check_valid_block_names(file_path, content, lines) if is_base_or_partial: return # Check for standalone marker in template (first 5 lines) first_lines = "\n".join(lines[:5]).lower() if "standalone" in first_lines or "noqa: tpl-00" in first_lines: print("⏭️ Template marked as standalone, skipping extends check") return # TPL-001: Admin templates extends check if is_admin: self._check_template_extends( file_path, lines, "admin/base.html", "TPL-001", ["login.html", "errors/", "test-"], ) # TPL-002: Store templates extends check if is_store: self._check_template_extends( file_path, lines, "store/base.html", "TPL-002", ["login.html", "errors/", "test-"], ) # TPL-003: Shop templates extends check if is_shop: self._check_template_extends( file_path, lines, "shop/base.html", "TPL-003", ["errors/", "test-"] ) def _check_template_extends( self, file_path: Path, lines: list[str], base_template: str, rule_id: str, exclusions: list[str], ): """Check that template extends the appropriate base template""" file_path_str = str(file_path) # Check exclusion patterns for exclusion in exclusions: if exclusion in file_path_str: print(f"⏭️ Template matches exclusion pattern '{exclusion}', skipping") return # Check for extends has_extends = any( "{% extends" in line and base_template in line for line in lines ) if not has_extends: template_type = base_template.split("/")[0] self._add_violation( rule_id=rule_id, rule_name=f"{template_type.title()} templates must extend base", severity=Severity.ERROR, file_path=file_path, line_number=1, message=f"{template_type.title()} template does not extend {base_template}", context=file_path.name, suggestion=f"Add {{% extends '{base_template}' %}} at the top, or add {{# standalone #}} if intentional", ) def _check_xtext_usage(self, file_path: Path, content: str, lines: list[str]): """TPL-004: Check that x-text is used for dynamic text content""" if "noqa: tpl-004" in content.lower(): return # Look for potentially unsafe patterns where user data might be interpolated # This is a heuristic check - not perfect unsafe_patterns = [ (r"\{\{\s*\w+\.name\s*\}\}", "User data interpolation without x-text"), (r"\{\{\s*\w+\.title\s*\}\}", "User data interpolation without x-text"), ( r"\{\{\s*\w+\.description\s*\}\}", "User data interpolation without x-text", ), ] for i, line in enumerate(lines, 1): # Skip Jinja2 template syntax (server-side safe) if "{%" in line: continue for pattern, message in unsafe_patterns: if re.search(pattern, line): # Check if it's in an x-text context already if 'x-text="' not in line and "x-text='" not in line: self._add_violation( rule_id="TPL-004", rule_name="Use x-text for dynamic text content", severity=Severity.INFO, file_path=file_path, line_number=i, message=message, context=line.strip()[:60], suggestion='Use x-text="item.name" instead of {{ item.name }} for XSS safety', ) return # Only report once per file def _check_xhtml_safety(self, file_path: Path, content: str, lines: list[str]): """TPL-005: Check that x-html is only used for safe content""" if "noqa: tpl-005" in content.lower(): return safe_xhtml_patterns = [ r'x-html="\$icon\(', # Icon helper is safe r"x-html='\$icon\(", r'x-html="`\$\{.*icon', # Template literal with icon ] for i, line in enumerate(lines, 1): if "x-html=" in line: # Check if it matches safe patterns is_safe = any( re.search(pattern, line) for pattern in safe_xhtml_patterns ) if not is_safe: # Check for potentially unsafe user content unsafe_indicators = [ ".description", ".content", ".body", ".text", ".message", ".comment", ".review", ".html", ] for indicator in unsafe_indicators: if indicator in line: self._add_violation( rule_id="TPL-005", rule_name="Use x-html ONLY for safe content", severity=Severity.WARNING, file_path=file_path, line_number=i, message="x-html used with potentially unsafe user content", context=line.strip()[:60], suggestion="Sanitize HTML content or use x-text for plain text", ) return def _check_template_loading_state( self, file_path: Path, content: str, lines: list[str] ): """TPL-006: Check that templates with data loads show loading state""" if "noqa: tpl-006" in content.lower(): return # Check if template has data loading (Alpine init, fetch, apiClient) has_data_loading = any( [ "x-init=" in content, "@load=" in content, "apiClient" in content, "loadData" in content, "fetchData" in content, ] ) if not has_data_loading: return # Check for loading state display has_loading_state = any( [ 'x-show="loading"' in content, "x-show='loading'" in content, 'x-if="loading"' in content, "x-if='loading'" in content, "loading_state" in content, "Loading..." in content, "spinner" in content.lower(), ] ) if not has_loading_state: self._add_violation( rule_id="TPL-006", rule_name="Implement loading state for data loads", severity=Severity.INFO, file_path=file_path, line_number=1, message="Template loads data but has no visible loading state", context=file_path.name, suggestion='Add