20 Commits

Author SHA1 Message Date
fd71bcc67b Record what the first full consumer had to work around
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m48s
libakstdlib CI Build / coverage (push) Successful in 2m37s
libakstdlib CI Build / mutation_test (push) Successful in 10m9s
akbasic is a ~6,300-line C interpreter built on this library. It is the first
consumer to exercise the whole surface rather than a corner of it, so what it
could not use is worth writing down: it prioritizes the section 3 wishlist by
what a real port actually reaches for, and it says which section 2 entries have
teeth.

The number that matters: across src/, akbasic makes 10 calls into this library
and 116 to raw libc. A library whose value proposition is turning silent libc
failures into error contexts is being bypassed 92% of the time by the consumer
most committed to it.

Four things it had to write for itself, each of which maps onto an existing
unchecked box. A strict strtoll/strtod wrapper, because 2.1.5 means aksl_atoi
would have turned four diagnosable errors into wrong answers -- VAL("garbage")
answering 0.0 instead of raising. A fixed-capacity string-keyed hash table,
which is 3.6's "hash map built on aksl_strhash_djb2", needed three times over
for variables, functions and labels. The bounded-copy-with-truncation-as-error
idiom at ten sites across six files. And case folding at three sites.

Also confirms 2.2.4 (aksl_sprintf unbounded -- akbasic deliberately never calls
it, and has 28 snprintf sites with no aksl_snprintf to route them through),
2.2.2 (aksl_fopen validation, reached from user input via DLOAD/DSAVE), 2.2.6
(the djb2 sign extension, benign here only because BASIC identifiers are ASCII)
and 2.1.4 (the missing va_end, which akbasic's text sink runs on every line of
program output).

Section 4.3 records what akbasic did *not* need, so the wishlist does not get
reordered purely by one consumer's shape: it allocates nothing and uses no lists
or trees, so a consumer that does allocate would weight 3.1's memory section far
higher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:29:01 -04:00
95e5002512 Version the library at 0.1.0
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m49s
libakstdlib CI Build / coverage (push) Successful in 2m37s
libakstdlib CI Build / mutation_test (push) Successful in 10m13s
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>
2026-07-30 22:40:49 -04:00
07c448508b Upgrade to libakerror 1.0.0
Bump deps/libakerror 22 commits to 5ff8790 (1.0.0), which makes the
status-name table private, moves consumer status codes to a band starting
at AKERR_FIRST_CONSUMER_STATUS, enforces range ownership rather than
treating it as advisory, and gives the library an soname. See
deps/libakerror/UPGRADING.md.

src/stdlib.c needed no changes. This library defines no status codes of its
own -- it raises libakerror's AKERR_* codes and propagates errno, both
inside libakerror's reserved 0-255 band -- and it never referenced
AKERR_MAX_ERR_VALUE, __AKERR_ERROR_NAMES, AKERR_STATUS_RANGE_OK or
AKERR_STATUS_NAME_OK. What moved was everything around the code:

A -DAKSL_COVERAGE=ON build stopped configuring at all. libakerror
namespaces its `mutation` target when embedded but not its `coverage`
target, so it collided with ours. Shadow add_custom_target for the duration
of the add_subdirectory() call and rename the dependency's to
akerror_coverage, alongside the existing add_test shadow. Fix upstream and
delete the workaround; recorded in TODO.md.

Pin the 1.0.0 floor three ways, since no single one covers every
consumption path: an #error in akstdlib.h feature-testing
AKERR_FIRST_CONSUMER_STATUS, because libakerror publishes no version macro;
Requires: akerror >= 1.0.0 in akstdlib.pc, which also gets consumers
-lakerror transitively; and find_dependency(akerror) in
akstdlibConfig.cmake. The last was already broken before this bump -- the
template still carried its MyLibraryConfig placeholder with the dependency
commented out, so any external find_package(akstdlib) failed with a bare
"akerror::akerror not found" out of the generated targets file.

Branch coverage of src/stdlib.c fell from 51.0% to 44.3% with no source or
test change: the 1.0.0 PREPARE_ERROR/FAIL_* macros expand to more branches
at every call site, so 337/661 became 481/1087 -- 144 more branches covered,
426 more counted. Line coverage held at 99.0% (200/202) and function
coverage at 100% (21/21). Re-ratchet the CI branch gate 45 -> 40 rather than
chase branches that belong to libakerror's own suite.

tests/test_status_registry.c pins the contract that made the status-code
migration a no-op: libakstdlib reserves no consumer range, so an application
may allocate from AKERR_FIRST_CONSUMER_STATUS without coordinating with it,
and every status this library raises is inside the reserved band with a name
actually registered -- an unnamed one degrades to "Unknown Error" in every
later stack trace, which nothing else would notice. It exercises the new
ownership enforcement too, so the "reserves nothing" assertion cannot pass
vacuously.

ctest 13/13, ASan+UBSan 13/13, coverage 15/15 at 90/40, mutation 89.6%
(155/173, unchanged). Also verified out of tree: the #error fires as the
first diagnostic against a stale akerror.h, pkg-config refuses akerror
0.9.0, and an external find_package(akstdlib) consumer builds and runs
against a temp-prefix install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:20:54 -04:00
a37ba3fb89 Namespace the coverage target when embedded
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m47s
libakstdlib CI Build / coverage (push) Successful in 2m38s
libakstdlib CI Build / mutation_test (push) Successful in 9m17s
Follows a87cbfb: a sibling dependency may ship a `coverage` target of its
own, so a non-top-level build gets `akstdlib_coverage` instead. The
top-level name is unchanged, and so is every documented command.

Verified: cmake --build build-coverage --target coverage still runs the
suite (14/14) and reports 99.0% of lines; the mutation target still
resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:08:25 -04:00
437da2960b Test the libc wrappers: 52% -> 99% line coverage
Every wrapper outside the list and tree code was untested. Six new test
files close that, following the plan already written in TODO.md 1.2-1.6:

  test_stream.c   fopen/fread/fwrite/fclose -- happy paths, the round
                  trip, AKERR_EOF on a short read, AKERR_IO on a stream
                  opened in the wrong mode, ENOENT, and the NULL guards
  test_format.c   printf/fprintf/sprintf -- text *and* count asserted
                  (stdout is pointed at a temp file to check aksl_printf),
                  all eight NULL guards, EBADF on a read-only stream, and
                  512 variadic calls in a loop as sanitizer cover for the
                  missing va_end
  test_convert.c  ato{i,l,ll,f} happy paths, negatives, leading
                  whitespace, NULL guards
  test_path.c     realpath on a file and on a symlink, both compared
                  against realpath(3) since TMPDIR may itself be a link;
                  ENOENT, ENOTDIR, NULL path
  test_strhash.c  djb2 known-answer vectors, len == 0, embedded NUL,
                  stability, NULL guards
  test_convert_strict.c
                  known-failing (2.1.5): the AKERR_VALUE / ERANGE
                  contract the ato* family cannot express today

test_tree.c gains the BFS AKERR_NOT_IMPLEMENTED contract, NULL arguments,
and a callback error that is not AKERR_ITERATOR_BREAK propagating out.

Tests deliberately say nothing about behaviour TODO.md records as
defective -- unchecked ptr/mode/resolved_path, short transfers reported as
success, *count left at -1, the djb2 sign extension -- so the eventual fix
does not have to come with a test rewrite. Each failure case in
test_path.c passes a zeroed buffer, because the wrapper's own error path
formats resolved_path with %s (2.1.6).

aksl_capture.h gains aksl_temp_file() with an atexit unlink backstop.
Without it every test that fails before its own unlink leaves temp files
behind -- which is the normal case for a known-failing test, and happens
173 times over in a mutation run.

Coverage on src/stdlib.c: 52.0% -> 99.0% of lines (200/202), 23.6% ->
51.0% of branches, 8/21 -> 21/21 functions. The two uncovered lines are
both `} HANDLE(e, AKERR_ITERATOR_BREAK) {`, where the macro starts with
the `break;` of PROCESS's `case 0:` arm -- reachable only via a non-NULL
error context whose status is zero, the pathology 2.2.1 exists to remove.

Mutation score on src/stdlib.c: 46.8% -> 89.6% (155/173 killed). CI, the
pre-push hook and the docs ratchet from 40 to 80 accordingly, and the 18
survivors are grouped by cause in TODO.md and README.md. A new CI
coverage job gates at 90% lines / 45% branches.

Verified:
  ctest --test-dir build            # 12/12
  ctest --test-dir build-asan       # 12/12 under ASan + UBSan
  ctest --test-dir build-coverage   # 14/14, report attached
  ctest --test-dir build -j8 --repeat until-fail:3
  gcc -Wall -Wextra -c on all nine test files  # no warnings
  python3 scripts/mutation_test.py --target src/stdlib.c  # 89.6%
No temp files left in /tmp after any of the above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:07:36 -04:00
82c47ed773 Add code coverage to the CTest suite
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>
2026-07-30 02:07:35 -04:00
a87cbfb26d Namespace the embedded mutation target
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m48s
libakstdlib CI Build / mutation_test (push) Successful in 8m0s
2026-07-29 18:02:21 -04:00
566004afd6 Add CLAUDE.md
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m45s
libakstdlib CI Build / mutation_test (push) Successful in 8m2s
2026-07-29 17:42:38 -04:00
11c04923f8 Add AGENTS.md
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m47s
libakstdlib CI Build / mutation_test (push) Successful in 8m3s
2026-07-29 17:29:49 -04:00
01734f511b Make error-status assertions authoritative
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m46s
libakstdlib CI Build / mutation_test (push) Successful in 8m1s
Replace standalone message assertions with a helper that checks the returned akerr status first, then validates message content only as secondary context. Drop ambiguous memcpy message checks where both NULL guards use the same format string.
2026-07-29 15:47:17 -04:00
8003239116 Add memory wrapper tests
Cover deterministic TODO 1.1 memory-wrapper behavior with a new CTest target. Exercise malloc/free round trips, NULL argument handling, memset fill/no-op semantics, and memcpy copy/no-op/null-pointer paths.

Harden AKSL_CHECK_OK so success requires no returned error context, catching wrappers that accidentally raise status-0 errors from stale errno.

Co-authored-by: Codex GPT-5.5 Default <codex-gpt-5.5-default@openai.com>
2026-07-29 14:42:27 -04:00
80205d5c4f Fix miscounted break-visit expectation in TODO.md 1.8
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m46s
libakstdlib CI Build / mutation_test (push) Successful in 7m49s
The bullet said breaking at tree[3] in pre-order should assert 4 visits.
Pre-order is 0, 1, 3, 4, 2, 5, 6, so the correct count is 3 -- which is what
tests/test_tree_iterate_break.c already asserts. The TODO was written before
that test existed and had the number wrong, not the test.

Also point the bullet at the existing pre-order test, and give the in-order
and post-order traversals with their break points so the remaining work does
not need the count re-derived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:22:25 -04:00
54fb44cd2b Document the test harnesses and add a pre-push hook
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m46s
libakstdlib CI Build / mutation_test (push) Successful in 7m48s
README.md was a build badge and nothing else. It now covers what the library
is, how to build it, and how to run each of the three harnesses locally: the
ctest suite, the AKSL_SANITIZE ASan/UBSan build, and mutation testing.

The section on reading ctest output matters most: two of the three test lists
are marked WILL_FAIL, so an all-green run 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.

.githooks/pre-push runs the two fast harnesses (default build + ctest, then
sanitizer build + ctest) before a push leaves the machine. It skips when there
is nothing to test (branch deletions, empty pushes), fails the push with the
captured output on any error, and builds under .git/aksl-prepush so it never
disturbs your own build/. The slow mutation gate is opt-in via
AKSL_HOOK_MUTATION=1, and git push --no-verify bypasses everything.

Enable with: git config core.hooksPath .githooks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:51:28 -04:00
68009ea0e3 Add mutation testing to the Gitea workflow
A separate mutation_test job runs scripts/mutation_test.py over src/stdlib.c
with a --threshold gate and publishes a JUnit report, mirroring libakerror's
CI. Checkout needs submodules: recursive -- the harness copies the repo and
builds the copy top-level, so deps/libakerror has to be there.

Measured score on the section 1.0 suite is 46.8% (81/173 killed). The gate is
set at 40: a regression ratchet, not a quality bar. Survivors cluster in the
wrappers that have no tests yet (printf family, ato* family, realpath, the
memory and stream wrappers); the list and tree code that section 1.0 does
cover leaves only 5 survivors between them.

Also caps every test with TIMEOUT 30. Measuring the score turned this up: a
mutant deleting `fast = fast->next->next` makes aksl_list_iterate spin
forever, so ctest never returned, the harness killed ctest at its own 120s
timeout, and the orphaned test binary kept burning a core while holding the
pipe open -- the run wedged and needed killing by hand. With a per-test
timeout ctest reaps its own child, those mutants are killed in 30s, and
nothing is left behind. It guards the real suite against the same shape of
bug too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:35:46 -04:00
3e296c3bff Build a real test harness (TODO.md section 1.0)
The suite could not fail and did not run. test_linkedlist.c asserted nothing
at all -- it printed node names and returned 0 -- so every confirmed list
defect passed it. test_tree.c was red on every run because parms.steps was
never reset between its three searches, and four libakerror tests registered
but never built, reporting Not Run. CI, meanwhile, was not fetching the
submodule, so configure failed before reaching any of it.

- tests/aksl_capture.h: AKSL_CHECK (NDEBUG-proof), AKSL_CHECK_STATUS/_OK to
  run an akerror-returning call, assert its status and release the context,
  a capturing akerr_log_method, aksl_slots_in_use(), and an AKSL_RUN driver
  that fails any test leaking an error-pool slot.
- test_linkedlist.c: rewritten as 15 assertion cases over the append,
  iterate and pop behaviour that is correct today.
- test_tree.c: each search builds its own tree and params.
- Registration driven by AKSL_TESTS, plus AKSL_WILL_FAIL_TESTS (aborts by
  design) and AKSL_KNOWN_FAILING_TESTS, which marks WILL_FAIL the three new
  tests asserting correct behaviour for the confirmed defects in TODO.md
  2.1.1-2.1.3. Fixing a defect flips its test to "unexpectedly passed",
  which is the cue to promote it into AKSL_TESTS.
- add_test/set_tests_properties are shadowed across the libakerror
  add_subdirectory call: CMake cannot un-register a test and
  set_tests_properties cannot cross directory scopes. The dependency has
  its own CI.
- AKSL_SANITIZE=ON builds library, tests and dependency with ASan+UBSan.
- scripts/mutation_test.py ported and retargeted at src/stdlib.c; wired to
  a manual `mutation` target, not a CI gate until 1.1-1.9 exist.
- CI: checkout with submodules: recursive, and ctest --output-on-failure.

ctest is now 5/5 green in both the default and sanitizer builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:39:54 -04:00
5793f6a178 Add tree search test
Some checks failed
libakstdlib CI Build / cmake_build (push) Failing after 2m41s
2026-06-27 13:13:17 -04:00
57929be1af Fix the cycle detection in iterate() and rename push() to append()
Some checks failed
libakstdlib CI Build / cmake_build (push) Failing after 2m40s
2026-06-27 12:11:27 -04:00
3e3ea41dc8 Add CI build
Some checks failed
libakstdlib CI Build / cmake_build (push) Failing after 2m41s
2026-06-27 10:40:06 -04:00
e4597aef7d Add support for tree structures. Search function with DFS, BFS currently unimplemented. Improve linked list handling; fix bug in circular list detection. 2026-06-27 08:21:13 -04:00
06b2c8ad8a Add a DJB2 string hashing function 2026-06-02 17:11:38 -04:00
32 changed files with 8314 additions and 9 deletions

122
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,122 @@
name: libakstdlib CI Build
run-name: ${{ gitea.actor }} libakstdlib test
on: [push]
jobs:
cmake_build:
runs-on: ubuntu-latest
steps:
- run: echo "Triggered by ${{ gitea.event_name }} from ${{ gitea.repository }}@${{ gitea.ref }}. Building on ${{ runner.os }}."
- name: Check out repository code
uses: actions/checkout@v4
with:
# A top-level build uses deps/libakerror via add_subdirectory, so the
# submodule has to be present or the configure step fails outright.
submodules: recursive
- name: dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake gcc moreutils
# Depends on libakerror@main
git clone https://source.starfort.tech/andrew/libakerror.git
cd libakerror
cmake -S . -B build
cmake --build build
cmake --install build
- name: build and test
run: |
cmake -S . -B build
cmake --build build
sudo cmake --install build
ctest --test-dir build --output-on-failure
- run: echo "🍏 This job's status is ${{ job.status }}."
coverage:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
submodules: recursive
- name: dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake gcc python3
# scripts/coverage.py needs nothing but python3 and gcc's own gcov, so
# there is no lcov/gcovr to install here.
#
# The gate is a ratchet, not a target: src/stdlib.c is at 99.1% of lines
# and 45.1% of branches, so 90/40 fails on a real regression (a test
# deleted, or new untested code added) without tripping over rounding.
# The report is printed either way -- the uncovered lines it lists are the
# missing tests.
#
# Branch coverage is back over 45 (the aksl_version_* functions are fully
# covered, which lifted it from 44.3%), but the gate stays at 40: 0.1
# points of headroom is not a ratchet, it is a coin flip on the next
# rounding change.
#
# The branch gate was 45 against 51.0% until the libakerror 1.0.0 bump.
# Nothing about this library's tests changed: line coverage held at
# 99.0% (200/202) and function coverage at 100% (21/21), but the branch
# denominator went from 661 to 1087 because the 1.0.0 PREPARE_ERROR /
# FAIL_* macros expand to more branches at every call site in
# src/stdlib.c, and most of the added branches are not reachable from the
# way this library calls them. 337/661 became 481/1087 -- 144 more
# branches covered, 426 more branches counted. Chasing them here would be
# testing libakerror's macros, which is libakerror's mutation suite's job
# (macros expand at the call site, so coverage cannot see them properly
# from either side). Re-ratcheted rather than papered over.
- name: coverage
run: |
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \
-DAKSL_COVERAGE_THRESHOLD=90 \
-DAKSL_COVERAGE_BRANCH_THRESHOLD=40
cmake --build build-coverage
ctest --test-dir build-coverage --output-on-failure
cat build-coverage/coverage-summary.txt
- run: echo "🍏 This job's status is ${{ job.status }}."
mutation_test:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
# The harness copies the repo and builds the copy top-level, so it
# needs deps/libakerror present just like the main job does.
submodules: recursive
- name: dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake gcc moreutils python3
# Verify the tests actually catch bugs: break the library many ways and
# confirm the suite fails. Gated on src/stdlib.c (fast, deterministic);
# run the full default target locally for the macro header as well.
#
# The threshold is a ratchet, not a quality bar. The score is 89.6%
# (155/173 killed) now that TODO.md sections 1.2-1.6 have tests; it was
# 46.8% when only the list and tree functions were covered. 80 leaves
# headroom for the runner while still failing on a real regression (tests
# deleted, or new untested code added). The 18 survivors are listed in the
# published report -- each one is a missing assertion.
- name: mutation testing
run: |
python3 scripts/mutation_test.py \
--target src/stdlib.c \
--junit mutation-junit.xml \
--threshold 80
# Publish even when the threshold gate fails, so survivors are visible --
# each one is a missing test. Display-only (fail_on_failure: false); the
# --threshold above is the gate. annotate_only avoids the Checks API 404
# on Gitea (mikepenz/action-junit-report#23).
- name: publish mutation results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'mutation-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'false'
- run: echo "🍏 This job's status is ${{ job.status }}."

100
.githooks/pre-push Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# pre-push: build libakstdlib and run the test harnesses before anything leaves
# this machine.
#
# Install (once per clone):
#
# git config core.hooksPath .githooks
#
# Runs by default, a few seconds all in:
#
# * default build + ctest
# * ASan/UBSan build + ctest
#
# Opt in to the slow harness (~25 minutes -- it rebuilds and re-runs the whole
# suite once per mutant):
#
# AKSL_HOOK_MUTATION=1 git push
#
# Bypass everything (git's own escape hatch):
#
# git push --no-verify
#
# Builds go under .git/aksl-prepush so the hook never disturbs whatever is in
# your own build/ directory. Override with AKSL_HOOK_BUILD_DIR if you want them
# somewhere else.
set -u
# Keep in sync with the --threshold in .gitea/workflows/ci.yaml, so a push that
# would fail CI fails here first.
MUTATION_THRESHOLD="${AKSL_MUTATION_THRESHOLD:-80}"
ZERO_SHA=0000000000000000000000000000000000000000
root=$(git rev-parse --show-toplevel) || exit 1
cd "$root" || exit 1
# git feeds us one line per ref being pushed. A push that only *deletes* refs
# has no commits to test, so there is nothing to do. An empty stdin (nothing to
# push) lands here too, which is equally fine to skip.
has_updates=0
while read -r _local_ref local_sha _remote_ref _remote_sha; do
if [ "$local_sha" != "$ZERO_SHA" ]; then
has_updates=1
fi
done
if [ "$has_updates" -eq 0 ]; then
exit 0
fi
if [ ! -f deps/libakerror/CMakeLists.txt ]; then
echo "pre-push: deps/libakerror is empty. Run:" >&2
echo " git submodule update --init --recursive" >&2
exit 1
fi
builddir="${AKSL_HOOK_BUILD_DIR:-$(git rev-parse --git-dir)/aksl-prepush}"
mkdir -p "$builddir" || exit 1
logfile="$builddir/last.log"
# Run a step quietly; on failure, dump what it said and abort the push.
run() {
if ! "$@" > "$logfile" 2>&1; then
echo >&2
echo "pre-push: FAILED: $*" >&2
echo "---------------------------------------------------------------" >&2
cat "$logfile" >&2
echo "---------------------------------------------------------------" >&2
echo "pre-push: push aborted. Use 'git push --no-verify' to override." >&2
exit 1
fi
}
echo "pre-push: default build + ctest"
run cmake -S . -B "$builddir/default"
run cmake --build "$builddir/default"
run ctest --test-dir "$builddir/default" --output-on-failure
echo "pre-push: sanitizer build + ctest"
run cmake -S . -B "$builddir/asan" -DAKSL_SANITIZE=ON
run cmake --build "$builddir/asan"
run ctest --test-dir "$builddir/asan" --output-on-failure
if [ "${AKSL_HOOK_MUTATION:-0}" = "1" ]; then
echo "pre-push: mutation testing, threshold ${MUTATION_THRESHOLD}% (this takes a while)"
# Deliberately not wrapped in run(): this one is slow enough that you want
# to watch it make progress.
if ! python3 scripts/mutation_test.py \
--target src/stdlib.c \
--threshold "$MUTATION_THRESHOLD"; then
echo >&2
echo "pre-push: mutation score below ${MUTATION_THRESHOLD}%. Push aborted." >&2
echo "pre-push: use 'git push --no-verify' to override." >&2
exit 1
fi
fi
echo "pre-push: OK"
exit 0

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "deps/libakerror"]
path = deps/libakerror
url = https://source.starfort.tech/andrew/libakerror.git

82
AGENTS.md Normal file
View File

@@ -0,0 +1,82 @@
# Repository Guidelines
## Project Structure & Module Organization
`libakstdlib` is a C shared library that wraps libc calls and small data
structures in `libakerror` error contexts. Public API declarations live in
`include/akstdlib.h`; implementation lives in `src/stdlib.c`. Tests are
one-file CTest executables under `tests/test_<name>.c`, with shared test helpers
in `tests/aksl_capture.h`. CMake package templates are in `cmake/` and
`akstdlib.pc.in`. The vendored dependency is `deps/libakerror`; update it as a
submodule rather than editing generated files under `build/`, `build-asan/` or
`build-coverage/`.
## Build, Test, and Development Commands
Initialize dependencies before a fresh build:
```sh
git submodule update --init --recursive
cmake -S . -B build
cmake --build build
```
Run the normal suite with `ctest --test-dir build --output-on-failure`. Use the
instrumented build for memory and undefined-behavior checks:
```sh
cmake -S . -B build-asan -DAKSL_SANITIZE=ON
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure
```
For coverage, configure a third tree; the report is part of that suite (the
`coverage_reset` / `coverage_report` CTest entries) and also lands in
`build-coverage/coverage-summary.txt`:
```sh
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON
cmake --build build-coverage --target coverage
```
Run `cmake --build build --target mutation` only when you need the slower
mutation harness. `rebuild.sh` installs to `/home/andrew/local` and removes the
local build directory, so treat it as a local convenience script.
## Coding Style & Naming Conventions
Use C with 4-space indentation; existing files sometimes use tabs for continued
statements, so match the surrounding block. Public symbols use the `aksl_`
prefix, structs use `aksl_<Name>`, and tests use `test_<feature>.c` plus static
`test_<case>` functions. Preserve the `akerr_ErrorContext AKERR_NOIGNORE *`
return convention and the `PREPARE_ERROR` / `FAIL_*` / `SUCCEED_RETURN` pattern.
## Testing Guidelines
Add a new test by creating `tests/test_mything.c` and adding `mything` to the
right list in `CMakeLists.txt`. `AKSL_TESTS` must exit zero.
`AKSL_WILL_FAIL_TESTS` are deliberate abort/contract tests.
`AKSL_KNOWN_FAILING_TESTS` assert documented defects from `TODO.md`; when one
starts unexpectedly passing, move it into `AKSL_TESTS` with the fix.
`src/stdlib.c` is at 99.1% line coverage and CI gates it at 90 (line) / 40
(branch), so new code needs tests in the same commit. Run
`cmake --build build-coverage --target coverage` and check the uncovered-line
listing before proposing a change. Tests for behaviour that `TODO.md` records as
defective belong in `AKSL_KNOWN_FAILING_TESTS` asserting the *correct* contract —
do not pin current-but-wrong behaviour in `AKSL_TESTS`, since that turns the
eventual fix into a test failure.
## Commit & Pull Request Guidelines
Recent commits use short imperative summaries, for example `Add memory wrapper
tests` and `Make error-status assertions authoritative`. Keep commits focused
and include tests with behavior changes. Pull requests should describe the
changed API or behavior, list the CTest/sanitizer/mutation commands run, and
link the relevant `TODO.md` item or issue when fixing a known defect.
## Agent-Specific Instructions
Do not modify generated build trees, profiling artifacts, or untracked scratch
files unless explicitly asked. Prefer small, test-backed changes and update
`README.md` or `TODO.md` when changing documented workflows or known failures.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
See @AGENTS.md

View File

