Found by building a consumer against a fresh install: an installed libakstdlib was not usable. akstdlibConfig.cmake calls find_dependency(akerror), and the top-level build pulls the submodule in EXCLUDE_FROM_ALL, so `cmake --install` on this project installs only this project and leaves that dependency unresolvable. CI had been hiding it by installing libakerror@main in a separate step -- a different libakerror from the one actually compiled, which is the inconsistency TODO.md 2.3 recorded and the previous commit removed. Removing it exposed the real gap. CI now installs deps/libakerror, the same commit the top-level build compiles, so there is one libakerror in play and the install is consumable. tests/consumer/ is the check that would have caught it: a standalone project, configured against CMAKE_PREFIX_PATH rather than as part of this build, because being part of this build is exactly what would let it pass without testing anything. It exercises what only an install has -- find_package with a version request against the generated version file, find_dependency(akerror) resolving, and the exported akstdlib::akstdlib target -- and touches one function from each of the four sources, so a library installed with a source file missing from its link line fails there rather than in the next consumer to find it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
25 KiB
Markdown
465 lines
25 KiB
Markdown
# README
|
||
|
||

|
||
|
||
`libakstdlib` wraps C standard library functions so that they report failures
|
||
through [libakerror](https://source.starfort.tech/andrew/libakerror)'s
|
||
`ATTEMPT { ... } HANDLE { ... }` error contexts instead of through return codes
|
||
and `errno`. It also provides data structures built on the same convention.
|
||
|
||
Every entry point returns `akerr_ErrorContext *` and is marked `AKERR_NOIGNORE`.
|
||
See `TODO.md` for the current state of the library and `UPGRADING.md` if you are
|
||
coming from 0.1.0, which this release breaks.
|
||
|
||
## What it wraps
|
||
|
||
| Area | Source | Functions |
|
||
|---|---|---|
|
||
| Memory | `src/stdlib.c` | `malloc` `calloc` `realloc` `free` `freep` `memset` `memcpy` `memmove` `memcmp` `memchr` |
|
||
| Formatted output | `src/stdlib.c` | `printf` `fprintf` `snprintf` and their `v*` forms |
|
||
| String → number | `src/stdlib.c` | `strtol` `strtoll` `strtoul` `strtoull` `strtod` `strtof` `strtold`, and `atoi` `atol` `atoll` `atof` on top of them |
|
||
| Paths and hashing | `src/stdlib.c` | `realpath` `realpath_alloc` `strhash_djb2` `strhash_djb2_str` |
|
||
| Strings | `src/string.c` | `strlen` `strnlen` `strcpy` `strncpy` `strcat` `strncat` `strdup` `strndup` `strcmp` `strncmp` `strcasecmp` `strncasecmp` `strcoll` `strchr` `strrchr` `strstr` `strcasestr` `strpbrk` `strspn` `strcspn` `strtok_r` `strsep` `strerror` |
|
||
| Streams | `src/stream.c` | `fopen` `fread` `fwrite` `fclose` `fseek` `ftell` `rewind` `fseeko` `ftello` `fgetpos` `fsetpos` `fflush` `setvbuf` `fgetc` `fputc` `ungetc` `fgets` `fputs` `getline` `getdelim` `feof` `ferror` `clearerr` `fileno` `freopen` `fdopen` `tmpfile` `sscanf` `fscanf` `remove` `rename` `mkstemp` `mkdtemp` |
|
||
| Collections | `src/collections.c` | doubly-linked list (bare-node and tracked-container forms), binary search tree, breadth- and depth-first traversal, fixed-capacity hash map, growable string buffer, FNV-1a |
|
||
|
||
### Where it deviates from libc, and why
|
||
|
||
The whole point is to make silent failures loud, so several wrappers are
|
||
deliberately stricter than the function they are named for. These are the ones
|
||
that will surprise you:
|
||
|
||
| Wrapper | Deviation |
|
||
|---|---|
|
||
| `aksl_free(NULL)` | `AKERR_NULLPOINTER`. `free(NULL)` is legal and does nothing; in a codebase that routes every allocation through `aksl_malloc`, a pointer you believed was live turning out NULL means something upstream did not happen. |
|
||
| `aksl_malloc(0, &p)` | `AKERR_VALUE`. There is nothing useful to hand back, and `malloc(0)` returning NULL without setting `errno` is how an error with status `0` used to get raised. |
|
||
| `aksl_atoi` and friends | Report bad conversions. `atoi(3)` has no error channel at all: junk converts to `0` and overflow wraps. Base 10, whole string, `ERANGE` on overflow. |
|
||
| `aksl_strcpy` / `strncpy` / `strcat` / `strncat` | Take the destination's size, which the libc originals cannot be called safely without. Truncation is `AKERR_OUTOFBOUNDS` and writes nothing. `aksl_strncpy` always terminates and never NUL-pads. |
|
||
| `aksl_snprintf` | Truncation is `AKERR_OUTOFBOUNDS`, not a short success. There is no `aksl_sprintf`: an error-handling wrapper around an unbounded write is the sharp edge this library exists to remove. |
|
||
| `aksl_memcpy` | Overlapping ranges are `AKERR_VALUE` rather than undefined behaviour. Use `aksl_memmove`. |
|
||
| `aksl_fread` / `aksl_fwrite` | Require a transferred-count out-param, and report a short transfer with no stream error as `AKERR_IO` rather than as success. |
|
||
| `aksl_sscanf` / `aksl_fscanf` | Take the number of conversions you expect. Comparing `scanf(3)`'s return against that by hand at every call site is the check everyone eventually forgets. |
|
||
| `aksl_realpath` | Takes the destination's length and refuses anything below `PATH_MAX`, because `realpath(3)` cannot be bounded. |
|
||
| `aksl_list_pop` | Takes the head by reference, because popping the head has to move it. |
|
||
| Searching (`strchr`, `strstr`, `memchr`, `aksl_list_find`, `aksl_hashmap_get`, …) | Finding nothing is **success** with a NULL or zero result, not an error. Absent is an ordinary answer. |
|
||
| `aksl_strtok` | Does not exist. `strtok(3)` keeps its state in a hidden static; use `aksl_strtok_r` or `aksl_strsep`. |
|
||
|
||
### Thread safety
|
||
|
||
**This library is not thread-safe, and cannot be made so from here.**
|
||
|
||
libakerror hands out error contexts from `AKERR_ARRAY_ERROR`, a process-global
|
||
array with no locking, and every entry point in this library takes a slot from it
|
||
on any failure path. Two threads raising errors concurrently can be handed the
|
||
same slot. `errno` is thread-local so the wrapped calls themselves are fine; the
|
||
error reporting is not.
|
||
|
||
There is no TSan test here because there is nothing to verify — the answer is
|
||
known and it is "no". Fixing it means locking or thread-local storage in
|
||
libakerror's pool, which is that library's decision to make; `TODO.md` §1.9
|
||
records it. Until then: confine libakstdlib calls to one thread, or serialise
|
||
them yourself.
|
||
|
||
The wrappers add no state of their own beyond that. `aksl_strtok_r` and
|
||
`aksl_strsep` keep their state in the caller's `saveptr`, and no function here
|
||
uses a static buffer.
|
||
|
||
## Building
|
||
|
||
```sh
|
||
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.
|
||
|
||
### This library's own version
|
||
|
||
`libakstdlib` is at **0.2.0**. The version lives in exactly one place — the
|
||
`project()` call in `CMakeLists.txt` — and flows from there into everything
|
||
else, so a bump is a one-line edit:
|
||
|
||
| Artifact | Value at 0.2.0 | From |
|
||
| --- | --- | --- |
|
||
| `AKSL_VERSION_MAJOR` / `_MINOR` / `_PATCH` | `0` / `2` / `0` | `include/akstdlib_version.h.in` |
|
||
| `AKSL_VERSION_STRING` | `"0.2.0"` | same |
|
||
| `AKSL_VERSION_NUMBER` | `200` | same |
|
||
| `AKSL_VERSION_SONAME` | `"0.2"` | same |
|
||
| shared library | `libakstdlib.so.0.2.0`, soname `libakstdlib.so.0.2` | `VERSION` / `SOVERSION` |
|
||
| `pkg-config --modversion akstdlib` | `0.2.0` | `akstdlib.pc` |
|
||
| `find_package(akstdlib 0.2)` | accepted; `0.1` and `1.0` refused | `akstdlibConfigVersion.cmake` |
|
||
|
||
`akstdlib_version.h` is **generated** — that is why there is no such file in the
|
||
source tree, only the `.in` template beside `akstdlib.h`. Don't hand-edit the
|
||
copy in your build directory; change the template or `project()`.
|
||
|
||
It is `0.x` deliberately. The 0.1 → 0.2 bump was itself an ABI break — fixing the
|
||
confirmed defects changed five signatures and the `ato*` contract, all of it
|
||
listed in `UPGRADING.md` — and the API is not being promised until the wishlist
|
||
in `TODO.md` §3 has settled. While the major version is `0`, **the soname carries
|
||
`MAJOR.MINOR`**: 0.1 and 0.2
|
||
are different ABIs and the loader will not substitute one for the other. At 1.0
|
||
the soname becomes `MAJOR` alone — the `if(PROJECT_VERSION_MAJOR EQUAL 0)` in
|
||
`CMakeLists.txt` and the matching `#if` in `tests/test_version.c` are the two
|
||
places that encode this, and they are tested against each other.
|
||
|
||
`AKSL_VERSION_NUMBER` is computed rather than written as a literal, because a
|
||
literal `000100` is *octal* in C and would make 0.1.0 compare as 64.
|
||
|
||
#### Compiled-against vs. loaded
|
||
|
||
The macros above record what a caller was **compiled** against. What it actually
|
||
**loaded** is a different question, and the two can disagree:
|
||
|
||
```c
|
||
int major, minor, patch;
|
||
akerr_ErrorContext *e = aksl_version(&major, &minor, &patch); /* the loaded .so */
|
||
const char *v = aksl_version_string(); /* likewise */
|
||
|
||
e = AKSL_VERSION_CHECK(); /* compares the two; AKERR_VALUE on a mismatch */
|
||
```
|
||
|
||
`AKSL_VERSION_CHECK()` is a macro on purpose: it expands at *your* call site, so
|
||
it captures the `AKSL_VERSION_*` you were built with and passes them to a
|
||
function that compares against the values baked into the library. Calling
|
||
`aksl_version_check()` with hand-written numbers defeats the whole mechanism.
|
||
|
||
Compatibility is defined as "same soname", so pre-1.0 both major and minor must
|
||
match and the patch level is ignored — a caller built against 0.2.0 keeps working
|
||
against 0.2.7, which is exactly the promise the shared soname makes.
|
||
|
||
In normal use the soname catches the mismatch first, at load time, and the check
|
||
never fires. It earns its keep when the soname is bypassed: a hand-install that
|
||
drops a 0.3.0 build in under the 0.2 filename, or a package that strips
|
||
versioning. Then the loader is happy and only the check notices:
|
||
|
||
```
|
||
compiled against : 0.2.0 (soname 0.2)
|
||
loaded : 0.3.0 (0.3.0)
|
||
MISMATCH DETECTED: compiled against libakstdlib 0.2.0, loaded 0.3.0 (soname 0.3)
|
||
```
|
||
|
||
### The libakerror version floor
|
||
|
||
**libakerror 1.0.0 or newer is required.** That release made the status-name
|
||
table private, moved consumer status codes into a band starting at
|
||
`AKERR_FIRST_CONSUMER_STATUS` (256), made range ownership enforced rather than
|
||
advisory, and gave the library an soname — see
|
||
`deps/libakerror/UPGRADING.md`. It is a source *and* ABI break, so pairing this
|
||
header with an older `akerror.h` is not a compile problem you can work around;
|
||
the pairing is simply invalid.
|
||
|
||
Three things enforce the floor, because no single one covers every way the
|
||
library gets consumed:
|
||
|
||
| Mechanism | Where | Catches |
|
||
| --- | --- | --- |
|
||
| `#error` on a missing `AKERR_FIRST_CONSUMER_STATUS` | `include/akstdlib.h` | a stale `akerror.h` earlier on the include path, at the first diagnostic rather than as a pile of errors inside `src/stdlib.c` |
|
||
| `Requires: akerror >= 1.0.0` | `akstdlib.pc.in` | a pkg-config consumer, which also now gets `-lakerror` transitively |
|
||
| `find_dependency(akerror)` | `cmake/akstdlib.cmake.in` | a `find_package(akstdlib)` consumer, which previously failed with a bare *"akerror::akerror not found"* out of the generated targets file |
|
||
|
||
The header guard feature-tests rather than version-tests because libakerror
|
||
publishes no version macro; `AKERR_FIRST_CONSUMER_STATUS` is the symbol 1.0.0
|
||
introduced, so its absence is what "older than 1.0.0" actually looks like. The
|
||
CMake path requests no version for the same kind of reason: libakerror installs
|
||
no `akerrorConfigVersion.cmake`, so `find_dependency(akerror 1.0.0)` would be
|
||
refused for want of a version file no matter which akerror is installed.
|
||
|
||
**libakstdlib defines no status codes of its own.** It raises libakerror's
|
||
`AKERR_*` codes and propagates the host's `errno` values, all of which live in
|
||
libakerror's reserved `0`–`255` band, so it reserves no range and an application
|
||
is free to allocate from `AKERR_FIRST_CONSUMER_STATUS` without coordinating with
|
||
it. `tests/test_status_registry.c` pins that, along with the requirement that
|
||
every status this library raises actually has a name registered — an unnamed one
|
||
degrades to `"Unknown Error"` in every later stack trace, which nothing else
|
||
would notice.
|
||
|
||
## Testing
|
||
|
||
There are four harnesses. The first three take seconds; the fourth takes about
|
||
half an hour.
|
||
|
||
### 1. The test suite
|
||
|
||
```sh
|
||
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, `aksl_temp_file()` for tests that need a real
|
||
file to work on, and an `AKSL_RUN()` driver that additionally fails any test which
|
||
leaks a slot from libakerror's error pool.
|
||
|
||
One file per area of the API: `memory`, `format` (the `printf` family), `convert`
|
||
(the `ato*` family) and `strto` (the family underneath it), `stream`
|
||
(`fopen`/`fread`/`fwrite`/`fclose`) and `streamio` (everything else in
|
||
`src/stream.c`), `string`, `path` (`aksl_realpath`), `strhash`, `linkedlist`,
|
||
`tree`, `collections` (the list and tree additions), `hashmap`, `strbuf`,
|
||
`version`, `status_registry` (this library's side of the libakerror
|
||
status-registry contract — see "The libakerror version floor" above), and `pool`.
|
||
|
||
`pool` is the odd one out: it asserts two cross-cutting properties rather than
|
||
any function's behaviour. Every failure path is driven `AKERR_MAX_ARRAY_ERROR + 10`
|
||
times with the pool checked after each round, because a wrapper that raises an
|
||
error and forgets to release it does not fail visibly — it fails a hundred-odd
|
||
calls later in whatever unrelated code asks for a slot next. And every error is
|
||
checked to name the function and file it was actually raised from, which is what
|
||
catches a `FAIL` that migrates into a shared helper during a refactor: the status
|
||
stays right, the message stays right, and the origin quietly starts lying.
|
||
|
||
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`. |
|
||
|
||
**Both of those lists are currently empty**, which is the news: all six confirmed
|
||
defects in `TODO.md` §2.1 are fixed, and the four tests that used to sit in
|
||
`AKSL_KNOWN_FAILING_TESTS` are folded back into the tests for the things they
|
||
test, where they now have to keep passing rather than keep failing visibly. The
|
||
mechanism stays for the next one. When a defect is fixed its known-failing test
|
||
starts passing, CTest reports it as failed with *unexpectedly passed*, and that
|
||
is the cue to move it into `AKSL_TESTS`.
|
||
|
||
Two more entries, `negative_noignore` and `negative_format_mismatch`, are
|
||
compile-time assertions rather than programs. Each builds a source file under
|
||
`tests/negative/` with `-Werror` and is marked `WILL_FAIL`, so the test passes
|
||
only when the compile *fails*. They exist because `AKERR_NOIGNORE` and
|
||
`AKSL_PRINTF_FORMAT` are enforced by the compiler and by nothing else: drop
|
||
either attribute in a refactor and every test still passes, the library still
|
||
builds, and the guarantee just quietly stops existing.
|
||
|
||
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.
|
||
|
||
### 1a. The installed package
|
||
|
||
```sh
|
||
cmake -S deps/libakerror -B build-akerror && cmake --build build-akerror
|
||
cmake --install build-akerror --prefix /some/prefix
|
||
cmake --install build --prefix /some/prefix
|
||
cmake -S tests/consumer -B build-consumer -DCMAKE_PREFIX_PATH=/some/prefix
|
||
cmake --build build-consumer && ./build-consumer/consumer
|
||
```
|
||
|
||
The suite links the build tree, so it says nothing about whether an *installed*
|
||
libakstdlib is usable. `tests/consumer/` is a standalone project that does the
|
||
things only an install exercises: `find_package(akstdlib 0.2)` against the
|
||
generated version file, `akstdlibConfig.cmake`'s `find_dependency(akerror)`, and
|
||
the exported `akstdlib::akstdlib` target. It touches one function from each of
|
||
the four sources, so a library installed with a source file missing from its link
|
||
line fails here rather than in whatever consumer finds it next.
|
||
|
||
Note that libakerror has to be installed too. A top-level build compiles the
|
||
vendored copy with `EXCLUDE_FROM_ALL`, so `cmake --install` on this project
|
||
installs only this project -- and an installed libakstdlib whose
|
||
`find_dependency(akerror)` cannot resolve is not usable. Install the submodule's
|
||
copy, not `libakerror@main`: that is the version this repository pins and tests
|
||
against, and it is what CI does.
|
||
|
||
### 2. Sanitizers
|
||
|
||
```sh
|
||
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`. Three of the defects fixed in 0.2.0 only misbehaved
|
||
under instrumentation — the uninitialised `%s` in `aksl_realpath`'s error path,
|
||
the unbounded `vsprintf` behind the old `aksl_sprintf`, and the missing `va_end`
|
||
in the `printf` family — and the tests that pin them are written to be run this
|
||
way. `tests/test_path.c` deliberately passes an *uninitialised* buffer on every
|
||
failure path for exactly that reason.
|
||
|
||
One test needs help from the sanitizer to test the same thing the normal build
|
||
does: `tests/test_memory.c` asks for `SIZE_MAX / 2` bytes to check that a refused
|
||
allocation reports `ENOMEM` and leaves `*dst` NULL. Plain `malloc` returns NULL;
|
||
ASan treats a request that large as a bug in the caller and aborts before `malloc`
|
||
returns at all. `CMakeLists.txt` sets `ASAN_OPTIONS=allocator_may_return_null=1`
|
||
for that one binary so the contract under test stays the same in both builds.
|
||
|
||
### 3. Code coverage
|
||
|
||
```sh
|
||
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.
|
||
|
||
`coverage` here is this project's target. libakerror ships a `coverage` target of
|
||
its own and, unlike its `mutation` target, does not namespace it when embedded,
|
||
so a top-level `-DAKSL_COVERAGE=ON` build would collide on the name and fail to
|
||
configure at all. `CMakeLists.txt` renames the dependency's to `akerror_coverage`
|
||
on the way past — it drives its own instrumented build tree, so
|
||
`cmake --build build-coverage --target akerror_coverage` still works. The
|
||
workaround goes away when libakerror namespaces it upstream; see `TODO.md` §2.3.
|
||
|
||
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:
|
||
|
||
```sh
|
||
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 90 --branch-threshold 40
|
||
```
|
||
|
||
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:
|
||
|
||
```sh
|
||
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \
|
||
-DAKSL_COVERAGE_THRESHOLD=90 -DAKSL_COVERAGE_BRANCH_THRESHOLD=40
|
||
```
|
||
|
||
**Where it stands.** All four sources, with the whole suite:
|
||
|
||
| file | lines | branches | functions |
|
||
|---|---|---|---|
|
||
| `src/collections.c` | 99.3% (601/605) | 43.5% | 100% (43/43) |
|
||
| `src/stdlib.c` | 99.3% (557/561) | 46.5% | 100% (50/50) |
|
||
| `src/stream.c` | 100% (274/274) | 43.4% | 100% (31/31) |
|
||
| `src/string.c` | 100% (211/211) | 53.8% | 100% (23/23) |
|
||
| **total** | **99.5% (1643/1651)** | **46.0%** | **100% (147/147)** |
|
||
|
||
so the 90/40 gate above is a ratchet with headroom rather than a target. Eight
|
||
lines are uncovered and each is uncovered on purpose:
|
||
|
||
- **Four `} HANDLE(e, AKERR_ITERATOR_BREAK) {`** lines. In libakerror that macro
|
||
begins with the `break;` belonging to `PROCESS`'s `case 0:` arm, which is only
|
||
reachable when a callback returns a non-NULL context whose status is *zero* —
|
||
the pathological case §2.2.1 exists to remove. Left uncovered rather than
|
||
pinned by a test that would have to manufacture it.
|
||
- **Two in `strbuf_reserve`**, the `size_t` overflow guard on a doubling that
|
||
would wrap. Reaching it needs a buffer within a factor of two of `SIZE_MAX`,
|
||
which is not a test, it is a hang.
|
||
- **Two in `aksl_fread`/`aksl_fwrite`**, the short transfer with *neither* `feof`
|
||
nor `ferror` set. Every way of producing a short transfer on Linux sets one or
|
||
the other; the branch is there because the standard permits neither, not
|
||
because anything reaches it. `TODO.md` §1.2 records it as still open.
|
||
|
||
Branch coverage sits far below line coverage because most branches in these files
|
||
are inside the `FAIL_*`/`ATTEMPT`/`FINISH` macro expansions — pool exhaustion,
|
||
stack-trace buffer limits, `akerr_valid_error_address` failures — and belong to
|
||
libakerror's own suite rather than to this one. Every `FAIL_ZERO_RETURN` in the
|
||
tree contributes several branches that this library has no way to reach. The
|
||
libakerror 1.0.0 bump made that gap wider without changing a line here: the
|
||
branch denominator per call site grew, so identical tests scored lower. Chasing
|
||
the number would mean testing libakerror's macros, which is what libakerror's
|
||
mutation suite is for — macros expand at the call site, so coverage cannot see
|
||
them properly from either side.
|
||
|
||
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.
|
||
|
||
```sh
|
||
cmake --build build --target mutation # src/stdlib.c + include/akstdlib.h
|
||
```
|
||
|
||
or drive the script directly for a faster or narrower run:
|
||
|
||
```sh
|
||
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 80
|
||
```
|
||
|
||
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 80`. That is a regression ratchet
|
||
rather than a quality bar: the current score is **89.6% (155/173 killed)**, up
|
||
from 46.8% before the wrapper tests landed. Raise the threshold as the remaining
|
||
survivors are turned into assertions.
|
||
|
||
The 18 survivors cluster in three places, and each names a real gap rather than a
|
||
test-harness artifact:
|
||
|
||
- **Statements whose absence nothing observes** — deleting `free(ptr)`,
|
||
`obj->next = NULL`, or a `SUCCEED_RETURN` leaves behaviour the suite does not
|
||
look at (a leak, a stale pointer, a success that was already NULL).
|
||
- **The `aksl_list_append` cycle/tail walk** (`tail = slow`, `slow = slow->next`,
|
||
`tail = fast`) — the function is broken in exactly this area (`TODO.md` §2.1.1),
|
||
so its known-failing test cannot pin the internals yet.
|
||
- **`lalloc`/`lfree` defaulting in `aksl_tree_iterate`** — dead parameters
|
||
(§2.2.8): they are defaulted and then never called, so inverting the guard
|
||
changes nothing observable.
|
||
|
||
## 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:
|
||
|
||
```sh
|
||
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/`.
|
||
|
||
```sh
|
||
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 80, keep it in step with
|
||
`.gitea/workflows/ci.yaml`) and `AKSL_HOOK_BUILD_DIR`.
|