Add Rust workspace boundary check
Deploy / deploy (push) Successful in 30s
CI / Rust Checks (push) Successful in 5m13s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 4m34s

This commit is contained in:
github-ops
2026-06-20 20:55:55 +00:00
parent 81efa2f0fb
commit 5970db5449
5 changed files with 283 additions and 0 deletions
+3
View File
@@ -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
+14
View File
@@ -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-репозиторий не попали функции и тексты за пределами
+168
View File
@@ -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:]))
+6
View File
@@ -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" "$@"
+92
View File
@@ -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()