@@ -1,5 +1,95 @@
cmake_minimum_required(VERSION 3.10)
project(akstdlib LANGUAGES C)
# The single source of truth for the version. It flows from here into
# include/akstdlib_version.h (generated from the .in template next to it), the
# shared library's VERSION/SOVERSION, the Version: field in akstdlib.pc, and
# akstdlibConfigVersion.cmake. Nothing else should spell a version number.
#
# 0.x on purpose: TODO.md section 2.1 still records four confirmed defects whose
# fixes change documented behaviour (aksl_atoi's contract, aksl_realpath's
# signature), so this library is not promising a stable API yet.
project(akstdlib VERSION 0.1.0 LANGUAGES C)
# The granularity at which the ABI is allowed to break, and therefore the
# soname: libakstdlib.so.0.1. Pre-1.0 that is MAJOR.MINOR, because a 0.x library
# makes no compatibility promise across a minor bump. At 1.0 this becomes
# ${PROJECT_VERSION_MAJOR} alone -- change it here, and AKSL_VERSION_SONAME in
# the header template follows automatically because it is configured from this.
if(PROJECT_VERSION_MAJOR EQUAL 0)
set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
else()
set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}")
endif()
set(AKSL_GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/include")
set(AKSL_VERSION_HEADER "${AKSL_GENERATED_INCLUDE_DIR}/akstdlib_version.h")
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/include/akstdlib_version.h.in"
"${AKSL_VERSION_HEADER}"
@ONLY
)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -ggdb -pg")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -g -ggdb -pg")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -g -ggdb -pg")
# Sanitizer build, off by default:
# cmake -S . -B build-asan -DAKSL_SANITIZE=ON && ctest --test-dir build-asan
# Set before the dependency is added so libakerror is instrumented too --
# several of the defects in TODO.md section 2 (the uninitialised %s in
# aksl_realpath, the unbounded vsprintf in aksl_sprintf, the missing va_end in
# the printf family) only show up under ASan/UBSan.
option(AKSL_SANITIZE "Build the library and its tests with ASan + UBSan" OFF)
if(AKSL_SANITIZE)
set(AKSL_SANITIZE_FLAGS "-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${AKSL_SANITIZE_FLAGS}")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}")
message(STATUS "AKSL_SANITIZE=ON: building with ASan + UBSan")
endif()
# Coverage build, off by default:
# cmake -S . -B build-coverage -DAKSL_COVERAGE=ON
# cmake --build build-coverage --target coverage
# Instrumentation is applied per target below (the library and the test
# binaries) rather than through CMAKE_C_FLAGS, so deps/libakerror is left
# uninstrumented -- it has its own suite, and its .gcda would only be noise in
# this project's report.
option(AKSL_COVERAGE "Build the library and its tests with gcov instrumentation" OFF)
# Minimum total line / branch coverage for the `coverage_report` CTest entry
# that a -DAKSL_COVERAGE=ON build adds. 0 disables the gate and reports only.
set(AKSL_COVERAGE_THRESHOLD 0 CACHE STRING
"Fail the coverage_report test below this total line coverage percentage")
set(AKSL_COVERAGE_BRANCH_THRESHOLD 0 CACHE STRING
"Fail the coverage_report test below this total branch coverage percentage")
if(AKSL_COVERAGE)
if(NOT CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$")
message(FATAL_ERROR
"AKSL_COVERAGE=ON needs a gcov-compatible compiler; "
"CMAKE_C_COMPILER_ID is ${CMAKE_C_COMPILER_ID}")
endif()
# -O0 because the optimizer folds and reorders lines until per-line counts
# stop matching the source. -fprofile-abs-path makes gcov record absolute
# source paths, which is what lets the report be read from any directory.
set(AKSL_COVERAGE_COMPILE_FLAGS --coverage -O0 -g)
if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
list(APPEND AKSL_COVERAGE_COMPILE_FLAGS -fprofile-abs-path)
endif()
message(STATUS "AKSL_COVERAGE=ON: instrumenting akstdlib and its tests for gcov")
endif()
# Add gcov instrumentation to one target. No-op unless AKSL_COVERAGE is set.
function(aksl_target_coverage target)
if(NOT AKSL_COVERAGE)
return()
endif()
target_compile_options(${target} PRIVATE ${AKSL_COVERAGE_COMPILE_FLAGS})
if(CMAKE_VERSION VERSION_LESS 3.13)
set_property(TARGET ${target} APPEND_STRING PROPERTY LINK_FLAGS " --coverage")
else()
target_link_options(${target} PRIVATE --coverage)
endif()
endfunction()
if(TARGET akerror::akerror)
message(STATUS "FOUND akerror::akerror")
@@ -11,9 +101,52 @@ include(CTest)
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
if(NOT TARGET akerror::akerror)
find_package(PkgConfig REQUIRED)
find_package(akerror REQUIRED)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
# libakerror registers its own CTest entries unconditionally, and because we
# pull it in EXCLUDE_FROM_ALL its test binaries are never built -- so every
# one of them used to show up in our suite as "Not Run" and fail. CMake has no
# way to un-register a test, and set_tests_properties cannot reach across
# directory scopes, so add_test() is shadowed for the duration of the
# add_subdirectory() call. The dependency has its own CI; this project's suite
# should only contain this project's tests.
set(AKSL_SUPPRESS_ADD_TEST TRUE)
function(add_test)
if(NOT AKSL_SUPPRESS_ADD_TEST)
_add_test(${ARGV})
endif()
endfunction()
# libakerror also marks two of those tests WILL_FAIL, which would now be
# setting properties on tests that no longer exist, so suppress that too.
function(set_tests_properties)
if(NOT AKSL_SUPPRESS_ADD_TEST)
_set_tests_properties(${ARGV})
endif()
endfunction()
# libakerror namespaces its `mutation` target when it is embedded but not its
# `coverage` target (deps/libakerror/CMakeLists.txt:172 vs :189), so a
# -DAKSL_COVERAGE=ON build hits "another target with the same name already
# exists" against the `coverage` target this project adds, and fails to
# configure at all. Rename the dependency's on the way past rather than
# dropping it: its coverage script drives its own instrumented build tree, so
# `cmake --build build-coverage --target akerror_coverage` still does the right
# thing. Remove this once the dependency namespaces it upstream -- see TODO.md.
function(add_custom_target _name)
if(AKSL_SUPPRESS_ADD_TEST AND _name STREQUAL "coverage")
_add_custom_target(akerror_coverage ${ARGN})
else()
_add_custom_target(${ARGV})
endif()
endfunction()
add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)
set(AKSL_SUPPRESS_ADD_TEST FALSE)
else()
if(NOT TARGET akerror::akerror)
find_package(PkgConfig REQUIRED)
find_package(akerror REQUIRED)
endif()
endif()
set(akstdlib_install_cmakedir "${CMAKE_INSTALL_LIBDIR}/cmake/akstdlib")
@@ -29,14 +162,29 @@ add_library(akstdlib SHARED
add_library(akstdlib::akstdlib ALIAS akstdlib)
# Specify include directories for the library's headers (if applicable)
# Specify include directories for the library's headers (if applicable).
# The generated directory carries akstdlib_version.h, which akstdlib.h includes;
# it is PUBLIC because consumers building against the build tree need it too. On
# install both headers land side by side in ${CMAKE_INSTALL_INCLUDEDIR}, so the
# install interface needs no second entry.
target_include_directories(akstdlib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<BUILD_INTERFACE:${AKSL_GENERATED_INCLUDE_DIR}>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/>
)
# VERSION gives the real file (libakstdlib.so.0.1.0); SOVERSION gives the symlink
# and the ELF soname recorded in every consumer (libakstdlib.so.0.1), so a
# consumer built against 0.1 will not silently load an ABI-incompatible 0.2.
set_target_properties(akstdlib PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${AKSL_SOVERSION}
)
target_link_libraries(akstdlib PUBLIC akerror::akerror)
aksl_target_coverage(akstdlib)
set(main_lib_dest "lib/my_library-${MY_LIBRARY_VERSION}")
install(TARGETS akstdlib
EXPORT akstdlibTargets
@@ -47,6 +195,7 @@ install(TARGETS akstdlib
)
install(FILES "include/akstdlib.h" DESTINATION "include/")
install(FILES "${AKSL_VERSION_HEADER}" DESTINATION "include/")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akstdlib.pc DESTINATION "lib/pkgconfig/")
@@ -62,9 +211,205 @@ configure_package_config_file(
INSTALL_DESTINATION ${akstdlib_install_cmakedir}
)
# Without this, find_package(akstdlib 0.1 REQUIRED) is refused for want of a
# version file no matter what is installed -- which is exactly the gap that
# stops cmake/akstdlib.cmake.in from requesting a version of akerror.
#
# SameMinorVersion mirrors the soname: pre-1.0, 0.1 and 0.2 are different ABIs.
# It arrived in CMake 3.11 and this project declares 3.10, so fall back to
# ExactVersion on older CMake. That is stricter than the soname rule -- it pins
# the patch level too, so a 0.1.0 request refuses a compatible 0.1.1 -- but it is
# never laxer, and wrongly refusing a good pairing beats wrongly accepting a bad
# one. Drop the branch when the minimum moves past 3.11.
if(CMAKE_VERSION VERSION_LESS 3.11)
set(AKSL_VERSION_COMPATIBILITY ExactVersion)
else()
set(AKSL_VERSION_COMPATIBILITY SameMinorVersion)
endif()
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY ${AKSL_VERSION_COMPATIBILITY}
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake"
DESTINATION ${akstdlib_install_cmakedir}
)
# Each test is one source file tests/test_<name>.c, built into the executable
# test_<name> and registered as the CTest test <name>. Shared helpers live in
# tests/aksl_capture.h.
#
# AKSL_TESTS must exit 0.
# AKSL_WILL_FAIL_TESTS expected to abort by design (an unhandled error
# reaching FINISH_NORETURN, a deliberate contract
# violation), so a non-zero exit is a pass.
# AKSL_KNOWN_FAILING_TESTS assert the *correct* behaviour of a confirmed
# defect from TODO.md section 2.1. They fail until
# the defect is fixed, and are marked WILL_FAIL so
# the suite stays green and the gap stays visible.
# When one is fixed CTest reports it as failed with
# "unexpectedly passed" -- that is the cue to move
# it up into AKSL_TESTS.
set(AKSL_TESTS
convert
format
linkedlist
memory
path
status_registry
stream
strhash
tree
version
)
set(AKSL_WILL_FAIL_TESTS
)
set(AKSL_KNOWN_FAILING_TESTS
convert_strict # TODO.md 2.1.5 -- ato* cannot report a bad conversion
list_append_chain # TODO.md 2.1.1 -- append truncates lists of 2+ nodes
list_iterate_head # TODO.md 2.1.2 -- iterate starts at the midpoint
tree_iterate_break # TODO.md 2.1.3 -- ITERATOR_BREAK does not stop a walk
)
foreach(_test IN LISTS AKSL_TESTS AKSL_WILL_FAIL_TESTS AKSL_KNOWN_FAILING_TESTS)
add_executable(test_${_test} tests/test_${_test}.c)
target_include_directories(test_${_test} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests)
target_link_libraries(test_${_test} PRIVATE akstdlib)
# The test binaries are instrumented too. The default report filters them out
# (only src/ and include/ are shown), but `scripts/coverage.py --include
# tests` then answers a different question: whether every test case and
# helper branch in tests/ actually runs, which is how a test function that was
# written but never wired into AKSL_RUN shows up.
aksl_target_coverage(test_${_test})
add_test(NAME ${_test} COMMAND test_${_test})
list(APPEND AKSL_TEST_TARGETS test_${_test})
endforeach()
if(AKSL_WILL_FAIL_TESTS OR AKSL_KNOWN_FAILING_TESTS)
set_tests_properties(
${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
PROPERTIES WILL_FAIL TRUE
)
endif()
# Cap every test. The list and tree code is full of loops whose termination
# depends on a single condition, so a plausible bug -- or a mutant from the
# target below -- turns a test into an infinite loop. Without this, ctest waits
# forever; the mutation harness then kills ctest at its own timeout and the
# spinning test binary is left orphaned, holding the pipe open and burning a
# core. The whole suite runs in well under a second, so 30s is pure headroom.
set_tests_properties(
${AKSL_TESTS} ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
PROPERTIES TIMEOUT 30
)
# Both the coverage report and the mutation harness are Python scripts.
find_package(Python3 COMPONENTS Interpreter)
# Code coverage. A -DAKSL_COVERAGE=ON build wires the report into the suite
# itself, so `ctest --test-dir build-coverage` both runs the tests and prints
# what they touched:
#
# coverage_reset deletes the accumulated .gcda counters. gcov counts are
# cumulative, so without this every report would fold in
# earlier runs and overstate coverage. FIXTURES_SETUP makes
# CTest run it before any test that needs the fixture, even
# under `ctest -j`.
# coverage_report aggregates gcov output and prints the summary plus the
# uncovered lines. FIXTURES_CLEANUP makes CTest run it after
# the last test in the fixture, which is exactly when the
# counters are complete.
#
# The report is a plain report unless AKSL_COVERAGE_THRESHOLD is set, in which
# case coverage_report fails below that percentage. Same ratchet idea as the
# mutation threshold: gate on the number you have, raise it as tests land.
#
# CTest hides the output of a passing test, so coverage_report also writes
# <build>/coverage-summary.txt and <build>/coverage.xml (Cobertura). The
# `coverage` target below builds, runs and prints in one step.
if(AKSL_COVERAGE AND Python3_FOUND)
set(AKSL_COVERAGE_ARGS
${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/coverage.py
--build ${CMAKE_CURRENT_BINARY_DIR}
--source-root ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME coverage_reset COMMAND ${AKSL_COVERAGE_ARGS} --zero)
add_test(NAME coverage_report COMMAND ${AKSL_COVERAGE_ARGS}
--threshold ${AKSL_COVERAGE_THRESHOLD}
--branch-threshold ${AKSL_COVERAGE_BRANCH_THRESHOLD}
--output ${CMAKE_CURRENT_BINARY_DIR}/coverage-summary.txt
--cobertura ${CMAKE_CURRENT_BINARY_DIR}/coverage.xml)
set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP AKSL_GCDA)
set_tests_properties(coverage_report PROPERTIES FIXTURES_CLEANUP AKSL_GCDA)
set_tests_properties(
${AKSL_TESTS} ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
PROPERTIES FIXTURES_REQUIRED AKSL_GCDA
)
# gcov has to be spawned once per .gcda, so give the report more room than the
# 30s the test binaries get.
set_tests_properties(coverage_reset coverage_report PROPERTIES TIMEOUT 300)
# Namespaced when embedded in another project, for the same reason the mutation
# target below is: a sibling dependency may well ship a `coverage` target too.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKSL_COVERAGE_TARGET coverage)
else()
set(AKSL_COVERAGE_TARGET akstdlib_coverage)
endif()
# Convenience entry point that also builds the test binaries first. It runs
# the suite rather than the script directly, because the two fixture tests
# above already reset the counters and produce the report -- and CTest pulls a
# required fixture back in even when it is filtered out, so there is no way to
# run the tests without them. The second command re-reads the same counters to
# print the report that CTest suppressed for the passing coverage_report test
# (it only spawns gcov again, it does not re-run anything).
add_custom_target(${AKSL_COVERAGE_TARGET}
COMMAND ctest --test-dir ${CMAKE_CURRENT_BINARY_DIR} --output-on-failure
COMMAND ${AKSL_COVERAGE_ARGS}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
USES_TERMINAL
COMMENT "Running the test suite under gcov and reporting coverage"
)
add_dependencies(${AKSL_COVERAGE_TARGET} ${AKSL_TEST_TARGETS})
elseif(AKSL_COVERAGE)
message(WARNING "AKSL_COVERAGE=ON but Python3 was not found: "
"instrumenting the build, but the coverage report "
"(scripts/coverage.py) will not be wired into CTest")
endif()
# Mutation testing: break the library in small ways and confirm the test suite
# notices. This is a meta-check on the tests themselves, and it rebuilds and
# re-runs the whole suite once per mutant, so it is a manual target rather than
# a CTest test:
# cmake --build build --target mutation
# When embedded in another project, use a namespaced target to avoid collisions
# with mutation targets provided by sibling dependencies.
# This target covers both src/stdlib.c and include/akstdlib.h. CI runs the
# narrower, faster src/stdlib.c set with a --threshold gate; see
# .gitea/workflows/ci.yaml.
if(Python3_FOUND)
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKSL_MUTATION_TARGET mutation)
else()
set(AKSL_MUTATION_TARGET akstdlib_mutation)
endif()
add_custom_target(${AKSL_MUTATION_TARGET}
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
--source-root ${CMAKE_CURRENT_SOURCE_DIR}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
USES_TERMINAL
COMMENT "Running mutation tests (breaks the library, expects tests to fail)"
)
endif()
# pkgconfig

2863
Doxyfile Normal file

File diff suppressed because it is too large Load Diff

338
README.md Normal file
View File

