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:
2026-02-04 19:06:13 +01:00
parent 3e38db79aa
commit 5afc0fdfae

View File

@@ -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).