Fix the six confirmed defects and close the API contract gaps

TODO.md section 2.1 recorded six defects reproduced against the built
library, and section 2.2 seventeen contract gaps. Both are closed. The
four tests registered in AKSL_KNOWN_FAILING_TESTS are folded back into
the tests for the things they test, and that list is now empty.

The defects:

  2.1.1  aksl_list_append conflated Floyd cycle detection with finding
         the tail, so `tail` tracked the node behind the midpoint. Any
         append to a list of 2+ nodes silently dropped everything after
         it. Two separate walks now: Floyd to prove the list is finite,
         then a plain walk to the end.
  2.1.2  aksl_list_iterate started visiting from Floyd's `slow` cursor,
         so the whole first half of the list -- head included -- was
         never passed to the callback. It starts at the head.
  2.1.3  AKERR_ITERATOR_BREAK did not stop a tree traversal: the frame
         that raised it handled it and returned success, so the parent
         carried on into the sibling subtree. The recursion is split out
         and propagates the break; only the public entry swallows it.
  2.1.4  va_end now matches every va_start on every path.
  2.1.5  The ato* family had no error channel at all. Reimplemented over
         a new strto* family with errno cleared, an endptr check and a
         range check: AKERR_VALUE for junk, ERANGE for overflow.
  2.1.6  aksl_realpath never checked resolved_path, could not be told
         the buffer size, and formatted an unspecified buffer with %s on
         its own error path. It takes a length; aksl_realpath_alloc is
         the allocating form.

The contract gaps, in brief: errno is cleared before every wrapped call
and read back through a fallback so no error can carry status 0; fopen
validates pathname and mode; fread/fwrite report the transferred count
through a required out-param and no longer call a short transfer a
success; aksl_sprintf is gone in favour of aksl_snprintf, which treats
truncation as an error; the variadic wrappers carry format attributes;
djb2 reads bytes as unsigned; tree traversal is depth- and cycle-bounded
and implements BFS, so lalloc/lfree are used rather than merely stored;
an unknown searchmode is AKERR_VALUE rather than silent success;
list_pop takes the head by reference; aksl_freep, the node initialisers
and extern "C" are new.

Build: -pg is out of the default build (it never reached the C compiler
anyway, and it is what produced the stray gmon.out), -Wall -Wextra are
in, and there is a .gitignore.

Tests: 11 binaries, all green under the normal and sanitizer builds.
Visit-order assertions replace the step counts that could not tell the
three depth-first orders apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 07:14:11 -04:00
parent fd71bcc67b
commit 55eb0334c4
17 changed files with 3272 additions and 730 deletions

View File

@@ -28,9 +28,42 @@ configure_file(
@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")
# This used to be three lines setting CMAKE_CXX_FLAGS and the linker flags to
# "-g -ggdb -pg". In a LANGUAGES C project the CXX variable reaches no compiler
# at all, so the debug flags never applied -- but -pg did reach the linkers, and
# every test binary that ran dropped a gmon.out in the working directory. Nobody
# was reading those profiles. CMAKE_BUILD_TYPE controls debug info now, which is
# what it is for; add -pg deliberately when you actually want to profile.
#
# Debug is the default for a bare `cmake -S . -B build` because this is a
# library under active development and a stripped -O3 build is not what anyone
# configuring it without an opinion is asking for.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE)
message(STATUS "CMAKE_BUILD_TYPE was unset; defaulting to Debug")
endif()
# Warnings. The only warning in the tree when these went on was the unused
# `queue` parameter of aksl_tree_iterate, which TODO.md 2.2.8 had already
# recorded as a dead parameter -- so the cost of turning them on was one
# already-known defect, and the cost of leaving them off was every future one.
#
# -Wpedantic is deliberately not here. libakerror's FAIL_* macros take a message
# plus varargs, and a call with a bare message and no arguments trips "ISO C99
# requires at least one argument for the ...", which is a complaint about the
# macro's shape rather than about anything at this call site. Every FAIL_* in
# src/stdlib.c passes at least one argument regardless -- see the note in
# TODO.md 2.3 -- so the code is pedantic-clean; it is the expansion that is not.
if(CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$")
add_compile_options(-Wall -Wextra)
# CI turns this on. Locally it is off, because a warning that stops the build
# while you are in the middle of something is a good way to teach people to
# turn warnings off.
option(AKSL_WERROR "Treat compiler warnings as errors" OFF)
if(AKSL_WERROR)
add_compile_options(-Werror)
endif()
endif()
# Sanitizer build, off by default:
# cmake -S . -B build-asan -DAKSL_SANITIZE=ON && ctest --test-dir build-asan
@@ -263,6 +296,7 @@ set(AKSL_TESTS
status_registry
stream
strhash
strto
tree
version
)
@@ -270,11 +304,13 @@ set(AKSL_TESTS
set(AKSL_WILL_FAIL_TESTS
)
# Empty, and that is the news. It held four entries -- convert_strict,
# list_append_chain, list_iterate_head and tree_iterate_break -- one for each
# confirmed defect in TODO.md 2.1. All four are fixed, so each of those files
# was folded back into the test for the thing it was testing (tests/
# test_convert.c, tests/test_linkedlist.c and tests/test_tree.c) where it now
# has to keep passing rather than merely keep failing visibly.
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)
@@ -291,6 +327,18 @@ foreach(_test IN LISTS AKSL_TESTS AKSL_WILL_FAIL_TESTS AKSL_KNOWN_FAILING_TESTS)
list(APPEND AKSL_TEST_TARGETS test_${_test})
endforeach()
# tests/test_memory.c asks for SIZE_MAX/2 bytes to check that a refused
# allocation reports ENOMEM and leaves *dst NULL. Plain malloc returns NULL and
# sets ENOMEM, which is the case under test; ASan's allocator instead treats a
# request that large as a bug in the caller and aborts the process before malloc
# ever returns. allocator_may_return_null=1 restores the libc behaviour for this
# one binary, so the sanitizer build tests the same contract as the normal one
# rather than skipping it.
if(AKSL_SANITIZE AND "memory" IN_LIST AKSL_TESTS)
set_tests_properties(memory PROPERTIES
ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1")
endif()
if(AKSL_WILL_FAIL_TESTS OR AKSL_KNOWN_FAILING_TESTS)
set_tests_properties(
${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}