@@ -0,0 +1,338 @@
# README
![build badge](https://source.starfort.tech/andrew/libakstdlib/actions/workflows/ci.yaml/badge.svg?branch=main)
`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`.

715
TODO.md Normal file
View File

@@ -0,0 +1,715 @@
# TODO
Working notes for `libakstdlib` — the akerror-wrapped libc surface.
Scope of the current library (`include/akstdlib.h`, `src/stdlib.c`): 16 libc wrappers
(`fopen`, `fread`, `fwrite`, `fclose`, `malloc`, `free`, `memset`, `memcpy`, `printf`,
`fprintf`, `sprintf`, `atoi`, `atol`, `atoll`, `atof`, `realpath`), one hash helper
(`aksl_strhash_djb2`), a doubly-linked list (`append`/`pop`/`iterate`) and a binary tree
iterator (`aksl_tree_iterate`).
Items marked **[CONFIRMED]** were reproduced by building the library and running a probe
program against it, not just read off the source.
---
## 1. Unit tests that should be written
### 1.0 Test-harness prerequisites — **DONE** (branch `feature/test-harness`)
`ctest` is green: 5/5, no *Not Run* entries. Everything in §1.11.9 can now be written
against `tests/aksl_capture.h` and added to the `AKSL_TESTS` list.
- [x] **The current tests cannot fail.** `tests/test_linkedlist.c` asserted nothing at all;
it printed node names and returned `0` unconditionally, so any of the list bugs in
§2.1 would pass it. Rewritten as 15 assertion-based cases covering the append, iterate
and pop behaviour that is correct today.
- [x] **`tests/test_tree.c` was red.** `ctest` reported `tree (Failed)`, exit 136, because
`parms.steps` was never reset between the three searches, so the second assertion
compared an accumulated `14` against `7`. Each search now builds its own tree and
params. The file records that its step counts do not actually distinguish the three
traversal orders — real order assertions remain §1.8.
- [x] **Adopt libakerror's test conventions.** `tests/aksl_capture.h` provides `AKSL_CHECK()`
(an `NDEBUG`-proof assert), `AKSL_CHECK_STATUS()`/`AKSL_CHECK_OK()` which run an
akerror-returning call, assert on its status and release the context,
`AKSL_CHECK_MSG_CONTAINS()`, a capturing `akerr_log_method`, `aksl_slots_in_use()` for
pool-leak checks, and an `AKSL_RUN()` driver that additionally fails any test which
leaks an error-pool slot.
- [x] **One test source per behaviour, registered with CTest via a list variable.**
`tests/test_<name>.c` → executable `test_<name>` → CTest test `<name>`, driven by
`AKSL_TESTS` in `CMakeLists.txt`.
- [x] **Add a `WILL_FAIL` category.** Two lists: `AKSL_WILL_FAIL_TESTS` for tests that abort
by design (empty for now) and `AKSL_KNOWN_FAILING_TESTS` for the confirmed defects in
§2.1 — see the note at the head of §2.1.
- [x] **Fix the four permanently-failing submodule tests.** `deps/libakerror` is added
`EXCLUDE_FROM_ALL`, so akerror's `add_test` entries registered but its test binaries
were never built, and `ctest` reported `err_catch`, `err_cleanup`, `err_trace`,
`err_improper_closure` as *Not Run* → failed on every run. Neither the pinned commit
nor upstream `main` guards that registration, CMake has no way to un-register a test,
and `set_tests_properties` cannot reach across directory scopes — so `add_test` and
`set_tests_properties` are shadowed for the duration of the `add_subdirectory` call.
The dependency has its own CI; this suite now contains only this project's tests.
- [x] **Wire in a memory-error build.** `cmake -S . -B build-asan -DAKSL_SANITIZE=ON` adds
`-fsanitize=address,undefined -fno-sanitize-recover=all` to the library, the tests and
the vendored libakerror. Currently clean, and it is the intended way to test the items
in §2 that only misbehave under instrumentation (uninitialised `%s` in
`aksl_realpath`, unbounded `vsprintf`, missing `va_end`).
- [x] **Port `scripts/mutation_test.py` from libakerror.** Retargeted at `src/stdlib.c` and
`include/akstdlib.h` (178 mutants) and exposed as
`cmake --build build --target mutation`. CI runs the narrower `src/stdlib.c` set (173
mutants) as its own `mutation_test` job with `--threshold 80` and a JUnit report.
**Current score: 89.6%** — 155 killed, 18 survived. It was 46.8% (81 killed, 92
survived) before §1.21.6 were written; the remaining survivors are:
| surviving mutants | where | why |
|---|---|---|
| 5 | deleted statements whose absence nothing observes (`free(ptr)`, `obj->next = NULL`, three `SUCCEED_RETURN`s) | needs an assertion on the side effect, not on the status |
| 4 | `aksl_list_append`'s cycle/tail walk | the function is broken here (§2.1.1); its known-failing test cannot pin the internals yet |
| 4 | `lalloc`/`lfree` defaulting in `aksl_tree_iterate` | dead parameters (§2.2.8) — defaulted, then never called |
| 3 | `*count == -1` in the `printf` family | `*count` on the error path is itself a contract gap (§1.3) |
| 2 | `feof` in `aksl_fwrite`, `break` after the BFS `FAIL_RETURN` | unreachable in practice |
The threshold is a regression ratchet, not a quality bar; raise it as those survivors
become assertions. Each one is a concrete missing test — `mutation-junit.xml` lists
them with `file:line` and the exact edit.
- [x] **Cap every test with a CTest `TIMEOUT`.** Found while measuring the above: a mutant
that deletes `fast = fast->next->next` turns `aksl_list_iterate` into an infinite
loop, so `ctest` waited forever, the mutation harness killed `ctest` at its own
120 s timeout, and the orphaned test binary kept spinning at 100% CPU while holding
the pipe open — the run wedged and had to be killed by hand. `TIMEOUT 30` on every
test makes `ctest` reap its own child, so those mutants are now killed in 30 s and
leave nothing behind. It also guards the real suite against the same class of bug.
- [x] **CI could not have run any of this.** `actions/checkout@v4` was not fetching
submodules, so `add_subdirectory(deps/libakerror)` had nothing to descend into and the
configure step failed before ever reaching the tests. Added `submodules: recursive`,
and swapped `cmake --build build --target test` for `ctest --output-on-failure` so a
failure is diagnosable from the job log. The deeper problem — CI installing
`libakerror@main` while the build actually compiles the pinned submodule — is §2.3.
### 1.1 Memory wrappers
- [x] `aksl_malloc` — happy path returns non-NULL and writes through `dst`.
- [x] `aksl_malloc(n, NULL)``AKERR_NULLPOINTER`, message content asserted.
- [ ] `aksl_malloc(0, &p)` — pin down the contract. Today the result depends on whether the
platform's `malloc(0)` returns NULL; if it does, the wrapper reports `errno`, which may
be `0`. See §2.2.1.
- [ ] `aksl_malloc(SIZE_MAX, &p)` → allocation failure surfaces `ENOMEM`, and `*dst` is left
NULL rather than garbage.
- [x] `aksl_free(NULL)``AKERR_NULLPOINTER` (assert the documented behaviour, since
`free(NULL)` is legal C and callers will be surprised).
- [x] `aksl_free` happy path, and a malloc→free round trip under ASan.
- [x] `aksl_memset` — happy path fills the buffer; `n == 0` is a no-op; `s == NULL`
`AKERR_NULLPOINTER`.
- [x] `aksl_memcpy` — happy path copies; `d == NULL` and `s == NULL` each →
`AKERR_NULLPOINTER`; `n == 0` with valid pointers succeeds.
- [ ] `aksl_memcpy` with overlapping regions — decide and test whether this is rejected
(`AKERR_VALUE`) or documented as UB like `memcpy`.
### 1.2 File I/O
Covered by `tests/test_stream.c`.
- [x] `aksl_fopen` happy path on a temp file; `*fp` is written.
- [x] `aksl_fopen("/nonexistent/path", "r", &fp)``ENOENT` propagated as the status, with
the pathname in the message.
- [ ] `aksl_fopen` on a mode-denied path (e.g. `/proc/1/mem`, or a `chmod 000` temp file) →
`EACCES`.
- [x] `aksl_fopen(path, mode, NULL)``AKERR_NULLPOINTER`.
- [ ] `aksl_fopen(NULL, "r", &fp)` and `aksl_fopen(path, NULL, &fp)` → currently unchecked,
see §2.2.2. Test once the guards exist.
- [x] `aksl_fread` full read; short read at EOF → `AKERR_EOF`; read from a write-only stream
`AKERR_IO`; `fp == NULL``AKERR_NULLPOINTER`. `ptr == NULL` is still unchecked and
untested (see §2.2.3).
- [ ] `aksl_fread` **partial read that is neither EOF nor error** — assert whatever the fixed
contract is; today this silently returns success and the caller cannot tell how many
members were read.
- [x] `aksl_fwrite` happy path; write to a read-only stream → `AKERR_IO`; `fp == NULL`
`AKERR_NULLPOINTER`. The full-device (`/dev/full`) case is still open.
- [x] `aksl_fclose` happy path; `NULL``AKERR_NULLPOINTER`. Double-close is still
undecided, so it is neither caught nor tested.
- [ ] `aksl_fclose` on a stream whose buffered flush fails (`/dev/full`) → non-zero `fclose`
surfaces `errno`.
- [x] Round-trip test: `fopen``fwrite``fclose``fopen``fread` → compare bytes.
### 1.3 Formatted output
Covered by `tests/test_format.c`.
- [x] `aksl_printf` / `aksl_fprintf` / `aksl_sprintf` happy paths, asserting both the byte
count written through `count` and the produced text. (`aksl_printf` is checked by
pointing `stdout` at a temp file for the duration of the call.)
- [x] Each of the three with every pointer argument NULL in turn → `AKERR_NULLPOINTER`.
- [x] `aksl_fprintf` to a read-only stream → the `errno` it saw (`EBADF`) with "Short write"
in the message. `*count` is still left holding `-1`; the test asserts the status only,
so it will not have to change when that is fixed.
- [ ] `aksl_sprintf` with a format that overflows the destination — currently unbounded
(§2.2.4). Test the `aksl_snprintf` replacement once it exists.
- [x] Regression test for the missing `va_end` (§2.1.4) — 512 variadic calls in a loop, which
the sanitizer build also runs.
- [ ] Format-string/arg mismatch is caught at compile time once
`__attribute__((format(printf, ...)))` is added (§2.2.5) — a negative compile test.
### 1.4 String → number
Happy paths and NULL guards are covered by `tests/test_convert.c`; the strict-conversion
contract is asserted by `tests/test_convert_strict.c`, which is registered as a known
failure until §2.1.5 is fixed.
- [x] `aksl_atoi` / `atol` / `atoll` / `atof` happy paths, including negative values and
leading whitespace.
- [x] NULL `nptr` and NULL `dest` for each → `AKERR_NULLPOINTER`.
- [x] **Non-numeric input** (`"not a number"`) — **[CONFIRMED]** currently returns *success*
with `*dest == 0`. `AKERR_VALUE` is asserted by the known-failing test.
- [x] **Overflow** (`"99999999999999999999"`) — **[CONFIRMED]** currently returns success with
a garbage value (`-1` on this box). `ERANGE` is asserted by the known-failing test.
- [x] Empty string, `" "` and `"12abc"` (trailing junk) → `AKERR_VALUE`, in the known-failing
test. `"0x10"` and `"inf"`/`"nan"` for `atof` are still open.
- [ ] `LONG_MIN`/`LONG_MAX`/`LLONG_MIN`/`LLONG_MAX` boundary strings round-trip exactly.
### 1.5 `aksl_realpath`
Covered by `tests/test_path.c`. Every failure case there passes a zeroed `resolved_path`,
because the wrapper's own error path formats that buffer with `%s` (§2.1.6).
- [x] Happy path on an existing file and on a symlink chain; result matches `realpath(3)`.
- [x] Non-existent path → `ENOENT`.
- [x] A path component that is not a directory → `ENOTDIR`.
- [ ] Symlink loop → `ELOOP`.
- [x] `path == NULL``AKERR_NULLPOINTER`.
- [ ] `resolved_path == NULL` — currently unchecked and leaks (§2.1.6). Test once fixed.
- [ ] A failure case where `resolved_path` is an *uninitialised* buffer — this is the crash
case in §2.1.6; run it under ASan/MSan.
### 1.6 `aksl_strhash_djb2`
Covered by `tests/test_strhash.c`.
- [x] Known-answer vectors: `djb2("")` == 5381; a handful of fixed strings with their
pre-computed 32-bit values.
- [x] `len == 0` returns 5381 regardless of `str` contents.
- [x] NULL `str` and NULL `hashval``AKERR_NULLPOINTER`.
- [ ] **High-bit bytes** (`"\xff\xfe"`) — pins down the sign-extension bug in §2.2.6; the
expected value must be the `unsigned char` one (5868578; the wrapper returns the
sign-extended 5859874 today). Every vector in the test file is 7-bit ASCII precisely so
that it says nothing about this case either way.
- [x] Embedded NUL bytes are hashed (the function is length-driven, not NUL-driven).
- [x] Same input → same output across two calls (no hidden state).
### 1.7 Linked list
- [ ] **`aksl_list_append` builds a correct chain of N nodes** — **[CONFIRMED BROKEN]**, see
§2.1.1. Appending `n1..n4` to `n0` yields the chain `n0 -> n4`; `n1`, `n2`, `n3` are
silently dropped. This is the single most important test to add.
- [ ] `append` sets `obj->prev` to the real tail and `obj->next` to NULL.
- [ ] `append` onto an empty (single, zeroed) node.
- [ ] `append` NULL list / NULL obj → `AKERR_NULLPOINTER`.
- [ ] `append` onto a list containing a cycle → `AKERR_CIRCULAR_REFERENCE`, for a self-loop,
a 2-node cycle, and a cycle that does not include the head.
- [ ] `append` a node that is already in the list (aliasing) — define and test the contract.
- [ ] **`aksl_list_iterate` visits every node exactly once, starting at the head** —
**[CONFIRMED BROKEN]**, see §2.1.2: it starts iterating from the *midpoint* left behind
by the cycle detector, so the head is never visited. Assert both the visit count and
the visit order.
- [ ] `iterate` over a single-node list visits exactly one node.
- [ ] `iterate` NULL list / NULL iter → `AKERR_NULLPOINTER`.
- [ ] `iterate` over a cyclic list → `AKERR_CIRCULAR_REFERENCE` (self-loop, 2-node,
tail-to-middle).
- [ ] `iterate` where the callback raises `AKERR_ITERATOR_BREAK` → iteration stops at that
node, the wrapper returns success, and the visit count proves the early exit.
- [ ] `iterate` where the callback raises some *other* error → that error propagates out
unchanged, with the callback's message intact.
- [ ] `aksl_list_pop` on a middle node relinks `prev`/`next` correctly and clears the popped
node's pointers.
- [ ] `pop` on the head node, on the tail node, and on a single-node list.
- [ ] `pop(NULL)``AKERR_NULLPOINTER`.
- [ ] `pop` then `iterate` — the list is still traversable and the popped node is gone.
- [ ] Pool-accounting test: after a long sequence of list operations including failures,
`akerr_slots_in_use() == 0`.
### 1.8 Tree
- [ ] Visit **order** assertions, not just counts, for `DFS_PREORDER`, `DFS_INORDER` and
`DFS_POSTORDER` over the 7-node tree — record the visited node pointers into an array
and compare against the expected sequence. The current test only counts steps, which
cannot distinguish the three orders (all three visit 7 nodes).
- [ ] **`AKERR_ITERATOR_BREAK` aborts the whole traversal** — **[CONFIRMED BROKEN]**, see
§2.1.3. The pre-order case exists as `tests/test_tree_iterate_break.c`: pre-order
visits 0, 1, 3, so breaking at `tree[3]` must stop the walk at **3** visits, and today
it runs on to all 7. Add the equivalent for in-order (3, 1, 4, 0, 5, 2, 6 → breaking at
`tree[4]` gives 3) and post-order (3, 4, 1, 5, 6, 2, 0 → breaking at `tree[5]` gives 4).
- [x] `AKSL_TREE_SEARCH_BFS` and `AKSL_TREE_SEARCH_BFS_RIGHT``AKERR_NOT_IMPLEMENTED`
today, asserted in `tests/test_tree.c` along with the callback never being called;
replace with real order assertions once implemented (§3 / §2.2.9).
- [x] `aksl_tree_iterate` NULL `root` / NULL `iter``AKERR_NULLPOINTER`, and a callback error
that is *not* `AKERR_ITERATOR_BREAK` propagates out to the caller.
- [ ] **Unknown `searchmode`** (e.g. `99`) — **[CONFIRMED]** currently returns *success*
having visited nothing. Should be `AKERR_VALUE`.
- [ ] **`AKSL_TREE_SEARCH_VISIT`** (defined in the header, `5`) — **[CONFIRMED]** falls into
the same silent-success hole. Either implement it or reject it.
- [ ] `root == NULL` / `iter == NULL``AKERR_NULLPOINTER`.
- [ ] Single-node tree (no children) visits exactly once, in all three orders.
- [ ] Left-only and right-only degenerate chains.
- [ ] Callback raising a non-`ITERATOR_BREAK` error propagates out of the recursion with the
original status and message.
- [ ] Custom `lalloc`/`lfree` are actually invoked — **currently they are stored and never
called** (§2.2.8), so this test will fail until BFS lands.
- [ ] Deep/degenerate tree (e.g. 100k-node left chain) — documents the recursion-depth limit
(§2.2.7).
- [ ] A tree containing a cycle (child pointing back at an ancestor) — currently infinite
recursion; test for `AKERR_CIRCULAR_REFERENCE` once guarded.
### 1.9 Cross-cutting
- [ ] **Error-pool accounting**: for *every* wrapper, a test that drives the failure path
`AKERR_MAX_ARRAY_ERROR + 10` times and asserts the pool does not leak
(`akerr_slots_in_use() == 0` after each handled error).
- [ ] **Stack-trace content**: assert that the file/function/line recorded by each wrapper's
`FAIL` points at `src/stdlib.c` and the right function name.
- [ ] **`AKERR_NOIGNORE` is effective**: a negative compile test where a wrapper's return is
discarded and `-Werror=unused-result` fires.
- [ ] **Thread safety**: libakerror's `AKERR_ARRAY_ERROR` is a process-global array with no
locking. If `libakstdlib` is meant to be callable from threads, add a concurrent
smoke test under TSan — and if it is not, say so in the README.
---
## 2. Corner cases in existing functionality that should be resolved
### 2.1 Confirmed defects (reproduced against the built library)
Four of these now have a failing test apiece, registered in `AKSL_KNOWN_FAILING_TESTS`
and therefore marked `WILL_FAIL` so the suite stays green while the gap stays visible:
`tests/test_list_append_chain.c`, `tests/test_list_iterate_head.c`,
`tests/test_tree_iterate_break.c` and `tests/test_convert_strict.c`. When one of these
defects is fixed, CTest reports that test as failed with *unexpectedly passed* — that is the
cue to move it into `AKSL_TESTS`.
1. **`aksl_list_append` does not find the tail — it silently truncates the list.**
`src/stdlib.c:194`. The function conflates Floyd cycle detection with tail-finding:
`tail` is set to `slow` *before* `slow` advances, so it tracks the node *behind the
midpoint*, not the tail. Appending `n1`, `n2`, `n3`, `n4` to `n0` produces the chain
`n0 -> n4`; `n1``n3` are unlinked and lost. The fix is to keep the cycle check but walk
a separate cursor to the real tail (`while (tail->next) tail = tail->next;`), or run
Floyd first and then walk to the end.
2. **`aksl_list_iterate` skips the first half of the list.** `src/stdlib.c:324`. After the
cycle-detection loop, `slow` is left at the list midpoint, and the visiting loop then
starts from `slow` instead of from `list`. The head node is never passed to the callback.
Fix: iterate from `list`, not from `slow`.
3. **`AKERR_ITERATOR_BREAK` does not stop a tree traversal.** `src/stdlib.c:251`. The
recursive frame in which the callback raises the break handles it in its own
`PROCESS`/`HANDLE(e, AKERR_ITERATOR_BREAK)` block and returns *success*; the parent's
`PASS` therefore sees no error and continues on to the sibling subtree. Breaking at
`tree[3]` in pre-order still visits all 7 nodes. Fix by splitting the recursion: an
internal helper that propagates `AKERR_ITERATOR_BREAK` unhandled, and a public entry point
that swallows it exactly once at the top. `tests/test_tree.c` currently passes only
because the search target is the last node in all three orders.
4. **`va_end` is never called.** `src/stdlib.c:96`, `:109`, `:122` each call `va_start` with
no matching `va_end` on any path — happy or error. This is undefined behaviour per the C
standard and leaks register-save state on some ABIs.
5. **`aksl_atoi`/`atol`/`atoll`/`atof` cannot report a conversion failure.**
`src/stdlib.c:135``169`. `atoi("not a number")` returns success with `0`;
`atoi("99999999999999999999")` returns success with a wrapped value. Since the entire
point of this library is turning silent libc failures into error contexts, these should be
reimplemented over `strtol`/`strtoll`/`strtod` with `errno = 0` before the call, an
`endptr` check for "no digits consumed" and "trailing junk", and a range check —
raising `AKERR_VALUE` and `ERANGE` respectively. Keep the `atoi`-compatible names but
document the stricter contract, or add `aksl_strtol`-family wrappers alongside.
`tests/test_convert_strict.c` asserts that contract and is registered as a known failure.
6. **`aksl_realpath` mishandles `resolved_path`.** `src/stdlib.c:171`.
- `resolved_path` is never NULL-checked. `realpath(path, NULL)` is valid and mallocs a
buffer, but the wrapper discards `result`, so the caller gets nothing and the buffer
leaks.
- On failure the message formats `resolved_path` with `%s` while `realpath` leaves it
unspecified — for the normal caller who passed an uninitialised stack buffer, the error
path itself reads uninitialised memory and can crash.
- There is no way to express the buffer's size, so callers must know to supply `PATH_MAX`
bytes. Consider `aksl_realpath(const char *path, char *buf, size_t buflen)`, or an
allocating variant that returns the malloc'd pointer through an out-param.
### 2.2 Latent issues and API contract gaps
1. **`errno` is used as an error status where it may be stale.** `aksl_malloc` reports
`errno` when `malloc` returns NULL, but `malloc(0)` may legitimately return NULL without
setting `errno`, producing a `FAIL` with `status == 0` — an error context that every
`DETECT`/`CATCH` will read as *success* while still holding a pool slot. Guard every
`errno`-sourced status with a fallback (`errno ? errno : AKERR_IO`) and set `errno = 0`
before the call being wrapped.
2. **`aksl_fopen` does not validate `pathname` or `mode`.** `fopen(NULL, ...)` is UB. Add
`AKERR_NULLPOINTER` guards.
3. **`aksl_fread`/`aksl_fwrite` lose the transfer count and hide short transfers.**
- `ptr` is never NULL-checked in either function.
- Neither has an out-param for the number of members actually transferred, so a caller who
gets `AKERR_EOF` cannot tell how much data arrived. Add `size_t *nmemb_out`.
- If `nmemr != nmemb` but neither `feof` nor `ferror` is set, both functions fall through
to `SUCCEED_RETURN` — a short transfer reported as complete success.
- `aksl_fwrite`'s error message reads `"Error reading file"` (`src/stdlib.c:83`), and
checking `feof()` on a write path is meaningless.
4. **`aksl_sprintf` wraps `vsprintf`, which is unbounded.** There is no way for a caller to
bound the destination. Add `aksl_snprintf`/`aksl_vsnprintf` and consider deprecating the
`sprintf` wrapper, or reject it outright — an "error-handling" wrapper around an
unbounded write is a sharp edge the library exists to remove.
5. **No `format` attribute on the variadic wrappers.** Adding
`__attribute__((format(printf, 2, 3)))` (and the equivalents) restores the compile-time
format/argument checking that callers lose by going through the wrapper.
6. **`aksl_strhash_djb2` sign-extends bytes ≥ 0x80.** `src/stdlib.c:181` iterates a
`char *`, which is signed on x86/ARM Linux, so high bytes contribute a sign-extended
negative value and the hash differs from the canonical djb2 — and differs across
platforms where `char` is unsigned. Iterate a `const unsigned char *`. Also take
`const char *` in the signature so callers need not cast away constness.
7. **`aksl_tree_iterate` recursion is unbounded and cycle-blind.** A deep or degenerate tree
overflows the stack, and a child pointer that loops back to an ancestor recurses forever.
Add a depth limit (raising `AKERR_OUTOFBOUNDS`) and/or a visited check raising
`AKERR_CIRCULAR_REFERENCE`, consistent with what the list functions already do.
8. **`lalloc`, `lfree` and `queue` are dead parameters.** `lalloc`/`lfree` are defaulted and
then never called; `queue` is entirely unused (it is the only `-Wunused-parameter` warning
in the file). The doc comment even tells the caller to "pass NULL here", which is a sign
the queue belongs in an internal helper rather than the public signature. Either
implement BFS so they are used, or drop them from the public API until it is.
9. **Unknown `searchmode` values return success.** The `switch` in `aksl_tree_iterate` has no
`default:`; `searchmode == 99` and the header-defined `AKSL_TREE_SEARCH_VISIT` (5) both
fall straight through to `SUCCEED_RETURN` having visited nothing. Add
`default: FAIL_RETURN(e, AKERR_VALUE, ...)`.
10. **`AKSL_TREE_SEARCH_BFS`/`BFS_RIGHT` are unimplemented** (`AKERR_NOT_IMPLEMENTED`), and
`AKSL_TREE_SEARCH_VISIT` is declared in the header with a documented meaning but no
implementation anywhere.
11. **`aksl_memset`'s and `aksl_memcpy`'s inner checks are dead code.** `memset` returns `s`
and `memcpy` returns `d`, both unconditionally; neither can fail. The
`FAIL_ZERO_RETURN(e, memset(...), errno, ...)` and `(memcpy(...) == d)` checks can never
fire and only obscure the intent. Also, `memcpy` with overlapping ranges is UB — either
document that or dispatch to `memmove`.
12. **`aksl_list_pop` cannot tell the caller the new head.** Popping the head leaves the
caller's head pointer dangling at a now-detached node. Add an out-param for the new head,
or a `aksl_list_pop(aksl_ListNode **head, aksl_ListNode *node)` form.
13. **`aksl_free` does not clear the caller's pointer**, so double-free remains easy. Consider
an `aksl_freep(void **ptr)` that frees and NULLs.
14. **No initialisers for the public structs.** Every caller must remember to `memset` an
`aksl_ListNode`/`aksl_TreeNode` to zero before use (both existing tests do). Add
`aksl_list_node_init`/`aksl_tree_node_init` or `AKSL_LIST_NODE_INIT` macros.
15. **`aksl_TreeNode.parent` is declared but never set or read** by any library function.
16. **Header hygiene**: no `extern "C" { }` guard for C++ consumers; `akstdlib.h` pulls in
`stdio.h`/`stdlib.h`/`string.h`/`stdint.h` into every consumer's namespace. The version
macros this item also asked for are **done**: `AKSL_VERSION_MAJOR`/`_MINOR`/`_PATCH`/
`_STRING`/`_NUMBER`/`_SONAME` are generated into `akstdlib_version.h` from
`project(akstdlib VERSION …)`, with `aksl_version()`/`aksl_version_string()`/
`aksl_version_soname()` and `AKSL_VERSION_CHECK()` reporting the *loaded* library so a
header/`.so` mismatch is nameable. See `tests/test_version.c` and `README.md`.
17. **Doxygen coverage is 2 functions out of 24.** A `Doxyfile` exists but only
`aksl_tree_iterate` and `aksl_list_iterate` have doc comments. Every public function
needs `@param`/`@throws`/`@return`, especially the ones whose contract deviates from libc
(`aksl_free(NULL)` is an error; `aksl_atoi` will become strict). The four
`aksl_version_*` entry points have prose comments in the header but no Doxygen tags.
### 2.3 Build, CI and repository
- [ ] **`CMakeLists.txt:4-6` sets `CMAKE_CXX_FLAGS` in a C-only project**, so `-g -ggdb -pg`
never reach the C compiler. Use `CMAKE_C_FLAGS` — or better, drop `-pg` from the
default build entirely (it is what produces the stray `gmon.out` sitting untracked in
the repo root) and let `CMAKE_BUILD_TYPE` control debug info.
- [ ] **No warning flags.** Add `-Wall -Wextra` (and ideally `-Werror` in CI). The only
current warning is the unused `queue` parameter, so the cost of turning them on is low.
- [ ] `src/stdlib.c` triggers **"ISO C99 requires at least one argument for the `...`"** on
roughly 20 lines under `-Wpedantic`, because `FAIL_*` is called with a bare message and
no varargs. Either always pass an argument, or add a zero-arg-safe form in libakerror.
- [x] **The `deps/libakerror` submodule was pinned 22 commits behind `main`** (pinned at
`4fad0ce "Add gitea workflow"`). Bumped to `5ff8790`, which is libakerror **1.0.0**
the release that made the status-name table private, moved consumer status codes to a
band starting at `AKERR_FIRST_CONSUMER_STATUS` (256), made range ownership enforced,
and gave the library an soname. See `deps/libakerror/UPGRADING.md`. The bump also
brings the refcount-leak fix, the stack-trace buffer-overflow fix and the
format-string fix. Nothing in this library's sources used the removed
`AKERR_MAX_ERR_VALUE`, `__AKERR_ERROR_NAMES`, `AKERR_STATUS_RANGE_OK` or
`AKERR_STATUS_NAME_OK`, so the source change was confined to the build, the packaging
and one compile-time guard. What it did move is recorded in the three items below.
- [ ] **libakerror does not namespace its `coverage` target when embedded**, only its
`mutation` target (`deps/libakerror/CMakeLists.txt:172` vs `:189`). A
`-DAKSL_COVERAGE=ON` top-level build therefore failed to configure at all —
*"another target with the same name already exists"* — the moment the dependency
gained a coverage target. Worked around in `CMakeLists.txt` by shadowing
`add_custom_target` for the duration of the `add_subdirectory()` call and renaming the
dependency's to `akerror_coverage`, alongside the existing `add_test` shadow. **Fix
upstream and delete the workaround**: libakerror should apply the same
`CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR` test to `coverage` that it
already applies to `mutation`.
- [ ] **Branch coverage of `src/stdlib.c` fell from 51.0% to 44.3% on the 1.0.0 bump**, with
no change to this library's sources or tests. Line coverage held at 99.0% (200/202)
and function coverage at 100% (21/21); the branch *denominator* went from 661 to 1087
because the 1.0.0 `PREPARE_ERROR`/`FAIL_*` macros expand to more branches at every
call site. 337/661 became 481/1087 — 144 more branches covered, 426 more counted. The
CI branch gate was re-ratcheted 45 → 40 to match. Most of the added branches are
unreachable from the way this library calls the macros, so raising the number here
would mean testing libakerror's macros rather than this library; that belongs to
libakerror's mutation suite, since macros expand at the call site and coverage cannot
see them properly from either side. Revisit if the gap ever hides a real regression.
- [x] **`project()` declared no `VERSION`**, so `@PROJECT_VERSION@` expanded to nothing, the
installed `akstdlib.pc` shipped an empty `Version:` field, and `libakstdlib.so` carried
no soname. Now `project(akstdlib VERSION 0.1.0)`, with `SOVERSION` `0.1`
(`MAJOR.MINOR` while major is 0, `MAJOR` from 1.0 — the `if()` in `CMakeLists.txt` and
the matching `#if` in `tests/test_version.c` encode the rule and are tested against
each other), an `akstdlibConfigVersion.cmake` at `SameMinorVersion`, and version macros
generated into `akstdlib_version.h`. Verified: `find_package(akstdlib 0.1)` is accepted
while `0.2` and `1.0` are refused, and a 0.2.0 build dropped in under the 0.1 filename
is caught by `AKSL_VERSION_CHECK()`.
- [ ] **libakerror still installs no `akerrorConfigVersion.cmake`**, so
`cmake/akstdlib.cmake.in` has to call `find_dependency(akerror)` with no version — a
request for one would be refused for want of a version file regardless of what is
installed. libakstdlib now does this correctly and libakerror does not; fix it there
with the same `write_basic_package_version_file()` call, then add the `1.0.0` floor to
the `find_dependency` here. Until then the floor rests on `akstdlib.pc`'s `Requires:`
and the `#error` guard in `akstdlib.h`.
- [ ] **`aksl_version_check()` ignores its `patch` argument** (`src/stdlib.c`, the `(void)patch`),
which is correct for the current "same soname" rule but means the parameter exists only
so the error message can name the caller's full version. If a future rule needs patch
to participate, that is the line to change.
- [ ] **CI does not build against the submodule it pins.** `.gitea/workflows/ci.yaml` clones
`libakerror@main` and installs it, while the build it then runs is top-level and so
compiles `deps/libakerror` at the pinned commit — two different libakerror versions
depending on where you build, and the installed one is never actually linked. Pick
one. (The submodule is at least *present* in CI now: §1.0 added
`submodules: recursive` to the checkout, without which configure failed outright.)
- [x] **`ctest` was red** (`tree` failing plus four *Not Run* submodule tests) — fixed in
§1.0. The build badge in `README.md` means something again.
- [ ] **Untracked build litter** in the repo root: `build/`, `gmon.out`,
`CMakeLists.txt-akstdlib`, and a dozen `*~` editor backups. Add a `.gitignore`
(libakerror has one; this repo does not).
- [ ] **`README.md` is a build badge and nothing else.** It needs at minimum: what the
library is, the akerror wrapper contract, the list of wrapped functions, and the
deviations from libc semantics.
- [ ] **Indentation is inconsistent**`src/stdlib.c` mixes hard tabs and 4-space indents
within the same functions. libakerror standardised on Stroustrup style
(commit `e5f7616`); apply the same here, ideally with a checked-in `.clang-format`.
---
## 3. libc functions not yet wrapped
Ordered roughly by how much a caller of this library would miss them. Each entry means "add
an `aksl_`-prefixed wrapper that turns the documented failure modes into an
`akerr_ErrorContext`".
### 3.1 High priority — gaps in areas the library already covers
**Memory (`stdlib.h`, `string.h`)**
- [ ] `calloc`, `realloc`, `reallocarray``realloc`'s "returns NULL and the old pointer is
still valid" trap is exactly what this library should be hiding.
- [ ] `aligned_alloc`, `posix_memalign`
- [ ] `memmove`, `memcmp`, `memchr`
**Bounded string formatting (`stdio.h`)**
- [ ] `snprintf`, `vsnprintf` — needed to make §2.2.4 fixable.
- [ ] `vprintf`, `vfprintf`, `vsprintf` — the `va_list` forms, so consumers can build their
own variadic wrappers on top.
- [ ] `asprintf` / `vasprintf` (GNU) as an allocating alternative.
**String → number (`stdlib.h`)**
- [ ] `strtol`, `strtoll`, `strtoul`, `strtoull`, `strtod`, `strtof`, `strtold` — the correct
foundation for §2.1.5.
**Strings (`string.h`)**
- [ ] `strlen`, `strnlen`
- [ ] `strcpy`, `strncpy`, `strcat`, `strncat` — with truncation reported as an error rather
than silently accepted, which is the whole value proposition here.
- [ ] `strdup`, `strndup`
- [ ] `strcmp`, `strncmp`, `strcasecmp`, `strncasecmp`, `strcoll`
- [ ] `strchr`, `strrchr`, `strstr`, `strcasestr`, `strpbrk`, `strspn`, `strcspn`
- [ ] `strtok_r`, `strsep`
- [ ] `strerror_r`
**Stream I/O (`stdio.h`)**
- [ ] `fseek`, `ftell`, `rewind`, `fgetpos`, `fsetpos`, `fseeko`, `ftello`
- [ ] `fflush`
- [ ] `fgets`, `fputs`, `fgetc`/`getc`/`getchar`, `fputc`/`putc`/`putchar`, `ungetc`
- [ ] `getline`, `getdelim`
- [ ] `freopen`, `fdopen`, `fileno`
- [ ] `setvbuf`, `setbuf`
- [ ] `clearerr`, `feof`, `ferror` — thin, but worth exposing so callers never touch raw
`FILE *` internals.
- [ ] `sscanf`, `fscanf`, `scanf` — the return-value semantics (items matched vs `EOF`) are a
classic silent-failure source.
- [ ] `remove`, `rename`, `tmpfile`, `mkstemp`, `mkdtemp`
- [ ] `perror` — or an akerror-native equivalent.
### 3.2 POSIX file and process API (likely the next major surface)
**`unistd.h` / `fcntl.h`**
- [ ] `open`, `close`, `read`, `write`, `pread`, `pwrite`, `lseek`
- [ ] `readv`, `writev`
- [ ] `dup`, `dup2`, `pipe`, `fcntl`
- [ ] `fsync`, `fdatasync`, `truncate`, `ftruncate`
- [ ] `unlink`, `link`, `symlink`, `readlink`, `rmdir`, `mkdir`
- [ ] `access`, `faccessat`, `chmod`, `fchmod`, `chown`, `fchown`, `umask`
- [ ] `chdir`, `fchdir`, `getcwd`
- [ ] `isatty`, `ttyname_r`
- [ ] `sysconf`, `pathconf`
- [ ] `sleep`, `usleep`, `nanosleep`
**`sys/stat.h`**
- [ ] `stat`, `fstat`, `lstat`, `fstatat`
- [ ] `statvfs`, `fstatvfs`
**`dirent.h`**
- [ ] `opendir`, `fdopendir`, `readdir`, `readdir_r`, `closedir`, `rewinddir`, `scandir`
**Process control**
- [ ] `fork`, `execve` / `execvp` / `execl` family, `waitpid`, `wait`
- [ ] `posix_spawn`
- [ ] `system`, `popen`, `pclose`
- [ ] `getpid`, `getppid`, `getuid`, `geteuid`, `setuid`, `setgid`
- [ ] `atexit`, `exit`, `_exit`, `abort` — mostly to give akerror a hook on shutdown.
- [ ] `getenv`, `setenv`, `unsetenv`, `putenv`, `clearenv`
**`sys/mman.h`**
- [ ] `mmap`, `munmap`, `mprotect`, `msync`, `madvise`
### 3.3 Time
- [ ] `time`, `clock_gettime`, `clock_getres`, `gettimeofday`
- [ ] `localtime_r`, `gmtime_r`, `mktime`, `timegm`, `difftime`
- [ ] `strftime`, `strptime`
- [ ] `clock`, `times`
### 3.4 Sorting, searching and misc `stdlib.h`
- [ ] `qsort`, `qsort_r`, `bsearch` — comparator errors currently have nowhere to go; an
akerror-aware comparator signature would be a genuine improvement.
- [ ] `abs`, `labs`, `llabs`, `div`, `ldiv`, `lldiv`
- [ ] `rand`, `srand`, `random`, `srandom`, `getrandom`/`arc4random`
### 3.5 Lower priority / larger projects
- [ ] **Sockets**: `socket`, `bind`, `listen`, `accept`, `connect`, `send`/`sendto`/`sendmsg`,
`recv`/`recvfrom`/`recvmsg`, `shutdown`, `setsockopt`/`getsockopt`, `getaddrinfo`/
`freeaddrinfo`/`gai_strerror`, `inet_ntop`/`inet_pton`
- [ ] **Multiplexing**: `select`, `poll`, `ppoll`, `epoll_create1`/`epoll_ctl`/`epoll_wait`
- [ ] **Signals**: `sigaction`, `sigprocmask`, `sigemptyset`/`sigaddset`, `kill`, `raise`,
`signalfd`
- [ ] **Threads**: `pthread_create`/`join`/`detach`, `pthread_mutex_*`, `pthread_cond_*`,
`pthread_rwlock_*`, `sem_*` — blocked on resolving the thread-safety question in §1.9
(libakerror's error pool is an unlocked process-global array).
- [ ] **Dynamic loading**: `dlopen`, `dlsym`, `dlclose`, `dlerror`
- [ ] **Locale / wide chars**: `setlocale`, `mbstowcs`, `wcstombs`, `iconv_*`
- [ ] **Math**: the `math.h` functions that set `errno`/raise FP exceptions (`sqrt`, `log`,
`pow`, `acos`, …) — probably better served by a dedicated `libakmath`.
### 3.6 Non-libc additions the current data structures imply
Not libc wrappers, but the existing list/tree API is visibly incomplete:
- [ ] List: `prepend`, `insert_after`/`insert_before`, `length`, `find`, `reverse`,
`concat`, `free_all`, reverse iteration, and a head/tail-tracking container type so
`append` is O(1) instead of O(n).
- [ ] Tree: `insert`, `remove`, `find`, `height`, `count`, `free_all`, and the BFS traversal
the `lalloc`/`lfree`/`queue` parameters were designed for.
- [ ] Hash map built on `aksl_strhash_djb2`, plus additional hashes (FNV-1a, xxHash) and a
NUL-terminated `aksl_strhash_djb2_str` convenience form.
- [ ] Growable buffer / string-builder type, to make the `snprintf` and `strcat` wrappers
pleasant to use.
## 4. Evidence from the first full consumer
`akbasic` (`source.starfort.tech/andrew/akbasic`) is a ~6,300-line C interpreter built on this
library and `libakerror`. It is the first consumer to exercise the whole surface rather than a
corner of it, so what it *could not* use is worth recording: it prioritizes the wishlist in §3
by what a real port actually reaches for, and it confirms which §2 entries have teeth.
**The headline number.** Across `src/`, akbasic makes **10 calls into this library** and
**116 calls to raw libc**:
| `aksl_*` used | Count |
|---|---|
| `aksl_fopen` / `aksl_fclose` | 6 |
| `aksl_fprintf` | 2 |
| `aksl_fwrite` | 1 |
| `aksl_strhash_djb2` | 1 |
| Raw libc it had to use instead | Count | Wanted from §3 |
|---|---|---|
| `snprintf` | 28 | §3.1 bounded string formatting |
| `strlen` | 37 | §3.1 strings |
| `strcmp` | 16 | §3.1 strings |
| `strncpy` | 15 | §3.1 strings |
| `memcpy` / `memset` | 16 | wrapped, but see §2.2.11 |
| `strtoll` / `strtod` | 2 | §3.1 string → number |
| `fgets` | 2 | §3.1 stream I/O |
| `strstr` | 1 | §3.1 strings |
A library whose value proposition is "turn silent libc failures into error contexts" is being
bypassed 92% of the time by the consumer most committed to it. Every one of those calls is a
place where a failure goes unreported.
### 4.1 What the port had to write for itself
1. **A strict `strtoll`/`strtod` wrapper** (`akbasic/src/convert.c`, ~60 lines). This is §2.1.5
biting, and it is not cosmetic: the Go reference this port reproduces checks `strconv`'s
error at four sites and turns each into a BASIC error. Routing them through `aksl_atoi`
would have converted four *diagnosable errors* into *wrong answers*`VAL("garbage")`
silently returning `0.0` rather than raising, and a malformed line number becoming line 0.
akbasic's own `TODO.md` §1.9 formally bans the `aksl_ato*` family for this reason. **The
fix wanted is exactly §3.1's `strtol` family plus the endptr/`errno`/range contract §2.1.5
describes**; when it lands, `src/convert.c` gets deleted.
2. **A fixed-capacity string-keyed hash table** (`akbasic/src/symtab.c`, ~130 lines). §3.6
already calls for a "hash map built on `aksl_strhash_djb2`". This is that, three times over:
the interpreter needs one for variables, one for functions and one for labels per scope. It
is open-addressed with linear probing over a caller-supplied slot count, refuses rather than
resizes when full, and is the single most obviously-missing data structure in the library.
Worth lifting more or less verbatim if you want a starting point.
3. **The bounded-copy-with-truncation-as-error idiom, ten times.** `strncpy` followed by an
explicit NUL and a preceding length check appears at ten sites across six files. §3.1 lists
`strncpy` "with truncation reported as an error rather than silently accepted, which is the
whole value proposition here" — agreed, and the repetition is the evidence.
4. **Uppercase folding for case-insensitive lookup, three times.** Verb and function names are
case-insensitive in BASIC while variable names are not, so the port folds case before every
keyword lookup: by hand at `src/environment.c:197` and `src/parser_commands.c:113`, and
through `<ctype.h>`'s `toupper` in `src/verbs.c`. §3.1's `strcasecmp`/`strncasecmp` would
remove all three.
### 4.2 Confirmed with impact
- **§2.2.4 (`aksl_sprintf` is unbounded)** — akbasic never calls it, deliberately. Its 28
`snprintf` calls all write into fixed buffers whose size is the whole point, and there is no
`aksl_snprintf` to route them through. This is the single highest-value item in §3.1 for a
consumer of this shape.
- **§2.2.2 (`aksl_fopen` does not validate `pathname`/`mode`)** — real, and reached from user
input. akbasic's `DLOAD`/`DSAVE` take a filename from a BASIC string expression, so an empty
or unevaluated one is reachable from a running program; `akbasic_cmd_dload` validates the
name itself before handing it over, with a comment pointing here.
- **§2.2.6 (`aksl_strhash_djb2` sign-extends high bytes)** — benign for akbasic, which only
ever hashes 7-bit ASCII identifiers, and noted in its §1.3 as a hazard for anyone who later
keys a table on a filename or a string literal. Still worth fixing; the wrong answer is
platform-dependent, which is the bad kind of benign.
- **§2.1.4 (`va_end` is never called)** — akbasic's stdio text sink is built on `aksl_fprintf`
and therefore runs this UB on every line of program output. Nothing has misbehaved on
x86-64 glibc, which is exactly what makes it worth fixing before something does.
### 4.3 Not needed, and why
Recorded so the wishlist does not get prioritized purely by this consumer's shape. akbasic
uses **no** allocator (§3.1 memory), **no** lists or trees (§3.6), and **no** `aksl_realpath`:
it draws everything from fixed pools by design, and the `ak*` no-dynamic-allocation rule means
`calloc`/`realloc` would go unused here even if they existed. A consumer that does allocate
would weight §3.1's memory section far higher. The list and tree defects in §2.1.13 were
avoided rather than survived — akbasic's symbol tables are open-addressed precisely because
`aksl_list_append` truncates.

View File

@@ -6,5 +6,11 @@ includedir=${exec_prefix}/include
Name: akstdlib
Description: C stdlib with akerror sanity wrappers
Version: @PROJECT_VERSION@
# akerror is a public dependency, not a private one: akstdlib.h includes
# akerror.h and every entry point returns an akerr_ErrorContext *, so a consumer
# compiles against both headers and links both libraries. 1.0.0 is the floor --
# it is the release that made the status registry private and gave the library an
# soname, and akstdlib.h refuses to compile against anything older.
Requires: akerror >= 1.0.0
Cflags: -I${includedir}/
Libs: -L${libdir} -lakstdlib

View File

@@ -1,5 +1,17 @@
# cmake/MyLibraryConfig.cmake.in
include(CMakeFindDependencyMacro) # If your library has dependencies
# find_dependency(AnotherDependency REQUIRED) # Example dependency
# cmake/akstdlib.cmake.in -- installed as akstdlibConfig.cmake
#
# akstdlibTargets.cmake names akerror::akerror in the INTERFACE_LINK_LIBRARIES of
# the imported akstdlib target, because akstdlib links it PUBLIC. Without the
# find_dependency below, a consumer that has not already found akerror itself
# gets a bare "target not found" out of the generated targets file rather than a
# message naming the dependency it is missing.
#
# No version is requested here. libakerror installs no akerrorConfigVersion.cmake,
# so find_dependency(akerror 1.0.0) would be refused for want of a version file
# regardless of which akerror is actually installed. The 1.0.0 floor is carried by
# the `Requires: akerror >= 1.0.0` line in akstdlib.pc and enforced at compile
# time by the #error guard at the top of akstdlib.h.
include(CMakeFindDependencyMacro)
find_dependency(akerror)
include("${CMAKE_CURRENT_LIST_DIR}/akstdlibTargets.cmake")

1
deps/libakerror vendored Submodule

Submodule deps/libakerror added at 5ff87908e7

View File

@@ -2,9 +2,99 @@
#define _AKSTDLIB_H_
#include <akerror.h>
/*
* libakerror 1.0.0 is the floor. That release moved the status-name table into a
* private registry -- AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, the
* registry entry points raise akerr_ErrorContext * instead of returning int, and
* the library gained an soname -- so a translation unit that pairs this header
* with a pre-1.0.0 akerror.h is an ABI mismatch, not just a compile problem.
*
* libakerror publishes no version macro, so this feature-tests on
* AKERR_FIRST_CONSUMER_STATUS, which that release introduced, rather than on a
* version number that does not exist. Without the guard a stale installed header
* fails much further in, as a pile of unrelated errors inside src/stdlib.c.
*
* See deps/libakerror/UPGRADING.md.
*/
#ifndef AKERR_FIRST_CONSUMER_STATUS
#error "libakstdlib requires libakerror >= 1.0.0: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror."
#endif
/*
* AKSL_VERSION_MAJOR/MINOR/PATCH/STRING/NUMBER and AKSL_VERSION_SONAME.
* Generated by CMake from include/akstdlib_version.h.in, which is why there is
* no such file in the source tree -- it is configured into the build tree and
* installed alongside this header.
*/
#include <akstdlib_version.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
typedef struct aksl_ListNode {
void *data;
struct aksl_ListNode *next;
struct aksl_ListNode *prev;
} aksl_ListNode;
typedef struct aksl_TreeNode {
struct aksl_TreeNode *parent;
struct aksl_TreeNode *left;
struct aksl_TreeNode *right;
void *leaf;
} aksl_TreeNode;
#define AKSL_TREE_SEARCH_BFS 0 /** Breadth-first search mode for tree nodes. Currently unsupported. */
#define AKSL_TREE_SEARCH_BFS_RIGHT 1 /** Right-hand breadth-first search mode for tree nodes. Currentl unsupported. */
#define AKSL_TREE_SEARCH_DFS 2 /** Alias for AKSL_TREE_SEARCH_DFS_PREORDER */
#define AKSL_TREE_SEARCH_DFS_PREORDER 2 /** Depth first pre-order (root, left, right) search mode for tree nodes */
#define AKSL_TREE_SEARCH_DFS_INORDER 3 /** Depth first in-order (left, root, right) search mode for tree nodes. Currently unsupported. */
#define AKSL_TREE_SEARCH_DFS_POSTORDER 4 /** Depth first post-order (left, right, root) search mode for tree nodes. Currently unsupported. */
#define AKSL_TREE_SEARCH_VISIT 5 /** Used when iterating through a tree structure as a control flag: don't traverse the children, just visit the node */
typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_ListNodeIterator)(aksl_ListNode *node, void *data);
typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_TreeNodeIterator)(aksl_TreeNode *node, void *data);
typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_AllocFunc)(size_t size, void **dest);
typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_FreeFunc)(void *ptr);
/*
* Version of the shared library actually loaded, as opposed to the
* AKSL_VERSION_* macros above, which record what the caller was *compiled*
* against. The two differ exactly when a stale libakstdlib.so is on the loader
* path, which is the failure these entry points exist to name.
*
* All three pointers are required; this library treats a NULL out-param as a
* caller error rather than as "don't care" (so does aksl_free).
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_version(int *major, int *minor, int *patch);
/*
* The loaded library's version as "MAJOR.MINOR.PATCH", and its soname. Neither
* can fail -- they return pointers to string literals in the library's own
* .rodata -- so they return the string directly rather than an error context,
* the same exception akerr_name_for_status() makes for the same reason.
*/
const char *aksl_version_string(void);
const char *aksl_version_soname(void);
/*
* Compare the caller's compiled-in version against the loaded library's.
* Compatibility is defined as "same soname", so pre-1.0 both major and minor
* must match and patch is ignored; from 1.0 only major will matter. Raises
* AKERR_VALUE naming both versions on a mismatch.
*
* Call it through AKSL_VERSION_CHECK() below rather than directly. The macro
* expands at *your* call site, so it captures the AKSL_VERSION_* the caller was
* built with; the function compares them against the values baked into the
* library. Passing the numbers by hand defeats the entire mechanism.
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_version_check(int major, int minor, int patch);
#define AKSL_VERSION_CHECK() \
aksl_version_check(AKSL_VERSION_MAJOR, AKSL_VERSION_MINOR, AKSL_VERSION_PATCH)
akerr_ErrorContext AKERR_NOIGNORE *aksl_fopen(char *pathname, char *mode, FILE **fp);
akerr_ErrorContext AKERR_NOIGNORE *aksl_fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
@@ -16,7 +106,6 @@ akerr_ErrorContext AKERR_NOIGNORE *aksl_memset(void *s, int c, size_t n);
akerr_ErrorContext AKERR_NOIGNORE *aksl_memcpy(void *d, void *s, size_t n);
akerr_ErrorContext AKERR_NOIGNORE *aksl_free(void *ptr);
akerr_ErrorContext AKERR_NOIGNORE *aksl_printf(int *count, const char *restrict format, ...);
akerr_ErrorContext AKERR_NOIGNORE *aksl_fprintf(int *count, FILE *restrict stream, const char *restrict format, ...);
akerr_ErrorContext AKERR_NOIGNORE *aksl_sprintf(int *count, char *restrict str, const char *restrict format, ...);
@@ -28,4 +117,14 @@ akerr_ErrorContext AKERR_NOIGNORE *aksl_atof(const char *nptr, double *dest);
akerr_ErrorContext AKERR_NOIGNORE *aksl_realpath(const char *restrict path, char *restrict resolved_path);
akerr_ErrorContext AKERR_NOIGNORE *aksl_strhash_djb2(char *str, size_t len, uint32_t *hashval);
// Linked list functions
akerr_ErrorContext AKERR_NOIGNORE *aksl_list_append(aksl_ListNode *list, aksl_ListNode *obj);
akerr_ErrorContext AKERR_NOIGNORE *aksl_list_pop(aksl_ListNode *node);
akerr_ErrorContext AKERR_NOIGNORE *aksl_list_iterate(aksl_ListNode *list, aksl_ListNodeIterator iter, void *data);
// Tree functions
akerr_ErrorContext AKERR_NOIGNORE *aksl_tree_iterate(aksl_TreeNode *root, aksl_TreeNodeIterator iter, aksl_AllocFunc lalloc, aksl_FreeFunc lfree, uint8_t searchmode, void *data, aksl_ListNode *queue);
#endif

View File

@@ -0,0 +1,37 @@
#ifndef _AKSTDLIB_VERSION_H_
#define _AKSTDLIB_VERSION_H_
/*
* GENERATED FILE -- do not edit.
*
* Configured from include/akstdlib_version.h.in by CMake. The version itself
* lives in exactly one place, the project() call in CMakeLists.txt, and flows
* from there into these macros, the shared library's SOVERSION, the Version:
* field in akstdlib.pc, and akstdlibConfigVersion.cmake. To bump the version,
* edit project(); to change what the macros look like, edit this template.
*/
#define AKSL_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define AKSL_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define AKSL_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define AKSL_VERSION_STRING "@PROJECT_VERSION@"
/*
* A single ordered integer, for `#if AKSL_VERSION_NUMBER >= ...` compares.
* Computed rather than written out as a literal on purpose: a literal like
* 000100 is octal in C, which would silently make version 0.1.0 compare as 64.
* Each component gets two decimal digits, so this is correct up to x.99.99.
*/
#define AKSL_VERSION_NUMBER \
((AKSL_VERSION_MAJOR * 10000) + (AKSL_VERSION_MINOR * 100) + AKSL_VERSION_PATCH)
/*
* The soname of the library these headers describe -- "0.1" for libakstdlib.so.0.1.
* This is the granularity at which the ABI is allowed to break, and it is what
* aksl_version_check() compares: pre-1.0 a minor bump is a break, so the soname
* carries MAJOR.MINOR. From 1.0 it becomes MAJOR alone, and the SOVERSION
* expression in CMakeLists.txt and this macro change together.
*/
#define AKSL_VERSION_SONAME "@AKSL_SOVERSION@"
#endif // _AKSTDLIB_VERSION_H_

