From 5afc0fdfaec27df4ca25594f6e390593b4c9f10c Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 4 Feb 2026 19:06:13 +0100 Subject: [PATCH] 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 --- scripts/validate_architecture.py | 192 +++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/scripts/validate_architecture.py b/scripts/validate_architecture.py index b719c4b3..2e5ab41b 100755 --- a/scripts/validate_architecture.py +++ b/scripts/validate_architecture.py @@ -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).