Skip to content

Commit cb20035

Browse files
committed
test(mutate): Add mutation-testing harness and strengthen cobs/retry
Mutation testing measures test strength (would a test notice if a line were wrong?), complementing coverage (reach) and fuzzing (memory/UB). Harness (tools/mutation-testing/mutate.py, wired as `ci.sh --mutate`): - libclang AST finds mutation sites, so a comparison `<` is mutated but a template `<` is not, and strings/comments are untouched; un-typecheckable mutants are excluded from the score rather than miscounted as survivors. - Each mutant builds against a shadow include tree (a symlink mirror of include/ with only the mutated header materialised, used as the sole include root) so angle and quote includes resolve to the same file and #pragma once still dedupes. A flat -I overlay -I include lets quoted includes slip past to the real header and double-include it. - doctest_main.o is cached; covering test TUs run fastest-first and short-circuit at the first kill, keeping the common case cheap. - On-demand audit, NOT a per-commit gate: minutes long and scores are noisy (equivalent mutants survive legitimately and need triage). Self-skips when python3 / clang.cindex / a compiler is absent. Durable output is the assertions the audit drove into the suite: - retry.hpp 66.7% -> 97.2%: TransactionTiming defaults; in-loop early-return timing record (the timing tests used max_retries=0, which skips the loop entirely); timeout budget measured relative to a non-zero start clock; inclusive timeout boundary; the timeout_ms==1 edge -- for both retry_loop and retry_transaction. The lone remaining survivor is an equivalent mutant (the wait is zero-length at the gap boundary). - cobs.hpp 75.9% -> 79.3%: an exact-length sweep for cobs_encoded_length (block-code accounting), a test for the previously-untested scratch-buffer encode() overload, and an incremental-flush assertion. Remaining survivors are equivalent mutants or ASan-territory buffer bounds, not missing assertions. Full host and Arduino test suites pass.
1 parent 96df7f2 commit cb20035

5 files changed

Lines changed: 917 additions & 0 deletions

File tree

ci.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ ROOT="$(cd "$(dirname "$0")" && pwd)"
1616
# ./ci.sh --coverage Build with coverage instrumentation and generate report
1717
# ./ci.sh --integrations Build and run JSON backend integration tests (host)
1818
# ./ci.sh --fuzz Fuzz the JSON/JSONB parsers under ASan/UBSan
19+
# ./ci.sh --mutate Mutation-test the correctness-critical headers
20+
# (test-strength audit; on-demand, NOT a gate).
21+
# Extra args pass through, e.g. --mutate --all or
22+
# --mutate --target jsonb
1923
# ./ci.sh --hil Hardware-in-the-loop sweep — upload + run all
2024
# test groups on $PIO_LABGRID_DEVICE for the
2125
# `serial` and `i2c` envs in tests/integration/firmware
@@ -1239,6 +1243,32 @@ run_fuzz() {
12391243
echo "Parser fuzzing passed (corpus + ${iters}x3 iterations)."
12401244
}
12411245

1246+
# ── Mutation testing ──────────────────────────────────────────────────────
1247+
# Deliberately corrupt one operator/literal at a time in the correctness-
1248+
# critical headers, rebuild the covering host tests, and check a test fails
1249+
# ("kills" the mutant). A surviving mutant = a weak/missing assertion (or a
1250+
# legitimate equivalent mutant). This measures test *strength*, complementing
1251+
# coverage (reach) and fuzzing (memory/UB on bad input).
1252+
#
1253+
# On-demand audit, NOT a per-commit gate: it is minutes long and scores are
1254+
# noisy (equivalent mutants survive and need human triage). It exits 0 even
1255+
# with survivors; the durable output is the assertions the audit drives into
1256+
# the suite. Self-skips when python3 / clang.cindex / a compiler is absent.
1257+
# Pass-through args ($@) reach the harness (e.g. --all, --target NAME).
1258+
run_mutate() {
1259+
ci_stage "Mutation testing (test strength)"
1260+
if ! command -v python3 >/dev/null 2>&1; then
1261+
echo " no python3 found — skipping mutation testing."
1262+
return 0
1263+
fi
1264+
if ! python3 -c "import clang.cindex" >/dev/null 2>&1; then
1265+
echo " clang.cindex not installed — skipping mutation testing."
1266+
echo " enable with: python3 -m pip install libclang"
1267+
return 0
1268+
fi
1269+
python3 "$ROOT/tools/mutation-testing/mutate.py" "$@"
1270+
}
1271+
12421272
run_full() {
12431273
# Host CI + hardware sweep. The hardware step is self-skipping when
12441274
# $PIO_LABGRID_DEVICE isn't set, so this stays safe in headless
@@ -1271,6 +1301,10 @@ case "${1:-}" in
12711301
--fuzz)
12721302
run_and_log run_fuzz
12731303
;;
1304+
--mutate)
1305+
shift
1306+
run_and_log run_mutate "$@"
1307+
;;
12741308
--hil)
12751309
run_and_log run_integration_hardware
12761310
;;