469
scripts/coverage.py Normal file
View File

@@ -0,0 +1,469 @@
#!/usr/bin/env python3
"""
Code coverage harness for libakstdlib.
Reports which lines, branches and functions of the library the CTest suite
actually executed. Coverage answers "what did the tests touch"; the mutation
harness in scripts/mutation_test.py answers the stronger question "would the
tests notice if it broke". They are complementary: a line can be covered and
still have no assertion behind it, so an uncovered line is definitely untested
while a covered one is only maybe tested.
Requires a build configured with -DAKSL_COVERAGE=ON, which compiles the library
and its tests with --coverage. That emits a .gcno next to every object at build
time and a .gcda next to it at run time; this script feeds those to gcov and
aggregates its JSON output.
There are no third-party dependencies (Python stdlib + gcc's own gcov), so this
works anywhere the project already builds -- no lcov, gcovr or genhtml needed.
Usage:
scripts/coverage.py --build build-coverage [options]
--build DIR build tree to read .gcda from (default: build-coverage)
--source-root DIR repo root (default: parent of this script's dir)
--gcov PROG gcov binary to use (default: $GCOV or gcov)
--include PREFIX only report sources under this repo-relative prefix;
repeatable. Default: src, include
--exclude PREFIX drop sources under this repo-relative prefix;
repeatable. Default: tests, deps, build
--zero delete every .gcda in the build tree, then exit. Run
before the suite so counters do not accumulate across
runs.
--run-tests run ctest in the build tree first (implies --zero)
--threshold PCT exit non-zero if total line coverage < PCT (default 0)
--branch-threshold PCT
exit non-zero if total branch coverage < PCT
--summary-only omit the per-file uncovered-line listing
--output PATH also write the text report to PATH (CTest hides the
output of a passing test, so the in-suite report lands
in <build>/coverage-summary.txt this way)
--cobertura PATH also write a Cobertura XML report (for CI publishers)
"""
import argparse
import glob
import json
import os
import subprocess
import sys
import xml.sax.saxutils as saxutils
DEFAULT_INCLUDE = ["src", "include"]
DEFAULT_EXCLUDE = ["tests", "deps", "build"]
# --------------------------------------------------------------------------- #
# gcov invocation and parsing
# --------------------------------------------------------------------------- #
class FileCoverage:
"""Accumulated coverage for one source file, keyed by line number.
A single source file can be represented in several .gcda files -- a header
compiled into every test binary, or a .c file linked more than once -- so
counts are summed across all of them rather than overwritten.
"""
def __init__(self, path):
self.path = path # repo-relative
self.lines = {} # line number -> execution count
self.branches = {} # line number -> list of branch counts
self.functions = {} # function name -> execution count
def add_line(self, lineno, count):
self.lines[lineno] = self.lines.get(lineno, 0) + count
def add_branches(self, lineno, counts):
cur = self.branches.setdefault(lineno, [0] * len(counts))
# Defensive: different translation units should agree on the branch
# count for a line, but never let a mismatch raise.
if len(cur) != len(counts):
if len(counts) > len(cur):
cur.extend([0] * (len(counts) - len(cur)))
counts = counts + [0] * (len(cur) - len(counts))
for i, c in enumerate(counts):
cur[i] += c
def add_function(self, name, count):
self.functions[name] = self.functions.get(name, 0) + count
# -- derived totals ---------------------------------------------------- #
@property
def lines_total(self):
return len(self.lines)
@property
def lines_covered(self):
return sum(1 for c in self.lines.values() if c > 0)
@property
def branches_total(self):
return sum(len(b) for b in self.branches.values())
@property
def branches_covered(self):
return sum(1 for b in self.branches.values() for c in b if c > 0)
@property
def functions_total(self):
return len(self.functions)
@property
def functions_covered(self):
return sum(1 for c in self.functions.values() if c > 0)
def uncovered_lines(self):
return sorted(n for n, c in self.lines.items() if c == 0)
def uncovered_functions(self):
return sorted(n for n, c in self.functions.items() if c == 0)
def pct(covered, total):
"""Coverage percentage. A file with nothing instrumented counts as 100%,
matching gcov/lcov: there is nothing there to leave untested."""
return 100.0 * covered / total if total else 100.0
def find_gcda(build):
return sorted(glob.glob(os.path.join(build, "**", "*.gcda"), recursive=True))
def run_gcov(gcov, gcda):
"""Run gcov on one .gcda and return the parsed JSON, or None on failure.
gcov resolves the matching .gcno relative to the .gcda, so it is invoked
from the .gcda's directory with a bare filename.
"""
workdir = os.path.dirname(gcda) or "."
cmd = [gcov, "--json-format", "--stdout", "--branch-probabilities",
os.path.basename(gcda)]
try:
proc = subprocess.run(cmd, cwd=workdir, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
except OSError as exc:
sys.stderr.write(f"warning: cannot run {gcov}: {exc}\n")
return None
if proc.returncode != 0:
sys.stderr.write(f"warning: gcov failed on {gcda}:\n"
f"{proc.stderr.decode(errors='replace')}")
return None
text = proc.stdout.decode(errors="replace").strip()
if not text:
return None
try:
return json.loads(text)
except ValueError as exc:
sys.stderr.write(f"warning: unparseable gcov JSON for {gcda}: {exc}\n")
return None
def rel_to_root(path, cwd, root):
"""Resolve a path from gcov's JSON to a repo-relative path, or None if it
falls outside the repo (libc headers, the toolchain, an out-of-tree dep)."""
if not os.path.isabs(path):
path = os.path.join(cwd, path)
path = os.path.realpath(path)
try:
rel = os.path.relpath(path, root)
except ValueError: # different drive (Windows)
return None
if rel.startswith(os.pardir):
return None
return rel.replace(os.sep, "/")
def selected(rel, includes, excludes):
"""True if a repo-relative path passes the include/exclude prefix filters.
Excludes win, so `--include src --exclude src/generated` behaves sanely."""
def under(prefix):
return rel == prefix or rel.startswith(prefix.rstrip("/") + "/")
if any(under(e) for e in excludes):
return False
if not includes:
return True
return any(under(i) for i in includes)
def collect(build, root, gcov, includes, excludes):
"""Aggregate coverage for every selected source file in the build tree."""
files = {}
gcda_files = find_gcda(build)
for gcda in gcda_files:
data = run_gcov(gcov, gcda)
if not data:
continue
cwd = data.get("current_working_directory") or os.path.dirname(gcda)
for entry in data.get("files", []):
rel = rel_to_root(entry.get("file", ""), cwd, root)
if rel is None or not selected(rel, includes, excludes):
continue
fc = files.setdefault(rel, FileCoverage(rel))
for line in entry.get("lines", []):
lineno = line.get("line_number")
if lineno is None:
continue
fc.add_line(lineno, line.get("count", 0))
branches = line.get("branches") or []
if branches:
fc.add_branches(lineno, [b.get("count", 0) for b in branches])
for fn in entry.get("functions", []):
name = fn.get("demangled_name") or fn.get("name")
if name:
fc.add_function(name, fn.get("execution_count", 0))
return files, len(gcda_files)
# --------------------------------------------------------------------------- #
# Reporting
# --------------------------------------------------------------------------- #
def line_ranges(numbers):
"""Compress [3,4,5,9,11,12] into ['3-5', '9', '11-12'] for readability."""
out = []
start = prev = None
for n in numbers:
if start is None:
start = prev = n
elif n == prev + 1:
prev = n
else:
out.append(str(start) if start == prev else f"{start}-{prev}")
start = prev = n
if start is not None:
out.append(str(start) if start == prev else f"{start}-{prev}")
return out
def report(files, summary_only):
"""Render the text report. Returns (totals dict, report text)."""
names = sorted(files)
width = max([len(n) for n in names] + [len("TOTAL")])
out = []
header = (f" {'file'.ljust(width)} {'lines':>15} {'branches':>15} "
f"{'functions':>15}")
out.append("=" * len(header))
out.append("CODE COVERAGE SUMMARY")
out.append("=" * len(header))
out.append(header)
out.append(" " + "-" * (len(header) - 2))
def row(name, lc, lt, bc, bt, fc_, ft):
cell = lambda c, t: f"{pct(c, t):5.1f}% {c:4d}/{t:<4d}"
out.append(f" {name.ljust(width)} {cell(lc, lt):>15} "
f"{cell(bc, bt):>15} {cell(fc_, ft):>15}")
tot = [0, 0, 0, 0, 0, 0]
for name in names:
f = files[name]
vals = (f.lines_covered, f.lines_total, f.branches_covered,
f.branches_total, f.functions_covered, f.functions_total)
row(name, *vals)
tot = [t + v for t, v in zip(tot, vals)]
out.append(" " + "-" * (len(header) - 2))
row("TOTAL", *tot)
if not summary_only:
for name in names:
f = files[name]
gaps = f.uncovered_lines()
missing_fns = f.uncovered_functions()
if not gaps and not missing_fns:
continue
out.append(f"\n{name}: uncovered (each one is a missing test)")
if missing_fns:
out.append(" functions never called:")
for fn in missing_fns:
out.append(f" {fn}")
if gaps:
out.append(f" lines ({len(gaps)}): "
+ ", ".join(line_ranges(gaps)))
text = "\n".join(out) + "\n"
sys.stdout.write(text)
return text, {
"lines_covered": tot[0], "lines_total": tot[1],
"branches_covered": tot[2], "branches_total": tot[3],
"functions_covered": tot[4], "functions_total": tot[5],
}
def write_cobertura(path, files, root, totals):
"""Emit a minimal Cobertura report. One <class> per source file, which is
what CI coverage publishers expect for C."""
esc = saxutils.quoteattr
lr = pct(totals["lines_covered"], totals["lines_total"]) / 100.0
br = pct(totals["branches_covered"], totals["branches_total"]) / 100.0
out = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<coverage line-rate="{lr:.4f}" branch-rate="{br:.4f}" '
f'lines-covered="{totals["lines_covered"]}" '
f'lines-valid="{totals["lines_total"]}" '
f'branches-covered="{totals["branches_covered"]}" '
f'branches-valid="{totals["branches_total"]}" '
'complexity="0" version="0" timestamp="0">',
' <sources>', f' <source>{saxutils.escape(root)}</source>',
' </sources>', ' <packages>',
' <package name="akstdlib" '
f'line-rate="{lr:.4f}" branch-rate="{br:.4f}" complexity="0">',
' <classes>']
for name in sorted(files):
f = files[name]
flr = pct(f.lines_covered, f.lines_total) / 100.0
fbr = pct(f.branches_covered, f.branches_total) / 100.0
cls = os.path.basename(name)
out.append(f' <class name={esc(cls)} filename={esc(name)} '
f'line-rate="{flr:.4f}" branch-rate="{fbr:.4f}" '
'complexity="0">')
out.append(' <methods/>')
out.append(' <lines>')
for lineno in sorted(f.lines):
count = f.lines[lineno]
brs = f.branches.get(lineno) or []
if brs:
taken = sum(1 for c in brs if c > 0)
cond = (f' branch="true" condition-coverage='
f'{esc(f"{pct(taken, len(brs)):.0f}% ({taken}/{len(brs)})")}')
else:
cond = ' branch="false"'
out.append(f' <line number="{lineno}" '
f'hits="{count}"{cond}/>')
out.append(' </lines>')
out.append(' </class>')
out += [' </classes>', ' </package>', ' </packages>',
'</coverage>']
with open(path, "w") as fh:
fh.write("\n".join(out) + "\n")
# --------------------------------------------------------------------------- #
# Driver
# --------------------------------------------------------------------------- #
def zero_counters(build):
"""Delete accumulated .gcda. gcov counters are cumulative across runs, so
without this a report mixes in every earlier suite run (and any test binary
run by hand), which quietly overstates coverage."""
removed = 0
for gcda in find_gcda(build):
try:
os.remove(gcda)
removed += 1
except OSError as exc:
sys.stderr.write(f"warning: cannot remove {gcda}: {exc}\n")
return removed
def main():
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
here = os.path.dirname(os.path.abspath(__file__))
default_root = os.path.dirname(here)
ap = argparse.ArgumentParser(description="Code coverage for libakstdlib")
ap.add_argument("--build", default="build-coverage")
ap.add_argument("--source-root", default=default_root)
ap.add_argument("--gcov", default=os.environ.get("GCOV", "gcov"))
ap.add_argument("--include", action="append", default=None)
ap.add_argument("--exclude", action="append", default=None)
ap.add_argument("--zero", action="store_true")
ap.add_argument("--run-tests", action="store_true")
ap.add_argument("--threshold", type=float, default=0.0)
ap.add_argument("--branch-threshold", type=float, default=0.0)
ap.add_argument("--summary-only", action="store_true")
ap.add_argument("--output", default=None,
help="also write the text report to this path")
ap.add_argument("--cobertura", default=None)
args = ap.parse_args()
root = os.path.realpath(args.source_root)
build = os.path.abspath(args.build)
includes = args.include if args.include is not None else DEFAULT_INCLUDE
if args.exclude is not None:
excludes = args.exclude
else:
# Excludes win over includes, so a *default* exclude must not silently
# cancel an explicit --include: `--include tests` has to report tests/
# even though tests/ is excluded by default.
excludes = [e for e in DEFAULT_EXCLUDE
if not any(selected(e, [i], []) for i in (args.include or []))]
if not os.path.isdir(build):
sys.stderr.write(f"error: no such build directory: {build}\n"
"Configure one with:\n"
" cmake -S . -B build-coverage -DAKSL_COVERAGE=ON\n"
" cmake --build build-coverage\n")
return 2
if args.zero and not args.run_tests:
n = zero_counters(build)
print(f"Cleared {n} .gcda counter file(s) in {build}")
return 0
if args.run_tests:
n = zero_counters(build)
print(f"Cleared {n} .gcda counter file(s) in {build}")
print("Running ctest ...")
rc = subprocess.call(["ctest", "--test-dir", build,
"--output-on-failure"])
if rc != 0:
# Report anyway: partial coverage from a red suite is still useful,
# and the ctest output above already shows what failed.
sys.stderr.write(f"warning: ctest exited {rc}; "
"reporting coverage from a failing suite\n")
print()
files, n_gcda = collect(build, root, args.gcov, includes, excludes)
if not n_gcda:
sys.stderr.write(
f"error: no .gcda files under {build}.\n"
"Either the build was not configured with -DAKSL_COVERAGE=ON, or\n"
"the test suite has not been run yet (try --run-tests).\n")
return 2
if not files:
sys.stderr.write(
f"error: {n_gcda} .gcda file(s) found, but none of the covered "
"sources matched\nthe include/exclude filters "
f"(include={includes}, exclude={excludes}).\n")
return 2
text, totals = report(files, args.summary_only)
# CTest swallows the output of a passing test, so also drop the report on
# disk: that is what makes the in-suite coverage_report entry useful without
# having to re-run ctest under -V.
if args.output:
path = os.path.abspath(args.output)
try:
with open(path, "w") as fh:
fh.write(text)
print(f"\nReport written to: {path}")
except OSError as exc:
sys.stderr.write(f"warning: cannot write {path}: {exc}\n")
if args.cobertura:
path = os.path.abspath(args.cobertura)
write_cobertura(path, files, root, totals)
print(f"\nCobertura report written to: {path}")
line_pct = pct(totals["lines_covered"], totals["lines_total"])
branch_pct = pct(totals["branches_covered"], totals["branches_total"])
failed = False
if args.threshold > 0 and line_pct < args.threshold:
print(f"\nFAIL: line coverage {line_pct:.1f}% < "
f"threshold {args.threshold:.1f}%")
failed = True
if args.branch_threshold > 0 and branch_pct < args.branch_threshold:
print(f"\nFAIL: branch coverage {branch_pct:.1f}% < "
f"threshold {args.branch_threshold:.1f}%")
failed = True
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

439
scripts/mutation_test.py Executable file
View File

@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""
Mutation testing harness for libakstdlib.
Mutation testing measures how good the test suite is at catching bugs. It works
by making many small, deliberate breakages ("mutants") to the library source --
flipping a comparison, deleting a statement, swapping true/false -- and then
running the whole CTest suite against each one. If the tests fail, the mutant is
"killed" (good: the tests noticed the bug). If the tests still pass, the mutant
"survived" (bad: a real bug of that shape would slip through unnoticed).
The mutation score is killed / (killed + survived). Surviving mutants are printed
with file:line and the exact change so they can be turned into new test cases.
This harness has no third-party dependencies (Python stdlib + the project's
normal cmake/ctest toolchain). It never mutates the real working tree: it copies
the repo to a scratch directory and mutates there.
Usage:
scripts/mutation_test.py [options]
--source-root DIR repo root to copy (default: parent of this script's dir)
--target FILE source file to mutate, relative to root; repeatable.
Default: src/stdlib.c and include/akstdlib.h
--work DIR scratch dir for the mutated copy (default: a temp dir)
--timeout SECONDS per-suite ctest timeout (default: 120)
--threshold PCT exit non-zero if mutation score < PCT (default: 0 = off)
--list only list the mutants that would be run, then exit
--keep keep the scratch working copy on exit (for debugging)
-j N (reserved) currently runs sequentially
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
# --------------------------------------------------------------------------- #
# Mutation operators
#
# Each operator yields zero or more (start, end, replacement) edits for a single
# line of source. The driver applies exactly one edit per mutant so every mutant
# differs from the original by one localized change.
# --------------------------------------------------------------------------- #
# Relational operator replacement: map each operator to the alternatives that
# meaningfully change behaviour (not merely the strict negation).
_REL = {
"==": ["!="],
"!=": ["=="],
"<=": ["<", "=="],
">=": [">", "=="],
"<": ["<=", ">"],
">": [">=", "<"],
}
# Match a relational operator that is NOT part of ->, <<, >>, =>, <=, >=, ==, !=
# unless we intend it. We tokenize the two-char operators first, then single.
_REL_TWO = re.compile(r"(==|!=|<=|>=)")
_REL_ONE = re.compile(r"(?<![-<>=!+])([<>])(?![=<>])")
_LOGICAL = {"&&": "||", "||": "&&"}
_LOG_RE = re.compile(r"(&&|\|\|)")
_BOOL = {"true": "false", "false": "true"}
_BOOL_RE = re.compile(r"\b(true|false)\b")
# Arithmetic / compound-assignment on whitespace-delimited operands only, to
# avoid touching ++, --, ->, unary signs, or pointer/format punctuation.
_ARITH_RE = re.compile(r"(?<=\s)([+\-])(?=\s)")
_ARITH = {"+": "-", "-": "+"}
_COMPOUND_RE = re.compile(r"(\+=|-=)")
_COMPOUND = {"+=": "-=", "-=": "+="}
# Integer literal replacement: 0 <-> 1 (word-bounded, not inside identifiers or
# larger numbers, not a float).
_INT_RE = re.compile(r"(?<![\w.])([01])(?![\w.])")
_INT = {"0": "1", "1": "0"}
def _op_edits(line):
"""Yield (tag, start, end, replacement) for every candidate mutation."""
# Relational (two-char first so we don't split them with the one-char pass)
for m in _REL_TWO.finditer(line):
for alt in _REL[m.group(1)]:
yield ("ROR", m.start(1), m.end(1), alt)
for m in _REL_ONE.finditer(line):
for alt in _REL[m.group(1)]:
yield ("ROR", m.start(1), m.end(1), alt)
for m in _LOG_RE.finditer(line):
yield ("LCR", m.start(1), m.end(1), _LOGICAL[m.group(1)])
for m in _BOOL_RE.finditer(line):
yield ("BCR", m.start(1), m.end(1), _BOOL[m.group(1)])
for m in _COMPOUND_RE.finditer(line):
yield ("AOR", m.start(1), m.end(1), _COMPOUND[m.group(1)])
for m in _ARITH_RE.finditer(line):
yield ("AOR", m.start(1), m.end(1), _ARITH[m.group(1)])
for m in _INT_RE.finditer(line):
yield ("ICR", m.start(1), m.end(1), _INT[m.group(1)])
# Statement-deletion: neutralize a whole statement. We only delete statements
# that are safe to drop without guaranteeing a compile error, so a surviving
# deletion is a genuine test gap rather than compiler noise.
_STMT_DELETABLE = re.compile(
r"""^\s*(
break |
return\b[^;]* |
[A-Za-z_][-\w>().\[\]* ]*\s*=\s*[^;]* | # assignments
[A-Za-z_][\w]*\s*\([^;]*\) # bare function calls
)\s*;\s*(\\?)\s*$""",
re.VERBOSE,
)
# --------------------------------------------------------------------------- #
# Deciding which lines are eligible to mutate
# --------------------------------------------------------------------------- #
# Skip preprocessor control and the block of constant/error-code #defines in the
# template header: mutating buffer sizes or renumbering error codes produces
# equivalent or uninteresting mutants that swamp the signal.
_SKIP_LINE = re.compile(
r"""^\s*(
\#\s*(include|ifn?def|ifdef|if|elif|else|endif|error|pragma|undef) |
\#\s*define\s+AKERR_(MAX|LAST|NULLPOINTER|OUTOFBOUNDS|API|ATTRIBUTE|
TYPE|KEY|INDEX|FORMAT|IO|VALUE|RELATIONSHIP|EOF|CIRCULAR_REFERENCE|
ITERATOR_BREAK|NOT_IMPLEMENTED|BADEXC|NOIGNORE|USE_STDLIB)\b |
\* | // # comment bodies / line comments
)""",
re.VERBOSE,
)
def _is_comment_or_blank(line):
s = line.strip()
return (not s) or s.startswith("//") or s.startswith("/*") or s.startswith("*")
def eligible(line):
if _is_comment_or_blank(line):
return False
if _SKIP_LINE.match(line):
return False
return True
class Mutant:
__slots__ = ("path", "lineno", "op", "before", "after", "col")
def __init__(self, path, lineno, op, before, after, col):
self.path = path
self.lineno = lineno
self.op = op
self.before = before
self.after = after
self.col = col
def describe(self):
return (f"{self.path}:{self.lineno} [{self.op}] "
f"col{self.col}: {self.before.strip()} -> {self.after.strip()}")
def generate_mutants(root, rel_target):
"""Enumerate all mutants for one target file."""
abspath = os.path.join(root, rel_target)
with open(abspath, "r") as fh:
lines = fh.readlines()
mutants = []
for i, line in enumerate(lines, start=1):
if not eligible(line):
continue
# substitution operators
seen = set()
for tag, s, e, repl in _op_edits(line):
key = (s, e, repl)
if key in seen:
continue
seen.add(key)
mutated = line[:s] + repl + line[e:]
if mutated == line:
continue
mutants.append(Mutant(rel_target, i, tag, line, mutated, s))
# statement deletion
m = _STMT_DELETABLE.match(line)
if m:
indent = line[: len(line) - len(line.lstrip())]
cont = "\\" if line.rstrip().endswith("\\") else ""
deleted = f"{indent}/* mutant: deleted */ {cont}\n" if cont else f"{indent};\n"
mutants.append(Mutant(rel_target, i, "SDL", line, deleted, 0))
return mutants
# --------------------------------------------------------------------------- #
# Build / test orchestration against a scratch copy
# --------------------------------------------------------------------------- #
class Runner:
def __init__(self, work, timeout):
self.work = work
self.build = os.path.join(work, "build")
self.timeout = timeout
def _run(self, cmd, timeout=None):
return subprocess.run(
cmd, cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout,
)
def configure(self):
r = self._run(["cmake", "-S", ".", "-B", "build"], timeout=self.timeout)
return r.returncode == 0, r.stdout
def build_and_test(self):
"""Return ('killed-compile' | 'killed-test' | 'killed-timeout' | 'survived')."""
try:
b = self._run(["cmake", "--build", "build"], timeout=self.timeout)
except subprocess.TimeoutExpired:
return "killed-timeout"
if b.returncode != 0:
return "killed-compile"
try:
t = subprocess.run(
["ctest", "--test-dir", "build", "--output-on-failure",
"--stop-on-failure"],
cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=self.timeout,
)
except subprocess.TimeoutExpired:
return "killed-timeout"
return "survived" if t.returncode == 0 else "killed-test"
def _xml_escape(s):
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def write_junit(path, records, targets):
"""Write a JUnit XML report. One <testcase> per mutant; a surviving mutant
is a <failure> (test-suite gap), a killed mutant is a passing case."""
by_file = {t: [] for t in targets}
for m, result in records:
by_file.setdefault(m.path, []).append((m, result))
total = len(records)
total_fail = sum(1 for _, r in records if r == "survived")
out = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<testsuites name="mutation" tests="{total}" failures="{total_fail}">']
for f, items in by_file.items():
if not items:
continue
fails = sum(1 for _, r in items if r == "survived")
out.append(f' <testsuite name="mutation:{_xml_escape(f)}" '
f'tests="{len(items)}" failures="{fails}">')
for m, result in items:
name = _xml_escape(m.describe())
cls = "mutation." + _xml_escape(m.path)
if result == "survived":
detail = _xml_escape(f"{m.before.strip()} -> {m.after.strip()}")
out.append(f' <testcase name="{name}" classname="{cls}" time="0">')
out.append(f' <failure message="survived mutant '
f'({_xml_escape(m.op)} at {_xml_escape(m.path)}:'
f'{m.lineno})">{detail}</failure>')
out.append(' </testcase>')
else:
out.append(f' <testcase name="{name}" classname="{cls}" '
f'time="0"><system-out>{_xml_escape(result)}'
'</system-out></testcase>')
out.append(' </testsuite>')
out.append('</testsuites>')
with open(path, "w") as fh:
fh.write("\n".join(out) + "\n")
def copy_tree(src, dst):
# "build*" covers build/, build-asan/ and build-coverage/: the harness
# configures its own tree inside the copy, so copying those is pure IO --
# and a coverage tree drags along every .gcno/.gcda as well.
ignore = shutil.ignore_patterns("build*", ".git", "*.o", "*.so", "*~",
"#*#", "*.iso", "*.png", "*.gcno", "*.gcda")
shutil.copytree(src, dst, ignore=ignore, symlinks=True)
def read_lines(path):
with open(path) as fh:
return fh.readlines()
def write_lines(path, lines):
with open(path, "w") as fh:
fh.writelines(lines)
def main():
# Line-buffer stdout so progress is visible live under CI / the cmake target.
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
here = os.path.dirname(os.path.abspath(__file__))
default_root = os.path.dirname(here)
ap = argparse.ArgumentParser(description="Mutation testing for libakstdlib")
ap.add_argument("--source-root", default=default_root)
ap.add_argument("--target", action="append", default=None)
ap.add_argument("--work", default=None)
ap.add_argument("--timeout", type=int, default=120)
ap.add_argument("--threshold", type=float, default=0.0)
ap.add_argument("--junit", default=None,
help="write a JUnit XML report to this path")
ap.add_argument("--max-mutants", type=int, default=0,
help="cap the run at N evenly-sampled mutants (0 = all)")
ap.add_argument("--list", action="store_true")
ap.add_argument("--keep", action="store_true")
ap.add_argument("-j", type=int, default=1)
args = ap.parse_args()
root = os.path.abspath(args.source_root)
targets = args.target or ["src/stdlib.c", "include/akstdlib.h"]
# Enumerate mutants from the pristine sources.
all_mutants = []
for t in targets:
all_mutants.extend(generate_mutants(root, t))
print(f"Generated {len(all_mutants)} mutants across {len(targets)} file(s):")
for t in targets:
n = sum(1 for m in all_mutants if m.path == t)
print(f" {t}: {n}")
# Optional even-strided sampling to bound run time (CI / smoke tests).
if args.max_mutants and len(all_mutants) > args.max_mutants:
step = len(all_mutants) / args.max_mutants
sampled = [all_mutants[int(i * step)] for i in range(args.max_mutants)]
print(f"Sampling {len(sampled)} of {len(all_mutants)} mutants "
f"(--max-mutants {args.max_mutants}).")
all_mutants = sampled
if args.list:
for m in all_mutants:
print(" " + m.describe())
return 0
if not all_mutants:
print("No mutants generated; nothing to do.")
return 0
# Scratch working copy.
work_parent = args.work or tempfile.mkdtemp(prefix="akerr_mut_")
work = os.path.join(work_parent, "src") if args.work else work_parent
if os.path.exists(work):
shutil.rmtree(work)
print(f"\nCopying sources to scratch dir: {work}")
copy_tree(root, work)
runner = Runner(work, args.timeout)
print("Configuring baseline ...")
ok, out = runner.configure()
if not ok:
sys.stderr.write(out.decode(errors="replace"))
sys.stderr.write("\nBaseline configure FAILED; aborting.\n")
return 2
print("Verifying baseline is green (no mutation) ...")
baseline = runner.build_and_test()
if baseline != "survived":
sys.stderr.write(f"Baseline is not green ({baseline}); aborting. "
"Fix the suite before mutation testing.\n")
return 2
print("Baseline OK.\n")
# Group mutants by file so we mutate one file at a time and restore it.
killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0}
survivors = []
records = []
total = len(all_mutants)
# Cache pristine contents per target.
pristine = {t: read_lines(os.path.join(work, t)) for t in targets}
for idx, m in enumerate(all_mutants, start=1):
tgt_abs = os.path.join(work, m.path)
lines = list(pristine[m.path])
lines[m.lineno - 1] = m.after
write_lines(tgt_abs, lines)
try:
result = runner.build_and_test()
finally:
write_lines(tgt_abs, pristine[m.path]) # always restore
records.append((m, result))
if result == "survived":
survivors.append(m)
mark = "SURVIVED"
else:
killed[result] += 1
mark = result.upper()
print(f"[{idx}/{total}] {mark:16} {m.describe()}")
total_killed = sum(killed.values())
score = 100.0 * total_killed / total if total else 100.0
print("\n" + "=" * 72)
print("MUTATION TESTING SUMMARY")
print("=" * 72)
print(f" total mutants : {total}")
print(f" killed (test) : {killed['killed-test']}")
print(f" killed (compile): {killed['killed-compile']}")
print(f" killed (timeout): {killed['killed-timeout']}")
print(f" survived : {len(survivors)}")
print(f" mutation score : {score:.1f}%")
if survivors:
print("\nSurviving mutants (test-suite gaps -- turn these into tests):")
for m in survivors:
print(" " + m.describe())
if args.junit:
junit_path = os.path.abspath(args.junit)
write_junit(junit_path, records, targets)
print(f"\nJUnit report written to: {junit_path}")
if not args.keep and not args.work:
shutil.rmtree(work_parent, ignore_errors=True)
else:
print(f"\nScratch working copy kept at: {work}")
if args.threshold > 0 and score < args.threshold:
print(f"\nFAIL: mutation score {score:.1f}% < threshold {args.threshold:.1f}%")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -4,6 +4,58 @@
#include <errno.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
/*
* Version of the library itself. These read the AKSL_VERSION_* macros as they
* were when *this translation unit* was compiled, which is what makes them the
* loaded library's version rather than the caller's: the caller's copy of the
* same macros comes from whatever akstdlib_version.h it compiled against.
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_version(int *major, int *minor, int *patch)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, major, AKERR_NULLPOINTER, "major=%p, minor=%p, patch=%p",
(void *)major, (void *)minor, (void *)patch);
FAIL_ZERO_RETURN(e, minor, AKERR_NULLPOINTER, "major=%p, minor=%p, patch=%p",
(void *)major, (void *)minor, (void *)patch);
FAIL_ZERO_RETURN(e, patch, AKERR_NULLPOINTER, "major=%p, minor=%p, patch=%p",
(void *)major, (void *)minor, (void *)patch);
*major = AKSL_VERSION_MAJOR;
*minor = AKSL_VERSION_MINOR;
*patch = AKSL_VERSION_PATCH;
SUCCEED_RETURN(e);
}
const char *aksl_version_string(void)
{
return AKSL_VERSION_STRING;
}
const char *aksl_version_soname(void)
{
return AKSL_VERSION_SONAME;
}
/*
* Compatibility is "same soname". Pre-1.0 that is MAJOR.MINOR, so both are
* compared and patch is deliberately ignored; from 1.0 the minor comparison
* comes out, in step with the SOVERSION expression in CMakeLists.txt. The
* caller's numbers arrive as arguments because AKSL_VERSION_CHECK() expanded
* them at the caller's site -- see the macro in akstdlib.h.
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_version_check(int major, int minor, int patch)
{
PREPARE_ERROR(e);
(void)patch;
FAIL_NONZERO_RETURN(e,
(major != AKSL_VERSION_MAJOR || minor != AKSL_VERSION_MINOR),
AKERR_VALUE,
"compiled against libakstdlib %d.%d.%d, loaded %s (soname %s)",
major, minor, patch,
AKSL_VERSION_STRING, AKSL_VERSION_SONAME);
SUCCEED_RETURN(e);
}
akerr_ErrorContext AKERR_NOIGNORE *aksl_malloc(size_t size, void **dst)
{
@@ -98,6 +150,7 @@ akerr_ErrorContext AKERR_NOIGNORE *aksl_printf(int *count, const char *restrict
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, count, AKERR_NULLPOINTER, "count=%p, format=%p", (void *)count, (void *)format);
FAIL_ZERO_RETURN(e, format, AKERR_NULLPOINTER, "count=%p, format=%p", (void *)count, (void *)format);
va_start(args, format);
*count = vprintf(format, args);
FAIL_NONZERO_RETURN(e, (*count == -1), errno, "Short write");
SUCCEED_RETURN(e);
@@ -111,6 +164,7 @@ akerr_ErrorContext AKERR_NOIGNORE *aksl_fprintf(int *count, FILE *restrict strea
FAIL_ZERO_RETURN(e, count, AKERR_NULLPOINTER, "count=%p, stream=%p, format=%p", (void *)count, (void *)stream, (void *)format);
FAIL_ZERO_RETURN(e, stream, AKERR_NULLPOINTER, "count=%p, stream=%p, format=%p", (void *)count, (void *)stream, (void *)format);
FAIL_ZERO_RETURN(e, format, AKERR_NULLPOINTER, "count=%p, stream=%p, format=%p", (void *)count, (void *)stream, (void *)format);
va_start(args, format);
*count = vfprintf(stream, format, args);
FAIL_NONZERO_RETURN(e, (*count == -1), errno, "Short write");
SUCCEED_RETURN(e);
@@ -174,3 +228,174 @@ akerr_ErrorContext AKERR_NOIGNORE *aksl_realpath(const char *restrict path, char
FAIL_ZERO_RETURN(e, result, errno, "path=%s, dest=%s", path, resolved_path);
SUCCEED_RETURN(e);
}
akerr_ErrorContext AKERR_NOIGNORE *aksl_strhash_djb2(char *str, size_t len, uint32_t *hashval)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, str, AKERR_NULLPOINTER, "str");
FAIL_ZERO_RETURN(e, hashval, AKERR_NULLPOINTER, "hashval");
uint32_t h = 5381;
while (len--) {
h = ((h << 5) + h) + *str++;
}
*hashval = h;
SUCCEED_RETURN(e);
}
akerr_ErrorContext AKERR_NOIGNORE *aksl_list_append(aksl_ListNode *list, aksl_ListNode *obj)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, list, AKERR_NULLPOINTER, "list");
FAIL_ZERO_RETURN(e, obj, AKERR_NULLPOINTER, "obj");
aksl_ListNode *slow = list;
aksl_ListNode *fast = list;
aksl_ListNode *tail = list;
while ( fast != NULL && fast->next != NULL ) {
tail = slow;
slow = slow->next;
fast = fast->next->next;
if ( fast == slow) {
FAIL_RETURN(e, AKERR_CIRCULAR_REFERENCE, "%p", list);
}
}
if ( fast != NULL ) {
tail = fast;
}
tail->next = obj;
obj->next = NULL;
obj->prev = tail;
SUCCEED_RETURN(e);
}
akerr_ErrorContext AKERR_NOIGNORE *aksl_list_pop(aksl_ListNode *node)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node");
if ( node->prev != NULL ) {
node->prev->next = node->next;
}
if ( node->next != NULL ) {
node->next->prev = node->prev;
}
node->next = NULL;
node->prev = NULL;
SUCCEED_RETURN(e);
}
/**
* @brief Iterates over a tree structure in breadth or depth first order, executing a callback function on each node.
*
* This function recursively calls itself to traverse a binary tree datastructure.
*
* @param[in] root The tree structure to search
* @param[in] iter An aksl_TreeNodeIterator function which will be called for each node found
* @param[in] lalloc An aksl_AllocFunc function which will be used to allocate aksl_ListNode elements for the search, or NULL to use the default allocator (aksl_malloc)
* @param[in] lfree An aksl_FreeFunc function which will be used to free the elements procured with lalloc, or NULL to use the default free function (aksl_free)
* @param[in] searchmode One of the AKSL_TREE_SEARCH_BFS* defines
* @param[in] data Any user data that should be provided when the iterator is called
* @param[in] queue The linked list node to use as the head of the queue. The caller should pass NULL here.
*
* @throws AKERR_NULLPOINTER On null pointer inputs
* @return akerr_ErrorContext
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_tree_iterate(
aksl_TreeNode *root,
aksl_TreeNodeIterator iter,
aksl_AllocFunc lalloc,
aksl_FreeFunc lfree,
uint8_t searchmode,
void *data,
aksl_ListNode *queue)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, root, AKERR_NULLPOINTER, "root");
FAIL_ZERO_RETURN(e, iter, AKERR_NULLPOINTER, "iter");
if ( lalloc == NULL ) {
lalloc = &aksl_malloc;
}
if ( lfree == NULL ) {
lfree = &aksl_free;
}
ATTEMPT {
switch ( searchmode ) {
case AKSL_TREE_SEARCH_DFS_PREORDER:
CATCH(e, iter(root, data));
if ( root->left != NULL ) {
PASS(e, aksl_tree_iterate(root->left, iter, lalloc, lfree, searchmode, data, NULL));
}
if ( root-> right != NULL ) {
PASS(e, aksl_tree_iterate(root->right, iter, lalloc, lfree, searchmode, data, NULL));
}
break;
case AKSL_TREE_SEARCH_DFS_POSTORDER:
if ( root->left != NULL ) {
PASS(e, aksl_tree_iterate(root->left, iter, lalloc, lfree, searchmode, data, NULL));
}
if ( root-> right != NULL ) {
PASS(e, aksl_tree_iterate(root->right, iter, lalloc, lfree, searchmode, data, NULL));
}
CATCH(e, iter(root, data));
break;
case AKSL_TREE_SEARCH_DFS_INORDER:
if ( root->left != NULL ) {
PASS(e, aksl_tree_iterate(root->left, iter, lalloc, lfree, searchmode, data, NULL));
}
CATCH(e, iter(root, data));
if ( root-> right != NULL ) {
PASS(e, aksl_tree_iterate(root->right, iter, lalloc, lfree, searchmode, data, NULL));
}
break;
case AKSL_TREE_SEARCH_BFS:
case AKSL_TREE_SEARCH_BFS_RIGHT:
FAIL_RETURN(e, AKERR_NOT_IMPLEMENTED, "Searchmode %d", searchmode);
break;
}
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_ITERATOR_BREAK) {
// This is not an error condition, it's just telling us to stop early
SUCCEED_RETURN(e);
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Iterates over a linked list and execute a callback function on each node
*
* @param[in] list The linked list to iterate
* @param[in] iter An aksl_ListNodeIterator function which will be called for each node found
* @param[in] data Any user data that should be provided when the iterator is called
*
* @throws AKERR_NULLPOINTER on null pointer inputs
* @throws AKERR_CIRCULAR_REFERENCE when the linked list contains a circular reference
*
* @return akerr_ErrorContext
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_list_iterate(aksl_ListNode *list, aksl_ListNodeIterator iter, void *data)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, list, AKERR_NULLPOINTER, "list");
FAIL_ZERO_RETURN(e, iter, AKERR_NULLPOINTER, "iter");
aksl_ListNode *slow = list;
aksl_ListNode *fast = list;
while ( fast != NULL && fast->next != NULL ) {
slow = slow->next;
fast = fast->next->next;
if ( fast == slow) {
FAIL_RETURN(e, AKERR_CIRCULAR_REFERENCE, "%p", list);
}
}
while ( slow != NULL ) {
ATTEMPT {
CATCH(e, iter(slow, data));
slow = slow->next;
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_ITERATOR_BREAK) {
// This is not an error condition, it's just telling us to stop early
SUCCEED_RETURN(e);
} FINISH(e, true);
}
SUCCEED_RETURN(e);
}

341
tests/aksl_capture.h Normal file
View File

@@ -0,0 +1,341 @@
#ifndef AKSL_TEST_CAPTURE_H
#define AKSL_TEST_CAPTURE_H
/*
* Shared test helpers for libakstdlib.
*
* Modelled on libakerror's tests/err_capture.h, with additions for the shape of
* this library: almost every akstdlib entry point returns an
* akerr_ErrorContext * that the caller owns and must release, so the common
* assertion is "this call returned status X" rather than "this call logged Y".
*
* What is here:
*
* AKSL_CHECK() an NDEBUG-proof assertion. Fails the test by
* returning 1 from the enclosing function (unlike
* assert(), which is compiled out in release builds
* and would silently turn a test into a no-op).
* AKSL_CHECK_STATUS() run an akerror-returning expression, assert on the
* status it came back with, and release the context
* so the error pool does not leak.
* AKSL_CHECK_STATUS_MSG_CONTAINS()
* assert status first, then message content.
* AKSL_CHECK_OK() assert a call returned no error context at all.
* aksl_last_status/... the status, message and function name of the most
* recent context taken by AKSL_CHECK_STATUS.
* aksl_capture_install() swap in a capturing akerr_log_method so a test can
* assert on the *content* of stack traces and
* unhandled-error output.
* aksl_slots_in_use() how many slots are currently checked out of
* AKERR_ARRAY_ERROR, for pool-leak assertions.
* aksl_temp_file() create an empty temp file for the stream, formatted
* output and path tests to work on.
* AKSL_RUN() run one test function and tally the result.
*
* Tests are written as a set of `static int test_xxx(void)` functions that
* return 0 on success and non-zero on failure, driven from main() by AKSL_RUN.
*/
#include <akstdlib.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* ---------------------------------------------------------------------- */
/* Log capture */
/* ---------------------------------------------------------------------- */
#define AKSL_CAPTURE_BUFSZ 65536
static char aksl_capture_buf[AKSL_CAPTURE_BUFSZ];
static size_t aksl_capture_len = 0;
static void __attribute__((unused)) aksl_capture_logger(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(aksl_capture_buf + aksl_capture_len,
AKSL_CAPTURE_BUFSZ - aksl_capture_len, fmt, ap);
va_end(ap);
if ( n > 0 ) {
aksl_capture_len += (size_t)n;
if ( aksl_capture_len >= AKSL_CAPTURE_BUFSZ ) {
aksl_capture_len = AKSL_CAPTURE_BUFSZ - 1;
}
}
}
static void __attribute__((unused)) aksl_capture_reset(void)
{
aksl_capture_len = 0;
aksl_capture_buf[0] = '\0';
}
/*
* Install the capturing logger. akerr_init() only assigns a default logger when
* akerr_log_method is NULL, and it is idempotent, so calling this either before
* or after the first PREPARE_ERROR keeps our logger in place.
*/
static void __attribute__((unused)) aksl_capture_install(void)
{
aksl_capture_reset();
akerr_log_method = &aksl_capture_logger;
}
/* ---------------------------------------------------------------------- */
/* Error pool accounting */
/* ---------------------------------------------------------------------- */
/* Count array slots currently checked out of the pool (refcount != 0). */
static int __attribute__((unused)) aksl_slots_in_use(void)
{
int n = 0;
for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
if ( AKERR_ARRAY_ERROR[i].refcount != 0 ) {
n++;
}
}
return n;
}
/* ---------------------------------------------------------------------- */
/* Temp files */
/* ---------------------------------------------------------------------- */
#define AKSL_TMP_MAX 256
#define AKSL_TMP_TRACKED 32
static char aksl_tmp_paths[AKSL_TMP_TRACKED][AKSL_TMP_MAX];
static int aksl_tmp_count = 0;
/*
* Unlink every path aksl_temp_file() handed out. Registered with atexit, so the
* files go away even when a test returns early on a failed assertion -- which is
* the normal case for a known-failing test and for every mutant the mutation
* harness builds. Paths a test already unlinked simply fail here, harmlessly.
*/
static void aksl_temp_cleanup(void)
{
int i = 0;
for ( i = 0; i < aksl_tmp_count; i++ ) {
unlink(aksl_tmp_paths[i]);
}
aksl_tmp_count = 0;
}
/*
* Create an empty temp file under $TMPDIR (or /tmp) and write its path into buf.
* Returns 0 on success, non-zero on failure.
*
* mkstemp both names and creates the file, so a test that opens the path for
* reading is never racing another process for the name. Tests should still
* unlink what they create -- asserting on it catches a wrapper that removed or
* renamed the file -- but aksl_temp_cleanup() is the backstop.
*/
static int __attribute__((unused)) aksl_temp_file(char *buf, size_t n)
{
const char *dir = getenv("TMPDIR");
int fd = -1;
if ( dir == NULL || dir[0] == '\0' ) {
dir = "/tmp";
}
if ( (size_t)snprintf(buf, n, "%s/aksl_test_XXXXXX", dir) >= n ) {
return 1;
}
fd = mkstemp(buf);
if ( fd < 0 ) {
return 1;
}
close(fd);
if ( aksl_tmp_count < AKSL_TMP_TRACKED ) {
if ( aksl_tmp_count == 0 && atexit(&aksl_temp_cleanup) != 0 ) {
return 0; /* tracking is best-effort; the file itself is fine */
}
snprintf(aksl_tmp_paths[aksl_tmp_count], AKSL_TMP_MAX, "%s", buf);
aksl_tmp_count++;
}
return 0;
}
/* ---------------------------------------------------------------------- */
/* Taking ownership of a returned error context */
/* ---------------------------------------------------------------------- */
static int aksl_last_status = 0;
static char aksl_last_message[AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH];
/* Sized to match akerr_ErrorContext.function, which akerror declares with
* AKERR_MAX_ERROR_FNAME_LENGTH rather than AKERR_MAX_ERROR_FUNCTION_LENGTH. */
static char aksl_last_function[AKERR_MAX_ERROR_FNAME_LENGTH];
/*
* Record the status/message/function of a returned context, release it back to
* the pool, and hand back the status. A NULL context means success, which is
* status 0. Failure-path assertions should go through this so that no test
* leaks a pool slot. Success-path assertions use AKSL_CHECK_OK, which also
* verifies that no bogus status-0 context was returned.
*/
static int __attribute__((unused)) aksl_take(akerr_ErrorContext *e)
{
akerr_ErrorContext *released = NULL;
aksl_last_message[0] = '\0';
aksl_last_function[0] = '\0';
if ( e == NULL ) {
aksl_last_status = 0;
return 0;
}
aksl_last_status = e->status;
snprintf(aksl_last_message, sizeof(aksl_last_message), "%s", e->message);
snprintf(aksl_last_function, sizeof(aksl_last_function), "%s", e->function);
/*
* akerr_release_error is marked warn_unused_result, and a (void) cast does
* not silence that in GCC, so the result is assigned and discarded.
*/
released = akerr_release_error(e);
(void)released;
return aksl_last_status;
}
/* ---------------------------------------------------------------------- */
/* Assertions */
/* ---------------------------------------------------------------------- */
#define AKSL_CHECK(cond) \
do { \
if ( !(cond) ) { \
fprintf(stderr, " CHECK FAILED: %s at %s:%d\n", \
#cond, __FILE__, __LINE__); \
return 1; \
} \
} while ( 0 )
/*
* Run an akerr_ErrorContext *-returning expression and assert on its status.
* The context is always released, including on the failure path, so a failing
* assertion does not also corrupt the pool-leak checks that follow it.
*/
#define AKSL_CHECK_STATUS(__expr, __expected) \
do { \
int __st = aksl_take(__expr); \
if ( __st != (__expected) ) { \
fprintf(stderr, \
" CHECK FAILED: %s\n" \
" got %d (%s) \"%s\"\n" \
" expected %d (%s)\n" \
" at %s:%d\n", \
#__expr, \
__st, akerr_name_for_status(__st, NULL), \
aksl_last_message, \
(__expected), \
akerr_name_for_status((__expected), NULL), \
__FILE__, __LINE__); \
return 1; \
} \
} while ( 0 )
/*
* Success is represented by a NULL error context, not just status 0. This catches
* wrappers that incorrectly raise an error using a stale errno value of 0.
*/
#define AKSL_CHECK_OK(__expr) \
do { \
akerr_ErrorContext *__e = (__expr); \
if ( __e != NULL ) { \
int __st = aksl_take(__e); \
fprintf(stderr, \
" CHECK FAILED: %s\n" \
" returned error context with status %d (%s) \"%s\"\n" \
" expected no error context\n" \
" at %s:%d\n", \
#__expr, \
__st, akerr_name_for_status(__st, NULL), \
aksl_last_message, \
__FILE__, __LINE__); \
return 1; \
} \
aksl_last_status = 0; \
aksl_last_message[0] = '\0'; \
aksl_last_function[0] = '\0'; \
} while ( 0 )
#define AKSL_CHECK_CONTAINS(needle) \
AKSL_CHECK(strstr(aksl_capture_buf, (needle)) != NULL)
#define AKSL_CHECK_NOT_CONTAINS(needle) \
AKSL_CHECK(strstr(aksl_capture_buf, (needle)) == NULL)
/*
* Message checks are secondary: the returned akerr status is the contract. Keep
* the status and message assertions coupled so tests cannot pass on text alone.
*/
#define AKSL_CHECK_STATUS_MSG_CONTAINS(__expr, __expected, __needle) \
do { \
int __st = aksl_take(__expr); \
if ( __st != (__expected) ) { \
fprintf(stderr, \
" CHECK FAILED: %s\n" \
" got %d (%s) \"%s\"\n" \
" expected %d (%s)\n" \
" at %s:%d\n", \
#__expr, \
__st, akerr_name_for_status(__st, NULL), \
aksl_last_message, \
(__expected), \
akerr_name_for_status((__expected), NULL), \
__FILE__, __LINE__); \
return 1; \
} \
if ( strstr(aksl_last_message, (__needle)) == NULL ) { \
fprintf(stderr, \
" CHECK FAILED: message from %s\n" \
" got \"%s\"\n" \
" expected substring \"%s\"\n" \
" status %d (%s)\n" \
" at %s:%d\n", \
#__expr, \
aksl_last_message, \
(__needle), \
__st, akerr_name_for_status(__st, NULL), \
__FILE__, __LINE__); \
return 1; \
} \
} while ( 0 )
/* ---------------------------------------------------------------------- */
/* Test driver */
/* ---------------------------------------------------------------------- */
/*
* Run one test function, report it, and tally failures. Also asserts that the
* test left the error pool as it found it -- a wrapper that fails to release a
* context is a bug in the library, not just in the test.
*/
#define AKSL_RUN(__failures, __fn) \
do { \
int __before = aksl_slots_in_use(); \
int __r = __fn(); \
int __after = aksl_slots_in_use(); \
if ( __r != 0 ) { \
fprintf(stderr, "FAIL %s\n", #__fn); \
(__failures)++; \
} else if ( __after != __before ) { \
fprintf(stderr, \
"FAIL %s (leaked %d error pool slot(s))\n", \
#__fn, __after - __before); \
(__failures)++; \
} else { \
fprintf(stderr, "ok %s\n", #__fn); \
} \
} while ( 0 )
#define AKSL_REPORT(__failures) \
do { \
fprintf(stderr, "%s: %d failure(s)\n", __FILE__, (__failures)); \
return (__failures) == 0 ? 0 : 1; \
} while ( 0 )
#endif // AKSL_TEST_CAPTURE_H

128
tests/test_convert.c Normal file
View File

@@ -0,0 +1,128 @@
/*
* String -> number wrappers: aksl_atoi / atol / atoll / atof.
*
* TODO.md section 1.4. This file covers the parts of the contract that are
* stable today: the happy paths (including negatives and leading whitespace)
* and the NULL guards.
*
* It deliberately says nothing about non-numeric input, trailing junk or
* overflow. Those all return *success* today (TODO.md 2.1.5) and the correct
* behaviour is asserted by tests/test_convert_strict.c, which is registered as
* a known failure. Pinning the current lax behaviour here as well would mean
* this file starts failing on the day the defect is fixed.
*/
#include "aksl_capture.h"
static int test_atoi_converts_positive(void)
{
int out = -1;
AKSL_CHECK_OK(aksl_atoi("1234", &out));
AKSL_CHECK(out == 1234);
return 0;
}
static int test_atoi_converts_negative(void)
{
int out = 0;
AKSL_CHECK_OK(aksl_atoi("-42", &out));
AKSL_CHECK(out == -42);
return 0;
}
static int test_atoi_skips_leading_whitespace(void)
{
int out = 0;
AKSL_CHECK_OK(aksl_atoi(" \t 7", &out));
AKSL_CHECK(out == 7);
return 0;
}
static int test_atoi_rejects_null_arguments(void)
{
int out = 0;
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_atoi(NULL, &out),
AKERR_NULLPOINTER, "nptr=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_atoi("1", NULL),
AKERR_NULLPOINTER, "dest=");
return 0;
}
static int test_atol_converts_and_rejects_null(void)
{
long out = 0;
AKSL_CHECK_OK(aksl_atol("2147483648", &out));
AKSL_CHECK(out == 2147483648L);
AKSL_CHECK_OK(aksl_atol("-2147483648", &out));
AKSL_CHECK(out == -2147483648L);
AKSL_CHECK_STATUS(aksl_atol(NULL, &out), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_atol("1", NULL), AKERR_NULLPOINTER);
return 0;
}
static int test_atoll_converts_and_rejects_null(void)
{
long long out = 0;
AKSL_CHECK_OK(aksl_atoll("9007199254740993", &out));
AKSL_CHECK(out == 9007199254740993LL);
AKSL_CHECK_OK(aksl_atoll("-9007199254740993", &out));
AKSL_CHECK(out == -9007199254740993LL);
AKSL_CHECK_STATUS(aksl_atoll(NULL, &out), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_atoll("1", NULL), AKERR_NULLPOINTER);
return 0;
}
static int test_atof_converts_and_rejects_null(void)
{
double out = 0.0;
AKSL_CHECK_OK(aksl_atof("2.5", &out));
AKSL_CHECK(out == 2.5);
AKSL_CHECK_OK(aksl_atof(" -0.125", &out));
AKSL_CHECK(out == -0.125);
AKSL_CHECK_STATUS(aksl_atof(NULL, &out), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_atof("1", NULL), AKERR_NULLPOINTER);
return 0;
}
/* The wrappers hold no state: the same input converts the same way twice. */
static int test_conversions_are_repeatable(void)
{
int first = 0;
int second = 0;
AKSL_CHECK_OK(aksl_atoi("321", &first));
AKSL_CHECK_OK(aksl_atoi("321", &second));
AKSL_CHECK(first == second);
AKSL_CHECK(first == 321);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_atoi_converts_positive);
AKSL_RUN(failures, test_atoi_converts_negative);
AKSL_RUN(failures, test_atoi_skips_leading_whitespace);
AKSL_RUN(failures, test_atoi_rejects_null_arguments);
AKSL_RUN(failures, test_atol_converts_and_rejects_null);
AKSL_RUN(failures, test_atoll_converts_and_rejects_null);
AKSL_RUN(failures, test_atof_converts_and_rejects_null);
AKSL_RUN(failures, test_conversions_are_repeatable);
AKSL_REPORT(failures);
}

View File

@@ -0,0 +1,75 @@
/*
* KNOWN FAILING -- TODO.md section 2.1.5
*
* The ato* wrappers cannot report a conversion failure. atoi(3) and friends have
* no error channel at all: "not a number" converts to 0 and an overflowing
* literal converts to a wrapped value, and in both cases the wrapper hands back
* success. A library whose entire purpose is turning silent libc failures into
* error contexts should not be the one place a bad conversion passes unnoticed.
*
* This test asserts the contract the fix should provide -- reimplemented over
* strtol/strtoll/strtod with errno cleared, an endptr check for "no digits
* consumed" and "trailing junk", and a range check:
*
* no digits consumed / trailing junk -> AKERR_VALUE
* value out of range -> ERANGE
*
* Registered in AKSL_KNOWN_FAILING_TESTS (WILL_FAIL). When the wrappers get a
* real error channel, CTest reports this as unexpectedly passing -- move it into
* AKSL_TESTS then, and fold the cases into tests/test_convert.c.
*/
#include "aksl_capture.h"
#include <errno.h>
static int test_non_numeric_input_is_a_value_error(void)
{
int out = 0;
AKSL_CHECK_STATUS(aksl_atoi("not a number", &out), AKERR_VALUE);
return 0;
}
static int test_empty_input_is_a_value_error(void)
{
int out = 0;
AKSL_CHECK_STATUS(aksl_atoi("", &out), AKERR_VALUE);
AKSL_CHECK_STATUS(aksl_atoi(" ", &out), AKERR_VALUE);
return 0;
}
static int test_trailing_junk_is_a_value_error(void)
{
int out = 0;
AKSL_CHECK_STATUS(aksl_atoi("12abc", &out), AKERR_VALUE);
return 0;
}
static int test_overflow_is_erange(void)
{
int out = 0;
long lout = 0;
long long llout = 0;
AKSL_CHECK_STATUS(aksl_atoi("99999999999999999999", &out), ERANGE);
AKSL_CHECK_STATUS(aksl_atol("99999999999999999999999999", &lout), ERANGE);
AKSL_CHECK_STATUS(aksl_atoll("99999999999999999999999999", &llout), ERANGE);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_non_numeric_input_is_a_value_error);
AKSL_RUN(failures, test_empty_input_is_a_value_error);
AKSL_RUN(failures, test_trailing_junk_is_a_value_error);
AKSL_RUN(failures, test_overflow_is_erange);
AKSL_REPORT(failures);
}

224
tests/test_format.c Normal file
View File

@@ -0,0 +1,224 @@
/*
* Formatted-output wrappers: aksl_printf / aksl_fprintf / aksl_sprintf.
*
* TODO.md section 1.3. Each happy path asserts both halves of the contract --
* the byte count handed back through *count and the text that actually landed
* somewhere -- and every pointer argument is checked for its NULL guard.
*
* Readback goes through plain libc rather than aksl_fread so that a failure here
* points at the formatted-output wrapper under test and not at the stream
* wrappers, which tests/test_stream.c covers.
*
* Not covered: the destination-overflow case, because aksl_sprintf wraps the
* unbounded vsprintf and there is no bounded entry point to test yet
* (TODO.md 2.2.4).
*/
#include "aksl_capture.h"
#include <errno.h>
/* Read a whole file into buf and NUL-terminate. Returns bytes read, or -1. */
static long read_file(const char *path, char *buf, size_t n)
{
FILE *fp = fopen(path, "r");
size_t got = 0;
if ( fp == NULL ) {
return -1;
}
got = fread(buf, 1, n - 1, fp);
buf[got] = '\0';
if ( ferror(fp) ) {
fclose(fp);
return -1;
}
fclose(fp);
return (long)got;
}
static int test_sprintf_writes_text_and_count(void)
{
char buf[64];
int count = -1;
memset(buf, 0x00, sizeof(buf));
AKSL_CHECK_OK(aksl_sprintf(&count, buf, "%s=%d", "x", 7));
AKSL_CHECK(count == 3);
AKSL_CHECK(strcmp(buf, "x=7") == 0);
return 0;
}
static int test_sprintf_empty_format_writes_nothing(void)
{
char buf[8] = { 'z', 'z', 'z', 'z', 'z', 'z', 'z', 'z' };
int count = -1;
AKSL_CHECK_OK(aksl_sprintf(&count, buf, "%s", ""));
AKSL_CHECK(count == 0);
AKSL_CHECK(buf[0] == '\0');
return 0;
}
static int test_sprintf_rejects_null_arguments(void)
{
char buf[8];
int count = 0;
memset(buf, 0x00, sizeof(buf));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_sprintf(NULL, buf, "x"),
AKERR_NULLPOINTER, "count=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_sprintf(&count, NULL, "x"),
AKERR_NULLPOINTER, "str=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_sprintf(&count, buf, NULL),
AKERR_NULLPOINTER, "format=");
return 0;
}
static int test_fprintf_writes_to_stream(void)
{
char path[AKSL_TMP_MAX];
char readback[64];
FILE *fp = NULL;
int count = -1;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "w", &fp));
AKSL_CHECK_OK(aksl_fprintf(&count, fp, "%s %d", "value", 42));
AKSL_CHECK_OK(aksl_fclose(fp));
AKSL_CHECK(count == 8);
AKSL_CHECK(read_file(path, readback, sizeof(readback)) == 8);
AKSL_CHECK(strcmp(readback, "value 42") == 0);
AKSL_CHECK(unlink(path) == 0);
return 0;
}
/*
* vfprintf on a stream opened "r" fails outright, so the wrapper reports the
* errno it saw (EBADF on glibc). *count is left holding -1 in this case, which
* TODO.md 1.3 flags as a contract gap -- the status is the assertion here.
*/
static int test_fprintf_to_read_only_stream_reports_errno(void)
{
char path[AKSL_TMP_MAX];
FILE *fp = NULL;
int count = 0;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "r", &fp));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(&count, fp, "%d", 1),
EBADF, "Short write");
AKSL_CHECK_OK(aksl_fclose(fp));
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_fprintf_rejects_null_arguments(void)
{
int count = 0;
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(NULL, stdout, "x"),
AKERR_NULLPOINTER, "count=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(&count, NULL, "x"),
AKERR_NULLPOINTER, "stream=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(&count, stdout, NULL),
AKERR_NULLPOINTER, "format=");
return 0;
}
/*
* aksl_printf writes to stdout, so stdout is pointed at a temp file for the
* duration of the call and then restored through a dup of the original
* descriptor. Nothing between the freopen and the dup2 may return early: an
* assertion there would leave stdout attached to the temp file for the rest of
* the run, and the test report itself would vanish.
*/
static int test_printf_writes_to_stdout(void)
{
char path[AKSL_TMP_MAX];
char readback[64];
akerr_ErrorContext *err = NULL;
int count = -1;
int saved = -1;
int restored = -1;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
fflush(stdout);
saved = dup(fileno(stdout));
AKSL_CHECK(saved >= 0);
if ( freopen(path, "w", stdout) == NULL ) {
close(saved);
AKSL_CHECK(0);
}
err = aksl_printf(&count, "%s#%d", "out", 5);
fflush(stdout);
restored = dup2(saved, fileno(stdout));
close(saved);
clearerr(stdout);
AKSL_CHECK(restored >= 0);
AKSL_CHECK(aksl_take(err) == 0);
AKSL_CHECK(count == 5);
AKSL_CHECK(read_file(path, readback, sizeof(readback)) == 5);
AKSL_CHECK(strcmp(readback, "out#5") == 0);
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_printf_rejects_null_arguments(void)
{
int count = 0;
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_printf(NULL, "x"),
AKERR_NULLPOINTER, "count=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_printf(&count, NULL),
AKERR_NULLPOINTER, "format=");
return 0;
}
/*
* Regression cover for the missing va_end (TODO.md 2.1.4). Nothing here can
* assert on register-save state directly; the point is to run the variadic
* wrappers enough times, with enough arguments, that the sanitizer build has
* something to trip over.
*/
static int test_variadic_wrappers_survive_repeated_calls(void)
{
char buf[128];
int count = 0;
int i = 0;
for ( i = 0; i < 512; i++ ) {
AKSL_CHECK_OK(aksl_sprintf(&count, buf, "%d %s %ld %c %f",
i, "iteration", (long)i, 'x', (double)i));
AKSL_CHECK(count > 0);
AKSL_CHECK((size_t)count == strlen(buf));
}
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_sprintf_writes_text_and_count);
AKSL_RUN(failures, test_sprintf_empty_format_writes_nothing);
AKSL_RUN(failures, test_sprintf_rejects_null_arguments);
AKSL_RUN(failures, test_fprintf_writes_to_stream);
AKSL_RUN(failures, test_fprintf_to_read_only_stream_reports_errno);
AKSL_RUN(failures, test_fprintf_rejects_null_arguments);
AKSL_RUN(failures, test_printf_writes_to_stdout);
AKSL_RUN(failures, test_printf_rejects_null_arguments);
AKSL_RUN(failures, test_variadic_wrappers_survive_repeated_calls);
AKSL_REPORT(failures);
}

