Files
akbasic/MAINTENANCE.md

675 lines
38 KiB
Markdown
Raw Normal View History

Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
# Maintaining akbasic
Split the documentation by who reads it README.md was 577 lines and answered four different questions at once: what the project is, how to build it, every verb and function in the language, and how to maintain the test harness. The verb and function lists had already been written a second time in docs/11 and docs/12, which is how a list of that size goes stale -- there is no way to notice the two have drifted apart. README.md is now 150 lines and holds only what somebody evaluating the project needs: what it is, the quickstart, why it was rewritten in C, the five rules embedding imposes on the design, the two ways to use it, and where everything else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md. The akbasic_TextSink struct moved to docs/10-embedding.md rather than being deleted. It was the corpus's only `c excerpt=` block -- the check that caught the stale struct two commits ago -- so dropping it with the README would have quietly retired a test. docs/10 also stopped claiming README.md carries the full API surface and the pool limits, which the trim made false. CLAUDE.md went from 458 lines to 62, because almost none of it was agent-specific. The project goals, the Go reference and its architecture, the dependency version and ABI rules, the four ways an embedded build collides, the libakerror convention, the error-code range map and the style rules are all things a maintainer needs, and they are now in MAINTENANCE.md with one copy to keep true. CLAUDE.md points there and keeps only the rules no test enforces: tests in the same commit asserting the correct contract, file a missing dependency capability upstream, do not edit generated output or tests/reference/, co-author your commits. Four claims did not survive the move, having gone stale where nothing could notice: - "The repository is currently empty apart from its submodules -- no commits, no source tree, no build files." There are 43 commits. - libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE) at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is gone; only a historical mention in a comment remains. - "akbasic_init() claims 512-767." There is no akbasic_init. It is akbasic_error_register(), called from akbasic_runtime_init(). - Time-relative phrasing ("libakgl hit two of them in the last week"). Five places pointed at CLAUDE.md for the range map or the file-it-upstream rule and now point at MAINTENANCE.md: include/akbasic/error.h, src/runtime_disk.c and three entries in TODO.md. Both source changes are comments. deps/libakgl/TODO.md cites it too and is left alone; it is a submodule, and the rule it quotes is still reachable from CLAUDE.md. ctest is green at 95 of 95, docs_examples included: 36 programs, 9 transcripts, 44 output comparisons, 3 C snippets, 1 excerpt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
`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.
Document the interpreter's architecture as chapter fourteen Chapters 1 through 13 describe the language. Nothing described the machine that runs it, and the answers were spread across header comments, TODO.md sections written for a different purpose, and the source itself. Somebody embedding the interpreter, debugging something it did, or adding a verb had to reconstruct the shape from all three. docs/14-architecture.md is that shape, and only that: the three targets and the driver, the single akbasic_Runtime and why nothing is file-scope, akbasic_runtime_step() unrolled with the reason each stage sits where it does, the four modes and what set_mode() does beyond assigning, a line's journey from text through tokens and leaves to a verb handler, the dispatch table, the pool map with what each exhaustion actually says, environments doubling as block state, the two kinds of error, devices, and interrupts. It defers rather than restates. The headers are the authority on every function's contract and the chapter says so up front; where a rule is subtle the header comment already states it at more length than a chapter should. MAINTENANCE.md keeps the conventions and now points here for the mechanism, so there is still one copy of each. Two sections are the reason it exists at all. Debugging: reading a TRON trace as evidence about the loop rather than the lines, reading an akerror stack trace and what it is not, four breakpoints and the expressions worth printing at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause table. Changing it: the verb recipe end to end including the private src/verbs.h prototype that is easy to miss, the rule that a missing dependency capability gets filed upstream rather than worked around, and the five constraints goal 3 puts on any change. A `text` fence tag comes with it. Every fenced block in docs/ is executed and an untagged one is a hard error, so six block diagrams had nowhere to live. The tag means never executed, it is counted in the skip line like `cmake`, and MAINTENANCE.md documents it -- the alternative was an indented block the extractor never sees, and a picture nobody decided about is indistinguishable from a test nobody ran. tests/docs_examples.sh now makes --root and --basic absolute before it starts. Both are used from inside a sandbox directory it cd's into, so the invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic -- failed every example with "exited 127" and every setup= with "setup failed". CTest passes absolute paths and never saw it; running one document by hand hits it immediately. Writing the error section turned up a defect and TODO.md section 8 records it. The ATTEMPT blocks that turn a script's mistake into an error line wrap parsing and interpretation but not scanning, so a line with more than 32 tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt the REPL is gone. That is the same shape as section 8 item 2, on a path that fix did not cover. Not fixed here -- it is a behaviour change and wants its own tests -- but written down with the three call sites and what would cover them. Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37 programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and 2 shell blocks, and skips 9 text blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:14:17 -04:00
One thing is deliberately *not* here. **How the interpreter actually works — the step loop,
the four modes, the pools, the scopes that double as block state, the two kinds of error,
and how to debug all of it — is [`docs/14-architecture.md`](docs/14-architecture.md).** This
file is the conventions for changing the thing; that chapter is the thing.
Split the documentation by who reads it README.md was 577 lines and answered four different questions at once: what the project is, how to build it, every verb and function in the language, and how to maintain the test harness. The verb and function lists had already been written a second time in docs/11 and docs/12, which is how a list of that size goes stale -- there is no way to notice the two have drifted apart. README.md is now 150 lines and holds only what somebody evaluating the project needs: what it is, the quickstart, why it was rewritten in C, the five rules embedding imposes on the design, the two ways to use it, and where everything else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md. The akbasic_TextSink struct moved to docs/10-embedding.md rather than being deleted. It was the corpus's only `c excerpt=` block -- the check that caught the stale struct two commits ago -- so dropping it with the README would have quietly retired a test. docs/10 also stopped claiming README.md carries the full API surface and the pool limits, which the trim made false. CLAUDE.md went from 458 lines to 62, because almost none of it was agent-specific. The project goals, the Go reference and its architecture, the dependency version and ABI rules, the four ways an embedded build collides, the libakerror convention, the error-code range map and the style rules are all things a maintainer needs, and they are now in MAINTENANCE.md with one copy to keep true. CLAUDE.md points there and keeps only the rules no test enforces: tests in the same commit asserting the correct contract, file a missing dependency capability upstream, do not edit generated output or tests/reference/, co-author your commits. Four claims did not survive the move, having gone stale where nothing could notice: - "The repository is currently empty apart from its submodules -- no commits, no source tree, no build files." There are 43 commits. - libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE) at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is gone; only a historical mention in a comment remains. - "akbasic_init() claims 512-767." There is no akbasic_init. It is akbasic_error_register(), called from akbasic_runtime_init(). - Time-relative phrasing ("libakgl hit two of them in the last week"). Five places pointed at CLAUDE.md for the range map or the file-it-upstream rule and now point at MAINTENANCE.md: include/akbasic/error.h, src/runtime_disk.c and three entries in TODO.md. Both source changes are comments. deps/libakgl/TODO.md cites it too and is left alone; it is a submodule, and the rule it quotes is still reachable from CLAUDE.md. ctest is green at 95 of 95, docs_examples included: 36 programs, 9 transcripts, 44 output comparisons, 3 C snippets, 1 excerpt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
---
## 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 `<akstdlib_version.h>`, 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_<name>` while keeping the bare
CTest names. `libakstdlib` still uses bare `test_<name>` targets. **Name every test target in
this repo `akbasic_test_<name>`** — 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_<name>` 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 **512767** 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 0255 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.
Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
---
## 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 |
Document the interpreter's architecture as chapter fourteen Chapters 1 through 13 describe the language. Nothing described the machine that runs it, and the answers were spread across header comments, TODO.md sections written for a different purpose, and the source itself. Somebody embedding the interpreter, debugging something it did, or adding a verb had to reconstruct the shape from all three. docs/14-architecture.md is that shape, and only that: the three targets and the driver, the single akbasic_Runtime and why nothing is file-scope, akbasic_runtime_step() unrolled with the reason each stage sits where it does, the four modes and what set_mode() does beyond assigning, a line's journey from text through tokens and leaves to a verb handler, the dispatch table, the pool map with what each exhaustion actually says, environments doubling as block state, the two kinds of error, devices, and interrupts. It defers rather than restates. The headers are the authority on every function's contract and the chapter says so up front; where a rule is subtle the header comment already states it at more length than a chapter should. MAINTENANCE.md keeps the conventions and now points here for the mechanism, so there is still one copy of each. Two sections are the reason it exists at all. Debugging: reading a TRON trace as evidence about the loop rather than the lines, reading an akerror stack trace and what it is not, four breakpoints and the expressions worth printing at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause table. Changing it: the verb recipe end to end including the private src/verbs.h prototype that is easy to miss, the rule that a missing dependency capability gets filed upstream rather than worked around, and the five constraints goal 3 puts on any change. A `text` fence tag comes with it. Every fenced block in docs/ is executed and an untagged one is a hard error, so six block diagrams had nowhere to live. The tag means never executed, it is counted in the skip line like `cmake`, and MAINTENANCE.md documents it -- the alternative was an indented block the extractor never sees, and a picture nobody decided about is indistinguishable from a test nobody ran. tests/docs_examples.sh now makes --root and --basic absolute before it starts. Both are used from inside a sandbox directory it cd's into, so the invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic -- failed every example with "exited 127" and every setup= with "setup failed". CTest passes absolute paths and never saw it; running one document by hand hits it immediately. Writing the error section turned up a defect and TODO.md section 8 records it. The ATTEMPT blocks that turn a script's mistake into an error line wrap parsing and interpretation but not scanning, so a line with more than 32 tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt the REPL is gone. That is the same shape as section 8 item 2, on a path that fix did not cover. Not fixed here -- it is a behaviour change and wants its own tests -- but written down with the three call sites and what would cover them. Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37 programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and 2 shell blocks, and skips 9 text blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:14:17 -04:00
| `text` | **Never executed.** A block diagram, or a stack trace captured from a real run |
Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
Attributes combine: `basic requires=akgl setup=ship` is a real tag in `docs/08-sprites.md`.
Document the interpreter's architecture as chapter fourteen Chapters 1 through 13 describe the language. Nothing described the machine that runs it, and the answers were spread across header comments, TODO.md sections written for a different purpose, and the source itself. Somebody embedding the interpreter, debugging something it did, or adding a verb had to reconstruct the shape from all three. docs/14-architecture.md is that shape, and only that: the three targets and the driver, the single akbasic_Runtime and why nothing is file-scope, akbasic_runtime_step() unrolled with the reason each stage sits where it does, the four modes and what set_mode() does beyond assigning, a line's journey from text through tokens and leaves to a verb handler, the dispatch table, the pool map with what each exhaustion actually says, environments doubling as block state, the two kinds of error, devices, and interrupts. It defers rather than restates. The headers are the authority on every function's contract and the chapter says so up front; where a rule is subtle the header comment already states it at more length than a chapter should. MAINTENANCE.md keeps the conventions and now points here for the mechanism, so there is still one copy of each. Two sections are the reason it exists at all. Debugging: reading a TRON trace as evidence about the loop rather than the lines, reading an akerror stack trace and what it is not, four breakpoints and the expressions worth printing at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause table. Changing it: the verb recipe end to end including the private src/verbs.h prototype that is easy to miss, the rule that a missing dependency capability gets filed upstream rather than worked around, and the five constraints goal 3 puts on any change. A `text` fence tag comes with it. Every fenced block in docs/ is executed and an untagged one is a hard error, so six block diagrams had nowhere to live. The tag means never executed, it is counted in the skip line like `cmake`, and MAINTENANCE.md documents it -- the alternative was an indented block the extractor never sees, and a picture nobody decided about is indistinguishable from a test nobody ran. tests/docs_examples.sh now makes --root and --basic absolute before it starts. Both are used from inside a sandbox directory it cd's into, so the invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic -- failed every example with "exited 127" and every setup= with "setup failed". CTest passes absolute paths and never saw it; running one document by hand hits it immediately. Writing the error section turned up a defect and TODO.md section 8 records it. The ATTEMPT blocks that turn a script's mistake into an error line wrap parsing and interpretation but not scanning, so a line with more than 32 tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt the REPL is gone. That is the same shape as section 8 item 2, on a path that fix did not cover. Not fixed here -- it is a behaviour change and wants its own tests -- but written down with the three call sites and what would cover them. Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37 programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and 2 shell blocks, and skips 9 text blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:14:17 -04:00
`text` exists because `docs/14-architecture.md` needed block diagrams and an untagged block
is a hard error. It carries no executable claim, which is exactly why it has to be *said*:
the alternative was an indented code block that the extractor never sees, and a picture
nobody decided about is indistinguishable from a test nobody ran. Keep the diagrams inside
about 88 columns so they do not wrap on a forge.
Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
### 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.
Document the interpreter's architecture as chapter fourteen Chapters 1 through 13 describe the language. Nothing described the machine that runs it, and the answers were spread across header comments, TODO.md sections written for a different purpose, and the source itself. Somebody embedding the interpreter, debugging something it did, or adding a verb had to reconstruct the shape from all three. docs/14-architecture.md is that shape, and only that: the three targets and the driver, the single akbasic_Runtime and why nothing is file-scope, akbasic_runtime_step() unrolled with the reason each stage sits where it does, the four modes and what set_mode() does beyond assigning, a line's journey from text through tokens and leaves to a verb handler, the dispatch table, the pool map with what each exhaustion actually says, environments doubling as block state, the two kinds of error, devices, and interrupts. It defers rather than restates. The headers are the authority on every function's contract and the chapter says so up front; where a rule is subtle the header comment already states it at more length than a chapter should. MAINTENANCE.md keeps the conventions and now points here for the mechanism, so there is still one copy of each. Two sections are the reason it exists at all. Debugging: reading a TRON trace as evidence about the loop rather than the lines, reading an akerror stack trace and what it is not, four breakpoints and the expressions worth printing at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause table. Changing it: the verb recipe end to end including the private src/verbs.h prototype that is easy to miss, the rule that a missing dependency capability gets filed upstream rather than worked around, and the five constraints goal 3 puts on any change. A `text` fence tag comes with it. Every fenced block in docs/ is executed and an untagged one is a hard error, so six block diagrams had nowhere to live. The tag means never executed, it is counted in the skip line like `cmake`, and MAINTENANCE.md documents it -- the alternative was an indented block the extractor never sees, and a picture nobody decided about is indistinguishable from a test nobody ran. tests/docs_examples.sh now makes --root and --basic absolute before it starts. Both are used from inside a sandbox directory it cd's into, so the invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic -- failed every example with "exited 127" and every setup= with "setup failed". CTest passes absolute paths and never saw it; running one document by hand hits it immediately. Writing the error section turned up a defect and TODO.md section 8 records it. The ATTEMPT blocks that turn a script's mistake into an error line wrap parsing and interpretation but not scanning, so a line with more than 32 tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt the REPL is gone. That is the same shape as section 8 item 2, on a path that fix did not cover. Not fixed here -- it is a behaviour change and wants its own tests -- but written down with the three call sites and what would cover them. Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37 programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and 2 shell blocks, and skips 9 text blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:14:17 -04:00
- **A diagram or a captured trace** — `text`. Nothing is checked, so anything in one that
*could* be checked belongs in a block that is.
Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
### `sh` blocks: what is not run, and why
Split the documentation by who reads it README.md was 577 lines and answered four different questions at once: what the project is, how to build it, every verb and function in the language, and how to maintain the test harness. The verb and function lists had already been written a second time in docs/11 and docs/12, which is how a list of that size goes stale -- there is no way to notice the two have drifted apart. README.md is now 150 lines and holds only what somebody evaluating the project needs: what it is, the quickstart, why it was rewritten in C, the five rules embedding imposes on the design, the two ways to use it, and where everything else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md. The akbasic_TextSink struct moved to docs/10-embedding.md rather than being deleted. It was the corpus's only `c excerpt=` block -- the check that caught the stale struct two commits ago -- so dropping it with the README would have quietly retired a test. docs/10 also stopped claiming README.md carries the full API surface and the pool limits, which the trim made false. CLAUDE.md went from 458 lines to 62, because almost none of it was agent-specific. The project goals, the Go reference and its architecture, the dependency version and ABI rules, the four ways an embedded build collides, the libakerror convention, the error-code range map and the style rules are all things a maintainer needs, and they are now in MAINTENANCE.md with one copy to keep true. CLAUDE.md points there and keeps only the rules no test enforces: tests in the same commit asserting the correct contract, file a missing dependency capability upstream, do not edit generated output or tests/reference/, co-author your commits. Four claims did not survive the move, having gone stale where nothing could notice: - "The repository is currently empty apart from its submodules -- no commits, no source tree, no build files." There are 43 commits. - libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE) at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is gone; only a historical mention in a comment remains. - "akbasic_init() claims 512-767." There is no akbasic_init. It is akbasic_error_register(), called from akbasic_runtime_init(). - Time-relative phrasing ("libakgl hit two of them in the last week"). Five places pointed at CLAUDE.md for the range map or the file-it-upstream rule and now point at MAINTENANCE.md: include/akbasic/error.h, src/runtime_disk.c and three entries in TODO.md. Both source changes are comments. deps/libakgl/TODO.md cites it too and is left alone; it is a submodule, and the rule it quotes is still reachable from CLAUDE.md. ctest is green at 95 of 95, docs_examples included: 36 programs, 9 transcripts, 44 output comparisons, 3 C snippets, 1 excerpt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
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.
Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
| 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
```
Document the interpreter's architecture as chapter fourteen Chapters 1 through 13 describe the language. Nothing described the machine that runs it, and the answers were spread across header comments, TODO.md sections written for a different purpose, and the source itself. Somebody embedding the interpreter, debugging something it did, or adding a verb had to reconstruct the shape from all three. docs/14-architecture.md is that shape, and only that: the three targets and the driver, the single akbasic_Runtime and why nothing is file-scope, akbasic_runtime_step() unrolled with the reason each stage sits where it does, the four modes and what set_mode() does beyond assigning, a line's journey from text through tokens and leaves to a verb handler, the dispatch table, the pool map with what each exhaustion actually says, environments doubling as block state, the two kinds of error, devices, and interrupts. It defers rather than restates. The headers are the authority on every function's contract and the chapter says so up front; where a rule is subtle the header comment already states it at more length than a chapter should. MAINTENANCE.md keeps the conventions and now points here for the mechanism, so there is still one copy of each. Two sections are the reason it exists at all. Debugging: reading a TRON trace as evidence about the loop rather than the lines, reading an akerror stack trace and what it is not, four breakpoints and the expressions worth printing at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause table. Changing it: the verb recipe end to end including the private src/verbs.h prototype that is easy to miss, the rule that a missing dependency capability gets filed upstream rather than worked around, and the five constraints goal 3 puts on any change. A `text` fence tag comes with it. Every fenced block in docs/ is executed and an untagged one is a hard error, so six block diagrams had nowhere to live. The tag means never executed, it is counted in the skip line like `cmake`, and MAINTENANCE.md documents it -- the alternative was an indented block the extractor never sees, and a picture nobody decided about is indistinguishable from a test nobody ran. tests/docs_examples.sh now makes --root and --basic absolute before it starts. Both are used from inside a sandbox directory it cd's into, so the invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic -- failed every example with "exited 127" and every setup= with "setup failed". CTest passes absolute paths and never saw it; running one document by hand hits it immediately. Writing the error section turned up a defect and TODO.md section 8 records it. The ATTEMPT blocks that turn a script's mistake into an error line wrap parsing and interpretation but not scanning, so a line with more than 32 tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt the REPL is gone. That is the same shape as section 8 item 2, on a path that fix did not cover. Not fixed here -- it is a behaviour change and wants its own tests -- but written down with the three call sites and what would cover them. Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37 programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and 2 shell blocks, and skips 9 text blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:14:17 -04:00
`--root` and `--basic` are made absolute by the script before it starts, because both are
used from inside a sandbox directory it `cd`s into. Relative ones used to fail every
example with `exited 127` and every `setup=` with "setup failed" — invisible under CTest,
which passes absolute paths, and immediate for anyone running one document by hand.
Execute every documented example as a test docs/ and README.md carry 85 fenced blocks. Every one was checked by hand exactly once, when it was written, which is not a standard that survives a changing interpreter -- and four were already wrong: two transcripts showing a leading space PRINT does not emit, akbasic_TextSink in README.md missing the two members it had grown hours earlier, and FILTER's refusal quoted with wording the code does not use. tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds. BASIC programs and transcripts run and are byte-compared against an `output` block; C snippets compile with -fsyntax-only against the real include path, which CMake writes out because it is transitive through akerror, akstdlib and akgl; shell blocks run in a sandbox. Anything that would reconfigure the build tree, hit the network or re-enter the suite is tagged norun with the reason in MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision. An untagged block is a failure rather than a default, and the pass line reports what it executed by kind. Both exist because the way a harness like this dies is by quietly matching nothing and passing -- which it duly did on the first CTest run, where a generator expression evaluating to nothing still contributed an empty argument that the script read as a filename. The count is what caught it. The excerpt check earns its own mention: a block tagged `c excerpt=include/akbasic/sink.h` must still appear in that header, comments and whitespace ignored. Compiling it would only redefine the type, so a compile check could not have found the stale struct, and did not. Registered as the CTest case docs_examples in both configurations. Fixing the four wrong examples turned up two interpreter defects, fixed in the previous commit and recorded in TODO.md section 8. MAINTENANCE.md is new: the fence-tag reference, what to do when the case fails, and the conventions that until now only existed inside source comments -- the three test lists and how two of them invert "passed", the sorted verb table, that a golden file is never edited to suit this interpreter, and that a fix gets mutation-checked with a file copy rather than git checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
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.