name: Guards # 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): # 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 # 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层 # 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身 # 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) # 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 # 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91) on: push: branches: [main] pull_request: workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: core-no-ui: name: core 零 UI 守门 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: 依赖表直查(crates/core/Cargo.toml 不得出现 UI 框架) run: | set -uo pipefail forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' # 只看非注释行:manifest 里说明「不许出现 slint」的注释本身不该触发守门 if grep -vE '^[[:space:]]*#' crates/core/Cargo.toml | grep -nEi "$forbidden"; then echo "::error file=crates/core/Cargo.toml::core 不得依赖 UI 框架(见 AGENTS.md 硬约束)" exit 1 fi echo "OK: crates/core/Cargo.toml 无 UI 依赖" - name: 依赖树泄漏检查(cargo tree -p unlucky-core,排除自身行) run: | set -uo pipefail forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' tree=$(cargo tree -p unlucky-core --edges normal --prefix none) echo "$tree" if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" exit 1 fi echo "OK: unlucky-core 依赖树零 UI" # presenter 是 Slint 版(crates/app)与 iced 版(crates/app-iced)共用的唯一业务层(D26)。 # 一旦 slint/iced/winit 被拉进来,分层就废了,而编译与单测仍会全绿 —— 所以必须由 CI 守。 # 与 core-no-ui 同样是「依赖表 + cargo tree」双查,名单保持一致;脚本刻意复制而非抽公共脚本, # 理由是 CI 守门脚本自身挂掉的排查成本远高于这几行重复。 presenter-no-ui: name: presenter 零 UI 守门 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: 依赖表直查(crates/presenter/Cargo.toml 的依赖表不得出现 UI 框架) run: | set -uo pipefail forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' # 与 core-no-ui 的整文件 grep 不同:本 crate 的 description 合法地写着 # 「Slint 版与 iced 版共用」,整文件 grep 会把它当越线。所以先按表头切出依赖表 # (含 target.*.dependencies 与 dev/build 表),再去掉注释行,只查键名。 # 子表写法 `[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]` # 是合法的等长写法,且表头本身带着包名,所以表头也要一并落进检查范围—— # 否则「子表 + optional + 非默认 feature」这种组合能同时骗过依赖表与 cargo tree。 awk ' /^[[:space:]]*\[/ { in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/) if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0 next } in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 } ' crates/presenter/Cargo.toml > /tmp/presenter-deps.txt cat /tmp/presenter-deps.txt if grep -iE "$forbidden" /tmp/presenter-deps.txt; then echo "::error file=crates/presenter/Cargo.toml::presenter 不得依赖 UI 框架(D26)。窗口创建 / 渲染 / 图片解码属于 app 与 app-iced 侧;需要跨前端共用的类型请放进 presenter 的 dto。" exit 1 fi echo "OK: crates/presenter/Cargo.toml 依赖表无 UI 依赖" - name: 依赖树泄漏检查(cargo tree -p unlucky-presenter,排除自身行) run: | set -uo pipefail forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' # 注意:cargo tree 命中 UI 框架时**退出码仍是 0**,所以只能抓输出再判断。 if ! tree=$(cargo tree -p unlucky-presenter --edges normal --prefix none); then echo "::error::cargo tree -p unlucky-presenter 执行失败 —— crate 是否仍叫 unlucky-presenter?" exit 1 fi echo "$tree" hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden") if [ -n "$hits" ]; then echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:" printf '%s\n' "$hits" | sed 's/^/ - /' echo "::error::presenter 是 Slint 版与 iced 版共用的宿主层:把窗口 / 渲染 / 图片解码留在 app 侧,presenter 只持有与框架无关的 DTO、状态 reducer 与 host 装配。" exit 1 fi echo "OK: unlucky-presenter 依赖树零 UI" # unlucky-design 是「纯数据 + 纯函数、零第三方依赖」的设计令牌层,这是 README 明写的卖点 # (连 serde 都不加),但目前只靠人肉守。零依赖的判据比 UI 名单更硬:依赖树只允许有自己一行。 # 注意 `cargo tree --edges normal` 看不到 dev/build 依赖,所以另设一步按表头切依赖表逐个查空。 design-zero-dep: name: design 零第三方依赖守门 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: 依赖表必须为空(dependencies / dev / build 表一律不许有键,含子表写法) run: | set -uo pipefail # design 承诺的是「零第三方依赖」,不分 normal / dev / build —— 而 cargo tree # --edges normal 对 dev/build 是瞎的。所以先按表头切出全部依赖表(含 # `[dependencies.serde]` 这类子表写法,表头本身携带包名),再看是否为空。 awk ' /^[[:space:]]*\[/ { in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/) if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0 next } in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 } ' crates/design/Cargo.toml > /tmp/design-deps.txt if [ -s /tmp/design-deps.txt ]; then cat /tmp/design-deps.txt echo "::error file=crates/design/Cargo.toml::unlucky-design 的依赖表必须为空(normal / dev / build 都算),本次命中:" sed 's/^/ - /' /tmp/design-deps.txt echo "::error::design 是纯数据 + 纯函数的设计令牌,README 对外承诺零第三方依赖(连 serde 都不加):色彩 / 排版 / 形状 / 高程 / 动效请不要引入任何 crate,测试与构建辅助依赖同样不许加。序列化与运行时能力放到消费方(app / app-iced / presenter)。" exit 1 fi echo "OK: crates/design/Cargo.toml 依赖表全空(含 dev / build)" - name: 依赖树只允许出现自身(cargo tree -p unlucky-design --edges normal) run: | set -uo pipefail if ! tree=$(cargo tree -p unlucky-design --edges normal --prefix none); then echo "::error::cargo tree -p unlucky-design 执行失败 —— crate 是否仍叫 unlucky-design?" exit 1 fi echo "$tree" # 空行不算,剩下的每一行都必须是自己:正常时整个输出只有一行 # `unlucky-design v0.1.0 (/path/to/crates/design)`。 lines=$(printf '%s\n' "$tree" | grep -cE '[^[:space:]]' || true) others=$(printf '%s\n' "$tree" | grep -vE '^[[:space:]]*$' | grep -vE '^unlucky-design v' || true) if [ -n "$others" ] || [ "$lines" -ne 1 ]; then echo "::error file=crates/design/Cargo.toml::unlucky-design 必须零第三方依赖,本应只有自身一行,实际 $lines 行;越线的包:" printf '%s\n' "$others" | sed 's/^/ - /' echo "::error::design 是纯数据 + 纯函数的设计令牌:色彩 / 排版 / 形状 / 高程 / 动效请不要引入 serde、rand、once_cell 等任何 crate。序列化与运行时能力放到消费方(app / app-iced / presenter)。" exit 1 fi echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" # D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的 # `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。 # 判据必须是**集合相等**而不是「黑名单命中」: # - 新增第二个带 allow(unsafe_code) 的文件 → 红(黑名单写法在这里会假绿); # - 落点文件把 allow 删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 落点文件里多开别的 unsafe 能力 → 门禁看不见(这是本守门的已知边界, # workspace 的 `unsafe_code = "deny"` 也只是按文件放行,逐行审计靠 code review); # - 多行属性 / 同行合并属性:**已由跨行扫描覆盖**(r2 加固前是行级正则,那两种写法能过闸, # 当时唯一的缓解是 ci.yml 里独立的 fmt job——rustfmt 会把它们归成一行。现在不再依赖 fmt)。 unsafe-scope: name: unsafe 落点守门(集合必须恰好一个文件) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: 全仓 allow(unsafe_code) 的文件集合 == 唯一落点 run: | set -uo pipefail EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' # 扫描口径(M91 r2 加固):**跨行**扫描属性,不再用行级正则。 # 旧版是单行正则,实测有两种绕过能过闸:多行属性 `#![allow(\n unsafe_code\n)]`、 # 以及同行两个属性 `#[inline] #[allow(unsafe_code)]`——当时只能靠 ci.yml 的独立 # fmt job 兜底(rustfmt 会把这两种写法归成一行,所以 fmt 红会间接拦住它们)。 # 现在不依赖 fmt 了:注释先去掉,再把「方括号未闭合」的续行并成逻辑行, # 最后在逻辑行里逐个属性组 `#[...]` / `#![...]` 判断是否含 `allow(...unsafe_code...)`。 # # 仍然抓不住的(已知边界,别把它当全覆盖): # - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有 allow」,不看 unsafe 块有几个); # - 宏/`include!` 生成出来的 allow(扫描的是源码文本,不是展开后的 HIR); # - 字符串字面量里写着属性文本的行(会被当代码扫;本仓库不存在这种行); # - 注释里提到的属性**刻意不算**(`crates/app/src/main.rs` 的说明文字就提到过它)。 scan() { awk ' BEGIN { buf = ""; depth = 0; found = 0 } { line = $0 if (line ~ /^[[:space:]]*\/\//) next sub(/\/\/.*$/, "", line) buf = (buf == "" ? line : buf " " line) opens = gsub(/\[/, "[", line) closes = gsub(/\]/, "]", line) depth += opens - closes if (depth > 0) next s = buf; buf = ""; depth = 0 while (match(s, /#!?\[[^]]*\]/)) { attr = substr(s, RSTART, RLENGTH) if (attr ~ /allow\([^]]*unsafe_code/) found = 1 s = substr(s, RSTART + RLENGTH) } } END { exit(found ? 0 : 1) } ' "$1" } actual=$(git ls-files '*.rs' | while IFS= read -r f; do if scan "$f"; then printf '%s\n' "$f"; fi done | LC_ALL=C sort) echo "期望(恰好这一个):$EXPECTED" echo "实际扫到的集合:" if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ "$actual" != "$EXPECTED" ]; then extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true) if [ -n "$extra" ]; then echo "::error::多出来的 unsafe 落点(D36 只允许一个):" printf '%s\n' "$extra" | sed 's/^/ - /' fi if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then echo "::error file=$EXPECTED::这个文件里没有 allow(unsafe_code) —— 落点被搬走/改名/删了 allow?守门路径已失效。" fi echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手 allow。" exit 1 fi echo "OK: 全仓 allow(unsafe_code) 恰好只有 $EXPECTED" file-size: name: 文件行数 ≤400 守门 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: 检查单文件行数 run: | set -uo pipefail LIMIT=400 failed=0 while IFS= read -r f; do case "$f" in LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;; CHANGELOG*|*/CHANGELOG*) continue ;; esac n=$(wc -l < "$f") if [ "$n" -gt "$LIMIT" ]; then echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分" failed=$((failed + 1)) fi # 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束; # Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限) done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh') if [ "$failed" -ne 0 ]; then echo "共 $failed 个文件超限" exit 1 fi echo "OK: 全部受检文件 ≤$LIMIT 行" licenses: name: 依赖许可与来源(cargo-deny) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: EmbarkStudios/cargo-deny-action@v2 with: # 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管 command: check licenses bans sources