320
tests/test_linkedlist.c Normal file
View File

@@ -0,0 +1,320 @@
/*
* Linked-list behaviour that the library gets right today.
*
* The two confirmed list defects (TODO.md 2.1.1 aksl_list_append truncating the
* chain, and 2.1.2 aksl_list_iterate skipping the head) are asserted separately
* in tests/test_list_append_chain.c and tests/test_list_iterate_head.c, which
* are registered as known-failing. Everything in this file must pass.
*
* Note that the list-shape assertions here build their lists by hand rather
* than with aksl_list_append: append cannot be trusted to produce a chain
* longer than two nodes until 2.1.1 is fixed, and using it would make these
* tests fail for a reason that has nothing to do with what they are checking.
*/
#include "aksl_capture.h"
#define MAX_VISITS 16
typedef struct VisitLog
{
int count;
aksl_ListNode *seen[MAX_VISITS];
int break_at; /* visit index to raise ITERATOR_BREAK on, or -1 */
int fail_at; /* visit index to raise AKERR_VALUE on, or -1 */
} VisitLog;
static void visitlog_init(VisitLog *log)
{
memset((void *)log, 0x00, sizeof(VisitLog));
log->break_at = -1;
log->fail_at = -1;
}
static akerr_ErrorContext AKERR_NOIGNORE *record_visit(aksl_ListNode *node, void *data)
{
VisitLog *log = NULL;
int idx = 0;
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node");
FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data");
log = (VisitLog *)data;
idx = log->count;
if ( idx < MAX_VISITS ) {
log->seen[idx] = node;
}
log->count += 1;
if ( log->fail_at == idx ) {
FAIL_RETURN(e, AKERR_VALUE, "iterator failed at visit %d", idx);
}
if ( log->break_at == idx ) {
FAIL_RETURN(e, AKERR_ITERATOR_BREAK, "stop at visit %d", idx);
}
SUCCEED_RETURN(e);
}
/* ---------------------------------------------------------------------- */
/* aksl_list_append */
/* ---------------------------------------------------------------------- */
static int test_append_single_node(void)
{
aksl_ListNode head;
aksl_ListNode tail;
memset((void *)&head, 0x00, sizeof(head));
memset((void *)&tail, 0x00, sizeof(tail));
AKSL_CHECK_OK(aksl_list_append(&head, &tail));
AKSL_CHECK(head.next == &tail);
AKSL_CHECK(head.prev == NULL);
AKSL_CHECK(tail.prev == &head);
AKSL_CHECK(tail.next == NULL);
return 0;
}
static int test_append_null_arguments(void)
{
aksl_ListNode node;
memset((void *)&node, 0x00, sizeof(node));
AKSL_CHECK_STATUS(aksl_list_append(NULL, &node), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_list_append(&node, NULL), AKERR_NULLPOINTER);
return 0;
}
static int test_append_detects_self_cycle(void)
{
aksl_ListNode head;
aksl_ListNode node;
memset((void *)&head, 0x00, sizeof(head));
memset((void *)&node, 0x00, sizeof(node));
head.next = &head;
AKSL_CHECK_STATUS(aksl_list_append(&head, &node), AKERR_CIRCULAR_REFERENCE);
return 0;
}
static int test_append_detects_two_node_cycle(void)
{
aksl_ListNode a;
aksl_ListNode b;
aksl_ListNode node;
memset((void *)&a, 0x00, sizeof(a));
memset((void *)&b, 0x00, sizeof(b));
memset((void *)&node, 0x00, sizeof(node));
a.next = &b;
b.prev = &a;
b.next = &a;
AKSL_CHECK_STATUS(aksl_list_append(&a, &node), AKERR_CIRCULAR_REFERENCE);
return 0;
}
/* ---------------------------------------------------------------------- */
/* aksl_list_iterate */
/* ---------------------------------------------------------------------- */
static int test_iterate_null_arguments(void)
{
aksl_ListNode node;
VisitLog log;
memset((void *)&node, 0x00, sizeof(node));
visitlog_init(&log);
AKSL_CHECK_STATUS(aksl_list_iterate(NULL, &record_visit, &log), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_list_iterate(&node, NULL, &log), AKERR_NULLPOINTER);
AKSL_CHECK(log.count == 0);
return 0;
}
static int test_iterate_single_node(void)
{
aksl_ListNode node;
VisitLog log;
memset((void *)&node, 0x00, sizeof(node));
visitlog_init(&log);
AKSL_CHECK_OK(aksl_list_iterate(&node, &record_visit, &log));
AKSL_CHECK(log.count == 1);
AKSL_CHECK(log.seen[0] == &node);
return 0;
}
static int test_iterate_detects_self_cycle(void)
{
aksl_ListNode head;
VisitLog log;
memset((void *)&head, 0x00, sizeof(head));
head.next = &head;
visitlog_init(&log);
AKSL_CHECK_STATUS(aksl_list_iterate(&head, &record_visit, &log),
AKERR_CIRCULAR_REFERENCE);
return 0;
}
static int test_iterate_detects_two_node_cycle(void)
{
aksl_ListNode a;
aksl_ListNode b;
VisitLog log;
memset((void *)&a, 0x00, sizeof(a));
memset((void *)&b, 0x00, sizeof(b));
a.next = &b;
b.prev = &a;
b.next = &a;
visitlog_init(&log);
AKSL_CHECK_STATUS(aksl_list_iterate(&a, &record_visit, &log),
AKERR_CIRCULAR_REFERENCE);
return 0;
}
/* An error other than ITERATOR_BREAK must come back out of the iteration with
* its status and message intact. */
static int test_iterate_propagates_callback_error(void)
{
aksl_ListNode node;
VisitLog log;
memset((void *)&node, 0x00, sizeof(node));
visitlog_init(&log);
log.fail_at = 0;
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_list_iterate(&node, &record_visit, &log),
AKERR_VALUE, "iterator failed at visit 0");
AKSL_CHECK(log.count == 1);
return 0;
}
/* ITERATOR_BREAK is a control signal, not a failure: the caller sees success. */
static int test_iterate_break_is_not_an_error(void)
{
aksl_ListNode node;
VisitLog log;
memset((void *)&node, 0x00, sizeof(node));
visitlog_init(&log);
log.break_at = 0;
AKSL_CHECK_OK(aksl_list_iterate(&node, &record_visit, &log));
AKSL_CHECK(log.count == 1);
return 0;
}
/* ---------------------------------------------------------------------- */
/* aksl_list_pop */
/* ---------------------------------------------------------------------- */
static int test_pop_middle_node(void)
{
aksl_ListNode a;
aksl_ListNode b;
aksl_ListNode c;
memset((void *)&a, 0x00, sizeof(a));
memset((void *)&b, 0x00, sizeof(b));
memset((void *)&c, 0x00, sizeof(c));
a.next = &b;
b.prev = &a;
b.next = &c;
c.prev = &b;
AKSL_CHECK_OK(aksl_list_pop(&b));
AKSL_CHECK(a.next == &c);
AKSL_CHECK(c.prev == &a);
AKSL_CHECK(b.next == NULL);
AKSL_CHECK(b.prev == NULL);
return 0;
}
static int test_pop_head_node(void)
{
aksl_ListNode a;
aksl_ListNode b;
memset((void *)&a, 0x00, sizeof(a));
memset((void *)&b, 0x00, sizeof(b));
a.next = &b;
b.prev = &a;
AKSL_CHECK_OK(aksl_list_pop(&a));
AKSL_CHECK(b.prev == NULL);
AKSL_CHECK(b.next == NULL);
AKSL_CHECK(a.next == NULL);
AKSL_CHECK(a.prev == NULL);
return 0;
}
static int test_pop_tail_node(void)
{
aksl_ListNode a;
aksl_ListNode b;
memset((void *)&a, 0x00, sizeof(a));
memset((void *)&b, 0x00, sizeof(b));
a.next = &b;
b.prev = &a;
AKSL_CHECK_OK(aksl_list_pop(&b));
AKSL_CHECK(a.next == NULL);
AKSL_CHECK(a.prev == NULL);
AKSL_CHECK(b.next == NULL);
AKSL_CHECK(b.prev == NULL);
return 0;
}
static int test_pop_only_node(void)
{
aksl_ListNode a;
memset((void *)&a, 0x00, sizeof(a));
AKSL_CHECK_OK(aksl_list_pop(&a));
AKSL_CHECK(a.next == NULL);
AKSL_CHECK(a.prev == NULL);
return 0;
}
static int test_pop_null_argument(void)
{
AKSL_CHECK_STATUS(aksl_list_pop(NULL), AKERR_NULLPOINTER);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_append_single_node);
AKSL_RUN(failures, test_append_null_arguments);
AKSL_RUN(failures, test_append_detects_self_cycle);
AKSL_RUN(failures, test_append_detects_two_node_cycle);
AKSL_RUN(failures, test_iterate_null_arguments);
AKSL_RUN(failures, test_iterate_single_node);
AKSL_RUN(failures, test_iterate_detects_self_cycle);
AKSL_RUN(failures, test_iterate_detects_two_node_cycle);
AKSL_RUN(failures, test_iterate_propagates_callback_error);
AKSL_RUN(failures, test_iterate_break_is_not_an_error);
AKSL_RUN(failures, test_pop_middle_node);
AKSL_RUN(failures, test_pop_head_node);
AKSL_RUN(failures, test_pop_tail_node);
AKSL_RUN(failures, test_pop_only_node);
AKSL_RUN(failures, test_pop_null_argument);
AKSL_REPORT(failures);
}

