#!/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""" 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 cross-module imports (core cannot import from optional) self._validate_cross_module_imports(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) 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_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
Loading...
or use loading_state macro', ) def _check_template_empty_state( self, file_path: Path, content: str, lines: list[str] ): """TPL-007: Check that templates with lists show empty state""" if "noqa: tpl-007" in content.lower(): return # Check if template has list iteration has_list = any( [ "x-for=" in content, "{% for " in content, ] ) if not has_list: return # Check for empty state handling has_empty_state = any( [ ".length === 0" in content, ".length == 0" in content, "items.length" in content, "empty_state" in content, "No items" in content, "No results" in content, "No data" in content, "table_empty_state" in content, ] ) if not has_empty_state: self._add_violation( rule_id="TPL-007", rule_name="Implement empty state when no data", severity=Severity.INFO, file_path=file_path, line_number=1, message="Template has list but no empty state handling", context=file_path.name, suggestion='Add ', ) def _check_table_header_call_pattern( self, file_path: Path, content: str, lines: list[str] ): """TPL-008: Check that {% call table_header() %} is not used. The table_header() macro doesn't support caller() - it takes a columns list. Using {% call table_header() %} causes a Jinja2 error: "macro 'table_header' was invoked with two values for the special caller argument" Use table_header_custom() instead for custom headers with caller(). """ if "noqa: tpl-008" in content.lower(): return import re pattern = re.compile(r"{%\s*call\s+table_header\s*\(\s*\)\s*%}") for i, line in enumerate(lines, 1): if pattern.search(line): self._add_violation( rule_id="TPL-008", rule_name="Use table_header_custom for custom headers", severity=Severity.ERROR, file_path=file_path, line_number=i, message="{% call table_header() %} causes Jinja2 error - table_header doesn't support caller()", context=line.strip()[:80], suggestion="Use {% call table_header_custom() %} for custom headers with th_sortable or custom elements", ) def _check_valid_block_names( self, file_path: Path, content: str, lines: list[str] ): """TPL-009: Check that templates use valid block names from base template""" if "noqa: tpl-009" in content.lower(): return # Skip base templates if file_path.name == "base.html": return # Valid admin template blocks # Common invalid block names that developers might mistakenly use invalid_blocks = { "page_scripts": "extra_scripts", "scripts": "extra_scripts", "js": "extra_scripts", "footer_scripts": "extra_scripts", "head": "extra_head", "body": "content", "main": "content", } import re block_pattern = re.compile(r"{%\s*block\s+(\w+)\s*%}") for i, line in enumerate(lines, 1): match = block_pattern.search(line) if match: block_name = match.group(1) if block_name in invalid_blocks: self._add_violation( rule_id="TPL-009", rule_name="Use valid block names from base templates", severity=Severity.ERROR, file_path=file_path, line_number=i, message=f"Invalid block name '{block_name}' - this block doesn't exist in admin/base.html", context=line.strip(), suggestion=f"Use '{{% block {invalid_blocks[block_name]} %}}' instead", ) def _check_deprecated_macros( self, file_path: Path, content: str, lines: list[str] ): """TPL-011: Check for usage of deprecated macros""" if "noqa: tpl-011" in content.lower(): return # Deprecated macros and their replacements deprecated_macros = { "pagination_full": { "replacement": "pagination", "reason": "pagination_full expects flat variables (total, skip, page, limit) but components use nested pagination object", }, } for i, line in enumerate(lines, 1): for macro_name, info in deprecated_macros.items(): if macro_name in line and "# deprecated" not in line.lower(): # Check if it's an import or usage if f"import {macro_name}" in line or f"{{ {macro_name}" in line or f"{{{macro_name}" in line: self._add_violation( rule_id="TPL-011", rule_name="Avoid deprecated macros", severity=Severity.WARNING, file_path=file_path, line_number=i, message=f"Deprecated macro '{macro_name}' used - {info['reason']}", context=line.strip()[:80], suggestion=f"Use '{info['replacement']}' macro instead", ) def _check_escaped_quotes_in_alpine( self, file_path: Path, content: str, lines: list[str] ): """TPL-012: Check for problematic quotes in Alpine copyCode template literals. When using copyCode() with a multi-line template literal, double quotes inside the template will prematurely end the outer @click="..." attribute. Bad: @click="copyCode(`{{ func("arg") }}`)" # inner " ends the attribute Good: @click='copyCode(`{{ func("arg") }}`)' # single quotes for outer """ if "noqa: tpl-012" in content.lower(): return import re # Track multi-line copyCode template literals with double-quoted outer attribute in_copycode_template = False for i, line in enumerate(lines, 1): if "noqa: tpl-012" in line.lower(): continue # Check for start of copyCode with double-quoted attribute and template literal # Pattern: @click="copyCode(` where the backtick doesn't close on same line if '@click="copyCode(`' in line and "`)" not in line: in_copycode_template = True continue # Check for end of copyCode template (backtick followed by )" or )') if in_copycode_template: if '`)"' in line or "`)'" in line: in_copycode_template = False continue # Check for double quotes that would break the outer attribute # These appear in Jinja template code like {{ func("arg") }} if re.search(r'\(\s*"[^"]*"\s*[,)]', line): self._add_violation( rule_id="TPL-012", rule_name="Double quotes in copyCode template literal", severity=Severity.ERROR, file_path=file_path, line_number=i, message="Double quotes inside multi-line copyCode template literal will break HTML attribute parsing", context=line.strip()[:80], suggestion="Change outer attribute to single quotes: @click='copyCode(`...`)'", ) def _check_pagination_macro_api( self, file_path: Path, content: str, lines: list[str] ): """ TPL-013: Check for old pagination macro API. The pagination macro was simplified to only accept show_condition. It now relies on standardized Alpine.js component properties. OLD (deprecated): {{ pagination(current_page=..., total_pages=...) }} NEW (correct): {{ pagination(show_condition="!loading && pagination.total > 0") }} """ # Skip the macro definition file if "shared/macros/pagination.html" in str(file_path): return if "noqa: tpl-013" in content.lower(): return # Old API patterns that indicate deprecated usage old_patterns = [ (r"pagination\s*\([^)]*current_page\s*=", "current_page"), (r"pagination\s*\([^)]*total_pages\s*=", "total_pages"), (r"pagination\s*\([^)]*page_numbers\s*=", "page_numbers"), (r"pagination\s*\([^)]*start_index\s*=", "start_index"), (r"pagination\s*\([^)]*end_index\s*=", "end_index"), ] for i, line in enumerate(lines, 1): for pattern, param_name in old_patterns: if re.search(pattern, line): self._add_violation( rule_id="TPL-013", rule_name="Use new pagination macro API", severity=Severity.ERROR, file_path=file_path, line_number=i, message=f"Old pagination API with '{param_name}' parameter", context=line.strip()[:80], suggestion='Use: {{ pagination(show_condition="!loading && pagination.total > 0") }}', ) break # Only report once per line def _check_modal_simple_macro_api( self, file_path: Path, content: str, lines: list[str] ): """ TPL-014: Check for old modal_simple macro API. The modal_simple macro now uses {% call %}...{% endcall %} syntax. Content (including buttons) goes inside the call block. OLD (deprecated): {{ modal_simple(show_var=..., icon=..., confirm_fn=...) }} NEW (correct): {% call modal_simple('id', 'Title', show_var='...') %}...{% endcall %} """ # Skip the macro definition file if "shared/macros/modals.html" in str(file_path): return if "noqa: tpl-014" in content.lower(): return # Old API patterns - using {{ }} instead of {% call %} # Also checking for old parameters that don't exist anymore old_patterns = [ (r"\{\{\s*modal_simple\s*\(", "{{ modal_simple( instead of {% call modal_simple("), (r"modal_simple\s*\([^)]*icon\s*=", "icon parameter"), (r"modal_simple\s*\([^)]*icon_color\s*=", "icon_color parameter"), (r"modal_simple\s*\([^)]*confirm_text\s*=", "confirm_text parameter"), (r"modal_simple\s*\([^)]*confirm_fn\s*=", "confirm_fn parameter"), (r"modal_simple\s*\([^)]*confirm_class\s*=", "confirm_class parameter"), (r"modal_simple\s*\([^)]*loading_var\s*=", "loading_var parameter"), ] for i, line in enumerate(lines, 1): for pattern, issue in old_patterns: if re.search(pattern, line): self._add_violation( rule_id="TPL-014", rule_name="Use new modal_simple macro API with call block", severity=Severity.ERROR, file_path=file_path, line_number=i, message=f"Old modal_simple API: {issue}", context=line.strip()[:80], suggestion="Use: {{% call modal_simple('id', 'Title', show_var='...') %}}...{{% endcall %}}", ) break # Only report once per line def _check_page_header_macro_api( self, file_path: Path, content: str, lines: list[str] ): """ TPL-015: Check for old page_header macro API. The page_header macro does not accept a buttons=[] parameter. Use action_label/action_onclick for single button, or page_header_flex for multiple. OLD (deprecated): {{ page_header('Title', buttons=[...]) }} NEW (correct): {{ page_header('Title', action_label='Add', action_onclick='...') }} """ # Skip the macro definition file if "shared/macros/headers.html" in str(file_path): return if "noqa: tpl-015" in content.lower(): return for i, line in enumerate(lines, 1): if re.search(r"page_header\s*\([^)]*buttons\s*=", line): self._add_violation( rule_id="TPL-015", rule_name="Use correct page_header macro API", severity=Severity.ERROR, file_path=file_path, line_number=i, message="page_header does not accept 'buttons' parameter", context=line.strip()[:80], suggestion="Use action_label/action_onclick, or page_header_flex with {% call %}", ) def _check_alpine_template_vars( self, file_path: Path, content: str, lines: list[str], js_content: str ): """TPL-010: Check that Alpine variables used in templates are defined in JS""" if "noqa: tpl-010" in content.lower(): return import re # Common Alpine variable patterns that MUST be defined in the component # These are variables that are directly referenced in templates required_vars = set() # Check for error_state macro usage (requires 'error' var) if "error_state(" in content and "error_var=" not in content: required_vars.add("error") # Check for action_dropdown macro with custom vars dropdown_pattern = re.compile( r"action_dropdown\([^)]*open_var=['\"](\w+)['\"]" ) for match in dropdown_pattern.finditer(content): required_vars.add(match.group(1)) dropdown_pattern2 = re.compile( r"action_dropdown\([^)]*loading_var=['\"](\w+)['\"]" ) for match in dropdown_pattern2.finditer(content): required_vars.add(match.group(1)) # Check if variables are defined in JS for var in required_vars: # Check if variable is defined in JS (look for "varname:" pattern) var_pattern = re.compile(rf"\b{var}\s*:") if not var_pattern.search(js_content): # Find the line where it's used in template for i, line in enumerate(lines, 1): if var in line and ("error_state" in line or "action_dropdown" in line): self._add_violation( rule_id="TPL-010", rule_name="Alpine variables must be defined in JS component", severity=Severity.ERROR, file_path=file_path, line_number=i, message=f"Template uses Alpine variable '{var}' but it's not defined in the JS component", context=line.strip()[:80], suggestion=f"Add '{var}: null,' or '{var}: false,' to the Alpine component's return object", ) break def _check_pagination_macro_usage( self, file_path: Path, content: str, lines: list[str] ): """FE-001: Check for inline pagination that should use macro""" # 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 # Check for noqa: FE-001 comment has_noqa = any("noqa: fe-001" in line.lower() for line in lines) if has_noqa: return # 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"), ] 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(("{#", "