New AKSL_COVERAGE option instruments the library and its tests with --coverage -O0 and wires the report into the suite itself, so a plain ctest --test-dir build-coverage both runs the tests and produces coverage. Two CTest entries do the work, held in order by a CTest fixture rather than by declaration order so they also hold under ctest -j: coverage_reset (FIXTURES_SETUP) clears the .gcda counters before any test, since gcov counts are cumulative and would otherwise fold in earlier runs; coverage_report (FIXTURES_CLEANUP) aggregates gcov output afterwards. AKSL_COVERAGE_THRESHOLD / AKSL_COVERAGE_BRANCH_THRESHOLD gate the report, the same regression-ratchet idea as the mutation score. The `coverage` target builds, runs and prints in one step. scripts/coverage.py parses gcov's JSON output, aggregates line, branch and function counts across translation units, and lists every uncovered line and never-called function -- the actionable half, as with surviving mutants. Python stdlib plus gcc's own gcov only: no lcov, gcovr or genhtml. It also writes coverage-summary.txt (CTest hides the output of a passing test) and a Cobertura coverage.xml for CI publishers. Instrumentation is per target, so deps/libakerror stays out of the report. The mutation harness now ignores build*/ and gcov artifacts when copying the tree, so a coverage build does not slow it down. Baseline on src/stdlib.c: 52.0% of lines, 23.6% of branches, 8 of 21 functions. The uncovered functions are the untested wrappers the mutation survivors already point at (printf, ato*, stream, realpath, strhash). Verified: cmake -S . -B build-coverage -DAKSL_COVERAGE=ON cmake --build build-coverage --target coverage # 8/8, report printed ctest --test-dir build-coverage -j8 # fixture order holds cmake -S . -B build-coverage -DAKSL_COVERAGE=ON -DAKSL_COVERAGE_THRESHOLD=60 ctest --test-dir build-coverage --output-on-failure # gate fails as expected ctest --test-dir build --output-on-failure # 6/6, no .gcda emitted ctest --test-dir build-asan --output-on-failure # 6/6 scripts/mutation_test.py --target src/stdlib.c --list # 173 mutants, unchanged Totals match gcov itself: 51.98% of 202 lines, 23.60% of 661 branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.8 KiB
README
libakstdlib wraps C standard library functions so that they report failures
through libakerror's
ATTEMPT { ... } HANDLE { ... } error contexts instead of through return codes
and errno. It also provides a few data structures built on the same
convention (a doubly-linked list and a binary tree).
Every entry point returns akerr_ErrorContext * and is marked AKERR_NOIGNORE.
See TODO.md for the current state of the library: what is covered by tests,
which corner cases are still open, and which libc functions are not yet wrapped.
Building
git submodule update --init --recursive # deps/libakerror
cmake -S . -B build
cmake --build build
cmake --install build
A top-level build compiles the vendored deps/libakerror. When libakstdlib is
consumed as a subproject, it uses whatever akerror::akerror target or installed
package the parent provides instead.
Testing
There are four harnesses. The first three take seconds; the fourth takes about half an hour.
1. The test suite
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
Tests live one per file in tests/test_<name>.c and share the helpers in
tests/aksl_capture.h — AKSL_CHECK() for plain assertions (unlike assert()
it survives -DNDEBUG), AKSL_CHECK_STATUS(call, expected) to run a wrapper and
assert on the status it returns, and an AKSL_RUN() driver that additionally
fails any test which leaks a slot from libakerror's error pool.
To add a test, drop tests/test_mything.c in place and add mything to
AKSL_TESTS in CMakeLists.txt.
Reading the results. CMakeLists.txt splits tests into three lists, and two
of them invert the meaning of "Passed":
| List | Meaning |
|---|---|
AKSL_TESTS |
Ordinary tests. Must exit 0. |
AKSL_WILL_FAIL_TESTS |
Expected to abort by design — an unhandled error reaching FINISH_NORETURN, or a deliberate contract violation. Marked WILL_FAIL, so a non-zero exit is a pass. |
AKSL_KNOWN_FAILING_TESTS |
Assert the correct behaviour of a confirmed defect (see TODO.md §2.1). Also marked WILL_FAIL. |
So ctest reporting all green does not mean the library is defect-free — it
means the known-good tests passed and the known-bad ones are still failing in the
documented way. When a defect is fixed, its test starts passing, CTest reports it
as failed with unexpectedly passed, and that is the cue to move it from
AKSL_KNOWN_FAILING_TESTS into AKSL_TESTS.
Every test is capped with a 30-second CTest TIMEOUT. The list and tree code is
full of loops whose termination hangs on a single condition, so a bug of that
shape hangs the suite rather than failing it.
2. Sanitizers
cmake -S . -B build-asan -DAKSL_SANITIZE=ON
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure
Builds the library, the tests and the vendored libakerror with ASan + UBSan and
-fno-sanitize-recover=all. Several of the open items in TODO.md §2 only
misbehave under instrumentation — the uninitialised %s in aksl_realpath, the
unbounded vsprintf behind aksl_sprintf, the missing va_end in the printf
family — so new tests for those should be run this way.
3. Code coverage
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON
cmake --build build-coverage --target coverage
-DAKSL_COVERAGE=ON compiles the library and the tests with --coverage -O0,
and wires the report into the suite itself, so a plain
ctest --test-dir build-coverage also produces it. Two extra CTest entries
appear, held in place by a CTest fixture rather than by declaration order, so
they work under ctest -j too:
| Test | When | Does |
|---|---|---|
coverage_reset |
before every other test | deletes the accumulated .gcda counters |
coverage_report |
after every other test | aggregates gcov output, prints the summary, applies the threshold gate |
The reset matters: gcov counters are cumulative, so without it each report would fold in every earlier run and overstate coverage.
CTest hides the output of a passing test, so coverage_report also writes
build-coverage/coverage-summary.txt (the same text report) and
build-coverage/coverage.xml (Cobertura, for CI publishers). The coverage
target above prints the report to the terminal for you; otherwise read the file
or use ctest --test-dir build-coverage -V -R coverage_report.
The report lists per-file line, branch and function coverage, then every uncovered line and every function the suite never called — that listing is the actionable part, the same way surviving mutants are for the harness below.
Drive the script directly for anything narrower:
scripts/coverage.py --build build-coverage # report on disk counters
scripts/coverage.py --build build-coverage --summary-only # totals only
scripts/coverage.py --build build-coverage --include tests # coverage of the tests themselves
scripts/coverage.py --build build-coverage --run-tests # reset, run ctest, report
scripts/coverage.py --build build-coverage --threshold 50 --branch-threshold 25
It needs nothing but Python 3 and gcc's own gcov — no lcov, gcovr or genhtml.
To gate on coverage, set the threshold at configure time; coverage_report then
fails below it, and the same regression-ratchet logic applies as for the mutation
score:
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \
-DAKSL_COVERAGE_THRESHOLD=50 -DAKSL_COVERAGE_BRANCH_THRESHOLD=20
Two caveats. Coverage is measured at -O0, because the optimizer reorders lines
until per-line counts stop matching the source — so a coverage build is not the
build to profile. And gcov flushes its counters at normal process exit, which an
AKSL_WILL_FAIL_TESTS entry that aborts by design never reaches: such a test
contributes no coverage data at all, so lines only it reaches are reported as
uncovered.
4. Mutation testing
The suite tells you the library works. Mutation testing tells you the suite works: it breaks the library in small ways, one at a time, and checks that the tests notice.
cmake --build build --target mutation # src/stdlib.c + include/akstdlib.h
or drive the script directly for a faster or narrower run:
scripts/mutation_test.py --target src/stdlib.c # C source only
scripts/mutation_test.py --target src/stdlib.c --list # enumerate, build nothing
scripts/mutation_test.py --target src/stdlib.c --max-mutants 20
scripts/mutation_test.py --target src/stdlib.c --threshold 40
A mutant that makes the tests fail is killed (good); one the tests still pass
is a survivor, and names a missing test. The score is killed / total, and the
run prints every survivor with file:line and the exact edit. The harness never
touches your working tree — it copies the repo to a scratch directory and mutates
the copy.
CI runs the src/stdlib.c set with --threshold 40. That is a regression
ratchet rather than a quality bar: the current score is 46.8%, and the survivors
are concentrated in the wrappers that have no tests yet. Raise the threshold as
coverage lands.
The pre-push hook
.githooks/pre-push runs the fast harnesses — the default build and the
sanitizer build, each followed by ctest — before letting a push out. Enable it
once per clone:
git config core.hooksPath .githooks
It only builds when there are commits to push (a branch deletion is a no-op), and
it builds under .git/aksl-prepush so it never disturbs your own build/.
AKSL_HOOK_MUTATION=1 git push # also run the mutation gate (slow)
git push --no-verify # skip the hook entirely
Other knobs: AKSL_MUTATION_THRESHOLD (default 40, keep it in step with
.gitea/workflows/ci.yaml) and AKSL_HOOK_BUILD_DIR.