Changelog

All notable changes to the Reef programming language are documented in this file.

Format follows Keep a Changelog. Versions use Semantic Versioning.

0.11.1 — 2026-10-19

Highlights

Reef 0.11.1 adds one runtime hook for OS entropy, reef_rt_entropy. It uses getrandom on Linux and illumos, arc4random_buf on the BSDs and macOS, and /dev/urandom elsewhere, and it never falls back to the clock. Two new modules build on it. random.random has secure functions that return a Result and a seedable Pcg32 generator for reproducible sequences. encoding.uuid covers RFC 9562 v4, v5, v7, v8, nil and max, and is tested against the RFC's vectors. math.random has fixes that change seeded output: the same seed(n) now gives different random_int_max, random_int_range and random_float results than 0.11.0. On bare metal, math.random now links in a freestanding image and a kernel can override the default reef_panic.

Added — reef_rt_entropy: one OS entropy hook (RB-016)

  • The runtime has one entropy primitive, reef_rt_entropy(buf, len) (reef-runtime/include/reef_entropy.h). Linux and illumos use getrandom(2), the BSDs and macOS arc4random_buf(3), anything else /dev/urandom (also the fallback when the kernel lacks getrandom). It fills every byte or returns a code (1 no source, 2 source failed, 3 bad arguments) with the buffer zeroed. It never falls back to the clock. EINTR from the GC's SIGURG is retried.
  • Bare metal: reef_baremetal.c has a weak default that returns "no source". An OS links its own reef_rt_entropy to provide one, as it does reef_putchar.
  • Reef code reaches it through sys.platform.runtime.reef_rt_entropy. Prefer the random module, which returns a Result.
  • Link test: a strong reef_rt_entropy overrides the weak bare-metal default on amd64 (run and checked by behaviour), aarch64 and riscv64 (checked with nm), with no duplicate-symbol error. If the strong definition is in a static archive linked after reef_baremetal.o, the linker never pulls it in and the weak default is used silently. reef-baremetal/README.md gives the safe link lines (object file, or --whole-archive).

Added — encoding.uuid (RFC 9562) (RB-019)

  • v4() and v7(unix_ms) take their random bits from random.random and return a Result (Err(Unsupported) with no entropy source, never a predictable UUID). v7 puts the 48-bit millisecond time first, so UUIDs from different milliseconds sort by time.
  • v8(bytes) sets the version and variant bits over the caller's 16 bytes (the caller's array is not changed). v5(namespace, name) is SHA-1 name-based via crypto.sha1, with namespace_dns/url/oid/x500. nil_uuid() and max_uuid() (nil is a reserved word).
  • parse accepts only the canonical 8-4-4-4-12 form (either case); format writes lower case. from_bytes / to_bytes use RFC byte order, the order of the ext2/ext4 s_uuid field. Also version, variant, is_nil, is_max, eq, compare.
  • Tested against the RFC 9562 Appendix A vectors (v4, v5, v7, v8).
  • v4, v8 and to_bytes compile and link for bare metal.

Added — random.random: secure random numbers and a seedable PCG32 (RB-018)

  • secure_fill, secure_bytes, secure_u32, secure_u64, secure_below, secure_range (inclusive) and secure_float read the OS source through reef_rt_entropy and return a Result: Err(Unsupported) when the target has no source, Err(IoError) when it fails, Err(InvalidInput) for bad arguments. No silent fallback.
  • Pcg32 is a seedable, deterministic generator (PCG-XSH-RR 64/32, bit-identical to pcg-c-basic): pcg32_new(seed, stream), pcg32_from_entropy, pcg32_next_u32/u64, pcg32_below, pcg32_range, pcg32_float, pcg32_bool, pcg32_shuffle. Each generator has its own state, so Active Objects do not share one. Not for secrets.
  • Ranges are unbiased (rejection sampling) and floats are in [0, 1).
  • Bare metal: reef_baremetal.h has reef_array_length, so modules that call arr.length() (this one, encoding.uuid) compile freestanding.

Changed — math.random: fixes that change seeded sequences (RB-020)

The same seed(n) now gives different random_int_max, random_int_range and random_float results than in 0.11.0. Raw random_int() and random_bool() sequences do not change.

  • random_float() could return exactly 1.0 (the divisor was 2^31-1, the largest draw; seed(1584200935) hit it on the first call). It now divides by 2^31, so results are always in [0.0, 1.0).
  • random_int_max and random_int_range were biased (random_int() % n favoured small results: for max = 1500000000, values below 647483648 came up 60% of the time instead of 43%). They now use rejection sampling over the full 32-bit draw.
  • random_int_range overflowed for spans over 2^31-1: it returned values outside the range, and random_int_range(INT_MIN, INT_MAX) panicked with integer division by zero. Any int span now works.
  • seed_time() seeded from the clock in whole seconds, so programs started in the same second got the same sequence. It now seeds from reef_rt_entropy and uses the clock only when the target has no entropy source. math.random is still not for secrets; use random.random.

Fixed — bare-metal reef_panic can be overridden (RB-022)

  • The default reef_panic in reef-baremetal/src/reef_baremetal.c was a strong definition, although the README and header comments called it an optional override. A kernel that defined its own reef_panic and linked that file failed with a duplicate symbol (multiple definition in GNU ld, duplicate symbol in ld.lld). It is now weak, like the other hooks. The weak-override link test covers it on amd64 (run and checked by behaviour), aarch64 and riscv64 (checked with nm).
  • reef_baremetal defines reef_time (weak, returns 0 = no clock; an OS with an RTC overrides it) and reef_int_to_float (inline). Before, a freestanding image that called math.random.seed_time() or random_float() failed to link with undefined references to both.
  • New gate in run_tests.sh: random_link_probe.reef links against reef_baremetal.c on amd64, aarch64 and riscv64, and a strong reef_rt_entropy must override the weak default.

0.11.0 — 2026-10-13

Highlights

Reef 0.11.0 changes how process waits report a child's exit. The new ExitStatus type says whether a child exited with a code or was killed by a signal, and SysError says why a wait failed. The old process_wait and ProcessCapture.exit_code now return the shell-style code: a child killed by a signal gives 128 + signal (kill -9 gives 137) instead of -1, and -1 now means only that the wait failed. The old wait calls are deprecated and will be removed in 0.12. Waits and blocking socket, file and terminal reads now retry when a signal interrupts them, reefc doctor no longer prints shell errors for missing tools, and nested patterns such as Result_Ok(Option_Some(st)) work in match statements.

Added — ExitStatus: waits return how the child ended (RB-013)

  • sys.process has ExitStatus = Exited(code) | Signaled(signal, core) with success() and shell_code() (the code, or 128 + signal), and SysError = NoChild | InvalidPid(pid) | Errno(n) (NoChild is ECHILD). One-child waits reject pid <= 0 with InvalidPid instead of calling waitpid(-1) / waitpid(0), which reaped an unrelated child.
  • New calls: process_wait_status, process_try_wait_status (Ok(None) while running), process_wait_any_status and process_wait_any_nohang_status (both give the pid with the status), and exit_status_core_supported(). ProcessCapture has status.
  • core is true when the kernel reports a core file; best effort. WCOREDUMP is decoded in a translation unit without feature-test macros, so its visibility does not depend on other sources' macros. Verified on illumos: core = true for an aborting child.
  • Documented and tested: the runtime never calls waitpid(-1) except in the explicit wait-any calls, and never touches SIGCHLD.

Changed

  • process_wait(pid) returns shell_code(): a child killed by a signal gives 128 + signal (it used to give -1). -1 now always means the wait failed. ProcessCapture.exit_code is shell_code() too.

  • ReefVision yield_exec returns Err when the wait for the child fails (it returned Ok(1)); Ok is still the shell code. ReefIDE prints a signal death as signal N (with (core dumped) when reported) in the output pane and status line instead of -1.

Deprecated (removed in 0.12)

  • process_wait, process_try_wait, process_wait_any, process_wait_any_nohang, WaitAnyResult, process_exit_code, process_exited_normally, process_was_signaled, process_term_signal, ProcessCapture.exit_code.

Fixed — process waits

  • Blocking waits (process_wait, process_wait_any) retry EINTR. The GC's stop-the-world signal interrupted them, and the wait returned -1 for a child that was still running.
  • A wait that reaps nothing (error, or nothing ready) clears the last-wait record, so the getters no longer describe the previous child.
  • process_try_wait no longer reports a failed wait (ECHILD) as "still running", which made polling loops spin until their timeout.
  • Compiler: a trait-impl method on an enum declared in another module resolves (Type 'X' has no method 'm' before).
  • Compiler: nested constructor patterns in match statements work. Result_Ok(Option_Some(st)) => bound nothing ("Undefined variable 'st'"), and two arms with the same outer constructor (Out_O(In_Q()), Out_O(In_P(_))) failed in C with a duplicate case label. Arms now nest to any depth with literals, binders and _, first match wins, and exhaustiveness is checked through the nesting. A match expression or a when guard combined with nested patterns is rejected with a message (not supported yet).

Fixed — blocking I/O under SIGURG (RB-014)

  • Socket calls retry EINTR: tcp_recv, tcp_send, tcp_accept, unix_accept, udp_recvfrom, udp_sendto. The GC suspend signal (SIGURG, no SA_RESTART) and any outside SIGURG made them return -1, dropping a read, a connection or a datagram (zyginit's control socket lost commands under a SIGURG flood).
  • tcp_connect, unix_connect and TLS connect no longer fail when interrupted; per POSIX the attempt continues, so the runtime waits for it and reads SO_ERROR instead of giving up. tcp_connect_timeout waits with poll (no FD_SETSIZE limit) and retries EINTR against its deadline.
  • sys.fd fd_open / fd_read / fd_write, the terminal backends' read/write (reefvision, tty) and fs copy / fsync retry EINTR (a pipe, FIFO or terminal read returned -1 and lost the data). fd_close is deliberately not retried.
  • console.readLine / stream read_line (reef_fgets) and the reef_fread reads under io.file, io.buffer, io.stream retry EINTR. readLine returned a spurious blank line (and dropped a part-line) when interrupted; fread came back short.
  • Known gap: stdio writes (reef_fwrite, reef_fflush, print) are not retried. glibc discards the buffered bytes when a flush fails, so a retry would claim success for lost data; writes to a blocked pipe or terminal can still fail on EINTR and report it.

Fixed — reefc doctor / reefc info tool probes (RB-015)

  • On a binary install without OCaml, reefc doctor printed the shell's command not found: ocaml and a blank OCaml: line (install16a, Zygaena RC1). OCaml is only needed to build reefc from source; doctor now says OCaml: not found (only needed to build reefc from source).
  • All version probes (C compilers, linkers, cmake, ocaml) check that the tool exists, capture its stderr, and use its output only when it exits 0. CC=<missing> made reefc info show the shell's error as the compiler version; it now says (not found).
  • The probes use no shell at all: reefc resolves the tool on PATH itself and execs it with an argv list. A CC/reefc.conf value with arguments (ccache gcc -m64) is split on whitespace into argv words, Make-style (no quoting or expansion).

Fixed — release tooling

  • publish-spaces.sh --from-forge fetches the release's source and binary tarballs from the forge before the dist/ source check, and reads the release from the forge API's {"data": ...} envelope.
  • pack-foreign-os.sh puts the Homebrew directories on PATH before opam env, so on a non-interactive ssh session the darwin pack uses the opam switch's ocaml/dune rather than Homebrew's. The promote step's release-id parse also unwraps the data envelope.
  • make test-examples runs from reef-compiler/, so example tests that use relative template paths find them.

0.10.7 — 2026-10-08

Fixed — installer runs without bash

  • install.sh and uninstall.sh are POSIX /bin/sh. Binary tarballs install on OpenBSD, FreeBSD, and NetBSD, which do not ship bash.
  • The install banner names those systems instead of printing unknown.

Fixed — examples

  • birthday_calc calls date_diff_days(today, birthday). The function returns date1 minus date2, so the previous argument order reported a negative age.
  • file_stats with no arguments prints help and exits 0.

Changed — example programs are command-line tools

  • reefzip compress and decompress are subcommands, and --help lists the algorithms and arguments.
  • reefsum -u applies to every digest.
  • json_pretty, file_stats, mandelbrot, download_progress, download_custom_bar, and https_fetch take flags instead of prompting. https_fetch takes an optional URL.
  • parallel_hash accepts -j / --jobs and prints --help.
  • Renames: graphics_test is graphics_demo, https_test is https_fetch, and tls_test is tls_connect. Baremetal compiler fixtures moved out of examples/ into the test suite.

Changed — install documentation

  • Binary install notes add per-OS unpack and first-run steps, the macOS quarantine clear, and an SDKROOT pin when the active macOS SDK is a TBD stub that ld cannot read.

Changed — documentation

  • The traits reference cites reef-lang#5 as plain text.

0.10.6 — 2026-10-06

Highlights

Reef 0.10.6 is a fix and packaging release with no language changes. Runtime fixes for OpenBSD (including its built-in LibreSSL), NetBSD and macOS mean Reef now builds and runs there, and the release download includes reefide alongside reefc, with man pages and install steps for Linux, macOS, FreeBSD, OpenBSD and NetBSD. reefide's Project Settings no longer crashes on projects with empty source folders.

Fixed — Unix / OpenBSD runtime portability

  • Unix port slice: register main for GC stop-the-world scans in test_gc_fork; release heap regions only when fully free; skip RWX mprotect on Apple, NetBSD, and OpenBSD; keep nproc and no-SIGPIPE socket options visible on the BSDs.
  • OpenBSD system LibreSSL: detect system LibreSSL (CMake reports 2.0.0) and map the two OpenSSL 3 names it lacks; other platforms still require OpenSSL >= 3.0 and reject LibreSSL headers.
  • Guard SO_NOSIGPIPE in reef_net (#ifdef); OpenBSD has no SO_NOSIGPIPE (Linux/OpenBSD keep MSG_NOSIGNAL on send).

Fixed — reefide Project Settings null-safe source_dirs

  • Null-safe source_dirs_csv (str.is_empty) and fill unused source_dirs slots with "" so Project Settings no longer SEGV on sparse/NULL entries.

Changed — Unix packaging audit

  • Binary tarball (scripts/package-binary.sh) ships bin/reefide next to bin/reefc and installs man pages under share/man/man1/.
  • install.sh / uninstall.sh mirror reefc PATH symlink handling for reefide and install share/man/ when present.
  • dist-docs/ rewritten for Linux, macOS (Darwin), FreeBSD, OpenBSD, and NetBSD install-from-tarball flows matching reef-lang-VER-OS-ARCH.tar.gz.

Added — foreign OS pack and forge catalogs

  • pack-foreign-os.sh for foreign OS pack/promote.
  • publish-spaces catalogs forge release assets (--from-forge / --refresh / --stage-extra).

Fixed — pack-foreign-os OpenBSD portability

  • Remote pack via /bin/sh (not bash); load opam env on guests; REEF_PACK_ALLOW_TIP=1 packs tip without retagging.
  • Keep OpenBSD package-binary rewrite under scripts/; replace rsync with portable cp; fetch tarball list via heredoc so remote globs reach the guest intact.

0.10.5 — 2026-10-02

Fixed — mouse CSI leak and illumos ProcessCapture

  • Stop SGR mouse CSI leaking as pane text in reefvision input.
  • Rename ProcessCapture fields off illumos stdio macros (stdin/stdout/stderr).

0.10.4 — 2026-10-02

Fixed — CI goldens after 0.10.3

  • Refresh test_reefvision_settingschrome.expected to match ok settingschrome output.
  • Regenerate baseline_call_arg_slots.expected.c for new process capture FFI decls (process_run_capture and related).

0.10.3 — 2026-10-02

Added — reefide (in-tree ReefVision TUI IDE)

  • ide/ CLI IDE: edit/save buffers, Build/Run via sys.process capture, Open/Close Project (reef.toml), session chrome restore under ~/.config/reefide/session.toml, auto end-block autocomplete, Project Settings (surgical reef.toml), User Settings (settings.toml prefs), and a list-first File Tree panel.

Added — process capture, settings layout, terminal UI

  • sys.process.process_run_capture / ProcessCapture (ROADMAP §8.4 L4).
  • reefvision FormGrid and settingschrome (Apply/Cancel + discard).
  • Terminal frame-integrated scrollbar UI; examples install under share/reef/examples.

Fixed — VT / PTY / capture

  • Darwin test_gc_fork /usr/bin/true and pty TIOCSWINSZ ENOTTY.
  • VT COW cur_style before do_sgr mutates; honor SGR 39/49 default FG/BG.
  • Capture pipe read-end leaks closed.

0.10.2 — 2026-09-29

Fixed — Illumos PTY STREAMS line discipline and VT sgr0

  • On Illumos, PTY slave setup now I_PUSHes ptem, ldterm, and ttcompat so the child gets a working STREAMS line discipline.
  • TERM / LINES / COLUMNS are set in the child only after fork, so the parent process environment is not clobbered.
  • reefvision VT now consumes xterm charset-designate sequences (ESC ( B and kin) so sgr0 (\E(B\E[m) does not paint a literal B.

Added — fs.perm.has_execute_bit

  • perm.has_execute_bit(path) is true when the path's mode has any of S_IXUSR|S_IXGRP|S_IXOTH. It does not call access(X_OK).
  • perm.is_executable remains the access(X_OK) authz hint (true for root even when no execute bits are set).

0.10.1 — 2026-09-28

Changed — host statistics and platform build portability

  • Non-Linux CPU-count detection now uses sysconf.
  • Darwin builds define the required feature macros, use named sem_open semaphores for GC acknowledgement, and avoid RWX mprotect during GC heap expansion to comply with W^X.
  • Install paths now set SYSCONFDIR=$(PREFIX)/etc on Darwin and Linux, and Darwin builds auto-detect Homebrew OpenSSL.
  • illumos host statistics now report CPU utilization through kstat and load averages through getloadavg, with the required sys/loadavg.h include and reefc linkage against libkstat.
  • Darwin host statistics now report CPU utilization via host_processor_info, memory via HOST_VM_INFO64, and processes via libproc.

0.10.0 — 2026-09-24

Fixed — wrong-target asm fn call is a typecheck error (reef-lang#70)

A call to asm fn ... for <arch> compiled under a mismatched --target used to emit invalid C (call sites, no declaration or definition) while reefc exited 0. Typecheck now rejects reachable calls when no asm fn variant exists for the current target architecture. Same-arch and multi-arch (for amd64 / for arm64 / for riscv64) calls are unchanged.

Fixed — stray trailing ! is a typecheck error (reef-lang#78)

A stray ! after an expression was absorbed as prefix NOT on the next statement. When that statement was a bool-returning call, --check was clean and behaviour matched the program without the !. Bare expression-statements whose top node is logical NOT are now rejected; ! in conditions and assignments is unchanged.

Fixed — reef-os UART MMIO uses volatile helpers (reef-lang#80)

uart_16550_mmio and uart_pl011 accessed registers via addr-as-[T] array views, which could be GC-rooted as if they were heap arrays. MMIO now goes through volatile_read* / volatile_store* with addr as pointer. String iteration s as [uint8] is unchanged.

Changed — str.char_at returns option.Option[char]

core.str.char_at no longer returns '\0' for out-of-bounds indexes. In-bounds indexes yield Some(c); OOB yields None. Call sites that already prove the index is in range typically use option.unwrap(...).

Changed — html.entity_to_char returns option.Option[char]

encoding.html.entity_to_char no longer returns '\0' for unknown entities. Known names yield Some(c); unknown yield None. Named-entity decode (parse_named_entity / html_decode) follows the Option path so append_char never sees a NUL stand-in.

Fixed — chars.is_ascii includes DEL (127)

core.chars.is_ascii matched printable ASCII only (c <= '~'), so DEL ('\x7F' / 127) was false while docs claimed 0..127. Now c <= '\x7F'. Fixture proves 127 true and 128 false.

Fixed — 100_UNSAFE.md safe_strlen returns size_t

Doc example safe_strlen returned int after wrapping strlen; post-#72 strlen is size_t, so the example typechecked false. Return type is size_t to match the libc FFI table.

Changed — drop dead signed-char host wording (docs / comments)

reefvision.input.byte_of_char and reefvision.cell.utf8_decode_step no longer carry signed-host branches (char is unsigned 8-bit). Crypto hash pack comments reword to “mask to 8 bits”; & 255 masks kept.

Fixed — private html.append_char refuses embedded NUL (#75 surface)

Internal encoding.html.append_char panics on c == '\0' before writing (same message as convert.to_string_char). Public entity_to_char API unchanged.

Fixed — refuse string-literal out-buffers in runtime writers (reef-lang#71)

Safe Reef could pass a .rodata string literal to a writing runtime helper (reef_getcwd_safe, reef_readdir_name, reef_tls_recv, reef_string_copy_range, reef_fgets). getcwd on a literal returned EFAULT → silent n=0; other writers could SIGSEGV writing .rodata or the static header. Those callees now refuse REEF_FLAG_STATIC_LITERAL with a clean panic (same shape as str.set_length / str.sync_length). Allocate with reef_string_alloc(n) for out-buffers. Docs: 105_FFI.md now maps string → char* (not const char*) and stops teaching the literal-as-getcwd-buffer pattern. Raw libc.strcpy and friends remain unguarded (tier-3 / unsafe policy deferred).

Fixed — libc FFI signature table (reef-lang#72)

Whitelisted libc extern "C" names no longer skip every declaration-level check. reefc now carries a name→signature table (Reef types, LP64: size_t/long/double) consulted at --check; mismatches name both signatures. Codegen still suppresses the emitted extern for table names (headers, ctype macros, qualifiers). Added macro names isxdigit / iscntrl / isgraph / isblank. Stdlib strlen externs/wrappers now return size_t.

Fixed — reefvision byte/display/code-point unit mismatches (reef-lang#74)

  • (a) table: removed pad_or_trunc (byte pad/cut into a column-width cell). cellbuf_fill_rect already pads; cellbuf_draw_text_in already clips by display columns — accented/CJK cells no longer store a bare UTF-8 lead byte.
  • (b) textarea: caret_col / sel_col are UTF-8 code-point indices. Convert to bytes only at str.substring edit helpers; convert to display columns only at paint and mouse hit-test. Mid-sequence caret is impossible. col_offset / content_width stay display columns.
  • (c) menubar: apply_hot_label advances the hot glyph index only on UTF-8 lead bytes so it matches cellbuf_draw_text_hot's code-point contract.

Fixed — no embedded NUL in Reef strings (reef-lang#75)

A char holding byte 0 turned into a string (via "${c}", core.convert.to_string_char, or a safe s[i] = '\0' store) used to leave str_len counting the NUL while libc %s / strlen stopped at it — same value, two lengths, silent truncate on print. Construction now panics (panic: embedded NUL in string). REEF_STR_HARNESS also rejects finished strings whose content has non-zero bytes after an early NUL (mid-fill zero tails remain legal).

Breaking — ReefVision syntax dirs (reef-lang#79)

reefvision.syntax no longer loads syntax files from fixed system-wide and per-user directories. Apps must call add_syntax_dir for extra language directories. Bundled languages still load from $REEF_HOME/reef-stdlib/reefvision/syntax.d when REEF_HOME is set.

Breaking — 0.10 numeric core (N1–N13)

Integer and float arithmetic now follow Reef rules, not C's. Wrap and panic live in the emitted C helpers, not in -fwrapv (that flag is defense in depth only). char / UTF-8 is not this change.

  • N1 wrap: + - *, unary -, and bitwise ~ wrap at the result type's width. 127i8 + 1i8 is -128 stored and inline. ~0u8 is 255 stored and inline. -INT_MIN is INT_MIN. Compound assignment follows the operator. / and % do not wrap (N4).
  • N2 aliases: int ≡ int32 (C int). float ≡ float64 (C double). Same shape as byte ≡ uint8. int8 / int16 / int64 and float32 stay distinct.
  • N3 mixing: mixed integer + - * & | ^ and comparisons promote by width. Same-width signed/unsigned is a type error (int + uint32). Unsigned → strictly wider signed is implicit (uint8 + int → int). The earlier Unreleased "all signed/unsigned mixes are errors" rule is superseded by this table.
  • N4 / %: integer division truncates toward zero; the remainder sign follows the dividend. / 0, % 0, INT_MIN / -1, and INT_MIN % -1 panic (no SIGFPE, no wrap).
  • N5 literals: unsuffixed integer literals adapt to context if the value fits (let u: uint8 = 255 is legal). With no context: int if it fits in signed 32-bit, else int64, else a compile error. let a: int = 3000000000 is now a compile error (it used to wrap). Suffixes are still range-checked (300u8 is an error). Constant expressions fold, then the result is range-checked.
  • N6 int→float: implicit iff every value of the source type is exact in that float. int + 1.0 is float. int + float32 and int64 + 1.0 are type errors.
  • N7 as: wraps at run time. Out-of-range constants are compile errors. Float → int truncates toward zero and panics on NaN, Inf, or out of range. Identity as is a no-op.
  • N8 shifts: the result wraps at the left operand's type. A count not in 0 .. W-1 is a compile error if constant, otherwise a panic (including negatives). No x86-style mask. Signed >> is arithmetic. (255u8 << 1) >> 1 is 127.
  • N9 size_t: same type as uint64. size_t + int is a type error; size_t + 1 adapts the literal. libc size parameters (malloc, calloc, memcpy, memmove, memset count, strncpy/strncmp n) take size_t. An int length needs n as size_t.
  • N10 subranges: 0..9 and subrange LO HI are not types. Use a fixed-width integer or a type alias. .. remains for set ranges, slices, and for-loop ranges.
  • N11 unsafe: unsafe does not allow implicit integer narrowing. let b: uint8 = n and let c: char = n are type errors in every context; write n as uint8 / n as char. Widening does not need unsafe.
  • N12 **: the exponentiation operator is gone, and so is reef_int_pow. Use 1 << n, a loop, math.pow_int, or math.pow_f.
  • N13 IEEE: float overflow is Inf / -Inf; invalid is NaN; no panic on float overflow. N7 still panics float → int that does not fit.

Migration. Respell ** and subrange types. Add as at same-width signed/unsigned mixes and at inexact int→float. Replace overflowing unsuffixed int literals with int64 (or a suffix). Wrap narrowing stores with as. Pass size_t into libc size parameters.

Breaking — 0.10 text (T1–T10)

  • T1: char is unsigned 8-bit on every target. 200 as char as int is 200 on amd64 (was -56).
  • T2: char and uint8 convert implicitly both ways. They stay distinct types.
  • T3: char + char wraps as char. char + int is int. char + uint8 is a type error. c = c + 1 is legal.
  • T4/T5: string is bytes (UTF-8 by convention). s[i] and for ch in s yield an unsigned byte. No validator.
  • T8: '\xNN' is a char literal. '\u{N}' is only for strings.
  • T6/T7/T10: no rune type, no UTF-16/32 string, and no language-level utf8.valid in 0.10. Code points stay int (input.keys.KeyCode.Char).

Fixed — setof ordinal annotation check + OOR membership (reef-lang#73)

setof lowers to a uint32_t bitset. Unguarded C shifts at SetLit/SetMember were undefined behavior for element values outside 0..31, and non-ordinal element types (string, structs, …) were accepted at annotation sites because only SetLit checked ordinality.

  • Typecheck rejects non-ordinal setof[T] annotations and constant elements outside 0..31.
  • Runtime insertion of an out-of-range value is a defined no-op; membership returns false (not a C shift UB).
  • Docs/examples aligned to the 0..31 domain (see 030_TYPES.md).

--entry <name> was used as BOTH the C entry symbol and the reachability root name, so --entry none — which all three kernel Makefiles pass, and which means only "emit no C trampoline" — rooted nothing. Every function body was dropped as unreachable while the forward declarations were still emitted, so the C compiled clean and failed at link:

ld: limine_boot.o: in function `kmain': undefined reference to `reef_main'

Broken since rev 1329 made reachability-based emission the default, and shipped in v0.9.3. The documented bare-metal default --entry _start had the same fault, and so did any hosted build with a custom --entry: reefc hello.reef --entry not_a_fn failed to link.

The reachability root is now always Reef main, independent of the C entry symbol. A module with no main is a library translation unit with no entry root, so dropping is disabled for it rather than deleting every body.

Measured, kernel_limine.reef --target amd64-baremetal:

flags definitions of reef_main
--entry none (before / after) 0 / 1
--entry _start (before / after) 0 / 1

All three kernel Makefiles build, and the x86-64 kernel links to a 16 KB ELF with reef_main at ffffffff80001650.

New gate bm-link/amd64: emits the kernel's C, compiles it and limine_boot.c freestanding, and runs the real ld with the kernel's own linker script. Every previous bare-metal check was -fsyntax-only, which cannot see a missing definition — and with --entry none that unit does not even reference reef_main, so a plain -c is green too. RED-proven against the pre-fix compiler with the exact linker error. A "every prototype has a definition" check would not be sound: prototypes of legitimately dropped dead code are intentional, and extern "C" declarations such as reef_putchar have no Reef-side definition by design.

The reef-os compile gate was green vacuously and is fixed in the same commit. It passed --target amd64-baremetal for every module including the riscv64 and aarch64 ones; with every body dropped there was nothing for gcc -fsyntax-only to read. Each module now compiles for its own architecture, with the syntax check using the matching compiler and target. That immediately surfaced reef-lang#70 (filed, not fixed): a call to an asm fn ... for <arch> compiled for a different target emits neither declaration nor definition while its ten call sites survive — invalid C with no Reef diagnostic.

Fixed — signed integer overflow was miscompiling core.hash at gcc -O2

reefc now passes -fwrapv, so signed integer overflow is two's-complement wrapping instead of C undefined behaviour. The three bare-metal kernel Makefiles carry the flag too, because they compile the emitted C themselves.

This was not theoretical. core.hash's impl Hash for int computes (x * 33) + (x >> 16) and clears the sign bit with & 2147483647. The multiply overflows, so at gcc -O2 and -O3 the optimizer deleted the caller's own guard as provably-false while the value really was negative:

64 probe values, gcc -O2:   32 print NEGATIVE
                            the program's own `if h < 0` counter reads 0

clang was clean at every level and gcc clean at -O0/-O1, so the default hosted build never showed it — while all three kernel Makefiles build at gcc -O2, the broken configuration. The function's own comment asserts that its mask "is total: it maps every int, INT_MIN included, into [0, INT_MAX]". It was not.

The bug class is self-concealing: a defensive check written in Reef against an already-overflowed value is itself deleted by the optimizer, so neither the code nor a test asserting on it can see the problem.

Measured free. prime_sieve, mandelbrot and json_pretty build byte-identical at reefc's default, and within 0.1% at -O2. User --cflags are appended after the flag, so -fno-wrapv still wins for anyone who wants the old behaviour.

New gate: overflow/<cc>. examples/test_signed_overflow_defined.reef pins the 64 two's-complement values, and the gate rebuilds every fixture in reef-compiler/tests/optimized-overflow-manifest.txt at -O2 under each installed backend. That level is the point: the existing dual-backend leg builds at reefc's default, which is the one column where this was invisible. RED-proven by removing the flag — gcc -O2 and -O3 then differ on 32 of 64 lines while clang stays green. A missing compiler is a SKIP that names the uncovered backend; zero fixtures running is a FAIL.

This is a miscompile fix, not a decision about Reef's arithmetic. Two limits are deliberate. A flag only covers what reefc itself compiles, so --emit-c consumers are unprotected and any guarantee the language makes has to live in the emitted C text. And a #pragma is not a substitute: clang silently ignores #pragma GCC optimize("wrapv") and still folds x + 1 > x. Both belong to the 0.10 numeric-semantics work.

Breaking — mixing signed and unsigned integers is a type error

Superseded in part by N3 above. Same-width signed/unsigned stays an error; unsigned → strictly wider signed (uint8 + int) is now implicit and is not covered by this section.

Arithmetic (+ - * / %, and so += and friends) and comparison (== != < > <= >=) between a signed integer (int, int8–int64) and an unsigned one (uint8–uint64, size_t) of the same width is now rejected:

Type Error: Arithmetic mixes signed and unsigned integers ('int' and 'uint32').
Reef does not choose a signedness implicitly: the generated C can compute a
different value from the result type, so 'x + y' was -1 but 'y + x' was
4294967295 for x: int = -1 and y: uint32 = 0. Convert one operand explicitly
with 'as'.

Migration: write the as that says which answer you meant — x + (y as int) for a signed result, (x as uint32) + y for an unsigned one.

Why it had to be an error rather than a better default. The typechecker picked a result type by width with the left operand winning a tie, then codegen emitted the bare C operator and let C's own conversions compute the value. The two disagreed, silently, since rev 163 (v0.5.20, BUG-051 phase 4). With x: int = -1 and y: uint32 = 0, all measured on 0.9.3:

Expression Result
let s = x + y -1
let s = y + x 4294967295 — operand order changed the value
x + y < 0, sum stored in a let first true
x + y < 0, written inline false — naming a subexpression changed it
(x + y) / 2 inline, vs stored-then-divided 2147483647 vs 0

It reproduced at every width (int8/uint8 gave -1 or 255; int64/uint64 gave -1 or 18446744073709551615), and == had the same fault: -1 == 4294967295 is true in C.

Same-width mixes stay an error because C has no value-preserving answer (int + uint32). N3 restores the one value-preserving edge C also gets right: uint8 + int → int. Width-only mixing (int + int64, uint8 + uint32) still promotes to the wider type.

Unsuffixed integer literals are exempt — they carry no signedness — so count + 1 compiles on any integer type. On a width tie the result now takes the non-literal operand's type, which fixes a second order bug: 1 + u on a uint32 was typed int, so let r: uint32 = 1 + u failed while u + 1 compiled. A negative literal is unary minus on a literal, not a bare one, so u + -1 is rejected, as it should be.

Blast radius: zero in-tree. The stdlib compile gate is 179/179 with the rule active, and that zero is calibrated — a mixed expression planted in math.basic failed the gate with this diagnostic, then the module was restored byte-exact. All 32 gated docs contain no violating block, calibrated the same way. Code outside this repository that mixes signedness will stop compiling and needs the as above.

Tests: seven reef-compiler/examples/test_mixed_sign_*_rejected.reef (both operand orders, small widths, <, ==, +=, a negative literal) and examples/test_mixed_sign_ok.reef for every shape that must keep compiling — all RED against 0.9.3 first.

Documentation

  • char is one byte, not a 32-bit code point. 015_BASICS said otherwise. 0.10 text makes it unsigned 8-bit on every target (C unsigned char); see Breaking — 0.10 text above. 'é' is a lexical error and string indexing yields bytes.
  • float and float64 were documented as distinct C double types (the 0.9 reference). N2 makes them one type; see the numeric-core section above.
  • Exponent float literals (reef-lang#58, shipped in 0.9.3) are now in the reference, including that 1e3 is a float, never an int.

0.9.3 — 2026-09-11

This is a large stdlib release. Sixty-plus changesets since 0.9.2, and the bulk of them are one programme: removing in-band failure sentinels (C16), deleting accessors that wrapped public fields (C2), and collapsing duplicated bodies onto one implementation (C4) across all 22 stdlib packages. input is a new package. The detail is below, newest first; the shape of it is:

Theme What moved
Sentinels → Option/Result sys.process (fork, spawn, setsid, wait_any…), net.http (HttpUrl.is_valid), graphics.get_pixel, crypto.sha256_file, encoding (2 of 55 candidates — the rest triaged as legitimate), gameui, time.days_in_month, sync.mutex_create, compress.{zlib,gzip,lz4}, reefvision
Accessors deleted ~340 across ui (193), reefvision (98+19), graphics (37), gameui (32), sys.optparse (12), compiler (8)
Duplicate bodies collapsed *_failure_kind (11 → 2), crypto hex (5 → 1), compiler.symbol_table (3 → 1 generic), time calendars, the three KeyCode enums (→ input.keys)
Language underscore digit separators; == on a payload-carrying enum is now rejected; setof[EnumType] compiles for the first time (and is rejected where the enum carries a payload); float literal fixes (exponents, 12-digit truncation, overflow)
Compiler diagnostics interpolation fragments report their real line and column
Gates make test-compiler actually runs the tests; docs-check gates every reference chapter; reef-os and the stdlib get compile gates; download paths get their first coverage; source tarballs are gated for name/content version agreement

Breaking changes are called out per entry. The largest are sys.process's ForkResult, net.http's Option-returning URL parse, the single shared KeyCode, and == on payload-carrying enums becoming a compile error.

Fixed — a source tarball could be named for a version it did not contain

scripts/package-source.sh read the version from the working copy but staged its content from hg archive, which exports the committed parent revision and ignores uncommitted changes. Run between bump-version.sh X.Y.Z and the bump commit — the exact window the release procedure creates — it produced reef-lang-X.Y.Z-source.tar.gz whose four version files all still read the previous release. Nothing failed: the tarball built, passed make test, and sudo make install put a compiler reporting the old version on the path. Measured 2026-09-11 with a 0.9.3-named tarball carrying 0.9.2 sources.

Two rules now close it:

  1. the version is read from the staged tree, so the tarball's name cannot describe something other than what is inside it; and
  2. packaging with modified version files is a hard error, because rule 1 alone would quietly ship the old release under the old name while the operator believed they were cutting the new one.

scripts/test-package-source.sh gates rule 1 — the tarball's directory name must equal the version in dune-project, lib/version.ml, bin/reefc.ml and reef.opam — and the assertion was RED-proven against the mismatched tarball that prompted the fix. It waives rule 2 for itself via REEF_PACKAGE_ALLOW_DIRTY_VERSION=1, since it runs from the live working copy and must not turn the suite red for the duration of a bump.

Fixed — setof[EnumType] never compiled (ROADMAP §8.5 item 6)

The set lowering spliced the enum value into bitmask arithmetic at three places — a set literal's 1u <<, a range's endpoints, and membership's >> — and an enum value is a struct {tag, data}, so every such program was rejected by the C compiler:

(((s >> reef_M_Colour_Red()) & 1u) != 0)

Meanwhile docs/language/reference/030_TYPES.md has always listed enum values as valid set elements and shows setof[Color] in its own type table. The documentation was promising something that could not be built. All three sites now take .tag, as the ==/!= arm already did. Pre-existing; found while probing reef-lang#68 and verified identical against the pre-#68 typechecker.

The codegen fix alone would have been a net harm, which is the part worth reading. With it, setof[<payload-carrying enum>] compiles — and silently drops the payload:

Shape_Circle(2.0) in {Shape_Circle(1.0)}    // would answer true

That is reef-lang#68's defect in a new place. Before the fix the C compiler was rejecting it by accident; after it, the program would compile and lie. So a payload-carrying enum is now rejected as a set element, on the same rule and for the same reason as #68 — a set stores only the variant tag, so a payload cannot round-trip. Payload-free enums in sets work, and now actually build.

Breaking — reefvision's indexed getters return Option[string] (C16, ROADMAP §8.5 item 6)

Six getters that returned "" for "no such index" now return option.Option[string]:

Was Now _or twin
listbox.item(idx) Option[string] item_or(idx, fallback)
radio.label(idx) Option[string] label_or(idx, fallback)
combo.item(idx) Option[string] item_or(idx, fallback)
combo.selected_text() Option[string] selected_text_or(fallback)
syntax.lang_id(lang) Option[string] lang_id_or(lang, fallback)
textarea.selected_text() Option[string] —
filereq.selected_path() Option[string] —

Migration — match, or take the _or twin where a default is what you meant:

// before
let name = lb.item(lb.selected)
if str.length(name) > 0
    ...
end if

// after
match lb.item(lb.selected)
    case Some(name)
        ...
    case None
end match

// or, where a default really is the intent
let name = lb.item_or(lb.selected, "(none)")

The miss here is genuinely ordinary under 035_ERROR_HANDLING.md's rule: selected is 0 on an empty list, so the chained shape self.list.item(self.list.selected) fed a sentinel index into a sentinel string with nothing in between to notice. filereq.selected_path went with the group because a file requester returning "" is exactly the shape that gets handed straight to an open() call.

Two got no _or twin, deliberately. textarea.selected_text has zero callers that wanted one — building one for nobody is speculation, not symmetry — and its inner returns still yield "" on purpose: a zero-width selection really is the empty string, which is a value, not an absence. Only the no-selection guard became None.

Breaking — == on a payload-carrying enum is a compile error (reef-lang#68)

== / != on an enum that declares any variant with a payload is now rejected at typecheck:

Type Error: '==' is not defined for enum 'Shape', which has variants that carry
a payload: it would compare only the variant tag and ignore the payload, so
Some(1) == Some(2) would answer true (reef-lang#68). Use 'match' to test the
variant and its payload together.

Codegen emitted (a).tag == (b).tag and never read .data, so the comparison answered "same variant?" while reading like "same value?". It reached core.option and core.result:

@option.Option[int].Some(1) == @option.Option[int].Some(2)   // was true
@result.Result[int, error.Error].Ok(1) == Ok(999)            // was true

Payload-free enums are unaffected and still compare with == — at the time of the fix that was 63 of the stdlib's 82 enums, and tag-only is correct for every one of them: with no payload, "same variant?" is the whole question. That is also why this survived so long.

Three questions hide inside == on an enum. (a) same enum type? was always static — two structurally identical but distinct enums were already a type error. (b) same variant? is the tag compare, and is correct. (c) do the payloads match? was never asked. The rejection fires exactly where (c) would be needed.

Rejecting rather than comparing structurally is deliberate: Reef's == is reference identity for structs, arrays and objects, with string the one structural exception. Comparing payloads structurally would make an enum holding a struct answer false for two equal-valued structs — trading this footgun for a subtler one — and would still need a rule for payloads that have no equality at all (arrays, closures, active objects). Documented in 030_TYPES.md under "Comparing Enums".

Zero call sites in the tree needed changing; the stdlib compiled unchanged. Pinned by a pair: test_enum_eq_payload_rejected.reef (EXPECT_FAIL) and test_enum_eq_payload_free_allowed.reef, which is the over-firing guard — if the check ever widens to all enums, it stops compiling.

Fixed — math's integer functions stopped returning wrapped answers (reef-lang#65)

Five defects, all reachable at ordinary inputs because Reef's int is 32-bit by design:

  • abs(INT_MIN) returned INT_MIN — negative. gcd and lcm both called it.
  • lcm computed a * b before dividing by the gcd, so lcm(196608, 65536) returned 0 — which is also what lcm returns when an input is 0, making a wrong answer indistinguishable from a real one.
  • square / cube / pow_int were unguarded; pow_int(2, 62) returned 0.
  • random_int negated where its comment said it masked, and -INT_MIN is still negative.
  • rng_state held the canonical xorshift32 seed 0x92D68CA2 as an int, so it truncated to -1831433054 and every importer emitted a compiler warning.

abs, square, cube, pow_int, gcd and lcm now return option.Option[int], None on overflow. gcd runs Euclid on signed values and takes the absolute value only at the end; lcm divides first (q = a / g, then a checked multiply). The PRNG is uint32 throughout, and its output was verified against an independently computed reference xorshift32 sequence — the old .expected had pinned a wrong sequence.

Fixed — interpolation fragments report their real column (reef-lang#66)

Sibling of reef-lang#11. That fix made an error inside ${...} report the right line; this one makes it report the right column. Fragments now carry their own source position (RawExprPart gained a location, and interp_parts widened to carry one per fragment) instead of being re-lexed from position zero.

The sub-lexer recomputes pos_cnum buffer-relatively, so the fix sets pos_bol = -column and pos_cnum = 0 to make the arithmetic land on the original column. The first attempt looked correct against a fixture set where every bad fragment sat at the string's end — a confounded corpus that reported a constant offset. Only errors placed inside a fragment exposed the real rule.

Fixed — make test-compiler ran no tests, on every push (reef-lang#67)

The target reported success without executing the suite, so CI's compiler-test step had been vacuous. Each of tests/{lexer,parser,typechecker} now resolves reefc through one shared reef_test_support module that anchors on Sys.executable_name and is fatal on a miss — it never silently falls back to an installed reefc, which is the failure mode that made the green meaningless. The dune (deps %{exe:../../bin/reefc.exe}) edge makes the tests re-run when the compiler actually changes.

Added

  • Underscore digit separators (reef-lang#59). The spec has documented 1_000_000 and 0.1_234 since v2 (reef-language-spec-v2.md:456,464); the lexer never had them, so 1_000_000 lexed as the integer 1 followed by the identifier _000_000 and died with "Undefined variable '_000_000'".

    Separators work in every radix, in typed-suffix forms, and in floats including the exponent:

    let million = 1_000_000
    let mask    = 0xFFFF_FFFFu32
    let bits    = 0b1010_1010
    let perms   = 0o7_5_5
    let ratio   = 0.1_234
    let big     = 1.5e1_0
    

    They are source-level grouping only — stripped in the lexer action, so nothing downstream ever sees one. Measured: two files differing only in separator placement emit byte-identical C.

    A separator must sit between two digits; leading, trailing and repeated separators are rejected with a "Misplaced digit separator" error naming the radix. That rule is not a taste call — encoding.toml's scan_digit_run already enforced exactly it for TOML input ("'_' must be surrounded by digits of the same base"), and Reef source accepting what Reef's own parser rejects would have been an odd place to differ. _1 stays an identifier, because _ is a legal identifier start and the digit-run rules therefore begin with a digit.

    Sibling of reef-lang#58 from the same spec block, fixed separately because separators touch every integer rule — eight typed-suffix forms per radix plus the bare form, 38 rules in all — where the exponent fix touched two.

Changed — net.http's download result is a sum type (ROADMAP §8.2)

Two internal structs became one enum:

type DownloadOutcome = enum
    Succeeded
    Redirected(string)   // the Location to follow, never empty
    Cancelled            // the progress callback returned false -- NOT an error
    Failed(string)       // the reason
end DownloadOutcome

DownloadResult carried success + is_redirect + location + cancelled + error — three booleans for four outcomes, so success && is_redirect && cancelled was representable and meant nothing. Every consumer already destructured it as a sum type, with an if/elif chain.

DownloadResultVerbose is gone entirely, which measurement decided rather than taste: bytes_received was write-only (ten writes, zero reads) and content_length was producer-internal — a loop guard, and a value copied into HttpDownloadProgress. Neither was outcome data. content_length is a local now; bytes_received is deleted.

net/http.reef is 78 lines shorter. No public API changed — both types were internal.

The Redirected payload cannot be empty, and all six producers enforce that at construction. This has a visible consequence: a 3xx whose Location header is missing now reports

Err("redirect without a Location header")      // was: Err("Download failed")

because the producer is the only place that can tell, and making the variant honest forces it to decide. The consumers need no str.length(location) > 0 guard as a result — the old one was doing the type's job.

Cancelled remains its own outcome rather than a flavour of Failed, because http_download_file_callback returns Ok(false) for it: a user who cancels has not hit an error. Folding the two would have changed that, and examples/test_http_download_local.reef fails loudly if anyone tries.

Added — coverage for net.http's download paths

examples/test_http_download_local.reef (14 checks) drives http_download_file, http_download_file_callback and http_download_progress, which had no behavioural test at all. The only two programs that reached them — download_progress and download_custom_bar — are skipped by run_tests.sh:197 as network tests; they fetch https://www.zlib.net/zlib-1.3.1.tar.gz. So the redirect dispatch, the cancel path and the error propagation were untested.

The fixture is pure Reef: a forked child serves a scripted sequence over net.tcp, following test_http_local_server's precedent. No python, no external daemon, no network. It covers a plain 200 (file written, bytes verified), a 302 that must be followed to the real body, a 404 (must be Err, not a silently-empty file), a callback cancel (must be Ok(false), which is not an error), and http_download_progress's byte accounting.

The child is killed, not waited for, and that is deliberate. Matching the connection count to the scenarios exactly is fragile: if net.http ever changes how many connections a redirect or a cancel costs, a counted script leaves the child blocked in accept and the parent blocked in wait, and the test hangs instead of failing. A hang reports nothing. The script is deliberately longer than needed and the parent kills the child when its scenarios are done.

Gate quality is RED-proven three ways, each against a specific way the forthcoming DownloadResult reshape could break:

Injected break What fires
drop the str.length(location) > 0 redirect guard and did not write the empty 302 body
fold cancelled into the error arm a cancelled download is Ok, NOT Err + and it reports false
report success without writing 404 is an Err, plus the progress checks

Breaking — sys.optparse and reefvision read fields instead of accessors

30 accessors deleted whose whole body was return self.<a differently-named field>. Where the accessor had the better name, the field was renamed to it and the accessor removed; where the field was the more accurate half, it kept its name.

sys.optparse (12). The fields were abbreviated because the method had taken the good name — a method and the field it returns cannot share one. So deleting the method freed the name, and call sites lose two characters:

flag.value()   -> flag.value       // field was .val
flag.present() -> flag.present     // field was .was_set
rest.values()  -> rest.values      // field was .vals
cmd.name()     -> cmd.name         // field was .cmd_name
cmd.path()     -> cmd.path         // field was .path_str
cmd.invoked()  -> cmd.invoked      // field was .invoked_leaf

BoolFlag.default_val is default_value to match. Rest.len() stays — it is self.values.length(), which is derived.

reefvision (18). .th -> .theme, .cmds -> .commands, .open_flag -> .is_open, .hangup_flag -> .hangup, .list_open -> .is_open, .sb_dragging -> .is_sb_dragging, .indeterminate -> .is_indeterminate, .closable/.maximizable/.minimizable -> .is_*, .count -> .sample_count, .pid -> .child_pid, .capacity -> .max_lines.

Four kept the field's name and dropped the accessor, because the field said it better: .item_idx (over selected_item() — it is an index), .show_gutter (over line_numbers() — a display toggle, and set_line_numbers already owns that vocabulary as a setter), .fill_cycle_idx and .canvas_fill_override (the accessors carried a redundant desktop_ prefix on a Desktop method).

text.string_interner.count(i) is now the field i.count (was .size). text.stringbuilder's two are untouched — explicitly deferred by the core spec.

Two accessors that look identical and are not

menubar.is_open() stays a method: its body is self.open_idx >= 0, which is derived, and C2 keeps derived predicates. desktop.is_open() and combo.is_open() were plain field reads and are gone. Same spelling, opposite answers — the body decides, not the name.

view.hit_bounds() stays a method for a different reason: it is virtual. The base body is return self.bounds, but TextArea overrides it to add the frame-column thumb. Deleting it would have removed a dispatch point, not a wrapper, and every TextArea mouse hit would have silently shrunk by a column. The §8.6 accessor rule cannot see virtuality; ROADMAP §8.2 now says to grep for override .* fn <name> before deleting any method-shaped accessor. examples/test_reefvision_field_names.reef pins it and RED-proves against deleting the override.

Breaking — graphics drops six field accessors (C2, ROADMAP §8.2)

Deleted, each of which was return self.<the same name> over a public field:

Was Read instead
Surface.get_width() / .get_height() s.width / s.height
Surface.get_format() / .get_pixels() s.format / s.pixels
FramebufferSurface.present_count() f.presented
ttf.ttf_font_valid(f) f.valid

These were shared fn methods on an object hierarchy, not free functions, so the question to settle first was whether any was a dispatch contract — MemorySurface and FramebufferSurface both extend Surface, and deleting a virtual method is not a C2 sweep. Measured: nothing overrides any of the six. Only kind() and end_frame() are overridden.

The surface_width / _height / _format / _pixels free functions stay. They read the fields directly now, which means the §8.6 accessor instrument counts them as renaming accessors — and it should, because the rule cannot distinguish a redundant wrapper from a public vocabulary. These are the latter: 15 call sites across graphics, gameui and the examples, and they are how every consumer outside backend/ addresses a Surface. ROADMAP §8.2 records that so a later re-measure does not read the count as debt.

Three call sites outside graphics moved, all in tests. examples/test_stdlib_surface_objects.reef gains two assertions while it is there: that an inherited field (width, declared on Surface) is readable through a narrowed FramebufferSurface, and that it agrees with surface_width on the same object.

Breaking — two encoding lookups report absence instead of a sentinel

  • json.json_get_type and json_get_type_doc return option.Option[int], not -1 for a key the document does not have. The -1 travelled straight out through the public _doc wrapper, where nothing distinguished "no such key" from a type tag.

  • toml.toml_array_len_doc returns option.Option[int], and gains the toml_array_len_doc_or(doc, key, default) twin that its own family already had (toml_array_item_doc / _or).

    Some(0) is an empty inline array; None is not an array (missing, a scalar, or an inline table). The function's previous comment reasoned about exactly that distinction in prose — "An EMPTY inline array returns 0 … 'Absent' is still the -1 below" — which is the argument for putting it in the type instead.

    Of its four reefvision callers three tested the -1 (n <= 0, n <= 0, n < 3). The fourth, syntax.reef:736, did not: while ri < nr simply never enters when nr is -1, so it was correct by accident. It now reads toml_array_len_doc_or(doc, key, 0).

encoding's sentinel triage: 55 sites, 2 sentinels

The remaining 53 are recorded in ROADMAP §8.2 as deliberate, not deferred:

  • ~25 "" from encoders and trimmers, where empty-in-empty-out is the correct value (§8.2b already ruled this shape);
  • 6 "" from base64_decode_internal, whose failure channel is a documented status out-param — the string is explicitly "only meaningful when status[0] == 0";
  • 24 -1 from private lexer scanners, a checked internal convention where every caller tests it: int_end < 0, hour < 0 or hour > 23 (a -1 falls outside the range), run_end != len (a -1 can never equal len);
  • MSGPACK_UNKNOWN(), a named constant whose value is -1 — the same shape as crypto's round-constant tables, and the same trap for a grep;
  • html.char_to_entity's "", which all three callers concatenate, and which is a correct no-op for a character needing no entity.

This is the second time a sentinel count collapsed under reading — crypto went 41 → 1 for the same reason. A count of sentinel-shaped text is not a count of sentinels; triage before scheduling.

Changed — one *_failure_kind per shape, not eleven (C4, ROADMAP §8.2)

io and fs between them declared sixteen *_failure_kind functions. Eleven were copies of two bodies. They now live once each in fs.stat:

fn io_failure_kind(path: string): error.ErrorKind      // exists ? IoError : NotFound
fn access_failure_kind(path: string): error.ErrorKind  // exists ? PermissionDenied : NotFound

Nine copies of the first (io.buffer.open, io.stream.open, io.file.open / delete / fsync, io.dir.remove_dir, fs.ops.remove_file, fs.ops.rename, fs.stat.stat) and two of the second (fs.perm.perm_failure_kind, and io.dir.change_dir_failure_kind, which wrote the same predicate inverted).

All eleven were private, so no public API changed. fs.stat is the home because it already owns exists(), all six consumers already import it, and it imports only core and the runtime.

Five are genuinely different and keep their own bodies: io.dir's create_dir / create_dir_all / list_dir, fs.link's (an existing path is AlreadyExists there, not IoError), and fs.ops.copy, which takes two paths.

A constraint moved with them. io.buffer had a warning on its own copy: the helper must never classify a write open, because fopen(path, "w") does not create the file on failure, so an exists()-recheck always sees the path absent and reports NotFound for what is really permission denied on the parent directory. That is a property of the helper, not of one caller, and it now sits on io_failure_kind. The tree already honoured it — io.stream.stream_open branches on mode first, and io.buffer's write and append opens use a flat IoError.

examples/test_fs_failure_kind.reef (18 checks) drives every entry point whose copy was deleted, through both branches of the predicate, and both of its load-bearing assertions are RED-proven: routing change_dir to the wrong helper, and routing a write-open through io_failure_kind, each make it fail with the diagnostic that names the mistake. These functions decide which ErrorKind a caller sees, so a mis-wire compiles cleanly and only shows up as a misclassification.

Breaking — gameui drops its 32 field accessors (C2, ROADMAP §8.2)

The ROADMAP row read "34 renaming accessors, each a judgement call, not a sweep." Measured, it was 32 accessors of which only 11 were judgement calls, and for those the judgement went the other way from what the row assumed.

  • 19 were pure redundancy — style_background over a public field named background (seventeen of that shape), ui_hot_id / ui_active_id, and mouse_x / mouse_y over fields of the same name. Deleted; read the field.

  • 11 were abbreviation-versus-full-name, and the FIELD was renamed rather than the accessor kept, because one name per concept beats two:

    was now
    Style.button_h / .checkbox_s / .slider_h / .slider_thumb_s / .text_field_h .button_height / .checkbox_size / .slider_height / .slider_thumb_size / .text_field_height
    UIContext.surf / .inp / .st .surface / .input / .style
    InputState.text .text_input

    scroll_x() and scroll_y() are the exception: they returned .scroll_dx and .scroll_dy, and the fields were the better half of that pair — they are deltas, set per frame from a wheel event. The fields keep their names.

    A UIContext field named surface sits alongside import graphics.backend.surface as surface without trouble (the receiver disambiguates); the constructor parameters keep the short names, because a parameter of that name genuinely would shadow the module.

Nothing outside gameui used a single one of the 32, which is what made this a sweep rather than an API negotiation.

Fixed — gameui state stores could fail invisibly

find_state and ensure_state return option.Option[int], and ui_set_float / ui_set_bool now return bool instead of the UIContext they were handed.

§8.3 recorded ensure_state's -1 as "a silent drop". The drop was real but it was one step further out than the sentinel: both call sites already tested the -1 — and then returned the unchanged UIContext anyway. A UIContext is a reference, so that return value carried no information at all. A caller who filled the 256-entry state table got back the same context it passed in, with the value not stored and no way to distinguish that from success.

examples/test_gameui_state.reef (29 checks) fills the table and asserts that the set reports the failure and that the value really was not stored.

Breaking — one KeyCode for the whole tree (input.keys, ROADMAP §8.2a)

ui.event, reefvision.event and gameui.input each declared their own KeyCode. They were three different types, not three copies of one — the duplication survey that flagged them compared function body text, and return KeyCode_Enter() reads identically in two files that mean different things by it.

Owner decision, 2026-09-10: one shared KeyCode in the new input.keys, whose Char carries an int Unicode codepoint.

import input.keys as keys

let k = keys.key_char(0x1F600)          // an emoji, past the BMP
keys.key_codepoint_or(k, 0)             // 128512
keys.keycode_eq(k, keys.key_char(0x1F600))   // true
  • ui.event.KeyCode.Char(char) → keys.KeyCode.Char(int). A Reef char is one byte, so ui could not represent a non-ASCII keypress at all. This is a breaking change in the direction of correctness. ui.event.key_press now takes an int codepoint.
  • ui.event.KeyEvent.character and reefvision.event.KeyEvent.ch are both gone. Each was a second place to keep the codepoint, and therefore a way for the two to disagree. Read it with keys.key_codepoint(code) (or key_codepoint_or, since asking a non-printable key for its codepoint is ordinary rather than a failure).
  • reefvision.event.key_event lost its ch parameter — the code carries it now. key_esc is keys.key_escape; keycode_tag is keys.keycode_index.
  • The arrow constructors are key_arrow_up / _down / _left / _right. Plain key_up already means "a key was released" in ui.event, and a shared module is exactly where that collision would have bitten.
  • Modifiers moved too, with modifiers_none() as the single spelling (ui.event.no_modifiers is gone).
  • gameui.input keeps its own KeyCode and shares only Modifiers. That enum exists to be indexed — one variant per letter and digit feeding key_index() into a fixed-size array — which is a different design, not a copy of this one.

Fixed — reefvision.commands accelerator matching

accel_eq had been relying on keycode_eq being tag-only to fold Alt-q onto an Alt-Q binding. Once the shared keycode_eq started comparing the payload, that fold stopped happening and the two accelerator tests went red. It now asks for the variant explicitly (keycode_index) before applying the documented case-folding, which is what the function's own comment always said it did.

This was the only behavioural regression in a port that touched 35 files, and the suite caught it.

Known defect surfaced by that port — == on an enum is tag-only

Filed as reef-lang#68, with severity high, because it reaches the stdlib's own core types:

@option.Option[int].Some(1) == @option.Option[int].Some(2)   // true
@result.Result[int, error.Error].Ok(1) == Ok(999)            // true

codegen.ml's Eq/Ne arm emits ((a).tag == (b).tag) for every enum, with no branch on whether the enum declares a payload-carrying variant. Tag-only is correct for a payload-free enum, which is most of the tree — which is why this stayed invisible until KeyCode gained a payload. A grep of the whole tree found zero live sites comparing an Option or Result with ==, so nothing is getting a wrong answer today; it becomes live the moment someone writes the obvious thing. input.keys.keycode_eq exists to avoid it, and examples/test_shared_keycode.reef asserts the trap directly so the workaround cannot be "simplified" away while it is still open.

Changed — three cross-package duplicate bodies collapsed (C4, ROADMAP §8.2a)

io.file.rename now calls fs.ops.rename; io.file's private rename_failure_kind is deleted. Both files carried a comment naming the other and stating that the bodies were independent — which describes the setup for a drift rather than a defence against one. Two public names for one operation is right (io.file is the file API, fs.ops the tree API); two bodies is not.

io.dir.is_directory now calls fs.stat.is_directory. io.dir.dir_exists was deliberately left alone — it is not a third copy, despite looking like one: it runs an access(2) check before the isdir, so it asks a different question even where the two answers agree.

ui.widget.Size, ui.widget.size() and ui.widget.size_zero() are deleted rather than shared. They were a byte-identical copy of graphics.core's, and every other file under ui/ was already spelling it core.Size — only constrain_size, in the same file, used the local one. constrain_size now takes and returns core.Size. No caller outside ui/widget.reef was affected.

Fixed — ROADMAP §8.2a was measuring text, not types

Three of that section's five "duplicated across packages" rows were the instrument, not the tree. The duplication pass hashed function body text, and body text is identical whenever two different enums happen to share a variant name:

  • ui.event.key_enter() and reefvision.event.key_enter() are both return KeyCode_Enter() — naming two unrelated enums. ui.event.KeyCode has Char(char) plus None/Escape/Space; reefvision.event.KeyCode has a payload-free Char (the codepoint lives in KeyEvent.ch: int) plus Esc/BackTab/Insert/Unknown; gameui.input.KeyCode has no payloads at all and spells A..Z, 0..9, F1..F12 as separate variants feeding a dense key_index().
  • text_align_left / _center / _right: graphics.font.font.TextAlign has a fourth variant, Justify.
  • point / size_zero: Point is float x/y in graphics.core and int x/y in reefvision.cell (terminal cells).

The keycode module §8.2a asks for is therefore a design decision, not an extraction, and the sharpest edge is Unicode width: Reef's char is one byte, and reefvision decodes UTF-8 into an int codepoint. §8.6 gained the general rule (hash the TYPE, not just the text — within a package that collision is nearly impossible, across packages it is the normal case).

Breaking — reefvision stops encoding "absent" and "failed" as negative ints (C16)

Four public entry points changed shape. Each returned a negative number that the caller had to remember to test, and forgetting was not a no-op — -1 fed straight back into an index is a bounds violation, so the sentinel put memory safety on caller discipline:

Was Now
Desktop.window_hit_test(x, y): int Option[int], None for no window there
Desktop.window_drag_owner(...): int Option[int]
sys_glyph_x(...): int Option[int], None when the titlebar glyph is not shown
yield_exec(...): int Result[int, error.Error]

self.window_at(-1) was the shape that made this urgent.

sys_glyph_x was safe only accidentally: its three callers compare x == sys_glyph_x(...) and a real screen x is never negative, so the sentinel could not match by mistake — it was doing no work the type could not do better.

yield_exec is the one to read carefully, because three unrelated meanings shared one int:

-2          could not launch (missing path / not executable / spawn failed)
-1          the child ran, but the terminal could not be restored afterwards
0..255      the child's exit status, 128+sig when it was signalled

The ambiguity was live enough that the existing test already carried a defensive assertion against it. Ok(status) now means the child ran; the two failures are Err with distinct messages.

window_hit_test had a second return the first pass missed — an early return self.active_idx before the loop. The compiler caught it, not review.

Changed — reefvision helpers exist once (C4)

Eleven redundant copies of ten helper bodies are gone; reefvision's internal identical-body duplicate count is zero. is_left_click and is_activate_key now live in reefvision.event, next to the event type they inspect, and are exported — seven definitions replaced by two. clamp_byte, clamp_dim, min_int, ansi16_r / ansi16_g / ansi16_b and rgb_cube_level likewise moved to their owning module and are exported rather than re-declared.

The event predicates are worth a note because no single instrument found them. is_left_click sat privately in button, checkbox and radio; is_left_press in combo and window. They are the same predicate, spelled differently only because one combined two guards with or — so a body-identity check reported two separate duplications (3 copies and 2 copies) rather than one predicate copied five times, and a name-only check would have called them two unrelated functions. Reading the pair is what saw it.

Breaking — crypto (ROADMAP §8.5 item 4)

  • sha256_file returns result.Result[string, error.Error], not "" on a failed open. A SHA-256 digest is never the empty string, so the old sentinel was at least detectable — but it carried no reason, and a caller who skipped the check would compare "" against a real digest and simply get "not equal". This is the package's only genuine failure channel: hashing a string cannot fail, which is why crypto sitting at 0/6 on Option/Result was mostly correct-by-nature rather than debt.

  • md5_hex, md5_hex_lower, sha1_hex, sha1_hex_lower, sha256_hex and sha256_hex_lower are deleted. All six had exactly one line in the body — return md5(data) and its twins — and the suffix was actively misleading:

    crc32.crc32_hex("123456789")  ->  CBF43926     (UPPERCASE)
    md5.md5_hex("abc")            ->  900150983c…  (lowercase)
    

    The same _hex suffix meant opposite things in two modules of one package. Deleting the aliases removes the ambiguity without renaming anything that works today: md5(), sha1() and sha256() already return the canonical lowercase digest, and crc32_hex / crc32_hex_lower remain an unambiguous pair within their own module.

  • crc32_bytes(data: [int], len: int) is deleted. It was crc32_binary(data: [byte]) with a worse signature — an [int] standing in for bytes, plus a separate length the array already carries.

Changed — crypto hex formatting is one module (C4)

crypto/hexfmt.reef is new and replaces five copies of the same machinery:

Was Copies
md5_hex_digits / sha1_hex_digits / sha256_hex_digits three copies of the literal "0123456789abcdef"
sha1_state_to_hex / sha256_state_to_hex byte-identical apart from the word count
md5_state_to_hex the same loop, walking each word's bytes the other way
crc32's get_hex_digits_upper / _lower a fourth and fifth copy of the table
crc32's int_to_hex_upper / int_to_hex_lower one loop written twice, differing only in which table it indexed

crc32.reef alone went 411 → 256 lines. The buffer-filling loops live in crypto.hexfmt rather than delegating to encoding.hex for a measured reason: encoding.hex.byte_to_hex allocates a fresh string per byte, which would put a per-byte allocation in a hash's hot path. hexfmt writes into one preallocated buffer, as all five originals did.

Behaviour is unchanged, and that is asserted against published vectors rather than against this implementation's own output — examples/test_crypto_hexfmt.reef (24 checks) pins MD5/SHA-1/SHA-256 for "", "abc" and the pangram, the RFC-2202/4231 HMAC pair, the CRC-32 catalogue check value CBF43926, and int_to_hex at 0, 255, -1 and INT_MIN — the last two being the case each hand-written copy had to get right on its own, since C's % can go negative and the divide has to behave as a logical shift.

Breaking — stdlib C16, the "small tier" (ROADMAP §8.5 item 2)

Six stdlib entry points stopped reporting failure with an in-band value. Each change is here because a call site in this tree was measured getting it wrong, not because a sentinel is untidy.

  • sys.process — process_fork returns ForkResult, not int.

    match process.process_fork()
        ForkResult_Child()      => ... process.exit_now(0) end
        ForkResult_Parent(pid)  => ... process.process_wait(pid) end
        ForkResult_Failed()     => ... end
    end match
    

    The measurement: four of the five process_fork callers in the tree never tested for -1. A failed fork therefore ran the parent branch, and the process.process_wait(pid) that followed was process_wait(-1) — which waits for any child, not for the one that was never created. The one caller that did check (examples/test_http_local_server.reef) is the reason the enum has a Failed arm rather than an Option: the three outcomes are three different places to be, not one value plus an error.

    Also in sys.process, same release: process_spawn, process_spawn_shell, process_run, process_setsid, process_getpgid and process_wait_any return option.Option[int]; process_setpgid and killpg return bool (matching process_kill, whose success carries no value); and process_wait_any_nohang returns a new WaitAnyResult enum — Exited(pid) / NoneReady / Failed — because its three answers used to arrive as pid / 0 / -1, one of which means loop-again and one give-up, told apart only by a caller who remembered both.

    process_wait's -1 is deliberately NOT converted. It is entangled with the process_was_signaled() / process_exit_code() / process_term_signal() global side-channel — the exit status lives in module state, not in the return value — and that is a separate design problem across 17 call sites.

  • net.http — HttpUrl.is_valid is gone. http_parse_url returns option.Option[HttpUrl] and http_request_new returns option.Option[HttpRequest].

    A bool field is a Maybe spelled as a product, and therefore a Maybe a caller can forget to open. Two of the six call sites in net/http.reef did exactly that: both redirect loops re-parsed the current URL after a 3xx and used the result without ever reading the flag, so a malformed URL became a zero-host HttpUrl that was then formatted into a request line. Both are now forced by the type. The redirect loops also parse each URL once now (request_for builds a request around an already-parsed HttpUrl), where before they parsed it twice per iteration.

    Two guards were deleted as unreachable: http_send_timeout and http_send_binary each opened with if req.url.is_valid == false, and a HttpRequest can no longer exist without a URL that parsed.

    "://host/" still parses, with an empty scheme — that rejection happens downstream in http_send_timeout ("Unsupported scheme"), not in the parser. Pinned by examples/test_http_parse_url_option.reef rather than assumed.

Changed — stdlib hygiene

  • test.framework: nine copies of the assertion skeleton collapsed to one. assert_eq_int / _bool / _string / _float, assert_true / _false, assert_not_eq_int / _bool / _string and assert_contains_string each carried their own copy of increment count, increment passed-or-failed, print "." or the FAIL header. That skeleton is now one exclusive fn tally(ok, message): bool, and the two operand lines are one module-level show_operands. Public API unchanged; 268 -> 200 lines.

    assert_eq_float keeps printing its own operands on purpose: print_float emits C's %f (1.500000) while string interpolation emits 1.5, and that difference is pinned by test_framework_new.expected. The refactor is byte-identical on all three framework tests.

  • ROADMAP §8.2/§8.3/§8.5 re-stated against the tree, including one item measured and then deliberately left: net's DownloadResult encodes four outcomes in three booleans (success + is_redirect + cancelled), so illegal combinations are representable. It is internal-only, 55 boolean-field touches across 6 functions, and no defect is reachable from it today — which is why it is recorded rather than done.

Fixed

  • Diagnostics inside a ${...} interpolation fragment report the real line number (reef-lang#11 / BUG-182). An error at a node inside a fragment reported a line in the millions whenever any literal text preceded the first ${:

    println("a=${n + 1} b=${nope + 1}")
    -> Name Error: Undefined variable 'nope'
         at inner_err.reef:line 3000003, column 37     (should be line 3)
    

    Codegen_util.interp_part_loc added idx * 1_000_000 to the line each fragment's parse was seeded with. That offset existed to keep (line, column)-derived C identifiers from colliding across sibling fragments of one string — two ${match ...} fragments used to collapse onto one __reef_locals GC root slot (BUG-139). 0.9 Stage 4b re-keyed every synthesized temp to its node's eid, which left the offset with no job except to corrupt diagnostics, so interp_part_loc is deleted rather than adjusted.

    The issue carried an explicit "do not fix by dropping the offset" warning, because dropping it would re-open BUG-139 and BUG-184. That warning is spent, and it was verified spent rather than assumed:

    • With the offset gone the emitted C is byte-identical across all 909 corpus units, which is the proof that nothing derives identity from those locations.
    • That corpus includes BUG-139's own repro (test_match_scrutinee_interp_disambig.reef — two ${match} fragments in one string), whose generated C still shows two distinct temps (__scrut_e7, __scrut_e12) and four distinct frame roots.
    • BUG-184's nested-fragment witness (baseline_nested_interp_match) is byte-identical too; its own comment already recorded that eid-keyed temps give the two scrutinees distinct slots.
    • No C identifier anywhere in the compiler is built from a line or column; interp_part_loc had exactly one live caller.

    A wrong line number is worse than it reads: it silently defeats tooling that consumes the diagnostic. This one cost a compiler-guided refactor 60 wasted iterations, each editing a line that could not exist in a 99-line file. Pinned by test_interp_fragment_line.reef, RED-proven against a reintroduced offset (line 1000025 vs line 25).

    Still imperfect, and unchanged by this: a fragment's nodes carry the string's position, so the column can point at the string rather than the offending sub-expression. True fragment offsets from the lexer are independent work.

  • ui.textfield can be typed into (reef-lang#64, medium). insert_char and delete_char_at returned the receiver unmodified and delete_char_before moved the cursor without deleting anything — while handle_event reported Changed for all three, so a redraw driven by is_changed repainted forever with identical content. Measured before:

    after typing 'abc': text='' len=0 cursor=0
      each keypress reported changed = true true true
    after Delete at 0:  text='hello' len=5 changed=true
    

    The three helpers now do real substring/concat edits and return whether they changed anything, so Changed is reported only when something did; a refusal (at max_length, or backspace at position 0) is Handled instead. The bodies had said "in a real implementation, we would modify the string buffer" since before core.str could express it.

    A second facet fixed with it: the cached text_length disagreed with text. set_text truncated the count to max_length while storing the full string — set_max_length(5) then set_text("abcdefghij") left text='abcdefghij' with text_length=5 — and textfield_with_text applied no cap at all. Every write to text now goes through one private write_text, which caps the string, sets the length from it, and clamps the cursor. text_length is redundant (str.length is O(1) since BUG-147) but it is public API; a single writer is what keeps it from drifting, since a cache maintained by hand at each of five sites is a cache that drifts.

    Suite 146 → 168 assertions; section 8 covers insertion at the cursor rather than only at the end, backspace vs delete cursor behaviour, both no-op-is-not-Changed cases, max_length enforcement, and the set_text truncation.

  • ui has one event-result type instead of six — D3 of docs/design/2026-09-08-ui-modernization.md. Breaking.

    widget.EventResult is now what every widget's handle_event returns. Its kind enum gained Clicked, Toggled and Changed — the three semantic kinds the per-widget types added — plus predicates is_clicked, is_toggled and is_changed. is_event_handled is true for all of them, which is what the five deleted converters did.

    The design counted four per-widget types; there are five. Four are structs (ButtonEventResult, CheckboxEventResult, SliderEventResult, TextFieldEventResult) and ScrollViewEventResult is an enum (Consumed(ScrollView) / Ignored) with its own accessor pair, which is why a name-based survey missed it.

    Each of the five carried the widget itself as a payload, and that was the active defect, not just duplication — see reef-lang#63. Since setters mutate (reef-lang#62) the payload was always the receiver the caller already held, so it is gone rather than merged.

  • A button losing focus no longer reports that it wants focus (reef-lang#63, medium). Measured at rev 1393:

    focus OUT -> requests_focus = true
    focus OUT -> releases_focus = false
    hover     -> requests_focus = true
    

    ButtonEventResultKind had four variants — Ignored, Handled, Clicked, RequestFocus — and no ReleaseFocus, so the focus-out arm returned the nearest thing that existed and focus-in and focus-out were indistinguishable to the caller. A focus manager honouring the request re-focuses the widget that just released focus.

    The hover case is worse and has the same root: button_result_handled() filled the struct's button field with a freshly allocated dummy_button() and threw the real one away, so it was unusable wherever the caller needed the widget back — and RequestFocus became the de-facto "handled, here is the widget" constructor. Every state transition in handle_mouse_event returned it, so a plain mouse-move onto a button asked for keyboard focus. The payload field distorted the kind.

    All four struct types also allocated a throwaway widget in their _ignored constructor (new Checkbox(widget.widget_id(0), "") and siblings) — in an event loop, one allocation per ignored event. Both facets die with the payload; no result allocates now.

    Latent rather than live: window_handle_event does not yet dispatch mouse events to widgets. It would have become live for the first consumer to wire a focus manager to widget results.

  • ui widget behaviour lives on the widgets — phase 4. preferred_size, layout, handle_event and paint are methods; 82 free functions deleted, 0 added, and ui/widget.reef grew 87 lines against 520 removed from the eight widget modules.

    handle_event is virtual on Widget (default: event_ignored()), which is what D3 was approved to enable — a common result type is the precondition for a common slot. paint was already virtual; layout is now an exclusive proc rather than another mutate-and-return.

    preferred_size and layout are deliberately NOT virtual. panel and scrollview take a content_size, the other six take a theme.Theme, and those are genuinely different inputs rather than a naming accident — a panel cannot compute its preferred size from a theme. Unifying them needs a decision about what a layout pass passes down, which this phase does not make.

    The private per-event helpers (handle_mouse_event, handle_key_event, handle_focus_event, the paint_* helpers) stay module-private free functions. They are implementation detail, not surface.

    Dropping the payload orphaned nine let bindings whose value nothing read any more, because the private helpers behind them were themselves mutate-and-return (start_v_drag, end_drag, update_hover, insert_char and six siblings). Those nine are now procs called as statements, so the code no longer reads as though the returned widget mattered.

    Also deleted here: the 24 <widget>_get_id / _get_state / _get_widget_state / _get_bounds free functions that phase 2 left behind because they were filed under "widget interface" rather than "accessors". They were field reads (C2).

    Verified differentially rather than by inspection: each of the 32 moved bodies was reverse-substituted (self back to the original receiver) and diffed against rev 1393. 20 are byte-identical; the 12 that differ do so only in the intended ways — the preferred_size call becoming a method call, the D3 result constructors, and panel's child-painting tail being merged into its moved body.

    Suite 126 → 146 assertions. Section 7 is new and covers handle_event, which D4 asked for from the start and phase 1 did not deliver — that gap is why reef-lang#63 went unnoticed.

  • ui widget setters now mutate and say so — phase 3 of docs/design/2026-09-08-ui-modernization.md, closing reef-lang#62. Breaking.

    49 free setters of the shape fn button_set_label(b: Button, label: string): Button became exclusive proc set_label(label: string) methods, and 7 internal *_with_* helpers taking a receiver (button_with_state, checkbox_with_hover, slider_with_dragging, textfield_with_focus and siblings) became set_state / set_hovered / set_dragging / set_focused. button.button_set_label(b, "x") is now b.set_label("x").

    The signature was the defect, not the behaviour. Widgets became objects on 2026-08-23 and objects have reference semantics, so these functions had been mutating their argument and returning the same reference ever since. Measured before the change:

    let b1 = button.button("original")
    let b2 = button.button_set_label(b1, "changed")
    
    b1.label = changed
    b2.label = changed
    aliased  = true
    

    A : Button return type promising a copy while delivering an alias is worse than a mutating method, and the with_ spelling — the conventional name for a copy — was worse still. Removing the return value is what makes the mutation visible at the call site.

  • ui widgets gain clone (design D2). shared fn clone(): <Widget> on all eight widgets, hand-written per type because objects have no derived copy. It is a shallow copy: inherited Widget state (bounds, wstate, visible, enabled, focusable, tab_index) and every own field are copied, Panel's kids array is copied element-wise, and referenced widgets are shared rather than deep-copied.

    This is the replacement for what the old setter signature falsely promised. button_with_state(base, Hovered) to paint a hover preview — the real use case, and exactly the one that was silently broken — is now let hovered = base.clone() then hovered.set_state(...).

    The behaviour suite goes 98 → 126 assertions: section 5 was rewritten from pinning the #62 defect to pinning the fixed contract, and a new section 6 asserts clone independence once per widget so a reintroduced pseudo-copy setter fails loudly.

  • 193 field-wrapping accessors deleted from ui (C2, modernization phase 2). Breaking: read the field.

    190 free functions across all 22 modules — theme 25, backend/platform 20, event 17, window 16, backend/x11 15, and so on — plus the three method-form wrappers on the Widget base itself (get_id, get_bounds, get_wstate, 55 call sites). Net −769 lines.

    The phase-1 behaviour suite's 98 assertions pass unchanged, because they were written against field reads rather than accessors. That was the point of sequencing coverage first.

  • ui gains a widget behaviour suite — phase 1 of docs/design/2026-09-08-ui-modernization.md. 98 assertions across all eight widgets: construction defaults, every setter's effect on the field it names, state inherited from the Widget base, and preferred_size relations.

    ui had roughly 26 real assertions across 13,355 lines before this, with two of its four test files containing none — test_ui_widgets_import would pass against a package that painted nothing. Phases 2–4 delete 190 getters and change 66 setter signatures; the design sequences coverage first for that reason. No production code changed.

  • graphics.image.create_image no longer allocates a short buffer on 32-bit size overflow (reef-lang#61, high). It computed width * bytes_per_pixel * height in a 32-bit int unchecked. A negative size reaches the allocator's BUG-056 guard and aborts; an overflow wraps to a positive value the allocator cannot recognise.

    Measured: create_image_rgba(100000, 100000) succeeded, reporting width=100000 height=100000 stride=400000 while allocating 1,345,294,336 bytes instead of 40,000,000,000 — the struct lied about its own dimensions. And because get_pixel's offset y * stride + x * bpp wraps the same way, a high-row access landed back inside the short buffer: wrong pixel, no bounds violation, no error anywhere. Silent corruption rather than a crash.

    graphics.backend.memory.surface_to_rgba8_bytes had the same hole and was not covered by the same fix — it computes w * h * 4 from a surface that may be 1 byte per pixel, so a legal 30000×30000 grayscale image (900 MB) needs 3.6 GB as RGBA8. Both now check by division, so the check cannot itself overflow, and panic with a specific message.

    Panicking rather than returning Result matches the allocator's existing policy for an impossible allocation, and callers that must recover from attacker-supplied dimensions already bound them first — image/png.reef and image/bmp.reef cap and return their own Err well before reaching this.

  • graphics.image.png and .bmp use the real Result. Breaking: load_png / load_png_bytes / load_bmp / load_bmp_bytes return Result[image.Image, error.Error]; the hand-rolled PngResult / BmpResult structs ({ok, img, error_msg}) and their six accessors are gone. Use result.is_ok / unwrap_ok / unwrap_err(r).message.

    Both modules already imported core.result. The clones also forced make_png_error to allocate a dummy 0×0 image on every failure, purely to fill a field the caller was never meant to read — an Err carries no image at all.

  • 31 field-wrapping accessors deleted from graphics (C2). Breaking: read the field. image_width(img) → img.width, glyph_x(g) → g.x, metrics_ascender(m) → m.ascender, path_bounds(p) → p.bounds, and 27 more across canvas, core, image, path, font/bitmap, font/font, font/text and font/ttf.

  • ReefVision's exported sentinels return Option/Result (C16). Breaking.

    term.term_write and term.term_read return Result[int, Error]. The old -1 collapsed two different failures into one value — the terminal was never opened and the write(2)/read(2) itself failed — which a caller could not tell apart, nor from a short write. term_read's Ok(0) is EOF and is not an error.

    theme.desktop_fill_preset_index returns Option[int]; its own comment already documented the -1.

    Five other sentinel-returning functions in the package are deliberately unchanged: they are private index finders whose -1 never escapes (commands_find, syntax.find_ctx/find_list, desktop.walk_poll_fd, window.sys_glyph_x), and view.View.poll_fd() returns the POSIX no-descriptor -1 straight into poll().

  • 38 field-wrapping accessors deleted from ReefVision (C2). Breaking: read the field.

    term_width(t) → t.width, theme_name(th) → th.name, group_focused_idx(g) → g.focused_idx, vt_cursor_x(vt) → vt.cx, menu_item_label(it) → it.label, and 33 more across commands, input, syntax, term, theme, view, vt and menubar. Each returned a public field unchanged. Net −152 lines.

    Five sentinel-returning functions were deliberately left alone, because they are private index finders whose -1 never escapes the module as a value: commands_find, syntax.find_ctx / find_list, desktop.walk_poll_fd, window.sys_glyph_x. That is the same line drawn for symbol_table.slot_in_scope, which C16 kept for the same reason.

  • net.http's private parse_int got the overflow guard too — the fourth copy of the same unguarded accumulate, after json, toml and yaml. This one parses HTTP header values, Content-Length among them, so an over-long digit string from a hostile peer wrapped and returned Some(small number).

  • gameui.context gains the Option form its _or API was missing (ROADMAP §8.2). ui_get_float(ctx, id, default) and ui_get_bool took a default with no Option twin at all — so a caller could not distinguish "stored 0.0" from "never stored", which is exactly what 035_ERROR_HANDLING.md:427 requires the Option form to answer.

    ui_get_float / ui_get_bool now return Option, and ui_get_float_or / ui_get_bool_or are the defaulted twins delegating to them — the documented direction, rather than the default form being the only one. Both had zero callers, so nothing outside the module moved.

  • sys.proclist.proclist_get returns Option[Proc] and sys.pty.pty_open returns Option[Pty], pty_spawn returns Result[int, Error] (ROADMAP §8.2). Breaking.

    proclist_get(999999) used to return a fabricated zero record — pid: 0, empty user/comm — indistinguishable from a real entry, and pid 0 is a real pid on some systems. (The runtime's at() does bounds-check and return NULL, so this was never an out-of-bounds read; the fabrication happened on the Reef side.) It now checks against proclist_count().

    pty_open used to return Pty { master_fd: -1 } — a half-built value whose invalidity you had to remember to ask about, exactly the shape sync.mutex had before rev 1373. pty_ok survives for the same reason mutex_is_valid did: it is a derived predicate (master_fd >= 0) answering a later question the constructor's Option cannot.

  • encoding.{json,toml,yaml} no longer wrap on integer overflow. A value too large for a 32-bit int was accumulated with no guard, so {"big": 9999999999} came back as Some(1410065407) — a wrapped value indistinguishable from one that was really there.

    The 0.9.2 C16 pass converted these three to Option, which fixed "not a number" but not "a number that does not fit". C5 step 0 had already added exactly this guard to core.convert.to_int; the three private copies never received it. The guard is asymmetric — INT_MAX ends in 7, INT_MIN's magnitude in 8 — so -2147483648 is still accepted.

  • encoding.varint's decoders return Result and check bounds. A varint whose continuation bit was set on the final byte of the buffer ran off the end, and Reef's array bounds check aborted the process:

    Runtime error: array index 3 out of bounds (length 3)
    

    For a decoder handed untrusted bytes, truncation is an ordinary input condition, not a program error. The 10-byte cap is an error now too, instead of a silent break that returned a value assembled from a prefix of a malformed varint.

    New varint_decode_at returns value and size together; varint_decode_bytes is deleted — it was the tree's other literal out-param (out_value: [int]) and duplicated what varint_decode_at now returns.

  • Five byte-identical private char_to_int helpers deleted from encoding.{html,json,toml,yaml} and graphics.font.text, all restating core.convert.to_code (D4/C4: one library per concept).

  • compress.lz4 returns Result[int, Error] (ROADMAP §8.2, C16). Breaking: lz4_compress, lz4_compress_fast, lz4_compress_hc and lz4_decompress. Buffer-passing kept, as for zlib/gzip.

    The three compressors share one failure convention — LZ4 returns 0, and only 0, meaning the destination was too small — so they share one wrapper. lz4_decompress signals malformed input or an undersized destination with a negative return, a different convention the old int return blurred together with a valid size.

    Verification is weaker here and worth stating: liblz4.so's dev symlink is absent on the development machine, so nothing importing compress.lz4 links, and nothing in the tree imports it. This is --emit-c plus gcc -fsyntax-only (the stdlib gate) only — no runtime test.

  • compress.zip is deliberately NOT converted, and is now reef-lang#60. Its zipfile_entry_size reads libzip's zip_stat at a hand-computed offset, with the source's own comments saying "Actually on 64-bit it's more complex due to struct packing" and "Approximate - may need adjustment" — and on LP64 size sits at offset 24, so the code reads the low half of index instead. That wants a C accessor, not a Reef-side signature change, and libzip.so's dev symlink is absent so it cannot be tested here either.

  • compress.zlib and compress.gzip return Result[int, Error] instead of a -1 sentinel (ROADMAP §8.2, C16). Breaking: zlib_compress, zlib_compress_level, zlib_decompress, gzip_compress and gzip_decompress all changed.

    The buffer-passing shape is deliberately KEPT. The survey proposed converting these to Result[[byte], Error], moving allocation into the library — but reefzip, the only real consumer, allocates its buffers once and reuses them across every call, which is exactly what a compression primitive's caller should be able to do. Taking that away would be a regression dressed as modernisation. What was actually wrong was the return: a size and a failure sharing one int.

    The Err now carries what the sentinel discarded — zlib's own return code, and a message naming which stage failed (deflateInit2_, a too-small destination, deflate/inflate itself, truncated input). gzip_compress alone had three distinct -1 sites that were indistinguishable to a caller.

    No _or twin: a failed compression is not an ordinary miss.

    graphics.image.png was the notable consumer — its own comment read "zlib_compress returns -1 on failure; downstream byte-math would under-size the output buffer and corrupt the chunk". It now matches on the Result, so that hazard is unrepresentable rather than commented.

  • Eight field-wrapping accessors deleted from compiler.diagnostics and compiler.source (C2). Breaking: read the field.

    error_count / warning_count / total_count → bag.errors / bag.warnings / bag.count; pos_file / pos_line / pos_col → pos.file / pos.line / pos.col; span_start / span_end → span.start / span.ending. Each returned a public field unchanged.

    has_errors deliberately stays. It is bag.errors > 0 — a derived predicate, not a wrapper, and the same distinction that kept sync.mutex.mutex_is_valid when the valid field went away. C2 removes accessors that restate a field; it does not remove ones that compute something.

    Output of test_diagnostics and test_source_location is byte-identical.

  • compiler.symbol_table is one generic table, not three (ROADMAP §8.2, D9/D13). Breaking: every name changed.

    before after
    create_int_table() / create_string_table() / create_ptr_table() create_table[:V]()
    define_int / define_str / define_ptr define
    lookup_int / lookup_str / lookup_ptr lookup → Option[V]
    scope_push_int / _str / _ptr (and pop, depth) scope_push / scope_pop / scope_depth

    This was the last typed Int*/String*/Ptr* triplication in the tree — the collections 0.9.2 redesign deleted the others (IntOrderedMap / StringOrderedMap / PtrOrderedMap, IntTrie / StringTrie). The module's own comment said the split existed "due to generic limitations"; generics carry it now, as HashMap[K, V] and Trie[V] already demonstrate.

    Collapsing removed two things the copies were hiding:

    • lookup_ptr returned a bare pointer with null meaning "not found", so the Ptr copy never received the C16 treatment its Int and Str siblings got in 0.9.2. One generic lookup returns Option[V] for every V, so the sentinel has nowhere left to live.
    • a private DJB2 hash_string, duplicating impl Hash for string in core.hash — which D4 established as the single home for hashing and explicitly de-exported the standalone copy from. Keys hash with name.hash() now, masked against the sign bit before the modulo the way collections.hashmap does.

    The default_value field is gone: a per-table constant nothing could set, and the reason the pre-0.9.2 lookup could not distinguish "bound to 0" from "not bound". 656 lines → 235, with behaviour byte-identical — test_symbol_table.expected did not move.

    Unchanged and still true: scopes are fixed-capacity, so an insert into a full scope is silently dropped. Pre-existing, noted in examples/katas/stdlib, and not addressed here.

  • reefc now auto-links the compression modules (ROADMAP §6.5), and the four tests that were skip-gated because of it now run.

    auto_detect_libs covered compress.zlib only, so compress.gzip failed at link with undefined symbol: compressBound unless the caller passed -lz by hand. The roadmap entry framed this as "gzip/zip use libz directly", which is wrong about zip: the three modules need three different libraries, and -lz would never have helped compress.zip.

    module library
    compress.zlib, compress.gzip libz
    compress.zip libzip (zip_open, zip_fopen_index)
    compress.lz4 liblz4 (LZ4_compress_default)

    All four are auto-linked now, and duplicate flags are de-duplicated so importing both zlib and gzip emits -lz once.

    test_gzip, test_zlib, test_save_png and reefzip are un-skipped — suite skips drop 28 → 24 and all four pass. test_save_png and reefzip needed nothing but the auto-link. test_gzip and test_zlib declare the zlib externs themselves rather than importing the stdlib, so auto-detection had nothing to detect; each gained a link-effect import, the same pattern test_bug121_gzip_framing.reef already used and that typechecker.link_effect_modules exists to sanction.

  • IEEE-754 special-value conformance in math.basic, and therefore in math.floats (which forwards to it since reef-lang#56). Three defects, all found by adversarial review rather than by the measurements that shipped with the original changes:

    • hypot_f(±inf, NaN) returned NaN; IEEE-754 requires +inf — infinity wins over NaN for hypot by specification. abs_f(NaN) is NaN, every magnitude comparison against NaN is false, so control fell into the scaled branch and computed NaN/NaN. Infinity is now tested before any arithmetic. This one is a conformance regression the forwarding introduced: math.floats.hypot_f used to be libc.hypot, which is conforming.

    • Signed zeros were not preserved. sqrt_f(-0), atan_f(-0) and cbrt_f(-0) all returned +0; IEEE gives -0. Each guard read if x == 0.0 then return 0.0, and -0.0 == 0.0 is true. Returning x instead carries the sign through.

    • atan2_f collapsed four signed-zero cases into one. x == 0.0 is true for both +0 and -0, so atan2(+0, -0) returned +0 where IEEE requires +π, and atan2(-0, +0) returned +0 where it requires -0. The full zero table now matches libc bit-for-bit on all eleven cases. These are branch-cut semantics, not conventions — they are what makes atan2 usable for complex argument.

    Pinned by test_math_ieee_specials.reef (31 assertions), which compares bit patterns wherever a sign is under test, because -0.0 == +0.0 is true and an equality assertion cannot see the defect.

    Not fixed, and not claimed: the atan2/hypot infinity tables beyond the cases above, and sqrt_f(-0)-adjacent behaviour for other functions in the module, have not been audited against IEEE-754 as a whole.

  • sync.mutex.mutex_create returns Option[Mutex] (ROADMAP §8.3, C16). Breaking: callers must unwrap.

    It used to return a Mutex carrying a valid: bool saying whether it had actually worked. A caller that forgot to check got an object that silently did nothing — mutex_lock on an invalid mutex was a no-op, which for a mutual-exclusion primitive is the worst available failure mode: the critical section runs unprotected and nothing reports it.

    The valid field is gone. lock_ptr is now the single source of truth for liveness (non-nil = usable, nil = destroyed), so two fields can no longer disagree. mutex_is_valid remains, but it is now a derived predicate over lock_ptr answering a different question — use-after-destroy — which the Option cannot express because it is a later state change, not a creation failure.

    No mutex_create_or twin: None means the lock could not be created, and a defaulted mutex is exactly the silent no-op this removes.

  • sync.mutex.mutex_guard_is_locked deleted (C2). It wrapped the public locked field and returned it unchanged. Read g.locked directly.

  • time.date.days_in_month / time.time.days_in_month return Option[int] instead of a 0 sentinel for an out-of-range month (ROADMAP §8.3, C16). Breaking: callers must unwrap.

    The sentinel was not cosmetic. time.date's own date_make carried a comment naming it "the C16 violation" and worked around it rather than fixing it — the day-sum loop added days_in_month(year, m) for m in 1..month-1, and a 0 for m > 12 contributed nothing, so date_make(2024, 13, 1) and date_make(2024, 14, 1) both returned 20089 with no error.

    There is deliberately no days_in_month_or twin: _or suits a lookup whose miss is ordinary, and here None means the month argument is invalid, so defaulting would resume exactly the arithmetic the sentinel corrupted.

  • The calendar rules now live in one place. time.time carried byte-identical copies of is_leap_year and days_in_month — including a duplicate of the comment describing the sentinel bug — so a fix applied to one module would not have reached the other. time.date is now the single implementation and time.time forwards to it. Both public names are kept, since both have callers.

    Internal callers use a private month_length with a documented 1 <= month <= 12 precondition that every call site establishes (explicit validation, a while month <= 12 loop, or a month from date_month()). In time.time that helper is option.unwrap of the forwarded call, so a future precondition violation panics loudly instead of silently contributing 0 to a day sum — which is the failure mode the sentinel had.

    A now-dead if max_day == 0 check in time_parse_iso is removed; the month is validated three lines above it.

  • math.basic.sqrt_f is now correctly rounded (reef-lang#57). It was faithful — within 1 ulp — but IEEE-754 requires sqrt to return the nearest representable double (<= 0.5 ulp), and every libm does. Measured over 20,000 samples at x = i/97: 5005 of them (25.0%) differed from the correctly rounded value, each by exactly 1 ulp. After the fix, 0 mismatches on a 24,552-point grid that walks every one of the 2,046 normal exponents against pathological mantissas (all-zero, all-ones, top bit, low bit) plus pseudo-random ones — and 0 across 120,000 samples on three arithmetic strides.

    The stride measurement alone was not sufficient evidence, and this is worth stating because the first version of this entry rested on it: x = i/97 tops out around 1237, so it cannot reach the last binade, the subnormal neighbourhood, or the even-exponent/all-ones-mantissa family where the residual comparison underflows. An adversarial review supplied the grid, and the grid found real defects the strides could not. A measurement is only as good as the range it covers.

    Three separate causes, and fixing any one alone was insufficient:

    1. The Newton loop stopped on float_equal(y, prev), an absolute 2.2e-16. Below 1 that tolerance is wider than an ulp, so it exited while still 1–2 ulp out. The test is now exact equality, so the iteration runs until it genuinely stops moving.
    2. Even run to the floating-point fixed point, that fixed point is not in general the correctly rounded root — the division and the averaging each round, so the iteration can settle one ulp away and stay there. This is why an epsilon fix alone could not close it.
    3. The residual x - c*c used to discriminate candidates underflows for small x: two adjacent candidates produce the same subnormal magnitude, the strict < keeps whichever was tried first, and the answer lands 1 ulp out. sqrt_f now scales x by an even power of two into a mid-range binade first, corrects there, and scales the root back by half that shift — both scalings exact. Without it, 27 of 10,230 grid points were 1 ulp wrong, all at small exponents.

    The final choice between adjacent doubles is now explicit: the residual x - c*c is computed exactly (Veltkamp splitting recovers the bits the single rounding of c*c discards, using only multiply and subtract — no fma and no runtime support), and the candidate among c, nextafter(c, -inf), nextafter(c, +inf) whose square is nearest x wins. The one-ulp steps use the runtime's existing reef_float64_to_bits / reef_bits_to_float64, since for positive doubles the bit pattern is monotone — an ulp is not a fixed quantity, so no magnitude constant would have worked.

    This is the accuracy floor under hypot_f and every other sqrt-derived value in the tree. Cost is ~90 ns/call for the whole pure-Reef software sqrt; the correction is a small fraction of that next to the Newton divisions.

  • Float literals now accept exponent notation (reef-lang#58). The spec had documented 2.5e10 and 1.5e-8 since the language reference was written (reef-language-spec-v2.md:460-464), but the lexer's float rule was digit+ '.' digit+ with no exponent part, so 1.0e150 lexed as the float 1.0 followed by an identifier e150 and failed with Undefined variable 'e150'.

    Both forms now lex: 1.0e150, 2.5E10, 1.0e+3, 1.5e-8, and the no-fraction 1e3. Any constant outside roughly 1e-15..1e15 previously had to be spelled out in full, and the extremes could not be written at all — math.basic still builds math_HUGE() out of reef_make_float64 for exactly that reason, and a hypot_f overflow guard wanting a 1e150 threshold had to be restructured around the gap while fixing reef-lang#56.

    The trailing-digit requirement is preserved, so 1..10 still lexes as a range and not 1. .10; the exponent group requires at least one digit after e/E, so 1.0e still lexes as 1.0 then e; and 0x1e5 is unaffected. Lexical change only — the menhir conflict baseline is unchanged at 61 S/R and 81 R/R.

    Underscore separators from the same spec block (1_000_000, 0.1_234) remain unimplemented and are tracked separately as reef-lang#59.

  • Float literals were truncated to 12 significant digits (reef-lang#55). codegen.ml emitted them with OCaml's string_of_float, which is exactly %.12g; a double carries ~17. Every floating-point constant in every Reef program silently lost its low digits — a relative error of ~1e-12 — in every release up to and including 0.9.2. Two literals two ulp apart became one C token:

    source  1.1071487177940904   ->  emitted C  1.10714871779
    source  1.1071487177940906   ->  emitted C  1.10714871779
    

    Literals now emit as the shortest form that round-trips (%.15g/%.16g/ %.17g, each verified with float_of_string rather than assumed), so 0.1 stays 0.1 and long constants survive intact.

    Measured effect in the stdlib: math.basic declares its constants to 21 digits and got 12, so math_PI_2() was ~1e-12 off and every atan_f(|x| > 1) — computed as math_PI_2() - atan_f(1/x) — inherited it. atan(2.0) and atan(5.0) were −4.9e-12 against libc before, +4.4e-16 (2 ulp) after. All 16 emitted-C goldens are byte-identical, because no program in the corpus needed more than 12 digits — which is exactly why no gate ever caught this.

  • math.basic.atan_f lost ~4 digits near |x| = 1 (reef-lang#56), and with it asin_f, acos_f and atan2_f. atan(1) returned 0.784898 against a true 0.7853981634 — an absolute error of 5.0e-4. The Taylor series x − x³/3 + x⁵/5 − … converges more slowly the closer |x| gets to 1, and at x = 1 it is the Leibniz series for π/4, whose error after n terms is ~1/(2n); the loop capped at 500 terms and so exited on the cap rather than on convergence. The function was not even monotonic across the seam — atan_f(1.0) returned less than the true atan(0.999) — and because x > 1 folds back via the π/2 identity, the whole neighbourhood of 1 was affected.

    Propagation was structural: asin_f(x) = atan_f(x/√(1−x²)) maps x = √2/2 onto exactly atan(1.0), acos_f is π/2 − asin_f, and atan2_f(y, x) for x > 0 is atan_f(y/x).

    Fixed with one more argument reduction — atan(x) = π/4 + atan((x−1)/(x+1)) for x > tan(π/8) — which bounds the series argument at ~0.414 everywhere, about 22 terms for 1e-17. Against libc, atan_f/atan2_f/asin_f are now 2.2e-16 and acos_f 4.4e-16, from 5.0e-4 and 1.4e-7.

  • math.basic.hypot_f missed exact results. hypot_f(5, 12) returned 13 + 1.8e-15 (8 ulp). The scaled form ax·√(1 + (ay/ax)²) rounds four times around the sqrt; the direct form √(ax² + ay²) rounds twice and is exact for Pythagorean pairs. The direct form is now used whenever squaring neither overflows nor underflows, with the guard testing the result (sum > 0 and sum < HUGE) rather than the inputs — +inf and a flushed-to-zero sum are both rejected, and no magnitude threshold is needed. That last point is not stylistic: Reef has no exponent notation in float literals, so 1.0e150 lexes as 1.0 followed by an identifier e150, and a constant like that cannot be written at all.

Changed

  • math is now freestanding — math.floats no longer depends on libc/libm. It wrapped libc for eleven functions (trunc_f, cbrt_f, log2_f, asin_f, acos_f, atan_f, atan2_f, sinh_f, cosh_f, tanh_f, hypot_f), and those eleven were the module's only libc use. They were also exactly the eleven names it duplicated from math.basic, which implements all of them in pure Reef (IEEE-754 bit manipulation, ported from A2 Oberon MathL.Mod). The module therefore carried two bodies per name, with no caller able to tell which it would get, and the whole libm dependency existed only to serve the duplicates.

    They now forward to math.basic: one implementation per function, no libm, and the same public API — no signature changed and nothing was removed. Divergence from libc was measured across [-10, 10] before converging, and every one of the eleven agrees to ≤2.8e-14, nine of them to ≤1e-15 (trunc_f 0; cbrt_f/tanh_f/atan_f/atan2_f/asin_f 2.2e-16; log2_f 3.3e-16; acos_f 4.4e-16; hypot_f ~1 ulp; sinh_f/cosh_f 2.8e-14).

    That was not true before the two fixes above: forwarding would have silently degraded atan/atan2/asin/acos by up to 5.0e-4 in graphics, the one package that imports both modules. Fix the pure implementation, measure, then converge.

    Known residual, not fixed here: math.basic.sqrt_f is a few ulp rather than correctly rounded (measured up to 3.6e-15 over a swept range; IEEE-754 requires sqrt to be correctly rounded, and libc is). It is the accuracy floor under hypot_f and every other sqrt-derived value in the tree.

Added

  • *_or lookups across encoding.{toml,json,yaml,ini} — 21 convenience accessors pairing every Option-returning getter the 0.9.2 C16 conversion created (reef-lang#53) with a caller-supplied default. Same shape as sys.env.get_env_or.

    module added
    encoding.toml toml_get_or, toml_get_int_or, toml_get_bool_or, toml_array_get_or, and the four TomlDoc twins
    encoding.json json_get_or, json_get_int_or, json_get_bool_or, and the three JsonDoc twins
    encoding.yaml yaml_get_string_or, yaml_get_int_or, yaml_get_bool_or, yaml_get_list_item_or
    encoding.ini ini_get_value_or, ini_get_section_or, ini_get_key_or

    A present value always wins, including "", 0 and false. Those are real values these APIs return on success — {"name": ""} is legal JSON, key = legal TOML, [] a legal INI section — and substituting the default for them would reinstate exactly the sentinel collision C16 removed. Each module's test asserts that property directly, not just the happy path.

    The Option-returning getters are unchanged and remain the source of truth; these delegate to option.unwrap_or.

    Originated as a TOML-only change requested downstream; extended to the two TOML array accessors it had missed and to the three sibling formats converted by the same sweep, so the encoding package is consistent rather than one format having helpers and three not.

  • *_or for the C16 conversions outside encoding — 11 more: sys.optparse.argv_get_or, sys.hoststat.cpu_pct_at_or, ui.backend.x11.x11_default_screen_or, compiler.symbol_table.lookup_int_or / lookup_str_or, net.http.http_response_header_or / http_binary_response_header_or / http_auto_header_or, and the ReefVision widget methods Table.header_or, Table.cell_or, TextArea.line_or.

    symbol_table's pair is the interesting one: this is what the old default_value field provided, but as an argument the caller chooses per lookup rather than a struct field nothing could set.

    Four C16 conversions deliberately did NOT get an _or, and say so in the source. _or suits a lookup whose miss is ordinary; it is wrong where None means an operation FAILED, because a default silently continues past the failure:

    • sys.poll.poll_revents — None means the index is not in the set. poll_revents_or(idx, 0) answers "no pending events" for a stale index, which is the pre-reef-lang#54 behaviour and how you miss a hangup.
    • io.console.getch — None is EOF. A default turns end-of-stream into a real character, which is the defect C16 fixed here (EOF collapsed to '\0', looping confirm() forever and filling a 47 GB tmpfs).
    • sys.fd.fd_pipe, sys.signal.selfpipe_create — None means the syscall failed; a defaulted fd pair hands out descriptors nobody opened.
  • encoding.ini export block repaired — ini_get_value's entry was unindented and carried a stray copy of the definition's doc comment, both introduced by the C16 conversion's own edit at rev 1343.

0.9.2 — 2026-09-05

Post-0.9.1: spawn lowering, method/parameter defaults, lambda-in-AO codegen, field-less structs, object init defaults, await-cond captures, cross-module diagnostics, menhir-measurement integrity, and a slimmer source tarball. Then the collections redesign, which is a breaking change to the standard library and adds four language features to the compiler, and the core redesign, which is a second breaking stdlib change on the same 0.9.2 cut — one conversion API, one character library, no -1 or '?' sentinels, and no accessors wrapping public fields. One additive runtime symbol (reef_poll_count, reef-lang#54); no descriptor/layout change, so no ABI break — but a stdlib built from this cut needs the matching runtime, or the link fails loudly.

Also on this cut: reachability-based emission is the default (reef-lang#47). The compiler now drops functions, vtables and descriptors it can prove unreachable, which removes 42.2 % of emitted C on an identical corpus. The size baseline is re-cut accordingly (899 units / 94,010,285 B). A follow-up landed on top of it (reef-lang#49): construction is now read from the new node's own stamped type rather than from the class that declares the effective init, which for an inherited init named the base and never the class actually allocated. Worth a further 0.90 %.

Breaking

  • 39 stdlib lookups/parsers return Option instead of a magic value (reef-lang#53, C16). A lookup, parse or convert must not signal failure with a value a caller could legitimately receive. Every one of these collided:

    module now returns Option
    encoding.toml toml_get, toml_get_int, toml_get_bool, toml_array_get, toml_get_doc, toml_get_int_doc, toml_get_bool_doc, toml_array_item_doc, str_to_int
    encoding.json json_get, json_get_int, json_get_bool, json_get_doc, json_get_int_doc, json_get_bool_doc, str_to_int
    encoding.yaml yaml_get_string, yaml_get_int, yaml_get_bool, yaml_get_list_item, parse_int, parse_key
    encoding.ini ini_get_value, ini_get_section, ini_get_key
    net.http http_response_header, http_binary_response_header, http_auto_header, parse_hex_from_bytes, parse_hex_int
    sys.optparse argv_get
    sys.hoststat cpu_pct_at
    compiler.symbol_table lookup_int, lookup_str
    reefvision.widgets Table.header, Table.cell, TextArea.line
    ui.backend.x11 x11_default_screen
    reefmarker.evaluator as_int, as_float

    Migration: wrap a call site in option.unwrap_or(…, <old sentinel>) to preserve today's behaviour exactly, or branch on option.is_none to handle the case that was previously indistinguishable. The sentinels were "", 0 and false — all of them values these APIs also return on success: {"name": ""} is legal JSON, key = is legal TOML, Content-Length: 0 is a legal header, prog "" passes an empty argv element, and "" is the DEFAULT content of a Table cell.

  • A parse defect fixed with them, in four places. net.http's two chunked-transfer size parsers and the str_to_int/parse_int in encoding.json, .toml and .yaml ran a digit loop that never checked it had consumed a digit, so garbage returned the accumulator's initial 0. A "1z" chunk-size line decoded to a 1-byte body returned as the complete HTTP response — silent truncation, no diagnostic; and word: banana read back as the integer 0. Measured at rev 1343 vs the fix, gated by examples/test_http_chunked_malformed.reef. Written up as a standing constraint in docs/design/2026-09-04-parse-loops-must-prove-they-consumed-something.md.

  • sys.poll.poll_revents returns Option[int] (was int). It answered 0 for both "index out of range" and "this fd has no pending events", and the second is the common legitimate answer — so a caller holding a stale index (one kept across a poll_clear(), say) read "idle" forever instead of learning the index was gone. That is how you miss a hangup. None now means "not in the set". New sys.poll.poll_count() exposes the bound, which was previously unreachable from Reef: it lives in a file-static __thread int in reef_poll.c, which is why this one function could not be fixed during the C16 sweep and needed the runtime accessor.

    The four boolean predicates (poll_readable, poll_writable, poll_error, poll_hangup) are deliberately unchanged. They are closed-world predicates rather than lookups, the Result/Option adoption policy exempts bool predicates, and they have real callers.

  • encoding.base64 encode — bytes 128–255 are masked with & 255 before the alphabet index. Copy/OSC 52 of UTF-8 used to die with string index -15 out of bounds (length 64).

  • TextArea keyboard highlight — start_mark / end_mark / toggle_mark (F4). While mark mode is on, arrow/Home/End/Page movement extends the selection without Shift (many TTYs never send Shift+arrows).

  • Kate-style TOML syntax — languages in reefvision/syntax.d/*.toml (context stack, keyword lists, itemData roles). Schemes gained type, operator, function, preprocessor, variable, constant, alert, error, others, special, attribute, and builtin colors. Unknown files stay unhighlighted. Extra files: system-wide and per-user syntax.d directories. Languages: Reef, TOML, shell, C, C++, Python, Ruby, Crystal, OCaml, Assembly, JavaScript, XML, HTML, CSS, YAML, V, D. Color schemes: Default, Darcula, High Contrast, Paper, Monokai, Solarized, Twilight, Vibrant Ink, Dracula, One Dark, Tokyo Night, Gruvbox, Catppuccin, Everforest, Gruvbox Light, Synthwave '84. Syntax submenu capacity is 24 entries.

Fixed — tooling and stdlib hygiene

  • make docs no longer deletes files it did not generate (reef-lang#52). generate-docs.sh opened with rm -rf "$OUTPUT_DIR", so any file under docs/stdlib/ without a generating source was destroyed on every run — and one such file was tracked (reefvision/syntax-toml.md, a hand-written file-format reference, now at docs/guides/reefvision-syntax-toml.md). It now generates into a temp directory, copies in, and reports rather than removes anything it did not produce. A run that generates zero files fails loudly instead of wiping the tree. The 11 generated docs that had been accidentally tracked are untracked; docs/stdlib/ was already in .hgignore and the rule now takes effect again.

  • C4 is enforced by a gate that can see renamed duplicates (reef-lang#53). char_classifier_gate.sh matched candidate NAMES taken from core.chars, so it caught "same name, different body" and "same name, same body" but was structurally blind to "different name, same body" — renaming a duplicate hid it. A body-first pass now compares every stdlib function body against every core.chars body of the same signature, regardless of name (2,307 bodies). It found one: encoding/toml.is_toml_space was a verbatim copy of core.chars.is_blank, sitting behind a comment asserting C4 did not apply to it. It now delegates. Composite classifiers in nine modules migrated to core.chars; 22 sites that classify byte/int/keycodes rather than char are deliberately left, being a different domain.

ReefVision

  • Clock widget (reefvision.widgets.clock) paints date plus HH:MM:SS with blinking colons. Date style is YYYY-MM-DD or MM-DD-YY; time style is 24-hour or 12-hour AM/PM; TZ style is UTC or the host timezone (TZ / /etc/localtime, including DST). Colon blink uses reef_time_subsec_millis (0–999) because epoch milliseconds overflow 32-bit int.
  • time.time_offset_at / time_local_offset_seconds — host TZ offset in seconds east of UTC.
  • sys.proclist — OS process snapshot (proclist_scan / proclist_get). Linux reads /proc/<pid>/stat; illumos reads /proc/<pid>/psinfo.
  • sys.hoststat — overall CPU %, per-CPU % (ncpu / cpu_pct_at), RAM used/total, 1-minute load×100, uptime, and uname (Linux /proc + sysinfo/uname; other Unix returns RAM + uname, CPU/load/uptime 0 / −1).
  • Sparkline (reefvision.widgets.sparkline) — one-row ▁▂▃▄▅▆▇█ history strip (ring buffer; caller pushes).
  • Graph (reefvision.widgets.graph) — boxed one-series plot with 0/50/100 labels; · history and █ latest.
  • MenuBar right label — MenuBar.set_right_text and Application.set_menubar_right for a right-aligned name/version string.
  • FileReq (reefvision.widgets.filereq) — open/save requester with a directory list, Name field, Parent, OK, and Cancel (Turbo Vision layout).
  • TextArea — wheel/scrollbar pan without moving the caret (ensure_visible only on caret motion). Optional line-number gutter. Insert key toggles overwrite. CSI 2~ is Insert.
  • TextArea scrollbars — vertical and horizontal gutters; col_offset uses the current widget width so resize keeps the caret in view.
  • TextArea horizontal scroll — col_offset keeps the caret in view. Shift+arrows and mouse-drag select; selected_text / delete_selection / find_next / select_all.
  • Window title caps use display width, not str.length (byte length). A UTF-8 title such as Editor — file no longer leaves a gap before ├.
  • Window _ + x stays inset so the top-right cell remains a box corner (┐).
  • TextArea scrollbars are frame-integrated in ReefVision: the right border opens ┴ / empty / one █ / ┬; the bottom opens ├ / empty / one █ / ┤. Both knobs are a single cell.
  • TextArea re-fills each text row before paint so h-scroll cannot leave highlighted fragments on short lines.
  • Text paint never emits TAB/CR/C0 to the terminal (tabs become spaces at tab stops). \r\n files no longer carriage-return the TTY through the editor window. Syntax spans use display columns, not byte indices.
  • Table double-click / Enter — same activated flag as ListBox.
  • ListBox double-click — a second click on the same row within 500ms sets activated (same as Enter). FileReq uses this to enter a directory; a file still OKs the requester.
  • FileReq directory rows use a ▸ prefix instead of a trailing /.
  • ComboBox (reefvision.widgets.combo) — one-line field with a scrollable dropdown. Overlay paint/hit lives outside bounds.h so Group/Window can stack the list over sibling widgets.
  • Syntax schemes — editor palettes independent of the UI theme (default, darcula, highcontrast, paper, monokai). TextArea.set_syntax_scheme / syntax.scheme_by_name paint the buffer; chrome (frames, menus, status) still follows the UI theme.
  • Syntax highlighting (reefvision.syntax) — keywords, strings, comments, and numbers for Reef, TOML, C, and shell. TextArea.set_syntax enables it.
  • Menu mnemonics — menu_item("&Refresh", cmd) strips & and paints that letter bold. && is a literal ampersand.
  • Per-window context menus — Window.context_menu() (override to return items). Right-click hit-tests the window and opens that popup without replacing the app-wide menu from set_context_menu. Empty desktop / other windows do not reuse the last window menu. Table rows select on right-click too.
  • MessageBox sizes to its text (wraps if still too long). Label.set_wrap is opt-in word wrap at the label width.
  • Block caret uses SGR reverse without also swapping fg/bg (the two cancelled, so the PTY cursor was invisible).
  • Ctrl-C is a key, not Quit, in the Application decoder so a focused Terminal gets 0x03. spawn_shell resets the VT so a reused window is a fresh session.

Breaking — collections 0.9.2

The collections package was three eras stacked on each other: typed leftovers from before generic structs, generic free-function APIs that still looked like C, and a handful of method-based types. There were three growable arrays, three maps and two sets. It is now one API, and the duplicates are deleted rather than deprecated — no shims, on the same rule sys.optparse followed: one library, and the breakage is the migration.

Modules deleted. collections.list, collections.growable, collections.map and collections.hashset are gone, as is the dead generated collections/hashmap.c snapshot.

Was Now
collections.list, collections.growable collections.vec — Vec[T] absorbed insert / remove_at
collections.map (StringAnyMap, typed *Map) collections.hashmap — HashMap[K, V]
collections.hashset, linear collections.set collections.set — one hashed Set[T]
IntTrie / StringTrie one generic Trie[V]
IntOrderedMap / StringOrderedMap / PtrOrderedMap one generic OrderedMap[K, V]

HashMap is keyed by any Hash type, not just string, and no longer takes a dummy default value. That dummy existed so value_at() could return a V for an empty slot — a value the caller could not distinguish from a real entry. Lookups return Option[V]; is_occupied_at / key_at / value_at are replaced by keys() and values(), which walk the same slots in the same order.

Per-type factories are gone everywhere, replaced by one generic create[T]() / create_with_capacity[T](n) per container: create_int_hashmap, create_string_queue, create_int_list, create_int_vec, create_int_set, create_int_stack, create_int (trie) and their siblings. Vec.cap() is now capacity(), matching every other container. is_full() and the *_to_array(arr, max) out-parameter forms are dropped; to_array() returns a copy sized to len.

stack, queue, bitset and trie are method-based. It is s.push(x), not stack.push(s, x); bs.set(10), not bitset.set_bit(bs, 10).

Two silent-drop behaviours are gone. OrderedMap had a fixed capacity and discarded a new key once full — its own comment said so — and Trie's insert no-opped when the node pool was exhausted. Both grow now. Trie.try_insert therefore fails only on invalid input (an empty key or a non-ASCII character), never on capacity.

Map keys compare with ==, which is reference identity for structs and objects. For int, string and bool it is value equality, and those are the key types to reach for. Two separately constructed structs with identical fields are different keys, so get(new User(...)) cannot find an entry inserted under an equal-looking User. Key by an int or string field instead. There is no Eq trait in 0.9.2 — one would need Self, which the language does not have yet.

Map literals changed type. #{ } and to_map() now produce HashMap[string, Any] instead of collections.map's StringAnyMap. Keys stay strings and values stay Any. A program whose only map is a literal now compiles and runs without importing anything — previously it passed --check and failed at the C stage naming a module the source never mentioned.

Downstream: type annotations and factory calls drop the dummy default:

name_map: hashmap.HashMap[string, int]      // was HashMap[int]
name_map: hashmap.create[:string, int]()    // was create[:int](0 - 1)

get already returned Option, so the -1 was never the not-found path.

Breaking — core 0.9.2

core was the same three eras collections had been: C out-parameters and -1 sentinels, duplicate classifiers and int parsers, and accessor functions wrapping struct fields that were already public. Same rule as collections and sys.optparse — the duplicates are deleted rather than deprecated, on the same 0.9.2 cut, because a second breaking stdlib release one version later is worse than one.

One int/float/bool conversion API. core.str's integer conversions are gone and core.convert's parsers are snake_case.

Was Now
str.from_int(n) convert.to_string(n)
str.to_int(s) convert.to_int(s)
convert.toInt / toFloat / toBool convert.to_int / to_float / to_bool

str.to_int and convert.to_int are not the same function. They agree on trailing garbage — both reject "123abc" — but differ on whitespace: convert.to_int(" 42 ") is Ok, str.to_int(" 42 ") was Err. Every migrated call site therefore starts accepting surrounding whitespace. All 18 were audited; the two in production stdlib are safe (a TOML theme value, where being more forgiving is harmless, and an already-delimited lexer token).

One character library. Classification lives only in core.chars.

Was Now
str.is_digit / is_alpha / is_alnum / is_whitespace chars.is_digit / is_alpha / is_alphanumeric / is_whitespace
chars.char_upper / char_lower chars.to_upper / to_lower

Note is_alnum → is_alphanumeric is a name change, not just a qualifier. The four str predicates were verified byte-for-byte identical to their chars counterparts before deletion, so no call site changes behaviour. libc.ctype is kept but is a raw <ctype.h> binding, not a peer: its predicates return int, so they need an explicit != 0, and it is hosted only.

No sentinels. Lookups and digit conversions return Option.

Was Now
str.index_of / index_of_char / last_index_of / last_index_of_char → -1 → Option[int]
chars.to_digit / hex_to_digit / octal_to_digit → -1 → Option[int]
chars.from_digit / from_hex_digit → '?' → Option[char]
array.index_of, min_int, max_int already Option; only the array_ prefix moved

'?' was a particularly bad marker: it is a legitimate character, so a caller could not tell a converted '?' from a failure.

split returns an array; join drops its count.

// was
mut parts: [string] = new [string](64)
let n = str.split(csv, ',', parts, 64)
let s = str.join(parts, n, ",")

// now
let parts = str.split(csv, ',')
let s = str.join(parts, ",")

split sizes its own result, so there is no caller buffer and no cap. Splitting is total — adjacent and trailing delimiters keep their empty fields — so join(split(x, d), d) == x. join uses parts.length(), so a caller can no longer pass a count that disagrees with the array it was given.

BEHAVIOUR CHANGE — max_parts is gone, including its remainder-absorb. The old cap did not just limit the count; the final part swallowed everything after it. Measured before removal:

str.split("a,b,c,d", ',', buf, 2)   ->   ["a", "b,c,d"]

There is no replacement parameter. A caller who wants that shape splits fully and rejoins the tail, which reproduces the old output exactly.

Fields, not accessors. Error and the five Box* types have public fields; the functions that wrapped them are deleted. A method of the same name would have shadowed the field it returned.

Was Now
error.error_kind(e) / error_message(e) / error_code(e) e.kind / e.message / e.code
box.unbox_int(b) and siblings b.value

Construction is unchanged: error.error, error.error_with_code and the five box_* constructors do real work and stay.

Deleted outright. str.format (string interpolation superseded it; no production call sites), memory.fill_bytes / fill_ints (one-line forwarders onto memset_bytes / memset_ints), array.array_length (arr.length() is a language builtin), and libc.format.parse_hex / parse_octal / parse_binary (-1-returning forwarders onto core.convert.from_* — call convert directly and test the Result). array's sort, reverse and is_sorted families drop their len out-parameter and derive it from the array.

Renamed in core.array — the array_ prefix is gone from every export: array_contains → contains, array_index_of → index_of. (The Option-table row above says "only the prefix moved"; this is the row that names the two symbols, so a downstream grep for array_contains finds it.)

BEHAVIOUR CHANGE — convert.to_char clamps to 0..255 instead of casting. It used to wrap: to_char(-1) gave -1 and to_char(300) gave 44, a comma. It now saturates, matching memory.byte_from_int's range. Note the asymmetry that comes with it: char is signed and byte is not, so a code above 127 reads back negative through to_code — to_code(to_char(200)) is -56 while byte_to_int(byte_from_int(200)) is 200. The range is deliberately not narrowed to 0..127 to hide that: UTF-8 lead and continuation bytes are ≥ 128 by construction, and a 127 ceiling silently destroys every multi-byte encode.

Fixed — core 0.9.2

core.convert's formatter and its four parsers were silently wrong at the 32-bit boundary, while core.str's equivalents were correct. Since C5 keeps convert and deletes the str copies, the correct behaviour was ported first — comparing the two by name alone would have deleted the right implementations. Measured before the fix:

convert.to_string(INT_MIN)            '-'
convert.toInt("2147483648")           Ok(-2147483648)
convert.toInt("99999999999")          Ok(1215752191)
convert.from_hex("FFFFFFFFFF")        Ok(-1)
convert.from_binary(33 ones)          Ok(-1)
convert.from_octal("77777777777777")  Ok(-1)

Every one returned Ok. These were not "returns a bad value" bugs but "reports success on input it cannot represent" bugs, so a caller checking is_ok was told nothing had gone wrong. to_string now special-cases INT_MIN (which has no positive counterpart, so the negate-then-extract path produced a bare "-"), and all four parsers carry exact overflow guards — 134217727 * 16 + 15, 1073741823 * 2 + 1 and 268435455 * 8 + 7 are each precisely INT_MAX, so the largest literal in every base still parses.

Fixed — compiler

  • Generic inference now binds a type parameter reachable only through an Fn[..] return position (reef-lang#46). fn map[T, U](x: Option[T], f: Fn[T, U]): Option[U] and every combinator shaped like it were unwritable: the call was rejected with expected fn(int) -> ?, got fn(int) -> string. Nothing to do with Option, enums or closures — a bare fn tr[T,U](x: T, f: Fn[T,U]): U and an array map failed identically, while Fn[T,T] and plain-argument inference both worked. Two structural walkers (Typechecker.infer_type_args and Monomorph.infer_type_args_for_function) each had arms for type variables, generics and arrays and then a catch-all; neither had a TFunction arm. Both were needed: fixing only the typechecker moved the failure from typecheck to link, because the call site was named correctly while the specialization was never generated. This is what had blocked map/and_then since the 2026-07-09 design.

  • reefc warns on unused imports. Nothing flagged an import no one referenced, which is how 79 of them accumulated. The check is narrow on purpose: it reports only a module that exports no type (Reef does not import functions into the main env, so such a module is reachable only through its namespace, making "no namespace lookup" exactly equivalent to "unused"), that is not arch-conditional, and that has no link-time side effect — import compress.zlib can be the only thing supplying -lz, and removing it type-checks and then fails at link. Default is the module being compiled; --warn-unused-imports sweeps every loaded module.

Added — core 0.9.2

map and and_then over Option and Result, specced in the 2026-07-09 Result/Option adoption design and never shipped because the compiler could not type them (see the Fn[..] inference fix under Fixed — compiler).

option.map(opt, f)          // Option[T],  Fn[T, U]              -> Option[U]
result.map(r, f)            // Result[T,E], Fn[T, U]             -> Result[U,E]
result.and_then(r, f)       // Result[T,E], Fn[T, Result[U,E]]   -> Result[U,E]

map leaves an Err untouched with its error type unchanged; and_then is for the case where f itself can fail, which map cannot express — using map there would give Result[Result[U, E], E]. Free functions, because enums have no method production and a generic impl cannot bind T on Option[T]. No ? operator.

Downstream

str.split and str.index_of* carry the migration: those are semantic changes, not renames, so they are rewrites at each site rather than a search-and-replace.

  • A downstream package manager — 44 sites: split 17, index_of* 18, error_message 2, chars.to_digit 3, str.from_int 1.
  • A downstream init system — 24 sites: index_of* 12, str.from_int 5, str.to_int 3, error accessors 2, split 1, str.is_digit 1.

Neither is in this repository and neither is updated by this release.

Added — language

Four compiler features the collections redesign required. Each is independently useful.

  • impl Trait for int / string / bool. A trait implementation may target a primitive. 42.hash() and "x".hash() resolve like any method call. Deliberately narrow: float is still rejected, because NaN and -0.0 break the contract that equal values hash equally.
  • Traits are importable. export type Hash marks a trait, and an importer binds it under its bare name — impl and where take an identifier, not a qualified path.
  • Type-level where on generic structs. type Box[K] where K: Hash = struct bounds the type itself. Method bodies may use the bound, and it is enforced at every instantiation: Box[float] is a type error. Generic structs only; active and passive objects do not take type-level bounds.
  • Trait implementations are program-wide. An impl is visible once its module is in the build graph, without being imported directly. This is what lets import collections.hashmap alone satisfy where K: Hash for string, whose implementation lives in core.hash. Previously the bound was checked only against impls the calling module could see.

Added — stdlib

  • core.hash — the Hash trait (fn hash(): int) with implementations for int, string and bool. It sits beside core.option so a user type can impl Hash without importing a container. The hash/eq contract is documented on the trait and is not statically checked.

  • encoding.value — JSON-shaped Value tree (Null / Bool / Int / Float / Str / Array / Object) with from_bool / from_int / from_float / from_string / from_object / array / null, plus get, to_json, and a one-level from_any_map.

  • encoding.js — js_escape_string for JS string-literal escapes (\, ', ", newline, CR, tab, NUL, UTF-8 U+2028/U+2029).

  • reefmarker — veemarker-subset FTL (import reefmarker.engine): ${}, #if / #list / #assign / #noparse / #include / #macro / #attempt / #switch / #stop, ? builtins, StringLoader and FileLoader. Data is encoding.value. ${true == true} / ${flag == false} work (==/!= on Bool/Null); #include / <@macro> nesting is capped at 32.

Changed

  • Source tarball no longer ships in-repo development trees. package-source.sh already staged from hg archive (untracked files cannot leak) but still copied tracked bug notes, internal planning notes, CI YAML, Dockerfiles, and marketing copy. Those are now excluded. The binary tarball's share/examples/ omits test_* compiler fixtures. Gate: scripts/test-package-source.sh.

Fixed — generics

Three defects found while building the collections redesign. All three are pre-existing and reproduce on the installed v0.9.1.

  • new <module>.Generic[T]() now builds (reef-lang#43). It was rejected by codegen with "Cannot instantiate this type with 'new'" for any type argument, aliased or not, with no trait bounds involved — it typechecked, and only emission refused. Two omissions: the construction could not be named, and its specialization was never recorded. new [<module>.Generic[T]](n) had the same gap.
  • self inside a generic struct's method is T[params], not the bare T (reef-lang#44). Passing self where the struct's own type was expected — including to another method of the same struct — failed with "expected Pair['T], got Pair", so symmetric or delegating operations had to be written twice.
  • One module's generic instantiations no longer specialize another module's same-named function (reef-lang#45). Instantiations were keyed by bare function name, so any module declaring a matching name specialized against every recorded instantiation. With differing arity that emitted invalid C; with the same arity it could specialize a bounded function for a type that does not satisfy its bound, in a program where that function was never used with it. Keys now carry the callee's resolved defining module.

Fixed

  • ui widget setters now mutate and say so — phase 3 of docs/design/2026-09-08-ui-modernization.md, closing reef-lang#62. Breaking.

    49 free setters of the shape fn button_set_label(b: Button, label: string): Button became exclusive proc set_label(label: string) methods, and 7 internal *_with_* helpers taking a receiver (button_with_state, checkbox_with_hover, slider_with_dragging, textfield_with_focus and siblings) became set_state / set_hovered / set_dragging / set_focused. button.button_set_label(b, "x") is now b.set_label("x").

    The signature was the defect, not the behaviour. Widgets became objects on 2026-08-23 and objects have reference semantics, so these functions had been mutating their argument and returning the same reference ever since. Measured before the change:

    let b1 = button.button("original")
    let b2 = button.button_set_label(b1, "changed")
    
    b1.label = changed
    b2.label = changed
    aliased  = true
    

    A : Button return type promising a copy while delivering an alias is worse than a mutating method, and the with_ spelling — the conventional name for a copy — was worse still. Removing the return value is what makes the mutation visible at the call site.

  • ui widgets gain clone (design D2). shared fn clone(): <Widget> on all eight widgets, hand-written per type because objects have no derived copy. It is a shallow copy: inherited Widget state (bounds, wstate, visible, enabled, focusable, tab_index) and every own field are copied, Panel's kids array is copied element-wise, and referenced widgets are shared rather than deep-copied.

    This is the replacement for what the old setter signature falsely promised. button_with_state(base, Hovered) to paint a hover preview — the real use case, and exactly the one that was silently broken — is now let hovered = base.clone() then hovered.set_state(...).

    The behaviour suite goes 98 → 126 assertions: section 5 was rewritten from pinning the #62 defect to pinning the fixed contract, and a new section 6 asserts clone independence once per widget so a reintroduced pseudo-copy setter fails loudly.

  • ReefVision CMD_QUIT no longer calls Application.quit(). Accel and window commands set last_command so the app can confirm. run_loop still quits when it sees CMD_QUIT. EventKind_Quit still stops the loop. Demos that relied on auto-quit (reefvision_app_shell, reefvision_ide) now call app.quit().

  • Alt-letter accelerators match without Shift. norm_ch already uppercased Ctrl+letter; TTY Alt-q is often ch=113 while the bound accel is Alt-Q (ch=81).

  • Desktop.add caps visible windows, not lifetime children. close_window_at only hides; counting children.length() blocked a new window after close.

  • Object inherited init and object-method calls apply parameter defaults (reef-lang#42). The last two call forms after struct methods (#15), object new (#21), and AO new (#41). inherited init(x) against init(x, y = 7) splices y; obj.pick() and inherited pick() omit a defaulted method argument. Required params are still errors.

  • AO new applies init parameter defaults (reef-lang#41). Object new already did (#21). --check used to ignore AO construction args, so new Sized(1) against init(x, y = 5) was a C too-few- arguments error.

  • A typecase arm must be the scrutinee class or a subclass of it (reef-lang#17 / BUG-222). An unrelated class never matches; a superclass of the scrutinee always matches. Both are now unreachable- arm errors. Exact-type arms stay legal.

  • menhir-conflicts.sh no longer treats a hard error as 0/0/0/0 (reef-lang#35 / BUG-223). A dead measurement now prints MEASUREMENT_OK=0 and exits 2; the O7 gate requires the sentinel.

  • A closure inside an await condition that captures mut is a typecheck error (reef-lang#25 / BUG-263). --check used to accept it and only codegen refused, so EXPECT_FAIL could not pin the diagnostic.

  • Cross-module type mismatches name both modules (reef-lang#31 / BUG-271). expected Widget, got Widget is now expected liba.Widget, got libb.Widget when the defining modules differ. Same-module messages stay unqualified.

  • A qualified generic call infers against the defining module's signature (reef-lang#19 / BUG-236(a)). Two modules ending in the same path component (pa.gen / pb.gen) no longer share a first-wins namespace env, so pb.gen.pick(1, "hi") emits pick_string rather than the other module's pick_int.

  • A field-less struct allocates (reef-lang#18 / BUG-233). GNU empty structs have sizeof 0; reef_heaps_new_object rejected that, printed [Heaps] ERROR and returned NULL with exit 0. Field-less structs now emit a char __reef_pad. Objects already had D1's __reef_vtable.

  • Object init parameter defaults are applied by new (reef-lang#21 / BUG-245). new Sized(1) against init(x, y = 5) used to be a checked error (M1 decision C5). Required-arity now ignores defaulted params; codegen splices the omitted default, including when a derived class inherits the init. inherited init(...) is unchanged (still raw arity).

  • The t8 satellite-door gate locksteps Typecase arms with statement walkers (reef-lang#36 / BUG-256(b)). An armless walker used to leave the arm count green. Facet (a) already shipped.

  • spawn mod.f(args) typechecks (reef-lang#9 / BUG-178). The BUG-105 AO-method spawn guard treated the qualifier as a value, so a module namespace was "Undefined variable 'mod'". spawn mod.f[:T](...) already worked. Codegen uses the stamped CtFree symbol.

  • spawn f(...) splices omitted parameter defaults (reef-lang#16 / BUG-216). spawn greet("world") against greet(name, punct = "!") typechecked; the trampoline used to pass too few C arguments.

  • spawn f(x) when f is a closure local (reef-lang#10 / BUG-180). The trampoline used to call an undefined reef_f. It now invokes the closure (fn(env, args…)).

  • Struct-method parameter defaults work at the call site (reef-lang#15 / BUG-204). b.pick() against fn pick(fallback: int = 5) was expects 1 arguments, got 0. Required-arity now ignores defaulted params; codegen splices the omitted default (including generic methods).

  • A parameter default's type must inhabit the declared parameter (reef-lang#12 / BUG-185 remainder). fn f(n: int = "x") is a typecheck error. Stage 3 already typechecked defaults in isolation. The check uses the same inhabitation rule as argument passing, so fn defaulted(w: Base = new Derived(1)) stays legal.

  • A lambda with an explicit return inside an AO method no longer unlocks the monitor (reef-lang#24 / BUG-262). The lambda is not the method; in_ao_method is cleared for lambda scopes.

  • await inside a lambda is rejected at typecheck (reef-lang#22 / BUG-254), same house rule as await in run() (BUG-117).

  • f[T](args) without the colon hints f[:T](args) (reef-lang#32 / BUG-292). The documented explicit generic-call spelling uses [:T].

  • test_bug117_run_await_workaround no longer lost-wakes (reef-lang#39). run() does not hold the AO monitor, so self.fired = true there never called monitor_handoff. The test now sets fired via an exclusive self-call, matching parallel_hash. Reproduced 4/300 on one CPU before the fix; 0/300 after.

0.9.1 — 2026-08-25

Patch after 0.9.0: sys.optparse replaces sys.flag / sys.args, the ParseError codegen ICE is gone, Derived→Base inhabitation is total, and this is the first tag whose tarballs CI uploads to DigitalOcean Spaces (s3://reeflang/v0.9.1/). Compiler and runtime still upgrade together (descriptor ABI v4, unchanged from 0.9.0).

Changed

  • sys.flag and sys.args are gone. CLI parsing is sys.optparse: one Command tree, typed cells (verbose.value() after parse), git-style globals before the subcommand token, built-in --help/-h and --version/-V. Raw argv is optparse.program / argc / argv_get. This is the break downstream CLIs need in order to drop their private scanners (reef-lang#38).

Removed

  • reef-stdlib/sys/flag.reef (sys.flag) and reef-stdlib/sys/args.reef (sys.args).

Fixed

  • Matching an imported enum no longer ICEs when another module declares the same type name as a struct (reef-lang#40). resolve_variant_from_constructor still looked up the enum by the bare name "ParseError". After import sys.optparse and import encoding.toml (or json), the last-registered ParseError was the struct, so optparse.format()'s match died at codegen while --check stayed green. The lookup now uses (module, name). Witness: reefvision_hello (SKIP in the suite as interactive) and test_parseerror_optparse_toml_{a,b} / _json.

  • A Derived object now inhabits a Base slot everywhere (reef-lang#34, BUG-320). 0.9.0 shipped with four positions missing from the inhabitation rule; investigating them found the rule was actually missing from a whole category of positions, so the fix is broader than the four reported:

    • a module-level mut g: B = new Dv(1) and an array-element store a[0] = new Dv(3) emitted C with no pointer cast. Both built under clang and were a hard error under gcc (14+ makes -Wincompatible-pointer-types an error), so reefc --check passed and the failure surfaced in generated C. reefc prefers clang when both are installed, which is why a clang box never saw it.
    • a lambda expression body fn(k: int): B => new Dv(k) and a module-level array literal mut ga: [B] = [new Dv(1)] were refused at --check, though the named-function and local-declaration spellings of each were accepted.
    • Fixing the lambda refusal exposed the wider defect underneath it: an implicit trailing-expression return never emitted the cast in ANY of its six generators (free function, struct method, object method, Active Object method, lambda expression body, lambda block). Only the explicit return spelling did. So fn mk(): B return new Dv(1) end mk compiled while fn mk(): B new Dv(1) end mk did not — the same program, under gcc only.

    Both halves are now single points rather than call lists: every arm of the assignment generator takes its right-hand side from one coercion helper, all three module-global initializer arms publish through one store helper, and every return — explicit and implicit — produces its value through one shared gen_ret_expr. The emitted C for the whole 824-unit corpus is byte-identical across the change, since the cast is a no-op unless the source is a strict subclass of the target.

Added

  • The emitted-C corpus is now compiled with gcc in the example suite (gcc_syntax_corpus.sh, 822 units, ~15 s). Before this, no gate compiled the general corpus with gcc at all: the other corpus gates read emitted C textually, the dual-backend leg covers only the 17 window fixtures, and the stdlib gate covers only stdlib modules. That gap is what let BUG-320 reach a release, and it is the same shape as the other entries on the "gates blind to their own purpose" list. Cannot-run — no gcc, zero units, or a gcc that does not reject the defect shape — is a failure, not a skip.

  • Tag pipelines now upload the source and linux-amd64 tarballs to the reeflang Spaces bucket (publish-spaces step) after package and image. Versioned keys refuse overwrite; latest.json is the only floating pointer. https://dl.reeflang.dev/ is not live yet; origin URLs are under https://reeflang.mkc1.digitaloceanspaces.com/v$VER/. 0.9.1 is the first release that goes through that path.

0.9.0 — 2026-08-24

0.9 is the passive-object release: single inheritance, virtual methods, typecase, traits on objects, precise GC of heap-embedded enum payloads, and an opt-in owner-check harness. Language walkthrough: 040_OBJECTS.md. Showcase: examples/widget_tree.reef. Compiler and runtime must upgrade together (descriptor ABI v4).

Changed

  • CI is two-speed. Every push runs make all + make test (runtime ctest + dune test) + docs-check. The example suite (make test-harness) and vtable-check run on @, pull requests, and tags. ASAN, UBSAN, and the gcc/clang runtime matrix run on tags only (same commands locally any time). Job timeout 90m so the tag lane can finish; a feature-bookmark push no longer tries to do the whole matrix inside 30 minutes.

  • ReefVision is a passive-object hierarchy. View → Group → Window / Desktop; widgets extend View. Insertion is win.add(child) with locals (no widget id). new Application(th) is passive, not an Active Object; the loop is do_step / run_loop (step and run are reserved). InputLine takes three arguments (bounds, max_len, initial). handle_event returns EventResult. Deleted: ChildKind, parallel arrays, window_add_*, group_add_*, *_slot_by_id, app_input_text_by_id. Guide: reefvision-app.md. Demos: examples/reefvision_{hello,app_shell,installer,ide,gauge}.reef.

  • ui.widget.Widget hierarchy for all ui.widgets.*. Label, Button, Panel, Checkbox, Slider, Progress, ScrollView, and TextField extend Widget. Prefixed wrappers remain. Panel.add holds [Widget] children and virtual paint walks them. WidgetBase is unused by these widgets (still in the module for any out-of-tree embedders). Example: examples/test_stdlib_ui_objects.reef. Copy-on-write setters now mutate and return the same object.

  • graphics.backend.surface is a passive-object hierarchy. Surface is the base class; MemorySurface and FramebufferSurface override kind / end_frame. Prefixed surface_* functions remain as wrappers (canvas/ui/gameui unchanged). Example: examples/test_stdlib_surface_objects.reef.

  • io.stream / io.buffer are passive objects. Stream is the base; FileStream is fopen-backed (stream_open); MemoryStream is an in-memory buffer with virtual read/write/seek. BufferedReader and BufferedWriter are objects. Prefixed stream_* / reader_* / writer_* wrap methods. Example: examples/test_stdlib_stream_objects.reef.

  • Language reference chapters renumbered to reading order. The NNN_ prefix is the order (gaps of 5). Index: 000_INDEX.md. Bookmark mapping from the previous numbers:

    Was Now
    031_PROJECT_STRUCTURE 010_PROJECT_STRUCTURE
    010_BASICS 015_BASICS
    015_STRING_INTERPOLATION 025_STRING_INTERPOLATION
    028_ERROR_HANDLING 035_ERROR_HANDLING
    035_OBJECTS 040_OBJECTS
    040_ACTIVE_OBJECTS 045_ACTIVE_OBJECTS
    080_TRAITS 050_TRAITS
    050_GENERICS 055_GENERICS
    055_CLOSURES 060_CLOSURES
    026_DEFER 065_DEFER
    027_SPAWN 070_SPAWN
    060_MODULES 075_MODULES
    029_TESTING 080_TESTING
    025_UNSAFE 100_UNSAFE
    070_FFI 105_FFI
    075_INLINE_ASSEMBLY 110_INLINE_ASSEMBLY
    076_SYSTEMS_PROGRAMMING 115_SYSTEMS_PROGRAMMING
    077_FILESYSTEM_OPERATIONS 120_FILESYSTEM_OPERATIONS
    078_STREAMING_IO 125_STREAMING_IO
    085_RAW_SYSCALLS 130_RAW_SYSCALLS
    090_REEF_OS 135_REEF_OS

    Unchanged: 000_INDEX, 005_COMPILER_USAGE, 020_FUNCTIONS, 030_TYPES. Systems chapters start at 100 so a later language chapter can use 070–095 without colliding with FFI.

  • Passive-object pointers in the reference chapters that would otherwise invite the wrong construct: generics reject object Stack[T]; closures note that capturing a widget does not transfer ownership; unsafe does not bless a cross-owner touch. typecase is pointed from Basics' match section; Testing shows reefc --owner-harness.

  • Owner-check harness (0.9 Phase 10 / M4). Opt-in: reefc --owner-harness stamps each new object with its owner (innermost held Active Object, else the allocating thread) and aborts on a mismatch at object method entry and field writes. Finalize-time access always fires (O8). Default builds emit no probes (goldens unchanged). Not a release CI gate.

  • impl Trait for an object class (0.9 Phase 9 M3 / spec §4.5). Subclasses inherit the base's impls for method syntax; a where T: Trait bound accepts a Derived when a Base implements the trait. Trait methods stay statically dispatched. The documented pattern for per-class behaviour is delegate-to-virtual (class method is the virtual; the impl forwards under a different name — same-name own method plus impl is still D42). Reference chapter docs/language/reference/040_OBJECTS.md (docs-check floor 6); showcase examples/widget_tree.reef.

  • typecase lowers to an is-chain (0.9 Phase 9 M3). Multi-arm runtime type dispatch over a hierarchy (typecase w ... end typecase) compiles and runs. Binders are the narrowed view. The previous 'typecase' not yet enabled door is gone. The T8 satellite gate now counts the six Typecase traversal arms (the old grep demanded the door-shaped (_, _, _) wildcards and reported 0 after the rewrite). Two fixtures that now run (test_object_typecase_single_class, test_typecase_empty_arm) gained .expected files (BUG-166).

  • Trait-impl methods are callable from object, struct, and Active Object method bodies (BUG-255). Prototypes live in the module forward-declaration block, same naming pair as the definition.

  • A Derived object inhabits any Base slot (owner decision D39 / BUG-302). Struct literals, positional new S(...), append, returns, call arguments, match expressions and if expressions accept a Derived where a Base is expected. Array types stay invariant ([B] is not a [W]). types_compatible is still unification only — the object arm stays on types_compatible_unsafe so trait-impl signatures cannot become covariant.

  • Every stdlib module is compiled in CI (owner decision D38). A package with no example can no longer sit broken while the suite stays green (BUG-315's seven months). tests/tools/stdlib_compile_gate.sh: importer → --emit-c → gcc -fsyntax-only for every tracked module (arch-specific ones with --target). 159/159. Cannot-run is a failure.

  • gameui is buildable (owner decision D37 / BUG-315). Arrow-key constants are key_arrow_up/down/left/right(); the query key_down(inp, key) is unchanged. A duplicate top-level function name in one module is a type error (Reef has no overloading). --check used to accept the collision; codegen emitted both prototypes and one body; C was the only catch. The package had never compiled. Same-name extern "C" plus a Reef wrapper, and asm bodies for different architectures, remain legal.

  • UBSan is a CI gate (owner decision D49). make test-ubsan now uses halt_on_error=1 (the old =0 printed diagnostics and still exited 0) and runs scripts/ubsan_halt_canary.sh first so a mute sanitizer cannot green the suite. CI has a test-ubsan step. Local parity: make test-ubsan.

Fixed

  • BUG-323: InputLine / TextArea paint as inset fields: a darker panel shade (not the window client, not near-black). Idle and focused still share that fill (BUG-322). No [ ] chrome — it read as part of the value and stole two columns.

  • BUG-322: InputLine / TextArea idle and focused fills use the same panel background (caret / brighter fg mark focus). Clicking into a field no longer punches a near-black bar.

  • BUG-321: Application.message_box / modal_run no longer write the dialog result into last_command. A do_step loop that opens a Greeting box on CMD_OK used to close the modal and immediately reopen it.

  • BUG-269: a generic enum used only as a parameter (or field) type is still specialized. Box[string] no longer ICEs in resolve_variant_from_constructor when the module never constructs a Box.

  • BUG-276: a type alias used as a generic argument is the same type as its underlying (Option[MyRect] vs Option[Rect]). mangle_type / same_type_args expand aliases too, so Result[StrOpt, Error] with type StrOpt = Option[string] is one specialization (BUG-081's fixture stays green).

  • BUG-277: a local type Error no longer steals core.result's E. Enum-ctor return types use the user-type ambient ("main", not ""), and imported signatures are converted against a registry seeded from the whole loaded graph before main is checked — so convert.toInt stays Result[int, core.error.Error].

  • BUG-187: generic-instantiation scan walks lambdas, struct/map literals, type_name/type_id/is/as, and to_map.

  • BUG-190: substitute_type descends into Fn[…] and set[…].

  • BUG-207: trait-impl bodies and module-level initializers are scanned for generic instantiations.

  • BUG-217 / BUG-316: match binders substitute nested generics and array payloads (Inner[T], [T]), not only a bare type variable.

  • BUG-210: a do-while/do-until condition that names a non-GC local declared in the body is a typecheck error, not an undeclared C identifier.

  • BUG-228: struct methods get C prototypes before their definitions, so self.later() no longer depends on declaration order.

  • BUG-235: gameui.widgets calls sys.platform.runtime.float_to_int instead of a missing reef_float_to_int.

  • BUG-241: type_name in interpolation or return no longer discards const (call-site cast from BUG-225).

  • BUG-244: record field names that collide with C reserved words are mangled at declaration, access, and offsetof.

  • BUG-246: Any (and any) fields emit void*; a heap value can inhabit Any. The object-retention fixture traces an Any field.

  • BUG-183: a module const whose initializer is not a C constant (interpolation, enum constructor) is initialized at module-init time.

  • BUG-247: new Box[int](9) now assigns constructor arguments to fields instead of splicing them into reef_heaps_new_object.

  • BUG-215: defer bodies are emitted in a C block, so a shadowed let n inside a defer is legal C (and legal Reef).

  • BUG-225: type_name / type_id on a scalar is a typecheck error; on a heap value, the const char* result can be bound without a -Werror failure.

  • BUG-300: slice assignment (a[1..3] = b) is rejected at typecheck — a slice is not an lvalue.

  • BUG-221: a missing field on a passive object is reported as Object, not Struct.

  • BUG-242: reefc doc documents object declarations.

  • BUG-281: is_enum_valued_type looked up types by bare name, so a module global of an imported generic struct beside a local enum of the same name was not registered as a GC root and was collected while referenced. It now uses the qualified table (is_enum_type_q), same one-liner as the locals/fields twin at rev 993.

  • BUG-296: += / -= / *= / /= / %= evaluated the left-hand side twice (side()[i] += 1 called side twice). Codegen now evaluates the designator once when both sides share the same source node.

  • BUG-256 (a): the cross-module test runner treated All tests passed with zero assertions as a pass. It now also requires Tests run: with a positive count.

  • reefc --target *-baremetal without --emit-c is a pointed error (owner decision D46 / BUG-280). The help text advertised a full compile; the driver then invoked hosted cc (-lreef_runtime -lpthread) and died with 'reef_baremetal.h' file not found. Baremetal targets emit C for an external freestanding build (the kernel Makefiles). --check and --emit-c still work. --linker-script was parsed and ignored; it now errors as unused.

  • GC values are no longer unrooted across the CALL/RETURN HANDOFF (0.9 Phase 8, BUG-299 / owner decision D33). This was silent memory corruption in any multithreaded Reef program, and it shipped. A GC value returned BY VALUE — a string, an array, a struct, an object, a closure, an enum's payloads — was rooted NOWHERE between the callee's frame pop and the caller's publication of it: the callee's frame is gone, the caller has not stored it yet, and precise marking cannot see a register or a return temp. That interval contains no allocation by that thread, which is why the compiler's rooting was correct at every site on the value's path. It stopped being sufficient when the collector gained preemptive stop-the-world (0.7.x): a SIBLING thread's collection suspends this thread at an arbitrary instruction, so a collection can land in a window this thread never allocates in. The value is then swept while live, its memory reused, and read back as unrelated data — no crash, no diagnostic. It needed only a second thread, i.e. spawn or any Active Object; neither a sanitizer nor the GC torture mode was required (measured: 4 of 32 ordinary runs, no sanitizer, plain build). Values returned by a function are now published into a per-thread GC handoff cell that the collector scans, and per-call-site argument roots are held until the enclosing frame pops instead of being released after the call. Programs get slightly larger emitted C; nothing about the language changes.

  • int as float is accepted (BUG-317). Integer sources may as to float / float32 / float64. This was a missing is_castable arm, not a design: float as int already worked and codegen already emitted ((double)n). Justify as explicit conversion, not as lossless — a 32-bit int is exact in float64 and not in float32. int stays 32-bit. 5 as size_t and int as char / bool as bool are accepted in the same batch (BUG-194's as half). Mixed int + float arithmetic is still rejected.

  • await conditions are live expressions (owner decision D44 / BUG-251). Captured locals and parameters are re-read from the parked frame on every wake; self was already a live pointer. A mut local written through a captured closure while the waiter is parked is now visible (the snapshot copied the int at entry and deadlocked). await self.count == expected is unchanged. Shadowed names in a condition remain a type error (D5). No runtime ABI change.

  • Trait impl methods on enum targets are callable with method syntax (owner decision D43 / BUG-238). impl Show for Shape where Shape is an enum already emitted and ran as Shape_show(c) and inside where T: Show; c.show() was rejected with Cannot resolve type 'Shape'. The method-call resolver now looks up the impl on an enum receiver the same way it does for a struct with no own method. A miss says Type 'Shape' has no method 'show'.

Breaking

  • Ordering operators (<, >, <=, >=) are numeric only (owner decision D45 / BUG-219). ==/!= still apply to any matching pair (strings via strcmp, objects/AOs as reference identity). Same-type struct, array, string, bool, set, enum, and Active Object ordering used to typecheck as a raw C < — pointer compares, and for enums invalid C. They are now a type error: ordering operators are not defined for type 'T'. Integer family (including char and size_t) and floats are unchanged. String lexicographic < is not this bug.

  • A type cannot have two methods of the same name (owner decision D42 / BUG-274 option 3). An own method m (struct, Active Object, or object — including inherited object slots) plus a trait-impl method m, or two impls providing m, is a type error: Type 'T' already has a method 'm'; an impl cannot provide another. This used to pass --check and fail only as a C-symbol collision (D12). x.m() and Type_m(x) are not two namespaces. An own method satisfying a trait (so the impl may omit it) is deferred to Phase 9.

  • A body on a trait method is a type error (owner decision D41 / BUG-202 option B). The grammar still parses default implementations, but they were never typechecked, never emitted, and never callable — reefc --check accepted arbitrary garbage inside them. A trait is signatures only; every impl must provide every method (the documented 0.8 rule). Clone-per-impl defaults — the better long-term language — are deferred: reef-lang#5.

  • as no longer C-casts a heap value to an arbitrary type (owner decision D40). "hi" as bool, "hi" as int, and obj as int used to typecheck and compile to a raw C cast of the pointer (always-true bool; address bits as int). They are now a type error, including inside unsafe. Downcasts (p as Person, any as BoxInt, w as Button) and FFI arr as pointer are unchanged. The diagnostic for a refused as names both types instead of claiming a heap-allocated source is required. Integer-to-pointer still requires unsafe (BUG-119); its error says so.

  • Descriptor ABI v4 — compiler and runtime must upgrade together (0.9 Phase 8, BUG-299). The descriptor STRUCT is unchanged (LP64 sizeof stays 96); REEF_DESCRIPTOR_ABI_VERSION moves 3 -> 4 because generated C now writes a NEW RUNTIME ROOT — the per-thread GC handoff cell __reef_gc_handoff (reef_heaps.h) — and the dangerous skew direction for that is the SILENT one. An OLD runtime with a NEW compiler is already loud (the emitted C names an undeclared array and does not compile); a NEW runtime with an OLD compiler would link, run, never write the cell, and silently restore the memory unsafety BUG-299 removes. The version's documented scope is therefore widened here from "the descriptor ABI generation" to "the emitted-C <-> runtime CONTRACT generation", of which the descriptor layout is one part. Every unit a 0.9 reefc emits carries a _Static_assert on it, so a mismatched install fails the C compile loudly. Downstream GC implementors must add __reef_gc_handoff to their root set when they migrate — the freestanding mirror in reef-baremetal declares the array so generated C compiles, but nothing there scans it, exactly as with the shadow-frame chain. Same lockstep note as the ABI v2/v3, the finalizer-API and the 0.8.6 string-header breaks.

  • The finalizer registration API changed shape — compiler and runtime must upgrade together (0.9 Phase 8-D31, BUG-303). reef_heaps_add_finalizer now takes the finalizer PROCEDURE (reef_heaps_add_finalizer(obj, proc)); it used to take a caller-allocated reef_finalizer_node_t*, and that type — along with the unused reef_finalized_collection_t — is removed from reef_heaps.h (and from reef-baremetal's mirror). The registration record is now allocated, recycled and freed by the runtime. Why it is a break rather than an addition: the old signature made two defects unfixable from inside the runtime. It could not free the record (it did not own the storage, and in-tree callers passed stack and static memory), so 24 bytes leaked per finalizable object EVER constructed — a leak 0.9's door 5 multiplies from "one per Active Object" to "one per object". And caller-supplied storage could die before the object did, which is a use-after-free the API invited (BUG-304). Emitted C shrinks: three lines and a calloc per construction become one call, and the calloc's unchecked NULL return — a silent crash on OOM — is now a loud runtime abort. A unit generated by a pre-0.9 reefc names reef_finalizer_node_t and therefore fails to COMPILE against the 0.9 runtime, which is the loud direction; the reverse (0.9 units, older runtime) fails at link. Same lockstep note as the ABI v2/v3 and 0.8.6 string-header breaks. Downstream GC implementors inherit the ownership rule with it.

  • Descriptor ABI v3 — compiler and runtime must upgrade together (0.9 Phase 8e, object milestone M2). reef_record_block_t.methods is re-typed reef_method_proc_t* -> const void* and num_methods is RETIRED; REEF_DESCRIPTOR_ABI_VERSION moves 2 -> 3 (LP64 sizeof 104 -> 96, the retired int taking its own trailing padding hole with it), and the now-unused reef_method_proc_t typedef goes with them. The method table is a per-class VTABLE STRUCT — a struct of differently-typed slots, each typed by the class that INTRODUCED the virtual — so no single function-pointer type describes it, and slot COUNT is a compile-time property of that struct type rather than something a pointer beside it could track. The field is OPAQUE to the runtime: the collector, the allocator and the RTTI walk never dereference it; only generated C, which knows the class, casts it back. Measured before the change: methods/num_methods were read NOWHERE across runtime, baremetal and compiler. Every unit generated by a 0.9 reefc carries a _Static_assert on the version and on sizeof, so a mismatched install fails the C compile loudly. Downstream GC implementors must drop num_methods from their descriptor mirror and re-type methods; nothing reads either, so no logic moves with it. Same lockstep note as the ABI v2 and 0.8.6 string-header breaks.

  • Descriptor ABI v2 — compiler and runtime must upgrade together (0.9 Phase 8c, BUG-240). reef_record_block_t gained four fields (enum_fields/num_enum_fields/variants/num_variants — the conditionally-a-pointer root kind; LP64 sizeof 72 -> 104) and REEF_DESCRIPTOR_ABI_VERSION moved 1 -> 2. Every unit generated by a 0.9 reefc carries a _Static_assert on both, so a mismatched install fails the C compile loudly instead of silently zero-initializing the new fields (which would reintroduce the exact bug the fields fix). Units generated by a pre-0.9 reefc carry no assert and compile silently against the v2 header; their zero-initialized new fields are the correct empty state, so the consequence is benign. Same lockstep note as the 0.8.6 string-header break. Downstream GC implementors must adapt their descriptor mirror before consuming 0.9 descriptors: an enum's descriptor now carries a per-variant trace table, and containing types reference it for by-value payload-enum fields.

  • Type identity is now MODULE-QUALIFIED, and generated C symbols carry the defining module. A type is identified by its defining module plus its name, not by its name alone. Two modules may each declare a Rect and a program may import both; qualify at the use site and each keeps its own fields, layout and GC descriptor. What changes for existing code:

    • A bare reference to a type that two imported modules both declare is now a compile error naming both modules, instead of silently picking one. Qualify it (core.Rect). This matches what bare references to same-named GENERIC types have done since 0.7.
    • Passing one module's Widget where another module's Widget is declared is now a type error. It used to compile, because the two were one type.
    • Generated C symbols are renamed, including monomorphized specializations, which now carry their type ARGUMENT's module as well. For a type you DECLARED, type_name() still returns the source name you wrote. A SPECIALIZATION has no source spelling, so its descriptor name is its mangled name and follows the rename: type_name() on a Result[int, Error] value now returns core_result_Result_int_core_error_Error. Generic FUNCTION specializations move with them (reef_core_result_is_err_int_Error -> reef_core_result_is_err_int_core_error_Error, 84 of them across the compiler's standing corpus). Measured over that corpus, no symbol is removed: every one of those 84 has a renamed counterpart, and the rename adds 2,369 names without dropping one. Nothing else in the language depends on these names, but anyone reading --emit-c output or a linker error will see the new spelling.
    • Two declarations that derive the same C symbol are now a checked error naming both. Symbol components are joined with _ and Reef identifiers may contain _, so this is possible even with distinct modules (module A_b class c and module A class b_c). Rename one. One shape that used to compile is caught by this at the C-symbol door: a struct with an OWN method m and a trait impl providing m for the same struct. Those were two C symbols before (Buf_size and reef_Buf_size) and are one now. As of D42 that shape is a type error (BUG-274 option 3); D12 remains the backstop for the A_b.c / A.b_c join, which is not a source-level same-name pair.
    • Two modules declaring the same object class were still refused when this note was written; they coexist as of the object inheritance milestone later in this same release (BUG-272 — passive-object class identity is module-keyed too now). See the entry under Added.
  • typecase and inherited are now RESERVED WORDS (0.9, owner decision 2026-08-12 — the only two additions to the reserved-word list in the 0.9 cycle; extends and override remain contextual and safe to use as identifiers). Any program using either word as an identifier — a variable, function, field, type, or method name — stops parsing with the standard reserved-word diagnostic:

    program.reef: Syntax error at line 2, column 17
      note: 'inherited' is a reserved word and cannot be used as an identifier
    

    (As with every reserved word, the reported line/column points at the token AFTER the offending identifier — the =, (, or : that follows it — not at the identifier itself.)

    Migration: rename the identifier; any other name works. A scan of the entire in-tree corpus (stdlib, examples, reef-os, baremetal) found ZERO real uses of either word as an identifier, so typical programs are expected to be unaffected. Why: the contextual spellings were implemented first and measured inadequate — contextual typecase is structurally unparseable at statement head (LR-ambiguous against bare-identifier statements), and contextual inherited (recognized by statement-pair fusion) could not represent value-position base calls like let r = inherited area(). Reserving the two words makes both features ordinary grammar: typecase gains its statement form (arms shaped exactly like statement-match arms, with per-arm end), and inherited m(...)/inherited init(...) work in statement AND value position. When this note was written both were still gated at the build step; inherited is fully implemented as of the object inheritance milestone later in this same release, and typecase remains gated ("'typecase' not yet enabled") until its lowering lands.

  • self can no longer be assigned to (BUG-226; 0.9 Phase 7 T1). Rebinding the receiver itself — self = expr, in a struct, Active Object or passive-object method, or in init/finalize — type-checked cleanly in every receiver kind and is now a checked error:

    Type Error: Cannot assign to 'self': the receiver is an immutable
    binding and cannot be rebound. (Assigning a FIELD of the receiver,
    'self.field = value', is a different statement — legal in exclusive
    methods, and rejected in shared ones by the data-race rule.)
    

    Breaking in the strict sense: a program containing self = ... compiles at v0.8.6 and does not compile now. Nothing useful is lost — the receiver was always bound as immutable (the check simply never looked at it, because self is not an ordinary variable node), and the emitted C assigned a local alias, so the statement never did what it appeared to do. A scan of the whole in-tree corpus found ZERO uses. Migration: assign the fields you meant to change (self.x = ...), which is what the statement was silently approximating.

  • A closure now captures the INNERMOST binding visible where the closure is written (0.9 Phase 7 T9, owner decision D4). This is a behaviour change, not a new rejection: affected programs compiled before and compile now — they just produce a different (correct) value. If a name is not shadowed anywhere in your program, nothing changes.

    The rule is now the ordinary scoping rule, applied once. Capture decides how a value is stored (copied, or boxed by reference), never which binding a name means; a capture and a direct read of the same name at the same point always refer to the same binding.

    The headline shape is a loop variable that happens to share a name with something already in scope — no deliberate shadowing involved:

    let v = "OUTER"
    let xs: [string] = ["A", "B"]
    mut out = ""
    for v in xs
        let f = fn(): string => v
        out = out + f() + ";"
    end for
    println(out)        // was: OUTER;OUTER;   now: A;B;
    println(v)          // OUTER, unchanged
    

    What was actually happening. The rule being replaced was never "outermost" in any principled sense — it was "the first binding of that NAME registered in the enclosing function's frame, in source order", a statement about a hash table rather than about scopes. It therefore also mis-resolved DISJOINT SIBLING BLOCKS, where no shadowing exists at all and neither binding encloses the other: two if blocks each declaring their own let v, and the second block's closure read the FIRST block's value. Worse, it was not even one behaviour — captures of GC-managed values (strings, arrays, structs, objects) went through that name map, while captures of plain scalars emitted a bare C name and were resolved INNERMOST by C's own block scoping. So the same shadowed name could resolve two different ways in one function depending only on the captured value's type. All of that is gone: captures are resolved by the type checker against real scopes, per binding instance.

    Migration: none required, and there is no diagnostic to look for (the old behaviour was silent). If a program deliberately relied on a closure reaching past a shadowing binding to an outer one of the same name, rename one of the two bindings — that program was reading the outer value by accident of the implementation, and its scalar-typed equivalent already read the inner one. Also fixed by the same change: a lambda-local binding that shadows a captured name is now read correctly inside the lambda (BUG-229), and closures nest to any depth (BUG-230) — both listed under Fixed below.

  • An await condition may no longer name a binding that is shadowed at the await site (0.9 Phase 7 T9, owner decision D5). This is a new checked error on code that compiles at v0.8.6:

    await condition names 'v', which is SHADOWED at this point. An await
    condition is a by-value snapshot taken once and re-evaluated on every
    wake without refresh, so a shadowed name is never what you meant — the
    wait would be either instantly true or never true. Rename the inner
    binding, or read the value through 'self'.
    
    exclusive fn go(v: int): int
        if true
            let v = 2          // shadows the parameter
            await v == 5       // <- now rejected
            return v
        end if
        return 0
    end go
    

    Why refuse rather than pick a rule. An await condition is not a closure: it is a by-value SNAPSHOT, taken once when the wait begins and re-evaluated on every wake WITHOUT refreshing the values it named, with self the only term that can change in between. So a shadowed name is wrong in both directions, and both were measured on this exact program shape: under the old outermost reading you wait on a predicate the programmer did not write (a string probe returned immediately); under D4's innermost reading you wait on a frozen local that nothing in the condition can ever change, and the int version DEADLOCKED. House precedent is BUG-117, where await in run() was rejected rather than made to compile, on exactly the reasoning that a silently non-blocking (or never-blocking) wait is worse than a compile error.

    Migration: rename the inner binding, or make the condition depend on self (await self.ready) — which is what a condition that is meant to become true has to do anyway. An UNSHADOWED name in an await condition is unaffected. As of D44 the condition is live (BUG-251 closed): unshadowed names are re-read each wake. D5 stays because a shadowed name is still never what was meant.

  • Declaring a mut binding that a closure captures now ALLOCATES (0.9 Phase 7-ter; the storage half of the BUG-253 fix below). This is behaviour-breaking in exactly two places, both narrow:

    • Inside finalize(). Finalizers run inside the allocator and must not allocate — an allocating finalizer hangs the program with no diagnostic. Writing a closure in a finalizer already allocated (the closure's environment is a heap object), so the newly affected shape is precise: a mut binding declared unconditionally in a finalizer body whose capturing closure is only built on a branch that never runs. That used to complete; it now hangs. What to do: do not declare captured mut bindings in a finalizer. The full list of allocating constructs — which now names closures and captured mut bindings, and previously mentioned neither — is in Active Objects → finalize.
    • On freestanding/baremetal targets, where the allocator is a panic stub. A kernel that declares a captured mut binding now panics at that declaration instead of running. This is deliberate: the alternative is keeping a write-through-a-dangling-stack-pointer primitive alive inside kernel code, which is the worst possible place for one. Emitting the box is unconditional — there is no target switch — and the freestanding shape is gated in CI across amd64, aarch64 and riscv64.

    Everything else about the change is a fix, not a break: see below.

Fixed

  • Argument evaluation order no longer decides whether a GC value survives a call (0.9 Phase 8, BUG-301, owner decision D29). C leaves the order in which a call's arguments are evaluated unspecified, and the two supported C backends disagree: gcc evaluates argument lists right-to-left, clang left-to-right. reefc's argument-rooting gate asked whether a rootable argument appeared before an allocating one in the SOURCE — which is not a fact about evaluation — so a call whose only GC-typed argument was the LAST one, beside an earlier argument that allocates, was left unrooted. Under gcc the GC value is produced first and can be collected while the earlier argument runs, and the callee receives a dangling pointer: 374 of 2000 iterations corrupted under gcc, 0 under clang, from safe (non-unsafe) Reef with no diagnostic. Invisible on an ordinary build, because reefc prefers clang; live on gcc-only targets. The gate is now order-independent. No Reef-source change is needed; recompiling is enough. Pre-existing: the shape is present in every release with argument rooting, and releases before it (0.8.6 and earlier) have no argument rooting at all.

  • The mutable-capture GC box migrated onto the recursive descriptor root kind (0.9 Phase 8d, owner decision D17 — internal to the emitted C; no Reef-source or runtime change). A captured mut payload-enum's cell no longer carries compiler-emitted sibling shadow words refreshed at every store site (7-ter's BUG-253 fix shape, where a missed store site would have been a silent collection); the cell descriptor now carries a typed enum_fields entry and the collector reads the live tag at mark time, the same mechanism as every other heap population since 8c. All five BUG-253 shapes, BUG-261 and the enum-payload retention gate re-proven in the migration commit, including load-bearing scratch controls in both failure directions.

  • Heap-embedded enum payloads are now traced by the collector (BUG-240 — Critical, silent GC corruption, live in the shipped stdlib: graphics.canvas's current fill/stroke paint color could be reclaimed and reused mid-program with exit 0; 0.9 Phase 8c). Struct fields, Active Object fields, passive object fields, array elements, closure-env captures and nested enums (any depth) now keep their payloads alive through typed descriptor entries + per-variant tables; the collector reads the LIVE tag and descends, defended against corrupt tags (an out-of-range tag marks nothing and never indexes the table; under REEF_GC_STRICT it aborts with a diagnostic).

  • Array literals of GENERIC by-value enums no longer allocate as pointer arrays (BUG-282; 0.9 Phase 8c — 8-byte slots for 16-byte-plus values: element 1+ stored out of bounds and tag/union words were traced as heap pointers; a 32-element literal SIGSEGV'd under a small heap, identically on shipped v0.8.6). They are value arrays now, elements traced through the enum's variant table, and the enum-array literal's base pointer is rooted across element construction.

  • An array literal with allocating element expressions can no longer be collected mid-construction (BUG-283; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6). While a literal's elements evaluate, the array block was reachable only through an unrooted C temp; an element expression that allocates (["a" + x, ...], [new S(...), ...], or a plain value array whose element expressions allocate) could trigger a collection that reclaimed the block, and the remaining element stores wrote into reused memory — measured SIGSEGV 3/3 on an 8-element string literal and on a two-element new Struct(...) literal under a small heap. Every array-literal branch now roots its base pointer across element construction (extending the rooting 8c gave enum-array literals); literals whose elements cannot allocate are unchanged.

  • .append() on a by-value enum array now compiles (BUG-284; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6, where it NEVER compiled, plain or generic: the emitted compound literal brace-initialized the enum's tag member with the whole enum rvalue, a C constraint violation rejected by gcc and clang with a cryptic generated-C error). The item now goes through a named temporary — uniformly for every element type, including all-scalar-payload enums, which are struct-lowered too and failed identically.

  • Stored map values could be reclaimed while still in the map — a shipped v0.8.6 standard-library hole (closed by the BUG-286 change; 0.9 Phase 8c-bis). collections.map keeps its values in an internal new [any](32) array, and any-element arrays were allocated UNTRACED — under memory pressure a value whose only reference was the map itself was invisible to the collector and could be reclaimed and reused. Measured on v0.8.6: a boxed value read back from a map after allocation churn failed its type assertion 3 runs in 3 ("expected BoxString, got <array>" — the value's memory reincarnated as something else); shapes that don't type-assert would see silently wrong values instead. If you kept data in maps on 0.8.6 under load, this affected you. any-element arrays (the map's values array included) are traced pointer arrays now.

  • A map entry whose key AND value are both freshly computed no longer loses the key (BUG-289; 0.9 Phase 8c-bis — 0.9-exposed: on v0.8.6 the same program failed earlier through the untraced-values hole above). The emitted store call received both freshly-allocated expressions as unrooted sibling arguments; one could be collected while the other allocated. Measured: entries missing from the map (Option.unwrap called on None / map_has false), deterministic under pressure. Entry keys and values now evaluate into rooted slots, in defined key-then-value order.

  • Concatenating two freshly-built arrays no longer corrupts the result (BUG-290; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6). [a, b] + [c, d] passed both operand arrays as unrooted sibling arguments to the concat helper — string concatenation has rooted its operands since C6.3, but the ARRAY branch never did — so the left array (and its element payloads) could be collected while the right one was still being built. Measured: 16/2000 corrupted read-backs, deterministic; operands already held in variables were unaffected. Both operands now evaluate into rooted slots when either can allocate.

  • Map literals and to_map() can no longer be collected mid-construction (BUG-288; 0.9 Phase 8c-bis — pre-existing emission shapes). A map literal whose entry expressions allocate (#{ "k" => box.box_string("x" + y) }), or a to_map() whose struct argument is freshly built, held the new map — and for to_map(), the struct too — only in unrooted temporaries while later allocations could trigger a collection; under memory pressure the map was reclaimed mid-construction (measured: a NULL-array bounds panic 3/3 for the literal, a SIGFPE 3/3 for to_map — and silent wrong data in shapes that don't crash). Both constructions now root their temporaries across entry evaluation; literals whose entries cannot allocate are unchanged.

  • Arrays of closures are now visible to the garbage collector (BUG-286 + BUG-287; 0.9 Phase 8c-bis follow-on). An array of lambdas ([f, g]) compiled — newly, in 0.9; v0.8.6 rejected the shape with a generated-C error — but allocated as a plain untraced array, and separately the closure CONSTRUCTION held the freshly allocated environment in an unrooted temporary while allocating the wrapper, so a collection landing there freed the environment under a live closure (that half also exists on shipped v0.8.6 for ordinary closure bindings, where it fires only if a collection lands exactly in the two-instruction window). Closure-element arrays now allocate as traced pointer arrays (literals, new [T](n) and append alike), and every capture-bearing closure construction roots its environment across the wrapper allocation. Invoking an array element after memory pressure was a deterministic SIGSEGV in the witness; both halves were independently measured necessary.

  • An array rebuilt from empty keeps its GC visibility (BUG-285; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6, silent GC corruption: measured 429-500/500 corrupted read-backs). Empty Reef arrays are NULL at runtime, and append took its "does the collector trace the elements?" truth from the source array's heap block — absent for NULL — so the first append onto an emptied array (pop() to empty, remove() of the last element, an empty slice or []) allocated a PLAIN untraced array, and its string/array/struct/enum-payload elements could be reclaimed while still in use, for the array's whole life. The append call now carries the element's GC truth from the compiler (pointer flag or enum descriptor, from the static element type), so arrays rebuilt from empty are stamped correctly at allocation; scalar arrays stay unstamped. Fixed together with BUG-284 in one change (fixing 284 alone would have made 285's enum variant live the moment enum append first compiled — demonstrated 134/500 corrupted in a scratch build with the second half withheld). Compiler and matching runtime headers upgrade together, as usual since 0.8.6.

  • A value stored inside an enum-inside-an-enum could be collected while still in use (BUG-214, release blocker; 0.9 Phase 7-quater). When an enum's payload was itself a payload-carrying enum, the string, array or object at the bottom had no garbage-collection root at all — for the entire lifetime of the binding holding it. The most common spelling in real code is the standard library's own Result[Option[string], Error], which io.stream.stream_read_line, io.buffer.reader_read_line, io.buffer.reader_read_bytes and their callers return:

    let o = Outer_Wrap(Inner_Payload(str.concat("nested", "!")))
    // ... anything that allocates ...
    peel(o)        // the string was gone
    

    No diagnostic at any stage: it type-checked, emitted clean C, compiled and ran, and the loss appeared only under memory pressure — 30 reads out of 30 returned corrupted or empty content once a collection ran between the store and the read. A plain local was enough; no closure, no match, nothing exotic. Measured shapes: a plain local, a match scrutinee with an allocating guard, and a variable captured by an escaping closure.

    On 0.8.6 and earlier this was not only wrong data — it could crash. The affected value is read through a pointer the collector has already reclaimed, so the outcome depends on whether that memory is still mapped: garbage content when it is, a SIGSEGV when it is not. A program doing nothing more exotic than reading a file line by line with io.buffer — no user-declared enum anywhere in it — segfaults 3 runs out of 3 on shipped v0.8.6 once it prints a line it held across an allocating call. If you read lines through io.stream, io.buffer or fs on 0.8.6, treat this as a memory-safety fix, not a correctness nicety.

    The cause was one level of analysis. The compiler decided a variant's field needed a root only when that field's own type was a garbage-collected type — and an enum is a value, not a pointer, so an enum-typed field looked like a scalar and contributed nothing. The analysis now follows the nesting transitively, at any depth, emitting a compound tag-guarded root chain (Outer is Wrap and the Inner inside it is Payload → root that pointer, otherwise root nothing), so every place a payload is rooted — locals, match scrutinees, call and spawn arguments, and captured mutable variables — gains transitivity from the same place. Programs without nested enums emit byte-identical C.

    Still open and deliberately distinct: a payload-carrying enum stored in a struct, Active Object or object field, in an array element, or in a closure environment is a different defect (BUG-240) with a descriptor-side fix, and is not addressed here.

  • An escaping closure that captured a mut variable read and wrote memory that no longer existed (BUG-253, release blocker; 0.9 Phase 7-ter). A closure that captured a mut binding held the address of that binding's storage in the enclosing function's stack frame. When the closure outlived that function — returned from it, stored in a field, passed to a callback — every read and every write went to a stack slot that had already been reused:

    fn make(): Fn[int, int]
        mut acc = 211
        let peek = fn(x: int): int => acc + x
        return peek                    // peek outlives make()
    end make
    
    proc main()
        let f = make()
        println("${f(0)}")             // printed 0, not 211
    end main
    

    There was no diagnostic at any stage: it type-checked, emitted clean C, compiled clean and ran. AddressSanitizer named it exactly — stack-use-after-return, for reads and writes. The write case is what made this critical rather than merely wrong: a closure assigning to its captured variable deposited a value into a frame that had already returned, i.e. into whatever ran next. Captures of string, arrays and objects crashed outright (SIGSEGV), and the same value was also invisible to the garbage collector, so it could be reclaimed while the closure still held it.

    This affected every released version, including v0.8.6. It was not introduced by 0.9.

    Fixed: a mut binding that any closure captures now lives in a garbage-collected cell instead of on the stack. The declaring function and every closure share that one cell, so writes from either side are visible to both — the semantics mut capture always advertised — and the cell lives as long as any closure that holds it.

    What changes for you:

    • Closures that capture mut variables just work now. Returning one, storing one in a field, handing one to a callback that runs later: all correct. Any workaround written around the old defect (copying into a let before capturing, keeping the declaring function alive artificially) is no longer necessary, though it stays harmless.
    • A mut binding declared inside a loop body gets its own cell per iteration, so closures created in different iterations see different variables rather than all sharing the last iteration's value.
    • Reading a mut variable is enough to trigger this. Capture storage is decided by the variable's mutability, not by whether your closure assigns to it, so a closure that only reads a mut variable shares the cell too. If you want an independent copy, capture a let.
    • A closure that captures a mut variable and escapes into a spawn now gives two threads a shared, unsynchronised, mutable heap value. That is the intended fix — a crash became a well-defined data race — but it is your race to manage. If the value crosses threads, put it behind an Active Object.
    • The generated C for capturing closures changed shape. This matters only if you inspect --emit-c output or link against generated code; the Reef-level API is unchanged.
    • See also the two behaviour changes under Breaking above.

    Fixed alongside it: storing into a captured enum from inside a closure published no root for that enum's payload, so the payload could be collected while still in use (BUG-261 — measured at 138 of 200 values lost, on v0.8.6 as well).

  • A garbage collection during new could collect a constructor argument (BUG-227; 0.9 Phase 7 T4). Constructing an Active Object with arguments left those argument values unprotected across two collection points, so a collection at the wrong instant could reclaim a freshly built string, array or object while it was on its way into the constructor — the field ended up holding reused memory, with no diagnostic:

    let w = new Worker(tag("one"), tag("two"))   // either value could be lost
    let h = new Holder(tag("solo"))              // this one could be lost too
    

    Two independent windows, both closed:

    • At the call site. Construction sites never received the per-call-site argument root slots every other call form has had since 0.9's Stage 6. With two or more arguments, the value produced first was unrooted while a later argument allocated.
    • Inside the allocator. The generated <AO>_new wrapper allocated the object before establishing the frame that roots anything, so its own parameters were unprotected across that allocation — which is itself a collection point, reached on every construction. This one bit even a single-argument new.

    Construction with no arguments, and struct construction (new Point(a, b), which stores each value into the object as it goes), were never affected and are unchanged. Programs need no source changes; recompile to pick up the fix.

  • A call to a function in one package no longer silently runs a same-named function from a DIFFERENT package (BUG-206; 0.9 Phase 7 T2). When a program loaded two modules whose paths end in the same component — ui.event and reefvision.event, ui.theme and reefvision.theme, graphics.backend.memory and core.memory, reefvision.input and gameui.input — code generation resolved every qualified call through a program-global table keyed by that last component, first module compiled wins. event.key_event_char(ke) in a module that imports ui.event could therefore emit a call to reefvision.event's function of the same name. reefc --check accepted the program (the type checker resolves imports correctly, per module); when the two signatures happened to match, the binary simply called the wrong package's code with no diagnostic at all. Callee identity is now decided at type-check time and carries the resolved defining module, so each call goes where its own module's imports say it should. Programs that combine two such packages may see corrected behavior; nothing that was already correct changes (the compiler's full example/stdlib corpus emits byte-identical C apart from the programs that actually hit the collision).

  • A function's default argument may again call its own module's functions and be used from another module (BUG-211; 0.9 Phase 7 T2). Given fn decorate(prefix: string, suffix: string = join2(part("x"), part("y"))) exported from module dshared, calling dshared.decorate("CCC") from anywhere else failed to build with call to undeclared function 'reef_part': the default expression was spliced into the caller and its interior calls were then named against the CALLER's module. The workaround — module-qualifying every call inside a default — is no longer needed.

  • A shared Active Object method can no longer smuggle an exclusive self-call past the compiler inside an expression wrapper — closing a guaranteed runtime DEADLOCK (BUG-220; 0.9 Phase 6g). The shared-method checker previously missed calls nested inside string interpolations ("${self.bump()}"), struct-literal fields, map/set literal elements, is/as/type_name/type_id/to_map operands, and lambda bodies; every one of those shapes compiled, and the interpolation shape self-locked on the AO reader lock at first call (verified hang). All are now rejected at typecheck with the standard BUG-114 diagnostic ("Cannot call exclusive method '...' on 'self' from a shared method..."). Breaking in the strict sense: previously COMPILING programs containing one of these shapes now fail reefc --check — but every such program deadlocked (or would deadlock) at runtime, so nothing working is lost. Migration: same as BUG-114's original rule — make the calling method exclusive, or move the mutating call out of the shared method. Wrapper forms that merely READ state ("${self.n}", P { x: self.n }) remain legal and running. Internally, both shared-method checkers (Active Objects and 0.9 passive objects) now drive the compiler's single total AST walker, so this class of descent blind spot is closed structurally, not per-form.

  • A trait implemented for a payload-carrying enum no longer reads its receiver's payload after the collector may have reclaimed it (BUG-239; 0.9 Phase 7 T3). impl Show for Shape, where Shape is an enum whose variants carry strings or other heap values, compiled a method whose implicit self was the last enum-typed parameter in the compiler without the GC payload root slots every other enum parameter has had since 0.8.x. A method that allocated before reading its receiver's payload — and whose caller held no other reference, e.g. Slot_show(mk("P")) — read freed memory: a 400-concatenation repro fails 20/20 runs before the fix and passes 20/20 after. The receiver is an ordinary stamped binding now, so it gets the same treatment. No other emitted code changes.

  • A method parameter named after a C keyword builds again (BUG-200; 0.9 Phase 7 T1). Method SIGNATURES emitted their parameter names raw while the bodies read the mangled form, so fn get(volatile: int) on a struct or Active Object emitted int Sbox_get(Sbox* self, int volatile) against a body reading reef_volatile — a loud C compile failure for any parameter named after a C keyword that is not a Reef keyword (register, union, volatile, ...). The title of the original report understated it: struct methods had the identical defect, and the fix covers all six signature-emitting sites (AO exclusive/shared methods, <AO>_init, struct methods and their forward declarations). Emitted C for the rest of the corpus is byte-identical.

  • A binding declared inside a lambda no longer reads the captured outer value of the same name (BUG-229; 0.9 Phase 7 T9). A lambda that declared a local shadowing a name it also captured emitted the local's initializer as a dead store, and every read of the name inside the lambda returned the CAPTURED outer value — a silent wrong answer on an ordinary program, with no diagnostic and no crash:

    let msg = "outer"
    let g = fn(): string
        let msg = "inner"        // a local of g, not the capture
        return msg               // was: outer      now: inner
    end fn
    

    Pre-existing on v0.8.6. The cause was two composing facts, both now gone: the free-variable walker never excluded a lambda-BODY declared name, so it entered the closure environment as if it were free; and code generation consulted a name-keyed capture table ahead of the binding resolver. Capture sets are computed by the type checker now, which has real scoping, and each use routes on its own resolution — Capture and local binding are mutually exclusive per use, so there is no precedence left to get wrong.

  • Lambdas nested two or more levels deep can capture from any enclosing scope (BUG-230; 0.9 Phase 7 T9). Closures were effectively single-level: a lambda inside a lambda could not see anything from outside its immediate parent, failing with a C compile error naming the identifier (use of undeclared identifier 'base'). The documented feature simply did not work past one level:

    let base = 7
    let mid = fn(): int
        let inner = fn(): int => base + 1   // was: build failure
        return inner()
    end fn
    

    Capture lists are now closed TRANSITIVELY — a binding an inner lambda needs from outside the middle lambda appears in the middle lambda's list too — and both capture-initialization sites resolve the enclosing scope by binding instance, so a capturing scope hands its own environment member to the scope inside it.

  • Calling a CAPTURED closure from inside another lambda works (BUG-231; 0.9 Phase 7 T9). The shape was rejects-valid, and reported as an Internal Compiler Error ("frame discovery and emission disagree") — which reads to a user as a broken compiler rather than an unsupported shape. (At v0.8.6 the same program was a loud C error, so it never worked; 0.9's stricter code generation turned the loud C error into an ICE.) The closure-call path now routes its callee on the callee's own resolution, so a captured callee reads the environment member. The shape compiles and runs.

  • Assigning to a captured payload-carrying enum inside a lambda works again (BUG-232; 0.9 Phase 7 T9). This one was a REGRESSION against the last release: the program below compiles and runs on v0.8.6, and 0.9's enum-payload root slots turned it into a rejects-valid ICE. Assignment through a capture is not a binding store at all (a mutable capture is by-reference, so it writes the enclosing variable, whose own frame slots still protect it), so the store site no longer asks for payload slots it cannot have. v0.8.6 parity is restored.

  • A lambda inside a method can capture self (BUG-224; 0.9 Phase 7 T9). let f = fn(): int => self.n * 2 inside an Active Object, struct or passive-object method emitted a bare self into the closure function and failed to build (loud C error, all three receiver kinds identically). The receiver is an ordinary stamped binding now, so it is captured like anything else: the closure environment gets a typed member for it, and — because that member is in the environment's GC descriptor — the receiver is a traced root for the closure's lifetime. Pre-existing; not a regression.

  • Captures of a shadowed name resolve as D4 specifies (BUG-198; 0.9 Phase 7 T9). Closed as the owner decision recorded under Breaking above, which is what this report asked for: the question was never "which of two implementations is right" but "which binding does the LANGUAGE say a capture means", and the answer is the innermost visible one, stated once by the scoping rules. Documented for users for the first time in docs/language/reference/060_CLOSURES.md ("Which Binding Is Captured").

Added

  • Passive-object INHERITANCE — object milestone M2 (0.9 Phase 8e). object Button extends Widget works end to end: extends, virtual methods with a mandatory override marker, inherited m(...) base calls, the inherited init(...) construction chain (and its implicit form — a class that declares no init but has an ancestor that does takes that signature), Derived-to-Base assignment at bindings, parameters, returns, array elements and field stores, ==/!= reference identity across a hierarchy, and is/as/type_name walking the full base chain. Hierarchies work within a module, across modules (including a three-module chain), and in either declaration order. Dispatch is through a per-class vtable whose slots are typed by the class that introduced each virtual; a derived class's vtable embeds its base's as its first member, so one dispatch shape is correct at every depth. A derived class's GC descriptor carries its base's traced fields as well as its own. Still refused, naming what opens it: typecase lowering.

  • Passive objects may declare finalize (0.9 Phase 8f). The last object-milestone refusal is gone: an object may declare a finalize() body, and constructing one registers it with the collector exactly as an Active Object's is. Chaining is AUTOMATIC and base-ward — after a derived finalize returns, the base's runs, then its base's, and so on. There is no inherited finalize() (it is a syntax error) and finalize may not be marked override (it is not virtual): the chain is the language's, not the program's, so the forgotten-base-cleanup mistake is not expressible. A class that declares no finalize of its own but inherits one still registers, because registration is per HIERARCHY. Chains work across module boundaries. What opened the door was two runtime fixes shipped with it: the global finalizer list is now protected by the heap allocation lock (BUG-278 — registration was lock-free while the sweep traversed and unlinked the same list, so concurrent construction silently dropped finalizers), and an allocating finalizer now ABORTS.

  • An allocating finalizer now aborts loudly instead of hanging (0.9 Phase 8f). finalize() runs on the reclaim path with the heap allocation lock held, so any GC allocation from a finalizer used to wait forever on a lock its own thread already owned: the process hung with no output, no diagnostic and no stack. The no-allocation rule was documented but unenforced. It is now checked at the allocator: a finalizer that allocates gets a fatal diagnostic that restates the rule and lists the constructs that allocate — including the two that surprise people, string interpolation and a closure capturing a mut binding (whose capture box is allocated where the mut is DECLARED, even if the closure is only built on a branch that is never taken). This applies to Active Objects and passive objects alike.

  • Two same-named object classes in two different modules may now coexist (BUG-272). Passive-object class identity carries its defining module, like every other type kind since 0.9's earlier qualification work, so liba.Widget and libb.Widget may both be objects in one program: each keeps its own field layout, its own slot map and its own dispatch. The compile-time refusal that stood in for this is gone.

  • make vtable-check — compiles every vtable-bearing unit of the in-tree corpus under both C backends (adding -Werror=incompatible-function-pointer-types to clang, which is the only backend that accepts the flag; gcc 14+ rejects the same drift with no flag) to catch a vtable slot signature that drifted from the compiler's own slot map. Also a CI step. Deliberately NOT part of reefc's own build command: correct emission cannot trip it.

  • Milestone M1 is complete: single-class passive objects work end to end, and everything still unfinished says so BY NAME (0.9 Phase 7, task T8). The refusals that used to open with "objects not yet enabled" were true while objects were a parse-only surface. They are not true any more — objects declare, construct, dispatch and are garbage-collected — so each one now names the single construct it refuses and the milestone that opens it:

    object inheritance not yet enabled: object 'Button' declares 'extends' - ... milestone M2.
        Single-class objects are fully supported: fields, init, methods, dispatch,
        `is`/`as`/`type_name` and GC all work at M1.
    
    object finalizers not yet enabled: object 'Resource' declares 'finalize' - ... milestone M2 ...
    
    'typecase' not yet enabled: cannot generate code for 'typecase'
        (0.9 passive-object model; lowering lands with milestone M3 - use an `is` chain
        in the meantime)
    

    The difference is practical, not cosmetic. Under the old wording a program refused for one extends looked like a program refused for using objects, and the obvious response was to stop using objects. The refusal now points at the line to change, and says what still works.

    What M1 ships: object declarations with fields; init; exclusive/shared methods with order-independent self.m() calls; new C(...); reference ==/!=; is, as and type_name; arrays of objects (invariant); objects as struct fields, module globals and function parameters/returns; and full GC coverage of an object's own reference-typed fields.

    What M1 does not ship: extends/override/inherited (M2), object finalize (M2 — refused rather than ignored, because a finalizer that registers and never runs is invisible), and typecase (M3; is chains are the 0.9 spelling). One known gap inside what does ship: an object field whose type is a payload-carrying enum has its payload unmarked by the collector (BUG-240) — the field itself is tracked, the value behind the enum's payload is not.

  • Passive objects have METHODS, and you can call them (0.9 Phase 7, milestone M1, task T7). With construction (T6) and methods (T7) an object is finally useful: it holds state and it has behaviour.

    object Counter
        n: int
    
        init(start: int)
            self.n = start
        end init
    
        exclusive proc bump()
            self.n = self.n + 1
        end bump
    
        shared fn describe(): string
            return "Counter(${self.n})"
        end describe
    end Counter
    
    proc main()
        let c = new Counter(5)
        c.bump()
        println(c.describe())     // Counter(6)
    end main
    
    • A method may call another method on self regardless of declaration order, including from init. There is no "declare before use" rule to learn.
    • Object methods can be called from anywhere — a free function, a struct method, an Active Object method, a lambda, another object's method, or a module-level initializer.
    • exclusive and shared do not lock anything on a passive object. A passive object has no monitor of its own; the markers are the write-exclusivity classification (a shared method may not assign through self or call an exclusive method on it), and they are checked at compile time.
    • == and != on object references are REFERENCE IDENTITY — two separately constructed objects with equal fields are NOT equal. Ordering operators (<, >, <=, >=) are rejected on objects.
    • Arrays of objects work and are INVARIANT in their element type. An annotated array literal (let ws: [Widget] = [...]) checks each element against the declared element type.

    extends, typecase and object finalize remain gated behind their own checked errors.

  • Passive objects can be CONSTRUCTED: new Widget(x, y) allocates and runs init (0.9 Phase 7, milestone M1, task T6). This is the point where objects stop being a type you can only declare and become instances you can build, hold and read:

    object Point
        x: int
        y: int
    
        init(x: int, y: int)
            self.x = x
            self.y = y
        end init
    end Point
    
    proc main()
        let p = new Point(1, 2)
        println("${p.x},${p.y}")
    end main
    
    • init runs, and it is not positional field assignment. Converting a struct to an object changes what new X(a, b) MEANS: the struct form assigns arguments to fields in declaration order, the object form calls init with them. An object's init may transform its arguments, write fields in any order, and write fields that have no corresponding argument at all.
    • Objects with no init construct with new C() and zero-init — scalar fields read 0/false, GC-managed fields are NULL, which is legal and is exercised under real collections.
    • A constructed object keeps its contents across garbage collection. Strings, arrays, structs, other objects, closures, Active Object references and generic instantiations held in object fields are all traced; sets, subranges, by-value enums and raw C pointers correctly are not.
    • finalize on an object is REFUSED with a checked error naming the milestone that opens it. It is deferred rather than silently registered-and-never-run, because the runtime's finalizer registration is not yet safe to reach from any thread.
    • init parameter DEFAULTS are not applied — every parameter must be supplied. This was already the behaviour; the diagnostic now says so instead of reporting a bare argument-count mismatch (BUG-245).

    Object METHODS remain gated behind their own checked error, as do extends and typecase.

  • Passive objects are a real type in the generated code (0.9 Phase 7, milestone M1, task T5). An object declaration now produces a C type — a forward typedef, a struct, a GC pointer-offset table and a type descriptor — so a program that DECLARES an object compiles, links and runs. Three things arrive with it:

    • is, as and type_name work on objects. They already type-checked; they execute now that the descriptor exists. Matching is exact (an object is its own hierarchy until inheritance lands), and type_name prints the name you wrote in the source — type_name(c) on a Counter prints Counter.
    • Field-less objects are legal. object Greeter with no fields (behaviour-only classes are a natural first object) allocates correctly instead of failing with [Heaps] ERROR: Invalid type descriptor — the object half of BUG-233. The struct half (type Empty = struct end Empty) is still open.
    • Object type symbols carry their module in the generated code, so an object's generated C name is unique to its defining module. SUPERSEDED — see "Type identity is module-qualified" under Breaking above. When this entry was written, structs and Active Objects still used program-global names and Reef identified a type by its unqualified name; both are now false. Struct, enum, Active Object, alias and trait-impl symbols all carry their module, and a type's IDENTITY is its defining module plus its name. Two modules declaring the same object CLASS are still refused with a checked error (passive-object class identity is not module-keyed yet, BUG-272), but every other kind now coexists.

    Still gated, each with its own checked error: extends, finalize and typecase. (Construction and method calls were gated here too and are open as of T6/T7 — see the entries above; the diagnostics were re-worded at T8 to name their own feature instead of reporting "objects not yet enabled".) reefc --check continues to accept these programs in full.

  • Passive objects: the full surface grammar and its typechecking are in — objects are NOT yet buildable (0.9 Phase 6, milestone M0). Reef gains object Button extends Widget declarations with virtual-by-default methods, a mandatory override marker on redefinitions, exclusive/ shared markers with the same spelling Active Object methods use, init/finalize, and inherited init(...) / inherited m(...) base calls — plus typecase, runtime type dispatch over an object hierarchy (typecase w ... end typecase, arms shaped exactly like statement-match arms with per-arm end, optional narrowing binder, optional else). Keyword policy (as amended 2026-08-12): object was already reserved; extends and override are CONTEXTUAL and stay valid identifiers (running example in docs/language/reference/015_BASICS.md); typecase and inherited are RESERVED — see the Breaking entry above for the migration note. The typechecker checks the whole M0 catalogue: extends target validity (objects only — never an AO, struct, enum, trait or alias), inheritance cycles, cross-module base export, override rules (mandatory marker, exact signature match, marker changes rejected in both directions, init/finalize never virtual), the init chain (inherited init(...) must be the first statement, transitively required, may not mention self), the shared rule (a shared method may not assign fields or call an exclusive method on self), Derived→Base substitutability at bindings/parameters/returns (arrays stay invariant), ==/!= as reference identity with ordering operators rejected, is/as on hierarchies, and the two deliberate 0.9 rejections: generics on objects (object Stack[T], extends Container[int]) and object-typed spawn arguments or receivers. Object method bodies are fully type-checked and stamped like any other scope. What is missing is codegen: a valid object program passes reefc --check cleanly and then fails at the build step with "objects not yet enabled" (see Changed below). Emission arrives in the next milestone.

  • typecase is not part of 0.9. The multi-arm type-dispatch sugar (typecase w … end typecase) had been accepted for 0.9 conditionally, gated on a grammar conflict-count STOP condition. The condition fired: the productions cost +34 shift/reduce and +1 reduce/reduce conflicts as specified, and no precedence-free refactoring got closer than +6/+3, because the conflict is structural — an arm's leading type name juxtaposes with Reef's bare-identifier statement form at statement head. Per the pre-agreed fallback the sugar was dropped rather than shipped ambiguous, and nothing half-wired was left behind (no productions, no keyword, no AST nodes). Type dispatch stays is-chains (if w is Button then let b = w as Button), which are unchanged.

Fixed

  • Module-level enum globals compile (BUG-213 facet 1). Every enum value is built by a constructor CALL, so a module-level let/mut whose type is an enum always took codegen's deferred-initializer path, which emitted static Slot g = 0; — a scalar 0 into a struct type, i.e. a hard C error. Every enum global in the language was unusable. The placeholder is now shaped by the C type ({0} for the one aggregate mapping, value-type enums; 0/NULL unchanged for scalars and for the pointer-mapped struct/string/array/AO globals), so payload-free enum globals and enum globals whose payloads are all scalars now work end to end: deferred module-init assignment, reassignment from a function, match dispatch and payload reads. Emitted C for the rest of the corpus is byte-identical.

  • Value-type enum module globals are excluded from GC global-root registration (BUG-213, review I2). reef_heaps_register_global_root takes the address of a POINTER slot and the mark phase dereferences it as void* — an 8-byte read. Codegen's module-global root test matched TUserType/TGeneric unconditionally, which was inert only because no enum global had ever reached a C compiler; the moment the fix above made them compile, a 4-byte payload-free enum global became an out-of-bounds read (ASAN: global-buffer-overflow, READ of size 8, 0 bytes after global … of size 4) and a larger one handed the collector a tag/payload word as a heap-pointer candidate. An enum is a value, not a pointer slot, so it is now excluded at both module-global emission sites.

  • Module-level enum globals whose payload can hold a GC-managed value are rejected at typecheck (BUG-213 facet 2). Fixing the placeholder above would otherwise have made them compile while leaving them collectable: an enum is a value type with no pointer slot, so it gets no module root (see the entry above), and a store to a module global resolves as a ModuleRef, not a binder, so the 0.9 Stage 7 payload root slots (BUG-164) never fire either — the payload would be reachable only through a root nothing scans. Instead of shipping that, such a declaration is now a checked error naming the type ("module-level enum globals with GC payloads are not yet supported"). The check is transitive through nested enums (the BUG-214 lesson: one level of payload analysis is not enough) and substitutes generic type arguments RECURSIVELY, so it holds for a generic enum nested inside a generic enum (Outer[T] = Wrap(Inner[T]) at Outer[string]) as well as for the flat cases — Box[string] is rejected where Box[int] is allowed. The classifier enumerates the non-GC types and defaults to reject, so an unrecognised payload kind can never read as safe. Rooting statically-embedded payloads for real is sequenced with the 0.9 GC-descriptor work, alongside the heap-embedded enum-field class.

  • Enum-typed locals, parameters, pattern bindings and loop elements now root their GC payloads at every store (BUG-164, 0.9 substrate Stage 7 — the FINAL substrate stage). An enum is a value type and is never itself GC-tracked, so before this fix a payload-carrying enum value held OUTSIDE a match-scrutinee window — an ordinary let/mut local, a parameter received by value, a match catch-all or constructor binding whose payload is itself an enum, or a for x in arr element — kept its payload string/array/struct alive by luck alone: heavy allocation between the store and the read let the precise GC sweep the payload out from under the live enum value (reproduced 3/3 under REEF_HEAP_BLOCK_SIZE=65536 for each shape). Every binder store site now publishes the payload pointer into a binder-attached __b<id>_pay<k> root slot via a tag-conditional write (VarDecl init, each reassignment, the function prologue for enum params, all four match pattern-binder paths, and the per-iteration ForEach element store). The scrutinee-attached slots from BUG-158 are retained — a non-binding match arm with an allocating guard still needs them. Known residuals filed, not silently absorbed: nested enum-inside-enum payloads have no root before their binder store (BUG-214), and module-level enum globals don't compile at all yet (BUG-213).

Changed

  • reefc --check now accepts passive-object programs; the "objects not yet enabled" refusal moved to the build step (0.9 Phase 6/M0). While the object surface was being built, objects were rejected by the typechecker. Now that the M0 catalogue above is implemented, the typechecker's job on an object program is to check it: a well-formed object program type-checks clean (exit 0, no output beyond the success line), and the single remaining refusal fires at code generation with "objects not yet enabled". Practical effect: reefc --check is a usable editor/CI front end for object code before codegen lands, but a clean --check on an object program does not mean it builds. Malformed object code is still rejected at --check with an ordinary diagnostic, as usual. (Programs with no objects are entirely unaffected.)

  • Call sites instrumented for GC argument rooting evaluate their arguments in a DEFINED left-to-right order (0.9 Stage 6, owner decision §9.1): receiver first, then listed arguments, then spliced defaults. Un-instrumented call sites keep C's unspecified order. Both halves are documented in docs/language/reference/020_FUNCTIONS.md, with the standing advice not to depend on argument evaluation order either way. The six hand-rolled transient GC frame shapes (nested concat, interpolation, struct literals, new-with-args, return-across- defers, foreach) were also unified onto one TransientFrame builder — byte-identical emission except one latent fix: the new Type(args) constructor temp is now NULL-initialized before its rooting frame links (previously an uninitialized root slot was live for the duration of the allocation call itself).

  • BREAKING (--check strictness): parameter-default expressions are now type-checked (BUG-185, 0.9 Stage 3). Previously a default went parser → codegen splice with no check_expr in between, so a default referencing an undefined variable (or with any other expression-level type error) passed reefc --check and died only in the generated C. Such programs are now rejected at typecheck with an ordinary diagnostic. Scoping rule now enforced: a default is checked in the defining module's module-level scope — it may not reference sibling parameters or self (the splice into caller contexts could never bind them). This is the only intended behavior change of the 0.9 Stage 3 annotation work; a default whose type mismatches the parameter's declared type is still NOT checked (BUG-185 stays open for that half).

  • BREAKING (--check strictness): trait IMPL method bodies are now type-checked (BUG-188, 0.9 Phase 4a). The typechecker's body pass had arms for plain functions, struct methods and Active Object methods only, so every impl Trait for Type block fell into its catch-all: undefined variables, call-arity errors, type mismatches and bad field accesses inside such a body all passed reefc --check (exit 0) and surfaced, at best, as a C compile error. Those bodies are now checked with self bound to the impl target type, exactly like a struct method. Trait DEFAULT method bodies (a trait method written with a body) remain unchecked — they are also never registered as callable symbols and never emitted, i.e. dead code today; recorded as BUG-202 with the design question that has to be answered before they can be checked once.

  • BREAKING (--check strictness): a name declared inside an If/Unless/While/DoWhile/DoUntil/Loop/UnsafeBlock/Defer body no longer resolves after the block ends (BUG-208 + BUG-209, 0.9 Phase 5a-bis/ter — the block-scoping fixes below). A post-block read of a name that only exists as a block-local (no outer binding of the same name) now reports a clean Name Error: Undefined variable instead of silently resolving to the block-local's leaked binding. This is a strict improvement, not a new failure mode: for a GC-tracked block-local (e.g. a string), the leaked read previously compiled silently and read whatever the block-local's field happened to hold at runtime (wrong value, uninitialized/NULL on an untaken branch, or — for Defer — a value not yet written because the deferred code hadn't run yet); for a non-GC block-local (e.g. a plain int), the leaked read already failed at C compile time with an opaque "use of undeclared identifier" (the codegen emits such locals as bare, block-scoped C variables) — this change only moves that failure earlier, to a clear diagnostic at --check time, matching the project's established pattern (see BUG-185's entry above) of preferring an early typecheck rejection over a late, cryptic C error. Corpus impact: zero. A full-tree scan plus this-tree's downstream consumers found no in-tree dependency on the leak — every unsafe block with a shadowed name (11 in-tree instances, reef-stdlib/core/str.reef and the crypto modules) reads the shadow strictly INSIDE its own block, never after it. Downstream freestanding consumers pin against reefc versions predating this change and are not expected to be affected on their next upgrade for the same reason (no code pattern in this tree relies on the leak; see BUG-208/BUG-209's resolutions for the corpus evidence).

Fixed

  • BUG-175 (general variant — CLOSED, all variants): a call site's GC-managed rvalue arguments are now all rooted simultaneously while the argument list is still being evaluated, at EVERY call, not just spawn (0.9 substrate Stage 6). The runtime's __reef_gc_inflight is one slot per thread, so evaluating a later argument that allocates (even one whose own type is a scalar) dropped the only protection an earlier bare-rvalue argument had. Qualifying call sites (a GC-typed argument or receiver evaluated before any later argument that may allocate) now carry eid-keyed __arg_e<eid>_<i> root slots as extra fields in the enclosing function's own shadow frame: arguments evaluate defined left-to-right into typed temps, each rootable value is published into its slot before the next argument evaluates, and the slots are released only after the call returns — which also bridges the Active Object lock-before-frame window (an exclusive/shared callee blocks on the monitor before linking its own frame). Receivers, complex-callee closures and spliced parameter defaults are all covered; wrapped sites therefore have a DEFINED left-to-right argument evaluation order (documented in docs/language/reference/020_FUNCTIONS.md; unwrapped sites remain C-unspecified). The spawn variant was fixed at Stage 0 (below); the synchronous window was never statistically reproducible and is gated structurally (tests/tools/argslot_gate.sh

    • the baseline_call_arg_slots golden).
  • BUG-177: payload-carrying enum arguments are now rooted across call AND spawn boundaries (0.9 substrate Stage 6). An enum is a value type — never itself GC-tracked — so neither BUG-174/175's spawn fixes nor any slot family covered the GC-managed payload INSIDE an enum passed as a bare rvalue: spawn f(mk_slot()) copied the enum by value into the malloc'd args struct with its payload string reachable from nowhere for the whole thread-start latency (reproduced for the first time during this fix: 8/7/10 corrupted payloads per 12,000 requests under forced GC pressure, 3/3 runs; 0 after). Wrapped call sites give enum-payload argument positions tag-conditional __arg_e<eid>_<i>_pay<k> sub-slots; spawn sites engage the parent/child handshake for enum args (an all-enum argument list no longer takes the fire-and-forget path), root the payload parent-side interleaved with the struct stores, and child-side from unpack until the callee returns. Every payload write is a tag-guarded ternary chain ending NULL — never a raw union read.

  • BUG-193: an Active Object's _new() allocator wrapper no longer leaves the just-allocated object unrooted between init() returning and run() starting (0.9 substrate Stage 6). __obj was a plain C local with no frame: once init()'s own frame (which roots self only while init runs) popped, an init() that had allocated internally left __obj protected by nothing — a concurrent collection could sweep the object out from under its own constructor (deterministic SIGSEGV under forced GC pressure, 5/5 runs pre-fix; clean 5/5 after). The wrapper now roots __obj in a transient frame for its whole body. Found by inspection at Phase 3a, upgraded to a deterministic reproduction here (test_bug193_ao_new_window).

  • BUG-209: UnsafeBlock and Defer had the identical unscoped-body leak as BUG-208 — found while double-checking that fix's completeness. Neither arm copied env/binder_env before checking its body, so a let inside either leaked past the block the same way. Fixed identically (env AND binder_env copied in lockstep, mirroring arm_ctx/loop_ctx). Defer had a distinguishing worse shape: because deferred code is checked in ordinary source order but documented to run at function exit, a post-defer read's stamp could route to the defer body's own binder field BEFORE that field was assigned at runtime — a NULL-string read, not merely a wrong-but-defined value. The reviewer's scope call for the fix held: the scoping copy alone resolves this too, with no separate temporal-ordering fix needed — a post-defer read of a defer-only name now either correctly resolves to an outer binding (if one exists) or is rejected as Undefined variable at typecheck time (the shape previously masked by the leak into silently compiling and reading NULL at runtime). No codegen changes; routing follows stamps exclusively (same reasoning as BUG-199/BUG-208). Corpus-verified: --emit-c byte-identical on reef-stdlib/core/str.reef, reef-stdlib/crypto/md5.reef, reef-stdlib/crypto/sha256.reef (the in-tree modules with the heaviest unsafe-block usage) before and after. Regression tests: test_bug209_unsafe_defer_shadow_same_type, test_bug209_mismatched_type, test_bug209_defer_no_shadow_masked_undefined.

  • BUG-208: a let inside an If/Unless/While/DoWhile/DoUntil/Loop body that shadows an outer name leaked past the block at the typechecker's stamp layer — observable today with silently wrong output. Sibling defect to BUG-199 one construct over, but worse: these five arms MUTATE ctx.env/binder_env IN PLACE (Hashtbl.add shadowing on the same shared table) rather than aliasing a separate unpopped side table, so a same-typed shadow was silently wrong (if true \n let x = "a" \n end if \n println(x) printed a instead of the outer value) and a mismatched-typed shadow was rejected by a CLEAN --check type error (not a C compile error like BUG-199's case) — both symptoms of the identical routing bug, not genuine language errors. If/Unless's two branches additionally shared ONE context (then-branch checked first), so a then-branch binding could leak into the else branch's own resolution; on the branch that doesn't execute at runtime this reads an uninitialized (zero-initialized) __reef_locals field — a NULL string read reachable by ordinary code, no unsafe required. Nesting compounded the leak: an inner block's shadow could clobber even the OUTER block's own reads of its own shadow, not just code after the whole nest. Fixed by scoping every body (and, for If/Unless, EACH BRANCH independently — mutually isolated, not a copy-of-a-copy) in a copied context (env AND binder_env in lockstep), mirroring match arms' arm_ctx and BUG-199's loop_ctx; DoWhile/DoUntil's trailing condition is deliberately checked against the same scoped context as the body (it runs after each body execution as part of the same iteration — matching the emitted do { body } while (cond); — so it must keep seeing the body's own bindings). A corpus-wide check (full suite plus direct --emit-c diffs of the three heaviest if/while stdlib modules) confirmed zero behavior change for existing code — the fix only stops bindings from leaking past their true scope; a tightening witness — silently accepted at 824 due to the leak itself (the shadow's own type happened to satisfy the post-block use, masking a real conflict with the outer binding's true type), rejected after — shows the fix also catches an ill-typed program the leak previously let through by accident. Regression tests: test_bug208_block_shadow_same_type (same-type shadow across all six constructs), test_bug208_if_then_else_isolation, test_bug208_nested_2deep, test_bug208_mismatched_type, test_bug208_genuine_mismatch_rejected.

  • BUG-199: a ForEach/For loop's element, index, or loop variable leaked past the end of the loop at the typechecker's stamp layer. Unlike match arms (which check bodies in a copied arm_ctx), ForEach's element/index bindings and For's loop variable were added directly to the enclosing ctx.env/binder_env and never popped, so a post-loop read of a name shadowed by the loop's own binding stamped eres to the LOOP's binder instance instead of the outer one. For GC-tracked types (each binder gets its own name_b<id> field) this silently read the wrong value (a same-typed shadow) or failed to compile (a mismatched-typed shadow, referencing a field under the wrong binder's id). Plain (non-GC) int/bool loop variables happened to stay visually correct today — codegen emits them as bare, C-block-scoped locals inside the loop's own braces rather than through the persistent __reef_locals struct — but shared the same stamp-layer scope leak, a latent risk for any future pass that trusts eres (this is exactly what BUG-197's sibling, the codegen classification walker, already hit one layer down). Both For and ForEach now check their bodies in a scoped loop_ctx (env AND binder_env copied in lockstep, mirroring match arms' arm_ctx), so every loop-introduced binding — element, index, and loop variable — pops at loop end. Regression tests: test_bug199_foreach_shadow_same_type, test_bug199_foreach_shadow_mismatched_type, test_bug199_foreach_index_and_for_var_shadow.

  • 0.9 Stage 5 (type-truth cutover): emission-time type truth is now the typechecker's stamps. Codegen's ~570-line second inference engine (infer_expr_type, TInt on any unresolved binding) and monomorph's own third engine are DELETED; every emission decision — GC classification and __reef_locals membership, VarDecl C types, match dispatch strategy AND the match expression's result type (the first-arm peek is retired for the node's own unified stamp), interpolation format specifiers, method mangling, spawn argument classification, generic type-argument inference — reads typ_of = ety. The walkers that build shadow frames read the same stamped cells the emitter reads, so walker/emitter classification disagreement (the BUG-055/143/161/197 family) is structurally unrepresentable. The cutover was baked under REEF_TYPECK_XCHECK (both engines hard-compared across the full corpus + stdlib before deletion; every disagreement triaged) and gated by a corpus-wide frame-field/roots multiset comparison. User-visible fixes that fell out of stamped truth: let C types now follow the language's rules where the old inference silently diverged (char arithmetic → int, set membership → bool, float struct fields no longer truncated to int in multi-package programs), and a nested match under an expression-form catch-all now roots its enum payload and binding (the last known BUG-158/165-shaped gap).

  • 0.9 Stage 5 (ICE flip): the loud-failure regime is permanent. A missing type stamp or a stamped-but-undiscovered GC binding instance at emission is now an unconditional internal compile error carrying the node's real source location (previously a deduped warning, escalated only under REEF_CODEGEN_STRICT=1; that interim flag is retired — the warn census was held at zero across the full corpus + stdlib through every cutover step first). Exactly two scopes keep the name-keyed plain-local fallback, both documented in the code: spliced parameter defaults (the Stage 6 argument-slot residual) and await-condition snapshot functions (the BUG-118 value-snapshot mechanism, Stage 8's).

  • BUG-201: an UNANNOTATED module-level initializer calling through an import alias passed --check but died at emit ("Name Error: Undefined variable '<alias>'"). gen_module re-inferred the declared type by running Typechecker.check_expr_type against codegen's env, which never knew import aliases; the three consults now read the initializer's stamped type and check_expr_type is deleted. Regression test: test_bug201_modinit_alias_call.

  • BUG-197: the GC-classification walker's scope-blind type_env seeding rejected legal code (a match arm's binding type leaked into the walk-time classification of later unannotated lets → hard C error). Dies with the seeding itself. Positive test: test_bug197_walker_seed_scope.

  • BUG-205: a generic call with INFERRED type args and an omitted defaulted argument registered no specialization — the emitted C called a function that was never generated and spliced the template's raw-T default. Monomorph's inference now accepts defaulted arity (required..full, provided-prefix unification — the same rule the emission side always used), and the inferred-arg call arms splice defaults from the specialization (BUG-191's clones). Completes BUG-191 for the inferred-type-argument spelling. Regression test: test_bug205_inferred_type_args_omitted_default.

  • BUG-191: a generic callable's parameter DEFAULT was never specialized — every specialization shared the template's un-substituted expression. fn first_or[T](xs: [T], fallback: [T] = new [T](0)) called as first_or[:int](a) with the default omitted emitted raw T into the generated C (((T**)reef_heaps_new_ptr_array(0)) → "use of undeclared identifier 'T'") on a program reefc --check accepts. Monomorph now runs each param_default through substitute_in_expr per specialization (substituted types AND an un-shared clone, so the 0.9 annotation cells are per-specialization), and generic call sites splice the default off the SPECIALIZED declaration instead of the template. Fixed for call sites that write their type arguments explicitly. The inferred-type-argument spelling of the same call (first_or(a), default omitted) still fails to compile — monomorph's argument-count-gated type inference registers no specialization for it at all, so there is nothing to substitute; that pre-existing defect is tracked as BUG-205. Regression tests: test_bug191_generic_param_default_substituted, test_bug191_two_specializations_independent_defaults.

  • BUG-188 (codegen half): bindings inside trait impl method bodies collided by name. Because those bodies were never typechecked they carried no binder stamps, so codegen routed the whole scope through the pre-0.9 name-keyed table — which meant BUG-167's collision was still live inside them after 167 itself was fixed (a match arm rebinding an outer let clobbered it: outer=INNER), and expression-form match bindings there were emitted as plain, frame-less C locals (BUG-165's shape). With the bodies checked and stamped, trait impl methods route by binder instance like every other scope: shadowed instances get their own name_b<id> slots and expression-form bindings become real GC roots. The legacy_scope fallback plumbing is deleted — there is no unstamped region left. Regression tests: test_bug188_trait_body_shadow_instance, test_bug188_trait_body_expr_match_rooting.

  • BUG-167: a match binding sharing a name with another GC-tracked binding read the WRONG value (0.9 Stage 4b, the binder-identity cutover). __reef_locals field identity moved from bare names to typechecker-stamped binder INSTANCES (name_b<id> fields; Var reads route through each use's stamped resolution), so two same-named bindings can no longer collide on one slot: the same-type shadow (silent wrong value — a nested match arm read the OUTER binding) and the mismatched-type shadow (a hard C compile error on the shared field) are both fixed structurally. The expression-form switch path's write-then-never-read __auto_type binding is gone — all four match lowerings now share one binder-store emitter. Regression tests: test_bug167_match_shadow_same_type, test_bug167_match_shadow_mismatched_type.

  • BUG-165: match-EXPRESSION pattern bindings were never GC-registered. The statement-only registration walker missed every expression-form match; those bindings survived only via BUG-158's independent payload slot rooting. Expression-form arms now register through their stamped binder lists, per instance (fixing this name-keyed would have widened BUG-167 — the two closed together, as their bug files prescribe). Regression test: test_bug165_match_expr_ctor_binding.

  • BUG-181: binding declaration sites skipped mangle_ident — a binding named after a C keyword (register, union, volatile, ...) emitted an invalid declaration plus an undeclared-identifier error. All the affected sites (six enumerated + the numeric for-loop variable and ForEach index binder, same defect class) now route through the shared binder-store/mangling path. Regression tests: test_bug181_reserved_word_bindings, test_bug181_reserved_word_ctor_bindings.

  • BUG-184: nested ${...} interpolation fragments could share one GC root slot (synthetic location offsets compose additively). Match scrutinee temps and enum-payload slots are now keyed by the node's globally-unique eid (__scrut_e<N>, __scrut_e<N>_pay<k>), so no location scheme is load-bearing for identity: the sibling-fragment (BUG-139) and nested-fragment collisions are unrepresentable, and the shared module-init frame no longer needs module-qualified temp names. Golden: tests/baseline/baseline_nested_interp_match pins the two distinct slots. (BUG-182 — fragment DIAGNOSTIC positions — is unchanged and stays open.)

  • BUG-176: monomorphization skipped set literals and in operands. A generic function with a type-param-dependent expression inside a set literal ({tag_of[:T](x), 5}) or as a membership operand (tag_of[:T](x) in s) kept the generic form in every specialization (undefined C symbol like reef_tag_of_T), and the instantiation scanner never recorded generic calls in set positions. Both the substitution arm and the scanner arm are fixed; regression test test_bug176_generic_set_subst.reef.

  • BUG-186: set-literal membership silently miscompiled (C operator precedence). x in {1, 2} (a set LITERAL right operand) evaluated wrong with no diagnostic — e.g. 1 in {1, 2} returned false. Codegen emitted a set literal as a bare |-chain ((1u<<1) | (1u<<2)) and spliced it unparenthesized into the membership template's >> operand and into the set-difference/intersection templates' &/&~ operands; since C's >>/& bind tighter than |, the spliced text parsed with the wrong grouping. A set VARIABLE right operand (a single identifier) happened to parenthesize correctly by accident, masking the bug. Fixed by parenthesizing the whole or-chain at its single emission site (SetLit arm, reef-compiler/lib/codegen.ml), which also closes the identical-defect case of a set literal as a direct operand of -/* (BUG-108's set-op lowering). Regression test test_bug186_setlit_membership_precedence.reef (bare condition, negated, compound boolean, let initializer, SetRange element, and the set-difference sibling).

Infrastructure

  • Grammar conflict-count gate (scripts/menhir-conflicts.sh + the tracked baseline reef-compiler/tests/menhir_conflicts.baseline, run as part of tests/run_tests.sh). The parser's shift/reduce and reduce/reduce counts are now pinned to a recorded baseline and the suite fails if they move in EITHER direction — an unnoticed grammar ambiguity and a stale baseline are both caught, with a documented deliberate-update protocol in the baseline file's header. This is the mechanism behind the typecase decision above; the whole object surface landed with the count byte-identical to its pre-object value.

  • // EXPECT_BUILD_FAIL: <substring> test marker (tests/run_tests.sh), for the class of program that must type-check cleanly and then fail at the build step. It asserts BOTH halves — reefc --check exits 0 and the build fails with the given substring — so a test in this class cannot silently degenerate into "fails for some other reason". Used to pin the object programs whose sole remaining error is the codegen door; complements // EXPECT_FAIL: (typecheck rejections).

  • Standing ASAN gate for the module-global GC-root class (asan_gate_enum_global_root in reef-compiler/tests/asan_exit_gate.sh, fifth gate). The BUG-213 root-registration defect above was invisible to existing CI — make test-asan sanitizes the runtime, never a compiled example — so a witness program (plus a control asserting that a genuine string global still registers) now runs under AddressSanitizer on every CI run. The gate runner also now fails on an ASAN report even when the program exits 0, and leads failure output with the ASAN header.

  • New downstream toolchain image reef/reef-toolchain (docker/Dockerfile.release): the released linux-amd64 binary tarball installed on ubuntu:26.04 with clang/lld and the system libraries Reef programs link against (OpenSSL, X11) — no OCaml. Built and pushed as a prebuilt toolchain image (:<version> + :latest) by the tag pipeline's new image step (COPY-only BuildKit build from the same dist/ artifact the release publishes and install-gates). Downstream projects should migrate their CI from reef/reef-builder:latest to reef/reef-toolchain:latest; reef-builder remains the OCaml build environment for reef-lang's own CI ("for building Reef itself" vs. "for building with Reef"), rebuilt manually via docker/build-ci-image.sh only when toolchain dependencies change. The 0.8.6 image was first built from this Dockerfile and pushed manually, verified in-container (reefc doctor, hello, a 4-worker Active Object hashing run, a net.tls link test) — briefly under the name reef/reef, renamed to reef-toolchain the same day before anything consumed it.

  • reef-builder no longer bakes in a Reef install. With downstream projects on reef-toolchain, the preinstalled compiler in the CI builder image was dead weight (reef-lang's CI builds from source every run and never used it — the registry copy had silently sat at 0.8.0 since 2026-07-26). docker/Dockerfile.ci is now a pure build-environment image (OCaml/opam, dune, menhir, cmake, gcc+clang/lld, dev libraries), decoupled from the release cycle: bump-version.sh no longer stamps the docker files, and docker/build-ci-image.sh tags by rebuild date instead of Reef version. Rebuild it only when the OCaml switch or system deps change; the existing reef-builder:latest on the registry keeps working for CI unchanged in the meantime.

Known limitations

Loud limitations — each is a compile-time refusal with a diagnostic, not silent wrong behaviour.

  • init parameter defaults are not applied by object construction (BUG-245). A default in an object's init signature is accepted at the declaration but not spliced at the new site, so every parameter must be supplied explicitly. The diagnostic names the bug and says so.

    object P
        n: int
        init(n: int = 7)     // the default is not applied
            self.n = n
        end init
    end P
    
    let p = new P()          // Type Error: expects 1 argument(s), got 0
    let q = new P(7)         // supply it explicitly
    
  • Explicit type arguments cannot be spelled at a call site (BUG-292). ident[int](3) is a syntax error; generic calls rely on inference from the argument types. Annotate the binding, or introduce a concretely-typed wrapper, when inference is not enough.

  • A Derived object does not inhabit a Base slot in four positions (BUG-320). Fixed in 0.9.1. The inhabitation rule (a Derived is usable wherever its Base is) holds everywhere it is documented — locals, named-function returns, call arguments, struct and object fields, array literals — but four positions were missed, each of which has a working sibling:

    Position Symptom Use instead
    mut g: B = new Dv(1) at module level fails the C compile under gcc; builds under clang assign in main, or annotate new Dv(1) as B
    a[0] = new Dv(3) (array element) fails the C compile under gcc; builds under clang bind first: let e: B = new Dv(3) then a[0] = e
    fn(k: int): B => new Dv(k) (lambda return) --check rejects use a named function
    mut ga: [B] = [new Dv(1)] at module level --check rejects annotate the element: [new Dv(1) as B]

    The first two are the awkward pair: reefc --check passes and the failure appears in generated C, naming a .c line rather than yours. reefc prefers clang when both are installed, so a clang box will not see them at all.

0.8.6 — 2026-08-09

The harness-integrity + match-arm-scoping batch. Two fixes, both filed during the 2026-08-06/07 GC-rooting batch reviews: the example-suite test harness stopped silently auto-passing test_* files that exit 0 with no .expected companion (BUG-166), and match arm bodies now get their own C block scope so a let inside an arm can shadow that arm's own pattern binding without a C-level redefinition error (BUG-168). A related grammar-widening attempt (BUG-163: match-expression arm bodies restricted to primary_expr, so a bare function call needs parens) was investigated this batch and found structurally BLOCKED rather than merely untried — see Known limitations below — and stays open, unfixed. One further issue was filed during this batch and remains open: BUG-170 (a latent, currently benign Menhir reduce/reduce merge that is the structural reason BUG-163 is blocked).

Spawn exit semantics (2026-08-08). spawn now has one unified, documented "no guarantees" contract (no ordering, no completion, no survival past main's return, no external kill, and output can be torn or lost, not just reordered — A2 Active Oberon lineage), and generated main() enforces the "no survival" half structurally instead of leaving it as a race: main returning now calls fflush(NULL) then exit/ _exit, with no heap teardown at all, closing a real (if narrow) UAF exposure where the old teardown sequence freed every heap memory block with no join/suspend of still-running spawned threads (BUG-171). An initial version of this fix quiesced sanitizer builds (stop-the-world, never resumed) before running the old teardown "for LeakSanitizer's benefit"; SE-1 review found that design actively deadlock-prone (a suspended thread holding the stdio lock froze the very next print solid) and unnecessary (LSan runs its own internal stop-the-world at exit() and does not need the heap pre-freed), so it was deleted rather than repaired — see the BUG-171 entry below for the full account, including the empirical LeakSanitizer verification. BUG-169 (spawn/stdout ordering nondeterminism, filed last batch) is resolved as a documentation fix — the contract this batch adds subsumes it. SE-1 review also escalated a further, much more serious gap found while building (and then simplifying) the exit fix: bare spawn threads were invisible to the GC's stop-the-world suspend/scan machinery entirely, and this reproduced as memory corruption under default settings (no ASAN needed) — filed and confirmed as BUG-172, Critical. A same-day follow-up (SE-2) fixed it: spawn threads now register with and deregister from the same GC-visible process list Active Object threads use, closing all three documented failure modes — see the Fixed entry below. 070_SPAWN.md's safety warning against holding GC-managed values in a spawned task's OWN locals has been removed accordingly (the rest of the "no guarantees" contract is unchanged). Investigating BUG-172 at high allocation/GC-cycle rates surfaced one further, unrelated issue — a pre-existing nested-signal-handler stack-growth risk under an extremely bursty GC cycle rate, reproduced with plain Active Objects too and independent of this fix — filed as BUG-173 and left open (see Known limitations). Note for anyone updating a reefc/runtime pair independently: generated C from this batch now references reef_machine_exit(), reef_objects_register_spawn(), and reef_objects_deregister_spawn(), so a compiler from this batch requires a runtime from this batch (and vice versa) — the new symbols are purely additive (no existing signature changed) and a mismatch fails loudly at link time with an undefined symbol, not silently at runtime.

SE-2 review of the BUG-172 fix (same day) found two further issues, both fixed/filed in this same round: (C1, Critical, fixed) exit()/ panic() (reef_exit(), reef_prelude.h) still ran the pre-BUG-171 ordered teardown directly — a path SE-1's fix never touched, since it only rewired generated main()'s own epilogue — so reef_objects_ cleanup()'s reap loop could still reach a live spawned thread's process entry and cancel-then-join-then-free it despite that entry having no joinable thread handle to join, a reachable UAF/double-free confirmed under ASAN (5/5 in the reviewer's repro). (C2, Critical, filed as BUG-174, fixed same day — SE-3, see the Fixed entry below) the new 070_SPAWN.md sentence claiming unqualified GC memory safety for spawn overclaimed: GC-managed arguments passed to a spawned function travel through a malloc()'d struct that nothing marks as a GC root during the handoff window between the caller dropping its reference and the spawned thread re-establishing one — a pre-existing gap (predates BUG-172), reproduced 3/3 runs on the ordinary spawn f(arg) shape. See the C1 Fixed entry and the "GC-managed spawn arguments are now rooted across the parent/child handoff (BUG-174)" Fixed entry below.

Fixed

Multiple GC-managed spawn arguments are now rooted while the argument list is still being evaluated (BUG-175, spawn variant)

spawn f(build_a(n), build_b(n), n) — two or more GC-managed argument expressions evaluated directly in one spawn, with no caller-side name for any of them — silently corrupted the earlier arguments. Between an argument's own evaluation finishing and the spawned child copying it into its own shadow frame, the value's only protection was the runtime's __reef_gc_inflight: a SINGLE per-thread slot holding the most recent allocation. Evaluating any later argument overwrote it — including an argument whose own static type is a plain scalar, if its evaluation allocates internally — leaving the earlier value reachable from nowhere the mark phase can see for as long as it took the child thread to start (BUG-174's handshake roots the handoff, not this earlier window). Reproduced at rev 776: 10-34 corrupted payloads per 12,000 requests, 3/3 runs per shape, with the predicted asymmetry (the LAST argument, which keeps riding __reef_gc_inflight, never corrupts).

Fix (compiler-only; the runtime and its frame ABI are untouched): each spawn site with at least one GC-managed argument now emits a transient shadow frame of void* root slots, one per GC-managed argument, and each slot is written as part of the same assignment that stores the argument into the spawn-args struct — so argument i is chain-reachable before argument i+1's expression begins evaluating. The frame is released only after the parent's handoff wait returns, i.e. after the child has taken over rooting durably. Argument GC-ness is decided from the callee's resolved DECLARED parameter types (new plumbing: the Spawn lowering site resolves the callee's declaration — including substituted parameter types for generic spawns and namespace lookup for module-qualified ones — and passes them into gen_spawn_with_args, which previously saw only a mangled C name and re-inferred each argument's type), never from codegen's own expression inference, which fails toward TInt and would drop roots silently. Where a callee's declared types are unavailable (an unresolvable callee, or a signature whose parameter count does not cover the call's positional arguments), the site falls back to codegen inference — i.e. the pre-fix classification, not a stronger conservative one: a genuinely uninferrable argument is still unrooted, and its args-struct field is already mistyped, since that field's C type comes from the same inference. No stronger predicate is implementable at this stage; Stage 6 owns closing it, with declared types available at every call site (not just spawn ones) and driving the field type as well as the classification. The emitted C labels which source each site used (callee-declared vs inference-fallback (<cause>)), so a fallback is greppable rather than silent; across the current examples corpus every spawn-with-args site resolves declared types. Frame construction follows the existing F3 discipline (slots NULL-initialized and the header fully written before the single store that links the frame, single store to unlink), verified in the generated C and in disassembly at -O0 and -O2. Spawns with no GC-managed argument keep the previous zero-overhead shape. New regression tests: test_spawn_args_multi_rvalue_roots, test_spawn_args_scalar_alloc_roots, test_spawn_args_generic_roots, test_spawn_args_modqual_roots (each RED 3/3 pre-fix, 0 across 5 runs post-fix, non-ASAN).

Provenance and remaining scope: BUG-175 was filed 2026-08-08 during SE-3 review of the BUG-174 fix; this is Stage 0 of the 0.9 "scoped codegen environments" substrate program (design §5 row 0), landed on the v0.9-oo bookmark. The general multi-argument CALL gap remains open — the same mechanism applies to any plain synchronous multi-argument call (a much smaller window, never independently reproduced) and is deliberately out of scope here; it is closed structurally by Stage 6's general per-call-site argument slots. BUG-177 also remains open: a payload-carrying enum passed as a bare rvalue still classifies as non-GC-tracked and gets no slot, in spawn and elsewhere, until Stage 6's tag-conditional payload sub-slots land.

spawn threads are now registered with the GC's stop-the-world suspend/scan machinery (BUG-172)

reef_objects_suspend_activities() and reef_objects_scan_all_stacks() (reef-runtime/src/reef_objects.c) both walk g_objects.root — the Active Object process list — to decide which threads to signal-suspend during a GC cycle and which threads' shadow-frame chains to scan for roots. A bare spawn fn() thread never entered that list: codegen's Spawn case called reef_platform_thread_create() directly, a raw pthread_create() wrapper with zero GC registration. Three consequences, all closed by the same root fix: (1) a spawned thread's own GC-managed locals were invisible to mark and could be reclaimed while still live and referenced — reproduced as a 3/3 SIGSEGV at default settings, no ASAN, inside an ordinary string comparison; (2) a spawned thread was never signal-suspended, so it could keep mutating shared memory during a window the collector believed the world was stopped; (3) a spawned thread whose own allocation crossed the auto-GC threshold ran reef_heaps_gc() with reef_objects_current_process() resolving NULL (TLS was never set for it), so the collector never published its own roots during its own mark. Fix: two new runtime entry points, reef_objects_register_spawn() and reef_objects_deregister_spawn() (reef_objects.c), mirror Active Object threads' body_starter() registration onto the exact same g_objects.root list and process_list_mutex (no parallel bookkeeping) — publishing TLS + thread_id before the entry becomes list-visible (the same ordering body_starter() uses, for the same race reason), which also fixes failure mode 3 by making reef_objects_current_process() resolve correctly on a spawned thread. Unlike Active Object threads (joinable, freed later by reef_objects_join_process()), a spawned thread is fire-and-forget and detached immediately after creation, so reef_objects_deregister_spawn() frees the process struct itself once unlinked, rather than leaving that to a join that will never happen. Codegen (reef-compiler/lib/codegen.ml, the Spawn case, all three call shapes — Call, GenericCall, QualifiedGenericCall — with and without arguments) now always routes through a generated trampoline that calls reef_objects_register_spawn() before the spawned body's first allocation and reef_objects_deregister_spawn() after it returns; no join was added anywhere, so main's abandon-on-exit contract (BUG-171, SE-1) is unaffected. New tests: test_spawn_gc_rooting.reef (the reproducer shape: a spawned worker holds a [string] array across concurrent main-thread GC churn — RED 3/3 SIGSEGV at pre-fix tip, GREEN 20/20 clean post-fix), test_spawn_gc_selftrigger.reef (failure mode 3: the spawned thread's own allocation triggers its own collection while holding live locals — RED 3/3, GREEN 20/20), test_spawn_register_churn.reef (200 short-lived spawned tasks each registering/deregistering and calling into a shared Active Object's exclusive method — RED: fatal abort 3/3 pre-fix, since a bare spawn thread had no process context to lock with; GREEN 20/20, plus clean under ASAN with leak detection). No change to the shape of reef_process_t itself (spawn threads reuse the existing struct/list) — but see the SE-2 entry below, which adds an is_spawn field to that same struct later the same day, so "no ABI change" does not hold for the branch as a whole, only for this entry in isolation.

exit()/panic() could still UAF/double-free a live spawned task's process entry (SE-2 review, C1)

reef_exit() (reef_prelude.h, backing the exit() builtin — and panic(), which calls it) still ran the pre-BUG-171 ordered teardown directly (reef_objects_cleanup(); reef_heaps_cleanup(); reef_machine_cleanup(); exit(code);), a path BUG-171/SE-1's fix never touched (that fix only rewired generated main()'s own epilogue to the no-teardown reef_machine_exit()). reef_objects_cleanup()'s reap loop calls reef_objects_cancel_process() on every remaining live process; for a spawned thread's entry (process->thread == NULL by construction — reef_objects_register_spawn() never captures a joinable reef_platform_thread_t, and codegen detaches the thread immediately after creation) the if (process->thread) join guard silently skipped the join and fell straight through to process_cleanup()'s condvar_destroy() + free() — tearing down the struct (and mutex) out from under a thread pthread_cancel() had merely been asked to stop, not proven to have stopped (cancellation is asynchronous by default). The still-running spawn thread's own epilogue then double-frees/UAFs the same memory. Reproduced under ASAN, 4/5 runs, heap-use-after-free, on a spawned task hammering an Active Object's exclusive method while main does brief unrelated work and calls exit(0). Fix, two parts: (a) a new is_spawn flag on reef_process_t (set by reef_objects_register_spawn(), NOT inferred from thread == NULL/body == NULL, which are legitimately NULL for a spawn entry for unrelated reasons and would be a fragile heuristic) — reef_objects_cancel_process() now unlinks-and-abandons a spawn entry instead of cancelling/joining/freeing it, matching this codebase's own "process death is the teardown" contract; the struct is deliberately leaked at this specific call site rather than freed, since there is no safe point at which anything other than the thread's own eventual epilogue could free it without risking the same UAF. (b) reef_exit() now simply calls reef_machine_exit(code), giving exit()/panic() the same no-teardown abandon-on-exit contract main returning already has, so reef_objects_cleanup()'s reap loop is no longer reachable from any compiled Reef program's normal control flow at all (it remains correct and load-bearing for reef-runtime's own unit tests, e.g. test_ao_cleanup_uaf, which call it directly). New gate: asan_gate_exit_spawn.reef, wired into reef-compiler/tests/ asan_exit_gate.sh (now three gates under make test-asan) — the exit()-with-a-live-spawned-task shape the other two gates (main returning) didn't cover. RED (pre-fix): 4/5 ASAN heap-use-after-free. GREEN (post-fix): 10/10 clean, plus the new gate script PASS. No exit-code regression: test_exit_in_value_fn (exit 2), test_panic_basic/ test_panic_in_value_fn (exit 1), exit_codes (exit 64) all still pass — reef_machine_exit() preserves the requested code exactly, it just skips the now-provably-unnecessary teardown before calling it.

main returning now terminates spawned tasks immediately instead of racing their teardown (BUG-171)

Every generated main() ended with reef_objects_cleanup(); reef_heaps_cleanup(); reef_machine_cleanup(); return N; — reef_machine_cleanup() unconditionally free()s every heap memory block with no join or signal-suspend of any thread a spawn call might still have running. Empirical probing (30x normal + 30x ASAN per scenario, plus a 16-way concurrent-spawn variant) confirmed the mechanism is real: no crash was hit in ~140 runs on this hardware, but a still-running spawned task's marker was absent, present, or partially present run to run depending purely on timing — a structural race, not a hypothetical one. New runtime entry reef_machine_exit(int code) (reef-runtime/include/reef_machine.h) is now generated main()'s sole exit path, uniform across normal and sanitizer builds: fflush(NULL); followed by exit(code) (sanitizer builds, so LeakSanitizer's own internal stop-the-world scan runs) or _exit(code) (normal builds, skipping atexit entirely) — no heap teardown in either case. An earlier version of this function quiesced sanitizer builds first (signal-suspend every registered thread, never resume, then run the old ordered teardown) on the theory that LSan needed the heap actually freed to report meaningfully; SE-1 review found and reproduced (11/12, then 3/3) a solid deadlock in that design — a thread parked forever while holding the stdio lock (e.g. an Active Object mid-println) freezes the next print, including a verbose-mode log line from the teardown that followed, violating reef_objects.c's own documented "collector must not touch stdio while the world is stopped" contract — plus POSIX-UB lock destruction and an unsuspended timer thread racing the same teardown. The whole quiesce mechanism was deleted rather than patched, since its premise was also wrong: LSan's default leak heuristic is reachability, and Reef's heap blocks are malloc()'d and reachable at exit via the global memory_block_head list regardless of whether reef_machine_cleanup() ever runs — verified empirically post-fix (an AO + spawn program compiled against the ASAN runtime produces zero LSan reports across repeated runs; a deliberate positive-control leak via an extern "C" shim is still correctly reported, proving leak detection itself wasn't disabled by the simplification). New test: test_spawn_exit_no_survival.reef. New regression gate: reef-compiler/tests/asan_exit_gate.sh (wired into make test-asan) — compiles an AO-printing example and a spawn example against the ASAN runtime and runs each under a timeout, specifically to catch a reintroduced exit-time hang; make test-asan's own ctest suite never exercised generated main()'s exit path at all.

GC-managed spawn arguments are now rooted across the parent/child handoff (BUG-174)

spawn f(arg) where arg is a string, array, or object copied the argument into a malloc()'d (not GC-heap) args struct that nothing marked as a GC root. Between the caller dropping its own reference to arg (typically: returning from the helper that built it, right after the spawn call) and the spawned thread unpacking that struct into the callee's own parameters (where the callee's shadow frame re-roots it), the value was reachable only through the unrooted struct — a GC cycle landing in that window could reclaim it out from under the spawned task. Reproduced 3/3 runs both for a named-variable argument (spawn log_event(msg, n), 4-22 corrupted payloads per run) and for a bare rvalue/interpolated argument with no caller-side name at all (spawn log_event("${prefix}_${n}_payload", n), 6-24 corrupted payloads per run). Fix: gen_spawn_with_args (reef-compiler/lib/codegen.ml, replacing what had been three near-identical inline blocks — one per spawn-callee AST shape) makes argument transfer a brief, synchronous handshake instead of a fire-and-forget struct handoff, scoped to spawns with at least one GC-managed argument (a pure-scalar arg list, e.g. spawn f(1, 2), has nothing to root and keeps the original zero-synchronization shape, identical to the argless path):

  • The parent creates a one-shot mutex + condvar, stores them in the args struct alongside the arguments, creates the spawned thread, then blocks in a predicate-recheck wait loop (while (!unpacked) wait(...) — per BUG-120 lore, the runtime's SIGURG has no SA_RESTART, so a condvar wait can wake spuriously during a GC stop-the-world and must always recheck its predicate rather than assume one wake means done).
  • The spawned trampoline's first act (after reef_objects_register_spawn(), BUG-172, which must stay first) copies every argument into its own plain C locals and wraps the GC-managed subset in a shadow frame — the exact mechanism an ordinary function's own parameters get (build_shadow_frame's extra_tracked) — before signaling the parent. Only after that frame is linked does it signal; only after observing the signal does the parent unblock and free the struct itself (never the child — freeing from the signaling side would race the waiter's still-in-progress lock reacquisition on freed memory).
  • The handoff mutex is a private, single-use, per-spawn object never nested with process_list_mutex/gc_lock/heap_alloc_lock in either direction, so it cannot participate in a lock-ordering cycle with the runtime's existing discipline.

New tests: test_spawn_args_gc_rooting.reef (the named-variable repro shape, 20/20 clean post-fix), test_spawn_args_rvalue_temp.reef (the rvalue/interpolated-argument shape, 20/20 clean), and test_spawn_register_churn_args.reef (200 back-to-back spawns each carrying two GC-managed arguments through the handshake — deterministic via a shared Active Object tally rather than stdout ordering — also wired into asan_exit_gate.sh as a 4th make test-asan gate covering the handoff mutex/condvar's create-once/destroy-once lifecycle under ASAN). Negative check: disabling the wait loop (while leaving the rest of the handshake — including the now-premature struct free — in place) reproduces a hard crash (SIGSEGV, 3/3) rather than merely silent corruption, confirming the wait is load-bearing. 070_SPAWN.md's BUG-174 warning box is removed; the doc now states arguments are transferred synchronously at the spawn point, with the call itself still fully asynchronous. Scope, corrected on SE-3 review: this fix covers a single GC-managed argument, or arguments already rooted in the caller's own frame (named locals/parameters) — it does NOT cover two or more bare rvalue GC-managed arguments evaluated directly in the same call (spawn f(build_a(n), build_b(n)) with no caller-side name for either result), which can still corrupt the first-evaluated one; this is a general, pre-existing call-argument gap (reproduces with spawn removed too), filed separately as BUG-175 and NOT fixed by this entry — see the Known limitations entry below.

The example-suite harness now fails test_* files that exit 0 with no .expected (BUG-166)

run_tests.sh's test_run() treated a zero exit code with no .expected companion file as an automatic PASS, with no check of the program's actual output at all — a test that printed FAIL: ... lines to stdout while still exiting 0 gated green. Two confirmed false-greens (test_base64_valid_decode_agree.reef, test_base64_strict_padding.reef) had already shipped through this exact gap before being caught and fixed ad hoc during BUG-160's review. test_* files reaching that branch now [FAIL] with an explicit "unasserted" message instead of silently passing. Closing the gap required a one-time audit of every test_*.reef lacking an .expected across both example directories (233 files): 225 got a new .expected capturing current, correct output; 8 genuinely nondeterministic tests (live PIDs, timings, host env vars, hardware info, and spawn's racy stdout interleaving — see BUG-169) were skip-listed with inline justifications; and 2 real test-authoring bugs (not product bugs) were found and fixed along the way — test_chars_extended.reef was calling the wrong stdlib function, and test_list_prereqs.reef assumed an inclusive to-range upper bound. No product/runtime code changed.

Match arm bodies now get their own C block scope (BUG-168)

A pattern binding (a bare catch-all PVar or a constructor payload sub-binding) and its arm's body statements used to share a single C block in generated code, so a body let reusing the binding's name — ordinary shadowing, unrestricted at the Reef language level — produced a hard error: redefinition of 'v' (or similar) from the C compiler. Fixed at all six no-guard emission sites across both the switch and if-chain codegen paths; guarded arms were already correctly scoped by the guard's own if. New test: test_match_arm_body_scope.reef.

Documentation

The spawn contract: no ordering, no completion, no survival, no external kill (BUG-169, BUG-171)

docs/language/reference/070_SPAWN.md's Characteristics section gained a "The spawn Contract: No Guarantees" subsection stating, as one unified contract rather than scattered footnotes: no ordering guarantee between a spawned task's output and anything else (BUG-169 — measured 4-5+ distinct stdout orderings across repeated runs of test_spawn_args.reef / test_spawn_enum_args.reef, still skip-listed for exactly that nondeterminism, unchanged by this fix); no completion guarantee, including output that can be torn or lost, not just reordered; no survival past main's return (BUG-171, now structurally enforced, not just documented); no external kill. Cites the Active Oberon Unix.Machine.Mod lineage (Shutdown -> Unix.exit(), no join, no heap teardown) as precedent. Recommends Active Objects + await for anything needing lifecycle control. Same-day follow-up (SE-2): once BUG-172 was fixed (see Fixed above), the safety-warning box was removed from 070_SPAWN.md — a spawned task's OWN locals are memory-safe to hold across a concurrent GC cycle now, and the rest of the "no guarantees" contract (ordering, completion, survival, external kill) is unaffected and unchanged. SE-2 review caught that an earlier draft of this doc update overclaimed unqualified memory safety; the doc briefly scoped the claim to a spawned task's own locals and carried a warning box for BUG-174 (GC-managed arguments to spawn), fixed the same day (SE-3, see Fixed above) — but SE-3's own fix is itself scoped, not unconditional: it covers a single GC-managed argument, or arguments already rooted in the caller's own frame, and does NOT cover two or more bare rvalue GC-managed arguments evaluated directly in the same call, which is filed separately as BUG-175 (open) and still carries a warning box in 070_SPAWN.md.

Known limitations

  • BUG-173 (open, Critical, found during BUG-172's fix, independent of it): an extremely bursty GC cycle rate (observed with REEF_HEAP_BLOCK_SIZE set low plus one thread allocating in a tight loop while another sits in a blocking, interruptible call) can overflow a signal-suspended thread's stack via nested reef_gc_suspend_handler() re-entry — each SIGURG that arrives while a previous invocation is still parked in sigsuspend() nests rather than replacing it, and under a fast-enough back-to-back cycle rate the nested frames were observed to reach 15,000+ deep before crashing (SIGSEGV, stack exhaustion). Reproduced with plain Active Objects (no spawn involved), at a rate independent of and comparable to the spawn case — confirming this is a pre-existing GC/signal-delivery scaling gap, not something BUG-172's fix introduced. test_spawn_gc_selftrigger.reef's parameters were deliberately tuned (REEF_HEAP_BLOCK_SIZE=1048576, a smaller churn count) to stay well clear of this edge while still exercising BUG-172's failure mode 3. Not fixed — needs its own design work (e.g., bounding nested handler depth, or coalescing back-to-back suspend requests) and is out of scope for BUG-172.

  • BUG-175 (spawn variant FIXED — see the Fixed entry above; the general multi-argument CALL variant remains open, High; found during SE-3 review of the BUG-174 fix): two or more GC-managed argument expressions evaluated directly in the same call, with no caller-side name for any of them (e.g. spawn f(build_a(n), build_b(n))), are not all rooted simultaneously — evaluating a LATER argument (even one whose own static type is a plain scalar, if its evaluation allocates internally) overwrites __reef_gc_inflight (the runtime's single-slot last-allocation root), dropping protection for an EARLIER argument's already-computed value. Reproduced independently this round: 7-11 corrupted payloads per run (3 runs, 3000 requests each) for the two-string-argument shape, and 9-15 per run for a scalar-second-argument variant that still allocates internally; the reviewing session reported higher, more deterministic rates (300/300 and 200/200 respectively) under different tuning. This is a GENERAL call-argument gap, not specific to spawn — the same mechanism applies (with a much smaller window) to any multi-argument call; the reviewing session additionally reported identical corruption with spawn removed entirely, not independently reproduced this round. Ties to the GC design doc's F2 return-value window (docs/ROADMAP.md:347-359, owner-accepted with "revisit only if new evidence surfaces") as the sibling argument-passing case satisfying that revisit criterion. The spawn variant is now fixed (Stage 0 of the 0.9 scoped-codegen-environments program — parent-side rooted argument slots written interleaved with argument evaluation; see the Fixed entry above). What is still open is the general case: a plain synchronous multi-argument call gets no such slots, so the same mechanism applies there with a much smaller (no thread-creation-latency) window. Fixing it is Stage 6 of that program — a general per-call-site transient root set designed fresh, not a resurrection of the deleted gen_call_with_transient_roots helper.

  • BUG-163 (open, Low): a match-expression arm's body accepts only primary_expr in the grammar — a bare function call, or anything else built on top of primary_expr (e.g. a binary operation), as an arm's body is a parse error, unlike the match-statement form, which allows a full statement list. Workaround: wrap the body in parens (other => (unwrap_or(other, "MISSING")) — LPAREN expr RPAREN is itself a primary_expr, so expr inside it is unrestricted). The obvious fix — widening body from primary_expr to expr — was attempted this batch and found structurally BLOCKED: it reliably reopens a pre-existing, currently-benign Menhir LALR merge between match-expression and match-statement arms (BUG-170) into a genuine ambiguity, for every widening candidate tried. Stays open; a real fix needs to break that structural prefix collision, not just widen a nonterminal.

0.8.5 — 2026-08-07

The GC-rooting hardening batch. Six fixes: three from a systematic audit of precise-GC rooting and match-arm codegen that followed the C6 shadow-frame work, landing in dependency order BUG-159 -> BUG-158 -> BUG-157; a fourth (BUG-161) filed during that same audit's Task 3 review and fixed in this batch too; and two independently-filed fixes carried over from the previous (v0.8.4) branch's reviews and scheduled into this batch. Guarded constructor-pattern match arms combined with a when guard used to fail to compile for a non-GC-typed payload binding, or, with a GC-typed payload, compile clean but silently select no arm at all or crash (BUG-157, from the rooting/codegen audit); three codegen contexts — lambda bodies, module initializers, and await conditions — built no shadow frame at all, leaving any GC-typed local they computed unrooted (BUG-159, from the rooting/codegen audit); stdlib-origin trust for the BUG-151 FFI string boundary copy was decided by string-matching an already-resolved path instead of the resolution step that found it (BUG-156, found independently during the v0.8.4 branch's Task 3 review); an enum match scrutinee's GC-managed payload left unrooted across an allocating guard (BUG-158, from the rooting/codegen audit); a match binding usable inside its own arm silently inferred as int in codegen, degrading string comparisons, breaking interpolation, and in one shape crashing the compiler outright (BUG-161, filed during the rooting/codegen audit's Task 3 review, with a second confirmed symptom added in that same audit's final review); and a base64 validation/decode disagreement left over from the BUG-150 strictness change (BUG-160, found independently during the v0.8.4 branch's final review). No ABI change. Five further issues were filed during this batch's review and remain open: BUG-162 through BUG-165, and BUG-167 (found during BUG-161's own follow-up review, confirmed pre-existing rather than introduced by it).

Fixed

Guarded constructor-pattern match arms now compile and match correctly (BUG-157)

A match arm combining a when guard with a constructor pattern that binds the payload (e.g. Some(x) when x > 0 => ...) used to drop the binding whenever codegen took the if-else-chain path (any arm guarded, or a string pattern/string scrutinee forcing the chain) — previously a compile error for a non-GC-typed binding (use of undeclared identifier), or, with GC-typed payloads, a match that silently selected no arm at all (falling through an otherwise-exhaustive match with no output), or crashed, with no diagnostic to catch either. Guarded constructor patterns with payload bindings now compile and run correctly in both cases. A payload binding consumed inline by comparison or interpolation within its own arm — the gap this bullet used to caveat — is fixed below (BUG-161).

A match binding read inline within its own arm silently inferred as int in codegen — degrading string comparisons, breaking interpolation, and crashing the compiler on a nested match (BUG-161)

A match arm's bound variable — a catch-all PVar (other => ...) or a constructor payload sub-binding (Some(x) => ...) — was never registered into codegen's ctx.type_env while generating that arm's guard or body, in any of the three match codegen paths (the expression-form if-chain, the expression-form switch, and the statement-form if-chain/switch). Reading the binding directly in a comparison or interpolation therefore hit infer_expr_type's generic "could not infer type" fallback and silently defaulted to int: x == "literal" lowered to raw pointer comparison instead of strcmp (permanently wrong for two content-equal, pointer-distinct strings, with no compile error — just a wrong runtime result); "${x}" interpolation emitted an int assignment from an actual char*, a hard C compile failure; and using the binding as a NESTED match's own scrutinee crashed the compiler outright with no source location and no readable message (resolve_enum_tag resolving against a bogus int "enum", raising a CompileError that propagated past every diagnostic-formatting layer to reefc's last-resort Unexpected error: ...(_) handler). Statement-form matches were unaffected — a separate, dedicated walker already registered these bindings for that path — which is what limited BUG-157's original fix to a caveat rather than a full close. Every match arm's bound variable(s) are now scoped into ctx.type_env immediately before that arm's guard/body generation and popped immediately after (an exact-inverse restore snapshotting and rebuilding each name's whole binding stack, not a single blind pop — a review fix closing a same-day CRITICAL regression where a nested block's own same-named let inside the arm could leak the pattern's binding past the arm's end), so sibling arms never see each other's bindings and a binding that shadows an outer local of a different type — including one shadowed again by a nested let inside the arm — resolves correctly inside the arm and reverts correctly outside it. One known residual: a match binding used as a NESTED match's own scrutinee still produces harmless extra "could not infer type" warnings from the unrelated, still-open BUG-165 rooting pre-pass, which has no per-arm binding awareness of its own — the program compiles and runs correctly regardless. Filed during the GC-rooting batch's Task 3 review (the original silent-miscompare shape); a second symptom (the nested-match crash) and confirmation that constructor payload sub-bindings share the same gap were added during that batch's final review. BUG-167 (a match binding sharing a name with a GC-tracked outer local reads the outer's __reef_locals slot instead of its own) was found during this fix's own follow-up review, confirmed pre-existing on both sides of this fix, and filed separately — not fixed here.

GC-typed locals in three previously frameless codegen contexts are now precisely rooted (BUG-159)

Lambda bodies, module-level initializer expressions, and the condition function generated for await <expr> never built a __reef_locals shadow frame, so any GC-typed value they computed (e.g. a match scrutinee or a freshly allocated string) had no root and could be collected while still live. All three now get a real shadow frame, closing the gap for systems-level code that relies on precise GC rooting in these positions.

Stdlib-origin trust is now decided by resolution path, not by string-matching (BUG-156)

The BUG-151 fix exempts stdlib-shipped extern "C" functions from the FFI string boundary copy because they're already known to return headered memory — but that exemption was granted by pattern-matching the resolved file path, so a project-local ./stdlib override directory or a legacy REEF_STDLIB_PATH-pointed tree (both ordinary user code, not the shipped stdlib) could be misclassified as trusted and skip the copy it needs. Origin is now recorded at the module-resolution step that actually found the file: only the shipped, REEF_HOME/install-resolved stdlib is trusted; ./stdlib overrides, REEF_STDLIB_PATH, REEF_OS_PATH, and ~/.reef/os are all treated as untrusted, ordinary source.

An enum match scrutinee's GC-managed payload could be freed while live across a guard (BUG-158)

When a match scrutinee was an enum carrying a GC-managed payload (e.g. Option[string]), the payload value was reachable only through the scrutinee's own unrooted copy — if an allocating when guard ran before the payload was read, the GC could reclaim it out from under the match. Every GC-managed payload field reachable through a scrutinee is now extracted into its own rooted shadow-frame slot, written tag-conditionally immediately after the scrutinee binds and before any guard runs.

base64_is_valid now agrees with strict base64_decode (BUG-160)

The BUG-150 strictness fix to base64_decode enforced RFC 4648 final-group padding rules that base64_is_valid didn't know about, so a caller could see base64_is_valid(x) == true immediately followed by base64_decode(x) returning Err(...) on the same input. base64_is_valid now enforces the identical final-group rules, with a paired test asserting the two agree.

0.8.4 — 2026-08-06

The pre-0.9 hardening release. Six low/medium-severity fixes, clearing the deck before the 0.9 OO work begins: two diagnostic-quality gaps and a silently-ignored build setting reported downstream against 0.8.3 (BUG-153/154); a match-scrutinee and a match-arm-ordering correctness bug found in the 2026-07-24 adversarial review (BUG-139/145); a literal-escaping bug found in the BUG-147 final review — already a known issue in the 0.8.3 entry below, now fixed (BUG-149); and a base64 strict-padding bug found in the BUG-147 batch C audit (BUG-150). No ABI change since 0.8.3.

Fixed

Syntax errors now name the file, and flag reserved words (BUG-153)

A syntax error caused by a reserved word where an identifier was expected (e.g. module shared, or import shared in the importing file, since shared lexes as the keyword SHARED before the imported file is ever opened) printed an unattributed Syntax error at line N, column M with no filename, pointing at code that looked valid. Both syntax-error sites (the entry-file parse path and modules.ml's imported-module path) now print the filename and, when the offending token is a reserved word, an additional note: '<word>' is a reserved word and cannot be used as an identifier line.

The published keyword list was missing 18 of the lexer's 83 reserved words (BUG-153)

docs/language/reference/015_BASICS.md §Keywords existed but omitted Any, Fn, asm, await, byte, const, defer, impl, is, module, spawn, then, to_map, trait, type_id, type_name, when, and where — type_name and shared were both found the hard way, by hitting them. The list is now complete at all 83 entries.

[build] source_dirs in reef.toml is now honored (BUG-154)

Previously source_dirs was written by reefc init's project template and never read by anything — module resolution only ever searched the CWD, the entry file's own directory, and src/. reefc build now searches every directory listed in source_dirs, resolved relative to the project root and non-recursively, after the existing search order, so no existing project's resolution changes unless it lists directories. The non-project compile paths (reefc foo.reef, reefc run) are unaffected. Named path dependencies ([dependencies] name = { path = "..." }) — the better long-term fix for sharing modules between projects — are a larger design deferred to v0.9.

Non-ASCII string literals could fail to compile (BUG-149)

A string literal with a non-ASCII byte immediately followed by a hex-digit character (e.g. "café" + "au", since é encodes as \xC3\xA9 followed by a) failed C compilation with hex escape sequence out of range: C's \xNN escape has no fixed width, so the following hex digit was absorbed into the same escape. The compiler now emits fixed-width octal escapes for non-ASCII bytes instead, which have no such hazard. Reachable from any string literal since 0.8.3 made every literal (not just match-pattern strings) go through the same escaper — closes the BUG-149 known issue called out in the 0.8.3 entry below.

A match scrutinee with side effects could be evaluated more than once (BUG-139)

match f() { ... } could call f() once to test a value against an arm and again to bind it (e.g. a catch-all binding), so a side-effecting or non-deterministic scrutinee could run multiple times with the bound value coming from a different call than the one tested. The scrutinee is now evaluated exactly once into a rooted temporary, referenced by every arm. Only observable with a side-effecting or non-deterministic scrutinee expression — a plain value or local was always unaffected.

A specific match arm placed after an unconditional catch-all is now rejected, instead of silently never running (BUG-145)

match x { other => ...; SpecificPattern() => ... } — a catch-all arm before a specific one — used to compile even though Reef matches in source order, so the specific arm could never run; on the switch-codegen path it could even be dispatched to for a matching value, contradicting that ordering. This is now a typecheck error naming the unreachable arm. Only affects matches with an unconditional (unguarded) catch-all placed before other arms — idiomatic code always puts catch-alls last.

encoding.base64 decode now rejects malformed padding instead of silently truncating (BUG-150)

A '=' character was accepted as padding at any position in the input, not just the final group, so malformed base64 with a stray '=' (e.g. "QQ==QQ==") decoded "successfully" to a shorter, wrong result instead of being rejected. Decoding now enforces RFC 4648 padding placement and returns Err(InvalidInput) for invalid characters or misplaced padding.

0.8.3 — 2026-08-02

Version numbers 0.8.1 and 0.8.2 are retired without a public release: 0.8.1 was never published, and a downstream consumer built pre-BUG-151-fix trunk labeled 0.8.2 — retiring the number keeps "0.8.2" unambiguous (it always means the build with the FFI regression).

ABI break: generated C from an older reefc has no literal headers, and the heap-block header gains a field — the compiler and runtime must be upgraded together (already a documented constraint for the prelude; this makes it load-bearing).

Fixed

User extern "C" functions returning string now boundary-copy their results (BUG-151)

The O(1) string primitives below read a heap-block header at ptr - 48; a user C helper returning a raw pointer (static buffer, environ, .rodata) has no header there, so str.length()/indexing read unowned memory — a downstream init system's realpath() helper made every resolved symlink look dangling, and a fabricated length could defeat the safe-context bounds check. The compiler now wraps the result of every user-declared extern "C" fn ...: string call in reef_string_dup_c() (one copy per call, cold FFI paths). Stdlib-shipped externs are exempt — they already return headered memory. Freestanding targets are exempt (kernel FFI owns its headers). No downstream source changes needed: existing C helpers returning char* work as they did on 0.8.1. Note the copy is a value snapshot: mutating the C side's buffer afterward does not change the returned Reef string, and externs that FILL a caller-provided Reef buffer still need str.sync_length()/str.set_length() after the fill (see docs/language/reference/105_FFI.md).

Bounds-checked string indexing and str.length() were O(length), making character scans quadratic (BUG-147)

s[i] in safe context, and core.str.length(), both re-derived a string's length with strlen on every call. Any safe-context loop walking a string character by character was therefore O(L²); core.str.split compounded it further by calling substring() (itself calling length()) once per part. Reported by a downstream package manager, whose commands hit multi-second to multi-minute runtimes on ordinary inputs (list: 30.4s; owner: 227s) working around it with private reimplementations of split and base64 decode.

Fixed by storing an explicit logical length (str_len) in the heap-block header — the same place array length already lives — instead of scanning to the NUL. String literals gained real headers too (a compiler-emitted static aggregate via a new REEF_STRING_LIT macro), and ~21 runtime builtins that previously returned header-less C strings (getenv, argv, getpwuid names, CPU/OS strings, TLS/net error strings, ...) now return a boundary copy with a proper header. reef_string_check, core.str.length(), and reef_string_copy_range's append destination read str_len directly; every other operation keeps strlen (they're O(n) memcpy anyway) but writes str_len on its output.

Measured: a 160 KB checked character scan went from 98-107 ms to 0-1 ms; core.str.split on 16,000 lines went from 2971 ms to 3 ms (~990x).

reef_string_alloc(0) wrote one byte past a zero-byte allocation (BUG-148)

Found during BUG-147's design review. reef_string_alloc always wrote s[0] = '\0' regardless of the requested size; with len == 0 that byte landed past the allocation. No known stdlib caller passed 0, but the function is a public runtime builtin, so user code could reach it directly. Fixed as part of the BUG-147 constructor-guard work (if (s != NULL && len > 0) s[0] = '\0';).

Changed

  • String buffers can now be filled by index in safe context with no unsafe block, closing the BUG-146 family for index-fill buffers: a fresh reef_alloc_string_buffer(n) reports its full logical length (n - 1) from allocation (the data region is zeroed, so unwritten indices read '\0'), which is what makes an in-bounds indexed store legal before the buffer is fully written.
  • string_offset now requires unsafe. It returns an interior pointer with no heap-block header behind it; using it outside unsafe is now a typecheck error rather than a silent header-corruption hazard.
  • unsafe code that moves a string's NUL terminator now owns the str_len invariant. core.str.set_length(s, n) and core.str.sync_length(s) are new — call one after any unsafe write that changes where a string's NUL sits, or str.length()/bounds checks will keep reporting the old length. See docs/language/reference/100_UNSAFE.md.
  • Strings with embedded NUL bytes (e.g. http.bytes_to_string on binary data) now report their full byte count from str.length(); C interop (anything taking the string as a bare char*) still only sees up to the first NUL, unchanged.
  • --no-gc semantics clarified: the heap is always initialized; --no-gc now only disables the collection pass (reef_heaps_disable_gc()). Previously implied (and in one code path, behaved as if) no heap existed at all, which broke boundary-copy allocation under --no-gc.

Known issues

  • BUG-149 (Medium, pre-existing, not fixed in this release): a string literal containing a non-ASCII byte immediately followed by a hex-digit character fails C compilation. c_escape_string's maximal-munch escaping emits a plain \xNN sequence for the non-ASCII byte with no delimiter before the following hex digit, so the C compiler consumes it as part of the same hex escape (\xNN + hex digit = a wider, likely out-of-char-range escape, which GCC/Clang reject). Found during the BUG-147 final review; not yet fixed. Fixed in 0.8.4, above.

0.8.1 — 2026-07-27

The installable-release fix. 0.8.0's binary tarball could not be installed: the installer it advertised was absent from the archive, and would have failed on the archive's own directory layout if present. Nothing in the release pipeline noticed, because nothing ran the install suite. This release makes the binary distribution usable, adds the install documentation it never carried, fixes a stdlib function that aborted on every input it was given, and closes the two gaps in the test harness and CI that let all of it ship.

No API changed. Everything here is a defect in the 0.8.0 artifact.

Fixed

The binary distribution could not be installed

  • scripts/install.sh describes itself as "Install Reef from distribution tarball", but scripts/package-binary.sh never shipped it — and it would have aborted if it had. It copied from a flat lib/{stdlib,runtime} layout while the packager writes nested lib/reef/{stdlib,runtime}; under set -e the install died after copying only the compiler, leaving bin/reefc beside empty directories, whose reefc then silently resolved to whatever other Reef installation existed on the machine. Fixed by copying lib/ wholesale and letting the compiler's own resolver accept either shape, which it already did.
  • Upgrade-in-place was broken independently. bin/reefc is packaged mode 555, the only non-writable file in the archive, so cp over any previous install failed with Permission denied. Reinstalling 0.8.0 over 0.8.0 could not work.
  • Installing over an older flat-layout install silently kept the old stdlib and runtime. The wholesale copy preserves the source layout but cannot remove a different layout already at the destination, and the resolver prefers flat — so a 0.7.x user upgrading in place would have got a new compiler linked against an old runtime archive, reported as a successful install. Now detected and refused, naming the stale directory to remove.

The binary distribution shipped no install instructions

  • Added README.md and INSTALL.md at the tarball root, written for someone installing a prebuilt archive. Both state the prerequisite that appeared nowhere previously: reefc compiles through C, so a C compiler and linker must be present — the archive is not self-contained.
  • The archive stopped shipping the repository's own INSTALL.md, titled "Building Reef from Source", which was the only install document a binary user could find and which told them to install opam, dune and cmake.

io.path.normalize() aborted on every non-empty input (BUG-146)

It filled a scratch buffer with indexed stores in safe context. Such a store is bounds-checked against the string's current length, not its capacity, and the buffer allocator returns capacity with length 0 — so after k writes a store at index k needed k < k, false every time. Every non-empty input aborted at index 0. Latent since the function was written; BUG-131's addition of store bounds-checking (after 0.7.8) turned a silently-working raw store into a hard abort. Fixed by wrapping the loop in unsafe, matching every other buffer-building function in the stdlib.

Changed

The installer

  • Offers a choice of install location when run interactively — the three locations the compiler already searches, plus a custom path — defaulting to a no-privileges user install. Non-interactive behavior is unchanged, including the system default, so scripted callers are unaffected.
  • Puts reefc on PATH by symlinking into a directory already there rather than editing a shell rc file. Editing an rc file is now opt-in via --add-path, and an existing reefc is never replaced without asking.
  • Verifies its own work before reporting success: it runs the freshly installed compiler and confirms it resolves both stdlib and runtime from the prefix just written. The 0.8.0 defect above produced a tree that copied without error and still could not compile.
  • uninstall.sh now removes the PATH symlink the installer created, when it points into the prefix being removed.

Testing and CI

  • A runtime crash no longer counts as a passing test. run_tests.sh printed [WARN] compiled but run failed and incremented the pass count for any test that aborted without a companion .expected file. Compiling is not the property these tests exist to check. Both that branch and the timeout branch now fail. This is how BUG-146 reached a release, and it was simultaneously masking BUG-147 — test_match_guards had been aborting two thirds of the way through, reported green, reading past the end of a string in a hand-rolled length helper (the BUG-123 pattern, in a file that fix did not cover).
  • The release is now gated on the binary tarball being installable. The package step runs scripts/test-install.sh, alongside the existing source-tarball smoke build, so both published artifacts are checked: the source tarball must compile from a clean extract on both toolchains, and the binary tarball must install. The suite gained a third case covering package-binary.sh → install.sh — the sequence a real user performs, and the one combination its two existing cases left untested.

0.8.0 — 2026-07-26

The concurrency-correctness release. Three interlocking subsystems that govern how Reef programs share memory across threads are made sound: the garbage collector (the BUG-132 family — a five-defect allocator/collector race), the Active Object monitor (BUG-120 — Hoare-faithful directed handoff, plus a cluster of cancellation and precise-GC-rooting fixes around await and run()), and the SIGURG channel the new stop-the-world collector depends on (BUG-133). Rounding it out is a compiler-correctness bug punchlist — eight codegen/typecheck defects, several of which silently mis-compiled or segfaulted valid-looking programs — and stdlib/tooling fixes. Two runtime memory-safety bugs in this set (BUG-142 unrooted run() GC-locals, BUG-137 leaked monitor mutex on cancellation) were the ones that most warranted the deliberate, review-gated pace. No public API changed incompatibly.

Fixed

Garbage collector — the BUG-132 family (R1-R5)

A five-defect race between concurrent allocation and stop-the-world mark/sweep that could hand out the same memory twice or reclaim a still-live object, latent since heap_alloc_lock was introduced (rev 111) and made deterministic by the C6 precise-marking landing (2026-05-18). Caught by the test-asan CI gate on a contended runner, not by local development — credit to that gate for surfacing a latent race that had been silently wrong for months.

  • BUG-132 (R1-R4): header publication (type, mark, flags), the allocate-black mark stamp, zero-init, and array redzone unpoison/poison all moved inside heap_alloc_lock as one atomic free-block→allocated-block handoff, closing the window where a concurrent sweep could observe a still-free-sentinel header and coalesce a block already handed to another thread. The GC prologue (mark-stack reset, epoch bump) moved inside gc_lock, guarded by a gc_collector_owner CAS so a second concurrent reef_heaps_gc() trigger becomes a no-op wait instead of a second, overlapping collection.
  • BUG-132 (R5): restored real stop-the-world marking. The collector now preemptively signal-suspends (SIGURG) every other registered thread; each target's own handler publishes its precise shadow-frame chain head and in-flight allocation root (__reef_gc_inflight, the F2 mitigation — see docs/design/GC_PLATFORM_CONTRACT.md §8.3) before parking, and mark walks every published chain with the world fully stopped. Previously the cooperative-safepoint machinery this replaced had zero callers wiring it to a live GC request, so every GC scanned only the collector thread's own chain — a running sibling thread's roots were silently dropped.
  • Fork safety: pthread_atfork handlers reset collector/process-list state in a forked child and defer all collection until the child execs (allocation stays legal via heap expansion in the meantime), closing the fork hazard ranked #1 by the downstream consumer audit.
  • BUG-134: lazy_sweep's found-split path now unpoisons the entire coalesced free run before writing the split remainder's header, closing an ASAN use-after-poison flake in make test-asan (taskset-amplified test_gc_fork: 17/20 failing pre-fix -> 0/50 post-fix).
  • BUG-133: the runtime signal API (reef_signal_block, reef_signal_set_handler, reef_signal_ignore, reef_signal_default, reef_signal_wait_set, and the self-pipe registration path) now refuses SIGURG with EINVAL — it is the collector's stop-the-world suspension channel, so a program that blocked or re-purposed it would deadlock the next GC cycle. reef_signal_unblock(SIGURG) stays allowed. New gate test_sigurg_reserved.

Active Object monitor & await

  • BUG-120: the monitor is now Hoare-faithful. A single shared monitor_handoff() helper serves both exclusive release sites (unlock and await), publishing locked_by = next under the mutex before signalling — closing a transient dual-exclusive-ownership/barging window where unlock and await had diverged into two different release protocols. await now waits in an ownership-recheck loop (while (locked_by != self) cond_wait) rather than a single wait plus blind re-stamp — the correct spurious-wake defense under the GC's SA_RESTART-less SIGURG suspension — and recursive lock_count is saved/restored across the wait. New blocking gate test_ao_monitor (6 cases incl. the platform-independent errant-wake witness) plus a never-> 1-exclusive-holder invariant.
  • BUG-136: reef_objects_cleanup()'s cancel loop spun on and use-after-freed a freed-but-still-linked process; cancel_process() now unlinks under process_list_mutex before cancelling and joins the thread before freeing continue_cond/the struct. New gate test_ao_cleanup_uaf.
  • BUG-137: cancelling a process parked in await's ownership loop had no pthread_cleanup handler, so POSIX cancellation reacquired then leaked the monitor mutex, permanently deadlocking the object. Added pthread_cleanup_push/pop around all three cancellable monitor cond-waits; the await handler also evicts the cancelled process from awaiting_cond and forwards a racing handoff (a find_condition use-after-free the mutex-only fix's own test surfaced under ASAN). New gate test_ao_cancel_parked_await.
  • BUG-135: removed the confirmed-dead awaiting_lock queue and the write-only locked field from reef_protected_object_t (pure removal, no behavior change).
  • BUG-142: run() bodies built no precise-GC shadow frame, so a GC-pointer local declared directly in run() was unrooted for its entire lifetime and could be collected out from under live code. gen_run_method now calls the same build_shadow_frame helper the exclusive/shared method paths use. New regression test_bug142_run_body_gc_local.reef (RED content-corruption pre-fix, GREEN under plain and ASAN builds).
  • BUG-118: an await condition referencing a method parameter or local (not just self) failed C compilation; the generated condition function now snapshots captured free variables by value alongside self (reusing collect_free_vars). Self-only sites are byte-for-byte unchanged.
  • BUG-117: await written lexically inside run() is now rejected at typecheck with a diagnostic naming the working workaround (move it into an exclusive fn/proc and self-call it). run()'s thread never holds the monitor, so an earlier same-day attempt to make it compile silently no-op'd the wait — the rejection is the sound fix.
  • BUG-143: inside any method body, let k = self.method() inferred the local as int regardless of the callee's real return type — codegen's infer_expr_type had no arm for a Self-receiver call. Added the missing arm (including the TGeneric receiver case).

Compiler — unsound programs now rejected

  • BUG-124: a bare zero-arg enum-constructor pattern in a match arm (Status_Pending => without parens) parsed as a catch-all binding — a silent non-exhaustive match ending in a runtime segfault. Now a hard type error; the 23-file stdlib blast radius (graphics/UI) was mechanically parenthesized in the same change.
  • BUG-140: a match with two catch-all-shaped arms (a binding then a trailing _) that reached the switch-codegen path emitted two C default: labels ("multiple default labels"). Rejected at typecheck instead (mirrors BUG-124), counting only unguarded catch-alls so the legitimate guarded-fallback idiom (_ when c1, _ when c2, _) is unaffected.

Compiler — codegen correctness

  • BUG-129: a match catch-all binding (other =>) was never assigned the scrutinee across 9 codegen sites on the if-else-chain and match-expression paths — reading the binding segfaulted on a NULL. A shared gen_match_binding_assign helper now mirrors the already-correct PVar case (GC-tracked store vs. plain local), with guard-before-binding ordering.
  • BUG-128: codegen's bare-pattern match classification was cruder than the typechecker's, so a binding named after a different enum's variant built an undeclared C tag; codegen now uses the same resolve_enum_for_scrutinee lookup, and variant resolution fails loudly instead of silently falling back.
  • BUG-131: array-index assignment (arr[i] = v) emitted an unchecked C subscript store even outside unsafe. The store is now bounds-checked (single-evaluating array/index/RHS) exactly mirroring the read path's dispatch and unsafe-gating; RFE-005 made this 64-bit-reachable.
  • BUG-138: a string match whose only arm is a catch-all binding selected the integer switch path — switch (char*) is a hard C error. The strcmp chain is now selected whenever the scrutinee is string-typed, not only when a string-literal pattern is present.
  • BUG-130: ui/widgets/slider.reef used the reserved keyword step as a field/parameter name (a parse failure invisible to the suite since nothing imported it); renamed to step_value, and a ui.widgets --check smoke section now covers all 8 widget files.
  • BUG-122: the inline-asm clobber list emitted invalid 32-bit sub-register names (r8d, r9d, …) for extended x86-64 registers R8-R15, which GCC rejects; codegen now normalizes any R8-R15-family clobber to its single valid GCC spelling and de-dups (REX-less eax/ecx/… untouched). New dedicated gcc -c (real-compile) regression gate.

Standard library

  • BUG-121: compress.gzip's in-memory gzip_compress/gzip_decompress emitted plain zlib framing (78 9c …) despite the name; rebuilt on zlib's stream API with windowBits = 31/47 so they emit and auto-detect true gzip framing (1f 8b …), verified interoperable with system gzip(1)/gunzip both directions. Also fixed a z_stream/errnum native-heap leak on the new path.
  • BUG-144: compress.zlib leaked its scratch buffers on all return paths (pre-existing); freed on every path (ASAN detect_leaks=1: 0).

Tooling & packaging

  • BUG-125: paths.ml now resolves both the flat lib/{stdlib,runtime} layout (install.sh/package.sh) and the nested lib/reef/{stdlib,runtime} layout that make install/package-binary.sh (the real forge release) actually produce, at every home candidate.
  • BUG-126: install.sh/package.sh/build-release.sh derive the version from reef-compiler/dune-project instead of a nonexistent top-level VERSION file. New make install-test regression-guards the whole package→install→run-without-REEF_HOME path across both layouts.

Changed

  • RFE-005 (reported downstream): array and string index expressions now accept the full integer family (not just int), and the bounds-check path is widened to int64_t.
  • BUG-127: finalize() is now allowed in any position among an Active Object's methods (previously position-constrained).
  • Lazy sweep revived as the sole sweep mechanism. Mark is the only
  • Lazy sweep revived as the sole sweep mechanism. Mark is the only stop-the-world work; sweep is folded into the allocator under heap_alloc_lock and runs incrementally, concurrently with other mutators, once the world resumes. Added a swept-clean-until-next-epoch latch, mark-measured bytes_live accounting (replacing threshold inflation from stale byte counts), and pause/sweep telemetry.
  • Deleted the dead cooperative-GC hybrid: reef_objects_set_gc_status, the gc_requested/gc_safepoint park arm, and the unused reef_heaps_write_barrier — all verified zero-caller at tip. --quantum-checks codegen emission and reef_objects_yield_or_gc's name/signature are preserved (downstream carve-out; unrelated to hosted GC correctness).

Added

  • docs/design/GC_PLATFORM_CONTRACT.md: the normative contract for the shipped STW-mark/lazy-sweep design — lock discipline, the SIGURG suspension protocol, fork safety, and F2 (see below).
  • New blocking test gates: test_ao_monitor, test_ao_cleanup_uaf, test_ao_cancel_parked_await, test_sigurg_reserved, test_gc_suspend, test_gc_stw_retention, test_gc_lazy_sweep, and the re-enabled/amplified test_gc_race_window and test_gc_fork — all run under both the plain and ASAN ctest suites.
  • make install-test (scripts/test-install.sh): end-to-end package→install→run-without-REEF_HOME smoke test covering both install layouts (BUG-125/126 guard).

Build & CI

  • The source tarball builds warning-tolerantly by default. -Werror is now gated behind REEF_WERROR (default OFF), so a compiler- or version-specific warning cannot break an end user's build of a pristine release. CI and developers opt into strict mode (RUNTIME_WERROR=ON / -DREEF_WERROR=ON).
  • CI now builds the runtime under both GCC and clang at Release -O3 with -Werror, and smoke-builds the packaged source tarball before publishing. This closes the gap that let a GCC-15-only -Werror=stringop-truncation in a test file (test_gc_fork.c — not product code) break the initial 0.8.0 source tarball on a stock GCC toolchain, while the clang-based build passed.

Known limitations / follow-ups

  • F2 (accepted, documented, not fixed): a narrow return-value register window — an allocated object's only live reference can transiently sit in a register or dead stack slot between a callee's frame unlinking and the caller storing it into a chain-rooted slot. __reef_gc_inflight closes the dominant instance (the most recent allocation on a thread); the residual (an earlier allocation returned past a later one) is bounded, empirically unobserved across hundreds of stress runs, and formally accepted as an owner decision rather than scheduled for a fix. See the platform contract §8.3 for the two named (unimplemented) closures.
  • BUG-139 (open, Low): match's if-else-chain codegen re-evaluates the scrutinee once per arm; a side-effecting/non-deterministic scrutinee (match f() { … }) runs f() N times. Pure-value scrutinees (the common case, incl. any plain local) are unaffected, and a repo-wide grep found zero in-tree triggers. Fix (deferred): bind the scrutinee to one temp.
  • BUG-145 (open, Low): an unconditional catch-all arm placed before a specific arm silently shadows it, and switch-codegen label order-independence can dispatch to the later specific case. Pre-existing and anti-idiomatic; fix (deferred): generalize BUG-140's rule to "any arm after an unguarded catch-all is unreachable".
  • BUG-141 (open, enhancement): run() has no await semantics of its own — a bare await there is rejected (BUG-117) with a working workaround; automatic lowering to that shape is a possible future enhancement.

Test suite: 518 passed / 0 failed / 21 skipped (was 478/0/21 at v0.7.8); runtime test_heaps 44/44, ctest 19/19 (was 9/9 at v0.7.8 — grew with the new GC and Active Object monitor gates), make test-asan 19/19, make docs-check 19/19.

0.7.8 — 2026-07-21

The baremetal restoration release. Two regressions that made baremetal targets unbuildable since the C6 precise-GC landing (2026-05-18) are fixed — the raw-address integer→pointer cast baremetal code depends on, and the missing C6 shadow-frame contract in the freestanding builtins header — and a suite gate now catches this class of regression automatically. A main-thread await() runtime bug is fixed, and the website's examples library ships its seventh and final entry.

Fixed

  • BUG-119: integer→pointer/array casts (addr as pointer, addr as [T]) inside unsafe blocks are accepted again, symmetric with the already-unsafe-gated pointer→X direction. This restores the pre-0.5.20 raw-address idiom — MMIO registers, physical pages, page tables, trap frames — that all baremetal Reef code depends on, and had no workaround (not even an FFI shim) since it regressed. Unblocks a downstream OS migration (~282 call sites) and the shipped reef-os/ UART drivers, which no longer compiled under the regression. Casts outside unsafe continue to be rejected with the existing error. New coverage: a positive round-trip test (examples/test_unsafe_ptr_cast.reef) plus two EXPECT_FAIL negatives.
  • BUG-116: reef-baremetal/include/reef_baremetal.h now carries the C6 precise-GC shadow-frame contract — reef_gc_frame_hdr, a freestanding __reef_gc_top (a plain global; freestanding has no TLS runtime, so schedulers save/restore it per-thread and SMP ports go per-hart), and freestanding reef_string_check/reef_array_check routed to reef_panic. Every baremetal target has failed freestanding C compilation on any nontrivial program (string or array indexing) since the C6 landing on 2026-05-18; this was the gap. Baremetal kernels build again for the first time since then.
  • BUG-115: reef_objects_register_main_process() never allocated the main thread's continue_cond, so await() called directly from main() with an initially-false condition returned immediately instead of blocking — silent and 100% reproducible, but never caught because every prior await-from-main test only exercised the condition-already-true fast path. Found building the new parallel_hash example (a worker-AO/collector design where main blocks on collector.wait_for() until all workers finish). Fixed by allocating continue_cond in reef_objects_register_main_process() exactly as reef_objects_create_process() already does.
  • BUG-123: the three Limine example kernels (kernel_limine{,_aarch64,_riscv64}.reef) read their string's NUL terminator in print_string's while ch != '\0' loop without wrapping the read in unsafe. reef_string_check's bounds semantics are correct (index == strlen is out of bounds, matching the hosted runtime exactly, and matching core.str.length's identical idiom which does wrap in unsafe) — this was an example-source bug, exposed the moment BUG-116 let the freestanding build run far enough to hit it. Boot-verified under QEMU: kernel_limine.reef now prints its full banner through "System halted." before reaching its own halt loop.
  • run_tests.sh redirects example stdin from /dev/null. Under an interactive terminal, GNU timeout puts its child in a background process group; the first stdin-reading example (birthday_calc) got SIGTTIN on TTY read and stopped forever, freezing make test-all. Never seen in CI or piped runs, where stdin is already EOF.

Added

  • Freestanding-compile suite gate: every suite run now emits C for the three Makefile-built baremetal kernels and syntax-checks it freestanding — gcc natively for amd64, clang cross for amd64/arm64/riscv64 (no cross-gcc needed). Closes the suite gap that let BUG-116 ship silently: a codegen change that assumes hosted-only symbols now fails the suite instead of shipping unnoticed. kernel.reef is excluded (not built by any Makefile, and affected by an unrelated pre-existing asm-clobber codegen defect, BUG-122, that -fsyntax-only cannot catch anyway).
  • examples/parallel_hash.reef: concurrent SHA-256 over worker Active Objects, demonstrating the worker/collector wait_for() pattern that surfaced BUG-115.
  • Website examples library complete at seven entries (docs/marketing/examples/), including entry 06 (baremetal kernel), un-blocked by the BUG-116/BUG-123 fixes and backed by a genuine QEMU boot capture.

Known issues

  • BUG-122 (Low, pre-existing, not fixed in this release): the inline-asm clobber-list codegen emits invalid GCC 32-bit sub-register clobber names (r8d, r9d, ...) for the extended x86-64 registers R8–R15. GCC accepts only the unsuffixed spelling (r8, ...) in a clobber list; Clang normalizes and is unaffected. Found incidentally while building the freestanding suite gate above. Narrow blast radius: the only in-tree consumer is examples/baremetal_kernel/kernel.reef, which is not built by any Makefile and is not exercised by the test suite.

Test suite: 478 passed / 0 failed / 21 skipped (was 469/0/21 at v0.7.7; grew via the freestanding gate, BUG-119/BUG-115 regression coverage, and the run_tests.sh stdin fix's interactive-run determinism — no example or baseline was removed); runtime test_heaps 45/45 unchanged, ctest 9/9 unchanged.

0.7.7 — 2026-07-21

The licensing and internals release. The project's license is formally established, ten compiler bugs from the reference-documentation audit are fixed (five of them by rejecting unsound programs that previously compiled), the emitted C prelude is relocated into the runtime as real, tested C, and CI now runs the GC ASAN redzone suite on every push.

License

  • Reef is now licensed Apache-2.0 WITH LLVM-exception. Previously the LICENSE file was empty while packaging metadata claimed MIT. Apache-2.0 adds an explicit patent grant; the LLVM runtime-library exception waives attribution obligations that embedded runtime code would otherwise impose on compiled Reef binaries. This is the first release under the new license.

Changed — programs now rejected (previously accepted, unsound)

  • BUG-113: shared Active Object methods that mutate state are rejected — they broke the data-race-freedom guarantee.
  • BUG-114: a shared method calling an exclusive method on self is rejected — it deadlocked silently at runtime.
  • BUG-105: spawn on an Active Object method is rejected at typecheck (previously generated invalid C).
  • BUG-106: a required parameter after a default parameter is rejected at typecheck (previously passed --check, failed as a raw C error).
  • BUG-107: Active Object method-level generics are rejected at typecheck (previously passed, failed as a raw C error).

Fixed

  • BUG-108: set union/intersection/difference (+, *, -) now lower to bitwise operations — they emitted integer arithmetic, producing silent wrong answers.
  • BUG-109: closure capture analysis now walks string-interpolation nodes and 16 sibling expression forms it previously skipped, including match guards.
  • BUG-112: defer blocks now run at the implicit end of void Active Object method bodies instead of being silently discarded.
  • BUG-110: architecture names (amd64, arm64, riscv64) are contextual keywords instead of reserved words — two reef-os modules were unimportable.
  • BUG-111: removed the --gc-signals flag; its rtmin mode emitted a call to a runtime symbol that does not exist.

Changed — generated C

  • Every generated .c file starts with a provenance header: reefc version, source module, DO-NOT-EDIT notice, and license scoped in prose to the embedded runtime portions (deliberately no file-level SPDX tag — the user's translated code remains theirs).
  • The hosted builtin prelude (~500 lines: string concat/alloc, bounds checks, stdio wrappers, formatting) is no longer emitted inline. It lives in reef-runtime/include/reef_prelude.h and is pulled in with a single #include; the duplicated forward declarations are gone and the header is the single source of truth for builtin signatures. Compiled output is unchanged (preprocessed output verified byte-identical at relocation). Note: reefc 0.7.7 requires the matching runtime install — older runtime trees lack reef_prelude.h.

Added

  • Unit tests for the GC-adjacent prelude helpers (reef_string_concat, reef_alloc_string_buffer, reef_buffer_append): content, GC-survival, and abort-guard coverage, running under the ASAN GC-heap redzone build where an out-of-bounds write traps at the exact prelude line.
  • CI runs the ASAN runtime suite (make test-asan) on every push, PR, and tag. Stress tests scale down under sanitizers; the plain build keeps full scale and the strict BUG-027 performance gate.
  • docs/runtime/ARCHITECTURE.md: placement rule for compiler builtins (prelude vs libreef_runtime.a vs baremetal).

Documentation

  • README code examples fixed (neither compiled).
  • docs/language/reference audited against the compiler; the audit filed the nine bugs fixed above.
  • Research-page marketing copy revised.

Test suite: 469 passed / 0 failed / 21 skipped (was 460/0/22); runtime test_heaps 45/45.

0.7.6 — 2026-07-17

The hardening release. No new stdlib surface: same-named cross-module generics, full TOML value grammar, a large compiler-correctness campaign (15 bugs fixed, several found by compile-gating every documentation example), and both top-level language documents brought current.

Added

  • TOML value grammar (BUG-089): encoding.toml now validates scalar values — integers (sign, _ separators, 0x/0o/0b, no leading zero), floats (fraction/exponent, inf/nan), booleans, and full RFC 3339 date-times (all four TOML shapes, field-range checked; per-month day counts and leap years are not validated). A malformed unquoted value (port = eighty, a = 01, t = 25:00:00) is a positioned Err(InvalidInput) instead of a silently accepted raw string.
  • TOML structured values, flattened into the existing key-path model: dotted keys (a.b.c = 1 → key a.b.c; quoted segments supported), inline tables (k = { x = 1 } → k.x; nested tables recurse; TOML 1.0 rules enforced — single-line, no trailing comma, duplicate keys rejected), and inline arrays (k = [1, 2] → k.0, k.1 + a synthetic k.#len length marker; multi-line with comments, trailing comma legal, mixed types permitted, nested arrays/tables compose). New accessors toml_array_len_doc / toml_array_item_doc.
  • Same-named generics across modules (BUG-066): two modules may now each define a generic type with the same short name — each definition is independently specialized under a module-qualified C symbol. A module that can see both definitions cannot use the bare name (rejected as ambiguous, naming the candidate modules).
  • All-paths-return analysis (BUG-096): a value-returning function whose control flow can reach the end of its body without returning is now a compile error ("may reach the end of its body without returning a value") instead of silently returning garbage. A trailing bare expression remains a valid implicit return; panic/exit tails, both-branch if/else, all-arm match, and while true divergence are recognized.

Fixed

  • is type-test with aliases (BUG-104): p is Boxed (an alias to a struct) works; is against an alias resolving to a primitive is a clean compile error instead of a C build failure. The target of is is now validated at typecheck (it was previously ignored entirely).
  • Type aliases are usable (BUG-103): primitive aliases (type Age = int) were rejected by the return-type check, interpolation, comparisons, and arithmetic; all type-dispatch sites now resolve aliases. Two latent miscompiles fixed along the way: ==/+ on string aliases lowered to pointer operations, and arrays of primitive aliases were traced as pointer arrays. A cyclic type alias is now a clean compile error (previously hung the compiler). Casting to/from a primitive alias works (5 as Age).
  • Lambda defer scope (BUG-102): a defer inside a lambda ran at the wrong time (leaked into the enclosing function's cleanup, duplicated); lambdas now have their own defer scope with the same capture-before-defer return semantics as every other construct.
  • String interpolation single evaluation (BUG-100): ${expr} evaluated side-effecting expressions twice (once to measure, once to format); each interpolated expression now evaluates exactly once, in order, with GC-safe handling of freshly allocated strings.
  • defer/return semantics unified (BUG-101): the return value is now computed before deferred blocks run in every construct (plain functions, struct and trait methods, Active Object methods) — a mutating defer cannot change the returned value — and a GC-typed return value is kept rooted while defers execute. Also fixed: struct methods leaked defer registrations across methods and never flushed defers on implicit returns.
  • Generic Active Objects work (BUG-095): the documented Container[T] pattern (a generic AO assigning a T-typed parameter to a T-typed field) never compiled ("cannot assign 'T to T"); AO fields are now registered with the enclosing type parameters, and generic AO templates are correctly excluded from C emission.
  • AO method implicit returns (BUG-097): an exclusive/shared method ending in a bare trailing expression compiled but returned garbage; it now returns the expression's value, and defer blocks are flushed on all AO return paths with capture-before-defer semantics (a mutating defer cannot change the returned value).
  • Module-level Active Object / struct instances (BUG-098): methods called on a module-level let instance from a different function emitted un-mangled C calls ("call to undeclared function"); module-level bindings now carry their inferred types.
  • Match exhaustiveness for transitive enums (BUG-099): matching all variants of an Option/Result (or any enum) returned by another module no longer requires a direct import of the enum's defining module; the spurious "Non-exhaustive match" error is gone, and genuine misses now name the actual missing variant.
  • Generic type confusion (BUG-094): passing one module's Box[string] where another module's same-named Box[string] is expected is now a typecheck error (previously compiled silently through a C pointer-type warning and misread fields at runtime).
  • TOML table-array capacity (BUG-089 T6): exceeding 64 unique [[name]] table-arrays is now Err(OutOfRange) (previously silently collided later tables under index 0).

0.7.5 — 2026-07-15

The Result/Option release. Fallible stdlib operations no longer signal failure through in-band sentinels ("", -1, nil, silent lenient recovery). They now return result.Result[T, core.error.Error] when failure carries a reason, or option.Option[T] when a value may simply be absent. Pure functions, predicates, and constants are unchanged.

This is a breaking API change across most of the stdlib. Callers must bind the result and branch on it (result.is_ok/unwrap_ok, option.is_some/unwrap_or) instead of comparing against a sentinel.

Added

  • core.error.Error { kind, message, code } — the canonical structured error type, constructed with error.error(kind, msg). ErrorKind is an enum (InvalidInput, NotFound, IoError, Timeout, WouldBlock, OutOfRange, …).
  • core.option / core.result are now the canonical generic Option[T] / Result[T, E]. Unwrapping the wrong variant panics.
  • panic(msg) noreturn builtin (plus a freestanding reef_panic for baremetal targets).
  • Strict, validating JSON and TOML parsers: json_parse_doc / toml_parse_doc return Result[Doc, Error]; malformed input is a positioned Err instead of a silently-recovered Ok. New JsonDoc struct.
  • JSON \uXXXX escape decoding (BMP + surrogate pairs → UTF-8) on read.
  • net errno→ErrorKind mapping via socket.net_err().
  • Compiler intrinsics reef_feof / reef_ferror / reef_ftello / reef_fseeko (back the io/fs Result migration).

Changed

  • Parsers → Result[T, Error], rejecting invalid input: str.to_int, core.convert.* (toInt/toFloat/from_hex/from_binary/from_octal), time.time_parse_iso, date.date_parse. Trailing garbage is now an Err.
  • Environment: sys.env.get_env → Option[string] (was a "" sentinel; set-but-empty and unset now agree via the Option).
  • Collections lookups → Option[V]: vec, list, stack, queue, map, hashmap, growable, ordered_map, trie (get/pop/peek/first/last/dequeue/…).
  • io / fs → Result[T, Error]: file reads/writes/opens, seek/tell, stat metadata, mkdir/rmdir/cwd, symlink/hardlink/readlink, copy/remove/rename/tree ops. Console readLine → Option[string]. file_size/file_mtime widened to int64.
  • encoding decoders → Result[T, Error]: base64, hex, utf8, msgpack.
  • net → Result: tcp/udp/unix/dns/tls fallible ops; recv returns Ok("") for a clean EOF (distinct from Err). http: the public get/download API returns Result; an HTTP status is data (4xx/5xx → Ok, transport failure → Err), and download_file failures now carry a reason.
  • text.string_interner and core.array index/min/max → Option/Result.
  • JSON writer now escapes control characters < 0x20 (\b/\f/\u00XX) per RFC 8259 (was emitting raw bytes → invalid JSON).

Removed

  • Monomorphized core.result / core.option types and the core.result_generic / core.option_generic modules — use the canonical generic core.result / core.option.
  • json_parse_status / toml_parse_status (subsumed by Err(OutOfRange) on overflow) and TomlDoc.truncated.

Fixed

  • A long tail of monomorphization / type-inference compiler bugs surfaced by the migration, most of the form "a generic specialization was silently dropped → C-stage call to undeclared function": BUG-065, 067, 069, 070, 071, 072, 073, 074, 075, 076, 077, 078, 079, 080, 081, 082, 083, 084, 086, and 093 (the infer_expr_type if-expression-initializer case, closing that family). BUG-066 (same-name cross-module generic collision) gets a clear compile-time guard.
  • Stdlib: BUG-057/058/059/060/062/063/064 (graphics parsers, str length overflow, ctype), BUG-087 (msgpack integer sign-extension + int-width overflow), BUG-088 (JSON \u decoding), BUG-090 (JSON control-char escaping), BUG-091 (unix recv GC copy).

Security

  • The strict JSON/TOML parsers reject malformed structure instead of silently recovering, so a malformed document is a caught Err rather than a plausible-looking partial Ok. msgpack integer decode no longer sign-extends or overflows. str.repeat/str.replace guard length overflow with a panic.

0.7.2 — 2026-07-08

Security

  • Hardened the PNG, BMP, and TTF parsers against hostile input: bounds-checked reads, dimension/count caps, integer-overflow guards, a TTF hmtx out-of-bounds-write clamp, and a compound-glyph recursion limit.

Fixed

  • Graphics rendering correctness: arc direction (clockwise arcs no longer become their large CCW complement); non-zero winding fill (previously rendered nothing); blend modes add/multiply/screen now respect destination alpha.
  • Added 10 new headless graphics regression tests (memory-surface rendering, no X11 dependency) covering the parser-hardening and rendering-correctness fixes above.

Changed

  • canvas.fill now composites semi-transparent fills over the destination (previously overwrote) and defaults to the NonZero fill rule (was even-odd). Opaque convex fills are unchanged; self-intersecting/nested same-winding paths and translucent fills now render correctly.

Added

  • graphics.font.ttf.load_ttf_bytes([int]), graphics.canvas.set_fill_rule, graphics.backend.memory.surface_to_rgba8_bytes (all additive).
  • reefc auto-links -lz when compress.zlib is used.

0.7.1 — 2026-07-08

Fixed

  • Memory-safety codegen bug (BUG-055): a function returning an allocating expression (a struct literal, or a call that allocates) could free still-live locals mid-return. The shadow-frame epilogue that unroots a function's GC roots was emitted before the return expression was evaluated, so a garbage collection triggered during that evaluation could sweep objects reachable only through the frame being returned from. The return value is now captured while the frame is still live. Affected any allocating return expression, not just the graphics code that surfaced it.

Security

  • Allocator size guard (BUG-056): reef_heaps_new_array now rejects a negative array length, or an elem_size × count product that would overflow size_t, with a clear abort instead of unsigned-wrapping it into a multi-gigabyte allocation. Defense-in-depth backstop beneath any future codegen or library bug that produces a bad size; array lengths cross the allocator boundary as a signed value so a negative never silently becomes huge.

0.7.0 — 2026-07-06

Changed

  • TLS backend: Reef no longer bundles LibreSSL. The runtime TLS shim (libreef_tls.a) builds against the OS-provided OpenSSL (>= 3.0) and TLS-using programs link the system libssl/libcrypto dynamically. Building with HTTPS support now requires OpenSSL development files (e.g. libssl-dev). The net.tls API is unchanged. REEF_CA_BUNDLE still overrides the trust store; the system default store is used otherwise.
  • Abrupt peer close (EOF without close_notify) still reads as clean EOF, matching the previous backend's behavior.

Security

  • TLS trust configuration now fails closed: an explicitly configured CA source (REEF_CA_BUNDLE or tls_config_set_ca_file/_ca_path) that cannot be loaded is a hard error instead of silently falling back to the system trust store. REEF_CA_BUNDLE takes a CA bundle file (not a directory).
  • A verifying TLS connection with no server name is now rejected ("server name not specified") instead of silently skipping hostname verification.

Added

  • Offline TLS regression suite (test_tls, 50 checks against a loopback TLS server) covering verification defaults, the insecure knobs, fail-closed trust loading, socket-upgrade ownership, and EOF handling.

Removed

  • Vendored LibreSSL tree (third-party/libressl/).

0.6.5 — 2026-06-01

Changed

  • Source control and CI migrated from git to Isurus, the company's Mercurial forge with built-in CI; this is the first release published through Isurus.
  • scripts/bump-version.sh now drives Mercurial (hg commit/hg tag) instead of git.

0.6.4 — 2026-05-19

Closes the BUG-055 family. Defensive hardening only — no observable behavior change for current code. The 10 new warnings added here fire zero times across the 379-test suite, but stand as passive guards for future typechecker regressions.

Added

  • BUG-055 follow-up B55.3 (audit): silent TInt fallbacks in infer_expr_type now warn where they could plausibly misclassify a GC pointer. The original Var-lookup-miss at codegen_analysis.ml:196 and the Call-Var-callee fallback at line 247 (added in v0.6.1) were joined by ten more, covering the same shape across:

    • Method dispatch resolution failures (5 sites under Call → FieldAccess → Var — generic-type instance, regular-type instance, trait impl mangling, var-not-in-env)
    • Call with complex callee whose inferred type isn't TFunction
    • ArrayAccess on a value inferring to neither TString nor TArray
    • FieldAccess: field-not-found in struct, field-not-found in AO, obj-not-a-struct/AO, obj not a struct at all (4 sites)
    • GenericCall fn_name not in env
    • QualifiedGenericCall fn_name not in module ns, and qualifier not a known module (2 sites)

    Every other _ -> TInt site in the file (suffix-less IntLit, empty SetLit / empty Match-arm defaults, TypeId reflection, the Built-in-array unknown-method fallback) gained an explanatory comment but no warning — each is either a correct default or unreachable in typecheck-valid code.

Final BUG-055 family verdict

Release What landed
v0.6.1 Pre-pass binding threading — closed the let-alias warning + latent UAF
v0.6.2 ForEach element + collection precise rooting — closed the foreach SIGSEGV
v0.6.3 Match pattern binding precise rooting — closed the 24% block-reuse corruption
v0.6.4 Audit of remaining silent TInt fallbacks — defensive guards for future regressions

The compiler now has consistent precise-marking coverage across all the binding-introducing constructs Reef supports (let / mut / foreach element / match pattern) and active warnings for every silent-fallback site that could plausibly degrade a GC-pointer expression to TInt.

Final suite

  • Compiler: 379 passed / 0 failed / 22 skipped (unchanged from v0.6.3).
  • Runtime: 38 / 38.

0.6.3 — 2026-05-19

Closes the second follow-up from BUG-055: Match pattern bindings have the same precise-marking gap as the let-alias (v0.6.1) and foreach-elem (v0.6.2) cases.

Fixed

  • BUG-055 follow-up F2: Match pattern bindings unrooted. Arms like match opt | Some(x) => use(x) previously emitted x as a plain C local outside __reef_locals. When the matched value's heap payload was held ONLY through the enum's union — for example let opt = make_some_of_string(i) where the string is allocated inside the constructor and never named anywhere else — body-internal allocations could sweep the payload AND reuse its block, leaving x reading garbage content.

    Verified pre-fix: 12/50 wrong-suffix corruptions on a 50-iter Option[string] match with allocation pressure tuned to provoke block reuse (REEF_HEAP_BLOCK_SIZE=262144). Post-fix: 0/50.

    Two-part fix:

    1. Pre-pass (codegen_util.ml::collect_tracked_vardecls_in_stmt Match arm) now extracts pattern bindings via a new pattern_variant_payload_types helper that resolves variant payload types from the scrutinee's enum binding. Handles TGeneric type-parameter substitution for monomorphized enums (e.g., Option[string] → Option_string with payload TString). PVar bindings (whole-scrutinee) get the scrutinee's type; PConstructor bindings get one payload type per slot.

    2. Emit-pass (codegen.ml::gen_match_arm) at three sites — the PVar default arm (~line 2086), the PConstructor single-binding case (~line 2154), and the PConstructor multi-binding case (~line 2192) — assigns through __reef_locals.<var> when the binding is in gc_tracked_locals, instead of declaring a plain C local. Without this, the pre-pass classification took effect but the body's Var references (routed through __reef_locals via the existing gc_tracked_locals check at line 261) read uninitialized memory.

    Regression test: reef-compiler/examples/test_gc_match_pattern_alias.reef carries // ENV: REEF_HEAP_BLOCK_SIZE=262144 to provoke GC + block reuse reliably.

Final suite

  • Compiler: 379 passed / 0 failed / 22 skipped (was 378 — +1 for the new regression test).
  • Runtime: 38 / 38.

Remaining BUG-055 follow-ups

  • Categorise the ~22 silent -> TInt fallbacks left in infer_expr_type. The Call-arm sibling I added a warning to in v0.6.1 has now run across three versions of the suite and never fired, so the others are probably similarly inert; each still warrants a one-line "when can this fire" note.

0.6.2 — 2026-05-19

Closes the first follow-up from BUG-055: the foreach-element precise-marking gap. Pre-v0.6.2, for x in [a, b, c] over a non-rooted collection was a hard UAF when the loop body allocated.

Fixed

  • BUG-055 follow-up F1: foreach element + collection unrooted. Sibling of v0.6.1's let-alias fix. ForEach codegen previously emitted both __arr (the collection alias) and the loop element variable as plain C locals — body-internal allocations could sweep the array's contents while the loop was still iterating. Trigger pattern was foreach over an array literal, a function-call result, or any other collection not held in a rooted slot. Pre-fix repro SIGSEGV'd at str.length(s) reading post-sweep poisoned memory (0xDEADDEAD).

    Two-part fix:

    1. Pre-pass (codegen_util.ml::collect_tracked_vardecls_in_stmt) now infers the element type from the collection expression (TString → TChar, TArray inner → inner), registers the loop variable in ctx.type_env, and seeds the GC-tracked-locals set when the element type is GC-pointer-shaped.
    2. Emit-pass (codegen.ml::gen_stmt ForEach arm) wraps the array-iteration block in a transient reef_gc_frame_hdr that roots __arr, and routes a GC-tracked element through __reef_locals.elem instead of a plain C local. String iteration is unchanged (the elements are chars, not GC pointers).

    Regression test: reef-compiler/examples/test_gc_foreach_string_alias.reef — array-literal foreach with body-internal allocation pressure.

Final suite

  • Compiler: 378 passed / 0 failed / 22 skipped (was 377 — +1 for the new regression test).
  • Runtime: 38 / 38.

Remaining BUG-055 follow-ups

Both deferred — not blocking and not yet known to bite:

  • Verify Match pattern bindings (Some(x) ->) reach ctx.type_env. The match codegen is more involved; needs an audit trace.
  • Categorise the ~22 silent -> TInt fallbacks left in infer_expr_type (the Call-arm sibling I added a warning to in v0.6.1 hasn't fired once across the suite, so the others are probably similarly inert, but each warrants checking).

0.6.1 — 2026-05-19

Fixes BUG-055 — a codegen pre-pass asymmetry that had been latent since v0.5.0. The visible symptom was a flood of "Could not infer type for variable 'X', defaulting to int" warnings on most non-trivial Reef programs (118 sites in the shipped stdlib, 244+ in a downstream package manager); the deeper issue was a precise-marking gap for GC-pointer aliases.

Fixed

  • BUG-055: pre-pass binding-threading asymmetry. codegen_util.ml's collect_tracked_vardecls_in_stmt (the pre-pass that decides which locals get lifted into __reef_locals for precise GC tracking) called infer_expr_type on each VarDecl's initializer but never added the resulting binding to ctx.type_env. The later gen_stmt emission pass did. So Pass 1 saw an incomplete env: any let X = Y where Y was a local from a prior stmt in the same scope hit the Var-lookup fallback, warned spuriously, and silently classified X as TInt. When X was actually a string or struct pointer, the misclassification meant X got emitted as a plain C local outside __reef_locals — invisible to the chain walker, swept by any GC during X's live range. Same family of UAF as C6.3 case 01 (nested call args) and BUG-053 (interior-pointer heap-walk corruption).

    Fix: thread the binding forward in the pre-pass. Hashtbl.add allows duplicates and lookup returns the most recent, so gen_stmt's later add_binding for the same name is harmless. ~10-line change in codegen_util.ml. The 118 stdlib warnings, the 244+ downstream warnings, and the latent UAF all close at once.

    Reframing: the original BUG-055 report claimed v0.6.0 introduced this via a stricter typechecker. Verified false — v0.5.20 emits byte-for- byte identical warnings; the warning was added in da976e9 ("Phase 4: Codegen refactor") shipped in v0.5.0. The warning is from codegen, not the typechecker. Severity revised Low → Medium for the latent UAF angle.

Added

  • test_gc_let_alias.reef regression test exercising the string-chain let t = s pattern under deliberate allocation pressure. --emit-c of this test now shows t in the __reef_locals struct (i.e., precisely tracked by the GC).
  • Warning on infer_expr_type Call-arm silent fallback at codegen_analysis.ml:247 — the sibling site of the Var-arm fallback at line 196. Fires when a Call's callee is in scope but not resolvable as an FnBinding/GenericFnBinding/lambda. Currently unreachable across the entire test suite; lands as a passive guard against future builtin/FFI binding propagation gaps.

Internal

  • Plan used the Level 2 (tactical fix + audit) approach. Audit identified three follow-up tasks (filed as deferred work):
    • ForEach (idx, elem, coll, body) doesn't add elem to ctx.type_env in either pass. Same family of bug for for x in [string] patterns under allocation pressure.
    • Match pattern bindings (Some(x) ->) need a separate trace to confirm they reach ctx.type_env via the gen_stmt match arm.
    • ~22 remaining silent -> TInt fallbacks in infer_expr_type (mostly nested FieldAccess paths) warrant categorisation by reachability + can-this-be-GC-typed.

Final suite

  • Compiler: 377 passed / 0 failed / 22 skipped (was 376 — +1 for the new regression test).
  • Runtime: 38 / 38 tests passing.

0.6.0 — 2026-05-19

Unified GC overhaul. Reef now has precise stack marking via per-thread shadow-frame chains, canonical user pointers (no interior-pointer slow path), and cooperative stop-the-world (SIGURG tickle instead of async-signal-based suspension). The transitional conservative stack scan that bridged the rollout is gone. Closes BUG-053 and BUG-054.

This is the largest single release since 0.5.0 (41 commits, ~300 net lines removed from the runtime). Compiler ABI is unchanged for hosted programs that don't reach into the runtime header; downstream consumers that provided their own conservative-scan implementations need the migration steps in the downstream notes below.

Added

  • Precise stack root marking (Phase F). Every Reef-generated function emits a __reef_frame containing a reef_gc_frame_hdr and a void** roots[] array, linked through a __thread reef_gc_frame_hdr* __reef_gc_top chain. reef_objects_scan_all_stacks walks the chain for every registered process (using the GC-calling thread's live top and each parked thread's process->gc_top_at_safepoint snapshot).
  • Cooperative stop-the-world. New reef_objects_gc_safepoint parks the calling thread on gc_resume_cond after publishing its chain top and incrementing gc_acked; the GC thread waits for gc_acked == target_count before scanning. Quantum-check sites and a SIGURG tickle path replace the old async-signal-based STW.
  • REEF_HEAP_BLOCK_SIZE env hook for test-driven heap-expansion scenarios (default 64 MB). Used by examples/test_gc_heap_expansion.reef to force many expansions cheaply.
  • REEF_GC_STRICT env var to abort (instead of log) on heap-invariant violations during GC.
  • Two new regression tests that lock in the precise-GC paths: test_gc_nested_concat.reef (case 01 — nested string concat) and test_gc_ao_precise_chain.reef (Gate A — AO worker chain publish).
  • C-side regressions for BUG-054 in reef-runtime/tests/test_heaps.c drive the region-list lookup directly without depending on what addresses mmap returns.

Fixed

  • BUG-053: interior-pointer GC vs. array stack temporaries — closed by Phase A's canonical pointer layout. Every user pointer is now at block_base + REEF_HEAP_BLOCK_OFFSET, where block_base is aligned to BLOCK_SIZE. The find_containing_object slow path and the interior-pointer recovery it depended on are gone (also a slow-path source of BUG-034-class re-corruption).
  • BUG-054: heap-expansion clobber in allocate_from_region — the allocator used to assume insert_memory_block appended to the tail of the region list and re-acquired the region with a tail walk. Since insert_memory_block sorts by start address, the new region is not necessarily at the tail; mmap returning a lower address would make the tail walk return the wrong region and clobber an unrelated block's metadata. Fixed by introducing reef_heaps_find_memory_block_by_start and looking up by begin_block_adr.
  • C6 case 01 (nested GC-allocating call args) — codegen for reef_string_concat operands now lifts both args into chain-rooted slots in a transient __nest_frame before the call. Closes the unrooted window where an inner concat's result sat only in the outer call's argument slot during the outer call's allocation. Affected test_module_array_init_canonical, test_gc_struct_stress, and test_toml_sized when precise marking ran without the safety net.

Removed

  • Transitional conservative stack scan (C6.4). The four runtime symbols reef_heaps_register_candidate, reef_heaps_scan_memory_range, reef_heaps_check_candidates, reef_heaps_clear_candidates are deleted, along with the candidate array, REEF_MAX_CANDIDATES, REEF_MAX_OBJECT_SIZE, and reef_heaps_is_valid_heap_pointer. The // Conservative Stack Scanning (transitional…) section of reef_heaps.h is gone. Precise marking is the sole source of stack roots; there is no longer a safety net.
  • get_current_sp arch-specific inline-asm helper — was only used by the conservative scan.
  • Signal-based STW. reef_machine.c's old SIGUSR1/SIGUSR2 thread- suspension path is replaced by the cooperative protocol; signal handlers and the associated state are gone.
  • find_containing_object and the interior-pointer recovery slow path (BUG-053 root cause).
  • reef_heap_block_t.next field renamed to .length (BUG-053 prep — the field stored array length all along; the name was misleading and contributed to the bug).

Changed

  • Active Object direct root in reef_objects_find_roots now marks via reef_heaps_mark_root(process->obj) instead of going through the (now-deleted) candidate API.
  • reef_heaps_new_array / new_ptr_array write block->length at allocation time; traverse_mark_stack reads it from the header instead of an interior word. Removes one source of interior-pointer arithmetic.
  • Coalesced-into block headers are scrubbed with a DEBUG_VALUE pattern (A2-style) so any stale traversal sees a recognisable poison value instead of stale type pointers.

Internal

  • Phase F design with seven sequential phases (A–G).
  • C6 plan with three verification gates (AO chain audit, FFI boundary, downstream consumer) all closed before C6.4 landed.
  • C6 case catalog — the empirical precise-marking-gaps catalog. Only one category surfaced (nested GC call args); BUG-054's predicted ArrayLit/MapLit/ match-scrutinee/closure-capture patterns didn't regress under the hook.
  • Liveness analysis scaffold and stack-map data structures in the runtime (reef_machine.c registry) and compiler (liveness.ml stub) — placeholders for the future move from per-frame roots[] lists to fully-precise per-PC stack maps. Not used in 0.6.0; lays groundwork for a later milestone.
  • REEF_DISABLE_CONSERVATIVE_SCAN env hook (C6.1) was introduced and then deleted in the same release once C6.4 made it redundant. Its job was to surface precise-marking gaps empirically during the campaign.

Downstream / migration notes

  • Freestanding consumers with their own runtime fork: adopting v0.6.x is a one-time runtime port (chain TLS + safepoint publish + walker) — the only substantial step; the C6.4 header deletion needs a small consumer-side compat header for the four removed prototypes.
  • Hosted consumers (illumos/Linux) link the hosted runtime and pick up the changes automatically. No source changes needed.

0.5.21 — 2026-05-13

Closes BUG-052, RFE-003, and RFE-004 — all reported by a downstream OS project. Also consolidates six private copies of int_to_str / int_to_string across stdlib encoding/net modules.

Fixed

  • BUG-052: module-level new [T](n) produces invalid C static initializer — Codegen now detects non-constant module-level initializers (calls, new, struct literals, interpolated strings, etc.) and defers them to a runtime call sequenced AFTER reef_heaps_init and GC root registration, BEFORE the user's main. Works across imported modules. Also corrected the --no-gc path to call reef_heaps_init and emit deferred inits when needed.

Added

  • RFE-003: hex / octal / Unicode escape sequences in string literals — Both regular and triple-quoted strings now support \xNN (2-digit hex byte), \NNN (1–3 digit octal byte), and \u{N+} (Unicode codepoint, UTF-8 encoded; rejects surrogates and codepoints > 0x10FFFF).
  • RFE-004: str.from_int and str.to_int — core.str gains from_int(n: int): string (lifts the common pattern across stdlib; INT_MIN-safe) and to_int(s: string): ResultInt (strict — rejects empty input, non-digits, trailing garbage, and integer overflow). The pre-existing core.convert.to_string / core.convert.toInt are unchanged; see docs/core/str.md for when to use which.

Refactored

  • Six stdlib modules (encoding/json, encoding/yaml, encoding/html, encoding/toml, net/http, net/extras) no longer carry private copies of int_to_str / int_to_string; they now call str.from_int. Four of those modules also drop private append_string or str_append helpers in favor of str.concat. Net: ~280 lines removed across stdlib.

Internal

  • New codegen-context field module_initializers : (string list) ref, parallel to gc_root_registrations, accumulates deferred initializers across all imported modules.
  • New helper is_c_constant_initializer in codegen.ml detects which AST forms can serve as C static initializers and which must be deferred.

Reporter credit

  • BUG-052, RFE-003, RFE-004 reported by a downstream OS project (originating from boot-UI visual-pass work).

0.5.20 — 2026-05-04

Closes BUG-051 — the broader gap surfaced during BUG-050 (v0.5.19): every typecheck class except struct-literal validation was silently skipped for imported module bodies. The broad-fix attempt in v0.5.19 surfaced ~49 latent stdlib issues; this release audits and resolves all of them.

Fixed

  • BUG-051: full typecheck on imported module bodies — Driver (bin/reefc.ml) now runs check_module_with_imports on every loaded module rather than just the entry point. Catches return- type mismatches, undefined functions, argument-count mismatches, field-assignment type errors, and every other typecheck class for imported module code at compile time instead of runtime/link-time.

Typechecker loosenings

The strict baseline was preventing routine stdlib patterns that were silently passing only because imported bodies were never checked. Each loosening is an explicit, defensible language-design choice that matches C/Rust semantics where applicable:

  1. nil in == / != outside unsafe — comparison itself is safe; only deref does. if conn != nil no longer requires unsafe.
  2. Bitwise ops on byte/uint8 with int via integer promotion — routine in crypto code: byte_val & 255, byte_val | 0x80.
  3. Unary negation extended to all signed numeric types — int8/ int16/int32/int64, plus float/float32/float64. Was int-only.
  4. Numeric as casts no longer require unsafe — was forcing wrappers like unsafe { value as int } for routine int64→int narrowing. Float and char casts also accepted.
  5. Implicit widening int→int64 / int32 — no information loss. progress.elapsed_ms = 0 works without 0i64 literal.
  6. Comparison ops allow any int-pair / float-pair — e.g. elapsed_ms < 1000 where elapsed_ms is int64.
  7. Char compares against ints — routine ASCII checks like c >= 65 and c <= 90.
  8. Char ↔ uint8 coercion in unsafe — both are single bytes; stdlib byte/string interop.
  9. Arithmetic ops use C-style integer/float promotion — int * int64 → int64 (wider type wins). Mirrored in codegen_analysis.infer_expr_type so untyped let bindings and string interpolation format specifiers infer the same wider type (otherwise silent narrowing in C output).
  10. reef_array_data_ptr added to typechecker builtin table — function is auto-emitted as inline C by codegen but the binding was missing, causing undefined-function errors in stdlib io/file and crypto/sha256 once their bodies were typechecked.

Stdlib fixes (genuine bugs surfaced by the broad typecheck)

  • encoding/toml.reef:352: let digit_char → mut digit_char (the variable is reassigned in the unsafe block immediately below; pre-fix the bug masked itself because the body was never checked).
  • io/file.reef:170,181: return [] → return new [byte](0) — empty array literal can't be element-type-inferred from return context with current typechecker.
  • net/http.reef:2516,2520: restructured a uint8/int mix where the new arithmetic promotion would have triggered narrowing- assignment errors on every iteration (case-folding loop).

Test infrastructure

  • tests/run_tests.sh: routed EXPECT_FAIL files in compiler-examples/ through test_check instead of skipping them. Previously every EXPECT_FAIL file under reef-compiler/examples/ was silently unrun, masking missing-assertion bugs.

Tests

  • tests/cross_module/bug051/: 2 negative tests verifying full typecheck fires on imported modules (return-type mismatch, argument-count mismatch).
  • examples/test_bug_051_loosenings.reef: 9 positive assertions covering each loosening — including the exact case that caught the pre-shipping codegen-analysis blocker (int + int64 → must promote to int64 in BOTH typechecker AND infer_expr_type).
  • examples/test_bug_051_narrowing_rejected.reef: negative test locking the safety boundary — int64 → int without as must still error.

Internal

  • Suite: 362 → 368 passed (+6 from new tests). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).
  • Code review caught a critical typechecker/codegen-analysis divergence in arithmetic promotion (Blocker 1) before tagging — the shipped fix mirrors the promotion logic in both passes.

0.5.19 — 2026-05-04

Single-bug release closing BUG-050 (filed by repoman v0.1.1 against v0.5.18). High-severity silent-acceptance bug: the typechecker's missing-field check on struct literals fired correctly in single-file mode but was skipped when the literal was inside an imported module's function body. Build passed; the uninitialized field caused runtime segfaults when read.

Fixed

  • BUG-050: missing-field check on struct literals skipped inside imported module bodies — check_module_with_imports validates ONLY the bodies of the module it's called on. Imported modules' types/signatures are loaded into namespaces (so the consumer can reference them), but their function bodies — including struct literals inside those bodies — were never typechecked. An incomplete Outer { ... } literal in an imported module compiled cleanly, and the consumer segfaulted at runtime when the uninitialized field was read.

    Fix: new Typechecker.validate_struct_literals_with_imports pass that walks every imported module's expressions looking specifically for StructLit nodes and runs the duplicate/unknown/missing field checks. Uses a minimal context (module's own type defs + imported type names — just enough for lookup_binding to find the struct) rather than invoking the full typechecker. The full-typecheck approach was attempted and reverted because it surfaced ~49 unrelated pre-existing strictness issues in stdlib modules that had been silently passing because their bodies were never checked.

    Driver change in bin/reefc.ml: after the main module's full typecheck, iterate every loaded module via Hashtbl.iter and run the focused validator.

    Walker covers: StructLit, Call, GenericCall, QualifiedGenericCall, GenericConstructor, New, NewGeneric, NewArray, BinOp, UnOp, If, Match (expression form), ArrayLit, ArrayAccess, ArraySlice, FieldAccess, Spawn, TypeTest, TypeCast, TypeName, TypeId, ToMap, SetMember, SetLit, MapLit, InterpolatedString, Lambda. Statement shapes: VarDecl, Return, ExprStmt, Assignment, If, Unless, While, DoWhile, DoUntil, Loop, UnsafeBlock, Defer, Await, For, ForEach, Match (statement form) — the last three were added in code review followup after empirical confirmation that data-construction patterns inside for x in arr / for i in 1 to N / match ... end match were the most likely real-world bug sites and would have silently compiled as initially shipped.

Test infrastructure

  • tests/run_tests.sh cross-module runner now supports the // EXPECT_FAIL: <substring> marker (mirrors the existing single-file negative-test mechanism). Used by BUG-050's four negative tests.

Known follow-up

  • BUG-051: the BUG-050 fix is targeted at the struct-literal bug class. Every OTHER typecheck (return type mismatches, field-assignment type errors, undefined variables, etc.) is still skipped for imported module bodies. The broader fix needs a stdlib audit first to resolve the ~49 latent strictness issues the attempted broad fix surfaced. Workaround until then: library authors should run reefc <module>.reef directly to typecheck their module bodies in single-file mode.

Internal

  • Suite: 357 → 362 passed (+5 from new tests in tests/cross_module/bug050/: 1 positive + 4 negatives covering outer scope, ForEach, For, and Match-statement bodies). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).

0.5.18 — 2026-05-02

Single-bug release closing BUG-049, a regression introduced by v0.5.17's new process_run wrapper. Same family as BUG-042 (struct/AO method short-name collision) but on a different resolver code path.

Fixed

  • BUG-049: parameter named args (or any module name) silently resolved as the module, not the parameter — When v0.5.17 added process_run(program, args: [string]) to fix BUG-048, the parameter name args collided with the common import sys.args as args (or import sys.args) pattern. The resolver matched args against module_namespaces BEFORE checking whether args was a local/ parameter binding, so args.length() inside process_run's body emitted reef_sys_args_length() (which doesn't exist), breaking every consumer's C compile as soon as they imported sys.process.

    Fix combines two changes:

    1. Resolver gate (the principled fix) — extracted Types.binding_shadows_namespace helper applied uniformly at 12 sites across lib/typechecker.ml, lib/codegen.ml, and lib/codegen_analysis.ml. The helper returns true for any binding kind that should shadow a module of the same name: VarBinding, ConstBinding, ModuleVarBinding, and ModuleMutBinding. Without this, module-level mutable variables and constants could also silently misresolve.

    2. Stdlib rename (defense in depth) — process_run / process_run_exec parameter args → arg_list. Strictly redundant after the gate but reduces the collision surface for the most common case.

    Test fixture update: examples/test_path.reef had a local var path shadowing the imported io.path module — pre-fix the resolver let the module name win silently, post-fix the local correctly shadows. Renamed local to p (the test was relying on the buggy behavior).

    New test examples/test_bug_049_param_shadows_module.reef exercises parameter, local, and module-level const shapes with collision- prone names (args, str).

Internal

  • Code review identified and addressed three gap classes the initial fix missed: ungated sites in codegen_analysis.ml, missing ModuleVarBinding / ModuleMutBinding in the codegen gate, and an ungated New (type_name, args) arm in the typechecker. The shared helper resolves the asymmetry by giving all sites a single source of truth for the shadowing predicate.
  • Suite: 356 → 357 passed (+1 from new BUG-049 regression test, +0 net from the test_path fixture rename). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).

0.5.17 — 2026-05-02

Single-bug patch release closing BUG-048 (filed by repoman v0.1 against v0.5.16). API ergonomics fix for sys.process — the existing spawn primitives followed raw POSIX execve(2) semantics but the parameter name args implied the Python/Go convention, leading to confusing first-time-user errors.

Added

  • sys.process.process_run(program, args) — high-level wrapper that auto-prepends program as argv[0], matching Python subprocess.run / Go exec.Command / Node child_process.spawn convention. Use this for typical spawning; the runtime handles the argv[0] convention for you.
  • sys.process.process_run_exec(program, args) — exec variant (replaces current process; does not return on success).

Changed

  • process_spawn and process_exec parameter args → argv. These remain POSIX-purist primitives (caller supplies the full argument vector). The rename makes the convention visible at the signature; tightened docstrings spell out: argv[0] is conventionally the program name; the runtime does NOT prepend program for you. Prefer process_run / process_run_exec for typical use; reach for process_spawn / process_exec only when you specifically need to override argv[0] (busybox-style multi-call binaries, argv[0]-sniffing tools, etc.).

    Backward-compatible: Reef has no keyword arguments, so the parameter rename is purely cosmetic at every call site. Existing callers that used the working POSIX form process_spawn("stat", ["stat", arg]) continue to work unchanged.

Internal

  • Code review verified that the runtime's execvp call passes the argv array through directly (no wrapper transformation), so the documented behavior matches actual emission.
  • Suite: 355 → 356 passed (+1 from test_bug_048_process_run.reef, which exercises both process_run shapes and the preserved process_spawn POSIX form). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).

0.5.16 — 2026-05-01

Single-bug patch release closing BUG-047, the qualified struct literal parser asymmetry filed by repoman v0.1 against v0.5.13. The v0.5.15 attempt was deferred after a naive fix introduced an LR(1) state-90 conflict that broke all obj.field access; this release ships the researched-and-tested fix.

Fixed

  • BUG-047: module.Type { ... } struct literal syntax error — lib.Foo { x: 1 } previously failed to parse with a generic syntax error; only the bare form Foo { ... } (with optional qualified type annotation let f: lib.Foo = ...) worked. Fix in lib/parser.mly: add the rule at postfix_expr level rather than primary_expr level —

    | obj = postfix_expr LBRACE fields = ... RBRACE
      { match obj.expr_desc with
        | FieldAccess ({ expr_desc = Var _; _ }, type_name) ->
            make_expr (StructLit (type_name, fields)) ...
        | _ -> failwith "..." }
    

    This works because the qualifier (lib.Foo) reduces through the existing postfix_expr DOT IDENT FieldAccess path before the LBRACE disambiguation, leaving state 90 (the conflict site of the failed v0.5.15 attempt) untouched. Out-of-scope shapes (e.g. obj.method() { x: 1 }) are rejected with a clear parse-time error.

    The bare workaround pattern let b: lib.Foo = Foo { x: 2 } continues to work for backward compat. Plain obj.field access — which the v0.5.15 attempt accidentally broke — works as before.

Investigation notes

The v0.5.15 attempt placed the rule at primary_expr level as IDENT DOT IDENT LBRACE field_init_list RBRACE. Menhir reported one new shift/reduce conflict in state 90 (after reading just IDENT): adding a shift on DOT action suppressed the reduce IDENT → primary_expr action that the existing FieldAccess rule depends on, breaking ALL obj.field expressions. Menhir's default "shift wins" resolution is what triggered the regression.

Approach validated against the OCaml compiler's own grammar (which sidesteps the issue with Module.{ ... } syntax — adopted as Reef's direct fix would have required a syntax change), Menhir documentation on shift/reduce conflicts, and Rust/Go's contextual-parser-state techniques (not applicable to LR(1)).

Test: tests/cross_module/bug047/ exercises qualified head, bare workaround, and plain field access (locks down the no-regression contract).

Internal

Suite: 354 → 355 passed (+1 from new regression test). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).

0.5.15 — 2026-05-01

Two-bug patch release closing BUG-043 (filed during BUG-042 work in v0.5.13) and BUG-046 (filed by repoman v0.1 against v0.5.13). A third filed bug, BUG-047 (qualified struct literal syntax), was attempted but deferred — the parser fix requires more careful design than this batch could accommodate; investigation notes are in the bug record.

Fixed

  • BUG-046: cross-module monomorphization fixpoint missing in driver — When module B's body registered an instantiation of a generic defined in module A, but A had already been monomorphized before B was scanned, A's specialization was never emitted. Concrete failure: a consumer importing a module that exports a Result[T, E]-returning function would fail to compile (with C errors like unknown type name 'Result_Foo_string') if the consumer never called the function — the producer's make_ok() body referenced Result_Foo_string but the typedef was missing because nothing triggered the cross-module emission. Fix in bin/reefc.ml: wrap the per-module monomorphize_module loop in an outer fixpoint that re-iterates over every module until count_instantiations is stable. 50-iteration safety cap with a warning. New regression test at tests/cross_module/bug046/.

  • BUG-043: AO method generic return-type resolution gap — let r = ao.method_returning_generic() generated int r = AO_method(...), breaking downstream calls to generic helpers like is_ok/unwrap_ok. Root cause: codegen's per-decl type_env registration set struct_methods=[] for ActiveObjectDecl while ClassDecl correctly populated the methods list. Plain struct methods worked; AO methods fell through to the TInt fallback and any return type whose downstream interpretation mattered (Result, bool, etc.) got a wrong-typed VarBinding. Fix in lib/codegen.ml: mirror ClassDecl's method-registration block for ActiveObjectDecl (also registers init to keep parity with the typechecker). New regression test examples/test_bug_043_ao_method_generic.reef exercising int, bool, and Result return types.

    Side fix: examples/test_ao_chained_calls.expected was documenting the BUGGY behavior — bool method calls printed 1/0 because bool was silently coerced to int through the TInt fallback. Updated to the correct true/false. The test was passing because the bug masked itself.

Deferred

  • BUG-047: module.Type { ... } struct literal syntax error — Low-severity parser asymmetry; clean workaround exists (bare literal with qualified type annotation). The fix attempt added a parallel IDENT DOT IDENT LBRACE field_init_list RBRACE rule, but Menhir reported a new shift/reduce conflict that broke the existing FieldAccess(Var, IDENT) path used by the workaround pattern itself. Investigation notes captured for a future release that can revisit the parser carefully.

Internal

  • Suite: 352 → 354 passed (+2 from new regression tests). One pre-existing GC stress flake remains; test_save_png still in the -lz skip group (v0.5.14).

0.5.14 — 2026-05-01

Stdlib feature-completeness release. The 2026-05-01 audit across all 21 stdlib packages identified read/write asymmetries (parsers without serializers, loaders without writers) and absent-but-expected functions. Six high-impact gaps are closed in three sprints, plus two pre-existing bugs (BUG-044, BUG-045) surfaced during code review.

Added

Sprint 1 — small wins (4 modules):

  • encoding/csv: csv_format_line(fields: [string], n: int): string — array-form formatter handling arbitrary widths with the same RFC-4180 quoting/escaping as the existing hardcoded csv_format_line_2/3/4 (which remain for backward compat).
  • encoding/msgpack: pack_float/unpack_float, pack_binary/unpack_binary, unpack_binary_len, is_float/is_binary predicates. Closes the round-trip gap where pack/unpack pairs existed for nil/bool/int/string/array/map but not float or binary blobs. Uses runtime.reef_float64_to_bits for IEEE-754 conversion. Auto-picks bin8/bin16/bin32 by length.
  • encoding/utf8: utf8_validate(s: string): bool — whole-string validator with DFA-style range checks per RFC 3629 (catches overlong, surrogates, invalid continuations, out-of-range codepoints). Common API-boundary check.
  • encoding/ini: IniBuilder + ini_serialize writer with mappings, comments, and value-typed setters (ini_set / ini_set_int / ini_set_bool). Mirrors the TOML builder pattern.

Sprint 2 — parsing pairs (3 modules):

  • time: time_parse_iso(s: string): Result[time_t, string] — accepts YYYY-MM-DDTHH:MM:SS with optional fractional seconds, optional timezone (Z, +HH:MM, -HH:MM), and RFC 3339 tolerance (lowercase t/z, space separator). Fractional seconds parsed but discarded (time_t is 1-sec resolution). Validates leap days using existing days_in_month (e.g. 1900-02-29 correctly rejected as a non-leap century).
  • time/date: date_parse(s: string): Result[date_t, string] — strict YYYY-MM-DD parser with leap-aware day validation. Rejects shorthand (2026-5-1), wrong separators (2026/05/01), trailing whitespace, and out-of-range months/days.
  • encoding/yaml: YamlBuilder + yaml_serialize document writer scoped to the JSON-equivalent subset of YAML 1.2. Supports flat mappings, nested mappings (any depth), lists of scalars, lists of mappings, automatic value AND key quoting (closes Sprint 2 review finding), 2-space indent. Out of scope (deferable to v0.5.15+): anchors, aliases, explicit type tags, multi-document streams, block scalars, complex keys, flow style.

Sprint 3 — graphics persistence (2 modules):

  • graphics/image/png: save_png(filepath, img): bool and encode_png_bytes(img): [byte] — pure Reef encoder using existing compress.zlib (deflate) and crypto.crc32. Auto-maps Reef PixelFormat to PNG color type (RGBA→6, RGB→2, gray→0, gray+alpha→4). Always 8-bit depth, no interlace, no palette, filter type None per scanline. CRCs validated byte-perfect against Python's zlib.crc32 in code review. test_save_png joins the existing -lz skip group in run_tests.sh.
  • graphics/image/bmp: save_bmp(filepath, img): bool and encode_bmp_bytes(img): [int] — uncompressed BMP writer. Auto-selects 32bpp BGRA when image has alpha, 24bpp BGR otherwise. Standard 14-byte file header + BITMAPINFOHEADER + bottom-up rows with 4-byte row padding for 24bpp.

Fixed

  • BUG-044: msgpack had no entry point for the spec's 0xCF (uint64) and 0xD3 (int64) type codes — values requiring 64-bit representation (timestamps in milliseconds, large counters, file sizes >2GB) couldn't round-trip. Adds msgpack_pack_int64(value: int64, ...) and msgpack_unpack_int64(buf, offset): int64. Existing pack_int/unpack_int unchanged (correct for int32-range values).
  • BUG-045: yaml_encode_string (legacy per-line emitter) had a hand-rolled needs-quote check that missed many triggers (bool/int/null lookalikes, anchor sigils, flow brackets) AND failed to escape embedded " when it did quote. Now delegates to the same yaml_value_needs_quoting + yaml_escape_quoted helpers as the new builder so both API surfaces produce consistent, valid YAML.
  • Pre-existing loader bugs in graphics/image/png and graphics/image/bmp: both used file.readFile (C-string-based, truncates at first NUL byte). Since binary file formats contain NUL bytes throughout headers and pixel data, the loaders were effectively useless for real files. Both now use file.readBinaryFile. Discovered while writing round-trip tests for the new writers (which obviously couldn't pass otherwise).

Internal

  • Test suite: 343 → 352 passing (+9 from new tests across the sprints), plus the existing 22 skipped (test_save_png joined the -lz group).
  • Two code-review checkpoints (between Sprint 1 and 2, and between Sprint 2 and 3) plus a final review before tagging. Each surfaced small polish items that were addressed before the next sprint started; review process documented for future stdlib batches.
  • BUG-043 filed during BUG-042 work (v0.5.13). BUG-044 and BUG-045 filed during this batch's reviews; both fixed in this release.

Roadmap

Medium and low-impact gaps from the audit — full crypto (AES/ChaCha20/Ed25519/RSA), TLS/HTTP server, raw DNS encode/decode, modern compression (zstd/brotli/deflate-raw/snappy), base64 streaming, URL builder, hashmap.values(), udp_recv ergonomics — moved to v0.5.15 per docs/ROADMAP.md §8.1–8.2.

0.5.13 — 2026-05-01

Single-bug patch release: closes BUG-042, the FieldAccess short-name collision noted (and mischaracterized as benign) in the v0.5.12 release notes. Reinvestigation found it was a real misinference: when a struct or AO method shared a short name with a top-level function, monomorph recorded the wrong specialization and the linker failed on undefined symbols at the call site.

Fixed

  • BUG-042: FieldAccess Call arms misinfer struct/AO method calls on short-name collision — scan_expr_for_generics and infer_init_type matched obj.method() against the short-name fn_return_types table, mis-registering an unrelated top-level function's return type as the method's. Symptom: a call like let r = box.compute() (where Box.compute() returns Result[Banana, string] and a top-level compute() returns Result[Apple, string]) recorded the Apple variant; the typechecker then emitted Banana-variant call sites that referenced never-specialized symbols. Six-phase fix in reef-compiler/lib/monomorph.ml:
    • Gate both existing FieldAccess(Var ns, ...) arms with not (Hashtbl.mem ctx.var_types ns) so the top-level lookup only fires for genuine module qualifiers.
    • scan_fn_body_for_generics now seeds var_types with each parameter's type (and self for methods) so the gate works for parameter qualifiers, not just locals.
    • New struct_method_return_types : (string * string, reef_type) table keyed by (struct_or_ao_name, method_name), populated in register_generics for non-generic methods on non-generic structs/AOs.
    • Complementary FieldAccess arms in both scanners use the new table when ns is a known local/param of struct/AO type.
    • Self-qualified arm added separately because self.method() parses as FieldAccess(Self, ...), not FieldAccess(Var "self", ...). Closes the recurrence path for methods that call other methods on self.
    • Bonus fix surfaced during cross-module test writing: infer_expr_type's New arm now strips alias.Type prefix the same way codegen_analysis.ml does, so cross-module let box = new lib.Box() resolves to the unprefixed name that matches register_generics' keying convention.
    • Collision warning when two modules register the same (struct_name, method_name) with different return types — surfaces the same-shape bug one level up instead of silently overwriting.
    • Four new regression tests: local-var, parameter, self-qualified, and cross-module shapes.

Known follow-up

  • BUG-043: AO methods returning generic types (e.g. exclusive fn make(): Result[Foo, string]) hit a typechecker gap — the call site falls back to TUnknown, emitting unspecialized helpers downstream. BUG-042's monomorph fix correctly registers and emits the AO method specialization, but the typechecker doesn't resolve the call's return type to use it. Plain struct methods work; AO methods need a separate typechecker-side fix. Filed for next release.

0.5.12 — 2026-04-30

Single-bug patch release: closes BUG-041, the cross-module generic-return-type monomorphization gap surfaced by repoman v0.1 against v0.5.11. Together with BUG-037 (v0.5.10) and BUG-038 (v0.5.11), the cross-module generic story is now complete.

Fixed

  • BUG-041: cross-module generic return type not monomorphized in consumer — When a non-generic function in module A returned Result[Foo, string] (or any generic enum/struct), consumers in other modules failed to compile: the monomorphized type wasn't emitted and the generic helper specializations (is_ok_Foo_string, unwrap_ok_Foo_string, etc.) were missing. Two adjacent fixes in monomorph.ml:
    • infer_init_type now handles Call(FieldAccess(Var ns, fn_name), _) by consulting fn_return_types[fn_name]. Pre-fix, qualified calls without an explicit type annotation left the variable's type as TUnknown, poisoning downstream generic-helper inference.
    • scan_expr_for_generics's Call(Var) and Call(FieldAccess) arms now fall through to fn_return_types when the called function isn't itself generic. If the return type is TGeneric(name, args), record_instantiation registers the type for emission.
    • Both fixes work in concert: A makes downstream type-arg inference work, B captures the underlying type instantiation. Pure additive — no existing emission paths altered.
    • New tests/cross_module/ infrastructure runs each subdir-project to catch cross-module regressions in CI. First inhabitant: bug041/ mirrors the project layout from the bug report.

Internal

  • reef-compiler/tests/run_tests.sh gained a "Cross-Module Tests" section that compiles and runs every tests/cross_module/<project>/tests/*.reef from inside the project root.

Known low-priority follow-up (BUG-042)

The FieldAccess arms in scan_expr_for_generics look up function names by short name across all of fn_return_types / generic_functions. A theoretical short-name collision between a struct method and a top-level function would over-record an instantiation (benign — produces dead C specialization). Not observed in practice; tightening the qualifier check is bundled into the next compiler-correctness batch.

0.5.11 — 2026-04-30

Followups to v0.5.10 from a fresh batch of bugs surfaced by repoman v0.1 adoption, plus a thorough sweep of every deferred review finding from the prior cycle. No items remain on the deferred list after this release.

Fixed

  • BUG-038: Monomorphized generic enum with user-struct payload emitted invalid C — When a generic enum like Result[T, E] was monomorphized with a user-defined struct as T, the generated Result_Foo_string referenced Foo* at the top of the C output (in the imports' emission section) before Foo was typedef'd later (in main's emission section). GCC rejected with "unknown type name 'Foo'". Fix: emit forward typedef struct Name Name; declarations for every non-generic ClassDecl and ActiveObjectDecl across all loaded modules at the very top of the C output, before any module body. Pure additive — covers the pointer references that monomorphizations need without affecting struct layout. Verified C11-compatible with gcc -Wall -Wextra -Wpedantic -std=c11. Reported by repoman v0.1 build flow.
  • BUG-038 followup: SumTypeDecl forward decls — Extended gen_forward_typedefs to also emit forward typedefs for non-generic enum types. Defensive — no current Reef source can construct a "pointer to enum" struct field that triggers the case (struct fields holding enum types emit as values per BUG-036), but the asymmetry was a future-debugging trap.
  • BUG-039: Parser accepts @module.Type[args].Variant(args) qualified generic constructor syntax — Pre-fix, only the bare @Type[args].Variant(args) parsed; the module-aliased form failed with a syntax error. The result_generic.reef docstring used the bare form so following the docs worked, but it was inconsistent with Reef's "import + qualify everything" convention. Reported by repoman v0.1.
  • BUG-040: spawn with enum-typed argument codegen mismatch — Three Spawn arms in codegen (Spawn(Call), Spawn(GenericCall), Spawn(QualifiedGenericCall)) generated their spawn-args struct field types via the legacy c_type_name. With an enum-typed argument the struct held EnumType * but the trampoline call passed by value — same shape as BUG-036, different code path. All three sites switched to c_type_name_with_context. Latent in v0.5.10; the BUG-036 commit had explicitly flagged the closure-capture struct field paths as a follow-up risk if exercised.
  • io.file.fsync portability — Changed from O_RDONLY to O_RDWR so the helper works on macOS and the BSDs (POSIX leaves fsync on a read-only fd unspecified). Also now reports close() errors instead of swallowing them. Behavior-visible: callers fsyncing a file they can read but not write (e.g., mode 0444) will now get false where they got true on Linux before — this is a portability fix, not a regression. The directory variant (fsync_parent) intentionally stays on O_RDONLY because directories cannot be opened O_RDWR.

Added

  • io.file.fsync_parent(path) — Completes the crash-safe atomic-write recipe: writeFile(tmp) → fsync(tmp) → rename(tmp, dst) → fsync_parent(dst). Without the trailing parent-directory fsync, a power loss after rename but before the directory entry is durable can leave the destination missing. The file at path does not need to exist; only its containing directory does. New C helper reef_fs_fsync_parent opens dirname(path) with O_RDONLY, calls fsync(), closes. Resolves the HIGH from the v0.5.10 final code review (the docstring had called out the gap honestly but the helper itself didn't ship).
  • io.path.expand_user(p) — Handles ~user/foo via getpwnam; expand_home now delegates the ~user case here. Cases:
    • ~root → /root (via getpwnam)
    • ~root/foo → /root/foo
    • ~root/ → /root/ (preserves directory marker, matches expand_home("~/"))
    • ~/foo, ~, /abs, relative → same as before (delegates to expand_home behavior)
    • ~unknown_user/foo → passthrough on getpwnam failure
    • New C helper reef_fs_user_home(name) uses thread-local buffer pattern (matches reef_fs_uid_name/gid_name from BUG-033).
  • sys.flag.subcommand_parser declarative API (deferred Phase 2 of repoman feedback item 5):
    • subcommand_parser() → SubcommandParser
    • add_subcommand(sp, name, desc) → FlagParser (caller defines flags on the returned parser)
    • dispatch(sp) / dispatch_args(sp, argv) → matched subcommand name or ""
    • sub_error(sp) / sub_usage(sp, prog_name)
    • Built atop flag_parser_from from v0.5.10. Per repoman's design answer: no global flags before the subcommand token, no sub-subcommands.
  • test.framework.assert_contains_string — Now emits a WARN: assert_contains_string with empty needle notice when the needle is empty. Still passes (matches core.str.contains contract that empty is contained), but the vacuous assertion is now visible in test output rather than slipping past review.

Changed

  • encoding.toml parser deduplication — toml_parse, toml_parse_sized, and toml_parse_status previously held three near-identical 130-line bodies (the only differences being the entry cap and an optional post-loop scan). Extracted to a shared parse_toml_entries helper; each public function is now a 3-line wrapper. Behavior is byte-for-byte unchanged. Net: −257/+26 lines. Resolves the HIGH from the v0.5.10 Sprint 1 code review.

Internal / development workflow

  • reef-compiler/tests/run_tests.sh exports REEF_HOME so tests resolve against the working tree (already shipped in v0.5.10; verified still working).
  • Cumulative review confirms all c_type_name → c_type_name_with_context migration sites identified by BUG-036 + BUG-040 are now complete; no bare c_type_name calls remain in any path that emits user types.

0.5.10 — 2026-04-29

Stdlib enhancements driven by repoman v0.1 downstream feedback. Adds encoders for TOML and JSON (closing the asymmetric reader-without-writer gap), atomic-write primitives in io.file, ergonomic TomlDoc struct API, subcommand-aware flag parsing, home-directory expansion in io.path, several new test-framework assertions, and a codegen fix for Active Object methods that take or return enum types.

Added

  • core.result_generic — generic Result[T, E] type with is_ok[T,E], is_err[T,E], unwrap_ok[T,E], unwrap_err[T,E], unwrap_or[T,E]. Mirrors core.option_generic. Construction requires explicit type args (@Result[int, string].Ok(42)) because each variant only carries one of the two type parameters. The type-specific core.result variants (ResultInt, ResultStr, etc.) remain available for backward compatibility.
  • encoding.toml.TomlBuilder — streaming TOML serializer with toml_builder(), toml_set_string/_int/_bool/_string_array, toml_begin_table/_end_table, toml_array_append_table ([[name]]), and toml_render. Backed by text.stringbuilder; supports basic-string escaping for ", \, newline, tab, CR. Round-trips through toml_parse_doc.
  • encoding.json.JsonBuilder — streaming JSON serializer with json_builder(), json_set_*, json_begin/end_object, json_begin/end_array, json_array_append_*, json_array_begin_object/_array for nested array elements, and json_render. Comma placement via per-scope has_item stack with overflow-safe push/pop bounded by JSON_MAX_DEPTH.
  • encoding.toml.TomlDoc — struct-based parse API: toml_parse_doc(input), toml_parse_doc_sized(input, max_capacity), plus toml_get_doc/_int_doc/_bool_doc/has_key_doc wrappers. Bundles (keys, values, count) parallel-array threading into a single value with explicit truncated flag. Coexists with the existing flat API.
  • encoding.toml.toml_parse_status and encoding.json.json_parse_status — variants returning -1 when the cap was hit and substantive content remained in the input. Trailing whitespace, newlines, and # comments (TOML only) at the cap boundary are treated as clean parses.
  • io.file.rename(old, new) and io.file.fsync(path) — thin wrappers over the runtime.reef_fs_* helpers. rename delegates to the same C function as fs.ops.rename; fsync is new (reef_fs_fsync opens the path, calls fsync, closes). Doc-block notes the parent-directory fsync is not yet exposed for full crash-safe atomic writes.
  • io.path.expand_home(path) — expands ~ → $HOME, ~/foo → $HOME/foo, preserves ~/ as $HOME/, passes ~user/... through (deferred). Returns the input unchanged when HOME is unset.
  • io.path.join(a, b) — canonical name; join_path retained as a backward-compatible synonym.
  • sys.flag.flag_parser_from(args: [string]) — alternate constructor that binds the parser to a caller-supplied argv slice. Enables subcommand dispatch without forking the parse loop. Backward-compatible with existing flag_parser() callers.
  • test.framework.TestRunner — seven new exclusive-proc assertions: assert_contains_string, assert_ok_int/_str/_bool, assert_err_int/_str/_bool. (ResultFloat variants intentionally omitted; add on demand.)
  • C runtime: reef_fs_fsync(path) in reef_fs.c.

Fixed

  • BUG-036: Active Object methods with enum-typed parameters or return values generated mismatched C — gen_exclusive_method, gen_shared_method, AO method forward declarations, AO field declarations, AO init parameters, AO allocator-wrapper init parameters, plain struct method parameters and return types, and AO method body return types all called the legacy c_type_name (which renders TUserType as a pointer regardless of whether it is an enum or struct). Switched all nine sites to c_type_name_with_context, which checks is_enum_type and emits the value type for enums. Aligns with regular function emission (already context-aware since BUG-026).
  • BUG-037: cross-module generic functions and types not specialized at qualified call sites — monomorph.ml's scanner handled Call(Var fn, args) and explicit GenericCall/GenericConstructor AST shapes but had no arm for Call(FieldAccess(Var ns, fn), args), the shape that every cross-module qualified call (lib.peek(b)) parses as. The same gap existed for type annotations: TGeneric was recognized but TGenericQualified (lib.Box[int]) was not. Even when the scanner was extended, the driver in bin/reefc.ml registered each module's generics only when that module's own monomorphization turn came — too late for call sites in the consumer module. Three-part fix: (a) added a FieldAccess arm to scan_expr_for_generics's Call match; (b) added a TGenericQualified arm to the VarDecl type-annotation match; (c) pre-registered generics from all imported modules in the driver before scanning begins. Also extended codegen's FieldAccess call-site emission to consult GenericFnBinding from the type environment and produce the specialized mangled name (matching the behaviour the Call(Var) arm has had since BUG-026). Unlocks core.option_generic and core.result_generic for cross-module use.

Changed

  • reef-compiler/tests/run_tests.sh exports REEF_HOME pointing at the working tree, so tests no longer require make install to pick up stdlib changes.

Deferred (filed as separate bugs)

  • subcommand_parser declarative API (item 5b in repoman feedback) — Phase 1 (flag_parser_from) fully unblocks repoman; the cobra-style declarative API is a follow-on for v0.5.11.
  • expand_user("~user/...") — deferred; needs a getpwnam-backed runtime helper. Current expand_home passes ~user/... through unchanged so adding it later won't break callers.
  • toml_parse_status / toml_parse_sized parse-loop deduplication — flagged as HIGH in code review; deferred to a focused refactor commit. Adding it under the encoder work would have entangled two large edits in one file.

0.5.7 — 2026-03-11

GC crash fix: interior pointer dereference in conservative stack scanner.

Fixed

  • BUG-034: GC crash dereferencing invalid type descriptor from interior pointer — reef_heaps_is_valid_heap_pointer() had a tautological interior pointer check: REEF_GET_OBJECT(REEF_GET_HEAP_BLOCK(ptr)) always equals ptr, so interior pointers (pointers into the middle of a heap object's data) were never rejected. When the speculative block header computation read garbage data from another object, dereferencing block->type->size crashed with SIGSEGV on an unmapped address. Fix: replace tautological check with find_containing_object() to verify the candidate is a real block boundary, and validate that block->type is not in heap bounds before dereferencing (type descriptors live in data/BSS segment, never on the GC heap).

0.5.6 — 2026-03-10

GC stability fix for large struct array walks + stdlib uid/gid name resolution.

Fixed

  • BUG-033: GC crash on large struct array walk — Conservative stack scanner registered every non-NULL stack word as a candidate pointer, overflowing the 4096-entry limit almost immediately. Non-heap values (return addresses, frame pointers, integers) consumed all slots, causing real heap pointers to be silently dropped. Live objects went unmarked and were swept, causing use-after-free corruption. Fix: pre-filter candidates with reef_heaps_in_heap_bounds() before registering (~95% reduction), increase limit to 65536, add overflow warning.
  • OOM abort: reef_heaps_new_object() and reef_heaps_new_array() now abort with diagnostic message instead of returning NULL on allocation failure.

Added

  • fs.stat uid/gid name resolution: uid_name(), gid_name(), file_uid_name(), file_gid_name() — zero-allocation POSIX name resolution using getpwuid()/getgrgid() with thread-local buffers. Eliminates need for Reef-level /etc/passwd parsing.
  • C runtime functions: reef_fs_uid_name(), reef_fs_gid_name(), reef_fs_file_uid_name(), reef_fs_file_gid_name() in reef_fs.c
  • GC stress test: test_gc_struct_stress — 3000 structs with 7 string fields + garbage pressure
  • Reef test: test_fs_uid_name — uid/gid name resolution via fs.stat
  • Streaming I/O reference guide: docs/language/reference/125_STREAMING_IO.md

0.5.5 — 2026-03-10

Compiler codegen cleanup: suppress all C compiler warnings from generated code.

Fixed

  • BUG-032: Unused variables/constants/parameters in codegen — Four categories of C compiler warnings eliminated: (1) module-level const declarations marked __attribute__((unused)) (25 warnings), (2) local variable declarations from VarDecl marked __attribute__((unused)) (8 warnings), (3) function parameters suppressed with (void)param; casts at function entry (1 warning), (4) volatile MMIO stubs marked __attribute__((unused)) (1 warning), (5) all builtin helper functions wrapped with #pragma GCC diagnostic ignored "-Wunused-function" (69 warnings). Generated C code now compiles cleanly with clang -Wall -Wextra and gcc -Wall -Wextra.

0.5.4 — 2026-03-09

Bug fix: GC does not trace module-level mut variables as roots.

Fixed

  • BUG-030: Module-level mut variables not traced by GC — Module-level mut variables of heap-allocated types (string, arrays, structs, functions, any) are stored as static C globals in BSS/data segment. The conservative stack scanner never reaches them, and they had no type descriptors for tracing. Fix: runtime adds reef_heaps_register_global_root() to register individual global variables as GC roots. Compiler emits registration calls in main() for each module-level mut of pointer type. Also fixes the root cause of BUG-031 (GC corrupts arrays during HTTP download with progress callback) — net.extras module-level mut strings (bar_url, bar_filename, bar_fill_char) are now properly rooted.

Added

  • reef_heaps_register_global_root() runtime API for registering individual GC roots
  • Reef regression test test_gc_module_mut_roots — module-level mut string survives GC pressure

0.5.3 — 2026-03-09

Bug fix: GC fails to trace array fields in structs due to interior pointer layout.

Fixed

  • BUG-029: Arrays in structs collected while live — Reef's [T] array layout stores base+1 in struct fields (skipping the length slot at index -1). mark_object() assumed all pointers are exact object starts, so REEF_GET_HEAP_BLOCK(base+1) read garbage from the wrong offset and silently failed. Arrays referenced through struct fields were never marked and got swept. Fix: mark_object() now falls back to find_containing_object() when the direct block header probe fails validation, handling both the base+1 array trick and array_type_sentinel.size==0 blocks.

Added

  • Reef regression test test_gc_array_in_struct — struct with [string] field survives GC pressure

0.5.2 — 2026-03-08

Bug fix: GC corrupts live heap objects via lazy sweep double-allocation.

Fixed

  • BUG-028: Auto-GC corrupts string references — lazy_sweep() coalesced blocks already on free lists from linear_scan_sweep(), causing find_free_block() to return already-allocated blocks (double allocation). String concat loops produced corrupted output; struct string fields were silently overwritten. Four-part fix: (1) clear free lists when lazy sweep restarts after a GC cycle, (2) move auto-GC trigger to before allocation so no half-initialized block exists during collection, (3) capture stack base from main() via &argc for reliable conservative stack scanning, (4) include TString, TFunction, TAny, and TGeneric in codegen type descriptor pointer_offsets so the GC mark phase traces all pointer fields in structs and active objects.

Added

  • Interior pointer support in conservative stack scanner (find_containing_object)
  • Heap bounds check in mark_object() to safely skip .rodata string literals
  • Register flushing (__builtin_unwind_init()) before stack scanning
  • Reef regression test test_gc_struct_strings — 100 structs with string fields survive GC
  • Reef regression test test_string_concat_loop — 10,000-iteration concat loop (previously corrupted at ~1,370)

Changed

  • reef_objects_register_main_process() now takes void* stack_base parameter (API change)
  • All 10 baseline codegen tests regenerated for new register_main_process(&argc) signature

0.5.1 — 2026-03-08

Bug fix: automatic GC triggering prevents heap allocator degradation.

Fixed

  • BUG-027: Heap allocator O(n²) degradation — reef_string_concat() in a loop hung after ~3,200 iterations due to dead blocks accumulating on free lists without GC ever running. Added three-layer fix: (1) threshold-based auto-GC triggers collection when allocation pressure exceeds max(bytes_live * 2, 256KB), (2) GC-before-expand safety net tries a GC cycle before growing the heap when find_free_block() returns NULL, (3) existing --no-gc flag serves as escape valve. Programs that previously hung now complete in under 1 second.

Added

  • C stress test test_gc_auto_trigger — 50,000 tight-loop allocations verifying auto-GC fires and completes in <10 seconds
  • Reef regression test test_string_concat_loop — 10,000-iteration string concat loop

0.5.0 — 2026-03-07

Filesystem module family (RFE-002) — native filesystem operations replacing shell-outs.

Added

  • fs.stat module — file metadata and type queries: is_file, is_directory, is_symlink, is_pipe, is_socket, is_block_device, is_char_device, exists, file_size, file_mode, file_uid, file_gid, file_mtime
  • fs.perm module — permission operations: chmod, chown, lchown, set_executable, set_readonly, is_readable, is_writable, is_executable, plus 12 POSIX constants (S_IRUSR through S_ISVTX)
  • fs.link module — link operations: symlink, readlink, hardlink
  • fs.ops module — file operations: copy_file, copy_file_preserve, copy_symlink, remove_file, rename, remove_tree, copy_tree
  • reef_fs.c — new C runtime file consolidating all filesystem operations (~50 functions)
  • Filesystem operations guide — docs/language/reference/120_FILESYSTEM_OPERATIONS.md
  • 6 new compiler tests + 21 C-level unit tests (316 total passing)

Changed

  • Filesystem C implementations migrated from OCaml codegen.ml string literals to proper C runtime file
  • remove_tree has safety checks: refuses "/" and paths with < 2 components
  • copy_tree has 256-level depth limit, preserves symlinks

0.4.0 — 2026-03-05

Systems programming enhancements (RFE-001) for a downstream OS project.

Added

  • sys.fd module — file descriptor operations: open, close, read, write, dup, dup2, pipe, set_nonblocking, set_cloexec, plus constants (STDIN/STDOUT/STDERR, O_RDONLY/WRONLY/RDWR/CREAT/TRUNC/APPEND/CLOEXEC)
  • sys.poll module — poll(2) event loop: poll_clear, poll_add, poll_wait, poll_readable, poll_writable, poll_error, poll_hangup (64 fd limit, thread-local)
  • net.unix module — Unix domain sockets: unix_connect, unix_listen, unix_accept, unix_send, unix_recv, unix_close, unix_unlink
  • sys.process additions — process_fork, process_spawn, process_exec, process_setsid, process_getpgid, process_setpgid, process_wait_any, process_wait_any_nohang, killpg, umask, exit_now
  • sys.signal additions — signal_block, signal_unblock, signal_wait, selfpipe_create, selfpipe_register, selfpipe_read, SIGRTMIN, SIGRTMIN_offset
  • encoding.toml additions — toml_alloc_keys_sized, toml_alloc_values_sized, toml_parse_sized; default entry limit raised from 256 to 1024
  • --no-gc compiler flag — disable GC and Active Object initialization
  • --gc-signals rtmin compiler flag — use SIGRTMIN+0/1 instead of SIGUSR1/2 for GC thread suspension
  • reef_poll.c — new C runtime file for poll(2) wrapper
  • String array marshaling — reef_string_array_create/free for [string] to char** conversion
  • Systems programming guide — docs/language/reference/115_SYSTEMS_PROGRAMMING.md
  • 14 new tests (310 total passing)

Changed

  • CI builder image now includes clang and lld as default toolchain
  • TOML parser default entry limit raised from 256 to 1024
  • exit_now() flushes stdout/stderr before calling _exit()
  • GC signals in reef_machine.c now configurable via reef_platform_gc_set_signals()

Fixed

  • GCC -Werror=unused-result on self-pipe write() in signal handler

0.3.5 — 2026-03-04

Fixed

  • Cross-module non-exported function return type falls back to int instead of failing (BUG-026)

Changed

  • Updated inline assembly docs for -masm=intel usage and --emit-c workflow

0.3.4 — 2026-03-03

Fixed

  • Operator precedence: a - b > c parsed as a - (b > c) instead of (a - b) > c (BUG-024)
  • Separated unary ops into unary_expr non-terminal; ordered expr before unary_expr for correct Menhir reduce/reduce resolution

0.3.3 — 2026-02-28

Fixed

  • Inline asm missing register clobbers — codegen now scans substituted asm body for hard-coded register names and adds them to the clobber list (BUG-023)
  • Case-insensitive register detection for x86 Intel syntax; ARM64 xN/wN and AMD64 rax/eax deduplication

0.3.2 — 2026-02-26

Added

  • Conditional imports with ifarch blocks — ifarch amd64 ... end ifarch for architecture-specific module imports (BUG-022)

0.3.1 — 2026-02-25

Added

  • Heap allocator: segregated free lists (16 size classes) for O(1) allocation
  • ASAN/UBSAN build targets (make test-asan, make test-ubsan)
  • Concurrent allocation stress test (test_concurrent_alloc.c)
  • Module-level mut warning when accessed from Active Object methods
  • Changelog (docs/changelog-0.3.1.md)

Fixed

  • Heap free list race condition — added heap_alloc_lock mutex (BUG internal)
  • AO chained method calls missing class prefix in codegen (BUG-018)
  • Await condition functions emitted twice in multi-module builds (BUG-019)
  • reef_objects_await() never checks condition before sleeping — deadlock fix (BUG-020)
  • Baremetal TLS global variable causes deadlock with cooperative scheduling (BUG-021)
  • 382 -Wparentheses-equality warnings in generated C code eliminated

Changed

  • Major version bump 0.2.x → 0.3.x reflecting runtime heap allocator rewrite
  • Async AO design resolved: use AO composition pattern (A2/Bluebottle model)

0.2.12 — 2026-02-24

Fixed

  • AO intra-method calls missing class prefix in codegen (BUG-017)

0.2.11 — 2026-02-24

Fixed

  • Duplicate __reef_quantum declaration in multi-module builds (BUG-016)

0.2.10 — 2026-02-24

Added

  • Await codegen implementation (BUG-012)
  • Cooperative yield points via --quantum-checks flag (BUG-014)

Fixed

  • Baremetal AO stub signatures (BUG-013)
  • Platform abstraction layer for AO scheduling (BUG-015)

0.2.9 — 2026-02-24

Fixed

  • Widened ticks_increment test tolerance for slow platforms (RISC-V, QEMU)

0.2.8 — 2026-02-24

Added

  • C toolchain selection: clang/lld as defaults with --cc/--ld flags, CC env var, and config file support

Fixed

  • Inline asm codegen — parameter substitution and register handling (BUG-011)
  • reefc doctor output clarified: shows Active vs Available for compiler and linker

0.2.7 — 2026-02-24

Fixed

  • Pointer cast crash in codegen
  • Module constant namespacing in C output (BUG-010)
  • Large hex literal parsing

0.2.6 — 2026-02-23

Fixed

  • 7 baremetal bugs: volatile MMIO access, spinlock dispatch, firmware bindings (BUG-001 through BUG-007)

0.2.5 — 2026-02-23

Added

  • Platform/target info to reefc info and reefc doctor commands

Fixed

  • Build output binary naming

0.2.4 — 2026-02-23

Added

  • RISC-V 64-bit baremetal target (riscv64-baremetal)
  • AArch64 baremetal target (arm64-baremetal)
  • Script-friendly --version output

0.2.3 — 2026-02-22

Added

  • Array methods: append, pop, remove (immutable-style, return new arrays)
  • For-each with index: for i, item in collection
  • For loop step: for i in 0 to 100 step 10
  • Unsafe modules: unsafe module ... end module (bounds checks suppressed module-wide)
  • Trait method dispatch Phase 1: concrete type dot notation
  • Trait method dispatch Phase 2: generic function dispatch via monomorphization
  • Baseline tests (codegen comparison) and negative tests (EXPECT_FAIL)

Fixed

  • Runtime signal handler async-safety
  • Lock NULL checks in runtime
  • libc FFI modules: extern "C" declarations use actual C names

Changed

  • Cleaned up roadmap: resolved deferred features and runtime stubs
  • Replaced failwith/Unknown placeholders with proper error handling throughout compiler

0.2.2 — 2026-02-21

Added

  • Do-while and do-until loops: do while cond ... end do, do until cond ... end do

Fixed

  • Timer thread: replaced 1ms polling with condition variable sleep
  • Unsafe bounds check suppression
  • Overhauled test infrastructure

Changed

  • Added trait method dispatch roadmap

0.2.1 — 2026-02-20

Added

  • Complex function call expressions (call-through)
  • Multi-level module qualification (module.EnumType.Variant)
  • Exhaustiveness warning when wildcard hides enum variants

Fixed

  • mangle_type gaps for several type combinations
  • Active Objects codegen: use lock/unlock instead of thread-per-call
  • GC-managed closures (was leaking with malloc)
  • Bounds checking on array/string access

Changed

  • Added O(n^2) concatenation warnings to str.concat and str.join docs

0.1.16 — 2026-01-30

Added

  • Callback-based download progress reporting
  • Compiler usage guide and man page for reefc
  • Program exit codes section in BASICS guide

Fixed

  • UTF-8 character handling in string literals
  • Closure symbol naming collision across modules

0.1.15 — 2026-01-29

Added

  • exit() builtin for program exit codes
  • fn main(): int for returning exit codes from main
  • CLI tools updated to use proper exit codes

0.1.14 — 2026-01-28

Fixed

  • sha256_file() hanging on large files (>12MB)

0.1.13 — 2026-01-28

Added

  • Streaming SHA256 API

Fixed

  • Infinite loop in HTTP chunked transfer encoding decoder
  • HTTP chunked encoding parsing

0.1.12 — 2026-01-27

Changed

  • Renamed config file from reefc.toml to reefc.conf

0.1.11 — 2026-01-27

Patch release (no notable changes).

0.1.10 — 2026-01-27

Patch release (no notable changes).

0.1.9 — 2026-01-27

Changed

  • Consistent version banner across all reefc commands

0.1.8 — 2026-01-26

Added

  • Binary distribution packaging (package-binary.sh)
  • Binary installation instructions in README

Fixed

  • Binary distribution layout for out-of-box experience
  • HTTP redirects and streaming downloads
  • TLS reliability improvements

0.1.7 — 2026-01-25

Added

  • CI pipeline
  • reefc doctor command for installation diagnostics
  • HTTPS support in net.http module
  • TLS/SSL support with bundled LibreSSL 4.2.1
  • illumos and FreeBSD build support
  • X11 backend for graphics
  • OS development support (reef-baremetal/, reef-os/)
  • Editor support and VS Code extension
  • Standard library integration
  • Source packaging script

Fixed

  • illumos TLS hang
  • illumos build: added __EXTENSIONS__ for IPV6_V6ONLY
  • X11 backend for composited desktops (XWayland)

Removed

  • Wayland support (broken)

0.1.0 — 2026-01-25

Initial release.

  • OCaml compiler with lexer, parser, type checker, C code generation
  • C runtime with Active Objects, GC, heaps
  • Standard library: core types, collections, I/O, networking, crypto
  • 261 compiler test files
  • Full CLI: build, run, new, init, clean, doc, info