From 7f070ad7934c1fbcb3a5622d69549384785076c1 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 28 Jun 2026 20:55:44 +0800 Subject: [PATCH] standard: update the tests --- CLAUDE.md | 1 + document/ai/COVERAGE.md | 61 ++++++++++ document/ai/DIRECTIVES.md | 1 + document/promo-v0.3.0.zh.md | 146 +++++++++++++++++++++++ tests/differential/README.md | 39 +++++++ tests/differential/known_diffs | 26 +++++ tests/differential/run_diff_smoke.sh | 166 +++++++++++++++++++++++++++ 7 files changed, 440 insertions(+) create mode 100644 document/ai/COVERAGE.md create mode 100644 document/promo-v0.3.0.zh.md create mode 100644 tests/differential/README.md create mode 100644 tests/differential/known_diffs create mode 100755 tests/differential/run_diff_smoke.sh diff --git a/CLAUDE.md b/CLAUDE.md index 1b6a800..3bcc52c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ C++23 现代 BusyBox 替代品(单二进制,123 applet,399 GTest,418 KB - [document/ai/ROADMAP.md](document/ai/ROADMAP.md) — Phase 全树 + 状态(薄索引,链向 [document/todo/](document/todo/)) - [document/ai/PLAN.md](document/ai/PLAN.md) — 当前焦点批级进度(批级,最易变) - [document/ai/CODING-TASTE.md](document/ai/CODING-TASTE.md) — 编码/注释风格权威(写代码前读) +- [document/ai/COVERAGE.md](document/ai/COVERAGE.md) — 测试/正确性/差分标尺(覆盖率怎么算、怎么防退化,季级) - [document/notes/](document/notes/) — 批级工作记录(`-.md`) ## 始终遵守(每条便宜,违规代价大) diff --git a/document/ai/COVERAGE.md b/document/ai/COVERAGE.md new file mode 100644 index 0000000..292fbf3 --- /dev/null +++ b/document/ai/COVERAGE.md @@ -0,0 +1,61 @@ +# CFBox 覆盖率与正确性 标尺 + +> 这份文档讲的是工作流程——怎么确认程序是对的、怎么防止它以后悄悄变错。大概一个季度才动一次。它放在 `document/ai/` 下,从 [CLAUDE.md](../../CLAUDE.md) 和 [DIRECTIVES.md](DIRECTIVES.md) 指过来,和 [CODING-TASTE.md](CODING-TASTE.md)(编码风格那篇)是平级的。 +> +> 一句话:正确性是这种嵌入式产品的命根子。这份文档只管两件事——"怎么知道它是对的" 与 "怎么防止它悄悄变错"。 + +## 一、我们的基本态度(三条) + +**1. "覆盖率" 其实有三种意思,别混成一团:** + +- **代码行/分支覆盖率**(用 gcov 这类工具量的那种):这只是个参考信号,告诉你"哪些代码分支从来没被任何测试碰到过",帮我们重构时找方向。它**证明不了程序是对的**,所以不拿它当合并的硬性门槛。 +- **功能正确性**(程序行为到底对不对):这才是真正顶用的柱子。单元测试(GTest)、集成测试、还有和 BusyBox/GNU 比对输出,都属于这一层。 +- **端到端测试**(让 cfbox 当 1 号进程、在模拟器里把整个系统启动起来):能让我们更有信心,但目前只是浅浅跑一下,以后再加深。 + +**2. 拿参考实现当 "标准答案":** +现在 "所有测试通过" 只能说明 "cfbox 的输出和写测试的人当初想的一样",**说明不了 "和 BusyBox/POSIX 一样"**。所以要做**对照测试**:把 cfbox 的输出和 BusyBox 或系统自带 coreutils 的输出逐字节比——后者就是 "标准答案"。`competition/busybox`(仓库里那棵完整 BusyBox 源码树)就是这个标准答案的来源。 + +**3. 设数量下限,而且只许升不许降:** +测试数量只能增加、不能偷偷删;新加的 applet 必须带测试;覆盖率指标等稳定下来之后,也只许升不许降。 + +## 二、每次改正确性相关的代码,走这四步 + +1. **造真实场景**:拿真实的用户用法、真正跑得多的路径来测,别写那种没意义的空转循环。 +2. **和标准答案对齐**:先在本地拿对照测试当安全网(改完代码跑一遍,看输出有没有偏离 BusyBox/GNU);挂到 CI 上是后面的事。 +3. **保证行为不变**:单元测试 + 对照测试都过了才合并。 +4. **加进回归集**:把新测试固定下来,防止以后又被改坏。 + +## 三、参考答案(标准答案)怎么选 + +- **冲突时怎么取舍**(沿用 [compatibility-policy.md](../todo/compatibility-policy.md)):POSIX 标准第一 > BusyBox 的高频行为 > GNU 的增强选项。比如 `ls -l` 的格式要对齐 BusyBox,而不是 GNU coreutils 那种更花哨的。所有 "故意不一样" 的差异都要写进文档。 +- **起步先用系统自带的 coreutils**(`/usr/bin/cat`、`/usr/bin/sort` 这些):零成本、机器上就有;而且 cat/head/tail/wc/sort 这几个流式命令,GNU 和 BusyBox 的输出基本一样。起步的目标只是 "抓输出漂移",不是 "认证 POSIX 合规"。`competition/busybox/testsuite/` 里那 69 个现成的测试用例,当输入素材补充进来。 +- **升级**:等遇到必须对齐 BusyBox 的命令(ls、find 这些),再编译 BusyBox 来当标准答案。 +- **和性能维度的关系(重要)**:将来一旦编译了 BusyBox,**同一份 BusyBox 二进制顺手也拿来当性能对比的基线**——比 cfbox 和 busybox 谁快、用 perf 看热点差异。一份二进制,既当正确性标准答案,又当性能参照。(性能那篇标尺还没立,到时候接上。) +- **必须有 "可接受的差异" 白名单**:对照测试要配一个 `tests/differential/known_diffs.yaml`,把 "本来就预期会不一样" 的地方(比如 grep 用 POSIX 正则、ls 列宽、df 列顺序)标成 "可接受的差异"。不然第一天跑起来全是噪音,没人会再看它。 + +## 四、检查门:哪些会挡合并,哪些只提醒 + +**会挡住合并的硬性检查(CI 里不通过就红):** + +- **测试数量下限**:用 `ctest -N` 数出测试个数,必须 ≥ `tests/expected_count.txt` 里记的数;[run_all.sh](../../tests/integration/run_all.sh) 里那个 `total_pass`(现在是个没人用、从不增加的死变量)要重新启用、加一个 "至少 N 个通过" 的下限。目的:防止有人偷偷删测试文件或删测试用例。 +- **新 applet 必须带测试**:CI 检查 "注册表(APPLET_REGISTRY)里每个命令名,都得有对应的 `test_<名字>.cpp` 或 `test_<名字>.sh`"。堵住 "现在有 24~29 个命令一个测试都没有" 这种缺口继续扩大。 +- **对照冒烟测试**:对 5 个流式命令(cat/head/tail/wc/sort)跑 "三样一起比"——标准输出、错误输出、退出码。**一开始只报告、不挡**(跑出差异只记录、不阻断合并),稳定几周后再升级成硬门。 + +**只提醒、不挡的软性检查:** + +- **代码覆盖率**:Debug 构建加上 `--coverage` 编译开关,用 gcovr 或 lcov 生成报告。**只看数据、不设门槛**;等稳定了再走 "只升不降"。 + +## 五、附录:CFBox 现在的实际情况(2026-06-28 查出来的) + +- **测试家底**:436 个 GTest 单元测试 + 56 个集成测试(但都是 "自己写预期值" 的那种)+ **0 个对照测试 + 0 个覆盖率插桩**。 +- **缺口**:24~29 个命令完全没有测试(ar/cpio/df/ed/install/mkfifo/mknod/pgrep/pidof/tee/sysctl/who 这些);没有测试数量下限门([run_all.sh:13](../../tests/integration/run_all.sh#L13) 的 `total_pass` 是死变量,只有 `total_fail` 被检查);集成测试里有 21 处用的是 "包含某段子串就算过" 的宽松匹配,会把列宽、数字对齐这种格式漂移漏过去;**[compatibility-policy.md 第 76-96 行](../todo/compatibility-policy.md#L76-L96)明明白白规定了一堆命令的退出码该怎么返回,但集成测试绝大多数只看标准输出、不看退出码**——规定和测试对不上。 +- **已知的真 bug**:`sort -n -r` 的排序比较器违反了 C++ 对排序器的要求(strict-weak-ordering,属于未定义行为),[sort.cpp:69-77](../../src/applets/sort.cpp#L69-L77)。这是本维度第一个要修的,而且按规矩必须等有了对照测试之后才动手修。 +- **CI 的隐患**:CI 的 native 那个任务,命令里没写 `-DCMAKE_BUILD_TYPE=Debug`([ci.yml:31](../../.github/workflows/ci.yml#L31)),结果 ASan/UBSan(地址/未定义行为检查)实际上**根本没在 CI 里跑**——本该有的安全网其实没张开。(这条归到性能/基建那篇标尺。) +- **标准答案资产在手但没接上**:`competition/busybox` 是完整的 BusyBox 源码树,还自带 `testsuite/` 里 69 个现成测试;phase-0a 那份文档把对照测试的整套设计都写好了,但一行代码都没落地。 + +## 六、确认这份标尺之后,按这个顺序干活 + +- **第 1 批**:搭最小的对照测试(5 个流式命令,拿系统 coreutils 当标准答案)+ 配 "可接受差异" 白名单。 +- **第 2 批**:加 "测试数量下限" 门 + "新 applet 必须带测试" 门。 +- **第 3 批**:在对照测试的保护下修 `sort -n -r` 那个未定义行为 bug,并补上和 coreutils `sort -n -r` 对照的用例。 +- (覆盖率插桩、编译 BusyBox 当性能基线,等到了性能维度再做。) diff --git a/document/ai/DIRECTIVES.md b/document/ai/DIRECTIVES.md index d1cf3a1..76f7ea0 100644 --- a/document/ai/DIRECTIVES.md +++ b/document/ai/DIRECTIVES.md @@ -20,6 +20,7 @@ - 命名:类型 `PascalCase`、函数/变量 `snake_case`、私有成员后缀 `_`、命名空间 `cfbox::*`(`base`/`fs`/`io`/`args`/`stream`/`applet`/`help`/`term`/`tui`/`proc`/`compress`/`checksum`/`awk`/`sh`/`init`/`utf`/`util`)、宏 `CFBOX_*`(`UPPER_SNAKE`)。 - 注释一律英文;机械风格以 [.clang-format](../../.clang-format) 为准,跑 clang-format 不手调。 - Result:`auto v = CFBOX_TRY(expr)`(宏展开判 `!v` 后 `return std::unexpected`);成功 `return value;` / `Result` 用 `return {};`;失败 `return std::unexpected(base::make_error(code, msg));`。 +- 测试 / 覆盖率 / 差分(对照)正确性标尺见 [COVERAGE.md](COVERAGE.md):三种"覆盖率"语义、对照测试(标准答案)、测试数量只升不降。 ## C. 操作模型(长期,Claude 主力开发) diff --git a/document/promo-v0.3.0.zh.md b/document/promo-v0.3.0.zh.md new file mode 100644 index 0000000..94782ca --- /dev/null +++ b/document/promo-v0.3.0.zh.md @@ -0,0 +1,146 @@ +# 一个月后的CFBox:CFBox v0.3.0发布,从QEMU的玩具走上了i.MX6ULL的真机启动 + +> 一个用现代 C++23 重写的 BusyBox 替代品。单二进制、零运行时依赖、能当 PID 1。 +> 从 v0.1.0 到 v0.3.0,它从一个「概念验证」长成了「能在真实硬件上开始尝试替代 BusyBox 的工具集」。 + +--- + +## TL;DR + +CFBox 用 C++23(`std::expected`、禁异常/RTTI、手写 deflate 替代 zlib)把 BusyBox 那套核心命令重做了一遍,编译出来**一个 418 KB 的单文件**,通过符号链接分发 123 个命令。v0.3.0 这次,我们让它**真的在 NXP i.MX6ULL 板子上当 PID 1,替代了 BusyBox**。 + +--- + +## v0.1.0 → v0.3.0,发生了什么 + +v0.2.0是一次小更新,所以的话我们跟v0.1.0的比。 + +先把两张牌亮在桌面上,所有数字都是我从仓库里现扒的——注册表条目数、`ctest` 实跑、size-opt strip 之后的体积,不是拍脑袋: + +| 指标 | v0.1.0 | v0.3.0 | 变化 | +|------|:------:|:------:|------| +| Applet 数 | 109 | **123** | +14 | +| GTest 单元测试 | 331 | **399** | +68 | +| size-opt 二进制 | 446 KB | **418 KB** | **−28 KB(−6.3%)** | +| 每 applet 均摊 | ~4.1 KB | **~3.4 KB** | 更省 | +| PID 1 验证 | QEMU aarch64 演示 | **i.MX6ULL 真机端到端** | 质变 | +| armhf 静态构建 | — | **~1.2 MB(自包含,直跑当 PID 1)** | 新增 | + +你大概率会先盯上第三行——命令多了 14 个,测试多了 68 个,二进制反而瘦了 28 KB。说实话这不太符合直觉,通常加功能就是往大了长,加着加着就膨胀了。但这回我们反着走,靠的不是砍东西,是 v0.2.0 那阵子把 C++ 这些年欠下的「隐性体积税」给补交了。这个事单独拎出来讲。 + +--- + +## 命令多了,二进制反而瘦了 + +先把最显眼的一个拎出来:iostream。这玩意儿在 C++ 二进制里是出了名的体积大头,但凡你 `#include` 过一次 ``,链接器就给你拽进来一大坨 locale、缓冲、格式化的运行时。v0.2.0 那轮我们下了狠手,把它整个家族——``、``、``——全量干掉,一个 fstream 符号都不留,文件读写全换回 `` 的 `FILE*` 配 `fgets` / `fprintf`。光这一刀,体积就松了一大截。 + +再一个是 `std::stoi`。这东西在 `-fno-exceptions` 底下藏着一个特别阴的坑:字符串解析失败的时候,它不给你返回错误码,而是直接 `abort()` 把整个进程干掉。放在一个普通工具里顶多是难用,但 CFBox 是要当 PID 1 的——init 进程被一个非法数字直接带走,那场面不要太刺激。所以全量换成 `std::strtol` 配 errno,雷拆了,异常运行时那一份开销也顺手省了。 + +剩下还有两块收尾的活。一块是把 100 多个 applet 各写各的 `fprintf(stderr, "cfbox xxx: ...")` 收敛到一个 `CFBOX_ERR` 宏,去重了一堆重复的格式化代码;另一块是给 `fs_util` 补上 `chown` / `lchown` / `for_each_entry()`,让 chmod、chown、chgrp 这几个不再各自把递归遍历重写一遍。 + +你会发现,这几件事没一件是「加功能」,全是把之前随手写的、能跑就行的那部分拧紧。但加起来的效果就是这么直白:命令越加越多,二进制反倒越来越小。这也是我们敢拿一个 C++ 项目,去和纯 C 的 BusyBox 掰体积的底气所在。 + +--- + +## 从 QEMU 里跑,到 i.MX6ULL 上板当 PID 1 + +这大概是 v0.3.0 花力气最多的一块,也是整个版本真正想交付的东西。 + +我们回头看 v0.1.0,`init` 这个 applet 其实早就有了,但老实说它当时更像一个「演示」——只在 QEMU aarch64 里把启动流程跑过一遍,本质上还是模拟器里的玩具。你真要把它搁到一块板子上、让它从上电那一刻起就作为 PID 1 把整个用户态管起来,中间差的那段路,远比「写一个能解析 inittab 的 init」要长得多。这一次,我们把这段路走通了:在 NXP i.MX6ULL(Cortex-A7,armhf)上,CFBox 作为 [imx-forge](../projects/imx-forge-demo) rootfs 的 PID 1 和工具集,撑起了完整一条启动闭环,实打实替代了 BusyBox。 + +为了把这条链路补全,我们新增了六个 applet,正好一个萝卜一个坑,卡在启动链的每个缺口上: + +| 启动阶段 | CFBox 承担 | +|---------|-----------| +| PID 1 | `init` —— 解析 busybox 格式 `/etc/inittab`,支持 `sysinit` / `askfirst` / `respawn` / `ctrlaltdel` / `shutdown` | +| rcS | `mount -a` / `mount -t devpts devpts /dev/pts` / `mdev -s`(冷启动扫 `/sys` 建 `/dev` 节点) | +| console | `askfirst` → 打印 `Please press Enter to activate this console.` → 回车 → 进 CFBox `sh` | +| 关机 | `umount -a -r` / `swapoff -a` / `reboot` | + +新加进来的这六个分别是:`mount`(`-a`/`-t`/`-o`,会读 fstab)、`mdev`(`-s` 冷启动扫描,照 `/sys` 给 `/dev` 补节点)、`umount`(`-a`/`-r`/`-f`)、`swapoff`,以及 `reboot` 和 `poweroff`。 + +启动起来之后的验证,是真的在板子上敲的那种,不是跑分: + +- `/proc/cpuinfo` 老老实实打印出 `ARMv7 Processor rev 5 (v7l)`,确认这玩意儿确实跑在 i.MX6ULL 上 +- 回车进 CFBox 自己的 `sh`,`ls` / `cat` / `df` / `ps` / `uname` / `free` 全部正常 dispatch,没一个是 BusyBox 在背后代劳 +- 从上电、rcS、askfirst 拉起 console,一直到关机那一下的 `umount -a -r` / `swapoff -a` / `reboot`,整条链路里 BusyBox 一次都没有介入 + +![cfbox on i.MX6ULL: rcS → askfirst → console](screenshots/enter_shell.png) + +事情到这里,CFBox 才算从「一个挺能打的练手项目」迈过了「能塞进真实嵌入式系统当组件用」那道坎。 + +--- + +## CFBox 到底想证明什么 + +说句心里话,CFBox 一直想验证的就一件事:用现代 C++,也能写出和 BusyBox 一样小、一样快、搞不好还更安全一点的 box。 + +手段其实都很笨、很基础,但每一条都是实打实省开销的。我们全局禁了异常和 RTTI(`-fno-exceptions -fno-rtti`),错误全部走 `cfbox::base::Result`(就是 `std::expected`)配 `CFBOX_TRY`,没有异常运行时那一坨零额外成本;对外零运行时依赖,压缩这边自己手写了一套轻量 deflate/inflate 把 zlib 替了,单文件静态链接就能扔出去。 + +性能这边也跑了一下,不是 PPT 上的数: + +| 操作 | 数据规模 | 耗时 | +|------|---------|------| +| `grep -c` | 10 MB | 54 ms | +| `cat` | 10 MB | 63 ms | +| `wc` | 10 MB | 17 ms | +| `sort` | 100K 行 | 32 ms | +| `diff` | 100K 行(相似文件) | 79 ms | + +这里头 grep、cat、wc 全是流式处理,喂 `/dev/urandom` 这种无限流不会内存爆炸;diff 用的是 Myers O(ND) 算法;grep 和 sed 用 POSIX 的 `regex_t` 顶掉了又慢又重的 `std::regex`;sort 则是预计算好排序 key,避免比较器里反复分配。工程纪律这块也不想糊弄:399 个 GTest 加 54 套集成脚本全绿,ASan 零泄漏,编译零 warning(`-Werror` 顶着),CI 覆盖原生构建、armhf / aarch64 交叉编译,以及 QEMU 的用户模式和系统模式。 + +--- + +## 拉个坐标看看自己站在哪 + +| 项目 | 语言 | 体积 | Applets | 体积/Applet | +|------|------|------|---------|-------------| +| **CFBox (size-opt)** | **C++23** | **418 KB** | **123** | **~3.4 KB** | +| Toybox | C | ~500 KB | 238 | ~2.1 KB | +| BusyBox (full) | C | ~1.7 MB | 274 | ~9 KB | +| uutils/coreutils | Rust | ~11 MB | ~100 | ~110 KB | + +CFBox 比 BusyBox 小 3 到 4 倍,而且在差不多这个体积下,该有的东西一样没落下:一个完整的 awk 解释器、一整套归档工具(tar/cpio/ar/unzip/gzip)、diff/patch、一整套进程工具(ps/top/pstree/pgrep/pmap),外加一个内置的 TUI 框架。 + +--- + +## 顺手填的几个坑 + +除了启动这条主线,v0.3.0 还顺手收拾了几个让 CFBox 在真终端上能正常用的坑。 + +一个是 `tail -f`。说真的,一个 box 要是没有 follow 模式,看日志基本没法用,v0.1.0 那会儿这功能是缺的。这次补的是 fd-based 的 follow:`fstat` 轮询配 64 KiB 的 quantum,`-F` 带一个 drain-switch 应对日志轮转,收到 SIGINT 就干净退出,而不是甩一个错误码出来恶心你。 + +另一个坑就特别有画面感了:串口控制台上,退格键按了不擦字。你想想,在串口终端上一个字一个字敲命令,敲错了一个退不掉,那血压直接拉满。根因其实不复杂,是 termios 里那个 `VERASE` 没设对,默认值不是 Ctrl-H,所以在串口上根本触发不了擦除。设成 `VERASE=Ctrl-H` 就好了。顺带把 cooked tty、默认 PATH、`ls -l` 的 owner/group 显示这几样也一起修了。 + +--- + +## 想自己跑一下的话 + +```bash +# 原生构建 + 测试 +cmake -B build && cmake --build build +ctest --test-dir build --output-on-failure # 399 个单元测试 +./build/cfbox echo "Hello, World!" + +# armhf 静态构建(自包含,可以直接当 PID 1 跑) +cmake -B build-armhf-static \ + -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/Toolchain-armhf.cmake \ + -DCMAKE_BUILD_TYPE=Release \ + -DCFBOX_OPTIMIZE_FOR_SIZE=ON \ + -DCFBOX_STATIC_LINK=ON +cmake --build build-armhf-static -j$(nproc) # 产物 ~1.2 MB +``` + +GitHub:,License 是 MIT。 + +--- + +## 接下来干嘛 + +v0.3.0 把「能当 PID 1」这条路打通了,接下来我们回到 Phase 2 的核心命令深化:`cp -a` 归档模式、全面的 POSIX `test`、`ls -R` 和 `--color`、`grep -A/-B/-C`、`find` 的布尔表达式、`sh` 的 case / heredoc / 函数。再往后就是 Phase 3 的网络最小闭环了。 + +我们的思路一直没变:不靠堆 applet 数量凑数,而是按真实运维频率,把那些高频命令的功能深度一点点做到位——场景闭环优先于数量。 + +--- + +走到真机上这一版,可以庆祝一下了。CFBox 是个会长期长下去的事情,欢迎来 GitHub 提 issue、提 PR,哪怕只是来聊聊「C++ 到底能不能写好一个 box」也行。完结撒花。 diff --git a/tests/differential/README.md b/tests/differential/README.md new file mode 100644 index 0000000..eec6898 --- /dev/null +++ b/tests/differential/README.md @@ -0,0 +1,39 @@ +# tests/differential — 对照冒烟测试 + +把 cfbox 的输出和"标准答案"(参考实现)逐字节比,抓"输出漂移"。整体方法和判定口径见 [document/ai/COVERAGE.md](../../document/ai/COVERAGE.md)。 + +## 跑 + +```bash +bash tests/differential/run_diff_smoke.sh +# 或指定被测二进制: +CFBOX=/path/to/cfbox bash tests/differential/run_diff_smoke.sh +``` + +## 结果标签 + +- **MATCH** — 标准输出 + 退出码与参考实现全一致。 +- **ACCEPTABLE** — 已登记、可接受的差异(如格式风格、POSIX 允许的行为差)。 +- **DEFECT** — 已登记、待修的缺陷(修好后从 `known_diffs` 删掉那一行)。 +- **NEW_DIFF** — 没见过的差异 → 脚本报错退出,提醒 triage。 + +## 当前覆盖(起步) + +5 个流式命令:cat / head / tail / wc / sort,参考答案用系统 coreutils(`type -P` 解析,绕开别名)。 + +- 输入走 stdin 重定向(暂不测文件名参数 / 多文件,留给后续)。 +- 判定口径:标准输出逐字节 + 退出码;stderr 只展示、不卡判定(报错措辞各实现不同,逐字节比是噪声)。 +- 全程 `LC_ALL=C`,排除 locale 排序差异。 + +## 加新用例 / triage 新差异 + +- 加用例:在 `run_diff_smoke.sh` 里加一行 `compare `。 +- triage:把 `NEW_DIFF` 对应的 `STATUS applet case_id` 写进 `known_diffs`(ACCEPTABLE 或 DEFECT)。 +- 修好一个 DEFECT 后,从 `known_diffs` 删掉那一行——对照测试会自动重新开始盯它。 + +## 当前已知差异摘要 + +见 `known_diffs`。首跑(2026-06-28)三类: +- `head`/`tail` 在结尾无换行的文件上凭空补换行(DEFECT,待修)。 +- `sort -rn` 比较器违反 strict-weak-ordering(DEFECT,第 3 批修)。 +- `wc` 列宽、`sort -n` 并列稳定性:格式 / POSIX 允许的差异(ACCEPTABLE)。 diff --git a/tests/differential/known_diffs b/tests/differential/known_diffs new file mode 100644 index 0000000..e740b06 --- /dev/null +++ b/tests/differential/known_diffs @@ -0,0 +1,26 @@ +# 对照冒烟测试 — 已登记差异(2026-06-28 首跑 triage) +# STATUS:ACCEPTABLE = 可接受(格式风格 / POSIX 允许的行为差) +# DEFECT = 待修缺陷(修好后把这一行删掉,对照测试会自动开始盯它) +# 字段:STATUS applet case_id(case_id 见 run_diff_smoke.sh 里 compare() 的第二个参数) + +# ── DEFECT:head/tail 在"结尾无换行"的文件上会凭空补一个换行,coreutils/BusyBox 保留原样 ── +DEFECT head notrail +DEFECT tail n2_notrail + +# ── DEFECT:sort -rn 比较器违反 strict-weak-ordering(未定义行为),数值并列项顺序被扰乱 ── +# (第 3 批在对照测试保护下修复;修复后删本行) +DEFECT sort rn_num + +# ── ACCEPTABLE:wc 输出列宽固定 8、空格右对齐;coreutils 用最小宽度。数字一致,纯格式差异 ── +# (等编译 BusyBox 后再核 BusyBox 的 wc 格式,决定是否对齐) +ACCEPTABLE wc small +ACCEPTABLE wc l_small +ACCEPTABLE wc w_small +ACCEPTABLE wc c_small +ACCEPTABLE wc m_small +ACCEPTABLE wc empty +ACCEPTABLE wc c_binary + +# ── ACCEPTABLE:sort -n 数值并列时 cfbox 保输入序(stable_sort),coreutils 用整行做 tiebreak ── +# POSIX 规定无 -s 时并列顺序 unspecified,cfbox 合规;是否对齐 GNU/BusyBox 待定 +ACCEPTABLE sort n_num diff --git a/tests/differential/run_diff_smoke.sh b/tests/differential/run_diff_smoke.sh new file mode 100755 index 0000000..22385e3 --- /dev/null +++ b/tests/differential/run_diff_smoke.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# 对照冒烟测试:把 cfbox 和系统 coreutils 在 5 个流式命令上比"标准输出 + 退出码"。 +# 标签:MATCH(全一致) / ACCEPTABLE(已登记、可接受的差异) / DEFECT(已登记、待修的缺陷) +# / NEW_DIFF(没见过的差异 → 脚本报错退出,提醒 triage)。 +# 详见 document/ai/COVERAGE.md。本期只本地跑;接入 CI 留给后续批次。 +# +# 判定口径:标准输出逐字节一致 AND 退出码相等 → MATCH。 +# 错误输出(stderr)只展示、不参与判定——cfbox 报错是 "cfbox :" 前缀,coreutils 是 ":", +# 报错措辞本就各写各的,逐字节比会是纯噪声。stderr 严格化是以后的事。 +set -u + +script_dir="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$script_dir/../.." && pwd)" +CFBOX="${CFBOX:-$repo/build/cfbox}" + +export LC_ALL=C # 排除 locale 排序差异,双方都按字节序 + +if [[ -t 1 ]]; then + C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_GRN=$'\033[32m'; C_RST=$'\033[0m' +else + C_RED=''; C_YEL=''; C_GRN=''; C_RST='' +fi + +if [[ ! -x "$CFBOX" ]]; then + echo "ERROR: cfbox 不在 $CFBOX(设 CFBOX 环境变量,或先 cmake --build build)" >&2 + exit 2 +fi + +# 解析 oracle 绝对路径(type -P 绕开 alias/函数,例如本机 cat 被别名成 bat) +declare -A ORA +for app in cat head tail wc sort; do + p="$(type -P "$app" 2>/dev/null || true)" + if [[ -z "$p" ]]; then + echo "ERROR: 系统 PATH 里找不到 $app" >&2; exit 2 + fi + ORA[$app]="$p" +done + +# 固定样本:每次现造在临时目录,干净、可复现、不提交二进制 +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +: > "$tmp/empty" +printf 'a\nbb\nccc\ndd\ne\n' > "$tmp/small" # 5 行 +printf '2 b\n2 a\n1 x\n2 c\n10 z\n9 y\n' > "$tmp/numericties" # 含数值并列(2 出现三次),专测 sort 稳定性 +printf 'c\na\nb\na\nc\nb\n' > "$tmp/dups" # 含重复,测 sort -u +printf 'line1\nline2\nline3' > "$tmp/notrailnl" # 结尾无换行 +printf 'ab\x00cd\nef\n' > "$tmp/binary" # 含 NUL 字节 + +# known_diffs:每行 "STATUS applet case_id",STATUS ∈ ACCEPTABLE|DEFECT(# 开头是注释) +declare -A KNOWN_STATUS +load_known() { + local f="$script_dir/known_diffs" st ap cid + [[ -r "$f" ]] || return 0 + while read -r st ap cid; do + [[ -z "$st" || "$st" == \#* ]] && continue + KNOWN_STATUS["$ap|$cid"]="$st" + done < "$f" +} +load_known + +n_match=0; n_acceptable=0; n_defect=0; n_new=0 +new_cases=() + +# compare +compare() { + local applet="$1" cid="$2" fixture="$3"; shift 3 + local -a args=("$@") + + local cf_out="$tmp/cf.out" cf_err="$tmp/cf.err" or_out="$tmp/or.out" or_err="$tmp/or.err" + local cf_rc=0 or_rc=0 + "$CFBOX" "$applet" "${args[@]}" < "$fixture" > "$cf_out" 2> "$cf_err" || cf_rc=$? + "${ORA[$applet]}" "${args[@]}" < "$fixture" > "$or_out" 2> "$or_err" || or_rc=$? + + local stdout_match=1 rc_match=1 + cmp -s "$cf_out" "$or_out" || stdout_match=0 + [[ "$cf_rc" == "$or_rc" ]] || rc_match=0 + + local status + if (( stdout_match && rc_match )); then + status=MATCH + else + local key="$applet|$cid" + if [[ -n "${KNOWN_STATUS[$key]:-}" ]]; then + status="${KNOWN_STATUS[$key]}" + else + status=NEW_DIFF + fi + fi + + local tag + case "$status" in + MATCH) n_match=$((n_match+1)); tag="${C_GRN}MATCH${C_RST}" ;; + ACCEPTABLE) n_acceptable=$((n_acceptable+1)); tag="${C_YEL}ACCEPTABLE${C_RST}" ;; + DEFECT) n_defect=$((n_defect+1)); tag="${C_RED}DEFECT${C_RST}" ;; + NEW_DIFF) n_new=$((n_new+1)); new_cases+=("$applet|$cid"); tag="${C_RED}NEW_DIFF${C_RST}" ;; + esac + + printf '%s %-6s %-16s args=[%s]\n' "$tag" "$applet" "$cid" "${args[*]}" + if [[ "$status" != MATCH ]]; then + local sout="same" srct="same" + (( stdout_match )) || sout="DIFF" + (( rc_match )) || srct="DIFF" + printf ' rc: cfbox=%s oracle=%s [%s] | stdout [%s]\n' "$cf_rc" "$or_rc" "$srct" "$sout" + if (( ! stdout_match )); then + printf ' --- cfbox stdout ---\n' + sed 's/^/ | /' "$cf_out" 2>/dev/null | head -8 + printf ' --- oracle stdout ---\n' + sed 's/^/ | /' "$or_out" 2>/dev/null | head -8 + fi + printf '\n' + fi +} + +echo "cfbox : $CFBOX" +echo "oracle: cat=$(command -v cat 2>/dev/null) (脚本内用 type -P 绕开别名)" +echo + +echo "=== cat ===" +compare cat basic "$tmp/empty" +compare cat small "$tmp/small" +compare cat n_small "$tmp/small" -n +compare cat b_small "$tmp/small" -b +compare cat A_small "$tmp/small" -A +compare cat binary "$tmp/binary" +compare cat notrail "$tmp/notrailnl" + +echo "=== head ===" +compare head n2_small "$tmp/small" -n 2 +compare head n100_small "$tmp/small" -n 100 +compare head c3_small "$tmp/small" -c 3 +compare head notrail "$tmp/notrailnl" +compare head n2_num "$tmp/numericties" -n 2 + +echo "=== tail ===" +compare tail n2_small "$tmp/small" -n 2 +compare tail n2_notrail "$tmp/notrailnl" -n 2 +compare tail c3_small "$tmp/small" -c 3 +compare tail n2_num "$tmp/numericties" -n 2 + +echo "=== wc ===" +compare wc small "$tmp/small" +compare wc l_small "$tmp/small" -l +compare wc w_small "$tmp/small" -w +compare wc c_small "$tmp/small" -c +compare wc m_small "$tmp/small" -m +compare wc empty "$tmp/empty" +compare wc c_binary "$tmp/binary" -c + +echo "=== sort ===" +compare sort small "$tmp/small" +compare sort n_num "$tmp/numericties" -n +compare sort r_small "$tmp/small" -r +compare sort rn_num "$tmp/numericties" -rn +compare sort u_dups "$tmp/dups" -u + +echo +echo "================================================================" +printf '汇总: MATCH=%s ACCEPTABLE=%s DEFECT=%s NEW_DIFF=%s\n' \ + "$n_match" "$n_acceptable" "$n_defect" "$n_new" +if (( n_new > 0 )); then + echo "${C_RED}出现未登记的新差异(见上 NEW_DIFF):${C_RST}" + printf ' - %s\n' "${new_cases[@]}" + echo "请 triage 后写进 known_diffs(ACCEPTABLE 或 DEFECT)。" + exit 1 +fi +echo "${C_GRN}无新差异(全部一致,或差异已登记进 known_diffs)。${C_RST}" +exit 0