tests/test_cobs.cpp

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,72 @@ TEST_CASE("COBS: encoder does not modify source") {
212212

213213
REQUIRE(memcmp(data, copy, sizeof(data)) == 0);
214214
}
215+
216+
// ---------------------------------------------------------------------------
217+
// Strength tests surfaced by mutation testing (tools/mutation-testing).
218+
// ---------------------------------------------------------------------------
219+
220+
TEST_CASE("COBS: cobs_encoded_length equals actual encoded length (sweep)") {
221+
// cobs_encoded_length feeds the JSON handshake `cobs` field, so it must
222+
// equal the number of bytes the encoder actually emits (excluding EOP) for
223+
// every length and zero pattern — especially the block-boundary code
224+
// accounting around multiples of 254. A coarse spot-check leaves the
225+
// code-reset arithmetic unconstrained; this sweep pins it.
226+
auto check = [](const std::vector<uint8_t>& d) {
227+
size_t predicted = note::cobs_encoded_length(d.data(), d.size());
228+
size_t actual = stream_encode(d.data(), d.size()).size();
229+
CAPTURE(d.size());
230+
CHECK(predicted == actual);
231+
};
232+
for (size_t len = 0; len <= 600; ++len) {
233+
std::vector<uint8_t> d(len);
234+
// Dense non-zero data — forces 254-byte block splits (code reaches 0xFF).
235+
for (size_t i = 0; i < len; ++i) d[i] = static_cast<uint8_t>((i % 255) + 1);
236+
check(d);
237+
// Periodic zeros — forces zero-driven block boundaries.
238+
for (size_t i = 0; i < len; ++i)
239+
d[i] = static_cast<uint8_t>((i % 7 == 0) ? 0 : (i % 251) + 1);
240+
check(d);
241+
}
242+
}
243+
244+
TEST_CASE("COBS: scratch-buffer encode overload matches the stack-buffer overload") {
245+
// The encode(src, len, scratch, flush) overload (caller-provided buffer) is
246+
// a separate code path from the stack-buffer encode(); it must produce the
247+
// identical encoding and still round-trip.
248+
std::vector<uint8_t> data(600);
249+
for (size_t i = 0; i < data.size(); ++i)
250+
data[i] = static_cast<uint8_t>((i % 5 == 0) ? 0 : (i * 7 + 3) % 256);
251+
252+
note::CobsEncoder enc;
253+
uint8_t scratch[NOTE_COBS_BLOCK_SIZE];
254+
std::vector<uint8_t> via_scratch;
255+
enc.encode(data.data(), data.size(), note::byte_span(scratch, sizeof(scratch)),
256+
[&](const uint8_t* b, size_t n) { via_scratch.insert(via_scratch.end(), b, b + n); });
257+
258+
CHECK(via_scratch == stream_encode(data.data(), data.size()));
259+
260+
auto decoded = stream_decode(via_scratch.data(), via_scratch.size());
261+
REQUIRE(decoded.size() == data.size());
262+
CHECK(memcmp(decoded.data(), data.data(), data.size()) == 0);
263+
}
264+
265+
TEST_CASE("COBS: decoder emits incrementally for large payloads") {
266+
// The decoder bounds its fixed working buffer by flushing as blocks fill,
267+
// rather than buffering the whole payload before emitting once.
268+
std::vector<uint8_t> data(2000);
269+
for (size_t i = 0; i < data.size(); ++i) data[i] = static_cast<uint8_t>((i * 5 + 1) % 256);
270+
auto encoded = ref_encode(data.data(), data.size());
271+
272+
note::CobsDecoder decoder;
273+
int sink_calls = 0;
274+
std::vector<uint8_t> decoded;
275+
auto sink = [&](const uint8_t* b, size_t n) { ++sink_calls; decoded.insert(decoded.end(), b, b + n); };
276+
decoder.feed(encoded.data(), encoded.size(), sink);
277+
uint8_t term = note::cobs_eop;
278+
decoder.feed(&term, 1, sink);
279+
280+
CHECK(sink_calls > 1);
281+
REQUIRE(decoded.size() == data.size());
282+
CHECK(memcmp(decoded.data(), data.data(), data.size()) == 0);
283+
}

