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:
2025-12-13 22:36:33 +01:00
parent d2b05441fc
commit 33c5875bc8
15 changed files with 2238 additions and 1490 deletions

View File

@@ -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: