project() now carries VERSION 0.1.0, and is the single place a version number is spelled. It flows into generated version macros, the shared library's VERSION/SOVERSION, the Version: field in akstdlib.pc, and a new akstdlibConfigVersion.cmake. Before this @PROJECT_VERSION@ expanded to nothing, so akstdlib.pc shipped an empty Version: and libakstdlib.so carried no soname at all. 0.x on purpose: TODO.md section 2.1 still records four confirmed defects whose fixes change documented behaviour, so the API is not being promised yet. While the major version is 0 the soname carries MAJOR.MINOR -- 0.1 and 0.2 are different ABIs -- and becomes MAJOR alone at 1.0. The if() in CMakeLists.txt and the #if in tests/test_version.c encode that rule and are tested against each other. include/akstdlib_version.h.in is configured into the build tree as akstdlib_version.h and installed beside akstdlib.h. It defines AKSL_VERSION_MAJOR/MINOR/PATCH/STRING/NUMBER and AKSL_VERSION_SONAME. 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; test_version.c asserts it against the runtime components, so a rewrite to a literal fails. Those macros record what a caller was compiled against. aksl_version(), aksl_version_string() and aksl_version_soname() report what actually loaded, and AKSL_VERSION_CHECK() compares the two, raising AKERR_VALUE naming both. It is a macro so that it expands at the caller's site and captures the caller's numbers; the function compares them against the ones baked into the library. Compatibility is "same soname", so patch is ignored -- a caller built against 0.1.0 keeps working against 0.1.7. Normally the soname catches a mismatch at load time and the check never fires. It earns its keep when the soname is bypassed: a 0.2.0 build dropped in under the 0.1 filename loads happily, and only the check notices. write_basic_package_version_file() uses SameMinorVersion to mirror the soname, falling back to ExactVersion below CMake 3.11 where that mode does not exist. The fallback is stricter than the soname rule -- it pins the patch level too -- but never laxer, and wrongly refusing a good pairing beats wrongly accepting a bad one. Coverage of src/stdlib.c rose to 99.1% of lines (217/219), 45.1% of branches and 25/25 functions. That puts branch coverage back over the old 45 gate, but the gate stays at 40: 0.1 points of headroom is not a ratchet. ctest 14/14, ASan+UBSan 14/14, coverage 16/16 at 90/40. Also verified out of tree: SONAME libakstdlib.so.0.1 recorded in consumers, pkg-config --modversion reporting 0.1.0, find_package(akstdlib 0.1) accepted with 0.2 and 1.0 refused, a patch-bumped 0.1.1 loading and passing the check, a 0.2.0 dropped in under the 0.1 filename caught by it, and an embedded add_subdirectory build keeping its own version rather than the parent's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
339 lines
16 KiB
Markdown
339 lines
16 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 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
|
||
|
||
```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.1.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.1.0 | From |
|
||
| --- | --- | --- |
|
||
| `AKSL_VERSION_MAJOR` / `_MINOR` / `_PATCH` | `0` / `1` / `0` | `include/akstdlib_version.h.in` |
|
||
| `AKSL_VERSION_STRING` | `"0.1.0"` | same |
|
||
| `AKSL_VERSION_NUMBER` | `100` | same |
|
||
| `AKSL_VERSION_SONAME` | `"0.1"` | same |
|
||
| shared library | `libakstdlib.so.0.1.0`, soname `libakstdlib.so.0.1` | `VERSION` / `SOVERSION` |
|
||
| `pkg-config --modversion akstdlib` | `0.1.0` | `akstdlib.pc` |
|
||
| `find_package(akstdlib 0.1)` | accepted; `0.2` 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. `TODO.md` §2.1 still records four confirmed defects
|
||
whose fixes change documented behaviour, so the API is not being promised yet.
|
||
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.1.0 keeps working
|
||
against 0.1.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.2.0 build in under the 0.1 filename, or a package that strips
|
||
versioning. Then the loader is happy and only the check notices:
|
||
|
||
```
|
||
compiled against : 0.1.0 (soname 0.1)
|
||
loaded : 0.2.0 (0.2.0)
|
||
MISMATCH DETECTED: compiled against libakstdlib 0.1.0, loaded 0.2.0 (soname 0.2)
|
||
```
|
||
|
||
### 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: `convert` (the `ato*` family), `format` (the
|
||
`printf` family), `stream` (`fopen`/`fread`/`fwrite`/`fclose`), `path`
|
||
(`aksl_realpath`), `strhash`, `memory`, `linkedlist`, `tree`, and
|
||
`status_registry` (this library's side of the libakerror status-registry
|
||
contract — see "The libakerror version floor" above).
|
||
|
||
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
|
||
|
||
```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`. 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
|
||
|
||
```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.** `src/stdlib.c` is at **99.1% of lines (217/219)**, **45.1% of
|
||
branches (519/1151)** and **25/25 functions**, so 90/40 above is a ratchet with
|
||
headroom rather than a target. The two uncovered lines are both
|
||
`} HANDLE(e, AKERR_ITERATOR_BREAK) {` — 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 error context whose status is *zero*. That is the
|
||
pathological case §2.2.1 of `TODO.md` exists to remove, so it is left uncovered
|
||
deliberately rather than pinned by a test.
|
||
|
||
Branch coverage sits far below line coverage because most branches in this file
|
||
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. The libakerror 1.0.0 bump made
|
||
that gap wider without changing a line of this library: the branch denominator
|
||
went from 661 to 1087 as those macros grew, so the same tests that scored 51.0%
|
||
(337/661) dropped to 44.3% (481/1087). The gate moved 45 → 40 to match; line and
|
||
function coverage did not move at all. Adding the `aksl_version_*` family and its
|
||
tests brought the figure back to 45.1% (519/1151), but the gate stays at 40 —
|
||
0.1 points of headroom is not a ratchet.
|
||
|
||
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`.
|