Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
16 changes: 12 additions & 4 deletions src/applets/head.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ constexpr cfbox::help::HelpEntry HELP = {
.extra = "",
};

auto head_lines(const std::vector<std::string>& lines, long n) -> void {
auto head_lines(const std::vector<std::string>& lines, long n, bool trailing_nl) -> void {
long count = (n >= 0) ? n : static_cast<long>(lines.size()) + n;
if (count < 0) count = 0;
for (long i = 0; i < count && i < static_cast<long>(lines.size()); ++i) {
std::printf("%s\n", lines[static_cast<std::size_t>(i)].c_str());
long total = static_cast<long>(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<std::size_t>(i)].c_str(), stdout);
} else {
std::printf("%s\n", lines[static_cast<std::size_t>(i)].c_str());
}
}
}

Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 5 additions & 5 deletions src/applets/sort.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ auto sort_lines(std::vector<std::string>& 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<std::string> sorted;
Expand Down
27 changes: 18 additions & 9 deletions src/applets/tail.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,25 @@ constexpr cfbox::help::HelpEntry HELP = {

// ===== static (non-follow) tail =====================================

auto tail_lines(const std::vector<std::string>& lines, long n, bool from_start) -> void {
auto tail_lines(const std::vector<std::string>& 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<long>(lines.size()) - 1 && !trailing_nl) {
std::fputs(lines[static_cast<std::size_t>(i)].c_str(), stdout);
} else {
std::printf("%s\n", lines[static_cast<std::size_t>(i)].c_str());
}
};
if (from_start) {
long start = n - 1;
if (start < 0) start = 0;
for (long i = start; i < static_cast<long>(lines.size()); ++i) {
std::printf("%s\n", lines[static_cast<std::size_t>(i)].c_str());
}
for (long i = start; i < static_cast<long>(lines.size()); ++i) emit(i);
} else {
if (n <= 0) return;
long start = static_cast<long>(lines.size()) - n;
if (start < 0) start = 0;
for (long i = start; i < static_cast<long>(lines.size()); ++i) {
std::printf("%s\n", lines[static_cast<std::size_t>(i)].c_str());
}
for (long i = start; i < static_cast<long>(lines.size()); ++i) emit(i);
}
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -311,7 +317,10 @@ auto follow_files(std::vector<std::string> 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;
Expand Down
88 changes: 88 additions & 0 deletions tests/check_test_floor.sh
Original file line number Diff line number Diff line change
@@ -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_<name>.cpp 或 test_<name>.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_<name>.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
17 changes: 8 additions & 9 deletions tests/differential/known_diffs
Original file line number Diff line number Diff line change
@@ -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<a)。剩下的并列顺序差异是和
# sort -n 同类的、POSIX 允许的稳定性差异(无 -s 时并列顺序 unspecified),故改判 ACCEPTABLE。
ACCEPTABLE sort rn_num

# ── ACCEPTABLE:wc 输出列宽固定 8、空格右对齐;coreutils 用最小宽度。数字一致,纯格式差异 ──
# (等编译 BusyBox 后再核 BusyBox 的 wc 格式,决定是否对齐)
Expand All @@ -21,6 +21,5 @@ 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 数值并列时 cfbox 保输入序,coreutils 用整行做 tiebreak(POSIX 允许)──
ACCEPTABLE sort n_num
7 changes: 7 additions & 0 deletions tests/expected_counts.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 测试数量基线(防静默删测试的地板门)。只许升、不许降:加了测试就把对应数字 bump 上去。
# 由 tests/check_test_floor.sh 读取校验。详见 document/ai/COVERAGE.md。
# GTest 数取自 `ctest -N` 的 "Total Tests:" 行;集成脚本数取自 tests/integration/test_*.sh。
# 2026-06-28 首次钉死真值 436+54(之前文档在 436/399、56/54 之间漂移);
# 同日 sort -rn 修复时新增 SortTest.ReverseNumericStableOnTies → 437:
gtest 437
integration 54
13 changes: 13 additions & 0 deletions tests/unit/test_sort.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,17 @@ TEST(SortTest, EmptyFile) {
EXPECT_EQ(out, "");
}

TEST(SortTest, ReverseNumericStableOnTies) {
TempDir tmp;
auto f = tmp.write_file("data.txt", "2 b\n2 a\n1 x\n2 c\n10 z\n9 y\n");
char a0[] = "sort", a1[] = "-r", a2[] = "-n", a3[256];
std::snprintf(a3, sizeof(a3), "%s", f.c_str());
char* argv[] = {a0, a1, a2, a3};
auto out = capture_stdout([&]{ return sort_main(4, argv); });
// 数值降序;三个并列的 "2" 保持输入序 b,a,c。
// 修前比较器用 `!less`,对相等数值键 (a,b) 与 (b,a) 都返回 true,违反
// strict-weak-ordering(std::stable_sort 的 UB),顺序会被打乱。
EXPECT_EQ(out, "10 z\n9 y\n2 b\n2 a\n2 c\n1 x\n");
}

#endif // CFBOX_ENABLE_SORT
35 changes: 35 additions & 0 deletions tests/untested_applets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 暂无测试的 applet 名单("新 applet 必须带测试"门的 grandfather 基线,老人老办法)。
# 由 tests/check_test_floor.sh 读取。规则:一个 applet 既没有 test_<name>.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
Loading