feat: add architecture validation system with comprehensive pattern enforcement
Implemented automated architecture validation to enforce design decisions: Architecture Validation System: - Created .architecture-rules.yaml with comprehensive rule definitions - Implemented validate_architecture.py script with AST-based validation - Added pre-commit hook configuration for automatic validation - Comprehensive documentation in docs/architecture/architecture-patterns.md Key Design Rules Enforced: - API-001 to API-004: API endpoint patterns (Pydantic models, no business logic, exception handling, auth) - SVC-001 to SVC-004: Service layer patterns (domain exceptions, db session params, no HTTP concerns) - MDL-001 to MDL-002: Model separation (SQLAlchemy vs Pydantic) - EXC-001 to EXC-002: Exception handling (custom exceptions, no bare except) - JS-001 to JS-003: JavaScript patterns (apiClient, logger, Alpine components) - TPL-001: Template patterns (extend base.html) Features: - Validates separation of concerns (routes vs services vs models) - Enforces proper exception handling (domain exceptions in services, HTTP in routes) - Checks database session patterns and Pydantic model usage - JavaScript and template validation - Detailed error reporting with suggestions - Integration with pre-commit hooks and CI/CD UI Fix: - Fixed icon names in content-pages.html (pencil→edit, trash→delete) Documentation: - Added architecture patterns guide with examples - Created scripts/README.md for validator usage - Updated mkdocs.yml with architecture documentation - Built and verified documentation successfully Usage: python scripts/validate_architecture.py # Validate all python scripts/validate_architecture.py --verbose # With details python scripts/validate_architecture.py --errors-only # Errors only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
407
.architecture-rules.yaml
Normal file
407
.architecture-rules.yaml
Normal file
@@ -0,0 +1,407 @@
|
||||
# Architecture Rules Configuration
|
||||
# This file defines the key architectural decisions and patterns that must be followed
|
||||
# across the application. The validator script uses these rules to check compliance.
|
||||
|
||||
version: "1.0"
|
||||
project: "letzshop-product-import"
|
||||
|
||||
# ============================================================================
|
||||
# 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. Routes catch and convert to HTTPException."
|
||||
|
||||
# ============================================================================
|
||||
# API ENDPOINT RULES (app/api/v1/**/*.py)
|
||||
# ============================================================================
|
||||
|
||||
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.
|
||||
pattern:
|
||||
file_pattern: "app/api/v1/**/*.py"
|
||||
check: "pydantic_model_usage"
|
||||
anti_patterns:
|
||||
- "return dict"
|
||||
- "-> dict"
|
||||
- "return db_object" # SQLAlchemy model returned directly
|
||||
example_good: |
|
||||
class VendorCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
@router.post("/vendors", response_model=VendorResponse)
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
example_bad: |
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(data: dict, db: Session = Depends(get_db)):
|
||||
return {"name": data["name"]} # No validation!
|
||||
|
||||
- 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.
|
||||
pattern:
|
||||
file_pattern: "app/api/v1/**/*.py"
|
||||
anti_patterns:
|
||||
- "db.add("
|
||||
- "db.commit()"
|
||||
- "db.query("
|
||||
- "SELECT"
|
||||
- "UPDATE"
|
||||
- "DELETE"
|
||||
exceptions:
|
||||
- "db parameter passed to service" # Allowed
|
||||
example_good: |
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
# Delegate to service
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
example_bad: |
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
# Business logic in endpoint - BAD!
|
||||
db_vendor = Vendor(name=vendor.name)
|
||||
db.add(db_vendor)
|
||||
db.commit()
|
||||
return db_vendor
|
||||
|
||||
- id: "API-003"
|
||||
name: "Endpoint must catch service exceptions and convert to HTTPException"
|
||||
severity: "error"
|
||||
description: |
|
||||
API endpoints must catch domain exceptions from services and convert them
|
||||
to appropriate HTTPException with proper status codes.
|
||||
pattern:
|
||||
file_pattern: "app/api/v1/**/*.py"
|
||||
required_patterns:
|
||||
- "try:"
|
||||
- "except"
|
||||
- "HTTPException"
|
||||
or_pattern: "service_method_without_exception_handling"
|
||||
example_good: |
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
except VendorAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
example_bad: |
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
# No exception handling - service errors leak to client!
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
|
||||
- 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.
|
||||
pattern:
|
||||
file_pattern: "app/api/v1/**/*.py"
|
||||
required_if_not_public:
|
||||
- "Depends(get_current_"
|
||||
example_good: |
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(
|
||||
vendor: VendorCreate,
|
||||
current_user: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
pass
|
||||
|
||||
# ============================================================================
|
||||
# SERVICE LAYER RULES (app/services/**/*.py)
|
||||
# ============================================================================
|
||||
|
||||
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"
|
||||
example_good: |
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data):
|
||||
if self._vendor_exists(db, vendor_data.subdomain):
|
||||
raise VendorAlreadyExistsError(f"Vendor {vendor_data.subdomain} exists")
|
||||
# ... business logic
|
||||
example_bad: |
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data):
|
||||
if self._vendor_exists(db, vendor_data.subdomain):
|
||||
raise HTTPException(status_code=409, detail="Vendor exists") # BAD!
|
||||
|
||||
- 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"
|
||||
required_patterns:
|
||||
- "class.*Error\\(Exception\\):" # Custom exception classes
|
||||
discouraged_patterns:
|
||||
- "raise Exception\\(" # Too generic
|
||||
example_good: |
|
||||
class VendorAlreadyExistsError(Exception):
|
||||
pass
|
||||
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data):
|
||||
if self._vendor_exists(db, vendor_data.subdomain):
|
||||
raise VendorAlreadyExistsError(f"Subdomain {vendor_data.subdomain} taken")
|
||||
example_bad: |
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data):
|
||||
if self._vendor_exists(db, vendor_data.subdomain):
|
||||
raise Exception("Vendor exists") # Too generic!
|
||||
|
||||
- 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()"
|
||||
example_good: |
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data: VendorCreate):
|
||||
# db passed as parameter - testable and transactional
|
||||
vendor = Vendor(**vendor_data.dict())
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
return vendor
|
||||
example_bad: |
|
||||
class VendorService:
|
||||
def create_vendor(self, vendor_data: VendorCreate):
|
||||
# Creating session inside - BAD!
|
||||
db = SessionLocal()
|
||||
vendor = Vendor(**vendor_data.dict())
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
|
||||
- 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:
|
||||
- "def .+\\(.*: BaseModel"
|
||||
- "def .+\\(.*: .*Create"
|
||||
- "def .+\\(.*: .*Update"
|
||||
|
||||
# ============================================================================
|
||||
# MODEL RULES (app/models/**/*.py)
|
||||
# ============================================================================
|
||||
|
||||
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: "app/models/**/*.py"
|
||||
required_patterns:
|
||||
- "class.*\\(Base\\):"
|
||||
- "from.*sqlalchemy.*import.*Column"
|
||||
|
||||
- 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: "app/models/**/*.py"
|
||||
anti_patterns:
|
||||
- "class.*\\(Base, BaseModel\\):" # Multiple inheritance - BAD!
|
||||
|
||||
# ============================================================================
|
||||
# EXCEPTION HANDLING RULES
|
||||
# ============================================================================
|
||||
|
||||
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: |
|
||||
# app/exceptions/vendor_exceptions.py
|
||||
class VendorError(Exception):
|
||||
"""Base exception for vendor-related errors"""
|
||||
pass
|
||||
|
||||
class VendorNotFoundError(VendorError):
|
||||
pass
|
||||
|
||||
class VendorAlreadyExistsError(VendorError):
|
||||
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*:"
|
||||
example_good: |
|
||||
try:
|
||||
result = service.do_something()
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
example_bad: |
|
||||
try:
|
||||
result = service.do_something()
|
||||
except: # BAD! Too broad
|
||||
pass
|
||||
|
||||
# ============================================================================
|
||||
# JAVASCRIPT ARCHITECTURE RULES
|
||||
# ============================================================================
|
||||
|
||||
javascript_rules:
|
||||
|
||||
- id: "JS-001"
|
||||
name: "Use apiClient directly, not window.apiClient"
|
||||
severity: "warning"
|
||||
description: "API client is globally available, no need for window prefix"
|
||||
pattern:
|
||||
file_pattern: "static/admin/js/**/*.js"
|
||||
anti_patterns:
|
||||
- "window\\.apiClient"
|
||||
example_good: "await apiClient.get('/api/v1/vendors')"
|
||||
example_bad: "await window.apiClient.get('/api/v1/vendors')"
|
||||
|
||||
- id: "JS-002"
|
||||
name: "Use centralized logger, not console"
|
||||
severity: "warning"
|
||||
description: "Use window.LogConfig.createLogger() for consistent logging"
|
||||
pattern:
|
||||
file_pattern: "static/admin/js/**/*.js"
|
||||
anti_patterns:
|
||||
- "console\\.log"
|
||||
- "console\\.error"
|
||||
- "console\\.warn"
|
||||
exceptions:
|
||||
- "// eslint-disable"
|
||||
- "console.log('✅" # Bootstrap messages allowed
|
||||
|
||||
- id: "JS-003"
|
||||
name: "Alpine components must spread ...data()"
|
||||
severity: "error"
|
||||
description: "All Alpine.js components must inherit base layout data"
|
||||
pattern:
|
||||
file_pattern: "static/admin/js/**/*.js"
|
||||
required_in_alpine_components:
|
||||
- "\\.\\.\\.data\\(\\)"
|
||||
|
||||
# ============================================================================
|
||||
# TEMPLATE RULES
|
||||
# ============================================================================
|
||||
|
||||
template_rules:
|
||||
|
||||
- id: "TPL-001"
|
||||
name: "Admin templates must extend base.html"
|
||||
severity: "error"
|
||||
description: "All admin templates must extend the base template for consistency"
|
||||
pattern:
|
||||
file_pattern: "app/templates/admin/**/*.html"
|
||||
required_patterns:
|
||||
- "{% extends ['\"]admin/base\\.html['\"] %}"
|
||||
exceptions:
|
||||
- "base.html"
|
||||
- "partials/"
|
||||
|
||||
# ============================================================================
|
||||
# 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 # Don't fail build, but report
|
||||
|
||||
info:
|
||||
description: "Suggestion for improvement"
|
||||
exit_code: 0
|
||||
|
||||
# ============================================================================
|
||||
# IGNORED PATTERNS (False Positives)
|
||||
# ============================================================================
|
||||
|
||||
ignore:
|
||||
files:
|
||||
- "**/*_test.py"
|
||||
- "**/test_*.py"
|
||||
- "**/__pycache__/**"
|
||||
- "**/migrations/**"
|
||||
- "**/node_modules/**"
|
||||
|
||||
patterns:
|
||||
# Allow HTTPException in specific files
|
||||
- file: "app/core/exceptions.py"
|
||||
pattern: "HTTPException"
|
||||
reason: "Exception handling utilities"
|
||||
43
.pre-commit-config.yaml
Normal file
43
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
# Pre-commit hooks configuration
|
||||
# Install: pip install pre-commit
|
||||
# Setup: pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
# Architecture validation
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: validate-architecture
|
||||
name: Validate Architecture Patterns
|
||||
entry: python scripts/validate_architecture.py
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
additional_dependencies: [pyyaml]
|
||||
verbose: true
|
||||
|
||||
# Python code quality
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
- id: check-json
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
|
||||
# Python formatting (optional - uncomment if you want)
|
||||
# - repo: https://github.com/psf/black
|
||||
# rev: 23.12.1
|
||||
# hooks:
|
||||
# - id: black
|
||||
# language_version: python3
|
||||
|
||||
# Python import sorting (optional)
|
||||
# - repo: https://github.com/pycqa/isort
|
||||
# rev: 5.13.2
|
||||
# hooks:
|
||||
# - id: isort
|
||||
@@ -157,14 +157,14 @@
|
||||
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<button
|
||||
@click="deletePage(page)"
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
611
docs/architecture/architecture-patterns.md
Normal file
611
docs/architecture/architecture-patterns.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# Architecture Patterns & Design Decisions
|
||||
|
||||
This document describes the architectural patterns and design decisions that must be followed throughout the codebase.
|
||||
|
||||
> **Note:** These patterns are enforced automatically by `scripts/validate_architecture.py`. Run the validator before committing code.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Principles](#core-principles)
|
||||
2. [Layered Architecture](#layered-architecture)
|
||||
3. [API Endpoint Patterns](#api-endpoint-patterns)
|
||||
4. [Service Layer Patterns](#service-layer-patterns)
|
||||
5. [Model Patterns](#model-patterns)
|
||||
6. [Exception Handling](#exception-handling)
|
||||
7. [JavaScript Patterns](#javascript-patterns)
|
||||
8. [Validation](#validation)
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Separation of Concerns
|
||||
|
||||
**Each layer has specific responsibilities:**
|
||||
|
||||
- **Routes/Endpoints**: HTTP handling, validation, authentication, response formatting
|
||||
- **Services**: Business logic, data processing, orchestration
|
||||
- **Models**: Data structure and persistence
|
||||
|
||||
**❌ Bad Example - Business logic in endpoint:**
|
||||
|
||||
```python
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
# ❌ BAD: Business logic in endpoint
|
||||
if db.query(Vendor).filter(Vendor.subdomain == vendor.subdomain).first():
|
||||
raise HTTPException(status_code=409, detail="Vendor exists")
|
||||
|
||||
db_vendor = Vendor(**vendor.dict())
|
||||
db.add(db_vendor)
|
||||
db.commit()
|
||||
db.refresh(db_vendor)
|
||||
return db_vendor
|
||||
```
|
||||
|
||||
**✅ Good Example - Delegated to service:**
|
||||
|
||||
```python
|
||||
@router.post("/vendors", response_model=VendorResponse)
|
||||
async def create_vendor(
|
||||
vendor: VendorCreate,
|
||||
current_user: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
# ✅ GOOD: Delegate to service
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
except VendorAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create vendor: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
```
|
||||
|
||||
### 2. Type Safety
|
||||
|
||||
Use Pydantic for API validation, SQLAlchemy for database models.
|
||||
|
||||
### 3. Proper Exception Handling
|
||||
|
||||
Services throw domain exceptions, routes convert to HTTP responses.
|
||||
|
||||
---
|
||||
|
||||
## Layered Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ API Layer │
|
||||
│ (app/api/v1/**/*.py, app/routes/**/*.py) │
|
||||
│ │
|
||||
│ Responsibilities: │
|
||||
│ - HTTP request/response handling │
|
||||
│ - Authentication/Authorization │
|
||||
│ - Input validation (Pydantic models) │
|
||||
│ - Exception handling (domain → HTTP) │
|
||||
│ │
|
||||
│ ❌ Should NOT: │
|
||||
│ - Contain business logic │
|
||||
│ - Directly access database (except via services) │
|
||||
│ - Raise domain exceptions │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Service Layer │
|
||||
│ (app/services/**/*.py) │
|
||||
│ │
|
||||
│ Responsibilities: │
|
||||
│ - Business logic │
|
||||
│ - Data validation (business rules) │
|
||||
│ - Database operations │
|
||||
│ - Orchestration of multiple operations │
|
||||
│ │
|
||||
│ ❌ Should NOT: │
|
||||
│ - Know about HTTP (no HTTPException) │
|
||||
│ - Create database sessions (accept as parameter) │
|
||||
│ - Handle HTTP-specific concerns │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Data Layer │
|
||||
│ (app/models/**/*.py) │
|
||||
│ │
|
||||
│ Responsibilities: │
|
||||
│ - Database schema (SQLAlchemy models) │
|
||||
│ - API schemas (Pydantic models) │
|
||||
│ - Data structure definitions │
|
||||
│ │
|
||||
│ ❌ Should NOT: │
|
||||
│ - Mix SQLAlchemy and Pydantic in same class │
|
||||
│ - Contain business logic │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint Patterns
|
||||
|
||||
### Rule API-001: Use Pydantic Models
|
||||
|
||||
**All endpoints MUST use Pydantic models for request/response.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Pydantic models for type safety
|
||||
class VendorCreate(BaseModel):
|
||||
name: str = Field(..., max_length=200)
|
||||
subdomain: str = Field(..., max_length=100)
|
||||
is_active: bool = True
|
||||
|
||||
class VendorResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
subdomain: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True # For SQLAlchemy compatibility
|
||||
|
||||
@router.post("/vendors", response_model=VendorResponse)
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ BAD: Raw dict, no validation
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(data: dict):
|
||||
return {"name": data["name"]} # No type safety!
|
||||
```
|
||||
|
||||
### Rule API-002: No Business Logic in Endpoints
|
||||
|
||||
**Endpoints should only handle HTTP concerns.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Delegate to service
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ BAD: Business logic in endpoint
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
# ❌ Database operations belong in service!
|
||||
db_vendor = Vendor(**vendor.dict())
|
||||
db.add(db_vendor)
|
||||
db.commit()
|
||||
return db_vendor
|
||||
```
|
||||
|
||||
### Rule API-003: Proper Exception Handling
|
||||
|
||||
**Catch service exceptions and convert to HTTPException.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Proper exception handling
|
||||
@router.post("/vendors", response_model=VendorResponse)
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
except VendorAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating vendor: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
```
|
||||
|
||||
### Rule API-004: Authentication
|
||||
|
||||
**Protected endpoints must use dependency injection for auth.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Use Depends for auth
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(
|
||||
vendor: VendorCreate,
|
||||
current_user: User = Depends(get_current_admin), # ✅ Auth required
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
result = vendor_service.create_vendor(db, vendor)
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer Patterns
|
||||
|
||||
### Rule SVC-001: No HTTPException in Services
|
||||
|
||||
**Services should NOT know about HTTP. Raise domain exceptions instead.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Domain exception
|
||||
class VendorAlreadyExistsError(Exception):
|
||||
"""Raised when vendor with same subdomain already exists"""
|
||||
pass
|
||||
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data: VendorCreate):
|
||||
if self._vendor_exists(db, vendor_data.subdomain):
|
||||
raise VendorAlreadyExistsError(
|
||||
f"Vendor with subdomain '{vendor_data.subdomain}' already exists"
|
||||
)
|
||||
|
||||
# Business logic...
|
||||
vendor = Vendor(**vendor_data.dict())
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
return vendor
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ BAD: HTTPException in service
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data: VendorCreate):
|
||||
if self._vendor_exists(db, vendor_data.subdomain):
|
||||
# ❌ Service shouldn't know about HTTP!
|
||||
raise HTTPException(status_code=409, detail="Vendor exists")
|
||||
```
|
||||
|
||||
### Rule SVC-002: Create Custom Exception Classes
|
||||
|
||||
**Don't use generic `Exception`. Create specific domain exceptions.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Specific exceptions
|
||||
class VendorError(Exception):
|
||||
"""Base exception for vendor-related errors"""
|
||||
pass
|
||||
|
||||
class VendorNotFoundError(VendorError):
|
||||
"""Raised when vendor is not found"""
|
||||
pass
|
||||
|
||||
class VendorAlreadyExistsError(VendorError):
|
||||
"""Raised when vendor already exists"""
|
||||
pass
|
||||
|
||||
class VendorService:
|
||||
def get_vendor(self, db: Session, vendor_code: str):
|
||||
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundError(f"Vendor '{vendor_code}' not found")
|
||||
return vendor
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ BAD: Generic Exception
|
||||
class VendorService:
|
||||
def get_vendor(self, db: Session, vendor_code: str):
|
||||
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code).first()
|
||||
if not vendor:
|
||||
raise Exception("Vendor not found") # ❌ Too generic!
|
||||
return vendor
|
||||
```
|
||||
|
||||
### Rule SVC-003: Database Session as Parameter
|
||||
|
||||
**Services should receive database session as parameter, not create it internally.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: db session as parameter
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data: VendorCreate):
|
||||
vendor = Vendor(**vendor_data.dict())
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
def _vendor_exists(self, db: Session, subdomain: str) -> bool:
|
||||
return db.query(Vendor).filter(Vendor.subdomain == subdomain).first() is not None
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ BAD: Creating session internally
|
||||
class VendorService:
|
||||
def create_vendor(self, vendor_data: VendorCreate):
|
||||
# ❌ Don't create session here - makes testing hard
|
||||
db = SessionLocal()
|
||||
vendor = Vendor(**vendor_data.dict())
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
return vendor
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Testability (can inject mock session)
|
||||
- Transaction control (caller controls commit/rollback)
|
||||
- Resource management (caller handles session lifecycle)
|
||||
|
||||
### Rule SVC-004: Use Pydantic for Input Validation
|
||||
|
||||
**Service methods should accept Pydantic models for complex inputs.**
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Pydantic model ensures validation
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data: VendorCreate):
|
||||
# vendor_data is already validated by Pydantic
|
||||
vendor = Vendor(**vendor_data.dict())
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
return vendor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Patterns
|
||||
|
||||
### Rule MDL-001: SQLAlchemy for Database Models
|
||||
|
||||
```python
|
||||
# ✅ GOOD: SQLAlchemy model
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from app.database import Base
|
||||
|
||||
class Vendor(Base):
|
||||
__tablename__ = "vendors"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
subdomain = Column(String(100), unique=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
```
|
||||
|
||||
### Rule MDL-002: Separate Pydantic from SQLAlchemy
|
||||
|
||||
**NEVER mix SQLAlchemy and Pydantic in the same class.**
|
||||
|
||||
```python
|
||||
# ❌ BAD: Mixing SQLAlchemy and Pydantic
|
||||
class Vendor(Base, BaseModel): # ❌ Don't do this!
|
||||
__tablename__ = "vendors"
|
||||
name: str = Column(String(200))
|
||||
```
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Separate models
|
||||
# Database model (app/models/vendor.py)
|
||||
class Vendor(Base):
|
||||
__tablename__ = "vendors"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
|
||||
# API model (app/api/v1/admin/vendors.py)
|
||||
class VendorCreate(BaseModel):
|
||||
name: str = Field(..., max_length=200)
|
||||
|
||||
class VendorResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exception Handling
|
||||
|
||||
### Rule EXC-001: Domain-Specific Exceptions
|
||||
|
||||
**Create exception hierarchy in `app/exceptions/`**
|
||||
|
||||
```python
|
||||
# app/exceptions/vendor_exceptions.py
|
||||
|
||||
class VendorError(Exception):
|
||||
"""Base exception for vendor domain"""
|
||||
pass
|
||||
|
||||
class VendorNotFoundError(VendorError):
|
||||
"""Vendor does not exist"""
|
||||
pass
|
||||
|
||||
class VendorAlreadyExistsError(VendorError):
|
||||
"""Vendor already exists"""
|
||||
pass
|
||||
|
||||
class VendorValidationError(VendorError):
|
||||
"""Vendor data validation failed"""
|
||||
pass
|
||||
```
|
||||
|
||||
### Rule EXC-002: Never Use Bare Except
|
||||
|
||||
```python
|
||||
# ❌ BAD: Bare except
|
||||
try:
|
||||
result = do_something()
|
||||
except: # ❌ Catches EVERYTHING including KeyboardInterrupt!
|
||||
pass
|
||||
```
|
||||
|
||||
```python
|
||||
# ✅ GOOD: Specific exceptions
|
||||
try:
|
||||
result = do_something()
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JavaScript Patterns
|
||||
|
||||
### Rule JS-001: Use apiClient Directly
|
||||
|
||||
```javascript
|
||||
// ✅ GOOD
|
||||
const vendors = await apiClient.get('/api/v1/vendors');
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ❌ BAD
|
||||
const vendors = await window.apiClient.get('/api/v1/vendors');
|
||||
```
|
||||
|
||||
### Rule JS-002: Use Centralized Logger
|
||||
|
||||
```javascript
|
||||
// ✅ GOOD: Centralized logger
|
||||
const vendorLog = window.LogConfig.createLogger('vendors');
|
||||
vendorLog.info('Loading vendors...');
|
||||
vendorLog.error('Failed to load vendors:', error);
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ❌ BAD: console
|
||||
console.log('Loading vendors...'); // ❌ Use logger instead
|
||||
```
|
||||
|
||||
### Rule JS-003: Alpine Components Pattern
|
||||
|
||||
```javascript
|
||||
// ✅ GOOD: Proper Alpine component
|
||||
function vendorsManager() {
|
||||
return {
|
||||
// ✅ Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// ✅ Set page identifier for sidebar
|
||||
currentPage: 'vendors',
|
||||
|
||||
// Component state
|
||||
vendors: [],
|
||||
loading: false,
|
||||
|
||||
// ✅ Init with guard
|
||||
async init() {
|
||||
if (window._vendorsInitialized) {
|
||||
return;
|
||||
}
|
||||
window._vendorsInitialized = true;
|
||||
|
||||
await this.loadVendors();
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
### Running the Validator
|
||||
|
||||
```bash
|
||||
# Validate entire codebase
|
||||
python scripts/validate_architecture.py
|
||||
|
||||
# Validate specific directory
|
||||
python scripts/validate_architecture.py app/api/
|
||||
|
||||
# Verbose output with context
|
||||
python scripts/validate_architecture.py --verbose
|
||||
|
||||
# Errors only (suppress warnings)
|
||||
python scripts/validate_architecture.py --errors-only
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
Install pre-commit to validate automatically before commits:
|
||||
|
||||
```bash
|
||||
# Install pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Setup hooks
|
||||
pre-commit install
|
||||
|
||||
# Run manually
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Add to your CI pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
- name: Validate Architecture
|
||||
run: |
|
||||
python scripts/validate_architecture.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Layer | Responsibility | Can Use | Cannot Use |
|
||||
|-------|---------------|---------|------------|
|
||||
| **API Endpoints** | HTTP handling, auth, validation | Pydantic, HTTPException, Depends | Direct DB access, business logic |
|
||||
| **Services** | Business logic, orchestration | DB session, domain exceptions | HTTPException, HTTP concepts |
|
||||
| **Models** | Data structure | SQLAlchemy OR Pydantic | Mixing both in same class |
|
||||
|
||||
---
|
||||
|
||||
## Common Violations and Fixes
|
||||
|
||||
### Violation: Business logic in endpoint
|
||||
|
||||
```python
|
||||
# Before
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
db_vendor = Vendor(**vendor.dict())
|
||||
db.add(db_vendor)
|
||||
db.commit()
|
||||
return db_vendor
|
||||
```
|
||||
|
||||
```python
|
||||
# After ✅
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return vendor_service.create_vendor(db, vendor)
|
||||
except VendorAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
```
|
||||
|
||||
### Violation: HTTPException in service
|
||||
|
||||
```python
|
||||
# Before
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data):
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Exists")
|
||||
```
|
||||
|
||||
```python
|
||||
# After ✅
|
||||
class VendorService:
|
||||
def create_vendor(self, db: Session, vendor_data):
|
||||
if exists:
|
||||
raise VendorAlreadyExistsError("Vendor already exists")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Remember:** These patterns are enforced automatically. Run `python scripts/validate_architecture.py` before committing!
|
||||
@@ -29,6 +29,7 @@ nav:
|
||||
# ============================================
|
||||
- Architecture:
|
||||
- Overview: architecture/overview.md
|
||||
- Architecture Patterns: architecture/architecture-patterns.md
|
||||
- Multi-Tenant System: architecture/multi-tenant.md
|
||||
- Middleware Stack: architecture/middleware.md
|
||||
- Request Flow: architecture/request-flow.md
|
||||
|
||||
227
scripts/README.md
Normal file
227
scripts/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Architecture Validation Scripts
|
||||
|
||||
This directory contains scripts for validating and enforcing architectural patterns across the codebase.
|
||||
|
||||
## Architecture Validator
|
||||
|
||||
### Overview
|
||||
|
||||
`validate_architecture.py` is an automated tool that checks the codebase against architectural rules defined in `.architecture-rules.yaml`.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **API Endpoint Validation**: Ensures proper separation of concerns, Pydantic usage, exception handling
|
||||
- **Service Layer Validation**: Enforces domain exceptions, database session patterns
|
||||
- **Model Validation**: Checks proper separation of SQLAlchemy and Pydantic models
|
||||
- **Exception Handling**: Validates proper exception patterns
|
||||
- **JavaScript Patterns**: Enforces coding standards for frontend code
|
||||
- **Template Validation**: Ensures templates follow required patterns
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Validate entire codebase
|
||||
python scripts/validate_architecture.py
|
||||
|
||||
# Validate specific directory
|
||||
python scripts/validate_architecture.py app/api/
|
||||
|
||||
# Verbose output with code context
|
||||
python scripts/validate_architecture.py --verbose
|
||||
|
||||
# Show only errors (suppress warnings)
|
||||
python scripts/validate_architecture.py --errors-only
|
||||
|
||||
# Use custom config file
|
||||
python scripts/validate_architecture.py --config custom-rules.yaml
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
|
||||
- `0`: Validation passed (warnings allowed)
|
||||
- `1`: Validation failed (errors found)
|
||||
|
||||
### Integration with Pre-commit
|
||||
|
||||
Install pre-commit hooks to run validation automatically:
|
||||
|
||||
```bash
|
||||
# Install pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Setup hooks
|
||||
pre-commit install
|
||||
|
||||
# Run manually on all files
|
||||
pre-commit run --all-files
|
||||
|
||||
# Run on staged files only
|
||||
pre-commit run
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The validation rules are defined in `.architecture-rules.yaml` at the project root. This file contains:
|
||||
|
||||
- **Core Principles**: Separation of concerns, type safety, exception handling
|
||||
- **API Endpoint Rules**: (API-001 through API-004)
|
||||
- **Service Layer Rules**: (SVC-001 through SVC-004)
|
||||
- **Model Rules**: (MDL-001 through MDL-002)
|
||||
- **Exception Rules**: (EXC-001 through EXC-002)
|
||||
- **JavaScript Rules**: (JS-001 through JS-003)
|
||||
- **Template Rules**: (TPL-001)
|
||||
|
||||
### Rule Categories
|
||||
|
||||
#### API Endpoint Rules
|
||||
|
||||
- **API-001**: Use Pydantic models for request/response
|
||||
- **API-002**: No business logic in endpoints
|
||||
- **API-003**: Proper exception handling (catch and convert to HTTPException)
|
||||
- **API-004**: Authentication on protected endpoints
|
||||
|
||||
#### Service Layer Rules
|
||||
|
||||
- **SVC-001**: No HTTPException in services (use domain exceptions)
|
||||
- **SVC-002**: Create custom exception classes (avoid generic Exception)
|
||||
- **SVC-003**: Database session as parameter (not created internally)
|
||||
- **SVC-004**: Use Pydantic for input validation
|
||||
|
||||
#### Model Rules
|
||||
|
||||
- **MDL-001**: Use SQLAlchemy Base for database models
|
||||
- **MDL-002**: Separate Pydantic from SQLAlchemy models
|
||||
|
||||
#### Exception Rules
|
||||
|
||||
- **EXC-001**: Define custom exceptions in exceptions module
|
||||
- **EXC-002**: Never use bare except
|
||||
|
||||
#### JavaScript Rules
|
||||
|
||||
- **JS-001**: Use apiClient directly (not window.apiClient)
|
||||
- **JS-002**: Use centralized logger (not console)
|
||||
- **JS-003**: Alpine components must spread ...data()
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
🔍 Starting architecture validation...
|
||||
|
||||
📡 Validating API endpoints...
|
||||
🔧 Validating service layer...
|
||||
📦 Validating models...
|
||||
⚠️ Validating exception handling...
|
||||
🟨 Validating JavaScript...
|
||||
📄 Validating templates...
|
||||
|
||||
================================================================================
|
||||
📊 ARCHITECTURE VALIDATION REPORT
|
||||
================================================================================
|
||||
|
||||
Files checked: 145
|
||||
Total violations: 3
|
||||
|
||||
❌ ERRORS (2):
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[API-002] Endpoint must NOT contain business logic
|
||||
File: app/api/v1/admin/vendors.py:45
|
||||
Issue: Database operations should be in service layer
|
||||
💡 Suggestion: Move database operations to service layer
|
||||
|
||||
[SVC-001] Service must NOT raise HTTPException
|
||||
File: app/services/vendor_service.py:78
|
||||
Issue: Service raises HTTPException - use domain exceptions instead
|
||||
💡 Suggestion: Create custom exception class (e.g., VendorNotFoundError) and raise that
|
||||
|
||||
⚠️ WARNINGS (1):
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[JS-001] Use apiClient directly
|
||||
File: static/admin/js/vendors.js:23
|
||||
Issue: Use apiClient directly instead of window.apiClient
|
||||
💡 Suggestion: Replace window.apiClient with apiClient
|
||||
|
||||
================================================================================
|
||||
❌ VALIDATION FAILED - Fix errors before committing
|
||||
================================================================================
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Add to your CI pipeline (GitHub Actions example):
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
validate-architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install pyyaml
|
||||
|
||||
- name: Validate Architecture
|
||||
run: |
|
||||
python scripts/validate_architecture.py
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
For detailed architectural patterns and examples, see:
|
||||
|
||||
- [Architecture Patterns Documentation](../docs/architecture/architecture-patterns.md)
|
||||
- [Architecture Rules Config](../.architecture-rules.yaml)
|
||||
|
||||
### Extending the Validator
|
||||
|
||||
To add new rules:
|
||||
|
||||
1. Add rule definition to `.architecture-rules.yaml`
|
||||
2. Implement validation logic in `validate_architecture.py`
|
||||
3. Add examples to `docs/architecture/architecture-patterns.md`
|
||||
4. Test with `python scripts/validate_architecture.py --verbose`
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Issue**: Validator reports false positives
|
||||
|
||||
**Solution**: Add exception patterns to `.architecture-rules.yaml` under `ignore` section
|
||||
|
||||
**Issue**: Validator doesn't catch a specific violation
|
||||
|
||||
**Solution**: The rule might not be implemented yet. Check `.architecture-rules.yaml` and `validate_architecture.py` to add the rule.
|
||||
|
||||
**Issue**: Pre-commit hook fails but manual run passes
|
||||
|
||||
**Solution**: Ensure pre-commit is using the same Python environment:
|
||||
```bash
|
||||
pre-commit clean
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
When adding new architectural patterns:
|
||||
|
||||
1. Document the pattern in `docs/architecture/architecture-patterns.md`
|
||||
2. Add validation rule to `.architecture-rules.yaml`
|
||||
3. Implement validation in `validate_architecture.py`
|
||||
4. Test thoroughly with existing codebase
|
||||
5. Update this README with the new rule
|
||||
|
||||
---
|
||||
|
||||
**Questions?** See the [Architecture Patterns Documentation](../docs/architecture/architecture-patterns.md)
|
||||
652
scripts/validate_architecture.py
Executable file
652
scripts/validate_architecture.py
Executable file
@@ -0,0 +1,652 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Architecture Validator
|
||||
======================
|
||||
Validates code against architectural rules defined in .architecture-rules.yaml
|
||||
|
||||
This script checks that the codebase follows key architectural decisions:
|
||||
- Separation of concerns (routes vs services)
|
||||
- Proper exception handling (domain exceptions vs HTTPException)
|
||||
- Correct use of Pydantic vs SQLAlchemy models
|
||||
- Service layer patterns
|
||||
- API endpoint patterns
|
||||
|
||||
Usage:
|
||||
python scripts/validate_architecture.py # Check all files
|
||||
python scripts/validate_architecture.py --fix # Auto-fix where possible
|
||||
python scripts/validate_architecture.py --verbose # Detailed output
|
||||
python scripts/validate_architecture.py app/api/ # Check specific directory
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import yaml
|
||||
|
||||
|
||||
class Severity(Enum):
|
||||
"""Validation severity levels"""
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Violation:
|
||||
"""Represents an architectural rule violation"""
|
||||
rule_id: str
|
||||
rule_name: str
|
||||
severity: Severity
|
||||
file_path: Path
|
||||
line_number: int
|
||||
message: str
|
||||
context: str = ""
|
||||
suggestion: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Results of architecture validation"""
|
||||
violations: List[Violation] = field(default_factory=list)
|
||||
files_checked: int = 0
|
||||
rules_applied: int = 0
|
||||
|
||||
def has_errors(self) -> bool:
|
||||
"""Check if there are any error-level violations"""
|
||||
return any(v.severity == Severity.ERROR for v in self.violations)
|
||||
|
||||
def has_warnings(self) -> bool:
|
||||
"""Check if there are any warning-level violations"""
|
||||
return any(v.severity == Severity.WARNING for v in self.violations)
|
||||
|
||||
|
||||
class ArchitectureValidator:
|
||||
"""Main validator class"""
|
||||
|
||||
def __init__(self, config_path: Path, verbose: bool = False):
|
||||
"""Initialize validator with configuration"""
|
||||
self.config_path = config_path
|
||||
self.verbose = verbose
|
||||
self.config = self._load_config()
|
||||
self.result = ValidationResult()
|
||||
self.project_root = Path.cwd()
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load validation rules from YAML config"""
|
||||
if not self.config_path.exists():
|
||||
print(f"❌ Configuration file not found: {self.config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(self.config_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
print(f"📋 Loaded architecture rules: {config.get('project', 'unknown')}")
|
||||
return config
|
||||
|
||||
def validate_all(self, target_path: Path = None) -> ValidationResult:
|
||||
"""Validate all files or specific path"""
|
||||
print("\n🔍 Starting architecture validation...\n")
|
||||
|
||||
target = target_path or self.project_root
|
||||
|
||||
# Validate API endpoints
|
||||
self._validate_api_endpoints(target)
|
||||
|
||||
# Validate service layer
|
||||
self._validate_service_layer(target)
|
||||
|
||||
# Validate models
|
||||
self._validate_models(target)
|
||||
|
||||
# Validate exception handling
|
||||
self._validate_exceptions(target)
|
||||
|
||||
# Validate JavaScript
|
||||
self._validate_javascript(target)
|
||||
|
||||
# Validate templates
|
||||
self._validate_templates(target)
|
||||
|
||||
return self.result
|
||||
|
||||
def _validate_api_endpoints(self, target_path: Path):
|
||||
"""Validate API endpoint rules (API-001, API-002, API-003, API-004)"""
|
||||
print("📡 Validating API endpoints...")
|
||||
|
||||
api_files = list(target_path.glob("app/api/v1/**/*.py"))
|
||||
self.result.files_checked += len(api_files)
|
||||
|
||||
for file_path in api_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# API-001: Check for Pydantic model usage
|
||||
self._check_pydantic_usage(file_path, content, lines)
|
||||
|
||||
# API-002: Check for business logic in endpoints
|
||||
self._check_no_business_logic_in_endpoints(file_path, content, lines)
|
||||
|
||||
# API-003: Check exception handling
|
||||
self._check_endpoint_exception_handling(file_path, content, lines)
|
||||
|
||||
# API-004: Check authentication
|
||||
self._check_endpoint_authentication(file_path, content, lines)
|
||||
|
||||
def _check_pydantic_usage(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""API-001: Ensure endpoints use Pydantic models"""
|
||||
rule = self._get_rule("API-001")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
# Check for response_model in route decorators
|
||||
route_pattern = r'@router\.(get|post|put|delete|patch)'
|
||||
dict_return_pattern = r'return\s+\{.*\}'
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Check for dict returns in endpoints
|
||||
if re.search(route_pattern, line):
|
||||
# Look ahead for function body
|
||||
func_start = i
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Find function body
|
||||
for j in range(func_start, min(func_start + 20, len(lines))):
|
||||
if j >= len(lines):
|
||||
break
|
||||
|
||||
func_line = lines[j]
|
||||
if re.search(dict_return_pattern, func_line):
|
||||
self._add_violation(
|
||||
rule_id="API-001",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=j + 1,
|
||||
message="Endpoint returns raw dict instead of Pydantic model",
|
||||
context=func_line.strip(),
|
||||
suggestion="Define a Pydantic response model and use response_model parameter"
|
||||
)
|
||||
|
||||
def _check_no_business_logic_in_endpoints(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""API-002: Ensure no business logic in endpoints"""
|
||||
rule = self._get_rule("API-002")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
anti_patterns = [
|
||||
(r'db\.add\(', "Database operations should be in service layer"),
|
||||
(r'db\.commit\(\)', "Database commits should be in service layer"),
|
||||
(r'db\.query\(', "Database queries should be in service layer"),
|
||||
(r'db\.execute\(', "Database operations should be in service layer"),
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip service method calls (allowed)
|
||||
if '_service.' in line or 'service.' in line:
|
||||
continue
|
||||
|
||||
for pattern, message in anti_patterns:
|
||||
if re.search(pattern, line):
|
||||
self._add_violation(
|
||||
rule_id="API-002",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=message,
|
||||
context=line.strip(),
|
||||
suggestion="Move database operations to service layer"
|
||||
)
|
||||
|
||||
def _check_endpoint_exception_handling(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""API-003: Check proper exception handling in endpoints"""
|
||||
rule = self._get_rule("API-003")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
# Parse file to check for try/except in route handlers
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError:
|
||||
return
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
# Check if it's a route handler
|
||||
has_router_decorator = any(
|
||||
isinstance(d, ast.Call) and
|
||||
isinstance(d.func, ast.Attribute) and
|
||||
getattr(d.func.value, 'id', None) == 'router'
|
||||
for d in node.decorator_list
|
||||
)
|
||||
|
||||
if has_router_decorator:
|
||||
# Check if function body has try/except
|
||||
has_try_except = any(
|
||||
isinstance(child, ast.Try)
|
||||
for child in ast.walk(node)
|
||||
)
|
||||
|
||||
# Check if function calls service methods
|
||||
has_service_call = any(
|
||||
isinstance(child, ast.Call) and
|
||||
isinstance(child.func, ast.Attribute) and
|
||||
'service' in getattr(child.func.value, 'id', '').lower()
|
||||
for child in ast.walk(node)
|
||||
)
|
||||
|
||||
if has_service_call and not has_try_except:
|
||||
self._add_violation(
|
||||
rule_id="API-003",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=node.lineno,
|
||||
message=f"Endpoint '{node.name}' calls service but lacks exception handling",
|
||||
context=f"def {node.name}(...)",
|
||||
suggestion="Wrap service calls in try/except and convert to HTTPException"
|
||||
)
|
||||
|
||||
def _check_endpoint_authentication(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""API-004: Check authentication on endpoints"""
|
||||
rule = self._get_rule("API-004")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
# This is a warning-level check
|
||||
# Look for endpoints without Depends(get_current_*)
|
||||
for i, line in enumerate(lines, 1):
|
||||
if '@router.' in line and ('post' in line or 'put' in line or 'delete' in line):
|
||||
# Check next 5 lines for auth
|
||||
has_auth = False
|
||||
for j in range(i, min(i + 5, len(lines))):
|
||||
if 'Depends(get_current_' in lines[j]:
|
||||
has_auth = True
|
||||
break
|
||||
|
||||
if not has_auth and 'include_in_schema=False' not in ' '.join(lines[i:i+5]):
|
||||
self._add_violation(
|
||||
rule_id="API-004",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Endpoint may be missing authentication",
|
||||
context=line.strip(),
|
||||
suggestion="Add Depends(get_current_user) or similar if endpoint should be protected"
|
||||
)
|
||||
|
||||
def _validate_service_layer(self, target_path: Path):
|
||||
"""Validate service layer rules (SVC-001, SVC-002, SVC-003, SVC-004)"""
|
||||
print("🔧 Validating service layer...")
|
||||
|
||||
service_files = list(target_path.glob("app/services/**/*.py"))
|
||||
self.result.files_checked += len(service_files)
|
||||
|
||||
for file_path in service_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# SVC-001: No HTTPException in services
|
||||
self._check_no_http_exception_in_services(file_path, content, lines)
|
||||
|
||||
# SVC-002: Proper exception handling
|
||||
self._check_service_exceptions(file_path, content, lines)
|
||||
|
||||
# SVC-003: DB session as parameter
|
||||
self._check_db_session_parameter(file_path, content, lines)
|
||||
|
||||
def _check_no_http_exception_in_services(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""SVC-001: Services must not raise HTTPException"""
|
||||
rule = self._get_rule("SVC-001")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'raise HTTPException' in line:
|
||||
self._add_violation(
|
||||
rule_id="SVC-001",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Service raises HTTPException - use domain exceptions instead",
|
||||
context=line.strip(),
|
||||
suggestion="Create custom exception class (e.g., VendorNotFoundError) and raise that"
|
||||
)
|
||||
|
||||
if 'from fastapi import HTTPException' in line or 'from fastapi.exceptions import HTTPException' in line:
|
||||
self._add_violation(
|
||||
rule_id="SVC-001",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Service imports HTTPException - services should not know about HTTP",
|
||||
context=line.strip(),
|
||||
suggestion="Remove HTTPException import and use domain exceptions"
|
||||
)
|
||||
|
||||
def _check_service_exceptions(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""SVC-002: Check for proper exception handling"""
|
||||
rule = self._get_rule("SVC-002")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Check for generic Exception raises
|
||||
if re.match(r'\s*raise Exception\(', line):
|
||||
self._add_violation(
|
||||
rule_id="SVC-002",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Service raises generic Exception - use specific domain exception",
|
||||
context=line.strip(),
|
||||
suggestion="Create custom exception class for this error case"
|
||||
)
|
||||
|
||||
def _check_db_session_parameter(self, file_path: Path, content: str, lines: List[str]):
|
||||
"""SVC-003: Service methods should accept db session as parameter"""
|
||||
rule = self._get_rule("SVC-003")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
# Check for SessionLocal() creation in service files
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'SessionLocal()' in line and 'class' not in line:
|
||||
self._add_violation(
|
||||
rule_id="SVC-003",
|
||||
rule_name=rule['name'],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Service creates database session internally",
|
||||
context=line.strip(),
|
||||
suggestion="Accept db: Session as method parameter instead"
|
||||
)
|
||||
|
||||
def _validate_models(self, target_path: Path):
|
||||
"""Validate model rules"""
|
||||
print("📦 Validating models...")
|
||||
|
||||
model_files = list(target_path.glob("app/models/**/*.py"))
|
||||
self.result.files_checked += len(model_files)
|
||||
|
||||
# Basic validation - can be extended
|
||||
for file_path in model_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# Check for mixing SQLAlchemy and Pydantic
|
||||
for i, line in enumerate(lines, 1):
|
||||
if re.search(r'class.*\(Base.*,.*BaseModel.*\)', line):
|
||||
self._add_violation(
|
||||
rule_id="MDL-002",
|
||||
rule_name="Separate SQLAlchemy and Pydantic models",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Model mixes SQLAlchemy Base and Pydantic BaseModel",
|
||||
context=line.strip(),
|
||||
suggestion="Keep SQLAlchemy models and Pydantic models separate"
|
||||
)
|
||||
|
||||
def _validate_exceptions(self, target_path: Path):
|
||||
"""Validate exception handling patterns"""
|
||||
print("⚠️ Validating exception handling...")
|
||||
|
||||
py_files = list(target_path.glob("**/*.py"))
|
||||
|
||||
for file_path in py_files:
|
||||
if self._should_ignore_file(file_path):
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# EXC-002: Check for bare except
|
||||
for i, line in enumerate(lines, 1):
|
||||
if re.match(r'\s*except\s*:', line):
|
||||
self._add_violation(
|
||||
rule_id="EXC-002",
|
||||
rule_name="No bare except clauses",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Bare except clause catches all exceptions including system exits",
|
||||
context=line.strip(),
|
||||
suggestion="Specify exception type: except ValueError: or except Exception:"
|
||||
)
|
||||
|
||||
def _validate_javascript(self, target_path: Path):
|
||||
"""Validate JavaScript patterns"""
|
||||
print("🟨 Validating JavaScript...")
|
||||
|
||||
js_files = list(target_path.glob("static/admin/js/**/*.js"))
|
||||
self.result.files_checked += len(js_files)
|
||||
|
||||
for file_path in js_files:
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# JS-001: Check for window.apiClient
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'window.apiClient' in line and '//' not in line[:line.find('window.apiClient')] if 'window.apiClient' in line else True:
|
||||
self._add_violation(
|
||||
rule_id="JS-001",
|
||||
rule_name="Use apiClient directly",
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Use apiClient directly instead of window.apiClient",
|
||||
context=line.strip(),
|
||||
suggestion="Replace window.apiClient with apiClient"
|
||||
)
|
||||
|
||||
# JS-002: Check for console usage
|
||||
for i, line in enumerate(lines, 1):
|
||||
if re.search(r'console\.(log|warn|error)', line):
|
||||
# Skip if it's a comment or bootstrap message
|
||||
if '//' in line or '✅' in line or 'eslint-disable' in line:
|
||||
continue
|
||||
|
||||
self._add_violation(
|
||||
rule_id="JS-002",
|
||||
rule_name="Use centralized logger",
|
||||
severity=Severity.WARNING,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Use centralized logger instead of console",
|
||||
context=line.strip()[:80],
|
||||
suggestion="Use window.LogConfig.createLogger('moduleName')"
|
||||
)
|
||||
|
||||
def _validate_templates(self, target_path: Path):
|
||||
"""Validate template patterns"""
|
||||
print("📄 Validating templates...")
|
||||
|
||||
template_files = list(target_path.glob("app/templates/admin/**/*.html"))
|
||||
self.result.files_checked += len(template_files)
|
||||
|
||||
for file_path in template_files:
|
||||
# Skip base template and partials
|
||||
if 'base.html' in file_path.name or 'partials' in str(file_path):
|
||||
continue
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# TPL-001: Check for extends
|
||||
has_extends = any('{% extends' in line and 'admin/base.html' in line for line in lines)
|
||||
|
||||
if not has_extends:
|
||||
self._add_violation(
|
||||
rule_id="TPL-001",
|
||||
rule_name="Templates must extend base",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=1,
|
||||
message="Admin template does not extend admin/base.html",
|
||||
context=file_path.name,
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top"
|
||||
)
|
||||
|
||||
def _get_rule(self, rule_id: str) -> Dict[str, Any]:
|
||||
"""Get rule configuration by ID"""
|
||||
# Look in different rule categories
|
||||
for category in ['api_endpoint_rules', 'service_layer_rules', 'model_rules',
|
||||
'exception_rules', 'javascript_rules', 'template_rules']:
|
||||
rules = self.config.get(category, [])
|
||||
for rule in rules:
|
||||
if rule.get('id') == rule_id:
|
||||
return rule
|
||||
return None
|
||||
|
||||
def _should_ignore_file(self, file_path: Path) -> bool:
|
||||
"""Check if file should be ignored"""
|
||||
ignore_patterns = self.config.get('ignore', {}).get('files', [])
|
||||
|
||||
for pattern in ignore_patterns:
|
||||
if file_path.match(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _add_violation(self, rule_id: str, rule_name: str, severity: Severity,
|
||||
file_path: Path, line_number: int, message: str,
|
||||
context: str = "", suggestion: str = ""):
|
||||
"""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 print_report(self):
|
||||
"""Print validation report"""
|
||||
print("\n" + "=" * 80)
|
||||
print("📊 ARCHITECTURE VALIDATION REPORT")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
print(f"Files checked: {self.result.files_checked}")
|
||||
print(f"Total violations: {len(self.result.violations)}\n")
|
||||
|
||||
# Group by severity
|
||||
errors = [v for v in self.result.violations if v.severity == Severity.ERROR]
|
||||
warnings = [v for v in self.result.violations if v.severity == Severity.WARNING]
|
||||
|
||||
if errors:
|
||||
print(f"\n❌ ERRORS ({len(errors)}):")
|
||||
print("-" * 80)
|
||||
for violation in errors:
|
||||
self._print_violation(violation)
|
||||
|
||||
if warnings:
|
||||
print(f"\n⚠️ WARNINGS ({len(warnings)}):")
|
||||
print("-" * 80)
|
||||
for violation in warnings:
|
||||
self._print_violation(violation)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 80)
|
||||
if self.result.has_errors():
|
||||
print("❌ VALIDATION FAILED - Fix errors before committing")
|
||||
print("=" * 80)
|
||||
return 1
|
||||
elif self.result.has_warnings():
|
||||
print("⚠️ VALIDATION PASSED WITH WARNINGS")
|
||||
print("=" * 80)
|
||||
return 0
|
||||
else:
|
||||
print("✅ VALIDATION PASSED - No violations found")
|
||||
print("=" * 80)
|
||||
return 0
|
||||
|
||||
def _print_violation(self, v: Violation):
|
||||
"""Print a single violation"""
|
||||
rel_path = v.file_path.relative_to(self.project_root) if self.project_root in v.file_path.parents else v.file_path
|
||||
|
||||
print(f"\n [{v.rule_id}] {v.rule_name}")
|
||||
print(f" File: {rel_path}:{v.line_number}")
|
||||
print(f" Issue: {v.message}")
|
||||
|
||||
if v.context and self.verbose:
|
||||
print(f" Context: {v.context}")
|
||||
|
||||
if v.suggestion:
|
||||
print(f" 💡 Suggestion: {v.suggestion}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate architecture patterns in codebase",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'path',
|
||||
nargs='?',
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Path to validate (default: current directory)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
type=Path,
|
||||
default=Path.cwd() / '.architecture-rules.yaml',
|
||||
help="Path to architecture rules config (default: .architecture-rules.yaml)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help="Show detailed output including context"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--errors-only',
|
||||
action='store_true',
|
||||
help="Only show errors, suppress warnings"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create validator
|
||||
validator = ArchitectureValidator(args.config, verbose=args.verbose)
|
||||
|
||||
# Run validation
|
||||
result = validator.validate_all(args.path)
|
||||
|
||||
# Print report
|
||||
exit_code = validator.print_report()
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user