128 lines
4.3 KiB
Python
128 lines
4.3 KiB
Python
#!/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())
|