View File

@@ -0,0 +1,59 @@
/*
* KNOWN FAILING -- TODO.md section 2.1.1
*
* aksl_list_append conflates Floyd cycle detection with finding the tail: the
* `tail` cursor is assigned from `slow` *before* `slow` advances, so it tracks
* the node behind the list midpoint rather than the last node. Appending to any
* list of two or more nodes therefore overwrites an interior link and silently
* drops every node after it.
*
* Appending n1..n4 to n0 produces the chain "n0 -> n4"; this test asserts the
* correct "n0 -> n1 -> n2 -> n3 -> n4" and so fails until append is fixed.
* It is registered in AKSL_KNOWN_FAILING_TESTS, which marks it WILL_FAIL; when
* the fix lands, CTest will report it as unexpectedly passing, which is the
* signal to move it into AKSL_TESTS.
*/
#include "aksl_capture.h"
#define CHAIN_LEN 5
static int test_append_builds_full_chain(void)
{
aksl_ListNode node[CHAIN_LEN];
aksl_ListNode *walk = NULL;
int i = 0;
memset((void *)node, 0x00, sizeof(node));
for ( i = 1; i < CHAIN_LEN; i++ ) {
AKSL_CHECK_OK(aksl_list_append(&node[0], &node[i]));
}
/* Forward links, head to tail. */
walk = &node[0];
for ( i = 0; i < CHAIN_LEN; i++ ) {
AKSL_CHECK(walk == &node[i]);
walk = walk->next;
}
AKSL_CHECK(walk == NULL);
/* Back links, tail to head. */
walk = &node[CHAIN_LEN - 1];
for ( i = CHAIN_LEN - 1; i >= 0; i-- ) {
AKSL_CHECK(walk == &node[i]);
walk = walk->prev;
}
AKSL_CHECK(walk == NULL);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_append_builds_full_chain);
AKSL_REPORT(failures);
}

View File

@@ -0,0 +1,71 @@
/*
* KNOWN FAILING -- TODO.md section 2.1.2
*
* aksl_list_iterate runs a Floyd cycle check that leaves `slow` sitting at the
* list midpoint, and then starts the visiting loop from `slow` instead of from
* `list`. Every node before the midpoint -- including the head -- is never
* passed to the callback.
*
* The list here is built by hand rather than with aksl_list_append so that this
* test fails only for the iterate defect, not for the separate append defect in
* TODO.md 2.1.1.
*
* Registered in AKSL_KNOWN_FAILING_TESTS (WILL_FAIL). When iterate is fixed,
* CTest reports this as unexpectedly passing -- move it into AKSL_TESTS then.
*/
#include "aksl_capture.h"
#define CHAIN_LEN 3
typedef struct VisitLog
{
int count;
aksl_ListNode *seen[CHAIN_LEN];
} VisitLog;
static akerr_ErrorContext AKERR_NOIGNORE *record_visit(aksl_ListNode *node, void *data)
{
VisitLog *log = NULL;
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node");
FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data");
log = (VisitLog *)data;
if ( log->count < CHAIN_LEN ) {
log->seen[log->count] = node;
}
log->count += 1;
SUCCEED_RETURN(e);
}
static int test_iterate_visits_every_node_from_the_head(void)
{
aksl_ListNode node[CHAIN_LEN];
VisitLog log;
int i = 0;
memset((void *)node, 0x00, sizeof(node));
memset((void *)&log, 0x00, sizeof(log));
for ( i = 1; i < CHAIN_LEN; i++ ) {
node[i - 1].next = &node[i];
node[i].prev = &node[i - 1];
}
AKSL_CHECK_OK(aksl_list_iterate(&node[0], &record_visit, &log));
AKSL_CHECK(log.count == CHAIN_LEN);
for ( i = 0; i < CHAIN_LEN; i++ ) {
AKSL_CHECK(log.seen[i] == &node[i]);
}
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_iterate_visits_every_node_from_the_head);
AKSL_REPORT(failures);
}

150
tests/test_memory.c Normal file
View File

@@ -0,0 +1,150 @@
/*
* Memory-wrapper behaviour that is deterministic today.
*
* TODO.md section 1.1 also calls out malloc(0), malloc(SIZE_MAX), and
* overlapping memcpy. Those are intentionally not pinned here yet: malloc(0)
* is platform-dependent under the current wrapper, huge allocations need
* sanitizer-safe handling, and overlapping memcpy is still an API-contract
* decision.
*/
#include "aksl_capture.h"
static int test_malloc_writes_nonnull_pointer(void)
{
void *ptr = NULL;
AKSL_CHECK_OK(aksl_malloc(32, &ptr));
AKSL_CHECK(ptr != NULL);
AKSL_CHECK_OK(aksl_free(ptr));
return 0;
}
static int test_malloc_rejects_null_destination(void)
{
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_malloc(32, NULL),
AKERR_NULLPOINTER, "NULL");
return 0;
}
static int test_free_rejects_null_pointer(void)
{
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_free(NULL),
AKERR_NULLPOINTER, "NULL");
return 0;
}
static int test_malloc_free_round_trip(void)
{
unsigned char *buf = NULL;
size_t i = 0;
AKSL_CHECK_OK(aksl_malloc(64, (void **)&buf));
AKSL_CHECK(buf != NULL);
for ( i = 0; i < 64; i++ ) {
buf[i] = (unsigned char)i;
}
for ( i = 0; i < 64; i++ ) {
AKSL_CHECK(buf[i] == (unsigned char)i);
}
AKSL_CHECK_OK(aksl_free(buf));
return 0;
}
static int test_memset_fills_buffer(void)
{
unsigned char buf[8];
size_t i = 0;
memset((void *)buf, 0x00, sizeof(buf));
AKSL_CHECK_OK(aksl_memset(buf, 0x5a, sizeof(buf)));
for ( i = 0; i < sizeof(buf); i++ ) {
AKSL_CHECK(buf[i] == 0x5a);
}
return 0;
}
static int test_memset_zero_length_is_noop(void)
{
unsigned char buf[4] = { 1, 2, 3, 4 };
AKSL_CHECK_OK(aksl_memset(buf, 0xff, 0));
AKSL_CHECK(buf[0] == 1);
AKSL_CHECK(buf[1] == 2);
AKSL_CHECK(buf[2] == 3);
AKSL_CHECK(buf[3] == 4);
return 0;
}
static int test_memset_rejects_null_buffer(void)
{
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_memset(NULL, 0x00, 4),
AKERR_NULLPOINTER, "s=");
return 0;
}
static int test_memcpy_copies_bytes(void)
{
unsigned char src[6] = { 0, 1, 2, 3, 4, 5 };
unsigned char dst[6] = { 9, 9, 9, 9, 9, 9 };
size_t i = 0;
AKSL_CHECK_OK(aksl_memcpy(dst, src, sizeof(src)));
for ( i = 0; i < sizeof(src); i++ ) {
AKSL_CHECK(dst[i] == src[i]);
}
return 0;
}
static int test_memcpy_zero_length_is_noop(void)
{
unsigned char src[3] = { 1, 2, 3 };
unsigned char dst[3] = { 4, 5, 6 };
AKSL_CHECK_OK(aksl_memcpy(dst, src, 0));
AKSL_CHECK(dst[0] == 4);
AKSL_CHECK(dst[1] == 5);
AKSL_CHECK(dst[2] == 6);
return 0;
}
static int test_memcpy_rejects_null_destination(void)
{
unsigned char src[1] = { 1 };
AKSL_CHECK_STATUS(aksl_memcpy(NULL, src, sizeof(src)),
AKERR_NULLPOINTER);
return 0;
}
static int test_memcpy_rejects_null_source(void)
{
unsigned char dst[1] = { 0 };
AKSL_CHECK_STATUS(aksl_memcpy(dst, NULL, sizeof(dst)),
AKERR_NULLPOINTER);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_malloc_writes_nonnull_pointer);
AKSL_RUN(failures, test_malloc_rejects_null_destination);
AKSL_RUN(failures, test_free_rejects_null_pointer);
AKSL_RUN(failures, test_malloc_free_round_trip);
AKSL_RUN(failures, test_memset_fills_buffer);
AKSL_RUN(failures, test_memset_zero_length_is_noop);
AKSL_RUN(failures, test_memset_rejects_null_buffer);
AKSL_RUN(failures, test_memcpy_copies_bytes);
AKSL_RUN(failures, test_memcpy_zero_length_is_noop);
AKSL_RUN(failures, test_memcpy_rejects_null_destination);
AKSL_RUN(failures, test_memcpy_rejects_null_source);
AKSL_REPORT(failures);
}

116
tests/test_path.c Normal file
View File

@@ -0,0 +1,116 @@
/*
* aksl_realpath -- TODO.md section 1.5.
*
* The happy paths compare against realpath(3) itself rather than against a
* hard-coded string, because $TMPDIR may itself be a symlink (/tmp -> /private/tmp
* and friends) and the resolved answer is what the platform says it is.
*
* Every failure case here passes a *zeroed* resolved_path buffer. That is
* deliberate: on failure the wrapper formats resolved_path with %s while
* realpath(3) leaves the buffer unspecified (TODO.md 2.1.6), so a test that
* passed an uninitialised buffer would be reading uninitialised memory in the
* library's own error path. The uninitialised-buffer crash is the defect's own
* test to write, not something these should trip over incidentally.
*
* Also not covered: resolved_path == NULL, which is unchecked today and leaks
* the buffer realpath(3) allocates (2.1.6).
*/
#include "aksl_capture.h"
#include <errno.h>
#include <limits.h>
static int test_resolves_an_existing_file(void)
{
char path[AKSL_TMP_MAX];
char resolved[PATH_MAX];
char expected[PATH_MAX];
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK(realpath(path, expected) != NULL);
AKSL_CHECK_OK(aksl_realpath(path, resolved));
AKSL_CHECK(strcmp(resolved, expected) == 0);
AKSL_CHECK(resolved[0] == '/');
AKSL_CHECK(unlink(path) == 0);
return 0;
}
/* A symlink resolves to its target, not to itself. */
static int test_resolves_a_symlink_to_its_target(void)
{
char target[AKSL_TMP_MAX];
char link[AKSL_TMP_MAX];
char resolved[PATH_MAX];
char expected[PATH_MAX];
AKSL_CHECK(aksl_temp_file(target, sizeof(target)) == 0);
AKSL_CHECK(aksl_temp_file(link, sizeof(link)) == 0);
/* mkstemp created the link path as a regular file; symlink needs it gone. */
AKSL_CHECK(unlink(link) == 0);
AKSL_CHECK(symlink(target, link) == 0);
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK(realpath(target, expected) != NULL);
AKSL_CHECK_OK(aksl_realpath(link, resolved));
AKSL_CHECK(strcmp(resolved, expected) == 0);
AKSL_CHECK(unlink(link) == 0);
AKSL_CHECK(unlink(target) == 0);
return 0;
}
static int test_missing_path_reports_enoent(void)
{
char resolved[PATH_MAX];
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_realpath("/nonexistent/aksl/path", resolved),
ENOENT, "/nonexistent/aksl/path");
return 0;
}
/* A regular file used as a directory component is ENOTDIR, not ENOENT. */
static int test_non_directory_component_reports_enotdir(void)
{
char path[AKSL_TMP_MAX];
char child[AKSL_TMP_MAX + 8];
char resolved[PATH_MAX];
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK((size_t)snprintf(child, sizeof(child), "%s/child", path)
< sizeof(child));
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK_STATUS(aksl_realpath(child, resolved), ENOTDIR);
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_rejects_null_path(void)
{
char resolved[PATH_MAX];
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_realpath(NULL, resolved),
AKERR_NULLPOINTER, "path=");
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_resolves_an_existing_file);
AKSL_RUN(failures, test_resolves_a_symlink_to_its_target);
AKSL_RUN(failures, test_missing_path_reports_enoent);
AKSL_RUN(failures, test_non_directory_component_reports_enotdir);
AKSL_RUN(failures, test_rejects_null_path);
AKSL_REPORT(failures);
}

View File

@@ -0,0 +1,225 @@
/*
* libakstdlib's side of the libakerror 1.0.0 status registry contract.
*
* See deps/libakerror/UPGRADING.md. That release made the status-name table
* private, moved consumer status codes to a reserved band starting at
* AKERR_FIRST_CONSUMER_STATUS, and made ownership of a range enforced rather
* than advisory. None of it broke this library's build, because libakstdlib
* defines no status codes of its own -- it raises libakerror's AKERR_* codes and
* propagates the host's errno values, both of which live in libakerror's own
* 0-255 band.
*
* That "defines none of its own" is now a contract worth pinning rather than an
* accident, because it is what lets libakstdlib sit in a process alongside any
* other libakerror consumer without a range negotiation. These tests assert it,
* and assert that the registry the codes are looked up in is actually populated
* -- a status whose name fails to register degrades to "Unknown Error" in every
* later stack trace, which is a silent loss of debuggability rather than a
* failure anyone would notice.
*
* If libakstdlib ever does define its own status codes, this file is where the
* reservation gets asserted: replace test_reserves_no_consumer_range with one
* that reserves the library's declared range and confirms its names registered.
*/
#include "aksl_capture.h"
#include <errno.h>
/*
* Every status libakstdlib raises, and where from. All of them belong to
* libakerror or to the host's errno space; none is defined here.
*/
static const struct {
int status;
const char *symbol;
const char *raised_by;
} aksl_raised_statuses[] = {
{ AKERR_NULLPOINTER, "AKERR_NULLPOINTER", "every NULL parameter guard" },
{ AKERR_IO, "AKERR_IO", "aksl_fread / aksl_fwrite short count" },
{ AKERR_EOF, "AKERR_EOF", "aksl_fread at end of stream" },
{ AKERR_ITERATOR_BREAK, "AKERR_ITERATOR_BREAK", "aksl_list_iterate / aksl_tree_iterate" },
{ AKERR_CIRCULAR_REFERENCE, "AKERR_CIRCULAR_REFERENCE", "aksl_list_append cycle guard" },
{ AKERR_NOT_IMPLEMENTED, "AKERR_NOT_IMPLEMENTED", "unsupported tree search modes" },
{ ENOENT, "ENOENT", "errno propagated by aksl_fopen" },
};
#define AKSL_RAISED_STATUS_COUNT \
((int)(sizeof(aksl_raised_statuses) / sizeof(aksl_raised_statuses[0])))
/*
* Ranges this file claims out of the consumer band. A reservation is
* process-global and there is no way to give one back, so each test function
* gets its own slice and no two overlap.
*
* +0 .. +15 test_reserves_no_consumer_range
* +16 .. +31 test_range_ownership_is_enforced
* +32 .. +47 test_naming_a_foreign_status_is_refused
*/
#define AKSL_TEST_RANGE_FREE (AKERR_FIRST_CONSUMER_STATUS + 0)
#define AKSL_TEST_RANGE_OWNED (AKERR_FIRST_CONSUMER_STATUS + 16)
#define AKSL_TEST_RANGE_FOREIGN (AKERR_FIRST_CONSUMER_STATUS + 32)
#define AKSL_TEST_RANGE_SIZE 16
#define AKSL_OWNER_A "aksl-test-a"
#define AKSL_OWNER_B "aksl-test-b"
/*
* The band boundary this library's "raises nothing of its own" claim rests on.
* Asserted rather than assumed: if libakerror ever moves the boundary, the
* status-band assertion below stops meaning what it says.
*/
static int test_consumer_band_starts_at_256(void)
{
AKSL_CHECK(AKERR_FIRST_CONSUMER_STATUS == 256);
AKSL_CHECK(AKERR_RESERVED_STATUS_COUNT == 256);
return 0;
}
/*
* Every status libakstdlib raises falls inside libakerror's reserved band, so it
* cannot collide with a consumer that allocates from 256 up.
*/
static int test_raised_statuses_are_in_the_reserved_band(void)
{
int i = 0;
for ( i = 0; i < AKSL_RAISED_STATUS_COUNT; i++ ) {
if ( aksl_raised_statuses[i].status >= AKERR_FIRST_CONSUMER_STATUS ) {
fprintf(stderr,
" CHECK FAILED: %s (%d) is outside libakerror's reserved band\n"
" raised by %s\n"
" at %s:%d\n",
aksl_raised_statuses[i].symbol,
aksl_raised_statuses[i].status,
aksl_raised_statuses[i].raised_by,
__FILE__, __LINE__);
return 1;
}
}
return 0;
}
/*
* The registry actually holds a name for each of them. This is the check that
* catches a build whose name table is too small: registration failures are
* reported at akerr_init() time, but the visible symptom afterwards is only that
* stack traces read "Unknown Error", which no other test would notice.
*/
static int test_raised_statuses_have_registered_names(void)
{
int i = 0;
char *name = NULL;
for ( i = 0; i < AKSL_RAISED_STATUS_COUNT; i++ ) {
name = akerr_name_for_status(aksl_raised_statuses[i].status, NULL);
if ( name == NULL || strcmp(name, "Unknown Error") == 0 ) {
fprintf(stderr,
" CHECK FAILED: %s (%d) has no registered name\n"
" raised by %s\n"
" reads back as \"%s\"\n"
" at %s:%d\n",
aksl_raised_statuses[i].symbol,
aksl_raised_statuses[i].status,
aksl_raised_statuses[i].raised_by,
name == NULL ? "(NULL)" : name,
__FILE__, __LINE__);
return 1;
}
}
return 0;
}
/*
* libakstdlib reserves nothing in the consumer band, so an application is free
* to allocate from AKERR_FIRST_CONSUMER_STATUS without coordinating with it.
* The library is driven first so that anything it might do lazily has happened
* before the range is claimed.
*/
static int test_reserves_no_consumer_range(void)
{
void *p = NULL;
AKSL_CHECK_OK(aksl_malloc(16, &p));
AKSL_CHECK_OK(aksl_free(p));
AKSL_CHECK_OK(akerr_reserve_status_range(AKSL_TEST_RANGE_FREE,
AKSL_TEST_RANGE_SIZE,
AKSL_OWNER_A));
return 0;
}
/*
* Ownership is enforced, which is what makes the assertion above meaningful --
* a reservation that succeeded against an already-claimed range would prove
* nothing. libakerror's own 0-255 band is refused for the same reason.
*/
static int test_range_ownership_is_enforced(void)
{
AKSL_CHECK_OK(akerr_reserve_status_range(AKSL_TEST_RANGE_OWNED,
AKSL_TEST_RANGE_SIZE,
AKSL_OWNER_A));
/* An identical reservation by the same owner is a documented no-op. */
AKSL_CHECK_OK(akerr_reserve_status_range(AKSL_TEST_RANGE_OWNED,
AKSL_TEST_RANGE_SIZE,
AKSL_OWNER_A));
/* The same range under a different owner is not. */
AKSL_CHECK_STATUS(akerr_reserve_status_range(AKSL_TEST_RANGE_OWNED,
AKSL_TEST_RANGE_SIZE,
AKSL_OWNER_B),
AKERR_STATUS_RANGE_OVERLAP);
/* Nor is any part of libakerror's own band. */
AKSL_CHECK_STATUS(akerr_reserve_status_range(AKERR_NULLPOINTER, 1, AKSL_OWNER_B),
AKERR_STATUS_RANGE_OVERLAP);
return 0;
}
/*
* Naming is enforced against the reservation too: a status nobody reserved and a
* status somebody else reserved are both refused, with different codes.
*/
static int test_naming_a_foreign_status_is_refused(void)
{
AKSL_CHECK_STATUS(akerr_register_status_name(AKSL_OWNER_A,
AKSL_TEST_RANGE_FOREIGN,
"Unreserved"),
AKERR_STATUS_NAME_UNRESERVED);
AKSL_CHECK_OK(akerr_reserve_status_range(AKSL_TEST_RANGE_FOREIGN,
AKSL_TEST_RANGE_SIZE,
AKSL_OWNER_A));
AKSL_CHECK_OK(akerr_register_status_name(AKSL_OWNER_A,
AKSL_TEST_RANGE_FOREIGN,
"Mine"));
AKSL_CHECK(strcmp(akerr_name_for_status(AKSL_TEST_RANGE_FOREIGN, NULL),
"Mine") == 0);
AKSL_CHECK_STATUS(akerr_register_status_name(AKSL_OWNER_B,
AKSL_TEST_RANGE_FOREIGN,
"Theirs"),
AKERR_STATUS_NAME_FOREIGN);
/* The refused registration left the owner's name in place. */
AKSL_CHECK(strcmp(akerr_name_for_status(AKSL_TEST_RANGE_FOREIGN, NULL),
"Mine") == 0);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_consumer_band_starts_at_256);
AKSL_RUN(failures, test_raised_statuses_are_in_the_reserved_band);
AKSL_RUN(failures, test_raised_statuses_have_registered_names);
AKSL_RUN(failures, test_reserves_no_consumer_range);
AKSL_RUN(failures, test_range_ownership_is_enforced);
AKSL_RUN(failures, test_naming_a_foreign_status_is_refused);
AKSL_REPORT(failures);
}

