workspace: enforce the layer dependency rule

README stated that a crate may only depend on layers to its left, but
nothing checked it, and the wording was self contradicting: the example
given for a cross layer jump was a reverse dependency. Clarify the rule,
add scripts/check-layers.sh to enforce it, run it in CI, and record the
boundary cases in docs/design/layers.md.
This commit is contained in:
ApsisRS contributors 2026-09-26 23:33:20 +08:00
parent 26ee3d72e9
commit 8f74f8f97a
5 changed files with 184 additions and 3 deletions

View File

@ -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

View File

@ -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/ # 设计文档
```

View File

@ -9,6 +9,7 @@
- [单位与坐标约定](units.md)
- [错误与容差策略](errors.md)
- [分层边界](layers.md)
## 各层设计

83
docs/design/layers.md Normal file
View File

@ -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/<layer>/<crate>` 形状,因此被跳过。
- **`dev-dependencies` 也计入检查。** 一条测试里的反向依赖同样说明分层
错了,应当修正代码而不是放宽测试。
- **门面再导出不构成新依赖。** `apsis-rs` 把 `apsis-orbit` 再导出,是
门面行为,不是某层对 `orbit` 的依赖。
## 检查
```bash
bash scripts/check-layers.sh
```
脚本输出检查过的依赖边数,并在发现反向依赖时逐条列出后以退出码 1 结束。
对当前工作区,它检查 141 条边,全部方向正确。

85
scripts/check-layers.sh Executable file
View File

@ -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/<layer>/<crate>/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"