refactor: split architecture rules into domain-specific files
Split the monolithic .architecture-rules.yaml (1700+ lines) into focused domain-specific files in .architecture-rules/ directory: - _main.yaml: Core config, principles, ignore patterns, severity levels - api.yaml: API endpoint rules (API-001 to API-005) - service.yaml: Service layer rules (SVC-001 to SVC-007) - model.yaml: Model rules (MDL-001 to MDL-004) - exception.yaml: Exception handling rules (EXC-001 to EXC-005) - naming.yaml: Naming convention rules (NAM-001 to NAM-005) - auth.yaml: Auth and multi-tenancy rules (AUTH-*, MT-*) - middleware.yaml: Middleware rules (MDW-001 to MDW-002) - frontend.yaml: Frontend rules (JS-*, TPL-*, FE-*, CSS-*) - language.yaml: Language/i18n rules (LANG-001 to LANG-010) - quality.yaml: Code quality rules (QUAL-001 to QUAL-003) Also creates scripts/validators/ module with base classes for future modular validator extraction. The validate_architecture.py loader now auto-detects and merges split YAML files while maintaining backward compatibility with single file mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -122,9 +122,24 @@ class ArchitectureValidator:
|
||||
self.project_root = Path.cwd()
|
||||
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
"""Load validation rules from YAML config"""
|
||||
"""
|
||||
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:
|
||||
@@ -133,6 +148,44 @@ class ArchitectureValidator:
|
||||
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")
|
||||
@@ -166,6 +219,9 @@ class ArchitectureValidator:
|
||||
# Validate templates
|
||||
self._validate_templates(target)
|
||||
|
||||
# Validate language/i18n rules
|
||||
self._validate_language_rules(target)
|
||||
|
||||
return self.result
|
||||
|
||||
def validate_file(
|
||||
@@ -1442,11 +1498,15 @@ class ArchitectureValidator:
|
||||
if "@router." in line and (
|
||||
"post" in line or "put" in line or "delete" in line
|
||||
):
|
||||
# Check next 15 lines for auth or public marker
|
||||
# Check previous line and next 15 lines for auth or public marker
|
||||
# (increased from 5 to handle multi-line decorators and long function signatures)
|
||||
has_auth = False
|
||||
is_public = False
|
||||
context_lines = lines[i - 1 : i + 15] # Include line before decorator
|
||||
# i is 1-indexed, lines is 0-indexed
|
||||
# So lines[i-1] is the decorator line, lines[i-2] is the line before
|
||||
start_idx = max(0, i - 2) # Line before decorator (1-2 lines up)
|
||||
end_idx = i + 15 # 15 lines after decorator
|
||||
context_lines = lines[start_idx : end_idx]
|
||||
|
||||
for ctx_line in context_lines:
|
||||
# Check for any valid auth pattern
|
||||
@@ -2442,6 +2502,363 @@ class ArchitectureValidator:
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# LANGUAGE/I18N VALIDATION
|
||||
# =========================================================================
|
||||
|
||||
def _validate_language_rules(self, target_path: Path):
|
||||
"""Validate language/i18n patterns"""
|
||||
print("\n🌐 Validating language/i18n rules...")
|
||||
|
||||
# LANG-001: Check for invalid language codes in Python files
|
||||
self._check_language_codes(target_path)
|
||||
|
||||
# LANG-002, LANG-003: Check inline Alpine.js and tojson|safe usage
|
||||
self._check_template_language_inline_patterns(target_path)
|
||||
|
||||
# LANG-004: Check language selector function exists in JS files
|
||||
self._check_language_selector_function(target_path)
|
||||
|
||||
# LANG-005, LANG-006, LANG-008: Check language names, flag codes, and API usage in JS files
|
||||
self._check_language_js_patterns(target_path)
|
||||
|
||||
# LANG-007, LANG-009: Check language patterns in templates
|
||||
self._check_language_template_patterns(target_path)
|
||||
|
||||
# LANG-010: Check translation files are valid JSON
|
||||
self._check_translation_files(target_path)
|
||||
|
||||
def _check_language_codes(self, target_path: Path):
|
||||
"""LANG-001: Check for invalid language codes in Python files"""
|
||||
py_files = list(target_path.glob("app/**/*.py"))
|
||||
py_files.extend(list(target_path.glob("models/**/*.py")))
|
||||
|
||||
invalid_codes = [
|
||||
("'english'", "'en'"),
|
||||
("'french'", "'fr'"),
|
||||
("'german'", "'de'"),
|
||||
("'luxembourgish'", "'lb'"),
|
||||
("'lux'", "'lb'"),
|
||||
('"english"', '"en"'),
|
||||
('"french"', '"fr"'),
|
||||
('"german"', '"de"'),
|
||||
('"luxembourgish"', '"lb"'),
|
||||
('"lux"', '"lb"'),
|
||||
]
|
||||
|
||||
for file_path in py_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
# Track if we're inside LANGUAGE_NAMES dicts (allowed to use language names)
|
||||
in_language_names_dict = False
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Track LANGUAGE_NAMES/LANGUAGE_NAMES_EN blocks - name values are allowed
|
||||
if ("LANGUAGE_NAMES" in line or "LANGUAGE_NAMES_EN" in line) and "=" in line:
|
||||
in_language_names_dict = True
|
||||
if in_language_names_dict and stripped == "}":
|
||||
in_language_names_dict = False
|
||||
continue
|
||||
if in_language_names_dict:
|
||||
continue
|
||||
|
||||
for wrong, correct in invalid_codes:
|
||||
if wrong in line.lower():
|
||||
self._add_violation(
|
||||
rule_id="LANG-001",
|
||||
rule_name="Only use supported language codes",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=f"Invalid language code '{wrong}' - use ISO code instead",
|
||||
context=stripped[:80],
|
||||
suggestion=f"Change to: {correct}",
|
||||
)
|
||||
except Exception:
|
||||
pass # Skip files that can't be read
|
||||
|
||||
def _check_template_language_inline_patterns(self, target_path: Path):
|
||||
"""LANG-002, LANG-003: Check inline Alpine.js and tojson|safe usage in templates"""
|
||||
template_files = list(target_path.glob("app/templates/**/*.html"))
|
||||
|
||||
for file_path in template_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
file_path_str = str(file_path)
|
||||
|
||||
# Skip partials/header.html which may use hardcoded arrays (allowed)
|
||||
if "partials/header.html" in file_path_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# LANG-002: Check for inline complex x-data with language-related properties
|
||||
if 'x-data="{' in line or "x-data='{" in line:
|
||||
# Check next few lines for language-related properties
|
||||
context_lines = "\n".join(lines[i - 1 : i + 10])
|
||||
if ("languages:" in context_lines or
|
||||
"languageNames:" in context_lines or
|
||||
"languageFlags:" in context_lines):
|
||||
# Check if it's using a function call (allowed)
|
||||
if "languageSelector(" not in context_lines:
|
||||
self._add_violation(
|
||||
rule_id="LANG-002",
|
||||
rule_name="Never use inline Alpine.js x-data for language selector",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Complex language selector should use languageSelector() function",
|
||||
context=line.strip()[:80],
|
||||
suggestion='Use: x-data="languageSelector(\'{{ lang }}\', {{ langs|tojson|safe }})"',
|
||||
)
|
||||
|
||||
# LANG-003: Check for tojson without safe in JavaScript context
|
||||
if "|tojson }}" in line and "|tojson|safe" not in line:
|
||||
if 'x-data=' in line:
|
||||
self._add_violation(
|
||||
rule_id="LANG-003",
|
||||
rule_name="Use tojson|safe for Python lists in JavaScript",
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="tojson without |safe may cause quote escaping issues",
|
||||
context=line.strip()[:80],
|
||||
suggestion="Add |safe filter: {{ variable|tojson|safe }}",
|
||||
)
|
||||
except Exception:
|
||||
pass # Skip files that can't be read
|
||||
|
||||
def _check_language_selector_function(self, target_path: Path):
|
||||
"""LANG-004: Check that languageSelector function exists and is exported"""
|
||||
required_files = [
|
||||
target_path / "static/shop/js/shop-layout.js",
|
||||
target_path / "static/vendor/js/init-alpine.js",
|
||||
]
|
||||
|
||||
for file_path in required_files:
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
|
||||
# Check for function definition
|
||||
has_function = "function languageSelector" in content
|
||||
has_export = "window.languageSelector" in content
|
||||
|
||||
if not has_function:
|
||||
self._add_violation(
|
||||
rule_id="LANG-004",
|
||||
rule_name="Language selector function must be defined",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=1,
|
||||
message="Missing languageSelector function definition",
|
||||
context=file_path.name,
|
||||
suggestion="Add: function languageSelector(currentLang, enabledLanguages) { return {...}; }",
|
||||
)
|
||||
|
||||
if has_function and not has_export:
|
||||
self._add_violation(
|
||||
rule_id="LANG-004",
|
||||
rule_name="Language selector function must be exported",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=1,
|
||||
message="languageSelector function not exported to window",
|
||||
context=file_path.name,
|
||||
suggestion="Add: window.languageSelector = languageSelector;",
|
||||
)
|
||||
|
||||
def _check_language_js_patterns(self, target_path: Path):
|
||||
"""LANG-005, LANG-006: Check language names and flag codes"""
|
||||
js_files = list(target_path.glob("static/**/js/**/*.js"))
|
||||
|
||||
for file_path in js_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//") or stripped.startswith("/*"):
|
||||
continue
|
||||
|
||||
# LANG-005: Check for English language names instead of native
|
||||
english_names = [
|
||||
("'fr': 'French'", "'fr': 'Français'"),
|
||||
("'de': 'German'", "'de': 'Deutsch'"),
|
||||
("'lb': 'Luxembourgish'", "'lb': 'Lëtzebuergesch'"),
|
||||
('"fr": "French"', '"fr": "Français"'),
|
||||
('"de": "German"', '"de": "Deutsch"'),
|
||||
('"lb": "Luxembourgish"', '"lb": "Lëtzebuergesch"'),
|
||||
]
|
||||
|
||||
for wrong, correct in english_names:
|
||||
if wrong.lower().replace(" ", "") in line.lower().replace(" ", ""):
|
||||
self._add_violation(
|
||||
rule_id="LANG-005",
|
||||
rule_name="Use native language names",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=f"Use native language name instead of English",
|
||||
context=stripped[:80],
|
||||
suggestion=f"Change to: {correct}",
|
||||
)
|
||||
|
||||
# LANG-006: Check for incorrect flag codes
|
||||
wrong_flags = [
|
||||
("'en': 'us'", "'en': 'gb'"),
|
||||
("'en': 'en'", "'en': 'gb'"),
|
||||
("'lb': 'lb'", "'lb': 'lu'"),
|
||||
('"en": "us"', '"en": "gb"'),
|
||||
('"en": "en"', '"en": "gb"'),
|
||||
('"lb": "lb"', '"lb": "lu"'),
|
||||
]
|
||||
|
||||
for wrong, correct in wrong_flags:
|
||||
if wrong.lower().replace(" ", "") in line.lower().replace(" ", ""):
|
||||
self._add_violation(
|
||||
rule_id="LANG-006",
|
||||
rule_name="Use correct flag codes",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=f"Invalid flag code mapping",
|
||||
context=stripped[:80],
|
||||
suggestion=f"Change to: {correct}",
|
||||
)
|
||||
|
||||
# LANG-008: Check for wrong API method (GET instead of POST)
|
||||
if "/language/set" in line:
|
||||
if "method: 'GET'" in line or 'method: "GET"' in line:
|
||||
self._add_violation(
|
||||
rule_id="LANG-008",
|
||||
rule_name="Language API endpoint must use POST method",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Language set endpoint must use POST, not GET",
|
||||
context=stripped[:80],
|
||||
suggestion="Change to: method: 'POST'",
|
||||
)
|
||||
if "/language/set?" in line:
|
||||
self._add_violation(
|
||||
rule_id="LANG-008",
|
||||
rule_name="Language API endpoint must use POST method",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Language set endpoint must use POST with body, not GET with query params",
|
||||
context=stripped[:80],
|
||||
suggestion="Use POST with JSON body: { language: lang }",
|
||||
)
|
||||
|
||||
def _check_language_template_patterns(self, target_path: Path):
|
||||
"""LANG-007, LANG-009: Check language patterns in templates"""
|
||||
template_files = list(target_path.glob("app/templates/**/*.html"))
|
||||
|
||||
for file_path in template_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
file_path_str = str(file_path)
|
||||
is_shop = "/shop/" in file_path_str or "\\shop\\" in file_path_str
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# LANG-009: Check for request.state.language without default
|
||||
if "request.state.language" in line:
|
||||
# Check if it has |default
|
||||
if "request.state.language" in line and "|default" not in line:
|
||||
# Make sure it's not just part of a longer expression
|
||||
if re.search(r'request\.state\.language[\'"\s\}\)]', line):
|
||||
self._add_violation(
|
||||
rule_id="LANG-009",
|
||||
rule_name="Always provide language default",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="request.state.language used without default",
|
||||
context=line.strip()[:80],
|
||||
suggestion='Use: request.state.language|default("fr")',
|
||||
)
|
||||
|
||||
# LANG-007: Shop templates must use vendor.storefront_languages
|
||||
if is_shop and "languageSelector" in content:
|
||||
if "vendor.storefront_languages" not in content:
|
||||
# Check if file has any language selector
|
||||
if "enabled_langs" in content or "languages" in content:
|
||||
self._add_violation(
|
||||
rule_id="LANG-007",
|
||||
rule_name="Storefront must respect vendor languages",
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=1,
|
||||
message="Shop template should use vendor.storefront_languages",
|
||||
context=file_path.name,
|
||||
suggestion="Use: {% set enabled_langs = vendor.storefront_languages if vendor else ['fr', 'de', 'en'] %}",
|
||||
)
|
||||
|
||||
def _check_translation_files(self, target_path: Path):
|
||||
"""LANG-010: Check that translation files are valid JSON"""
|
||||
import json
|
||||
|
||||
locales_dir = target_path / "static/locales"
|
||||
if not locales_dir.exists():
|
||||
return
|
||||
|
||||
required_files = ["en.json", "fr.json", "de.json", "lb.json"]
|
||||
|
||||
for filename in required_files:
|
||||
file_path = locales_dir / filename
|
||||
if not file_path.exists():
|
||||
self._add_violation(
|
||||
rule_id="LANG-010",
|
||||
rule_name="Translation files required",
|
||||
severity=Severity.ERROR,
|
||||
file_path=locales_dir,
|
||||
line_number=1,
|
||||
message=f"Missing translation file: {filename}",
|
||||
context=str(locales_dir),
|
||||
suggestion=f"Create {filename} with all required translation keys",
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
self._add_violation(
|
||||
rule_id="LANG-010",
|
||||
rule_name="Translation files must be valid JSON",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=e.lineno or 1,
|
||||
message=f"Invalid JSON: {e.msg}",
|
||||
context=str(e),
|
||||
suggestion="Fix JSON syntax error (check for trailing commas, missing quotes)",
|
||||
)
|
||||
|
||||
def _get_rule(self, rule_id: str) -> dict[str, Any]:
|
||||
"""Get rule configuration by ID"""
|
||||
# Look in different rule categories
|
||||
@@ -2453,6 +2870,7 @@ class ArchitectureValidator:
|
||||
"javascript_rules",
|
||||
"template_rules",
|
||||
"frontend_component_rules",
|
||||
"language_rules",
|
||||
]:
|
||||
rules = self.config.get(category, [])
|
||||
for rule in rules:
|
||||
|
||||
36
scripts/validators/__init__.py
Normal file
36
scripts/validators/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# scripts/validators/__init__.py
|
||||
"""
|
||||
Architecture Validators Package
|
||||
===============================
|
||||
|
||||
This package contains domain-specific validators for the architecture validation system.
|
||||
Each validator module handles a specific category of rules.
|
||||
|
||||
Modules:
|
||||
- base: Base classes and helpers (Severity, Violation, ValidationResult)
|
||||
- api_validator: API endpoint rules (API-*)
|
||||
- service_validator: Service layer rules (SVC-*)
|
||||
- model_validator: Model rules (MDL-*)
|
||||
- exception_validator: Exception handling rules (EXC-*)
|
||||
- naming_validator: Naming convention rules (NAM-*)
|
||||
- auth_validator: Auth and multi-tenancy rules (AUTH-*, MT-*)
|
||||
- middleware_validator: Middleware rules (MDW-*)
|
||||
- frontend_validator: Frontend rules (JS-*, TPL-*, FE-*, CSS-*)
|
||||
- language_validator: Language/i18n rules (LANG-*)
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
BaseValidator,
|
||||
FileResult,
|
||||
Severity,
|
||||
ValidationResult,
|
||||
Violation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Severity",
|
||||
"Violation",
|
||||
"FileResult",
|
||||
"ValidationResult",
|
||||
"BaseValidator",
|
||||
]
|
||||
312
scripts/validators/base.py
Normal file
312
scripts/validators/base.py
Normal file
@@ -0,0 +1,312 @@
|
||||
# scripts/validators/base.py
|
||||
"""
|
||||
Base classes and helpers for architecture validation.
|
||||
|
||||
This module contains:
|
||||
- Severity: Enum for validation severity levels
|
||||
- Violation: Dataclass for representing rule violations
|
||||
- FileResult: Dataclass for single file validation results
|
||||
- ValidationResult: Dataclass for overall validation results
|
||||
- BaseValidator: Base class for domain-specific validators
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
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 BaseValidator:
|
||||
"""
|
||||
Base class for domain-specific validators.
|
||||
|
||||
Provides common functionality for all validators including:
|
||||
- Violation tracking
|
||||
- File filtering
|
||||
- Rule lookup
|
||||
- Common pattern matching utilities
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
result: ValidationResult,
|
||||
project_root: Path,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize validator with shared state.
|
||||
|
||||
Args:
|
||||
config: Loaded architecture rules configuration
|
||||
result: Shared ValidationResult for tracking violations
|
||||
project_root: Root path of the project
|
||||
verbose: Whether to show verbose output
|
||||
"""
|
||||
self.config = config
|
||||
self.result = result
|
||||
self.project_root = project_root
|
||||
self.verbose = verbose
|
||||
|
||||
def validate(self, target_path: Path) -> None:
|
||||
"""
|
||||
Run validation on target path.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
|
||||
Args:
|
||||
target_path: Path to validate (file or directory)
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement validate()")
|
||||
|
||||
def _add_violation(
|
||||
self,
|
||||
rule_id: str,
|
||||
rule_name: str,
|
||||
severity: Severity,
|
||||
file_path: Path,
|
||||
line_number: int,
|
||||
message: str,
|
||||
context: str = "",
|
||||
suggestion: str = "",
|
||||
) -> None:
|
||||
"""Add a violation to results"""
|
||||
violation = Violation(
|
||||
rule_id=rule_id,
|
||||
rule_name=rule_name,
|
||||
severity=severity,
|
||||
file_path=file_path,
|
||||
line_number=line_number,
|
||||
message=message,
|
||||
context=context,
|
||||
suggestion=suggestion,
|
||||
)
|
||||
self.result.violations.append(violation)
|
||||
|
||||
def _should_ignore_file(self, file_path: Path) -> bool:
|
||||
"""Check if file should be ignored"""
|
||||
ignore_patterns = self.config.get("ignore", {}).get("files", [])
|
||||
|
||||
# Convert to string for easier matching
|
||||
file_path_str = str(file_path)
|
||||
|
||||
for pattern in ignore_patterns:
|
||||
# Check if any part of the path matches the pattern
|
||||
if file_path.match(pattern):
|
||||
return True
|
||||
# Also check if pattern appears in the path (for .venv, venv, etc.)
|
||||
if "/.venv/" in file_path_str or file_path_str.startswith(".venv/"):
|
||||
return True
|
||||
if "/venv/" in file_path_str or file_path_str.startswith("venv/"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_rule(self, rule_id: str) -> dict[str, Any] | None:
|
||||
"""Get rule configuration by ID"""
|
||||
# Look in different rule categories
|
||||
for category in [
|
||||
"api_endpoint_rules",
|
||||
"service_layer_rules",
|
||||
"model_rules",
|
||||
"exception_rules",
|
||||
"naming_rules",
|
||||
"auth_rules",
|
||||
"middleware_rules",
|
||||
"javascript_rules",
|
||||
"template_rules",
|
||||
"frontend_component_rules",
|
||||
"styling_rules",
|
||||
"language_rules",
|
||||
"multi_tenancy_rules",
|
||||
"code_quality_rules",
|
||||
]:
|
||||
rules = self.config.get(category, [])
|
||||
for rule in rules:
|
||||
if rule.get("id") == rule_id:
|
||||
return rule
|
||||
return None
|
||||
|
||||
def _get_files(self, target_path: Path, pattern: str) -> list[Path]:
|
||||
"""Get files matching a glob pattern, excluding ignored files"""
|
||||
files = list(target_path.glob(pattern))
|
||||
return [f for f in files if not self._should_ignore_file(f)]
|
||||
|
||||
def _find_decorators(self, content: str) -> list[tuple[int, str, str]]:
|
||||
"""
|
||||
Find all function decorators and their associated functions.
|
||||
|
||||
Returns list of (line_number, decorator, function_name) tuples.
|
||||
"""
|
||||
results = []
|
||||
lines = content.split("\n")
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
if line.startswith("@"):
|
||||
decorator = line
|
||||
# Look for the function definition
|
||||
for j in range(i + 1, min(i + 10, len(lines))):
|
||||
next_line = lines[j].strip()
|
||||
if next_line.startswith("def ") or next_line.startswith("async def "):
|
||||
# Extract function name
|
||||
match = re.search(r"(?:async\s+)?def\s+(\w+)", next_line)
|
||||
if match:
|
||||
func_name = match.group(1)
|
||||
results.append((i + 1, decorator, func_name))
|
||||
break
|
||||
elif next_line.startswith("@"):
|
||||
# Multiple decorators - continue to next
|
||||
continue
|
||||
elif next_line and not next_line.startswith("#"):
|
||||
# Non-decorator, non-comment line - stop looking
|
||||
break
|
||||
i += 1
|
||||
|
||||
return results
|
||||
|
||||
def _check_pattern_in_lines(
|
||||
self,
|
||||
file_path: Path,
|
||||
lines: list[str],
|
||||
pattern: str,
|
||||
rule_id: str,
|
||||
rule_name: str,
|
||||
severity: Severity,
|
||||
message: str,
|
||||
suggestion: str = "",
|
||||
exclude_comments: bool = True,
|
||||
exclude_patterns: list[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Check for pattern violations in file lines.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
lines: File content split by lines
|
||||
pattern: Regex pattern to search for
|
||||
rule_id: Rule identifier
|
||||
rule_name: Human-readable rule name
|
||||
severity: Violation severity
|
||||
message: Violation message
|
||||
suggestion: Suggested fix
|
||||
exclude_comments: Skip lines that are comments
|
||||
exclude_patterns: Additional patterns that mark lines to skip
|
||||
"""
|
||||
exclude_patterns = exclude_patterns or []
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# Skip comments if requested
|
||||
if exclude_comments and stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Check exclusion patterns
|
||||
skip = False
|
||||
for exc in exclude_patterns:
|
||||
if exc in line:
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
|
||||
# Check for pattern
|
||||
if re.search(pattern, line):
|
||||
self._add_violation(
|
||||
rule_id=rule_id,
|
||||
rule_name=rule_name,
|
||||
severity=severity,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=message,
|
||||
context=stripped[:80],
|
||||
suggestion=suggestion,
|
||||
)
|
||||
|
||||
def _is_valid_json(self, file_path: Path) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if a file contains valid JSON.
|
||||
|
||||
Returns (is_valid, error_message) tuple.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
json.load(f)
|
||||
return True, ""
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"Line {e.lineno}: {e.msg}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
Reference in New Issue
Block a user