tests/test_retry.cpp

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,125 @@ TEST_CASE("retry_loop: records timing on failure") {
542542
CHECK(timing.last_transaction_end_ms == 200);
543543
}
544544

545+
// ---------------------------------------------------------------------------
546+
// Timing defaults and timeout-boundary edges
547+
//
548+
// These pin behaviour that the matrix above leaves unconstrained — surfaced by
549+
// mutation testing (tools/mutation-testing): the default gap, the in-loop
550+
// early-return timing record, the relative-to-start timeout math, and the
551+
// inclusive timeout boundary all survived mutation without a covering assert.
552+
// ---------------------------------------------------------------------------
553+
554+
TEST_CASE("retry: TransactionTiming has the documented defaults") {
555+
TransactionTiming t;
556+
CHECK(t.min_gap_ms == 2); // default minimum inter-transaction gap
557+
CHECK(t.last_transaction_end_ms == 0);
558+
CHECK_FALSE(t.has_previous);
559+
}
560+
561+
TEST_CASE("retry_loop: records timing on in-loop early returns") {
562+
// Distinct from the max_retries==0 fall-through: with retries available the
563+
// success and non-retryable branches return from *inside* the loop, and
564+
// must still record timing so the next transaction's gap is enforced.
565+
SUBCASE("first attempt already succeeded") {
566+
LoopTestOps ops; ops.now_ms = 77;
567+
auto t = ops.ops();
568+
TransactionTiming timing;
569+
RetryPolicy policy{.max_retries = 5};
570+
AttemptTracker tracker;
571+
bool ok = retry_loop(true, Error{}, AttemptTracker::attempt, &tracker,
572+
t, timing, Safety::ReadOnly, policy);
573+
CHECK(ok);
574+
CHECK(timing.has_previous);
575+
CHECK(timing.last_transaction_end_ms == 77);
576+
}
577+
SUBCASE("first attempt failed non-retryably") {
578+
LoopTestOps ops; ops.now_ms = 88;
579+
auto t = ops.ops();
580+
TransactionTiming timing;
581+
RetryPolicy policy{.max_retries = 5};
582+
AttemptTracker tracker;
583+
bool ok = retry_loop(false, Error::Notecard, AttemptTracker::attempt, &tracker,
584+
t, timing, Safety::ReadOnly, policy);
585+
CHECK_FALSE(ok);
586+
CHECK(timing.has_previous);
587+
CHECK(timing.last_transaction_end_ms == 88);
588+
}
589+
}
590+
591+
TEST_CASE("retry_loop: timeout budget is measured relative to start (non-zero clock)") {
592+
LoopTestOps ops; ops.now_ms = 1000; // non-zero start: catches millis()+start vs millis()-start
593+
auto t = ops.ops();
594+
TransactionTiming timing;
595+
RetryPolicy policy{.max_retries = 100, .retry_delay_ms = 100, .timeout_ms = 250};
596+
AttemptTracker tracker;
597+
retry_loop(false, Error::SendFailed, AttemptTracker::attempt, &tracker,
598+
t, timing, Safety::ReadOnly, policy);
599+
CHECK(tracker.calls == 3); // same budget math as the zero-start case
600+
}
601+
602+
TEST_CASE("retry_loop: timeout boundary is inclusive (elapsed == timeout stops)") {
603+
LoopTestOps ops;
604+
auto t = ops.ops();
605+
TransactionTiming timing;
606+
RetryPolicy policy{.max_retries = 100, .retry_delay_ms = 100, .timeout_ms = 300};
607+
AttemptTracker tracker;
608+
retry_loop(false, Error::SendFailed, AttemptTracker::attempt, &tracker,
609+
t, timing, Safety::ReadOnly, policy);
610+
// 100ms steps, budget exactly 300: stop when elapsed first REACHES 300 (>=),
611+
// not only when it exceeds (>). 3 retries, not 4.
612+
CHECK(tracker.calls == 3);
613+
}
614+
615+
TEST_CASE("retry_loop: timeout_ms == 1 still enforces the budget (boundary)") {
616+
LoopTestOps ops;
617+
auto t = ops.ops();
618+
TransactionTiming timing;
619+
RetryPolicy policy{.max_retries = 5, .retry_delay_ms = 100, .timeout_ms = 1};
620+
AttemptTracker tracker;
621+
retry_loop(false, Error::SendFailed, AttemptTracker::attempt, &tracker,
622+
t, timing, Safety::ReadOnly, policy);
623+
// timeout_ms==1 (the > 0 guard, not > 1): after one 100ms delay the budget
624+
// is blown, so only one retry attempt runs.
625+
CHECK(tracker.calls == 1);
626+
}
627+
628+
TEST_CASE("retry: timeout budget is measured relative to start (non-zero clock)") {
629+
MockClock clock; clock.now_ms = 1000;
630+
TransactionTiming timing;
631+
RetryPolicy policy{.max_retries = 100, .retry_delay_ms = 100, .timeout_ms = 250};
632+
int attempts = 0;
633+
retry_transaction<TestResult>(
634+
clock, timing, Safety::ReadOnly, policy,
635+
[&]() -> TestResult { ++attempts; return send_failed(); },
636+
[&]() { clock.reset(); });
637+
CHECK(attempts == 4); // 1 initial + 3 retries within budget
638+
}
639+
640+
TEST_CASE("retry: timeout boundary is inclusive (elapsed == timeout stops)") {
641+
MockClock clock;
642+
TransactionTiming timing;
643+
RetryPolicy policy{.max_retries = 100, .retry_delay_ms = 100, .timeout_ms = 300};
644+
int attempts = 0;
645+
retry_transaction<TestResult>(
646+
clock, timing, Safety::ReadOnly, policy,
647+
[&]() -> TestResult { ++attempts; return send_failed(); },
648+
[&]() { clock.reset(); });
649+
CHECK(attempts == 4); // 4th retry blocked at elapsed == 300
650+
}
651+
652+
TEST_CASE("retry: timeout_ms == 1 still enforces the budget (boundary)") {
653+
MockClock clock;
654+
TransactionTiming timing;
655+
RetryPolicy policy{.max_retries = 5, .retry_delay_ms = 100, .timeout_ms = 1};
656+
int attempts = 0;
657+
retry_transaction<TestResult>(
658+
clock, timing, Safety::ReadOnly, policy,
659+
[&]() -> TestResult { ++attempts; return send_failed(); },
660+
[&]() { clock.reset(); });
661+
CHECK(attempts == 2); // initial + one retry, then 1ms budget blown
662+
}
663+
545664
// ---------------------------------------------------------------------------
546665
// Error to_string coverage
547666
// ---------------------------------------------------------------------------

