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:
File diff suppressed because it is too large
Load Diff
102
.architecture-rules/_main.yaml
Normal file
102
.architecture-rules/_main.yaml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Architecture Rules - Main Configuration
|
||||||
|
# This file defines core settings and includes domain-specific rule files.
|
||||||
|
|
||||||
|
version: "2.0"
|
||||||
|
project: "letzshop-product-import"
|
||||||
|
description: "Comprehensive architectural rules for multi-tenant e-commerce platform"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CORE ARCHITECTURAL PRINCIPLES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
principles:
|
||||||
|
- name: "Separation of Concerns"
|
||||||
|
description: "API endpoints should only handle HTTP concerns. Business logic belongs in services."
|
||||||
|
|
||||||
|
- name: "Layered Architecture"
|
||||||
|
description: "Routes → Services → Models. Each layer has specific responsibilities."
|
||||||
|
|
||||||
|
- name: "Type Safety"
|
||||||
|
description: "Use Pydantic models for request/response validation. Use SQLAlchemy models for database."
|
||||||
|
|
||||||
|
- name: "Proper Exception Handling"
|
||||||
|
description: "Services throw domain exceptions. Global handler converts to HTTP responses. Endpoints do NOT catch or raise HTTPException."
|
||||||
|
|
||||||
|
- name: "Multi-Tenancy"
|
||||||
|
description: "All queries must be scoped to vendor_id. No cross-vendor data access."
|
||||||
|
|
||||||
|
- name: "Consistent Naming"
|
||||||
|
description: "API files: plural, Services: singular+service, Models: singular"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RULE FILE INCLUDES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
includes:
|
||||||
|
- api.yaml
|
||||||
|
- service.yaml
|
||||||
|
- model.yaml
|
||||||
|
- exception.yaml
|
||||||
|
- naming.yaml
|
||||||
|
- auth.yaml
|
||||||
|
- middleware.yaml
|
||||||
|
- frontend.yaml
|
||||||
|
- language.yaml
|
||||||
|
- quality.yaml
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VALIDATION SEVERITY LEVELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
severity_levels:
|
||||||
|
error:
|
||||||
|
description: "Critical architectural violation - must be fixed"
|
||||||
|
exit_code: 1
|
||||||
|
|
||||||
|
warning:
|
||||||
|
description: "Pattern deviation - should be fixed"
|
||||||
|
exit_code: 0
|
||||||
|
|
||||||
|
info:
|
||||||
|
description: "Suggestion for improvement"
|
||||||
|
exit_code: 0
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# IGNORED PATTERNS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
files:
|
||||||
|
- "**/*_test.py"
|
||||||
|
- "**/test_*.py"
|
||||||
|
- "**/__pycache__/**"
|
||||||
|
- "**/migrations/**"
|
||||||
|
- "**/alembic/versions/**"
|
||||||
|
- "**/node_modules/**"
|
||||||
|
- "**/.venv/**"
|
||||||
|
- "**/venv/**"
|
||||||
|
- ".venv/**"
|
||||||
|
- "venv/**"
|
||||||
|
- "**/build/**"
|
||||||
|
- "**/dist/**"
|
||||||
|
|
||||||
|
patterns:
|
||||||
|
# Allow HTTPException in specific files
|
||||||
|
- file: "app/core/exceptions.py"
|
||||||
|
pattern: "HTTPException"
|
||||||
|
reason: "Exception handling utilities"
|
||||||
|
|
||||||
|
- file: "app/exceptions/handler.py"
|
||||||
|
pattern: "HTTPException"
|
||||||
|
reason: "Exception handler converts to HTTP"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DOCUMENTATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
documentation:
|
||||||
|
architecture: "docs/architecture/overview.md"
|
||||||
|
backend: "docs/backend/overview.md"
|
||||||
|
frontend: "docs/frontend/overview.md"
|
||||||
|
contributing: "docs/development/contributing.md"
|
||||||
|
code_quality: "docs/development/code-quality.md"
|
||||||
158
.architecture-rules/api.yaml
Normal file
158
.architecture-rules/api.yaml
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Architecture Rules - API Endpoint Rules
|
||||||
|
# Rules for app/api/v1/**/*.py files
|
||||||
|
|
||||||
|
api_endpoint_rules:
|
||||||
|
|
||||||
|
- id: "API-001"
|
||||||
|
name: "Endpoint must use Pydantic models for request/response"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All API endpoints must use Pydantic models (BaseModel) for request bodies
|
||||||
|
and response models. Never use raw dicts or SQLAlchemy models directly.
|
||||||
|
|
||||||
|
WHY THIS MATTERS:
|
||||||
|
- Type safety: Pydantic validates response structure at runtime
|
||||||
|
- Documentation: OpenAPI/Swagger auto-generates accurate docs
|
||||||
|
- Contract stability: Schema changes are explicit and reviewable
|
||||||
|
- IDE support: Consumers get autocomplete and type hints
|
||||||
|
- Prevents bugs: Field name mismatches caught immediately
|
||||||
|
|
||||||
|
COMMON VIOLATION: Service returns dict, frontend expects different field names.
|
||||||
|
Example: Service returns {"total_imports": 5} but frontend expects {"total": 5}.
|
||||||
|
With response_model, this mismatch is caught immediately.
|
||||||
|
|
||||||
|
SCHEMA LOCATION: All response schemas must be defined in models/schema/*.py,
|
||||||
|
never inline in endpoint files. This ensures schemas are reusable and discoverable.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "return dict"
|
||||||
|
- "-> dict"
|
||||||
|
- "return db_object"
|
||||||
|
- "return {"
|
||||||
|
example_good: |
|
||||||
|
# In models/schema/stats.py
|
||||||
|
class ImportStatsResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
pending: int
|
||||||
|
completed: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
# In app/api/v1/admin/marketplace.py
|
||||||
|
@router.get("/stats", response_model=ImportStatsResponse)
|
||||||
|
def get_import_statistics(db: Session = Depends(get_db)):
|
||||||
|
return stats_service.get_import_statistics(db)
|
||||||
|
example_bad: |
|
||||||
|
# WRONG: No response_model, returns raw dict
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_import_statistics(db: Session = Depends(get_db)):
|
||||||
|
return stats_service.get_import_statistics(db) # Returns dict
|
||||||
|
|
||||||
|
# WRONG: Schema defined inline in endpoint file
|
||||||
|
class MyResponse(BaseModel): # Should be in models/schema/
|
||||||
|
...
|
||||||
|
|
||||||
|
@router.get("/data", response_model=MyResponse)
|
||||||
|
def get_data(): ...
|
||||||
|
|
||||||
|
- id: "API-002"
|
||||||
|
name: "Endpoint must NOT contain business logic"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
API endpoints should only handle HTTP concerns (validation, auth, response formatting).
|
||||||
|
All business logic must be delegated to service layer.
|
||||||
|
|
||||||
|
Transaction control (db.commit) IS allowed at endpoint level - this is the recommended
|
||||||
|
pattern for request-scoped transactions. One request = one transaction.
|
||||||
|
|
||||||
|
What's NOT allowed:
|
||||||
|
- db.add() - creating entities is business logic
|
||||||
|
- db.query() - complex queries are business logic
|
||||||
|
- db.delete() - deleting entities is business logic
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "db.add("
|
||||||
|
- "db.delete("
|
||||||
|
- "db.query("
|
||||||
|
|
||||||
|
- id: "API-003"
|
||||||
|
name: "Endpoint must NOT raise ANY exceptions directly"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
API endpoints should NOT raise exceptions directly. Endpoints are a thin
|
||||||
|
orchestration layer that:
|
||||||
|
1. Accepts request parameters (validated by Pydantic)
|
||||||
|
2. Calls dependencies for authentication/authorization (deps.py raises exceptions)
|
||||||
|
3. Calls services for business logic (services raise domain exceptions)
|
||||||
|
4. Returns response (formatted by Pydantic)
|
||||||
|
|
||||||
|
Exception raising belongs in:
|
||||||
|
- Dependencies (app/api/deps.py) - authentication/authorization validation
|
||||||
|
- Services (app/services/) - business logic validation
|
||||||
|
|
||||||
|
The global exception handler catches all WizamartException subclasses and
|
||||||
|
converts them to appropriate HTTP responses.
|
||||||
|
|
||||||
|
WRONG (endpoint raises exception):
|
||||||
|
@router.get("/orders")
|
||||||
|
def get_orders(current_user: User = Depends(get_current_vendor_api)):
|
||||||
|
if not hasattr(current_user, "token_vendor_id"): # Redundant check
|
||||||
|
raise InvalidTokenException("...") # Don't raise here
|
||||||
|
return order_service.get_orders(db, current_user.token_vendor_id)
|
||||||
|
|
||||||
|
RIGHT (dependency guarantees, endpoint trusts):
|
||||||
|
@router.get("/orders")
|
||||||
|
def get_orders(current_user: User = Depends(get_current_vendor_api)):
|
||||||
|
# Dependency guarantees token_vendor_id is present
|
||||||
|
return order_service.get_orders(db, current_user.token_vendor_id)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "raise HTTPException"
|
||||||
|
- "raise InvalidTokenException"
|
||||||
|
- "raise InsufficientPermissionsException"
|
||||||
|
- "if not hasattr\\(current_user.*token_vendor"
|
||||||
|
exceptions:
|
||||||
|
- "app/exceptions/handler.py"
|
||||||
|
|
||||||
|
- id: "API-004"
|
||||||
|
name: "Endpoint must have proper authentication/authorization"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Protected endpoints must use Depends() for authentication.
|
||||||
|
Use get_current_user, get_current_admin, etc.
|
||||||
|
|
||||||
|
Auto-excluded files:
|
||||||
|
- */auth.py - Authentication endpoints (login, logout, register) are intentionally public
|
||||||
|
|
||||||
|
Public endpoint markers (place on line before or after decorator):
|
||||||
|
- # public - Descriptive marker for intentionally unauthenticated endpoints
|
||||||
|
- # noqa: API-004 - Standard noqa style to suppress warning
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# public - Stripe webhook receives external callbacks
|
||||||
|
@router.post("/webhook/stripe")
|
||||||
|
def stripe_webhook(request: Request):
|
||||||
|
...
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
required_if_not_public:
|
||||||
|
- "Depends(get_current_"
|
||||||
|
auto_exclude_files:
|
||||||
|
- "*/auth.py"
|
||||||
|
public_markers:
|
||||||
|
- "# public"
|
||||||
|
- "# noqa: api-004"
|
||||||
|
|
||||||
|
- id: "API-005"
|
||||||
|
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All queries in vendor/shop contexts must filter by vendor_id.
|
||||||
|
Use request.state.vendor_id from middleware.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/vendor/**/*.py"
|
||||||
|
file_pattern: "app/api/v1/shop/**/*.py"
|
||||||
|
discouraged_patterns:
|
||||||
|
- "db.query(.*).all()"
|
||||||
87
.architecture-rules/auth.yaml
Normal file
87
.architecture-rules/auth.yaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Architecture Rules - Authentication & Authorization Rules
|
||||||
|
# Rules for auth patterns and multi-tenancy
|
||||||
|
|
||||||
|
auth_rules:
|
||||||
|
|
||||||
|
- id: "AUTH-001"
|
||||||
|
name: "Use JWT tokens in Authorization header"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Authentication must use JWT tokens in Authorization: Bearer header
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/**/*.py"
|
||||||
|
enforcement: "middleware"
|
||||||
|
|
||||||
|
- id: "AUTH-002"
|
||||||
|
name: "Role-based access control with Depends"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Use Depends(get_current_admin/vendor/customer) for role checks
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
required: "Depends\\(get_current_"
|
||||||
|
|
||||||
|
- id: "AUTH-003"
|
||||||
|
name: "Never store plain passwords"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Always hash passwords with bcrypt before storing
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/auth_service.py"
|
||||||
|
required: "bcrypt"
|
||||||
|
|
||||||
|
- id: "AUTH-004"
|
||||||
|
name: "Vendor context pattern - use appropriate dependency for endpoint type"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Two vendor context patterns exist - use the appropriate one:
|
||||||
|
|
||||||
|
1. SHOP ENDPOINTS (public, no authentication required):
|
||||||
|
- Use: vendor: Vendor = Depends(require_vendor_context())
|
||||||
|
- Vendor is detected from URL/subdomain/domain
|
||||||
|
- File pattern: app/api/v1/shop/**/*.py
|
||||||
|
- Mark as public with: # public
|
||||||
|
|
||||||
|
2. VENDOR API ENDPOINTS (authenticated):
|
||||||
|
- Use: current_user.token_vendor_id from JWT token
|
||||||
|
- Or use permission dependencies: require_vendor_permission(), require_vendor_owner
|
||||||
|
- These dependencies get vendor from token and set request.state.vendor
|
||||||
|
- File pattern: app/api/v1/vendor/**/*.py
|
||||||
|
|
||||||
|
DEPRECATED for vendor APIs:
|
||||||
|
- require_vendor_context() - only for shop endpoints
|
||||||
|
- getattr(request.state, "vendor", None) without permission dependency
|
||||||
|
|
||||||
|
See: docs/backend/vendor-in-token-architecture.md
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/vendor/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "require_vendor_context\\(\\)"
|
||||||
|
file_pattern: "app/api/v1/shop/**/*.py"
|
||||||
|
required_patterns:
|
||||||
|
- "require_vendor_context\\(\\)|# public"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MULTI-TENANCY RULES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
multi_tenancy_rules:
|
||||||
|
|
||||||
|
- id: "MT-001"
|
||||||
|
name: "All queries must be scoped to vendor_id"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
In vendor/shop contexts, all database queries must filter by vendor_id
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
context: "vendor_shop"
|
||||||
|
required_pattern: ".filter\\(.*vendor_id.*\\)"
|
||||||
|
|
||||||
|
- id: "MT-002"
|
||||||
|
name: "No cross-vendor data access"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Queries must never access data from other vendors
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
enforcement: "database_query_level"
|
||||||
66
.architecture-rules/exception.yaml
Normal file
66
.architecture-rules/exception.yaml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Architecture Rules - Exception Handling Rules
|
||||||
|
# Rules for exception handling across the application
|
||||||
|
|
||||||
|
exception_rules:
|
||||||
|
|
||||||
|
- id: "EXC-001"
|
||||||
|
name: "Define custom exceptions in exceptions module"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Create domain-specific exceptions in app/exceptions/ for better
|
||||||
|
error handling and clarity.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/exceptions/**/*.py"
|
||||||
|
encouraged_structure: |
|
||||||
|
class VendorError(Exception):
|
||||||
|
"""Base exception for vendor-related errors"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
- id: "EXC-002"
|
||||||
|
name: "Never use bare except"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Always specify exception types. Bare except catches everything including
|
||||||
|
KeyboardInterrupt and SystemExit.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "except:"
|
||||||
|
- "except\\s*:"
|
||||||
|
|
||||||
|
- id: "EXC-003"
|
||||||
|
name: "Log all exceptions with context"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
When catching exceptions, log them with context and stack trace.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "logger.error"
|
||||||
|
- "exc_info=True"
|
||||||
|
|
||||||
|
- id: "EXC-004"
|
||||||
|
name: "Domain exceptions must inherit from WizamartException"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All custom domain exceptions must inherit from WizamartException (or its
|
||||||
|
subclasses like ResourceNotFoundException, ValidationException, etc.).
|
||||||
|
This ensures the global exception handler catches and converts them properly.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/exceptions/**/*.py"
|
||||||
|
required_base_class: "WizamartException"
|
||||||
|
example_good: |
|
||||||
|
class VendorNotFoundException(ResourceNotFoundException):
|
||||||
|
def __init__(self, vendor_code: str):
|
||||||
|
super().__init__(resource_type="Vendor", identifier=vendor_code)
|
||||||
|
|
||||||
|
- id: "EXC-005"
|
||||||
|
name: "Exception handler must be registered"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
The global exception handler must be set up in app initialization to
|
||||||
|
catch WizamartException and convert to HTTP responses.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/main.py"
|
||||||
|
required_patterns:
|
||||||
|
- "setup_exception_handlers"
|
||||||
539
.architecture-rules/frontend.yaml
Normal file
539
.architecture-rules/frontend.yaml
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
# Architecture Rules - Frontend Rules
|
||||||
|
# Combined rules for JavaScript, Templates, Components, and Styling
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# JAVASCRIPT ARCHITECTURE RULES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
javascript_rules:
|
||||||
|
|
||||||
|
- id: "JS-001"
|
||||||
|
name: "Use centralized logger, not console"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Use window.LogConfig.createLogger() for consistent logging.
|
||||||
|
Never use console.log, console.error, console.warn directly.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "console\\.log"
|
||||||
|
- "console\\.error"
|
||||||
|
- "console\\.warn"
|
||||||
|
exceptions:
|
||||||
|
- "// eslint-disable"
|
||||||
|
- "console.log('✅"
|
||||||
|
auto_exclude_files:
|
||||||
|
- "init-*.js"
|
||||||
|
|
||||||
|
- id: "JS-002"
|
||||||
|
name: "Use lowercase apiClient for API calls"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Use lowercase 'apiClient' consistently, not 'ApiClient' or 'API_CLIENT'
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "ApiClient\\."
|
||||||
|
- "API_CLIENT\\."
|
||||||
|
required_pattern: "apiClient\\."
|
||||||
|
|
||||||
|
- id: "JS-003"
|
||||||
|
name: "Alpine components must spread ...data()"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All Alpine.js components must inherit base layout data using spread operator
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
required_in_alpine_components:
|
||||||
|
- "\\.\\.\\.data\\(\\)"
|
||||||
|
|
||||||
|
- id: "JS-004"
|
||||||
|
name: "Alpine components must set currentPage"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All Alpine.js page components must set a currentPage identifier
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
required_in_alpine_components:
|
||||||
|
- "currentPage:"
|
||||||
|
|
||||||
|
- id: "JS-005"
|
||||||
|
name: "Initialization methods must include guard"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Init methods should prevent duplicate initialization with guard
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
recommended_pattern: |
|
||||||
|
if (window._pageInitialized) return;
|
||||||
|
window._pageInitialized = true;
|
||||||
|
|
||||||
|
- id: "JS-006"
|
||||||
|
name: "All async operations must have try/catch with error logging"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All API calls and async operations must have error handling
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
check: "async_error_handling"
|
||||||
|
|
||||||
|
- id: "JS-007"
|
||||||
|
name: "Set loading state before async operations"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Loading state should be set before and cleared after async operations
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
recommended_pattern: |
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
// operation
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
- id: "JS-008"
|
||||||
|
name: "Use apiClient for API calls, not raw fetch()"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All API calls must use the apiClient helper instead of raw fetch().
|
||||||
|
The apiClient automatically:
|
||||||
|
- Adds Authorization header with JWT token from cookies
|
||||||
|
- Sets Content-Type headers
|
||||||
|
- Handles error responses consistently
|
||||||
|
- Provides logging integration
|
||||||
|
|
||||||
|
WRONG (raw fetch):
|
||||||
|
const response = await fetch('/api/v1/admin/products/123');
|
||||||
|
|
||||||
|
RIGHT (apiClient):
|
||||||
|
const response = await apiClient.get('/admin/products/123');
|
||||||
|
const result = await apiClient.post('/admin/products', data);
|
||||||
|
await apiClient.delete('/admin/products/123');
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "fetch\\('/api/"
|
||||||
|
- 'fetch\\("/api/'
|
||||||
|
- "fetch\\(`/api/"
|
||||||
|
exceptions:
|
||||||
|
- "init-api-client.js"
|
||||||
|
|
||||||
|
- id: "JS-009"
|
||||||
|
name: "Use Utils.showToast() for notifications, not alert() or window.showToast"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All user notifications must use Utils.showToast() from static/shared/js/utils.js.
|
||||||
|
Never use browser alert() dialogs or undefined window.showToast.
|
||||||
|
|
||||||
|
Utils.showToast() provides:
|
||||||
|
- Consistent styling (Tailwind-based toast in bottom-right corner)
|
||||||
|
- Automatic fade-out after duration
|
||||||
|
- Color-coded types (success=green, error=red, warning=yellow, info=blue)
|
||||||
|
|
||||||
|
WRONG (browser dialog):
|
||||||
|
alert('Product saved successfully');
|
||||||
|
|
||||||
|
RIGHT (Utils helper):
|
||||||
|
Utils.showToast('Product saved successfully', 'success');
|
||||||
|
Utils.showToast('Failed to save product', 'error');
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "alert\\("
|
||||||
|
- "window\\.showToast"
|
||||||
|
exceptions:
|
||||||
|
- "utils.js"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEMPLATE RULES (Jinja2)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
template_rules:
|
||||||
|
|
||||||
|
- id: "TPL-001"
|
||||||
|
name: "Admin templates must extend admin/base.html"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All admin templates must extend the base template for consistency.
|
||||||
|
|
||||||
|
Auto-excluded files:
|
||||||
|
- login.html - Standalone login page (no sidebar/navigation)
|
||||||
|
- errors/*.html - Error pages extend errors/base.html instead
|
||||||
|
- test-*.html - Test/development templates
|
||||||
|
|
||||||
|
Standalone template markers (place in first 5 lines):
|
||||||
|
- {# standalone #} - Mark template as intentionally standalone
|
||||||
|
- {# noqa: TPL-001 #} - Standard noqa style to suppress error
|
||||||
|
- <!-- standalone --> - HTML comment style
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/admin/**/*.html"
|
||||||
|
required_patterns:
|
||||||
|
- "{% extends ['\"]admin/base\\.html['\"] %}"
|
||||||
|
auto_exclude_files:
|
||||||
|
- "login.html"
|
||||||
|
- "errors/"
|
||||||
|
- "test-"
|
||||||
|
standalone_markers:
|
||||||
|
- "{# standalone #}"
|
||||||
|
- "{# noqa: tpl-001 #}"
|
||||||
|
- "<!-- standalone -->"
|
||||||
|
exceptions:
|
||||||
|
- "base.html"
|
||||||
|
- "partials/"
|
||||||
|
|
||||||
|
- id: "TPL-002"
|
||||||
|
name: "Vendor templates must extend vendor/base.html"
|
||||||
|
severity: "error"
|
||||||
|
description: "All vendor templates must extend the base template"
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/vendor/**/*.html"
|
||||||
|
required_patterns:
|
||||||
|
- "{% extends ['\"]vendor/base\\.html['\"] %}"
|
||||||
|
exceptions:
|
||||||
|
- "base.html"
|
||||||
|
- "partials/"
|
||||||
|
|
||||||
|
- id: "TPL-003"
|
||||||
|
name: "Shop templates must extend shop/base.html"
|
||||||
|
severity: "error"
|
||||||
|
description: "All shop templates must extend the base template"
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
required_patterns:
|
||||||
|
- "{% extends ['\"]shop/base\\.html['\"] %}"
|
||||||
|
exceptions:
|
||||||
|
- "base.html"
|
||||||
|
- "partials/"
|
||||||
|
|
||||||
|
- id: "TPL-004"
|
||||||
|
name: "Use x-text for dynamic text content (prevents XSS)"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use x-text directive for dynamic content to prevent XSS vulnerabilities
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
recommended_pattern: '<p x-text="item.name"></p>'
|
||||||
|
|
||||||
|
- id: "TPL-005"
|
||||||
|
name: "Use x-html ONLY for safe content"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Use x-html only for trusted content like icons, never for user-generated content
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
safe_usage:
|
||||||
|
- 'x-html="\\$icon\\('
|
||||||
|
|
||||||
|
- id: "TPL-006"
|
||||||
|
name: "Implement loading state for data loads"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
All templates that load data should show loading state
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
recommended_pattern: '<div x-show="loading">Loading...</div>'
|
||||||
|
|
||||||
|
- id: "TPL-007"
|
||||||
|
name: "Implement empty state when no data"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Show empty state when lists have no items
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
recommended_pattern: '<template x-if="items.length === 0">No items</template>'
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FRONTEND COMPONENT RULES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
frontend_component_rules:
|
||||||
|
|
||||||
|
- id: "FE-001"
|
||||||
|
name: "Use pagination macro instead of inline HTML"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use the shared pagination macro instead of duplicating pagination HTML.
|
||||||
|
Import from shared/macros/pagination.html.
|
||||||
|
|
||||||
|
RIGHT (use macro):
|
||||||
|
{% from 'shared/macros/pagination.html' import pagination %}
|
||||||
|
{{ pagination() }}
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- 'aria-label="Table navigation"'
|
||||||
|
- "previousPage\\(\\).*nextPage\\(\\)"
|
||||||
|
exceptions:
|
||||||
|
- "shared/macros/pagination.html"
|
||||||
|
- "components.html"
|
||||||
|
|
||||||
|
- id: "FE-002"
|
||||||
|
name: "Use $icon() helper instead of inline SVGs"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use the Alpine.js $icon() helper for consistent iconography.
|
||||||
|
Do not use inline <svg> elements.
|
||||||
|
|
||||||
|
RIGHT (icon helper):
|
||||||
|
<span x-html="$icon('arrow-left', 'w-4 h-4')"></span>
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- "<svg.*viewBox.*>.*</svg>"
|
||||||
|
exceptions:
|
||||||
|
- "base.html"
|
||||||
|
- "components.html"
|
||||||
|
- "shared/macros/"
|
||||||
|
|
||||||
|
- id: "FE-003"
|
||||||
|
name: "Use table macros for consistent table styling"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared table macros for consistent table styling.
|
||||||
|
Import from shared/macros/tables.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/tables.html' import"
|
||||||
|
|
||||||
|
- id: "FE-004"
|
||||||
|
name: "Use form macros for consistent form styling"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared form macros for consistent input styling and validation.
|
||||||
|
Import from shared/macros/forms.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/forms.html' import"
|
||||||
|
|
||||||
|
- id: "FE-008"
|
||||||
|
name: "Use number_stepper macro for quantity inputs"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use the shared number_stepper macro instead of raw <input type="number">.
|
||||||
|
This ensures consistent styling, proper dark mode support, and hides
|
||||||
|
native browser spinners that render inconsistently.
|
||||||
|
|
||||||
|
RIGHT (use macro):
|
||||||
|
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||||
|
{{ number_stepper(model='quantity', min=1, max=99) }}
|
||||||
|
|
||||||
|
Suppress with:
|
||||||
|
- {# noqa: FE-008 #} on the line or at file level
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- 'type="number"'
|
||||||
|
- "type='number'"
|
||||||
|
exceptions:
|
||||||
|
- "shared/macros/inputs.html"
|
||||||
|
- "components.html"
|
||||||
|
|
||||||
|
- id: "FE-009"
|
||||||
|
name: "Use product_card macro for product displays"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared product_card macro for consistent product presentation.
|
||||||
|
Import from shared/macros/shop/product-card.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/product-card.html' import"
|
||||||
|
|
||||||
|
- id: "FE-010"
|
||||||
|
name: "Use product_grid macro for product listings"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared product_grid macro for responsive product grids.
|
||||||
|
Import from shared/macros/shop/product-grid.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/product-grid.html' import"
|
||||||
|
|
||||||
|
- id: "FE-011"
|
||||||
|
name: "Use add_to_cart macros for cart interactions"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared add-to-cart macros for consistent cart functionality.
|
||||||
|
Import from shared/macros/shop/add-to-cart.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/add-to-cart.html' import"
|
||||||
|
|
||||||
|
- id: "FE-012"
|
||||||
|
name: "Use mini_cart macro for cart dropdown"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared mini_cart macros for header cart functionality.
|
||||||
|
Import from shared/macros/shop/mini-cart.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/mini-cart.html' import"
|
||||||
|
|
||||||
|
- id: "FE-013"
|
||||||
|
name: "Use product_gallery macro for image galleries"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared product_gallery macros for product image displays.
|
||||||
|
Import from shared/macros/shop/product-gallery.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/product-gallery.html' import"
|
||||||
|
|
||||||
|
- id: "FE-014"
|
||||||
|
name: "Use variant_selector macros for product options"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared variant_selector macros for product variant selection.
|
||||||
|
Import from shared/macros/shop/variant-selector.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/variant-selector.html' import"
|
||||||
|
|
||||||
|
- id: "FE-015"
|
||||||
|
name: "Use product_info macros for product details"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared product_info macros for product detail sections.
|
||||||
|
Import from shared/macros/shop/product-info.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/product-info.html' import"
|
||||||
|
|
||||||
|
- id: "FE-016"
|
||||||
|
name: "Use product_tabs macro for product content tabs"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared product_tabs macros for tabbed product information.
|
||||||
|
Import from shared/macros/shop/product-tabs.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/product-tabs.html' import"
|
||||||
|
|
||||||
|
- id: "FE-017"
|
||||||
|
name: "Use category_nav macros for category navigation"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared category_nav macros for category navigation sidebars and menus.
|
||||||
|
Import from shared/macros/shop/category-nav.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/category-nav.html' import"
|
||||||
|
|
||||||
|
- id: "FE-018"
|
||||||
|
name: "Use breadcrumbs macros for breadcrumb navigation"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared breadcrumbs macros for navigation trails.
|
||||||
|
Import from shared/macros/shop/breadcrumbs.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/breadcrumbs.html' import"
|
||||||
|
|
||||||
|
- id: "FE-019"
|
||||||
|
name: "Use search_bar macros for product search"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared search_bar macros for product search functionality.
|
||||||
|
Import from shared/macros/shop/search-bar.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/search-bar.html' import"
|
||||||
|
|
||||||
|
- id: "FE-020"
|
||||||
|
name: "Use filter_sidebar macros for product filtering"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared filter_sidebar macros for product filtering panels.
|
||||||
|
Import from shared/macros/shop/filter-sidebar.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/filter-sidebar.html' import"
|
||||||
|
|
||||||
|
- id: "FE-021"
|
||||||
|
name: "Use star_rating macros for rating displays"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared star_rating macros for all rating displays and inputs.
|
||||||
|
Import from shared/macros/shop/star-rating.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/star-rating.html' import"
|
||||||
|
|
||||||
|
- id: "FE-022"
|
||||||
|
name: "Use review macros for review displays"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared review macros for product reviews.
|
||||||
|
Import from shared/macros/shop/reviews.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/reviews.html' import"
|
||||||
|
|
||||||
|
- id: "FE-023"
|
||||||
|
name: "Use trust_badges macros for trust signals"
|
||||||
|
severity: "info"
|
||||||
|
description: |
|
||||||
|
Use the shared trust_badges macros for security and trust indicators.
|
||||||
|
Import from shared/macros/shop/trust-badges.html.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "{% from 'shared/macros/shop/trust-badges.html' import"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FRONTEND STYLING RULES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
styling_rules:
|
||||||
|
|
||||||
|
- id: "CSS-001"
|
||||||
|
name: "Use Tailwind utility classes"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Prefer Tailwind utility classes over custom CSS
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
encouraged: true
|
||||||
|
|
||||||
|
- id: "CSS-002"
|
||||||
|
name: "Support dark mode with dark: prefix"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
All color classes should include dark mode variants
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
recommended_pattern: 'class="bg-white dark:bg-gray-800"'
|
||||||
|
|
||||||
|
- id: "CSS-003"
|
||||||
|
name: "Shop templates use vendor theme CSS variables"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Shop templates must use CSS variables for vendor-specific theming
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
required_pattern: 'var\\(--color-primary\\)'
|
||||||
|
|
||||||
|
- id: "CSS-004"
|
||||||
|
name: "Mobile-first responsive design"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use mobile-first responsive classes
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
recommended_pattern: 'class="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"'
|
||||||
240
.architecture-rules/language.yaml
Normal file
240
.architecture-rules/language.yaml
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Architecture Rules - Language & I18N Rules
|
||||||
|
# Rules for internationalization and language handling
|
||||||
|
|
||||||
|
language_rules:
|
||||||
|
|
||||||
|
- id: "LANG-001"
|
||||||
|
name: "Only use supported language codes"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Only use supported language codes: en, fr, de, lb
|
||||||
|
Never use full language names or invalid codes.
|
||||||
|
|
||||||
|
SUPPORTED CODES:
|
||||||
|
- en: English (fallback language)
|
||||||
|
- fr: French (default for Luxembourg)
|
||||||
|
- de: German
|
||||||
|
- lb: Luxembourgish
|
||||||
|
|
||||||
|
WRONG:
|
||||||
|
language = "english" # Use "en"
|
||||||
|
language = "french" # Use "fr"
|
||||||
|
language = "lux" # Use "lb"
|
||||||
|
|
||||||
|
RIGHT:
|
||||||
|
language = "en"
|
||||||
|
language = "fr"
|
||||||
|
language = "de"
|
||||||
|
language = "lb"
|
||||||
|
pattern:
|
||||||
|
file_pattern: "**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "'english'"
|
||||||
|
- "'french'"
|
||||||
|
- "'german'"
|
||||||
|
- "'luxembourgish'"
|
||||||
|
- "'lux'"
|
||||||
|
|
||||||
|
- id: "LANG-002"
|
||||||
|
name: "Never use inline Alpine.js x-data with Jinja for language selector"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Never put complex JavaScript objects inline in x-data when using Jinja variables.
|
||||||
|
Jinja outputs Python lists, not JSON, which breaks JavaScript parsing.
|
||||||
|
|
||||||
|
WRONG (inline with Jinja):
|
||||||
|
<div x-data="{
|
||||||
|
languages: {{ vendor.storefront_languages }},
|
||||||
|
...
|
||||||
|
}">
|
||||||
|
|
||||||
|
RIGHT (function with tojson|safe):
|
||||||
|
<div x-data="languageSelector('{{ lang }}', {{ langs|tojson|safe }})">
|
||||||
|
|
||||||
|
The languageSelector function must be defined in:
|
||||||
|
- static/shop/js/shop-layout.js (for storefront)
|
||||||
|
- static/vendor/js/init-alpine.js (for vendor dashboard)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- 'x-data="{\s*.*languages:'
|
||||||
|
- 'x-data="{\s*.*languageNames:'
|
||||||
|
- 'x-data="{\s*.*languageFlags:'
|
||||||
|
exceptions:
|
||||||
|
- "partials/header.html"
|
||||||
|
|
||||||
|
- id: "LANG-003"
|
||||||
|
name: "Use tojson|safe for Python lists in JavaScript"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
When passing Python lists to JavaScript in templates, always use tojson|safe.
|
||||||
|
- tojson converts Python list to JSON
|
||||||
|
- safe prevents HTML escaping of quotes
|
||||||
|
|
||||||
|
WRONG (raw output):
|
||||||
|
languages: {{ vendor.storefront_languages }}
|
||||||
|
<!-- Outputs: ['fr', 'de'] - Python syntax -->
|
||||||
|
|
||||||
|
WRONG (tojson without safe):
|
||||||
|
languages: {{ vendor.storefront_languages|tojson }}
|
||||||
|
<!-- May escape quotes to " -->
|
||||||
|
|
||||||
|
RIGHT:
|
||||||
|
languages: {{ vendor.storefront_languages|tojson|safe }}
|
||||||
|
<!-- Outputs: ["fr", "de"] - Valid JSON -->
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
required_with_jinja_array:
|
||||||
|
- "|tojson|safe"
|
||||||
|
|
||||||
|
- id: "LANG-004"
|
||||||
|
name: "Language selector function must be exported to window"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
The languageSelector function must be defined and exported to window
|
||||||
|
in the appropriate JavaScript file.
|
||||||
|
|
||||||
|
Required in static/shop/js/shop-layout.js:
|
||||||
|
function languageSelector(currentLang, enabledLanguages) { ... }
|
||||||
|
window.languageSelector = languageSelector;
|
||||||
|
|
||||||
|
Required in static/vendor/js/init-alpine.js:
|
||||||
|
function languageSelector(currentLang, enabledLanguages) { ... }
|
||||||
|
window.languageSelector = languageSelector;
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/shop/js/shop-layout.js"
|
||||||
|
required_patterns:
|
||||||
|
- "function languageSelector"
|
||||||
|
- "window.languageSelector"
|
||||||
|
file_pattern: "static/vendor/js/init-alpine.js"
|
||||||
|
required_patterns:
|
||||||
|
- "function languageSelector"
|
||||||
|
- "window.languageSelector"
|
||||||
|
|
||||||
|
- id: "LANG-005"
|
||||||
|
name: "Use native language names in language selector"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Language names must be in their native form, not English.
|
||||||
|
|
||||||
|
WRONG (English names):
|
||||||
|
languageNames: {
|
||||||
|
'fr': 'French',
|
||||||
|
'de': 'German',
|
||||||
|
'lb': 'Luxembourgish'
|
||||||
|
}
|
||||||
|
|
||||||
|
RIGHT (native names):
|
||||||
|
languageNames: {
|
||||||
|
'en': 'English',
|
||||||
|
'fr': 'Francais',
|
||||||
|
'de': 'Deutsch',
|
||||||
|
'lb': 'Letzebuergesch'
|
||||||
|
}
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "'fr':\\s*'French'"
|
||||||
|
- "'de':\\s*'German'"
|
||||||
|
- "'lb':\\s*'Luxembourgish'"
|
||||||
|
|
||||||
|
- id: "LANG-006"
|
||||||
|
name: "Use correct flag codes for languages"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Flag codes must map correctly to flag-icons library codes.
|
||||||
|
|
||||||
|
CORRECT MAPPINGS:
|
||||||
|
- en -> gb (Great Britain flag for English)
|
||||||
|
- fr -> fr (France flag)
|
||||||
|
- de -> de (Germany flag)
|
||||||
|
- lb -> lu (Luxembourg flag)
|
||||||
|
|
||||||
|
WRONG:
|
||||||
|
'en': 'us' # US flag incorrect for general English
|
||||||
|
'en': 'en' # 'en' is not a valid flag code
|
||||||
|
'lb': 'lb' # 'lb' is not a valid flag code
|
||||||
|
|
||||||
|
RIGHT:
|
||||||
|
'en': 'gb',
|
||||||
|
'fr': 'fr',
|
||||||
|
'de': 'de',
|
||||||
|
'lb': 'lu'
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "'en':\\s*'us'"
|
||||||
|
- "'en':\\s*'en'"
|
||||||
|
- "'lb':\\s*'lb'"
|
||||||
|
|
||||||
|
- id: "LANG-007"
|
||||||
|
name: "Storefront must respect vendor's enabled languages"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Shop/storefront templates must only show languages enabled by the vendor.
|
||||||
|
Use vendor.storefront_languages, not a hardcoded list.
|
||||||
|
|
||||||
|
WRONG (hardcoded):
|
||||||
|
{% set enabled_langs = ['en', 'fr', 'de', 'lb'] %}
|
||||||
|
|
||||||
|
RIGHT (from vendor config):
|
||||||
|
{% set enabled_langs = vendor.storefront_languages if vendor and vendor.storefront_languages else ['fr', 'de', 'en'] %}
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/shop/**/*.html"
|
||||||
|
required_for_lang_selector:
|
||||||
|
- "vendor.storefront_languages"
|
||||||
|
|
||||||
|
- id: "LANG-008"
|
||||||
|
name: "Language API endpoint must use POST method"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
The language set API must be called with POST method, not GET.
|
||||||
|
|
||||||
|
WRONG:
|
||||||
|
fetch('/api/v1/language/set?lang=' + lang)
|
||||||
|
fetch('/api/v1/language/set', { method: 'GET' })
|
||||||
|
|
||||||
|
RIGHT:
|
||||||
|
fetch('/api/v1/language/set', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ language: lang })
|
||||||
|
})
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "/language/set.*method.*GET"
|
||||||
|
- "/language/set\\?lang="
|
||||||
|
|
||||||
|
- id: "LANG-009"
|
||||||
|
name: "Always provide language default in templates"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
When accessing request.state.language, always provide a default value.
|
||||||
|
|
||||||
|
WRONG:
|
||||||
|
{{ request.state.language }} <!-- May be None -->
|
||||||
|
|
||||||
|
RIGHT:
|
||||||
|
{{ request.state.language|default("fr") }}
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/templates/**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- 'request\\.state\\.language[^|]'
|
||||||
|
required_pattern: 'request\\.state\\.language\\|default'
|
||||||
|
|
||||||
|
- id: "LANG-010"
|
||||||
|
name: "Translation files must be valid JSON"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All translation files in static/locales/ must be valid JSON.
|
||||||
|
No trailing commas, proper quoting, etc.
|
||||||
|
|
||||||
|
Files required:
|
||||||
|
- static/locales/en.json
|
||||||
|
- static/locales/fr.json
|
||||||
|
- static/locales/de.json
|
||||||
|
- static/locales/lb.json
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/locales/*.json"
|
||||||
|
check: "valid_json"
|
||||||
22
.architecture-rules/middleware.yaml
Normal file
22
.architecture-rules/middleware.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Architecture Rules - Middleware Rules
|
||||||
|
# Rules for middleware/**/*.py files
|
||||||
|
|
||||||
|
middleware_rules:
|
||||||
|
|
||||||
|
- id: "MDW-001"
|
||||||
|
name: "Middleware files use simple nouns without _middleware suffix"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Middleware files should be named with simple nouns (auth.py, not auth_middleware.py)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "middleware/**/*.py"
|
||||||
|
check: "middleware_naming"
|
||||||
|
|
||||||
|
- id: "MDW-002"
|
||||||
|
name: "Vendor context must be injected for vendor/shop routes"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Vendor context middleware must set request.state.vendor_id and request.state.vendor
|
||||||
|
pattern:
|
||||||
|
file_pattern: "middleware/vendor_context.py"
|
||||||
|
required: true
|
||||||
55
.architecture-rules/model.yaml
Normal file
55
.architecture-rules/model.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Architecture Rules - Model Rules
|
||||||
|
# Rules for models/database/*.py and models/schema/*.py files
|
||||||
|
|
||||||
|
model_rules:
|
||||||
|
|
||||||
|
- id: "MDL-001"
|
||||||
|
name: "Database models must use SQLAlchemy Base"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All database models must inherit from SQLAlchemy Base and use proper
|
||||||
|
column definitions with types and constraints.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "models/database/**/*.py"
|
||||||
|
required_patterns:
|
||||||
|
- "class.*\\(Base\\):"
|
||||||
|
|
||||||
|
- id: "MDL-002"
|
||||||
|
name: "Use Pydantic models separately from SQLAlchemy models"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Never mix SQLAlchemy and Pydantic in the same model.
|
||||||
|
SQLAlchemy = database schema, Pydantic = API validation/serialization.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "models/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "class.*\\(Base, BaseModel\\):"
|
||||||
|
|
||||||
|
- id: "MDL-003"
|
||||||
|
name: "Pydantic models must use from_attributes for ORM mode"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Pydantic response models must enable from_attributes to work with SQLAlchemy models.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "models/schema/**/*.py"
|
||||||
|
required_in_response_models:
|
||||||
|
- "from_attributes = True"
|
||||||
|
|
||||||
|
- id: "MDL-004"
|
||||||
|
name: "Database tables use plural names"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Database table names should be plural lowercase following industry standard
|
||||||
|
conventions (Rails, Django, Laravel, most ORMs). A table represents a
|
||||||
|
collection of entities, so plural names are natural: 'users', 'orders',
|
||||||
|
'products'. This reads naturally in SQL: SELECT * FROM users WHERE id = 1.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Good: users, vendors, products, orders, order_items, cart_items
|
||||||
|
- Bad: user, vendor, product, order
|
||||||
|
|
||||||
|
Junction/join tables use both entity names in plural:
|
||||||
|
- Good: vendor_users, order_items, product_translations
|
||||||
|
pattern:
|
||||||
|
file_pattern: "models/database/**/*.py"
|
||||||
|
check: "table_naming_plural"
|
||||||
56
.architecture-rules/naming.yaml
Normal file
56
.architecture-rules/naming.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Architecture Rules - Naming Convention Rules
|
||||||
|
# Rules for consistent naming across the codebase
|
||||||
|
|
||||||
|
naming_rules:
|
||||||
|
|
||||||
|
- id: "NAM-001"
|
||||||
|
name: "API files use PLURAL names"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
API endpoint files should use plural names (vendors.py, products.py)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
check: "plural_naming"
|
||||||
|
exceptions:
|
||||||
|
- "__init__.py"
|
||||||
|
- "auth.py"
|
||||||
|
- "health.py"
|
||||||
|
|
||||||
|
- id: "NAM-002"
|
||||||
|
name: "Service files use SINGULAR + 'service' suffix"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Service files should use singular name + _service (vendor_service.py)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
check: "service_naming"
|
||||||
|
|
||||||
|
- id: "NAM-003"
|
||||||
|
name: "Model files use SINGULAR names"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Both database and schema model files use singular names (product.py)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "models/**/*.py"
|
||||||
|
check: "singular_naming"
|
||||||
|
|
||||||
|
- id: "NAM-004"
|
||||||
|
name: "Use consistent terminology: vendor not shop"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use 'vendor' consistently, not 'shop' (except for shop frontend)
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/**/*.py"
|
||||||
|
discouraged_terms:
|
||||||
|
- "shop_id"
|
||||||
|
- "shop_service"
|
||||||
|
|
||||||
|
- id: "NAM-005"
|
||||||
|
name: "Use consistent terminology: inventory not stock"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Use 'inventory' consistently, not 'stock'
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/**/*.py"
|
||||||
|
discouraged_terms:
|
||||||
|
- "stock_service"
|
||||||
27
.architecture-rules/quality.yaml
Normal file
27
.architecture-rules/quality.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Architecture Rules - Code Quality Rules
|
||||||
|
# Rules for code quality, formatting, and linting
|
||||||
|
|
||||||
|
code_quality_rules:
|
||||||
|
|
||||||
|
- id: "QUAL-001"
|
||||||
|
name: "All code must be formatted with Ruff"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Run 'make format' before committing
|
||||||
|
enforcement: "pre_commit"
|
||||||
|
|
||||||
|
- id: "QUAL-002"
|
||||||
|
name: "All code must pass Ruff linting"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Run 'make lint' before committing
|
||||||
|
enforcement: "pre_commit"
|
||||||
|
|
||||||
|
- id: "QUAL-003"
|
||||||
|
name: "Type hints recommended for functions"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Add type hints to function parameters and return types
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/**/*.py"
|
||||||
|
encouraged: true
|
||||||
117
.architecture-rules/service.yaml
Normal file
117
.architecture-rules/service.yaml
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Architecture Rules - Service Layer Rules
|
||||||
|
# Rules for app/services/**/*.py files
|
||||||
|
|
||||||
|
service_layer_rules:
|
||||||
|
|
||||||
|
- id: "SVC-001"
|
||||||
|
name: "Service must NOT raise HTTPException"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Services are business logic layer - they should NOT know about HTTP.
|
||||||
|
Raise domain-specific exceptions instead (ValueError, custom exceptions).
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "raise HTTPException"
|
||||||
|
- "from fastapi import HTTPException"
|
||||||
|
|
||||||
|
- id: "SVC-002"
|
||||||
|
name: "Service must use proper exception handling"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Services should raise meaningful domain exceptions, not generic Exception.
|
||||||
|
Create custom exception classes for business rule violations.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
discouraged_patterns:
|
||||||
|
- "raise Exception\\("
|
||||||
|
|
||||||
|
- id: "SVC-003"
|
||||||
|
name: "Service methods must accept db session as parameter"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Service methods should receive database session as a parameter for testability
|
||||||
|
and transaction control. Never create session inside service.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
required_in_method_signature:
|
||||||
|
- "db: Session"
|
||||||
|
anti_patterns:
|
||||||
|
- "SessionLocal()"
|
||||||
|
- "get_db()"
|
||||||
|
|
||||||
|
- id: "SVC-004"
|
||||||
|
name: "Service must use Pydantic models for input validation"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Service methods should accept Pydantic models for complex inputs
|
||||||
|
to ensure type safety and validation.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
encouraged_patterns:
|
||||||
|
- "BaseModel"
|
||||||
|
|
||||||
|
- id: "SVC-005"
|
||||||
|
name: "Service must scope queries to vendor_id in multi-tenant contexts"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All database queries must be scoped to vendor_id to prevent cross-tenant data access.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
check: "vendor_scoping"
|
||||||
|
|
||||||
|
- id: "SVC-006"
|
||||||
|
name: "Service must NOT call db.commit()"
|
||||||
|
severity: "warning"
|
||||||
|
description: |
|
||||||
|
Services should NOT commit transactions. Transaction control belongs at the
|
||||||
|
API endpoint level where one request = one transaction.
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
- Composing multiple service calls in a single transaction
|
||||||
|
- Clean rollback on any failure
|
||||||
|
- Easier testing of services in isolation
|
||||||
|
|
||||||
|
The endpoint should call db.commit() after all service operations succeed.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "db.commit()"
|
||||||
|
exceptions:
|
||||||
|
- "log_service.py"
|
||||||
|
|
||||||
|
- id: "SVC-007"
|
||||||
|
name: "Service return types must match API response schemas"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
When a service method's return value will be used as an API response,
|
||||||
|
the returned dict keys MUST match the corresponding Pydantic schema fields.
|
||||||
|
|
||||||
|
This prevents the common bug where:
|
||||||
|
- Service returns {"total_imports": 5, "completed_imports": 3}
|
||||||
|
- Schema expects {"total": 5, "completed": 3}
|
||||||
|
- Frontend receives wrong/empty values
|
||||||
|
|
||||||
|
RECOMMENDED PATTERNS:
|
||||||
|
|
||||||
|
1. Return Pydantic model directly from service:
|
||||||
|
def get_stats(self, db: Session) -> StatsResponse:
|
||||||
|
return StatsResponse(total=count, completed=done)
|
||||||
|
|
||||||
|
2. Return dict with schema-matching keys:
|
||||||
|
def get_stats(self, db: Session) -> dict:
|
||||||
|
return {"total": count, "completed": done} # Matches StatsResponse
|
||||||
|
|
||||||
|
3. Document the expected schema in service docstring:
|
||||||
|
def get_stats(self, db: Session) -> dict:
|
||||||
|
"""
|
||||||
|
Returns dict compatible with StatsResponse schema.
|
||||||
|
Keys: total, pending, completed, failed
|
||||||
|
"""
|
||||||
|
|
||||||
|
TESTING: Write tests that validate service output against schema:
|
||||||
|
result = service.get_stats(db)
|
||||||
|
StatsResponse(**result) # Raises if keys don't match
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/services/**/*.py"
|
||||||
|
check: "schema_compatibility"
|
||||||
@@ -122,9 +122,24 @@ class ArchitectureValidator:
|
|||||||
self.project_root = Path.cwd()
|
self.project_root = Path.cwd()
|
||||||
|
|
||||||
def _load_config(self) -> dict[str, Any]:
|
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():
|
if not self.config_path.exists():
|
||||||
print(f"❌ Configuration file not found: {self.config_path}")
|
print(f"❌ Configuration file not found: {self.config_path}")
|
||||||
|
print(f" (Also checked for directory: {rules_dir})")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
with open(self.config_path) as f:
|
with open(self.config_path) as f:
|
||||||
@@ -133,6 +148,44 @@ class ArchitectureValidator:
|
|||||||
print(f"📋 Loaded architecture rules: {config.get('project', 'unknown')}")
|
print(f"📋 Loaded architecture rules: {config.get('project', 'unknown')}")
|
||||||
return config
|
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:
|
def validate_all(self, target_path: Path = None) -> ValidationResult:
|
||||||
"""Validate all files in a directory"""
|
"""Validate all files in a directory"""
|
||||||
print("\n🔍 Starting architecture validation...\n")
|
print("\n🔍 Starting architecture validation...\n")
|
||||||
@@ -166,6 +219,9 @@ class ArchitectureValidator:
|
|||||||
# Validate templates
|
# Validate templates
|
||||||
self._validate_templates(target)
|
self._validate_templates(target)
|
||||||
|
|
||||||
|
# Validate language/i18n rules
|
||||||
|
self._validate_language_rules(target)
|
||||||
|
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
def validate_file(
|
def validate_file(
|
||||||
@@ -1442,11 +1498,15 @@ class ArchitectureValidator:
|
|||||||
if "@router." in line and (
|
if "@router." in line and (
|
||||||
"post" in line or "put" in line or "delete" in line
|
"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)
|
# (increased from 5 to handle multi-line decorators and long function signatures)
|
||||||
has_auth = False
|
has_auth = False
|
||||||
is_public = 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:
|
for ctx_line in context_lines:
|
||||||
# Check for any valid auth pattern
|
# 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",
|
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]:
|
def _get_rule(self, rule_id: str) -> dict[str, Any]:
|
||||||
"""Get rule configuration by ID"""
|
"""Get rule configuration by ID"""
|
||||||
# Look in different rule categories
|
# Look in different rule categories
|
||||||
@@ -2453,6 +2870,7 @@ class ArchitectureValidator:
|
|||||||
"javascript_rules",
|
"javascript_rules",
|
||||||
"template_rules",
|
"template_rules",
|
||||||
"frontend_component_rules",
|
"frontend_component_rules",
|
||||||
|
"language_rules",
|
||||||
]:
|
]:
|
||||||
rules = self.config.get(category, [])
|
rules = self.config.get(category, [])
|
||||||
for rule in rules:
|
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