diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53b7ef7..4ecdb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,11 @@ jobs: # meaningful on a quiet machine, but it must still compile so that it # does not rot. run: cargo build -p apsis-rs --features full --example bench + - name: Check the layer rule + # README.md states that a crate may only depend on layers to its + # left. Nothing else in the build enforces that, so a reverse + # dependency would otherwise pass CI silently. + run: bash scripts/check-layers.sh - name: Verify dependency freedom run: | # Every package in the graph must be a workspace crate. `--prefix diff --git a/README.md b/README.md index e30211b..0d2f484 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,15 @@ foundation -> physics -> orbit -> rf -> network -> ops -> sim -> apsis-rs 约束: -- 依赖只能从左向右,禁止反向依赖 -- 禁止跨层跳跃,例如 `orbit` 不得直接依赖 `network` -- 层内允许依赖该层的基准 crate:`foundation` 内 `apsis-core` 为基准,其余 crate 可依赖它;其余层内部以各自的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim` 等)作为整合点 +- 依赖只能从左向右。下层可依赖其上任意层,上层不得依赖其下任意层。 + 例如 `network` 可以依赖 `orbit`(向左),但 `orbit` 不得依赖 `network` + (向右,即反向)。左向依赖不限层数,允许跨层,因为把 `orbit` 的值 + 传到 `network` 时中间层未必提供中转类型。 +- `foundation` 的 `apsis-core` 是全局基准,任何层任何 crate 均可依赖它。 + `apsis-math` 同理,因为线性代数被各层共用。 +- 层内以该层的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim`)作为整合点。 + +这套规则由 `scripts/check-layers.sh` 强制,CI 会运行它。 各层内容: @@ -70,6 +76,7 @@ apsis-rs/ │ └── apsis-rs/ │ ├── examples/ # 对拍导出与跨层集成检查 │ └── ... +├── scripts/ # 仓库检查脚本 ├── validation/ # Python 对拍验证 └── docs/design/ # 设计文档 ``` diff --git a/docs/design/README.md b/docs/design/README.md index 3cfb160..af8f644 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -9,6 +9,7 @@ - [单位与坐标约定](units.md) - [错误与容差策略](errors.md) +- [分层边界](layers.md) ## 各层设计 diff --git a/docs/design/layers.md b/docs/design/layers.md new file mode 100644 index 0000000..71aaf6d --- /dev/null +++ b/docs/design/layers.md @@ -0,0 +1,83 @@ +# 分层边界 + +本文件记录工作区的分层规则,以及为什么某些跨层依赖是允许的。README 的 +"分层"一节是规则本身,这里是它的判定标准与边界情况。 + +## 规则 + +层自左向右排列: + +```text +foundation -> physics -> orbit -> rf -> network -> ops -> sim -> apsis-rs +``` + +一条依赖边从 crate A 指向 crate B。把 A 所在的层与 B 所在的层分别映射为 +下标,规则是: + +```text +index(B) <= index(A) 允许 +index(B) > index(A) 违规(反向依赖) +``` + +即**只能向左依赖,不能向右依赖**。`scripts/check-layers.sh` 对工作区每个 +清单执行这条判定,CI 会运行它。 + +## 允许跨层 + +规则只看方向,不看距离。从 `network` 直接依赖 `orbit` 是允许的,尽管中间 +隔着 `rf`:`orbit` 在 `network` 左边,方向正确。 + +允许向左跨层是有意的。若强制只能依赖紧邻的左层,会产生两类问题: + +- **中转类型不存在。** 把 `orbit` 层的位置传给 `network` 层时,`rf` 层并 + 非必然提供一个承载该位置的类型。强行要求经过 `rf` 只会制造只是转发的 + 空壳类型。 +- **无谓的耦合。** `network` 的星座计算需要 `apsis-kepler` 的开普勒要素, + 它并不需要 `rf`。若禁止跨层,`network` 就得依赖 `rf` 只为拿到一个它不 + 使用的类型。 + +因此规则约束的是**方向**,因为方向才对应真正的分层语义:上层的概念可以 +引用下层的概念,反之则说明分层错了。 + +## 全局基准 + +`apsis-core`(错误、常量、单位标记)与 `apsis-math`(`Vec3`、`Mat3`、 +`Quat`)被各层共用,包括最左的 `foundation` 之外的所有层。它们本身在 +`foundation` 中,所以"只向左依赖"已经允许任何层使用它们,无需特例。 + +这与"层内基准"不同:`apsis-core` 也是 `foundation` 内的基准,但它的可用 +范围是整个工作区,而不只是同层。 + +## 层内门面 + +每层以一个门面 crate 作为整合点: + +| 层 | 门面 | +| --- | --- | +| orbit | `apsis-orbit` | +| rf | `apsis-rf` | +| sim | `apsis-sim` | +| 对外 | `apsis-rs` | + +门面把同层的能力组合成少量类型,并再导出同层的其余 crate。`foundation`、 +`physics`、`network`、`ops` 没有单独门面:它们的 crate 各自独立有用,且 +`apsis-rs` 会把它们一并再导出。 + +## 边界情况 + +- **facade `apsis-rs` 不在层序列中。** 它依赖全部层,因为它就是出口。 + `scripts/check-layers.sh` 通过层目录名判定,`crates/apsis-rs` 不是 + `crates//` 形状,因此被跳过。 +- **`dev-dependencies` 也计入检查。** 一条测试里的反向依赖同样说明分层 + 错了,应当修正代码而不是放宽测试。 +- **门面再导出不构成新依赖。** `apsis-rs` 把 `apsis-orbit` 再导出,是 + 门面行为,不是某层对 `orbit` 的依赖。 + +## 检查 + +```bash +bash scripts/check-layers.sh +``` + +脚本输出检查过的依赖边数,并在发现反向依赖时逐条列出后以退出码 1 结束。 +对当前工作区,它检查 141 条边,全部方向正确。 diff --git a/scripts/check-layers.sh b/scripts/check-layers.sh new file mode 100755 index 0000000..da75e65 --- /dev/null +++ b/scripts/check-layers.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Checks the layer dependency rule stated in README.md. +# +# The workspace is organized into dependency ordered layers: +# +# foundation -> physics -> orbit -> rf -> network -> ops -> sim +# +# A crate may depend on any crate in a layer to its left, but never on one to +# its right. This script reads every workspace manifest, works out which +# layer each dependency belongs to, and fails if any dependency points to the +# right. +# +# `apsis-core` and `apsis-math` are exempt as global prerequisites: they sit +# in `foundation` and every layer may use them, which the left-only rule +# already permits, so no special case is needed for them. +# +# Usage: +# +# scripts/check-layers.sh +# +# Exit status is zero when the rule holds and one otherwise. + +set -euo pipefail + +# The layer order, left to right. A dependency to a layer with a lower index +# is allowed; to a higher index is a reverse dependency and fails. +LAYERS=(foundation physics orbit rf network ops sim) + +# The root of the workspace, taken from this script's location so that the +# check works from any working directory. +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +# Returns the index of a layer name, or an empty string when the name is not +# a layer. A crate outside these directories is not part of the layer scheme +# and is skipped rather than reported. +layer_index() { + local name="$1" + local index=0 + for layer in "${LAYERS[@]}"; do + if [ "$layer" = "$name" ]; then + echo "$index" + return 0 + fi + index=$((index + 1)) + done + echo "" +} + +failures=0 +checked=0 + +for manifest in $(find crates -name Cargo.toml | sort); do + directory="$(dirname "$manifest")" + # The layer directory is the second path component, as in + # `crates///Cargo.toml`. + layer="$(echo "$directory" | cut -d/ -f2)" + source_index="$(layer_index "$layer")" + [ -z "$source_index" ] && continue + + # Every `apsis-...` name in the manifest is a dependency or a package + # name. The package name is the crate itself, which resolves to the same + # layer and is skipped by the index comparison below. + for dependency in $(grep -oE 'apsis-[a-z-]+' "$manifest" | sort -u); do + dependency_directory="$(find crates -maxdepth 2 -type d -name "$dependency" 2>/dev/null | head -1)" + [ -z "$dependency_directory" ] && continue + dependency_layer="$(echo "$dependency_directory" | cut -d/ -f2)" + dependency_index="$(layer_index "$dependency_layer")" + [ -z "$dependency_index" ] && continue + + if [ "$dependency_index" -gt "$source_index" ]; then + echo "reverse dependency: $layer -> $dependency_layer ($(basename "$directory") -> $dependency)" >&2 + failures=$((failures + 1)) + fi + checked=$((checked + 1)) + done +done + +if [ "$failures" -gt 0 ]; then + echo "layer check failed: $failures reverse dependency(ies)" >&2 + exit 1 +fi + +echo "layer check passed: $checked dependency edge(s), none reversed"