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>
313 lines
9.3 KiB
Python
313 lines
9.3 KiB
Python
# 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)
|