# Maintaining akbasic `README.md` is for people evaluating the interpreter. `docs/` is for people writing BASIC in it. This file is for whoever has to change the thing: what the project is trying to be, how the build fits together, and the conventions — the ones enforced by a test, and the ones enforced by nothing at all and therefore written down here. `CLAUDE.md` is for agents, and it is deliberately thin: it points here. Everything an agent is told about this project is in this file, so there is one copy to keep true. --- ## What this project is `akbasic` is a **C rewrite of the Go BASIC interpreter in `deps/basicinterpret`**, written in the idiom of the `ak*` C libraries it builds on. | Submodule | Language | Role | |---|---|---| | `deps/basicinterpret` | Go | The implementation that was rewritten; the behavioural spec for questions about semantics | | `deps/libakerror` | C | TRY/CATCH-style error contexts — the substrate every other `ak*` library is built on | | `deps/libakstdlib` | C | libc and data-structure wrappers that report through `libakerror` | | `deps/libakgl` | C | SDL3-based game/graphics library (sprites, text, tilemaps, actors, heap) — supplies all multimedia | ### The goals, in priority order 1. **Port Go → C.** Reproduce the reference interpreter in C: `akerr_ErrorContext *` returns everywhere, fixed-size pools instead of dynamic allocation, library-prefixed symbols, one-file CTest executables. The Go code is already written against static pools and explicit state structs, so it ports fairly directly — resist "improving" it into `malloc`-based data structures. SDL2/SDL2_ttf calls in the Go version become `libakgl` calls (`akgl_text_*`, the renderer, the registry), not raw SDL3 calls. 2. **Finish the language.** The full Dartmouth BASIC and Commodore 128 BASIC 7.0 verb and function set. `deps/basicinterpret/README.md` ends with the original's list of what was unimplemented, and that list was the work queue. What remains is in `TODO.md` and is summarised for a BASIC programmer in `docs/13-differences.md`. A few entries are deliberately out of scope on a modern PC — `BANK`, `FAST`, `MONITOR`, and `SPRDEF`, which is an interactive editor rather than a programmable verb. Keep that reasoning rather than reviving them. 3. **Be embeddable.** The end state is the interpreter linking *into* `libakgl` as a scripting engine for game authors. That constrains the design from the start: - The interpreter is a library target with a thin `src/main.c` driver on top. The REPL, `QUIT` and argv handling belong to the driver, not the library. - Interpreter state lives in explicit structs passed by pointer. No file-scope mutable globals. - **Nothing in the library may terminate the process.** Errors propagate out as `akerr_ErrorContext *` for the host game to handle; `FINISH_NORETURN` belongs only in a `main()`. - The interpreter owns no window, renderer or game loop. It draws through whatever `akgl` renderer the host already initialized, and a host must be able to bound execution rather than surrender control to a `run()` that never returns. ### Missing capabilities get filed upstream, not worked around **When `libakgl` — or `libakstdlib` — cannot supply something a verb needs, do not work around it here.** Add a numbered item to that repository's `TODO.md` describing the missing API: what the BASIC verb requires, what the `akgl_*` or `aksl_*` entry point should look like, and what tests would cover it. Follow the prose-paragraph style of the entries already there. Growing the dependency to serve the interpreter is a wanted outcome, not a detour. It works. Four gaps were filed this way — text measurement, immediate-mode drawing, audio, and a non-blocking keystroke read — and all four landed upstream as `akgl_text_measure`, the `akgl_draw_*` family, `akgl_audio_*` and `akgl_controller_poll_key`. `FILTER` is the one verb still blocked on a gap, and `DIRECTORY` is refused pending an `opendir`/`readdir` wrapper in `libakstdlib`. Both refuse at execution and say so, rather than being silently ignored: a program that asks for a low-pass filter and gets an unfiltered square wave has been lied to. ### The Go reference `deps/basicinterpret` is a Commodore BASIC 7.0 / Dartmouth BASIC dialect in Go with SDL2 + SDL2_ttf. It is the behavioural spec: when a question about semantics comes up — "what does `READ` do when it runs off the end of `DATA`?" — the answer lives in that code. **That project is deprecated and this interpreter is no longer required to match it.** `TODO.md` §0.1 retires the byte-for-byte fidelity constraint that several sections of that file were originally written on. The corpus stays green as a *regression* suite; the Go source stays readable as documentation of what the original did. Neither is binding. **It is not a build or test dependency.** Both configurations have been configured, built and run from scratch with it moved out of the tree. Its acceptance corpus is checked in at `tests/reference/` and its Commodore font at `assets/fonts/`. ```sh norun cd deps/basicinterpret make # builds ./basic (CGO_ENABLED=1, needs SDL2 + SDL2_ttf dev packages) ./basic # interactive REPL ./basic tests/language/functions.bas make tests # or: bash ./test.sh ``` #### Its architecture, and the two decisions that shaped ours Scanner → parser → tree-walking runtime, one source line at a time: - `basicscanner.go` — tokenizes a line into `BasicToken`s. Verbs and function names are case-insensitive; variable names are case-sensitive. - `basicparser.go` / `basicparser_commands.go` — recursive-descent parser producing `BasicASTLeaf` nodes; `basicgrammar.go` defines the leaf types. Commands get their own parse paths. - `basicruntime.go` — owns the source (`[MAX_SOURCE_LINES]BasicSourceLine`), the variable pool, the scanner/parser, the environment stack and the SDL window. `commandByReflection()` dispatches a verb to a method by name, so adding a verb there was mostly a matter of adding a correctly-named method. **We use a sorted dispatch table instead** — see below. - `basicenvironment.go` — a scope *and* the per-line working state: variables, functions, labels, plus the in-flight state of `IF`/`FOR`/`GOSUB`/`READ`. Environments chain via `parent`. - `basicvalue.go` / `basicvariable.go` — the strongly-typed value and the named slot. Type is carried by the identifier suffix: `#` integer, `%` float, `$` string. - `basicruntime_graphics.go` — output goes through an SDL text surface with a cursor, wrapped text, scrolling and a print buffer. `Write()` and `Println()` **mirror every line to stdout**, and that mirror is the only reason a golden-file suite is possible at all. Our `akbasic_TextSink`, and `akbasic_sink_init_tee()` in particular, is the equivalent. 1. **Everything is fixed-size and statically allocated.** `main.go` set the budget: `MAX_LEAVES`/`MAX_TOKENS` 32 per environment, `MAX_VALUES` 64, `MAX_VARIABLES` 128, `MAX_SOURCE_LINES` 9999, `MAX_LINE_LENGTH` 256, `MAX_ARRAY_DEPTH` 64. The 32-leaf ceiling is why a line is limited to roughly 16 operations. Ours are in `include/akbasic/types.h` and are the same numbers plus three the Go version did not need because it called `make()`. 2. **`waitingForCommand` drives block structure.** Rather than building nested blocks in the AST, the environment records a verb it is skipping forward to (`NEXT`, `RETURN`, …) and suppresses execution of intervening lines until it sees it. Loops and branches evaluate their condition at the *bottom* of the structure. We reproduce this, and it is the reason a whole `FOR ... NEXT` written on one line does not loop — block skipping walks source *lines*. Runtime modes are `MODE_REPL`, `MODE_RUN`, `MODE_RUNSTREAM` (piped input) and `MODE_QUIT`; `BASIC_TRUE` is `-1` and `BASIC_FALSE` is `0`, per Commodore convention. All of that carried over. --- ## Building ```sh norun git submodule update --init --recursive cmake -S . -B build cmake --build build --parallel ctest --test-dir build --output-on-failure ``` ### Build everything from the submodules, in one tree `libakgl` vendors its *own* copies of `libakerror` and `libakstdlib` (plus SDL3, SDL3_image, SDL3_mixer, SDL3_ttf, jansson and semver) under `deps/libakgl/deps/`. Its `CMakeLists.txt` guards every dependency with `if(NOT TARGET ...)`, so a top-level build must define `akerror::akerror` and `akstdlib::akstdlib` from `deps/libakerror` and `deps/libakstdlib` **before** `add_subdirectory(deps/libakgl)`, or the targets are declared twice. That order is load-bearing for a second reason: `deps/libakerror` is at **1.0.0**, a source and ABI break carrying an soname (`libakerror.so.1`). `libakstdlib` and `libakgl` must be compiled against that header, not a pre-1.0.0 one, and an installed `libakerror.so.0` must not be picked up. ### Dependency versions and what they promise | Submodule | Version | soname | ABI rule | Version API | |---|---|---|---|---| | `deps/libakerror` | 1.0.0 | `libakerror.so.1` | major only | **none** — no version macro, no `ConfigVersion` file | | `deps/libakstdlib` | 0.2.0 | `libakstdlib.so.0.2` | **`MAJOR.MINOR` while major is 0** | `AKSL_VERSION_*`, `aksl_version()`, `AKSL_VERSION_CHECK()` | | `deps/libakgl` | 0.4.0 | `libakgl.so.0.4` | **`MAJOR.MINOR` while major is 0** | `AKGL_VERSION*`, `akgl_version()`, `AKGL_VERSION_AT_LEAST()` | For both 0.x libraries the soname carries `MAJOR.MINOR` deliberately: 0.1 and 0.2 are *different* ABIs, and both become major-only at 1.0. Do not read `0.1 → 0.2` as a compatible bump — both libraries have actually made that jump, so anything built against the 0.1 headers must be rebuilt rather than relinked. **`libakstdlib` and `libakgl` move together and have to.** `libakgl` 0.2.0 consumes the `libakstdlib` 0.2.0 API, so a tree pinning `libakstdlib` at 0.1.0 while adding `libakgl` by `add_subdirectory()` compiles 0.2.0-era code against 0.1.0 headers and fails on `aksl_fwrite`, `aksl_fread` and `aksl_realpath`. Bump them as a pair. `akstdlib.h` pulls in ``, which CMake **generates into the build tree** and installs beside `akstdlib.h` — so link `akstdlib::akstdlib` and let the target carry its include directories. Hand-adding `deps/libakstdlib/include` to an include path gets you a missing-header error. It records what the caller was *compiled* against, and `aksl_version()` reports what actually *loaded*. `AKSL_VERSION_CHECK()` compares them and raises `AKERR_VALUE` naming both. **Call it once during initialization**: the soname normally catches a mismatch at load time, but a 0.2.0 dropped in under the 0.1 filename loads happily and only the check notices. It is a macro on purpose — it must expand at your call site to capture your numbers. `libakgl` followed the same pattern. `project(akgl VERSION ...)` is the single source and drives the generated `include/akgl/version.h`, which is `.gitignore`d because `include/` precedes the build tree on the include path and a stray copy there would shadow the generated one and pin every consumer. It publishes `AKGL_VERSION_AT_LEAST(major, minor, patch)` — the compile-time test `libakstdlib` could not write against `libakerror` — and `akgl_version()`. **Version-pinning in `find_package` is asymmetric, and that is deliberate.** `find_package(akstdlib 0.1)` and `find_package(akgl 0.1)` both work; each ships a `ConfigVersion.cmake` at `SameMinorVersion`, mirroring its soname. `find_package(akerror 1.0)` **fails against a correct install**, because `libakerror` ships `akerrorConfig.cmake` and `akerrorTargets.cmake` but no `akerrorConfigVersion.cmake`. Ask for `akerror` unversioned. Its floor is enforced instead by an `#error` feature-testing `AKERR_FIRST_CONSUMER_STATUS`, which `akstdlib.h`, `akgl/error.h` and our own `include/akbasic/error.h` all carry — include any of them and you inherit the guard. The missing version file is filed in `deps/libakstdlib/TODO.md` §2.3; when it lands, add the `1.0` floor to the `find_dependency` calls. ### Embedding all three dependencies collides four ways All three libraries register their CTest tests unconditionally and add coverage and mutation targets. A top-level build that `add_subdirectory`s them walks into four separate collisions. The handling is at the top of `CMakeLists.txt`, and its comments carry the same detail. **1. Duplicate CTest entries.** Every dependency's tests register into *our* suite. Under `EXCLUDE_FROM_ALL` their binaries are never built, so each lands as "Not Run" and fails. CMake offers no way to un-register a test, and `set_tests_properties` cannot reach across directory scopes. We shadow `add_test()` and `set_tests_properties()` for the duration of the `add_subdirectory()` calls. `libakstdlib` carries the same shadow but only arms it when *it* is top-level, so it does nothing for us — ours has to wrap all three. **Only one project in a tree may shadow `add_test()`, and this is that project.** CMake exposes an overridden command as `_name` and chains exactly one level: a second override rebinds `_add_test` to the first override and the builtin becomes unreachable to everyone, so our own registrations recurse until CMake stops at *"Maximum recursion depth of 1000 exceeded"*. `libakgl` 0.3.0 briefly shadowed it unconditionally and that is exactly what happened. **2. Duplicate test *target* names.** `add_subdirectory` creates a dependency's targets even under `EXCLUDE_FROM_ALL`. When `libakstdlib` added `tests/test_version.c` it collided with `libakgl`'s and stopped the configure dead: add_executable cannot create target "test_version" because another target with the same name already exists. `libakgl` fixed its side by building programs as `akgl_test_` while keeping the bare CTest names. `libakstdlib` still uses bare `test_` targets. **Name every test target in this repo `akbasic_test_`** — it costs nothing and it is the collision that actually stopped a build. **3. Duplicate custom targets.** `libakerror` namespaces its `mutation` target when embedded but **not** its `coverage` target, so any coverage-enabled top-level build fails with *"another target with the same name already exists"*. We shadow `add_custom_target` and rename that one to `akerror_coverage` on the way past. `libakstdlib` (both targets) and `libakgl` (its `mutation` target) namespace themselves correctly. **The real fix is upstream in `libakerror`** — the same `CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR` test it already applies to `mutation` — and it is filed in `deps/libakstdlib/TODO.md` §2.3. Delete the workaround when it lands. **4. Stale build trees poison the coverage report.** See below; it is the reason for `cmake -S . -B build`. ### Build trees stay out of the source directory `gcovr` searches for `.gcda`/`.gcno` under its `--root`, and the dependencies set that to their *source* directory — so a leftover instrumented build tree in the source dir is folded into the report, and `--object-directory` does not narrow the search. A leftover `build-coverage/` makes a freshly configured run fail in `coverage_reset`, before any test executes, with `Got function ... on multiple lines`. Because `build*/` is `.gitignore`d, the state is easy to reach and hard to see. Always `cmake -S . -B build`. ### Optional tooling, and what each one buys Everything builds and tests with cmake and a C compiler. Three tools are optional, and each buys a specific test rather than general convenience: | Tool | Buys | Without it | |---|---|---| | `xdotool` + `script`(1) | `akgl_typing` — types at a real focused SDL window with a real keyboard | The test reports **Skipped**. Nothing fails | | `gcovr` | the `coverage` target and its 90%-of-lines gate | `-DAKBASIC_COVERAGE=ON` will not configure | | `python3` | `scripts/mutation_test.py` and the `mutation` target | The target is not created | **`akgl_typing` is worth installing `xdotool` for.** It is the only test that exercises the path a person actually uses — X11 delivers a key press to a focused window, SDL composes it, libakgl rings it, the editor echoes it, the interpreter runs it. Every other keyboard test synthesises SDL events, which tests the code *downstream* of SDL and cannot test the code upstream of it. That gap shipped a bug: the frontend never called `SDL_StartTextInput()`, so SDL emitted no text-input events at all, the line editor dropped every keystroke, and the whole suite stayed green because it was pushing those events itself. It needs a real X server and **it steals keyboard focus for about fifteen seconds**. Set `AKBASIC_SKIP_INTERACTIVE=1` to skip it while you are using the machine. It skips itself when there is no display, no `xdotool`, or no window manager answering — CTest reports `Skipped` rather than a failure, because none of those means the answer is no. ### The two workflows **`.gitea/workflows/ci.yaml`** runs on every push: the suite, ASan+UBSan, coverage gated at 90% of lines, and mutation testing over two files. The coverage report is uploaded as a `code-coverage` artifact. **`.gitea/workflows/release.yaml`** is manual (`workflow_dispatch`) and is what a release runs. It builds the API documentation and uploads it as `api-documentation`, and it mutates the *whole* `src/` tree — 3675 mutants, hours of runner time, which is why it is not on the push path. It takes two optional inputs: a mutation threshold, and a space-separated file list to narrow the run. ### API documentation is a gate, not a convenience ```sh norun doxygen Doxyfile ``` The `Doxyfile` is configured the way `libakgl`'s is, including `WARN_AS_ERROR = FAIL_ON_WARNINGS` — a doc block that documents some of a function's parameters but not all of them fails the run. Every public declaration under `include/akbasic/` carries a `@brief`, a `@param` per parameter, a `@return` and its `@throws`. Keep it that way when you add one. --- ## Tests ### Three lists, and two of them invert "passed" `CMakeLists.txt` declares `AKBASIC_TESTS`, `AKBASIC_WILL_FAIL_TESTS` and `AKBASIC_KNOWN_FAILING_TESTS`. The first must exit 0. The second aborts by design. The third **asserts the correct contract for a defect that is documented in `TODO.md`** and is expected to fail. A green `ctest` therefore does not mean defect-free. When a known-failing test starts passing, CTest reports "unexpectedly passed" — that is the cue to move it into `AKBASIC_TESTS` along with the fix, not to delete it. `AKBASIC_WILL_FAIL_TESTS` is currently empty and is kept declared anyway, so the shape is there when it is next needed. The dependencies use the same split under their own prefixes: `AKSL_TESTS` / `AKSL_WILL_FAIL_TESTS` / `AKSL_KNOWN_FAILING_TESTS`, and `AKERR_TESTS` / `AKERR_WILL_FAIL_TESTS`. `libakstdlib` 0.2.0 fixed all six defects its `TODO.md` §2.1 listed and left `AKSL_KNOWN_FAILING_TESTS` empty. ### Test target names Every test program builds as `akbasic_test_` while registering under the bare CTest name. That is not cosmetic: `add_executable` creates a dependency's targets even under `EXCLUDE_FROM_ALL`, and when `libakstdlib` added a `test_version` it collided with `libakgl`'s and stopped the configure dead. ### The golden corpora `tests/reference/` is the Go implementation's own acceptance suite, byte-compared. **Nothing in it is ever edited to suit this interpreter.** If a case fails, either this interpreter is wrong or the divergence is deliberate — and a deliberate one goes in `TODO.md` and `docs/13-differences.md`, not into the expectation file. `tests/reference/README.md` says the same thing at more length. `tests/language/` is ours and may be changed freely. A new language feature needs a `.bas`/`.txt` pair there as well as unit tests. ### Mutation-check a fix before you believe it Coverage says a line ran; it does not say anything would have noticed if it were wrong. That matters more here than usual, because the `akerror` macros expand at their call sites and `gcov` attributes them to the caller. The discipline for any fix with a test: **revert the fix, confirm the test fails, restore it.** Use a file copy, not `git checkout` — `git checkout -- src` in a loop like this has already wiped a session's worth of unrelated edits. ```sh norun cmake --build build --target mutation # whole tree; hours python3 scripts/mutation_test.py --target src/value.c --threshold 70 ``` Mutation testing earns its keep. Writing this suite it found that nothing exercised a maximum-length string or symbol-table key, so every `MAX - 1` off-by-one in a `strncpy` would have gone unnoticed; and that `errno` was never asserted to be cleared before a `strtoll`, which is what stops a stale `ERANGE` from failing a perfectly valid conversion. ### The dependencies' own harnesses Option prefixes differ per repository, which is the thing that catches people out: ```sh norun cmake -S . -B build-asan -DAKSL_SANITIZE=ON # libakstdlib cmake -S . -B build-coverage -DAKSL_COVERAGE=ON # libakstdlib cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug # libakgl cmake --build build --target coverage cmake --build build --target mutation ``` `deps/libakerror/test.sh` shows the full CI gate for that library: ctest, mutation ≥ 65, and coverage ≥ 90 line / ≥ 50 branch. The branch gate in *this* repository was re-ratcheted 45 → 40 after the `libakerror` 1.0.0 bump, because the new `PREPARE_ERROR`/`FAIL_*` macros expand to more branches per call site — a denominator change, not a regression. `rebuild.sh` in `libakgl` and `libakstdlib` installs into the developer-specific prefix `/home/andrew/local` and deletes build directories. Use the portable commands above unless that exact behaviour is what you want. --- ## Code ### The libakerror error convention Mixing conventions breaks error propagation, so new C code follows this one — it is what `libakstdlib` and `libakgl` both do. - Any function that can fail returns `akerr_ErrorContext AKERR_NOIGNORE *` and opens with `PREPARE_ERROR(e);`. - Return with `SUCCEED_RETURN(e)` / `FAIL_RETURN(...)`, never a bare status code. - Propagate with `PASS(e, some_call());` when there is nothing local to do. - Handle locally with `ATTEMPT { CATCH(e, call()); } CLEANUP { } PROCESS(e) { } HANDLE(e, AKERR_X) { } FINISH(e, true);` — `FINISH(e, true)` re-raises unhandled errors to the caller. - **`CATCH` and the `FAIL_*_BREAK` macros expand to a C `break`**, so they must never appear inside a loop or a nested `switch` within an `ATTEMPT`: the `break` would escape only the loop, and the rest of the `ATTEMPT` would run with an error pending. Inside a loop use `PASS` or `FAIL_*_RETURN`, or move the loop into its own `akerr_ErrorContext *` helper and `CATCH` that single call. - Never use a `*_RETURN` macro inside an `ATTEMPT` block. It returns past `CLEANUP`, so every release, `fclose` and free is skipped. - `deps/libakerror/AGENTS.md` is the authoritative statement of the protocol, and `deps/libakerror/UPGRADING.md` is required reading before writing an error code. ### Error codes `akbasic` owns **512–767** under the owner string `"akbasic"`, claimed by `akbasic_error_register()` — which `akbasic_runtime_init()` calls, so a host gets it for free. Codes are an `enum` in `include/akbasic/error.h` so they stay compile-time integer constants; `HANDLE`'s `case` labels require that. **Add a code and you must name it in `akbasic_error_register()`**, or it prints as "Unknown Error" in every stack trace that carries it. #### The coordinated range map `libakerror` cannot enumerate its consumers, so this table is the coordination. Keep it current, and reserve the whole 256 even where fewer codes are used, so a later addition does not need a second reservation: | Owner string | Range | Status | |---|---|---| | `"libakerror"` | 0 – 255 | reserved by `akerr_init()`; do not touch | | *(none)* | — | `libakstdlib` deliberately reserves nothing and defines no codes of its own — it raises `AKERR_*` and propagates `errno`, both inside the reserved band. Its `tests/test_status_registry.c` pins that as a contract, so nobody has to coordinate with it | | `"libakgl"` | 256 – 260 | reserved by `akgl_error_init()`; `AKGL_ERR_BASE` … `AKGL_ERR_LIMIT - 1`, five codes | | *(free)* | 261 – 511 | headroom for `libakgl` to grow into; do not claim it | | `"akbasic"` | 512 – 767 | ours; `AKBASIC_ERR_BASE` is 512 | Values 0–255 are the host errno space plus the `AKERR_*` codes. Consumers allocate from `AKERR_FIRST_CONSUMER_STATUS` (256) upward, as **absolute integer constants** — never as offsets from `AKERR_LAST_ERRNO_VALUE`, so a libc that grows an errno cannot move them. Every library that can coexist in one process reserves its range in its own initializer: ```c norun akerr_ErrorContext AKERR_NOIGNORE *akerr_reserve_status_range(int first_status, int count, const char *owner); akerr_ErrorContext AKERR_NOIGNORE *akerr_register_status_name(const char *owner, int status, const char *name); ``` Both are `AKERR_NOIGNORE`, so a collision is an ordinary error to `CATCH`, `HANDLE` or `PASS` out of an init function. Ownership is **enforced**: naming a status in someone else's range fails with `AKERR_STATUS_NAME_FOREIGN`, and naming one nobody reserved fails with `AKERR_STATUS_NAME_UNRESERVED`. You do not need to call `akerr_init()` first — every registry entry point calls it for you, and it does not clear reservations made before it ran. Repeating an identical reservation is a no-op; a subset or superset of your own range is not, so reserve the whole thing in one call. `akgl_error_init()` has an ordering requirement worth knowing before wiring up a `main()`: it must run before anything else in `libakgl`, because a code raised before it registers carries no name into its stack trace. `akgl_game_init()` calls it as its first statement, but a program driving subsystems directly — which is what the embedded interpreter does — has to call it itself. It is idempotent. #### What 1.0.0 removed, and what it still does not solve These no longer exist. Any code, compile definition or documentation referring to them is stale: | Removed | Replacement | |---|---| | `AKERR_MAX_ERR_VALUE` | nothing — the name registry is sparse and takes any `int` | | `__AKERR_ERROR_NAMES` | `akerr_name_for_status()`; the table is private to the library now | | `AKERR_STATUS_RANGE_OK` / `AKERR_STATUS_NAME_OK` | success is a `NULL` `akerr_ErrorContext *`, like everything else | `libakerror`'s own `TODO.md` §2 says the limit plainly: ownership enforcement covers *naming*, which is the part the library mediates. It cannot detect two components compiling the same integer into a `HANDLE` `case` label without ever registering a name — that never reaches the registry. §3 adds that there is no way to ask who owns a status or to enumerate reservations, so the table above is the only tooling there is. Two rules follow: - **Always reserve, even for codes you never name.** Reservation is the only thing that makes a collision visible at all. - **Never define an error code as an offset from another library's symbol.** Capacity is not a concern: the name registry holds 3072 entries and `akerr_init()` consumes about 150; reservations cap at 64 ranges. Both are `PRIVATE` to the `libakerror` target, so raising them is a `libakerror` configure-time decision, not something `akbasic` sets. Registration is **not thread safe** — do it during single-threaded init, before the host game spawns anything. ### Nothing calls malloc `libakgl`'s hard rule, and ours: obtain objects from `akgl_heap_next_*` and release them back, and if no heap layer exists for a needed type, add one rather than calling `malloc`. On our side of the line every object comes from a fixed pool inside `akbasic_Runtime`, sized by the constants in `include/akbasic/types.h`. Exhausting a pool is a diagnosable error, not a crash and not a slow leak. ### The verb table is sorted, and a test says so `src/verbs.c` is searched with `bsearch`. A mis-sorted table does not fail to compile — it silently fails to find a verb, and the symptom is `Unknown command PRINT` a long way from the cause. `tests/verbs_table.c` asserts the ordering; adding a verb in the wrong place fails there. A builtin's name, arity and handler are one row in that table. Nothing is bootstrapped by running a BASIC program of `DEF` statements at startup, the way the Go version did, so the interpreter no longer has to be running before the interpreter is ready. ### Nothing in the library terminates the process Goal 3. `FINISH_NORETURN` appears only in a `main()` — today the driver's and the examples'. A *script's* error is reported through the sink and swallowed; an *interpreter* error propagates out as `akerr_ErrorContext *` for the host. Both halves of that need saying, because the second one is easy to get right and the first one is easy to get wrong: `process_line_run()` swallowed its context correctly from the start while the direct-mode branch of `process_line_repl()` used a bare `PASS`, so a `VERIFY` against a file that did not match — an ordinary user answer — tore down the driver with a stack trace. ### Generated files Never hand-edit `build/` trees, the generated `akerror.h`, `akgl.pc`, or `include/akgl/SDL_GameControllerDB.h`. Change the template or the generator. ### Style C99, four-space indent, braces on their own line for function bodies, spaces inside control-flow parentheses. **Match the surrounding file** — several mix tabs and spaces and there is no repo-wide formatter. Do not reformat code you are not otherwise changing; style conversions get their own commit. Public symbols take the `akbasic_` prefix, `akbasic_TypeName` for types, `AKBASIC_` for macros. `static` helpers drop it. Headers and sources are paired by feature — `src/scanner.c` and `include/akbasic/scanner.h`. ### Commits Short, imperative, sentence-case subjects describing observable behaviour: *"Fix refcount leak and stack-trace buffer overflow"*. Keep them focused, and include the tests with the behaviour change. `libakgl`'s `AGENTS.md` requires an agent to add itself — program, model and version — as a commit co-author, and this repository follows the same rule. Each dependency carries its own `AGENTS.md` with authoritative per-repo rules. Read the relevant one before editing a submodule. --- ## Editing the documentation **Every fenced block in `README.md` and `docs/*.md` is executed by `ctest`.** The test is `docs_examples`; it runs in both build configurations and it fails the build. It caught four wrong examples the day it was added — two transcripts showing a leading space `PRINT` does not emit, a `struct` in `README.md` that had grown two members hours earlier, and a `FILTER` refusal quoted with the wrong wording. An **untagged block is a failure**, not a default. That is deliberate: the way a harness like this dies is by quietly matching nothing and passing, so leaving a block untagged is a missing decision rather than a free pass. ### The tags | Info string | What happens | |---|---| | `basic` | Written to a file and run. Must exit 0 and print no `? line : CLASS` error | | `basic repl` | Fed to a fresh interpreter on **stdin**. The leading `READY` banner is dropped before comparison | | `basic norun` | Shown, not run. For fragments and for anything that loops forever | | `basic requires=akgl` | Run only in the `-DAKBASIC_WITH_AKGL=ON` build | | `basic requires=noakgl` | Run only in the default build. For verbs whose *refusal* is the example | | `basic setup=NAME` | Runs `tests/docs_setups/NAME.sh` in the sandbox first | | `output` | The **exact stdout** of the block above it, compared byte for byte | | `c` | Compiled with `-fsyntax-only -std=gnu99 -Wall -Wextra -Werror` against the real include path | | `c wrap=NAME` | The same, with `tests/docs_preludes/NAME.pre` before it and `NAME.post` after | | `c excerpt=PATH` | Must appear in `PATH`, ignoring comments and whitespace. Not compiled | | `c norun` | Shown, not compiled | | `sh` | Run in a sandbox with the built interpreter at `./build/basic`. Must exit 0. A leading `$ ` is stripped | | `sh setup=NAME` | The same, after `tests/docs_setups/NAME.sh` | | `sh norun` | Shown, not run | | `cmake` | **Never executed.** Hand-maintained, by decision | Attributes combine: `basic requires=akgl setup=ship` is a real tag in `docs/08-sprites.md`. ### Which one to reach for - **A program with visible output** — `basic` plus an `output` block. Preferred: it is the only shape that pins what the reader will actually see. - **A program with no output** — `basic` alone. The harness still asserts it parses, runs, and raises nothing. - **An interactive transcript** — `basic repl`, with the input in one block and the output in the next. A block that interleaves the two cannot be checked mechanically, and splitting it is not a loss: the piped interpreter prints `READY` once at startup rather than after each line, so an interleaved transcript is not literally true anyway. - **A verb that draws, plays or moves** — `basic requires=akgl`. These produce no stdout, so they get no `output` block; the assertion is that they do not refuse. - **A refusal message** — write the program that provokes it and put the message in an `output` block. Refusals carry a trailing blank line; the block has to have it too. - **`norun`** — for a fragment (`60 DATA 255, 129, ...`), an infinite loop, or a command that would reconfigure the tree the suite is running inside. Say why in the prose. ### `sh` blocks: what is not run, and why Fourteen of the sixteen shell blocks are `norun`, and the reason is the same for all of them: they operate on the build tree the test is running inside, or they need the network. Only `docs/02-getting-started.md` runs any, and it runs them in the sandbox. | Block | Why | |---|---| | `git submodule update --init --recursive` | Network, and the sandbox is not a repository | | `cmake -S . -B build` | Would reconfigure the live build tree mid-test | | `ctest --test-dir build` | Re-enters this suite | | `doxygen Doxyfile` | Slow, and writes into the build tree | | the `mutation` target | Hours | That leaves a real gap: a renamed CMake option in `README.md` would go unnoticed. It could be closed later with an `excerpt`-style check against `CMakeLists.txt`. It has not been. ### Preludes `tests/docs_preludes/NAME.pre` and `NAME.post` bracket a `c wrap=NAME` block. They exist so an example can be written the way a reader wants to read it — `PASS(e, akbasic_runtime_global(...))` and nothing else — while still compiling. What belongs in a prelude: - Symbols the example **invents** to stay readable: `your_clock_ms()`, `my_renderer`. - Scaffolding the macro protocol requires: a function body, a `PREPARE_ERROR`, an `ATTEMPT` for a block of `CATCH` calls. - Includes the surrounding prose already listed and the block does not repeat. What does **not** belong in a prelude is anything that would let a wrong example compile. A prelude declaring `akbasic_runtime_init` itself, for instance, would defeat the check. Compiler diagnostics point at the markdown: the harness emits a `#line` directive, so a broken example reports as `README.md:322: error: too few arguments`. ### Excerpts `c excerpt=include/akbasic/sink.h` says the block is a copy of something in that header and must still match it, comments and whitespace ignored. Use it where compiling the block would be wrong — echoing a `typedef` compiles only by redefining the type. This is the check that caught the stale `akbasic_TextSink`, and a compile check could not have. ### When `docs_examples` fails The message names the file and the line of the block. For an output mismatch it prints both sides through `cat -A`, because the errors it catches are trailing spaces and missing newlines, which a plain diff renders invisibly. **Fix the documentation, not the expectation** — unless the interpreter is what changed, in which case fix the interpreter first and the documentation second. Never edit an `output` block to match output you have not looked at. Run one document at a time while you work: ```sh norun ./tests/docs_examples.sh --root . --basic ./build/basic \ --cflags-file build/docs_cflags.txt docs/04-control-flow.md ``` The pass line reports **what it executed, by kind**. Read it. A harness that passes because it stopped matching anything looks exactly like a harness that passes because the documentation is correct, and the count is the only thing that tells them apart — it has already caught one such case, where a CMake generator expression evaluated to an empty argument that the script read as a filename.