188
tests/test_stream.c Normal file
View File

@@ -0,0 +1,188 @@
/*
* Stream wrappers: aksl_fopen / aksl_fread / aksl_fwrite / aksl_fclose.
*
* TODO.md section 1.2. Covered here: the happy paths, the round trip, the NULL
* guards that exist, and the two error statuses the wrappers can actually
* produce today -- AKERR_EOF from a short read and AKERR_IO from a stream whose
* error indicator is set.
*
* Not covered, because the behaviour is a documented gap rather than a
* contract: aksl_fopen(NULL, ...) and aksl_fopen(path, NULL, ...) are unchecked
* (2.2.2), aksl_fread/aksl_fwrite never check ptr and report a short transfer
* that is neither EOF nor error as complete success (2.2.3).
*
* Temp files come from aksl_temp_file() and are unlinked by the test that made
* them, so a failing test leaves nothing behind but the file it was mid-way
* through.
*/
#include "aksl_capture.h"
#include <errno.h>
static int test_fopen_writes_stream_pointer(void)
{
char path[AKSL_TMP_MAX];
FILE *fp = NULL;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "w", &fp));
AKSL_CHECK(fp != NULL);
AKSL_CHECK_OK(aksl_fclose(fp));
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_fopen_reports_missing_path(void)
{
FILE *fp = NULL;
/* The pathname belongs in the message; the status is the errno fopen saw. */
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_fopen("/nonexistent/aksl/stream", "r", &fp),
ENOENT, "/nonexistent/aksl/stream");
return 0;
}
static int test_fopen_rejects_null_stream_out(void)
{
char path[AKSL_TMP_MAX];
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fopen(path, "r", NULL),
AKERR_NULLPOINTER, "NULL");
AKSL_CHECK(unlink(path) == 0);
return 0;
}
/* fopen -> fwrite -> fclose -> fopen -> fread -> compare, all through the wrappers. */
static int test_write_read_round_trip(void)
{
char path[AKSL_TMP_MAX];
char payload[] = "libakstdlib round trip";
char readback[sizeof(payload)];
FILE *fp = NULL;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "w", &fp));
AKSL_CHECK_OK(aksl_fwrite(payload, 1, sizeof(payload), fp));
AKSL_CHECK_OK(aksl_fclose(fp));
fp = NULL;
memset(readback, 0x00, sizeof(readback));
AKSL_CHECK_OK(aksl_fopen(path, "r", &fp));
AKSL_CHECK_OK(aksl_fread(readback, 1, sizeof(readback), fp));
AKSL_CHECK_OK(aksl_fclose(fp));
AKSL_CHECK(memcmp(payload, readback, sizeof(payload)) == 0);
AKSL_CHECK(unlink(path) == 0);
return 0;
}
/* Asking for more members than the file holds sets feof, which is AKERR_EOF. */
static int test_fread_short_read_is_eof(void)
{
char path[AKSL_TMP_MAX];
char buf[32];
FILE *fp = NULL;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "w", &fp));
AKSL_CHECK_OK(aksl_fwrite("abcd", 1, 4, fp));
AKSL_CHECK_OK(aksl_fclose(fp));
fp = NULL;
memset(buf, 0x00, sizeof(buf));
AKSL_CHECK_OK(aksl_fopen(path, "r", &fp));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fread(buf, 1, sizeof(buf), fp),
AKERR_EOF, "EOF");
AKSL_CHECK_OK(aksl_fclose(fp));
/* The bytes that did arrive are still in the buffer. */
AKSL_CHECK(memcmp(buf, "abcd", 4) == 0);
AKSL_CHECK(unlink(path) == 0);
return 0;
}
/*
* A stream opened "w" has no read permission, so fread sets the error indicator
* rather than the EOF one: AKERR_IO, not AKERR_EOF.
*/
static int test_fread_from_write_only_stream_is_io_error(void)
{
char path[AKSL_TMP_MAX];
char buf[4];
FILE *fp = NULL;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "w", &fp));
memset(buf, 0x00, sizeof(buf));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fread(buf, 1, sizeof(buf), fp),
AKERR_IO, "Error reading file");
AKSL_CHECK_OK(aksl_fclose(fp));
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_fread_rejects_null_stream(void)
{
char buf[4];
memset(buf, 0x00, sizeof(buf));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fread(buf, 1, sizeof(buf), NULL),
AKERR_NULLPOINTER, "NULL");
return 0;
}
/* Mirror image of the fread case: a "r" stream cannot be written to. */
static int test_fwrite_to_read_only_stream_is_io_error(void)
{
char path[AKSL_TMP_MAX];
FILE *fp = NULL;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK_OK(aksl_fopen(path, "r", &fp));
AKSL_CHECK_STATUS(aksl_fwrite("xy", 1, 2, fp), AKERR_IO);
AKSL_CHECK_OK(aksl_fclose(fp));
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_fwrite_rejects_null_stream(void)
{
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fwrite("xy", 1, 2, NULL),
AKERR_NULLPOINTER, "NULL");
return 0;
}
static int test_fclose_rejects_null_stream(void)
{
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fclose(NULL),
AKERR_NULLPOINTER, "NULL");
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_fopen_writes_stream_pointer);
AKSL_RUN(failures, test_fopen_reports_missing_path);
AKSL_RUN(failures, test_fopen_rejects_null_stream_out);
AKSL_RUN(failures, test_write_read_round_trip);
AKSL_RUN(failures, test_fread_short_read_is_eof);
AKSL_RUN(failures, test_fread_from_write_only_stream_is_io_error);
AKSL_RUN(failures, test_fread_rejects_null_stream);
AKSL_RUN(failures, test_fwrite_to_read_only_stream_is_io_error);
AKSL_RUN(failures, test_fwrite_rejects_null_stream);
AKSL_RUN(failures, test_fclose_rejects_null_stream);
AKSL_REPORT(failures);
}

103
tests/test_strhash.c Normal file
View File

@@ -0,0 +1,103 @@
/*
* aksl_strhash_djb2 -- TODO.md section 1.6.
*
* The expected values are the canonical djb2 ones: h = 5381, then
* h = h * 33 + byte for each of len bytes, truncated to 32 bits. They were
* computed independently of this implementation.
*
* Every vector here is 7-bit ASCII, where signed and unsigned char agree. The
* high-bit case ("\xff\xfe") is the sign-extension defect in TODO.md 2.2.6 and
* is left for a test that can be registered as a known failure.
*/
#include "aksl_capture.h"
static int test_empty_string_is_the_djb2_seed(void)
{
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2("", 0, &h));
AKSL_CHECK(h == 5381);
return 0;
}
/* len drives the loop, so a zero length ignores the contents entirely. */
static int test_zero_length_ignores_the_buffer(void)
{
char buf[] = "ignored";
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, 0, &h));
AKSL_CHECK(h == 5381);
return 0;
}
static int test_known_answer_vectors(void)
{
char hello[] = "hello";
char libname[] = "libakstdlib";
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(hello, 5, &h));
AKSL_CHECK(h == 261238937u);
AKSL_CHECK_OK(aksl_strhash_djb2(libname, 11, &h));
AKSL_CHECK(h == 884285482u);
return 0;
}
/* The function is length-driven, not NUL-driven: an embedded NUL is hashed. */
static int test_embedded_nul_is_hashed(void)
{
char buf[3] = { 'a', '\0', 'b' };
uint32_t whole = 0;
uint32_t prefix = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf), &whole));
AKSL_CHECK(whole == 193482728u);
/* Stopping at the NUL would give the one-byte hash instead. */
AKSL_CHECK_OK(aksl_strhash_djb2(buf, 1, &prefix));
AKSL_CHECK(prefix != whole);
return 0;
}
static int test_hash_is_stable_across_calls(void)
{
char buf[] = "repeatable";
uint32_t first = 0;
uint32_t second = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf) - 1, &first));
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf) - 1, &second));
AKSL_CHECK(first == second);
return 0;
}
static int test_rejects_null_arguments(void)
{
char buf[] = "x";
uint32_t h = 0;
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_strhash_djb2(NULL, 1, &h),
AKERR_NULLPOINTER, "str");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_strhash_djb2(buf, 1, NULL),
AKERR_NULLPOINTER, "hashval");
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_empty_string_is_the_djb2_seed);
AKSL_RUN(failures, test_zero_length_ignores_the_buffer);
AKSL_RUN(failures, test_known_answer_vectors);
AKSL_RUN(failures, test_embedded_nul_is_hashed);
AKSL_RUN(failures, test_hash_is_stable_across_calls);
AKSL_RUN(failures, test_rejects_null_arguments);
AKSL_REPORT(failures);
}

182
tests/test_tree.c Normal file
View File

@@ -0,0 +1,182 @@
/*
* Depth-first tree search.
*
* Previously this test shared one TreeSearchParams across all three searches
* without resetting it, so `steps` accumulated (7, then 14, then 21) and the
* second assertion failed -- `tree` was a red test on every run. Each search
* now gets its own params.
*
* Caveat worth knowing when reading the step counts below: the value is hidden
* in tree[6], which is the last node visited in pre-, in- and post-order alike,
* so "7 steps" holds for all three orders and does not actually distinguish
* them -- nor does it prove that AKERR_ITERATOR_BREAK stopped anything (it does
* not; see tests/test_tree_iterate_break.c and TODO.md 2.1.3). Visit-order
* assertions that tell the three traversals apart are TODO.md section 1.8.
*/
#include "aksl_capture.h"
#define MAX_LEAVES 7
#define HIDDEN_VALUE ((void *)17336)
typedef struct TreeSearchParams
{
void *value;
int steps;
aksl_TreeNode *node;
} TreeSearchParams;
static akerr_ErrorContext AKERR_NOIGNORE *find_value(aksl_TreeNode *node, void *data)
{
TreeSearchParams *parms = NULL;
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node");
FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data");
parms = (TreeSearchParams *)data;
parms->steps += 1;
if ( node->leaf == parms->value ) {
parms->node = node;
FAIL_RETURN(e, AKERR_ITERATOR_BREAK, "stop");
}
SUCCEED_RETURN(e);
}
/*
* Build the 3-level tree used by every case here, with the search value hidden
* in the bottom-right leaf.
*
* LEFT RIGHT
* TREE[0]
* +--------^^---------+
* | |
* TREE[1] TREE[2]
* +---^^---+ +---^^---+
* | | | |
*TREE[3] TREE[4] TREE[5] TREE[6]
*/
static void build_tree(aksl_TreeNode *tree, TreeSearchParams *parms)
{
memset((void *)tree, 0x00, sizeof(aksl_TreeNode) * MAX_LEAVES);
tree[0].left = &tree[1];
tree[0].right = &tree[2];
tree[1].left = &tree[3];
tree[1].right = &tree[4];
tree[2].left = &tree[5];
tree[2].right = &tree[6];
tree[6].leaf = HIDDEN_VALUE;
memset((void *)parms, 0x00, sizeof(TreeSearchParams));
parms->value = HIDDEN_VALUE;
}
static int search_finds_hidden_value(uint8_t searchmode)
{
aksl_TreeNode tree[MAX_LEAVES];
TreeSearchParams parms;
build_tree(tree, &parms);
AKSL_CHECK_OK(aksl_tree_iterate(&tree[0], &find_value, NULL, NULL,
searchmode, &parms, NULL));
AKSL_CHECK(parms.node == &tree[6]);
AKSL_CHECK(parms.steps == MAX_LEAVES);
return 0;
}
static int test_dfs_preorder(void)
{
return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_PREORDER);
}
static int test_dfs_inorder(void)
{
return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_INORDER);
}
static int test_dfs_postorder(void)
{
return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_POSTORDER);
}
/*
* Both breadth-first modes are declared in the header but not implemented, and
* say so through AKERR_NOT_IMPLEMENTED rather than by silently visiting nothing.
* TODO.md 2.2.10 tracks implementing them; until then this is the contract.
*/
static int bfs_reports_not_implemented(uint8_t searchmode)
{
aksl_TreeNode tree[MAX_LEAVES];
TreeSearchParams parms;
build_tree(tree, &parms);
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_tree_iterate(&tree[0], &find_value, NULL, NULL, searchmode,
&parms, NULL),
AKERR_NOT_IMPLEMENTED, "Searchmode");
AKSL_CHECK(parms.steps == 0);
AKSL_CHECK(parms.node == NULL);
return 0;
}
static int test_bfs_is_not_implemented(void)
{
return bfs_reports_not_implemented(AKSL_TREE_SEARCH_BFS);
}
static int test_bfs_right_is_not_implemented(void)
{
return bfs_reports_not_implemented(AKSL_TREE_SEARCH_BFS_RIGHT);
}
static int test_iterate_null_arguments(void)
{
aksl_TreeNode tree[MAX_LEAVES];
TreeSearchParams parms;
build_tree(tree, &parms);
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_tree_iterate(NULL, &find_value, NULL, NULL,
AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL),
AKERR_NULLPOINTER, "root");
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_tree_iterate(&tree[0], NULL, NULL, NULL,
AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL),
AKERR_NULLPOINTER, "iter");
return 0;
}
/* A callback error that is not AKERR_ITERATOR_BREAK reaches the caller. */
static int test_iterate_propagates_callback_error(void)
{
aksl_TreeNode tree[MAX_LEAVES];
TreeSearchParams parms;
build_tree(tree, &parms);
/* find_value raises AKERR_NULLPOINTER when it is handed no data. */
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_tree_iterate(&tree[0], &find_value, NULL, NULL,
AKSL_TREE_SEARCH_DFS_PREORDER, NULL, NULL),
AKERR_NULLPOINTER, "data");
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_dfs_preorder);
AKSL_RUN(failures, test_dfs_inorder);
AKSL_RUN(failures, test_dfs_postorder);
AKSL_RUN(failures, test_bfs_is_not_implemented);
AKSL_RUN(failures, test_bfs_right_is_not_implemented);
AKSL_RUN(failures, test_iterate_null_arguments);
AKSL_RUN(failures, test_iterate_propagates_callback_error);
AKSL_REPORT(failures);
}

View File

@@ -0,0 +1,89 @@
/*
* KNOWN FAILING -- TODO.md section 2.1.3
*
* AKERR_ITERATOR_BREAK does not abort a tree traversal. aksl_tree_iterate
* recurses into itself, and the frame in which the callback raises the break
* handles it in its own PROCESS/HANDLE(e, AKERR_ITERATOR_BREAK) block and
* returns *success*. The parent frame's PASS therefore sees no error at all and
* carries straight on into the sibling subtree, so the traversal runs to
* completion.
*
* The 7-node tree below is walked pre-order (0, 1, 3, 4, 2, 5, 6) with the
* callback breaking on tree[3], the third node visited. A working break stops
* the walk at 3 visits; today the callback is invoked all 7 times.
*
* tests/test_tree.c cannot catch this: it hides its value in tree[6], which is
* the last node visited in all three depth-first orders, so a break that never
* fires is indistinguishable from one that fires on the final node.
*
* Registered in AKSL_KNOWN_FAILING_TESTS (WILL_FAIL). When the break semantics
* are fixed, CTest reports this as unexpectedly passing -- move it into
* AKSL_TESTS then.
*/
#include "aksl_capture.h"
#define MAX_LEAVES 7
typedef struct BreakParams
{
aksl_TreeNode *stop_at;
int visits;
} BreakParams;
static akerr_ErrorContext AKERR_NOIGNORE *break_at_node(aksl_TreeNode *node, void *data)
{
BreakParams *parms = NULL;
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node");
FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data");
parms = (BreakParams *)data;
parms->visits += 1;
if ( node == parms->stop_at ) {
FAIL_RETURN(e, AKERR_ITERATOR_BREAK, "stop");
}
SUCCEED_RETURN(e);
}
static int test_break_aborts_the_whole_traversal(void)
{
aksl_TreeNode tree[MAX_LEAVES];
BreakParams parms;
memset((void *)tree, 0x00, sizeof(tree));
memset((void *)&parms, 0x00, sizeof(parms));
/*
* TREE[0]
* +--------^^---------+
* | |
* TREE[1] TREE[2]
* +---^^---+ +---^^---+
* | | | |
*TREE[3] TREE[4] TREE[5] TREE[6]
*/
tree[0].left = &tree[1];
tree[0].right = &tree[2];
tree[1].left = &tree[3];
tree[1].right = &tree[4];
tree[2].left = &tree[5];
tree[2].right = &tree[6];
parms.stop_at = &tree[3];
AKSL_CHECK_OK(aksl_tree_iterate(&tree[0], &break_at_node, NULL, NULL,
AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL));
/* Pre-order visits 0, 1, 3 -- the break on tree[3] must stop the walk there. */
AKSL_CHECK(parms.visits == 3);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_break_aborts_the_whole_traversal);
AKSL_REPORT(failures);
}

177
tests/test_version.c Normal file
View File

@@ -0,0 +1,177 @@
/*
* Version reporting -- TODO.md section 2.2.16.
*
* There are two versions in play and the whole point of this API is that they
* are allowed to differ:
*
* the AKSL_VERSION_* macros what the caller was COMPILED against, baked
* into the caller's object file from whatever
* akstdlib_version.h was on its include path
* aksl_version() and friends what is actually LOADED, baked into
* libakstdlib.so when the library was built
*
* In this test they necessarily agree, because the test binary and the library
* are built from one tree in one configure. So the assertions below split into
* two kinds: the ones that check the two sides agree (which would catch a build
* that compiled the library and its tests against different generated headers),
* and the ones that drive aksl_version_check() with deliberately wrong numbers
* to prove it actually refuses a mismatch rather than always returning success.
* The second kind is what makes the first kind worth anything.
*/
#include "aksl_capture.h"
static int test_version_string_matches_its_components(void)
{
char expected[64];
snprintf(expected, sizeof(expected), "%d.%d.%d",
AKSL_VERSION_MAJOR, AKSL_VERSION_MINOR, AKSL_VERSION_PATCH);
AKSL_CHECK(strcmp(AKSL_VERSION_STRING, expected) == 0);
return 0;
}
/*
* AKSL_VERSION_NUMBER must be ordinary decimal arithmetic. Written out as a
* literal it would be tempting to spell 0.1.0 as 000100, which C reads as
* octal 64 -- so this compares the compile-time macro against the same figure
* computed at runtime from the loaded library's own components. A literal that
* went octal fails here; the current computed form cannot.
*/
static int test_version_number_is_decimal_and_ordered(void)
{
int major = -1;
int minor = -1;
int patch = -1;
AKSL_CHECK_OK(aksl_version(&major, &minor, &patch));
AKSL_CHECK(AKSL_VERSION_NUMBER == (major * 10000) + (minor * 100) + patch);
/* Usable in the preprocessor, which is the reason it exists at all. */
#if AKSL_VERSION_NUMBER < 0
AKSL_CHECK(0 && "AKSL_VERSION_NUMBER is negative");
#endif
AKSL_CHECK(AKSL_VERSION_NUMBER >= 0);
return 0;
}
/* Header and shared library came out of the same configure. */
static int test_loaded_version_matches_the_header(void)
{
int major = -1;
int minor = -1;
int patch = -1;
AKSL_CHECK_OK(aksl_version(&major, &minor, &patch));
AKSL_CHECK(major == AKSL_VERSION_MAJOR);
AKSL_CHECK(minor == AKSL_VERSION_MINOR);
AKSL_CHECK(patch == AKSL_VERSION_PATCH);
AKSL_CHECK(strcmp(aksl_version_string(), AKSL_VERSION_STRING) == 0);
AKSL_CHECK(strcmp(aksl_version_soname(), AKSL_VERSION_SONAME) == 0);
return 0;
}
/*
* The soname is the ABI-break granularity, and it mirrors the SOVERSION
* expression in CMakeLists.txt: MAJOR.MINOR while major is 0, MAJOR alone after
* that. If one of the two ever changes without the other, this fails.
*/
static int test_soname_matches_the_documented_rule(void)
{
char expected[64];
#if AKSL_VERSION_MAJOR == 0
snprintf(expected, sizeof(expected), "%d.%d",
AKSL_VERSION_MAJOR, AKSL_VERSION_MINOR);
#else
snprintf(expected, sizeof(expected), "%d", AKSL_VERSION_MAJOR);
#endif
AKSL_CHECK(strcmp(aksl_version_soname(), expected) == 0);
return 0;
}
static int test_version_check_accepts_a_matching_build(void)
{
AKSL_CHECK_OK(AKSL_VERSION_CHECK());
return 0;
}
/*
* The refusals. Without these the success above proves nothing -- a
* aksl_version_check() that returned NULL unconditionally would pass it.
*/
static int test_version_check_refuses_a_mismatch(void)
{
AKSL_CHECK_STATUS(aksl_version_check(AKSL_VERSION_MAJOR + 1,
AKSL_VERSION_MINOR,
AKSL_VERSION_PATCH),
AKERR_VALUE);
AKSL_CHECK_STATUS(aksl_version_check(AKSL_VERSION_MAJOR,
AKSL_VERSION_MINOR + 1,
AKSL_VERSION_PATCH),
AKERR_VALUE);
return 0;
}
/*
* Patch is deliberately not part of the comparison: a patch bump is an ABI
* promise, so a caller built against 0.1.0 must keep working against 0.1.7.
*/
static int test_version_check_ignores_the_patch_level(void)
{
AKSL_CHECK_OK(aksl_version_check(AKSL_VERSION_MAJOR,
AKSL_VERSION_MINOR,
AKSL_VERSION_PATCH + 9));
return 0;
}
/* The error has to say which two versions disagree, or it is not actionable. */
static int test_mismatch_message_names_both_versions(void)
{
char compiled[64];
snprintf(compiled, sizeof(compiled), "%d.%d.%d",
AKSL_VERSION_MAJOR + 1, AKSL_VERSION_MINOR, AKSL_VERSION_PATCH);
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_version_check(AKSL_VERSION_MAJOR + 1,
AKSL_VERSION_MINOR,
AKSL_VERSION_PATCH),
AKERR_VALUE, compiled);
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_version_check(AKSL_VERSION_MAJOR + 1,
AKSL_VERSION_MINOR,
AKSL_VERSION_PATCH),
AKERR_VALUE, AKSL_VERSION_STRING);
return 0;
}
static int test_rejects_null_arguments(void)
{
int major = 0;
int minor = 0;
int patch = 0;
AKSL_CHECK_STATUS(aksl_version(NULL, &minor, &patch), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_version(&major, NULL, &patch), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_version(&major, &minor, NULL), AKERR_NULLPOINTER);
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_version_string_matches_its_components);
AKSL_RUN(failures, test_version_number_is_decimal_and_ordered);
AKSL_RUN(failures, test_loaded_version_matches_the_header);
AKSL_RUN(failures, test_soname_matches_the_documented_rule);
AKSL_RUN(failures, test_version_check_accepts_a_matching_build);
AKSL_RUN(failures, test_version_check_refuses_a_mismatch);
AKSL_RUN(failures, test_version_check_ignores_the_patch_level);
AKSL_RUN(failures, test_mismatch_message_names_both_versions);
AKSL_RUN(failures, test_rejects_null_arguments);
AKSL_REPORT(failures);
}