diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a8164a..608ff6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: restore-keys: ccache-native-${{ runner.os }}- - name: Configure (Debug) - run: cmake -B build -DCMAKE_CXX_COMPILER=g++-13 -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + run: cmake -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++-13 -DCMAKE_CXX_COMPILER_LAUNCHER=ccache env: CCACHE_MAXSIZE: 256M @@ -41,6 +41,9 @@ jobs: - name: Integration tests run: bash tests/integration/run_all.sh + - name: Test floor gates (count floor + new-applet-must-have-tests) + run: bash tests/check_test_floor.sh + - name: Upload test results if: always() uses: actions/upload-artifact@v4 diff --git a/src/applets/head.cpp b/src/applets/head.cpp index 0fc17b5..5c38b02 100644 --- a/src/applets/head.cpp +++ b/src/applets/head.cpp @@ -19,11 +19,18 @@ constexpr cfbox::help::HelpEntry HELP = { .extra = "", }; -auto head_lines(const std::vector& lines, long n) -> void { +auto head_lines(const std::vector& lines, long n, bool trailing_nl) -> void { long count = (n >= 0) ? n : static_cast(lines.size()) + n; if (count < 0) count = 0; - for (long i = 0; i < count && i < static_cast(lines.size()); ++i) { - std::printf("%s\n", lines[static_cast(i)].c_str()); + long total = static_cast(lines.size()); + for (long i = 0; i < count && i < total; ++i) { + // Only the file's actual last line can lack a terminator; preserve those + // bytes instead of synthesizing a newline (coreutils/head behavior). + if (i == total - 1 && !trailing_nl) { + std::fputs(lines[static_cast(i)].c_str(), stdout); + } else { + std::printf("%s\n", lines[static_cast(i)].c_str()); + } } } @@ -53,7 +60,8 @@ auto head_file(std::string_view path, long n_lines, long n_bytes, if (use_lines) { auto lines = cfbox::io::split_lines(content); - head_lines(lines, n_lines); + bool trailing_nl = !content.empty() && content.back() == '\n'; + head_lines(lines, n_lines, trailing_nl); } else { head_bytes(content, n_bytes); } diff --git a/src/applets/sort.cpp b/src/applets/sort.cpp index 9f7070d..2aceff4 100644 --- a/src/applets/sort.cpp +++ b/src/applets/sort.cpp @@ -67,13 +67,13 @@ auto sort_lines(std::vector& lines, const SortOptions& opts) -> voi } std::stable_sort(entries.begin(), entries.end(), [&](const Entry& a, const Entry& b) { - bool less; + // Reverse must swap the operands (b < a), not negate (`!less`): negation makes + // the predicate true for equal keys, violating strict-weak-ordering and turning + // std::stable_sort into UB on numeric ties. Stable sort keeps equal-key order. if (opts.numeric) { - less = a.num_val < b.num_val; - } else { - less = a.key < b.key; + return opts.reverse ? b.num_val < a.num_val : a.num_val < b.num_val; } - return opts.reverse ? !less && a.key != b.key : less; + return opts.reverse ? b.key < a.key : a.key < b.key; }); std::vector sorted; diff --git a/src/applets/tail.cpp b/src/applets/tail.cpp index f806cb6..fd79300 100644 --- a/src/applets/tail.cpp +++ b/src/applets/tail.cpp @@ -35,20 +35,25 @@ constexpr cfbox::help::HelpEntry HELP = { // ===== static (non-follow) tail ===================================== -auto tail_lines(const std::vector& lines, long n, bool from_start) -> void { +auto tail_lines(const std::vector& lines, long n, bool from_start, bool trailing_nl) -> void { + auto emit = [&](long i) { + // Only the file's actual last line can lack a terminator; preserve those + // bytes instead of synthesizing a newline (coreutils/tail behavior). + if (i == static_cast(lines.size()) - 1 && !trailing_nl) { + std::fputs(lines[static_cast(i)].c_str(), stdout); + } else { + std::printf("%s\n", lines[static_cast(i)].c_str()); + } + }; if (from_start) { long start = n - 1; if (start < 0) start = 0; - for (long i = start; i < static_cast(lines.size()); ++i) { - std::printf("%s\n", lines[static_cast(i)].c_str()); - } + for (long i = start; i < static_cast(lines.size()); ++i) emit(i); } else { if (n <= 0) return; long start = static_cast(lines.size()) - n; if (start < 0) start = 0; - for (long i = start; i < static_cast(lines.size()); ++i) { - std::printf("%s\n", lines[static_cast(i)].c_str()); - } + for (long i = start; i < static_cast(lines.size()); ++i) emit(i); } } @@ -79,7 +84,8 @@ auto tail_file(std::string_view path, long n, bool use_bytes, tail_bytes(content, n); } else { auto lines = cfbox::io::split_lines(content); - tail_lines(lines, n, from_start); + bool trailing_nl = !content.empty() && content.back() == '\n'; + tail_lines(lines, n, from_start, trailing_nl); } return 0; } @@ -311,7 +317,10 @@ auto follow_files(std::vector files, long n, bool use_bytes, if (use_bytes) { tail_bytes(content, n); } else { - tail_lines(cfbox::io::split_lines(content), n, from_start); + // Follow streams appended bytes raw after this initial tail, so keep + // the historical always-newline behavior here; the trailing-newline + // fix only applies to the static tail_file path (what we test/diff). + tail_lines(cfbox::io::split_lines(content), n, from_start, true); } std::fflush(stdout); ff.offset = ff.regular ? ::lseek(ff.fd.get(), 0, SEEK_END) : 0; diff --git a/tests/check_test_floor.sh b/tests/check_test_floor.sh new file mode 100755 index 0000000..0a07429 --- /dev/null +++ b/tests/check_test_floor.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# tests/check_test_floor.sh — 覆盖率的"测试地板"两道硬门(见 document/ai/COVERAGE.md §4)。 +# +# 门 1 测试数量下限:GTest 数、集成脚本数不得低于 tests/expected_counts.txt 的基线。 +# 防的是"有人偷偷删测试文件 / 删 TEST() 用例,CI 仍然全绿"。 +# 门 2 新 applet 必须带测试:APPLET_REGISTRY 里每个命令都要有 test_.cpp 或 test_.sh; +# 没有的,必须在 tests/untested_applets.txt 里登记(老人老办法)。 +# 新加的、既没测试又没登记的 applet → 挂。 +# +# 本地、CI 都能跑。CI 挂在 native job 末尾,非零退出即红。 +# 只读测试与源码,不改任何东西。 +set -u + +script_dir="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +build_dir="${BUILD_DIR:-$repo/build}" +cd "$repo" + +# 共享主命令入口的别名:不单独要求测试(归并到主命令,如 [ / pkill / poweroff) +ALIASES='[ pkill poweroff' + +have_test() { [[ -f "tests/unit/test_$1.cpp" || -f "tests/integration/test_$1.sh" ]]; } +is_alias() { local x; for x in $ALIASES; do [[ "$1" == "$x" ]] && return 0; done; return 1; } + +fail=0 + +# ── 门 1:测试数量下限 ────────────────────────────────────────── +echo "=== 门 1:测试数量下限(不得低于基线 tests/expected_counts.txt)===" + +expected_gtest=""; expected_int="" +if [[ -r tests/expected_counts.txt ]]; then + while read -r k v; do + [[ -z "$k" || "$k" == \#* ]] && continue + case "$k" in gtest) expected_gtest=$v ;; integration) expected_int=$v ;; esac + done < tests/expected_counts.txt +fi + +cur_gtest="" +if ctest --test-dir "$build_dir" -N >/dev/null 2>&1; then + cur_gtest=$(ctest --test-dir "$build_dir" -N 2>/dev/null | awk '/Total Tests:/ {print $3; exit}') +fi +cur_int=$(ls tests/integration/test_*.sh 2>/dev/null | wc -l | tr -d ' ') + +if [[ -z "$cur_gtest" ]]; then + echo " WARN: $build_dir 没配置或没有测试二进制,跳过 GTest 计数门(先 cmake --build $build_dir)" +else + echo " GTest : 当前=$cur_gtest 基线=${expected_gtest:-?}" + if [[ -n "$expected_gtest" && "$cur_gtest" -lt "$expected_gtest" ]]; then + echo " FAIL: GTest 数 $cur_gtest < 基线 $expected_gtest(有人删测试了?)"; fail=1 + fi +fi +echo " 集成脚本 : 当前=$cur_int 基线=${expected_int:-?}" +if [[ -n "$expected_int" && "$cur_int" -lt "$expected_int" ]]; then + echo " FAIL: 集成脚本数 $cur_int < 基线 $expected_int(有人删脚本了?)"; fail=1 +fi + +# ── 门 2:新 applet 必须带测试 ────────────────────────────────── +echo "=== 门 2:新 applet 必须带测试(无测试的须登记在 tests/untested_applets.txt)===" + +mapfile -t applets < <(grep -oE '\{"[a-z0-9[]+' include/cfbox/applets.hpp | cut -c3- | sort -u) +declare -A grandfathered=() +if [[ -r tests/untested_applets.txt ]]; then + while read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + grandfathered["$line"]=1 + done < tests/untested_applets.txt +fi + +new_bare=0; stale=0 +for a in "${applets[@]}"; do + is_alias "$a" && continue + if have_test "$a"; then + if [[ -n "${grandfathered[$a]:-}" ]]; then + echo " 过期: $a 现在已有测试,建议从 untested_applets.txt 删掉该行"; stale=$((stale+1)) + fi + else + if [[ -z "${grandfathered[$a]:-}" ]]; then + echo " FAIL: $a 既无 test_.cpp/.sh,也没登记在 untested_applets.txt(新 applet 必须带测试)" + new_bare=$((new_bare+1)); fail=1 + fi + fi +done +echo " applet 名总数(含别名)=${#applets[@]}; 已登记未测=${#grandfathered[@]}; 过期登记=$stale; 裸奔新增=$new_bare" + +echo "===" +if (( fail )); then echo "测试地板门:FAIL"; exit 1; fi +echo "测试地板门:PASS" +exit 0 diff --git a/tests/differential/known_diffs b/tests/differential/known_diffs index e740b06..a836a35 100644 --- a/tests/differential/known_diffs +++ b/tests/differential/known_diffs @@ -1,15 +1,15 @@ -# 对照冒烟测试 — 已登记差异(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 +# ── 已修复(2026-06-28):head/tail 在结尾无换行的文件上凭空补换行 → 现 MATCH,两条 DEFECT 已删 ── -# ── DEFECT:sort -rn 比较器违反 strict-weak-ordering(未定义行为),数值并列项顺序被扰乱 ── -# (第 3 批在对照测试保护下修复;修复后删本行) -DEFECT sort rn_num +# ── ACCEPTABLE:sort -rn 数值并列时 cfbox 保输入序(stable_sort),coreutils 用整行做 tiebreak ── +# 原先是 DEFECT:比较器 `!less` 对相等数值键 (a,b) 与 (b,a) 都返 true,违反 strict-weak-ordering +# (std::stable_sort 的 UB)。UB 已修(反向改成交换操作数 b.cpp/.sh, +# 又不在这个清单里 → CI 挂。所以新加的裸奔 applet 会立刻被挡。 +# 这是"只升不降"的棘轮:给下面某个 applet 补上测试后,就把那一行删掉,让门开始盯它。 +# 名单取自 2026-06-28 实测(注册表对 test_*.cpp/.sh 做集合差,别名 [ pkill poweroff 归并到主命令)。 +ar +cpio +df +ed +env +expand +fold +gunzip +init +install +mkfifo +mknod +nice +nohup +patch +pgrep +pidof +shuf +split +stat +sync +sysctl +tac +tee +timeout +tsort +unlink +unzip +usleep +who