diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index eb1f447..2aa85bf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - name: Check Rust code health run: scripts/check-rust-code-health.sh + - name: Check Rust boundaries + run: scripts/check-rust-boundaries.sh + - name: Run clippy run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings diff --git a/scripts/README.md b/scripts/README.md index 8d99733..ca6cefe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -96,6 +96,20 @@ shell env file. ./scripts/check-rust-code-health.sh ``` +## `check-rust-boundaries.sh` + +Проверяет направление зависимостей между Rust workspace crates: + +- приложения из `apps/*` могут зависеть от crates, но не от других приложений; +- crates не должны зависеть от приложений; +- `crank-core` не зависит от runtime, registry и adapters; +- `crank-registry` не зависит от runtime и adapters; +- `crank-runtime` не зависит от registry. + +```bash +./scripts/check-rust-boundaries.sh +``` + ## `check-community-scope.sh` Проверяет, что в community-репозиторий не попали функции и тексты за пределами diff --git a/scripts/check-rust-boundaries.py b/scripts/check-rust-boundaries.py new file mode 100755 index 0000000..309da0a --- /dev/null +++ b/scripts/check-rust-boundaries.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class Package: + id: str + name: str + category: str + manifest_path: Path + + +@dataclass(frozen=True) +class Violation: + source: str + dependency: str + reason: str + + +def load_metadata(metadata_file: Path | None) -> dict[str, Any]: + if metadata_file is not None: + return json.loads(metadata_file.read_text(encoding="utf-8")) + + result = subprocess.run( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + check=True, + text=True, + stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + + +def package_category(name: str, manifest_path: Path, workspace_root: Path) -> str: + try: + rel = manifest_path.parent.relative_to(workspace_root) + except ValueError: + rel = manifest_path.parent + + parts = rel.parts + if parts and parts[0] == "apps": + return "app" + if name == "crank-core": + return "core" + if name == "crank-registry": + return "registry" + if name == "crank-runtime": + return "runtime" + if name.startswith("crank-adapter-"): + return "adapter" + return "crate" + + +def workspace_packages(metadata: dict[str, Any]) -> dict[str, Package]: + workspace_root = Path(metadata["workspace_root"]).resolve() + workspace_members = set(metadata["workspace_members"]) + packages: dict[str, Package] = {} + + for raw_package in metadata["packages"]: + package_id = raw_package["id"] + if package_id not in workspace_members: + continue + + manifest_path = Path(raw_package["manifest_path"]).resolve() + name = raw_package["name"] + packages[package_id] = Package( + id=package_id, + name=name, + category=package_category(name, manifest_path, workspace_root), + manifest_path=manifest_path, + ) + + return packages + + +def dependency_package_ids(raw_package: dict[str, Any], packages_by_name: dict[str, str]) -> list[str]: + dependency_ids: list[str] = [] + for dependency in raw_package.get("dependencies", []): + dependency_name = dependency["name"] + dependency_id = packages_by_name.get(dependency_name) + if dependency_id is not None: + dependency_ids.append(dependency_id) + return dependency_ids + + +def boundary_reason(source: Package, dependency: Package) -> str | None: + if source.category == "app": + if dependency.category == "app": + return "apps must not depend on other apps" + return None + + if dependency.category == "app": + return "workspace crates must not depend on apps" + + if source.category == "core" and dependency.category in {"runtime", "registry", "adapter"}: + return "crank-core must stay below runtime, registry and adapters" + + if source.category == "registry" and dependency.category in {"runtime", "adapter"}: + return "crank-registry must not depend on runtime or adapters" + + if source.category == "runtime" and dependency.category == "registry": + return "crank-runtime must not depend on registry" + + if source.category == "adapter" and dependency.category == "app": + return "adapters must not depend on apps" + + return None + + +def find_violations(metadata: dict[str, Any]) -> list[Violation]: + packages = workspace_packages(metadata) + packages_by_name = {package.name: package.id for package in packages.values()} + raw_packages_by_id = {package["id"]: package for package in metadata["packages"]} + violations: list[Violation] = [] + + for source in sorted(packages.values(), key=lambda package: package.name): + raw_package = raw_packages_by_id[source.id] + for dependency_id in dependency_package_ids(raw_package, packages_by_name): + dependency = packages[dependency_id] + reason = boundary_reason(source, dependency) + if reason is not None: + violations.append( + Violation( + source=source.name, + dependency=dependency.name, + reason=reason, + ) + ) + + return violations + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Check Rust workspace dependency boundaries.") + parser.add_argument( + "--metadata-file", + type=Path, + help="Use captured cargo metadata JSON instead of invoking cargo.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + metadata = load_metadata(args.metadata_file) + violations = find_violations(metadata) + + if violations: + for violation in violations: + print( + f"error: {violation.source} -> {violation.dependency}: {violation.reason}", + file=sys.stderr, + ) + return 1 + + print("Rust boundary check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/check-rust-boundaries.sh b/scripts/check-rust-boundaries.sh new file mode 100755 index 0000000..3eea7f0 --- /dev/null +++ b/scripts/check-rust-boundaries.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 "$ROOT_DIR/scripts/check-rust-boundaries.py" "$@" diff --git a/tests/unit/test_check_rust_boundaries.py b/tests/unit/test_check_rust_boundaries.py new file mode 100644 index 0000000..283686b --- /dev/null +++ b/tests/unit/test_check_rust_boundaries.py @@ -0,0 +1,92 @@ +import importlib.util +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CHECKER = ROOT / "scripts" / "check-rust-boundaries.py" + + +def load_checker_module(): + spec = importlib.util.spec_from_file_location("check_rust_boundaries", CHECKER) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def package(root: Path, name: str, rel_dir: str, dependencies: list[str] | None = None) -> dict: + dependencies = dependencies or [] + return { + "id": f"path+file://{root / rel_dir}#{name}", + "name": name, + "manifest_path": str(root / rel_dir / "Cargo.toml"), + "dependencies": [{"name": dependency} for dependency in dependencies], + } + + +def metadata(packages: list[dict], root: Path | None = None) -> dict: + root = root or Path("/tmp/crank") + return { + "workspace_root": str(root), + "workspace_members": [pkg["id"] for pkg in packages], + "packages": packages, + } + + +class RustBoundaryCheckTests(unittest.TestCase): + def setUp(self) -> None: + self.checker = load_checker_module() + self.root = Path("/tmp/crank") + + def test_allows_apps_to_depend_on_workspace_crates(self) -> None: + packages = [ + package(self.root, "admin-api", "apps/admin-api", ["crank-runtime"]), + package(self.root, "crank-runtime", "crates/crank-runtime", ["crank-core"]), + package(self.root, "crank-core", "crates/crank-core"), + ] + + violations = self.checker.find_violations(metadata(packages, self.root)) + + self.assertEqual(violations, []) + + def test_rejects_core_dependency_on_runtime(self) -> None: + packages = [ + package(self.root, "crank-core", "crates/crank-core", ["crank-runtime"]), + package(self.root, "crank-runtime", "crates/crank-runtime"), + ] + + violations = self.checker.find_violations(metadata(packages, self.root)) + + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0].source, "crank-core") + self.assertEqual(violations[0].dependency, "crank-runtime") + + def test_rejects_workspace_crate_dependency_on_app(self) -> None: + packages = [ + package(self.root, "crank-runtime", "crates/crank-runtime", ["admin-api"]), + package(self.root, "admin-api", "apps/admin-api"), + ] + + violations = self.checker.find_violations(metadata(packages, self.root)) + + self.assertEqual(len(violations), 1) + self.assertIn("must not depend on apps", violations[0].reason) + + def test_rejects_registry_dependency_on_adapter(self) -> None: + packages = [ + package(self.root, "crank-registry", "crates/crank-registry", ["crank-adapter-rest"]), + package(self.root, "crank-adapter-rest", "crates/crank-adapter-rest"), + ] + + violations = self.checker.find_violations(metadata(packages, self.root)) + + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0].source, "crank-registry") + self.assertEqual(violations[0].dependency, "crank-adapter-rest") + + +if __name__ == "__main__": + unittest.main()