tools/mutation-testing/README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Mutation testing
2+
3+
Coverage tells you a line *executed*. It can't tell you whether a test would
4+
*notice* if that line were wrong. Mutation testing measures the difference —
5+
test **strength**, not test reach.
6+
7+
The harness deliberately corrupts one operator or literal at a time in the
8+
correctness-critical headers, rebuilds the covering host tests, and runs them:
9+
10+
- the mutant is **killed** if a test fails — good, an assertion caught the change;
11+
- it **survived** if every test still passes — a weak/missing assertion, *or* an
12+
equivalent mutant that changes no observable behaviour.
13+
14+
A surviving mutant on a critical path is the signal. **The durable deliverable
15+
is the assertion it drives** — a normal, permanent unit test added to the suite
16+
that would have caught the corruption. The mutants are ephemeral (applied to a
17+
throwaway overlay, never to the tree); the assertions live forever. This mirrors
18+
fuzzing, whose durable artifact is its committed corpus.
19+
20+
## Running it
21+
22+
```sh
23+
./ci.sh --mutate # PoC set (cobs + retry); on-demand audit
24+
./ci.sh --mutate --all # every configured target
25+
./ci.sh --mutate --target jsonb # one target
26+
python3 tools/mutation-testing/mutate.py --list-targets
27+
```
28+
29+
Requires a C++ compiler (`c++` by default; override with `CXX`) and the
30+
`clang.cindex` Python bindings (`python3 -m pip install libclang`). The harness
31+
self-skips with a clear message if either is missing, so `./ci.sh --mutate` is
32+
safe to invoke anywhere.
33+
34+
**This is an on-demand audit, not a per-commit gate.** It is minutes long, and
35+
scores are inherently noisy because equivalent mutants survive legitimately and
36+
need human triage — a hard gate would false-fail. It exits 0 even with
37+
survivors; run it after touching the parsers, framing, or retry logic, then
38+
triage the survivor list.
39+
40+
## How it works
41+
42+
```mermaid
43+
flowchart LR
44+
A[libclang AST<br/>find mutation sites] --> B[apply ONE mutation<br/>to a shadow header]
45+
B --> C[recompile covering<br/>test TUs]
46+
C --> D[link vs cached<br/>doctest_main.o]
47+
D --> E[run with timeout]
48+
E -->|test fails| K[killed]
49+
E -->|all pass| S[survived]
50+
C -->|won't compile| U[uncompilable<br/>excluded from score]
51+
```
52+
53+
- **Sites come from the AST**, not regex: a comparison `<` is mutated, a template
54+
`Foo<int>` `<` is not, and strings/comments are never touched. Operators:
55+
relational/equality/logical/arithmetic swaps, integer-literal `n±1` boundaries,
56+
bool-literal flips, drop unary `!`, and standalone statement deletion.
57+
- **Shadow include tree.** Each mutant gets a private mirror of `include/` built
58+
from symlinks, with only the mutated header materialised as a real file, used
59+
as the *sole* include root. This makes both `<note/link/cobs.hpp>` and quoted
60+
`"link/cobs.hpp"` includes resolve to the mutated copy uniformly — a flat
61+
`-I overlay -I include` would let quote-includes slip past to the real header
62+
and double-include it (`#pragma once` dedupes by file identity).
63+
- **Cached objects.** `doctest_main.o` + `alloc_counter.o` are compiled once; a
64+
mutation only changes a header, so per mutant we recompile just the covering
65+
test TU(s).
66+
- **Short-circuit.** Test TUs run fastest-first and stop at the first failure, so
67+
a mutant killed by the quick unit test never pays the heavier TUs' compile.
68+
- A process pool runs ~(cores − 2) workers; a per-mutant run timeout guards
69+
mutations that induce an infinite loop.
70+
71+
## Targets
72+
73+
`TARGETS` in `mutate.py` maps each header to the test TU(s) that exercise it.
74+
The mapping must list **every** TU that directly asserts the target's
75+
behaviour, not just the obvious unit test — a mutant is only killed if a
76+
*rebuilt* test fails. (e.g. `cobs_encoded_length` is asserted in
77+
`test_binary_execute.cpp`, not `test_cobs.cpp`; omitting it produces false
78+
survivors.) Verify a new target's mapping against host coverage
79+
(`./ci.sh --coverage`).
80+
81+
## Triage
82+
83+
For each survivor, decide:
84+
85+
- **Real gap** → add a permanent assertion to the covering test that pins the
86+
behaviour the mutant broke. Re-run; the survivor should become killed.
87+
- **Equivalent mutant** → the corruption changes no observable behaviour, so no
88+
test can or should catch it. Common cases here: a default member initialiser
89+
overwritten by a constructor's `reset()`, a `<``<=` inside a `min`-style
90+
ternary that picks the same value at equality, or a redundant early flush.
91+
These are expected and left alone.
92+
93+
Some real defects only surface under a sanitizer (e.g. a deleted bounds-flush
94+
that overflows a fixed buffer with adversarial input). Those belong to the
95+
ASan/UBSan fuzz lane (`./ci.sh --fuzz`), not to a deterministic assertion.

0 commit comments

Comments
 (0)