feat: add cross-module import validation rules to architecture validator
Add IMPORT-001 and IMPORT-002 rules to enforce module separation: - IMPORT-001 (ERROR): Core module imports from optional module - IMPORT-002 (WARNING): Optional module imports without declared dependency The validator now: - Detects imports from optional modules in core modules (contracts, core, tenancy, cms, customers, billing, payments, messaging) - Detects imports between optional modules without dependency declaration - Skips TYPE_CHECKING blocks (valid for type hints) - Skips docstring examples (false positive prevention) - Suggests using provider patterns (MetricsProvider, WidgetProvider) This enforces the architectural rule: "Core modules NEVER import from optional modules" as documented in cross-module-import-rules.md. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,9 @@ class ArchitectureValidator:
|
||||
# Validate module structure
|
||||
self._validate_modules(target)
|
||||
|
||||
# Validate cross-module imports (core cannot import from optional)
|
||||
self._validate_cross_module_imports(target)
|
||||
|
||||
# Validate legacy locations (must be in modules)
|
||||
self._validate_legacy_locations(target)
|
||||
|
||||
@@ -4462,6 +4465,195 @@ class ArchitectureValidator:
|
||||
suggestion=f"Add 'def get_{module_name}_module_with_routers()' function",
|
||||
)
|
||||
|
||||
def _validate_cross_module_imports(self, target_path: Path):
|
||||
"""
|
||||
Validate cross-module import rules (IMPORT-001 to IMPORT-003).
|
||||
|
||||
Core architectural rule: Core modules NEVER import from optional modules.
|
||||
This ensures optional modules are truly optional and can be disabled without breaking the app.
|
||||
|
||||
Rules:
|
||||
- IMPORT-001: Core module imports from optional module (ERROR)
|
||||
- IMPORT-002: Optional module imports from unrelated optional module (WARNING)
|
||||
- IMPORT-003: Suggest using protocol pattern instead of direct import (INFO)
|
||||
|
||||
Uses provider patterns (MetricsProvider, WidgetProvider) for cross-module data.
|
||||
See docs/architecture/cross-module-import-rules.md for details.
|
||||
"""
|
||||
print("🔗 Validating cross-module imports...")
|
||||
|
||||
modules_path = target_path / "app" / "modules"
|
||||
if not modules_path.exists():
|
||||
return
|
||||
|
||||
# Define core and optional modules
|
||||
# Core modules are always enabled and cannot depend on optional modules
|
||||
CORE_MODULES = {
|
||||
"contracts", # Protocols and interfaces (can import from nothing)
|
||||
"core", # Dashboard, settings, profile
|
||||
"tenancy", # Platform, company, vendor, admin user management
|
||||
"cms", # Content pages, media library
|
||||
"customers", # Customer database
|
||||
"billing", # Subscriptions, tier limits
|
||||
"payments", # Payment gateway integrations
|
||||
"messaging", # Email, notifications
|
||||
}
|
||||
|
||||
# Optional modules can be enabled/disabled per platform
|
||||
OPTIONAL_MODULES = {
|
||||
"analytics", # Reports, dashboards
|
||||
"cart", # Shopping cart
|
||||
"catalog", # Product browsing
|
||||
"checkout", # Cart-to-order conversion
|
||||
"inventory", # Stock management
|
||||
"loyalty", # Loyalty programs
|
||||
"marketplace", # Letzshop integration
|
||||
"orders", # Order management
|
||||
}
|
||||
|
||||
# contracts module cannot import from any other module
|
||||
CONTRACTS_FORBIDDEN_IMPORTS = CORE_MODULES | OPTIONAL_MODULES - {"contracts"}
|
||||
|
||||
# Check each module directory
|
||||
module_dirs = [
|
||||
d for d in modules_path.iterdir()
|
||||
if d.is_dir() and d.name != "__pycache__" and not d.name.startswith(".")
|
||||
]
|
||||
|
||||
for module_dir in module_dirs:
|
||||
module_name = module_dir.name
|
||||
|
||||
# Skip if not a recognized module
|
||||
if module_name not in CORE_MODULES and module_name not in OPTIONAL_MODULES:
|
||||
continue
|
||||
|
||||
# Check all Python files in this module
|
||||
for py_file in module_dir.rglob("*.py"):
|
||||
if "__pycache__" in str(py_file):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
lines = content.split("\n")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Track if we're inside a docstring
|
||||
in_docstring = False
|
||||
docstring_delimiter = None
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments and empty lines
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or not stripped:
|
||||
continue
|
||||
|
||||
# Track docstring boundaries
|
||||
# Count triple-quote occurrences to handle docstrings
|
||||
if '"""' in line or "'''" in line:
|
||||
# Check for triple quotes
|
||||
for delim in ['"""', "'''"]:
|
||||
count = line.count(delim)
|
||||
if count == 1:
|
||||
# Single delimiter - toggle docstring state
|
||||
if not in_docstring:
|
||||
in_docstring = True
|
||||
docstring_delimiter = delim
|
||||
elif docstring_delimiter == delim:
|
||||
in_docstring = False
|
||||
docstring_delimiter = None
|
||||
elif count == 2:
|
||||
# Two delimiters on same line (single-line docstring or open+close)
|
||||
# This doesn't change state as it opens and closes on same line
|
||||
pass
|
||||
|
||||
# Skip lines inside docstrings (documentation examples)
|
||||
if in_docstring:
|
||||
continue
|
||||
|
||||
# Skip TYPE_CHECKING blocks (these are fine for type hints)
|
||||
if "TYPE_CHECKING" in line:
|
||||
continue
|
||||
|
||||
# Look for import statements
|
||||
import_match = re.match(
|
||||
r'^\s*(?:from\s+(app\.modules\.(\w+))|import\s+(app\.modules\.(\w+)))',
|
||||
line
|
||||
)
|
||||
|
||||
if not import_match:
|
||||
continue
|
||||
|
||||
# Extract the imported module name
|
||||
imported_module = import_match.group(2) or import_match.group(4)
|
||||
|
||||
if not imported_module:
|
||||
continue
|
||||
|
||||
# Skip self-imports
|
||||
if imported_module == module_name:
|
||||
continue
|
||||
|
||||
# Skip noqa comments
|
||||
if "noqa:" in line.lower() and "import" in line.lower():
|
||||
continue
|
||||
|
||||
# contracts module cannot import from any module
|
||||
if module_name == "contracts":
|
||||
if imported_module in CONTRACTS_FORBIDDEN_IMPORTS:
|
||||
self._add_violation(
|
||||
rule_id="IMPORT-001",
|
||||
rule_name="Contracts module cannot import from other modules",
|
||||
severity=Severity.ERROR,
|
||||
file_path=py_file,
|
||||
line_number=i,
|
||||
message=f"contracts module imports from '{imported_module}' - contracts should only define protocols",
|
||||
context=stripped[:80],
|
||||
suggestion="Contracts should only import from stdlib/typing. Move implementation elsewhere.",
|
||||
)
|
||||
continue
|
||||
|
||||
# IMPORT-001: Core module importing from optional module
|
||||
if module_name in CORE_MODULES and imported_module in OPTIONAL_MODULES:
|
||||
self._add_violation(
|
||||
rule_id="IMPORT-001",
|
||||
rule_name="Core module cannot import from optional module",
|
||||
severity=Severity.ERROR,
|
||||
file_path=py_file,
|
||||
line_number=i,
|
||||
message=f"Core module '{module_name}' imports from optional module '{imported_module}'",
|
||||
context=stripped[:80],
|
||||
suggestion=f"Use provider pattern (MetricsProvider, WidgetProvider) or contracts protocol instead. See docs/architecture/cross-module-import-rules.md",
|
||||
)
|
||||
|
||||
# IMPORT-002: Optional module importing from unrelated optional module
|
||||
# This is a warning because it may indicate missing dependency declaration
|
||||
elif module_name in OPTIONAL_MODULES and imported_module in OPTIONAL_MODULES:
|
||||
# Check if there's a dependency declared (would need to read definition.py)
|
||||
definition_file = module_dir / "definition.py"
|
||||
has_dependency = False
|
||||
if definition_file.exists():
|
||||
try:
|
||||
def_content = definition_file.read_text()
|
||||
# Check if requires=[...imported_module...] or requires = [...imported_module...]
|
||||
if f'"{imported_module}"' in def_content or f"'{imported_module}'" in def_content:
|
||||
# Likely has the dependency declared
|
||||
has_dependency = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not has_dependency:
|
||||
self._add_violation(
|
||||
rule_id="IMPORT-002",
|
||||
rule_name="Optional module imports from another optional module without dependency",
|
||||
severity=Severity.WARNING,
|
||||
file_path=py_file,
|
||||
line_number=i,
|
||||
message=f"Module '{module_name}' imports from '{imported_module}' without declaring dependency",
|
||||
context=stripped[:80],
|
||||
suggestion=f"Add '{imported_module}' to requires=[...] in definition.py, or use protocol pattern",
|
||||
)
|
||||
|
||||
def _validate_legacy_locations(self, target_path: Path):
|
||||
"""
|
||||
Validate that code is not in legacy locations (MOD-016 to MOD-019).
|
||||
|
||||
Reference in New Issue
Block a user