Completes plan 2 Tasks 7-8. owner.ml: mutable-value-semantics flow analysis producing the four plan-3 emitter tables (moves, drops incl. LIVE-MASK for trap unwinding, rc with elision, residual borrow sites) plus WO-E301-304 two-site diagnostics. Alias questions run over canonicalized places, so a double-mut reached through let-bound aliases lands in the residual table like the direct form; dump.ml's contract notes the emitter must coalesce guards per operand. main.ml: directory discovery, cross-file programs (symbols merge before bodies check), diagnostics ordered by (file,line,col), new WO-E214 for a name declared in two files. New docs/plan/oop-vm/01-error-catalog.md (14 emitted + 10 reserved codes), un-ignored so both plan tracks can cite it; justfile regains woc-*. builtin_scalars is now the five that work: Int, Bool, Text, Timestamp, Id. Money/SKU/Float and the abstract_types allowlist are gone — `abstract` never lexed, and Float had no literal syntax and no wob kind, so no value could exist. Fixtures and samples retype Money->Int, SKU->Text. The abstract newtype feature is rejected outright (verdict row adopt->reject); haxe-parity Task 7 keeps `is`. nullable-types-implementation.md corrected: ?T is plumbed but UNENFORCED (E211-213 declared, never emitted; probe exits 0), handed to haxe-parity Task 6 as next work item. Records all 10 dead codes incl. E205 — interface satisfaction is unchecked. crates/rt keeps its Money/SKU fixtures (opaque strings, Stage 2). Gate: build warning-clean, 14 + 264 checks 0 failures, pricing golden exit 0, docs/examples histograms unchanged (13/70, zero WO-E225).
12 KiB
writeonce systems track — Haxe keyword study, program mode, systems stdlib (design)
Date: 2026-08-01
Status: approved design, pre-implementation
Companion spec: 2026-08-01-oop-compiler-vm-design.md (the OOP core this track extends)
Driving workload: ~/projects/log-watcher — ~1,200 lines of Haxe compiled to C++ (--cpp), a single-binary systems daemon: log-tail watcher, cron.d supervisor, flock/pgrep probes, hand-rolled MCP-over-HTTP server, JSONL detection sink.
Motivation
writeonce today can only be a full-stack server. log-watcher is the counterexample class: a CLI daemon that reads files, probes processes, serves a small TCP protocol, and sleeps in a poll loop. The language should build such applications too. Method: study the language log-watcher is written in — Haxe — keyword by keyword, adopt broadly what fits, reject explicitly what breaks doctrine, and prove the result by re-expressing log-watcher in .wo.
Decisions locked during brainstorming
| Question | Decision |
|---|---|
| "Hexa" meaning | Haxe — log-watcher's language. |
| Deliverable | Spec + .wo sample workload. Repo pattern: samples force the grammar (blog/ecommerce/pricing precedent). |
| Adoption stance | Broad Haxe parity MINUS doctrine breakers. No inheritance (extends/override/super), no Dynamic/untyped, no macros — plan-13 doctrine and the OOP spec stay locked. Everything else adopts liberally. |
| Systems access | Program mode + safe builtin stdlib. No FFI/extern — capabilities are typed builtins implemented in the C runtime. |
Part 1 — The Haxe keyword verdict table
Every Haxe keyword (plus the contextual ones), one verdict each: have (writeonce equivalent exists), adopt (new surface this track adds), reject (with the reason). This table is normative for the plan documents.
| Haxe keyword | Verdict | writeonce mapping / reason |
|---|---|---|
class |
have | class (state + methods, no hierarchy) |
interface |
have | structural interfaces (OOP spec section 3) |
function |
have | fn |
var (locals) |
have | let; mutability via MVS rules, no second keyword |
this |
have | self (identifier, positionally bound) |
if / else |
have | same |
for / in |
have | same |
while |
have | same |
return |
have | same |
true / false |
have | same |
enum |
have+adopt | tagged unions exist; adopt payload variants (Pending | Failed(reason: Text)) with exhaustive switch |
final |
have | MVS immutability by default; const for named constants |
new |
have | constructor brace literal Type { … }; no keyword |
switch / case / default |
adopt | expression-form switch, exhaustive over unions; default optional when exhaustive |
typedef |
adopt | structural record aliases with optional fields (?field) — the SupConfig/TailState pattern |
null / Null<T> |
adopt | ?T optional types; forced handling before use (no nil deref trap possible); bare null only assignable to ?T |
try / catch / throw |
adopt | expression-form over the trap system: catch binds the structured error {code, method, line, msg}; throw value raises an EXPLICIT trap carrying the value; uncaught = existing trap surface |
break / continue |
adopt | loop control |
do (do-while) |
adopt | parity, trivial |
static |
adopt | class-level fn/const — namespaced functions without instances (Flock.held, Pgrep.alive pattern) |
abstract |
reject | a distinct scalar type adds a conversion surface without buying safety this language needs; domain scalars are plain Int/Text |
using |
adopt | static extension methods — doctrine-safe reuse (composition sugar, the inheritance substitute) |
import / package |
adopt | use + directory-as-module; stdlib namespaces (fs, proc, net, time, env, json) |
is |
adopt | runtime type test restricted to union variants and interface values; a compile error on statically-known types |
inline |
adopt (values) | const compile-time values; inline functions rejected — optimization is the compiler's job |
public / private |
adopt (as pub) |
default private; pub exports; property-accessor pattern (default, null) becomes pub(read) — public read, owner-only write |
#if / #else / #end |
adopt | build-flag conditional compilation only (the -D portable pattern); flags from the build command, no expression language beyond flag names |
string interpolation '${}' |
adopt | in string literals |
extends |
reject | no-inheritance doctrine (plan 13, OOP spec) — is-a via unions, has-a via composition |
super |
reject | no hierarchy to call up |
override |
reject | nothing to override |
overload |
reject | one name, one signature; keeps dispatch and diagnostics simple |
implements |
reject (keyword) | satisfaction is structural and implicit; declaring it adds a lie surface |
dynamic / Dynamic |
reject | static typing is the VM's foundation (untagged registers); typed json.decode covers the real use |
untyped |
reject | no escape hatch from the type system |
macro |
reject | kills the fast-compile promise; codegen belongs to wo gen tooling |
extern |
reject | no FFI hole in the memory-safety story; capabilities are audited builtins |
cast |
reject | no unsafe casts; conversions are typed (abstract from/to, explicit builtins) |
operator |
reject | no operator overloading; KISS |
Part 2 — Program mode
Entry. A project containing a free fn main(args: multi Text) -> Int compiles as a program; the return value is the exit code. wo run <dir> executes it. woc build produces the single binary (plan-3 trailer mechanics unchanged). A project with service blocks and no main remains a server. Both present: main runs first and decides what to start — exactly log-watcher's shape (watch/run/mcp subcommands selecting the daemon flavor).
Blocking model — the load-bearing decision. Program mode runs one shard and blocking builtins are legal (time.sleep, proc.run, blocking net.accept): the Haxe original is a poll loop around Sys.sleep, and writeonce expresses that directly. Server shards keep the never-block doctrine — the same stdlib calls are loop-integrated (io_uring) there. One API, two execution disciplines, selected by mode. No async/await keyword exists in either mode.
CLI surface. env.args() -> multi Text, env.get(name) -> ?Text, env.exit(code) (never returns), print/print_int (exist) plus print_err; output flushes on newline (the reason for log-watcher's Util.say disappears).
Daemon idiom. while true { …; time.sleep(ms) } is supported and expected. Signals: the runtime owns signalfd (doctrine); SIGTERM/SIGINT set a shutdown flag programs poll via env.stopping() -> Bool. No signal callbacks.
Part 3 — Systems stdlib
Five builtin modules, scoped to what log-watcher's code actually uses. Every handle (file, socket, process) is an owned object whose drop closes it — MVS deterministic destruction is RAII: no close bookkeeping, no leaked fds by construction, and a handle sent nowhere dies at scope end.
| Module | Surface | log-watcher use it covers |
|---|---|---|
fs |
exists(path); stat(path) -> ?{size, inode, mtime}; read_at(path, offset, max) -> Text; read_all(path, cap); append(path, text); list(dir) -> multi Text |
rotation detection needs the inode; bounded tail-chunk reads (never front-to-back scans); JSONL detection sink (open-append-close); cron.d directory scan. No write/truncate/delete in v1 — the read-only posture is the default posture. |
proc |
run(cmd, args: multi Text) -> {code: Int, out: Text, err: Text}, bounded capture |
the flock -n exit-code probe and pgrep -f. Args-array only — no shell-string form, command injection unrepresentable. |
net |
listen(addr, port) -> Listener; accept(listener) -> Conn; read(conn, max) -> Text; write(conn, text) |
the hand-rolled MCP HTTP subset (127.0.0.1 accept loop, one request per connection). TCP only in v1. |
time |
now() wall ms (exists); mono() monotonic ms; sleep(ms) |
poll-interval math on a monotonic clock; the daemon sleep. |
json |
json.decode(text) as RecordType -> ?RecordType; json.encode(value) -> Text |
config loading and JSON-RPC — typed, replacing Haxe's Dynamic idiom: missing optional fields are fine, shape mismatches yield nil, never a trap. The as here is the decode-target position only — a checked conversion returning ?T, not a cast; it exists nowhere else (the cast rejection stands). Reuses the HTTP plan's C codec as builtins. |
Part 4 — The sample workload
docs/examples/log-watcher/ — the Haxe original re-expressed in .wo, file-for-file:
.wo file |
.hx sibling |
carries |
|---|---|---|
main.wo |
Main.hx | subcommand dispatch, config decode into a typedef record with ?fields |
watcher.wo, logtail.wo |
Watcher.hx, LogTail.hx | TailState record, bounded tail reads, rotation-by-inode, quiet-period rule |
cron.wo |
Cron.hx | cron.d parse, next-fire computation |
probes.wo |
Flock.hx, Pgrep.hx | proc.run exit-code probes as static fns |
mcp.wo |
Mcp.hx, Tools.hx | typed request/response records; pure handle(req) -> resp kept socket-free (the original's best design decision, preserved); serve loop over net |
A README table records the mapping and what (if anything) each file could not express — an empty "could not express" column is this track's acceptance criterion.
Error handling
One system, two surfaces. Traps remain the runtime truth (OOP spec section 6). This track adds the language surface: try expr catch (e) fallback-expr — e is the structured error record; throw value raises EXPLICIT with the value attached. Optionals (?T) handle expected absence (missing file stat, failed decode, missing env var) — the stdlib returns nil for those, reserving traps/throw for genuine faults. The Haxe original's try … catch (e:Dynamic) return false probes become optional-returning calls — clearer than the original.
Testing
- Language adoptions: each feature lands with conformance-corpus fixtures — golden (runs, expected stdout) and must-fail (expected
WO-E###) — extending the OOP track's corpus and error catalog. - Stdlib: corpus fixtures against real resources — tempdir files (stat/inode/rotation simulation via rename), spawned
/bin/true-class processes, loopback sockets. Handle-drop RAII proven under ASan (a leaked fd test: open many handles in a loop, assert no fd growth). - Acceptance: the log-watcher sample compiles; its testable cores (tail state machine, cron next-fire, MCP
handle) pass fixtures ported from the Haxe test suite's cases; the sample binary runswatchagainst a growing tempfile and detects an error-final quiet period.
Success criteria
- The keyword table is fully implemented: every adopt row parses, typechecks, and executes with corpus coverage; every reject row has a diagnostic or a documented absence.
fn mainprogram mode:wo runexecutes a CLI program; exit codes propagate;woc buildproduces a self-contained binary for it.- All five stdlib modules pass their corpus fixtures; handle RAII is ASan-proven.
docs/examples/log-watcher/compiles and its README mapping table has an empty "could not express" column.- The sample's watch mode detects a silent death (error-final + quiet period) end to end on a real tempfile.
Out of scope (named)
Threads/worker pools in program mode (the shard-actor track owns concurrency); UDP/TLS; fs mutation beyond append; signal callbacks; sqlite-equivalent embedded SQL over RAM (that is the DB engine's job — a future sample can wire MiniLog's idea to select); Haxe macro-based reflection idioms.