#!/usr/bin/env python3 """Check for forbidden direct cross-plugin imports. This script enforces that plugins communicate only through contracts, not by importing internal modules from each other. Allowed: from app.plugins.builtins.contracts import get_contract from app.plugins.builtins..contracts import ... from app.plugins.builtins. import (in __init__.py only) Forbidden: from app.plugins.builtins..services import ... from app.plugins.builtins..models import ... from app.plugins.builtins..routes import ... Exceptions (files that are allowed to import anything): - */contracts.py — contracts import from internal modules - */__init__.py — plugin discovery - app/plugins/registry.py — registry manages all plugins - app/plugins/builtins/__init__.py — builtin discovery - tests/* — test files - conftest.py — test fixtures Usage: python scripts/check_cross_plugin_imports.py python scripts/check_cross_plugin_imports.py --path app/plugins/builtins Exit codes: 0 — no violations 1 — violations found """ from __future__ import annotations import os import re import sys from pathlib import Path # ─── Configuration ─── PROJECT_ROOT = Path(__file__).resolve().parent.parent BUILTINS_DIR = PROJECT_ROOT / "app" / "plugins" / "builtins" # Files that are exempt from the rule EXEMPT_FILES = { "contracts.py", "__init__.py", "conftest.py", } # Directories that are exempt EXEMPT_DIRS = { "tests", "__pycache__", "migrations", } # Files that are exempt by path EXEMPT_PATHS = { PROJECT_ROOT / "app" / "plugins" / "registry.py", PROJECT_ROOT / "app" / "plugins" / "builtins" / "__init__.py", PROJECT_ROOT / "app" / "plugins" / "base.py", PROJECT_ROOT / "app" / "plugins" / "manifest.py", PROJECT_ROOT / "app" / "plugins" / "migration_runner.py", # Paket 6 (#357): deliberate re-export bridge — the ContactsPlugin owns the # Contact model; app/models/contact.py only mirrors it so alembic env.py, # Core services and tests keep their stable import path. PROJECT_ROOT / "app" / "models" / "contact.py", } # Pattern for cross-plugin imports IMPORT_PATTERN = re.compile( r"^\s*(?:from|import)\s+app\.plugins\.builtins\.([^.]+)\.(.+?)\s+import\s+(.+)$" ) # Pattern for allowed contract imports CONTRACT_IMPORT_PATTERN = re.compile( r"^\s*from\s+app\.plugins\.builtins\.(?:contracts|[^.]+\.contracts)\s+import\s+(.+)$" ) # Pattern for __init__.py plugin class imports (allowed in __init__.py only) PLUGIN_CLASS_IMPORT_PATTERN = re.compile( r"^\s*from\s+app\.plugins\.builtins\.([^.]+)\s+import\s+([A-Z]\w*Plugin)\s*$" ) def is_exempt(filepath: Path) -> bool: """Check if a file is exempt from the cross-plugin import rule.""" # Exempt by filename if filepath.name in EXEMPT_FILES: return True # Exempt by path if filepath in EXEMPT_PATHS: return True # Exempt test directories parts = filepath.parts for exempt_dir in EXEMPT_DIRS: if exempt_dir in parts: return True return False def check_file(filepath: Path) -> list[str]: """Check a single file for forbidden cross-plugin imports. Returns a list of violation messages (empty if clean). """ if is_exempt(filepath): return [] violations: list[str] = [] rel_path = filepath.relative_to(PROJECT_ROOT) # Determine the source plugin from the file path try: parts = filepath.relative_to(BUILTINS_DIR).parts src_plugin = parts[0] if parts else "" except ValueError: src_plugin = "" # Core files (outside builtins) cannot declare plugin dependencies — # any cross-plugin import from Core is always a violation. is_core_file = src_plugin == "" with open(filepath, encoding="utf-8") as f: for line_num, line in enumerate(f, 1): line_stripped = line.strip() # Skip comments and empty lines if not line_stripped or line_stripped.startswith("#"): continue # Check for cross-plugin import match = IMPORT_PATTERN.match(line) if not match: continue tgt_plugin = match.group(1) tgt_module = match.group(2) # Skip if importing from contracts if tgt_module == "contracts": continue # Skip if same plugin (INTRA-Plugin import) if tgt_plugin == src_plugin: continue # Core files cannot import plugin internals — always a violation # regardless of any 'Declared dependency' annotation. if is_core_file: violations.append( f"{rel_path}:{line_num}: {line_stripped}\n" f" → Core→Plugin import forbidden: '{tgt_plugin}.{tgt_module}'. " f"Use contracts: get_contract(\"{tgt_plugin}\")" ) continue # Plugin→Plugin: check if declared in manifest.dependencies if not is_core_file and src_plugin: import re as _re manifest_path = BUILTINS_DIR / src_plugin / "plugin.py" declared_deps: set[str] = set() if manifest_path.exists(): with open(manifest_path, encoding="utf-8") as mf: manifest_content = mf.read() dep_match = _re.search(r'dependencies=\[([^\]]*)\]', manifest_content) if dep_match: declared_deps = set(_re.findall(r'"([^"]+)"', dep_match.group(1))) if tgt_plugin in declared_deps: continue # Declared dependency — allowed # Skip if it's a contract import via the central registry if CONTRACT_IMPORT_PATTERN.match(line): continue # This is a forbidden cross-plugin import violations.append( f"{rel_path}:{line_num}: {line_stripped}\n" f" → Forbidden cross-plugin import: '{tgt_plugin}.{tgt_module}'. " f"Use contracts instead: get_contract(\"{tgt_plugin}\")" ) return violations def find_python_files(search_path: Path | None = None) -> list[Path]: """Find all Python files in the search path. Default: scans both app/plugins/builtins/ (Plugin→Plugin) and app/core/, app/services/, app/routes/, app/commands/, app/ai/ (Core→Plugin). """ if search_path is not None: files: list[Path] = [] for root, dirs, fnames in os.walk(search_path): dirs[:] = [d for d in dirs if d not in EXEMPT_DIRS] for fname in fnames: if fname.endswith(".py"): files.append(Path(root) / fname) return sorted(files) # Default: scan the whole app/ tree so new subpackages are covered automatically files: list[Path] = [] scan_dirs = [ PROJECT_ROOT / "app", ] for scan_dir in scan_dirs: if not scan_dir.exists(): continue for root, dirs, fnames in os.walk(scan_dir): dirs[:] = [d for d in dirs if d not in EXEMPT_DIRS] for fname in fnames: if fname.endswith(".py"): files.append(Path(root) / fname) return sorted(files) def main() -> int: """Run the cross-plugin import checker.""" import argparse parser = argparse.ArgumentParser(description="Check for forbidden cross-plugin imports.") parser.add_argument( "--path", type=Path, default=None, help="Path to check (default: full scan of builtins + core dirs)", ) parser.add_argument( "--verbose", action="store_true", help="Print checked files even if clean.", ) args = parser.parse_args() # Resolve user-supplied paths so downstream relative_to(PROJECT_ROOT) works if args.path is not None: args.path = args.path.resolve() files = find_python_files(args.path) all_violations: list[str] = [] checked = 0 for filepath in files: checked += 1 violations = check_file(filepath) if violations: all_violations.extend(violations) elif args.verbose: print(f" ✅ {filepath.relative_to(PROJECT_ROOT)}") print(f"\nChecked {checked} files.") if all_violations: print(f"\n❌ Found {len(all_violations)} violation(s):\n") for v in all_violations: print(f" {v}") print( "\nFix: Replace direct imports with contract-based access:\n" " from app.plugins.builtins.contracts import get_contract\n" " contract = get_contract(\"plugin_name\")\n" " if contract:\n result = await contract.some_function(...)" ) return 1 else: print("✅ No forbidden cross-plugin imports found.") return 0 if __name__ == "__main__": sys.exit(main())