98eb1d0d89
Check Cross-Plugin Imports / check (push) Has been cancelled
Phase 1: Contracts konsequent nutzen - 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts) - 4 bestehende contracts.py an zentrale ContractRegistry angepasst - Alle 19 Plugins haben on_deactivate mit Contract-Unregister - 0 echte problematische INTER-Plugin Imports Phase 2: Hooks/Filters-System - app/core/hooks.py (HookRegistry mit actions + filters) - 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms) - BasePlugin.on_deactivate meldet alle Hooks ab Phase 3: Plugin-Isolation - scripts/check_cross_plugin_imports.py (Linting-Regel) - .github/workflows/check-cross-plugin-imports.yml (CI/CD) - .pre-commit-cross-plugin.yaml (Pre-commit Hook) - 155 Dateien geprueft, 0 Verstoesse Phase 4: Plugin-Versioning - app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release) - migration_runner.py erweitert: run_migration_down, rollback_to_version - manifest.py: min_app_version Feld - registry.py: App-Version-Compatibility-Check bei Installation - GET /api/v1/plugins/updates Endpoint Phase 5: Marketplace-Vorbereitung - app/plugins/signature.py (Ed25519 Signatur-Validierung) - app/plugins/quarantine.py (Plugin-Quarantine mit Validierung) - app/models/plugin_allowlist.py + Migration 0046 - manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price - registry.py: discover_external(), discover_all() - POST /api/v1/plugins/install-marketplace (deaktiviert) Phase 6: Manifest-Anpassung - manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung - MANIFEST_SCHEMA_DOC aktualisiert - Alle 19 Plugin-Manifeste aktualisiert - Frontend PluginUiManifest Typ erweitert Zusaetzliche Bug-Fixes: - test_sample-Modul erstellt - conftest.py Deadlock-Prevention - SESSION_COOKIE_SECURE=true - dump.rdb aus Git entfernt + .gitignore - backup.py datetime.utcnow -> func.now() - system_settings.py JSONB-Import nach oben - tax.py Mapped[float] -> Mapped[Decimal] - notification.py type_key-Laengen vereinheitlicht Tests: 91 neue Tests, alle bestanden
225 lines
6.5 KiB
Python
225 lines
6.5 KiB
Python
#!/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.<name>.contracts import ...
|
|
from app.plugins.builtins.<name> import <PluginClass> (in __init__.py only)
|
|
|
|
Forbidden:
|
|
from app.plugins.builtins.<name>.services import ...
|
|
from app.plugins.builtins.<name>.models import ...
|
|
from app.plugins.builtins.<name>.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",
|
|
}
|
|
|
|
# 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 = ""
|
|
|
|
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
|
|
|
|
# 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."""
|
|
if search_path is None:
|
|
search_path = BUILTINS_DIR
|
|
|
|
files: list[Path] = []
|
|
for root, dirs, fnames in os.walk(search_path):
|
|
# Skip exempt directories
|
|
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=BUILTINS_DIR,
|
|
help="Path to check (default: app/plugins/builtins)",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose",
|
|
action="store_true",
|
|
help="Print checked files even if clean.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
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())
|