#!/usr/bin/env python3 """Check the repository dependency direction without importing application code.""" from __future__ import annotations import argparse import ast import re import sys from dataclasses import dataclass from pathlib import Path SOURCE_ROOTS = {"apps", "services", "connectors", "packages"} SOURCE_SUFFIXES = {".py", ".pyi", ".js", ".jsx", ".ts", ".tsx"} IGNORED_PARTS = {"__pycache__", ".venv", "node_modules", "dist"} @dataclass(frozen=True) class Violation: path: Path line: int message: str def format(self, root: Path) -> str: return f"{self.path.relative_to(root).as_posix()}:{self.line}: {self.message}" def _owner(path: Path, root: Path) -> tuple[str, str] | None: relative = path.relative_to(root) if len(relative.parts) < 2 or relative.parts[0] not in SOURCE_ROOTS: return None return relative.parts[0], relative.parts[1].replace("-", "_") def _target(module: str) -> tuple[str, str] | None: normalized = module.replace("-", "_") parts = normalized.split(".") if len(parts) < 2 or parts[0] not in SOURCE_ROOTS: return None return parts[0], parts[1] def _allowed(owner: tuple[str, str], target: tuple[str, str]) -> bool: owner_root, owner_name = owner target_root, target_name = target if owner_root == "packages": if owner_name == "hub_contracts": return target_root == "packages" and target_name == "hub_contracts" if owner_name in {"hub_core", "hub_policy", "connector_kit"}: return target_root == "packages" and target_name in {owner_name, "hub_contracts"} return target_root == "packages" if owner_root == "connectors": return target_root == "packages" if owner_root == "services": if owner_name == "runtime": return target_root in {"packages", "services", "connectors"} return target_root == "packages" or target == ("services", owner_name) if owner_root == "apps": return target_root == "packages" or target == ("apps", owner_name) return True def _python_imports(path: Path) -> list[tuple[int, str]]: try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) except (OSError, SyntaxError): return [] imports: list[tuple[int, str]] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): imports.extend((node.lineno, alias.name) for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: imports.append((node.lineno, node.module)) return imports def _js_imports(path: Path) -> list[tuple[int, str]]: pattern = re.compile(r"(?:from\s+|import\s*\(?\s*|require\(\s*)['\"]([^'\"]+)") return [ (line_no, module) for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) for module in pattern.findall(line) ] def find_violations(root: Path, paths: list[Path] | None = None) -> list[Violation]: candidates = paths or [ path for source_root in SOURCE_ROOTS for path in (root / source_root).rglob("*") if path.is_file() and path.suffix in SOURCE_SUFFIXES and not (set(path.parts) & IGNORED_PARTS) ] violations: list[Violation] = [] for path in candidates: owner = _owner(path, root) if owner is None: continue imports = _python_imports(path) if path.suffix in {".py", ".pyi"} else _js_imports(path) for line, module in imports: target = _target(module) if target and not _allowed(owner, target): violations.append( Violation( path, line, f"forbidden dependency {module!r} from {owner[0]}/{owner[1]}" ) ) return violations def main() -> int: parser = argparse.ArgumentParser(description="Check ITWorx MCP Hub import boundaries.") parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) args = parser.parse_args() root = args.root.resolve() violations = find_violations(root) if violations: for violation in violations: print(violation.format(root), file=sys.stderr) return 1 print("Import-boundary check: PASS") return 0 if __name__ == "__main__": raise SystemExit(main())