Commit graph

38 commits

Author SHA1 Message Date
dd7dd42bd1 feat: bounded mailboxes + WO_T_ACTOR (trap 13); try-arm place-copy fix
- cap 1024 (WO_MAILBOX override at boot): sender-side atomic
  reserve/release on every path — same-shard, cross-shard envelope,
  OOM rollbacks; full mailbox traps the SENDER catchably; delivery
  pop releases; overshoot bounded by in-flight sends (disclosed)
- test_mailbox 12/0: exact cap single-threaded, two racing senders win
  exactly cap slots, drain/refill clean
- corpus run/mailbox-full-trap: parked sleeper, send loop catches
  "actor mailbox full" after >= 1024 sends
- pre-existing compiler bug found + fixed: a try ARM yielding a Text
  PLACE (bare e.msg, try box.field) aliased a register the arm's scope
  end freed — ASan use-after-free, SEGV on the next unwind's
  double-walk; emit_try now applies copy_place_text to both arm
  results; pinned by corpus run/catch-msg-place
- db-bench driver: msgrate keeps iteration 22's unbounded-flood
  contract via WO_MAILBOX=MSG_N (the cap is 24's policy, not 22's)
- battery 12/12 fresh-built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:05:13 +02:00
5fc32b4926 feat: iteration 34 — digest builtins sha1/sha256/hmac_sha256 (ids 85-87)
- runtime/src/crypto.c: hand-rolled cores, whole-value over Bytes,
  allocation-free tails; VM half returns fresh Bytes, WO_T_BOUNDS on
  wrong class id (Bytes builtins' message shape)
- test_crypto: RFC 3174 + FIPS 180-4 + RFC 4231 (incl. long-key case 6)
  + 63/64/65 block-boundary sweep + the RFC 6455 handshake input, 18/0
- compiler surface flat per house convention (sha1, not crypto.sha1 —
  matches base64_encode): types.ml signatures + result types, emit.ml
  ids/dispatch/arity/known-list/drop-table (fresh-Bytes entries so
  results get their drops)
- corpus run/crypto-digests pins the .wo path through base64_encode
- no .wob bump (ticks-84 precedent); WO_B_MAX 87; surface doc rows
- slice marker + board row: iteration 24 (absorbing 31+34) executing
- battery 12/12 fresh-built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:42:43 +02:00
ee018f06e2 Merge branch 'read-path-index' 2026-08-22 23:26:01 +02:00
c2c9b240f1 feat: iteration 36 task 4 — runtime bitwise opcodes, .wob v6
- WOP_BAND..WOP_SHR = 42..46 (wob.h), WOP_MAX 46, WOB_VERSION 6
- trap kind WO_T_SHIFT=12: shift count outside 0..63 traps (DIV0
  precedent, never x86's silent count%64); SHR arithmetic
- vm.c: one shared case-body serves both dispatch flavors; SHL shifts
  the unsigned word (wrapping), SHR casts int64_t (sign extends)
- loader.c + test runner battery + disasm: v6 accepted, new opcodes
  validated three-register, rendered BAND/BOR/BXOR/SHL/SHR
- woc-test 543/0, wovm-test ASan both flavors green, cli_smoke OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:38:22 +02:00
3d75196121 feat: iteration 36 tasks 2-3 — grammar, checker, emit lowering
- ast: unop Not, binop BAnd/BOr/BXor/Shl/Shr
- parser: | ^ join additive, & << >> join multiplicative (Go rungs);
  not joins unary (Lua placement); ladder doc updated
- compound assigns claim the dead +=/-= tokens plus *= /= %= —
  parse-time sugar via rewind-and-reparse, fresh ids by construction
- types: bitwise Int-only both sides, not Bool-only (WO-E201 family);
  literal shift count outside 0..63 rejected as new WO-E223;
  confident-typ knows bitwise=Int, not=Bool
- emit: op_band..op_shr 42..46; not lowers on existing EQ vs zero
- woc-test 543/0 unchanged; scratch smoke parses/typechecks clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:29:47 +02:00
881b90e9c7 feat: O(1) read path — index probe wired end to end
- engine: wo_idx_probe answers single-column equality from the index
  hash buckets (idx_hash_key1 reproduces idx_hash bit for bit; verify
  compares exactly as the slab walk did, so results identical);
  composite indexes keep the walk; both executors wired (local + DB
  actor RPC)
- compiler: probe_key_of_where lowers "var.col == key" on an indexed
  column to DB_PROBE; all where guards still run (guard stays the
  final arbiter); keys = ident/int-literal only; Float/Bytes excluded
  (engine raw-eq narrower than VM float-eq)
- measured: reads 1.3k -> 1.3M ops/s, p50 600us -> 1us (~x850);
  query x830; mixread 1.3k -> 89k s1, 21 -> ~1.9k sN
- gate policy moved into the driver (tolerance_for: refresh-proof);
  latency floors max(4x,100us); quick mode skips poll-bound mix
  floors; both tolerance classes proven to bite
- proof: test_table wo_idx_probe suite (RED first), corpus
  query-index-probe 105/0, full battery green, TSan clean, two
  campaigns pass the refreshed baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 16:44:56 +02:00
d24c705860 feat: iterations 19 + 17 — Float/Bytes scalars (.wob v5), library kind + internal/
- Float full stack: literals (fraction/exponent; `0..10` still a range), f64
  opcodes 34-41, @table column, WAL bit-exact replay, json fractions in and
  shortest-round-trip out. IEEE-quiet — FDIV never traps where DIV does.
- Bytes: a wo_str with its own class id, so alloc/free/copy are shared but no
  Text builtin accepts one; len/at/slice/eq/concat, base64 both ways, json
  boundary as base64; TEXT_COPY preserves the kind.
- No implicit Int/Float mixing (WO-E201 in the typechecker, not the emitter,
  which picks the opcode from one side and would misread the other).
- One IEEE deviation: float_cmp total order (NaN last, -0.0 == +0.0) for
  indexes and order-by, keys canonicalized to match. `?Float` nil is a
  reserved quiet NaN — the zero word is +0.0, WO_NIL_SCALAR's bits are -2.0.
- Renderer prefers fixed over exponential in 1e-6..1e21: pure shortest makes
  a price of 900.0 read `9e+02`. One renderer for interp/json/float_to_text.
- Fixed en route: lexer double-counted the leading digit; is_scalar_shaped
  took Float/Bytes as Int-shaped; Bytes ownership needed a shared heap-scalar
  predicate or temps never dropped; order-by bit-compared negatives backwards.
- Iteration 17: `kind = "library"` (absent = program; bad value = WO-E109),
  entry-less check mode retiring the `--emit` workaround, Go's `internal/` as
  WO-E108 at the consumer's `use`. Driver-only; VM/.wob/GC untouched.
- Framework reorg: internal/{parse,serve}.wo; http/form.wo split out to keep
  media_type/form_values public (parse.wo had grown public surface).
- Docs: link audit (97 -> 88 broken, conflict markers resolved, 2 duplicate
  stories removed), 00-code-review verified 26/27, iterations re-sequenced.
- Also carries the pre-staged pub(read)/using/#if work from the index.
- Gates: corpus 103/0, test_wal 156/0, web-app 26/0, oop-accept ALL MET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:24:15 +02:00
d2d1721e05 feat: spawn / send / actor M — the unified actor surface (arc T3)
- language: `spawn Cls { fields }` expression (ctor semantics — fields
  MOVE; result is the address); `actor M` parametric field type
  (contextual like multi/map — actor stays a legal identifier); `send`
  is a builtin free-fn name, not a keyword (shadowing rule applies)
- typing: M inferred from Cls's receive(msg: M); WO-E221 when receive
  is missing, mis-armed, or M is not a class/record/union; send checks
  addr is `actor M` and the message IS an M (silent when underivable);
  ctor half of spawn delegates to the Ctor arm (completeness, ?T, E207)
- ownership: send's message TRANSFERS (sender's later use = WO-E301,
  corpus-pinned); spawn's fields move via the ctor machinery; an
  address is Copy
- emit: spawn lowers to ctor + LOADK receive's method index + BUILTIN
  68; send is BUILTIN 69 with the message excluded from fresh-arg drops
  (the runtime owns it now)
- runtime: wo_actor (moved-in instance, receive idx, growable FIFO
  mailbox, one delivery fiber at a time); delivery reuses the fiber
  context across messages and re-queues per message (fairness — an
  actor never monopolizes); the runtime drops each message after its
  receive returns; actor state/queued/in-flight messages are GC roots;
  teardown drops everything (main-return reap included); loader knows
  the two arities
- corpus: run/actor-echo (typed spawn/send, one-at-a-time delivery
  interleaved with main by budget — output exact, ASan-clean),
  compile-fail/spawn-no-receive (WO-E221), send-after-move (WO-E301)
- battery green: oop-e2e 92/0, woc-test, wovm-test, log-watcher 7/0,
  employee 8/0, web-app 21/0, deps-accept 8/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:21:24 +02:00
ed29ccadbc fix(compiler): return of a Text interp of a place handed out the borrow
- emit_return's place test matched only bare Ident/Field/Index, so
  `return "${p.content}"` (p a loop borrow) returned the part's own
  string; the caller's eventual drop freed it under the container —
  arena corruption surfacing two requests later (multipart slice)
- the return test now sees through Interp exactly as copy_place_text
  does (is_borrowed_value_t, container reads excluded); bare
  Ident/Field/Index behavior at return unchanged
- interp-borrowed-field fixture grows the return flavor (fn first),
  50 iterations exact
- gates: oop-e2e 89/0 (ASan stage), woc-test green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:32:31 +02:00
81a9f882b5 fix(compiler): Text interp of a place crossed let/assign uncopied
- copy_place_text matched only bare Ident/Field/Index, so a Text-typed
  single-segment interpolation ("${r.method}", r a loop borrow) passed
  the place's own register through a binding/assignment boundary — the
  local aliased the row's field and its overwrite freed it
- release-build crash; invisible to ASan (in-arena free, no redzones)
- now asks is_borrowed_value_t && not is_container_read — exactly
  drop_fresh_text's place test; Int segments (fresh int_to_text) and
  container reads (already copies) stay uncopied as before
- pinned by tests/corpus/run/interp-borrowed-field (crashed both
  runtimes before the fix, 50 iterations now exact)
- gates: woc-test 540/0, oop-e2e 89/0 (ASan stage included)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:04:43 +02:00
970ae2566c feat(woc): dependency resolver + multi-root builds (iter 15 Tasks 2+3)
[deps] entries now resolve to fetched, locked checkouts and compile as
module roots.

- resolve_deps (driver): .wo-deps/<name>/ cache beside wo.toml, wo.lock
  pinning name -> commit SHA. Cold+unlocked: clone at the manifest rev,
  record HEAD. Cold+locked: clone and checkout the LOCKED sha — a moved tag
  cannot change the build. Warm+locked: HEAD==lock -> zero network.
  Divergence is WO-E106 "lock drift" naming both SHAs and pointing at
  --update-deps; every git failure (missing binary, bad URL, bad rev,
  missing locked commit) is WO-E106 naming the dep and step. Guard rails:
  fetched dep must be a writeonce project; a dep with its own [deps] is
  refused (flat-only); dep name colliding with a local module dir is
  WO-E107. All git via the git binary (Sys.command; output reads through a
  temp file) — no network code in the compiler. Full clone, not --depth 1
  (a locked SHA must be reachable regardless of tag movement) — recorded
  deviation from the plan's clone sketch.
- woc --update-deps <dir>: re-fetch at manifest revs, rewrite the lock.
- Multi-root compile: compile_image gains ~deps; app files first then deps
  sorted by name; module_of_multi maps a dep file to `<name>` /
  `<name>/<sub>`, so existing use/pub/collision machinery works across the
  boundary unchanged. The app root's walk skips .wo-deps via the existing
  dot-rule.
- Entry restriction: Emit.emit gains ?entry_ok (default true — test helpers
  untouched); the driver excludes dep files, so a dependency's fn main is
  never the entry.

Verified end to end against local file:// remotes: cold fetch + lock; `use
niceframework` + `use niceframework/strutil` build and run; offline rebuild
with the remote deleted; moved tag -> cold rebuild stays at the locked SHA;
--update-deps follows the tag and rewrites the lock; cache/lock drift,
transitive [deps], and name collision each produce their named diagnostic;
the dep's fn main (returning 99) never becomes the entry. woc-test 540/0;
oop-e2e 87/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:19:57 +02:00
f282451867 feat(json): Bool encodes true/false; fraction/exponent decode fails honestly
Closes json's two documented fidelity limits (iteration 5 strictness):

- field_class gains WOB_FIELD_BOOL (a plain `Bool` field) and
  WOB_FIELD_NIL_BOOL (a `?Bool`: WO_NIL_SCALAR nil + bool encoding) — the
  kind byte alone cannot tell a Bool slot from an Int slot, so the metadata
  carries it. Emitter writes them (field_class_meta); loader whitelists
  them; json.c encodes `true`/`false` (and `null` for a ?Bool nil), decode's
  null/omitted-key pre-write covers NIL_BOOL.
- A JSON number with a fraction or exponent is MALFORMED for an Int field:
  the checked decode (`json.decode(t) as T`) yields nil for the whole
  document instead of silently truncating 3.7 to 3 — the language has no
  float, and corrupting data quietly was the one thing a "checked decode"
  must never do. Floats stay representable through a raw `json.Value` field.
- corpus: run/json-bool-fidelity pins the round-trip (true/false both ways,
  ?Bool null both ways, fraction AND exponent rejected).
- Board's two known-gap entries struck; format doc's field_class marker list
  extended.

Verified: oop-e2e 87/0; runtime test + test-iso OK; woc-test 540/0;
log-watcher 7/0; employee 8/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:04:15 +02:00
092b528081 feat: retire RC from the emitter and the format — .wob v4 (7b Phase 3b)
The compiler no longer emits reference-counting ops anywhere, and the format
reserves them. With Phase 3a's collector this completes the runtime half of
iteration 7b: spec success criteria 3 (no RC ops in any image, opcodes
reserved) and 6 (corpus ASan-clean) are met — `just oop-accept` is fully green.

- owner.ml: the rc machinery is deleted outright — rc_site/rc_op types, the
  rcs table, fn_rcs/rc_groups/rc_escaped, record_rc, release_gc, gc_escape,
  resolve_rc, and the clobber rule (its only consumer was elision). The
  `push`-of-a-gc-value RC_INC special case is gone (the bug class cannot recur
  without RC). Drop tables (owned + LGc kinds) are untouched — the gc mask is
  what feeds the collector's root maps.
- emit.ml: emit_rc, the v_rc view, the escape-acquire anchor, and every
  caller deleted; assignment displacing a traced value emits nothing (the VM's
  store barrier owns it); scope-ended LGc handles clear their gc-mask bit so
  root maps stay precise.
- .wob v4: WOB_VERSION 3 -> 4 in wob.h + emit.ml + disasm.ml + the runner's
  loader battery; opcodes 27-28 removed from the enum/jump table/interpreter
  and REJECTED by the loader like any unknown opcode.
- dump.ml: the == RC == owner-dump section is gone; 6 goldens re-blessed
  (owner dumps lose the section, elision.wo's bc dump loses its RC ops).
- runner.ml: rc-table/ELIDED assertions deleted; the elision test now asserts
  the WHOLE image contains no RC op; the table-contract sweep asserts rc ops
  never appear.
- test_unwind.c: the rc-opcodes test becomes two — the loader rejects reserved
  opcode 27, and an abandoned traced instance is freed by rt_destroy
  (ASan-proven).

Verified: woc-test 540/0 + test_diag 14/0; runtime test + test-iso all suites
ASan/UBSan (test_unwind 12/0); cli_smoke; oop-e2e 79/0 (v4 images end to end);
employee 8/0; log-watcher 7/0; ring runs + reclaimed (freed=3) with zero RC
ops in its image; `just oop-accept` ALL CRITERIA MET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:07:43 +02:00
484a3259b5 feat(compiler): is_gc_class is inference-first (7b Phase 2a)
GC-ness now comes from the inference pass, not only the annotation. A class is
traced if the structural SCC put it in `syms.traced`, OR (temporary bridge
until demand-promotion lands) it still carries `@gc`.

- Types.symbols gains a `traced : StringSet.t`; is_gc_class reads it (union'd
  with the surviving @gc annotation). All symbols literals + both merges carry
  the field.
- typecheck_all injects the classification once (Gcinfer.classify -> traced)
  into the merged table AND every module table, before typecheck/owner/emit.
- emit.ml routes the class gc-flag and the union/drop decision through
  is_gc_class instead of the raw `.is_gc`, so structurally-inferred gc classes
  get the runtime flag. Field-kind derivation already routed through is_gc_class.
- gcinfer.traced_names exposes the traced set for injection.

Effect: docs/examples/gc-cycle now COMPILES with no annotation (the WO-E301
use-after-move at the ring-closing store is gone) — traced classes alias
freely. Bytecode is byte-identical to writing `@gc class Node`.

Verified: woc-test 566/0 (goldens unchanged — every current @gc class stays gc
via the annotation branch, and no golden has a structural-gc-non-annotated
class); oop-e2e 79/0 (gc corpus green).

Not in this slice: demand-promotion (the acyclic-aliased PriceCache case still
needs the @gc bridge) and @gc-in-source-as-error (Phase 2b); the ring RUNNING
(the RC runtime doesn't implement nullable-gcref `?Node` fields — Phase 3).
WO-W201 still fires on gc-cycle (retired in Phase 4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:51:06 +02:00
2677c1457e feat: FK restrict on delete + employee sample runs; group-by parked (9b)
- FK restrict: deleting a row a non-nullable `ref` still points at traps
  WO_T_FK (11), catchable. The compiler now records a `ref` field's
  target class in the class-table field_class metadata; the engine
  (wo_row_has_referrers) scans referencing scalar columns before a
  delete. Correctness-first full scan; the backlink-index optimization
  is recorded for later
- docs/examples/employee now COMPILES AND RUNS all six modes against a
  WAL-durable database: seed (+@unique trap across restart), report
  (per-dept aggregates + payroll), staff (unique probe + backlink +
  ref nav), raise (update-through-row), drop (FK restrict), and
  persistence via replay
- group-by SYNTAX parked to a future iteration (user decision): the
  report mode is hand-rolled from the shipped primitives meanwhile
  (same numbers). "table relations and FK" is complete
- scripts/employee-accept.sh (8 checks) + a `just employee` module;
  manifest parser tolerates iteration 9c's [share]/[[share.clients]]
  sections so `woc .` builds the sample on this branch
- fixtures trap/db-fk-restrict (code 11) + run/db-fk-restrict-catch;
  oop-e2e 79/0, woc-test 566/0, 15 runtime suites, log-watcher 7/0,
  employee-accept 8/0
- 9b story + status board updated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:37:23 +02:00
c8c34c118c feat(compiler): update-through-row + delete statement (9b cont.)
- `e.field = v` where e is a table row lowers to DB_UPDATE_FIELD
  (class, id, field, value) — the row's indexes maintained at the choke
  point; heap-object field assignment still emits SETF unchanged
- `delete <row>` expression: DB_DELETE(class, id), yields the id so it
  composes in `try delete x catch (e) nil` (restrict/trap surfaces
  catchably); Delete AST node threaded through type/owner/dump/emit
- disassembly-caught bug fixed: in tail position dst == the builtin
  window's first reg, so moving the id into dst clobbered the class id
  — reserve dst past the window (the emit_ctor guard)
- run/db-update-delete fixture; oop-e2e up, woc-test 566/0,
  log-watcher 7/0
- employee seed/list/staff/raise/drop now compile+run; only `report`
  (group-by aggregation + projection record) remains

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:24:58 +02:00
75b35fb456 feat(compiler): query order-by + take (9b cont.)
- `order by <field> [desc]` on whole-row queries: a selection sort over
  the result multi, re-reading the key per element via the range var
  (DB_GET_FIELD); O(n^2), KISS, no cost planner — the result sets are
  small by design
- Text order keys use a new WO_B_STR_LT builtin (content compare, reusing
  the WO_B_SORT elem_cmp); scalar keys use the LT opcode. The bug this
  fixes: op_lt on two Text pointers compares ADDRESSES
- `take N`: clamp to count, slice [0,N). `take` is the KwTake keyword,
  not an Ident — matched as the token
- two bugs found + fixed while testing: multi-line query clauses (skip
  the separating newlines) and the key-kind read (must bind the range
  var BEFORE ty_of_expr of the order key, or a Text key silently uses
  op_lt); Index typechecks to the container's element type (`ds[0]`)
- fixture run/db-query-order; oop-e2e 75/0, woc-test 566/0, 15 runtime
  suites, log-watcher 7/0
- employee `seed`/`list`/`staff` modes now compile and run; report
  (group-by+projection), raise (update), drop (delete) remain

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:20:17 +02:00
8817fdec2a feat(compiler): ref + backlink navigation in queries (9b cont.)
- `backlink C.f` field type: parsed, typed as `multi C`, and VIRTUAL —
  filtered out of the stored row layout (no column, omittable in
  ctor/insert), collected in clsrec.cr_backlinks
- reading a backlink (`d.staff`) lowers to DB_PROBE on the source
  class's index for the backing column (backlink_target resolves the
  (source class, index number); a backlink with no backing index has
  no efficient read)
- `ref C` navigation (`e.dept.name`) chains: a ref value is the target
  row's id, so a `Ref C` base navigates into C's fields exactly like a
  table-class value, routing to DB_GET_FIELD both in typecheck and emit
- query navigation source `from s in d.staff`: emit_query evaluates the
  nav expr to get its id-list instead of DB_SCAN; QNav typechecks with
  the range var bound to the navigation's element class
- fixture run/db-query-relations proves both directions; oop-e2e 75/0,
  woc-test 566/0, log-watcher 7/0
- still ahead for employee: order/take, group-by aggregates, projection
  records, delete + update-through-row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:10:13 +02:00
d4bfee9745 feat(compiler): language-integrated query — scan/where/select (9b slice)
- Ast.Query node + parser: `from <v> in <src> where* [group..into] [order
  by] [take] select <e>`, positional `from` trigger so it stays a usable
  identifier; select stays grammar-owned (no DbStub conflict)
- typecheck: range var bound to the source table class; a table-class
  value is its row id at runtime but TYPES as the class, so `e.field`
  checks against the class fields; result is `multi <select-type>`;
  group/order/take/navigation diagnosed WO-E250 not-yet (honest edge)
- emit: `from/where/select` lowers to a bytecode LOOP over DB_SCAN's
  materialized id list — DB_GET_FIELD per column read, where-guards skip
  the push, select projects, result is a fresh multi; no plan tree, no
  SQL text (disassembly-provable)
- field access on a @table-class value routes to DB_GET_FIELD instead of
  GETF (clsrec.cr_is_table + is_table_class); non-table classes unchanged
  so log-watcher is unaffected
- the ASan-caught bug kept in a comment: a table-class query element is a
  SCALAR id, not an OWNED pointer — tagging the result multi OWNED dropped
  an id as a pointer (SEGV in wo_drop_obj)
- fixture run/db-query-scan (where-filter + select-whole + select-field);
  oop-e2e 74/0, woc-test 566/0, log-watcher 7/0
- SLICE scope: group-by aggregation, order/take, and ref/backlink
  navigation are the next chunk (employee report/staff/raise need them)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:28:09 +02:00
6f2f9b6f0a feat: secondary indexes + @unique trap (iteration 9, Task 4; wob v3)
- .wob v3: class records carry an index tail (flags bit0 = unique,
  col_cnt, columns) -- @table(index:[a,b]) entries plus one unique
  single-column entry per @unique field; loader validates columns in
  range and scalar/Text-kinded; emitter validates the declarations
  (unknown column, un-indexable kind => diagnostic)
- engine: db_index hash multimap per table, built from the class
  table at first touch, maintained ONLY inside wo_row_insert/
  wo_row_remove; unique checks re-compare actual column values (a
  hash is a hint); replay re-indexes via wo_row_raw_commit AFTER
  slots are filled, so recovered tables carry their indexes
- WO_T_UNIQUE = 10; a violating insert is un-applied whole (bitmap,
  hash, count, and the never-observable id reclaimed) and traps
  catchably -- the employee SEED-DUP pattern
- wo_row_insert gains err_kind so db.c maps UNIQUE/OOM/other to the
  right trap; test images and the runner's loader mirror speak v3
- fixtures: trap/db-unique-violation (code 10 exact) and
  run/db-unique-catch (catchable dup, composite index accepts
  duplicates, next id dense after a refusal)
- gates: oop-e2e 73/0, all 15 runtime suites, woc-test green,
  log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 12:57:32 +02:00
30ef8d8533 feat: insert executes (iteration 9, Task 3) -- DB_STUB retires for insert
- compiler: `insert Class { ... }` is a typed Ast.Insert in statement
  AND expression position, sharing the ctor literal's field grammar;
  typechecked with the ctor's omittable rule; result = the row id (Int)
- owner pass: the engine copies at the row API, so an insert BORROWS
  its field values -- no transfer, no E304; node is trap-capable and
  carries a live-mask drop entry like DbStub did
- emit: builtin 61 window = class-id const + one slot per DECLARED
  field in declaration order; omitted defaults emitted, omitted ?scalar
  gets WO_NIL_SCALAR, other omitted optionals the zero word; fresh
  argument values reaped after (the push/set copy semantics)
- runtime: database/src/db.c executes via the choke-point row API;
  rt.db/rt.wal opaque handles on wo_rt; WO_DATA=<dir> = replay
  <dir>/shard-0.wal at boot + commit-before-ack per statement (the
  builtin's return IS the ack until iteration 8 ticks); failed commit
  un-applies the row and traps WO_T_IO; loader validates the class-id
  slot (variable window documented in wob.h + format doc)
- the promised diff: trap/pricing-set-price-db-stub is now
  run/pricing-set-price-insert printing engine-allocated ids;
  durability smoke prints 1,2 then 3,4 across two WO_DATA runs
- old "bare insert is an Ident" unit test rewritten to the new
  contract; runner's loader mirror accepts id 61; goldens re-blessed
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 566/0,
  wovm-test green, log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:15:06 +02:00
3c53d27347 fix: close per request, soak the daemon, kill five soak-found leaks (Tasks 5+6)
- Task 5: net.close on every path out of a serve iteration (400
  included) and the listener on stop; measured 4 -> 54 fds over 50
  requests before, 4 -> 4 over 200 after. The loop's comment claimed
  the iteration-end drop IS the close -- wrong twice (net.Conn is a
  scalar, and a drop would not close an fd); it now says what is true
- Task 6: LW_SOAK=<seconds> in the acceptance script -- each mode under
  load, resident+descriptor deltas against a WARMED baseline (warm-up
  includes load: cold-to-high-water is not growth), 256 KiB / zero
  tolerance; LW_ACCEPT_WOVM soaks another build
- the soak caught ~1.6 MiB/min of in-arena leaks ASan cannot see (the
  arena is one allocation to LeakSanitizer); an arena size-class
  census + pointer trace attributed five bugs:
  - jparse_string sized every decoded string at "rest of the input"
    and relabeled len after -- blocks filed on free lists their next
    allocation never reads (fs.read_all's mis-size, again); copy out
    exact, free at the taken size
  - `!=` never dropped fresh operands (headers["authorization"] !=
    "Bearer ${key}" leaked both sides per request); Ne now reaps as Eq
  - an Int interpolation segment is a fresh int_to_text, not a borrow;
    is_borrowed_value_t asks the segment's type
  - json.encode(Ctor{...}) had no owner -- record + both field copies
    leaked per tool call; its bespoke lowering now drops the argument
  - a discarded expression statement owns its result: `pop(lines);`
    leaked the popped element; reader builtins excluded
- after: arena live bytes flat per request on every handler; release
  soak 30 s per mode watch 0 / run 0 / mcp +20 KiB, descriptors flat;
  ASan build flat at 14600 KiB across 601686 requests in 90 s past its
  ~1200-request quarantine warm-up
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 565/0,
  wovm-test green, log-watcher 7/0 (soak opt-in, fast path <1 min)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:32:49 +02:00
22910e3974 fix: a stopping program stops (executable plan, Task 4)
- blocking stdlib calls that PARK (net.accept, socket read/write,
  time.sleep, a child wait) no longer restart the syscall when the
  stop flag is set on an interruption: a server sitting in accept
  ignored SIGTERM and only `kill -9` ended it
- a stop is NOT a trap -- builtin.h's WO_SYS_STOPPED carries no error
  record and no catch handler sees it (`try` must not swallow
  SIGTERM); the VM unwinds the whole stack through the same drop
  machinery an uncaught trap uses, so nothing leaks on the way out
- wo_vm_call gained a third outcome (1 = stopped); the CLI maps it to
  the status the program's own `return 0` would have given, and a
  regular-file read keeps its plain EINTR retry -- it does not park
- an ASSIGNMENT was not an ownership boundary: `api_key =
  j.mcp.apiKey` moved the field pointer into the local, so the local
  aliased the record and the first unwind freed the same string twice
  (SIGSEGV in class_free). `let` copied a Text place, assignment now
  does too -- the same double free was latent on the normal exit path,
  hidden by the order the compiler happens to emit drops in
- log-watcher-accept is 7 checks: the seventh is the stop itself, with
  the hard kill demoted to a fallback whose use is the failure
- measured under ASan: mcp parked, mcp after traffic, watch and run
  all exit rc 0 with zero leaks; SIGINT behaves as SIGTERM
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 565/0,
  wovm-test green, log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:58:21 +02:00
ef74d157b6 fix: drop the values nobody names (executable plan, Task 2)
- the drop tables track bindings only, so six shapes had no owner: a
  comparison operand (`if parse_expr(s) == nil` abandoned a schedule
  record and its five containers per cron line), a borrowed call
  argument (a 1 KB string per MCP request), a container read's copy,
  a loop's iterable, a projected record, and any of those escaped by
  a `return` from inside the statement that built them
- `c[i]` is the one place expression whose register holds a COPY:
  no second copy at a boundary (`let u = tokens[0]` copied twice and
  abandoned the first), and a drop where every other place is left be
- never drop an argument register after a CALL — the callee's frame
  overlaps it; the reap moved into call_window's pre-call stash
- a statement-owned temporary is parked in a LOCAL slot: a loop
  reclaims every temp for its body, and the end-of-statement DROP was
  releasing the loop counter instead of the record
- reader builtins (get/latest/key_at/val_at) keep arg0 alive — their
  result points into it — but their key argument is ordinary
- measured: run 2 112 B -> 64 B, flat 8 s to 20 s; MCP mix 21 312 B /
  63 -> 64 B / 1; every handler flat from 2 to 6 requests; the 64 B
  left is Task 3's argv container
- gates: oop-e2e 71/0, woc-test 565/0, wovm-test green, log-watcher 6/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:23:21 +02:00
eb0095428d fix: Text is an owned value copied at every boundary (executable plan, Task 1)
Measured on the workload's supervisor mode, eight seconds, clean SIGTERM exit:
run 1 051 040 B in 24 allocations -> 2 112 B in 19; watch 128 B in 2 -> 64 B in
1. corpus 71/0, woc runtest 565/0, wovm unit gates green, just log-watcher 6/0.

- owner.ml: `oclass_of` called `Text` a builtin scalar, so it was Copy and NO
  Text local was ever dropped — that, not the missing stdlib table, was the
  leak. Text is now Owned, which forces an answer for what it does at an
  ownership boundary, and the answer is uniform: it is COPIED. Into a
  container (push/set/`m[i] = v`, already true), into a field (SETF), out of a
  function (return), into a binding (`let s = other`), and into a loop cursor.
  The source keeps its value; a freshly built Text stays the caller's and is
  dropped at the site
- owner.ml: resolve_callee answers for three shapes it never knew — reserved
  stdlib members, builtins, and a class's `static` members — so their results
  get a type, an owner and a drop
- vm/builtin: WO_B_TEXT_COPY, the one new builtin the rule needs; SETF copies a
  TEXT field in; emit copies a Text read out of a container, bound from a
  place, returned from a place, or loaded into a cursor, and drops a freshly
  built one after a copying store
- sysio.c: fs.read_all/net.read allocated their cap then relabelled the buffer
  with the short length — but wo_str_free sizes a block by its len (no size
  headers, obj.h), so a 1 MiB buffer wearing a 30-byte length went onto a
  32-byte free list and never came back. They copy out at the true size now
- two regressions the corpus caught, fixed in the same pass: a @gc value read
  out of a container is a plain borrow, not an rc-counted alias; and push's @gc
  escape is keyed on "push is not a user-declared fn" rather than "the callee
  did not resolve", which stopped being true once builtins resolved
- docs: Task 1 closed in the executable plan with its before/after numbers, and
  the status board's item 1 records the deeper root cause

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:45:03 +02:00
4cf548d6a0 fix: copy-on-push closes the container double-free; line-buffer program output
The unsoundness is closed. All four MCP tools now answer correctly over HTTP
(get_running_crons, list_logs, tail_log -> ["info two","error three"],
search_log -> its match) where `tail_log` used to return
{"isError":true,"text":"tool failed: not a text value"}. corpus 71/0,
woc 565/0, wovm gates green, ASan clean on the container fixtures.

- builtin.c: multi_push, map_set (key AND value) and multi_set COPY a TEXT
  element into the container. The container's declared kinds already make it
  the owner of what it holds, so storing a caller-owned pointer gave one
  string two owners — `push(res, e.log_path)` freed a record's field out from
  under it. OWNED/GCREF elements still move (not copyable; the @gc escape
  keeps their counting), so `set`'s @gc gap is untouched and still recorded
- emit.ml: `drop_fresh_text` — after push/set and the `m[k] = v` / `m[i] = v`
  sugar, a value that was freshly BUILT (call result, `..` chain,
  interpolation) is dropped here, while a value read out of a place is left to
  its owner. That asymmetry is the point: before the copy the borrowed case
  double freed and the fresh case leaked
- obj.c: the runtime's output stream is line-buffered. A long-running program
  writing progress with `print` was invisible when stdout was a file or a pipe
  (full buffering), and a killed one lost its log entirely; byte-exact
  fixtures are unaffected
- scripts/log-watcher-accept.sh + `just log-watcher`: the acceptance test for
  the sample — compile, watch (alert), run (schedule), and three MCP checks.
  Hardened after it lied to me: a per-run port (a stale server on a fixed port
  answered for it), a connect-probe that fails loudly when OUR server did not
  come up, replies read by Content-Length rather than to EOF (the sample never
  closes), and kill -9 on teardown
- docs: the copy rule is in the builtin surface; the status board records the
  gap as closed and adds the new one — a blocking accept/read swallows SIGTERM,
  which belongs to the shard-actor runtime's event loop, not to a patch here

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:50:18 +02:00
0acb6be559 fix: net.Conn is a scalar, \r escape, map[k] is optional, interp node ids
Found by driving the compiled log-watcher's third path — the MCP server. It now
answers real JSON-RPC over HTTP: initialize returns protocolVersion/serverInfo,
tools/list returns the full tool list (1049 bytes of generated JSON), and an
unauthorized request gets 401 {"error":"unauthorized"}. corpus 71/0, woc 565/0,
wovm gates green.

- lexer: `\r` and `\0` escapes. Without `\r` a program cannot write CRLF at
  all — the server's `index_of(buf, "\r\n\r\n")` was searching for a literal
  backslash-r, so it never found a header terminator and hung on every request
- parser: an interpolated sub-expression now mints node ids from the OUTER id
  space. A fresh sub-parser started at 1, so `${...}` nodes collided with the
  file's own nodes — and every side table (drops, moves, rc, masks, f_decl) is
  keyed by node id. Surfaced as WO-E404 "ownership table names `headers`,
  which has no register"; silent misattribution otherwise
- types.ml: `net.Conn` is a reserved SCALAR type (a file descriptor). It was
  falling through as "some user class", i.e. WO_K_OWNED, so the frame would
  DROP an integer at scope end
- types.ml: confident_typ knows `..` yields Text. Interpolation desugars to a
  Concat chain, so without it every interpolated value looked underivable —
  which is why the `+`-on-Text check missed two live sites in the workload
- `m[k]` on a map is now the OPTIONAL read (nil for a missing key), while
  `get(m, k)` stays the asserting one that traps KEY. That is what makes
  `let v = m[k]; if v != nil` — the workload's header lookup — work.
  trap/missing-map-key now pins `get(...)`, and the surface doc records the
  split
- json.encode of a `json.Value` emits it verbatim (kind 255): an echoed id was
  coming back as "1" instead of 1
- disasm: TRY/ENDTRY render instead of ?OP32/?OP33
- status board: the push-of-a-borrowed-Text gap is now recorded with the
  concrete failure it produces (tools/call tail_log), plus the leaked
  temporary-record shell found in the same disassembly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:47:45 +02:00
993a540d7a fix: nullable scalars need their own nil word; EQS accepts nil
Found by running the compiled log-watcher, not by reading code: the supervisor
rejected every cron line ("malformed schedule: * * * * *") because a `*` field
expands to 0 and `?Int`'s nil was also 0, so `a == nil` was true for a real
value. Both log-watcher subcommands now behave: `watch` alerts on a live file,
`run` reports SCHEDULE /var/log/backup.log: * * * * *. corpus 71/0, woc 565/0,
wovm gates green.

- a nullable SCALAR (?Int/?Bool/?Timestamp/?Id) spells nil as WO_NIL_SCALAR
  (-2^62), not the zero word. Heap-shaped optionals keep 0 — a null pointer is
  unambiguous. The value is -2^62 and NOT INT64_MIN on purpose: the compiler's
  integers are OCaml's 63-bit natives, so INT64_MIN is not expressible there
  (and `min_int * 2` silently wraps to 0 — the first attempt did exactly that)
- the class table marks such fields (WOB_FIELD_NIL_SCALAR in field_class), so
  the runtime writes the right absence where it produces absence itself:
  json.decode leaving a key absent or seeing `null`, and parse_int on
  unparseable input (so parse_int("0") is now distinguishable from a failure).
  json.encode renders a nil scalar as JSON null
- emit.ml: `nil` takes its word from its destination (annotation, field,
  return type); a comparison against `nil` emits the literal with the other
  operand's type, so ?scalar compares against the sentinel and ?heap against 0
- vm.c: EQS accepts a nil operand — two `?Text` values compare with it, and the
  answer is "both absent is equal, one absent is not". Trapping there made
  `a != b` on optionals unusable (it was trapping BOUNDS "null text" in the
  supervisor's rescan). A non-nil operand must still be a real Text
- docs: both normative docs now state the heap-vs-scalar nil split and the EQS
  rule; the stale duplicate vm_unwind comment is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:28:50 +02:00
b973ea107e feat: program mode (argv + exit code); reject + on Text; nil compares by word
docs/examples/log-watcher now COMPILES AND RUNS: `wovm lw.wob watch app.log 2 1`
tails a live file, classifies levels and fires its alert
("last entry is error, quiet for 2s"). corpus 71/0, woc 565/0, wovm gates green.

- program mode: the entry is `fn main` taking nothing or one `multi Text`;
  runtime/src/main.c builds that list from the program's own arguments (not
  the program name, not the image path) and the entry's return value is the
  process exit code (low byte); the loader accepts a 0- or 1-arg free-fn entry
- `+` on Text is now WO-E201 pointing at `..`. This was a memory-safety hole,
  not a style nit: the emitter lowered it to ADD on two heap pointers, and the
  workload's own `out = out + char_of(c)` produced a wild pointer that
  segfaulted the VM inside starts_with. Reported off confident types only
- `x == nil` / `x != nil` lower to EQ (a word compare), never EQS: nil is the
  zero word and EQS dereferences its operands, so a nil guard would trap
  instead of answering
- docs/examples/log-watcher: seven `+`-on-Text sites corrected to `..`
  (logtail sanitize, mcp header/body/carry assembly, supervisor detections
  line) — the sample was carrying the Haxe habit, and the language reserves
  `+` for arithmetic by doctrine
- wovm CLI takes arguments after the image path (`wovm <file.wob> [args...]`)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:18:01 +02:00
065ac99224 feat: json encode/decode + as, .wob v2 class metadata — log-watcher compiles
docs/examples/log-watcher (1285 lines, 7 files) now compiles clean: 0
diagnostics, a 35KB .wob written. corpus 71/0, woc runtest 565/0, every wovm
unit gate green (both dispatch flavors).

- .wob v2: each class row gains three u32 per-field arrays — the field's NAME
  constant, the CLASS it refers to (or the json-raw marker), and a container
  field's ELEMENT kinds. json is then a runtime service driven by metadata
  instead of per-type generated code. loader/emitter/disassembler/test
  assembler all read and write v2; field-name constants are interned with the
  rest of the pool (interning during serialization silently loses them)
- runtime/src/json.c (new): encode by static kind + object headers + class
  table (nested records need no static knowledge); decode parses and BINDS
  straight into the target class — keys matched to field names, nested objects
  built as the field's class, arrays as a multi of the field's element kind,
  unknown keys skipped, absent keys nil. Malformed input is nil, never a trap
- `as`: `json.decode(text) as T` is the one cast this language has (WO-E403
  for any other `as`, and for a bare json.decode with no target type). Its
  result is `?T`, which is why the decode and the target are one instruction
- json.Value: a reserved type name for a value the source does not inspect —
  the raw JSON slice, kind TEXT, re-emitted verbatim by encode
- docs: 00-wob-format.md is now the v2 reference (class metadata, TRY/ENDTRY,
  the whole builtin surface, WO_T_IO); 08-builtin-surface.md documents the
  text/container builtins, the OS modules with their predeclared records, and
  json's two documented limits (Bool encodes 0/1, floats truncate)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:08:46 +02:00
76dacf6985 feat(compiler): richer field defaults + cross-file consts
log-watcher diagnostics 55 -> 48 (all json-rooted now). corpus 71/0, woc 565/0.

- emit.ml: a field default may now be `nil`, `{}` (a fresh empty container of
  the field's declared type) or `Rec {}` (a record built from its own field
  defaults) — the workload's `st: TailState = TailState {}` and
  `scheduled: map<Text, CronWatch> = {}` shapes
- parser.ml/main.ml: a top-level `const` is visible to every file of its
  module, not just its own file — files in a directory are one module by the
  discovery contract, and the workload reads logtail.wo's `const CHUNK` from
  mcp.wo. A file's own const still wins on a name collision

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:56:55 +02:00
fb91085bdb feat: systems stdlib OS half — fs, time, env, net, proc
log-watcher diagnostics 129 -> 55 (json is what is left: 13 encode sites,
3 `as` parses and their cascade). corpus 71/0, woc 565/0, wovm gates green.

- runtime/src/sysio.c (new): 17 builtins behind the reserved module names —
  fs.exists/list/stat/read_all/read_at/append, time.sleep/local/iso,
  env.get/stopping, net.listen/accept/read/write/close, proc.run. Thin
  blocking libc calls; a failed syscall traps the new WO_T_IO with errno's
  own message, which `try ... catch` is how a program handles
  - record-returning members (fs.stat, time.local, proc.run) take their
    result record's CLASS ID as the last argument, so the VM allocates what
    it fills without knowing any source type name (the err_fill pattern)
  - absence is the zero word: a missing path from fs.stat and an unset
    env.get are nil, not traps
  - env.stopping installs SIGTERM/SIGINT handlers on first use only
- types.ml: predeclared Stat/TimeParts/Proc records (field order is the
  contract with sysio.c) + the stdlib member table (arity, builtin id,
  return type, result record) + stdlib return types in confident_typ
- emit.ml: stdlib member calls lower to their builtin with the record class
  id appended; WO-E406 now means "no such member", not "not linked";
  predeclared records enter the class table only when a program needs them
- emit.ml: fstate carries the method's declared return type, so a tail
  `return []` / `return {}` gets its element kinds; a non-empty list literal
  falls back to its own element type when there is no declared destination
- types.ml: confident_typ chases a container read (`c[i]`), which is what
  makes a switch over a value pulled out of a map resolve; a void `try` arm
  no longer demands its catch arm agree
- corpus: lang-use-stdlib-not-linked now pins WO-E406 for an unknown MEMBER
  (fs.slurp) — the "not linked" premise is gone now that fs is linked

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:54:31 +02:00
ea77fc9d5c feat: map iteration (for k, v in m) + multi element writes
log-watcher parse errors 7 -> 3 (only `as` left); corpus 71/0, woc 565/0.

- wob.h/builtin.c/loader.c: WO_B_MULTI_SET — `m[i] = v` for a multi, dropping
  the element it replaces (the mirror of map_set, which the format doc's sugar
  rule already had; the "no element write" gap is closed)
- ast/parser: `for k, v in m` — the second name binds the value for that key
- types.ml/owner.ml: the two cursors take the map's key and value types; both
  are borrows of what the map owns, so neither is dropped per iteration
- emit.ml: map form lowers to len + key_at/val_at over slot indexes (insertion
  order, cont.h's parallel arrays), same loop skeleton as the multi form

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:46:53 +02:00
e5c5952cdc feat: nil literal + systems-stdlib text/container builtins
log-watcher diagnostics 272 -> 129 (parse errors 7, stdlib-module calls 49,
lowering gaps 72). corpus 71/0, woc runtest 565/0, wovm unit gates green.

- nil (haxe-parity Task 6's literal half): `nil` keyword, Ast.NilLit, lowered
  to the zero word for every `?T` — the representation the format doc already
  fixes ("a nullable field stores exactly what T stores and spells nil as 0"),
  so no boxing, no unbox on read, and every drop plan already skips it.
  Contextual on its destination in both type derivers, like `[]`/`{}`
- 23 new builtins (wob.h ids 16..38, loader arities, builtin.c): len, byte_at,
  print_err, starts_with, ends_with, index_of, last_index_of, substr, trim,
  to_lower, char_of, parse_int, split, split_ws, join, slice, pop, shift,
  sort, reverse, remove, key_at, val_at
  - fresh-Text/fresh-multi results allocate in the VM; `split`/`split_ws` fix
    their element kind (Text), `slice` copies its source's — and COPIES Text
    elements so a slice and its source never both own one value
  - pop/shift hand the element's ownership to the caller; remove drops the
    map's own key and value; key_at/val_at expose slot-ordered enumeration
    (what `for k, v in m` will lower onto)
  - parse_int is optional-shaped: unparseable is 0, `?Int`'s own nil
- obj.c/obj.h: wo_str_alloc (uninitialized Text of known length) so `join`
  builds its result in one allocation instead of one per element
- types.ml/emit.ml: builtin signatures, argument-shape requirements and
  return types for all 23 — the return table is also what classifies a `let`
  holding a fresh Text or multi as owned, so an omission there is a leak

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:43:38 +02:00
2bb39d6b6b feat: try/catch over the trap system (haxe-parity plan 8, Task 5)
VM catch frames + expression-form try/catch in the compiler. Uncaught traps
keep byte-for-byte today's surface. log-watcher parse errors 18 -> 7;
corpus 71/0, woc runtest 565/0, wovm unit gates green (both dispatch flavors).

- wob.h: WOP_TRY (A sBx: push catch frame, handler at pc+sBx) / WOP_ENDTRY;
  WO_B_ERR_FILL builtin (fills the catch record: 0 code, 1 line, 2 method,
  3 msg — the field-order contract with the compiler)
- vm.h/vm.c: catch stack (depth, handler pc, error reg) + the caught error;
  vm_unwind takes a stop depth, so a caught trap kills every frame above the
  catching one exactly as an uncaught trap would, then releases only what the
  try region owned in the catching frame (drop-entry diff against the handler
  pc) and resumes at the handler; RET/RET0 drop the catch frames of the frame
  they leave; TRAPF resumes instead of returning when the trap was caught
- builtin.c: err_fill allocates the method/msg Texts into the record the
  compiler owns, so the pending error never has to outlive the landing
- loader.c: TRY's handler target validated like a jump, error register like
  any register operand; err_fill arity
- lexer/token/ast/parser: `try`/`catch` keywords; `try expr catch (e) expr`
  and `catch (e) { block }`, newline allowed before `catch`; try binds looser
  than every operator, so `try a / b catch (e) 0` catches the division
- types.ml: predeclared `Error` record (merged table only), catch binding,
  arm-type agreement reported only when both arms are confidently typed
- owner.ml: analyze_try — the catch arm is an alternate flow join off the
  entry state, the error record is an owned handler-scope local
- emit.ml: TRY/body/ENDTRY/JMP + handler prologue (NEW Error, err_fill),
  join drops on both arms, `Error` class entry only for programs that catch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:35:36 +02:00
2a98186259 feat(compiler): let-type annotations, container literals, statics, pub(read)
Driving goal: compile docs/examples/log-watcher (1285 lines of .wo).
Diagnostics on that program: 481 -> 379; parse errors 85 -> 18.
tests/corpus via scripts/oop-e2e.sh stays 71 checks / 0 failures.

- ast.ml: `Let.ty` is a full `field_ty` (was a bare name), so `multi Text`,
  `map<K, V>` and `?T` annotate a local; new `ListLit`/`MapLit` expressions;
  `method_decl.is_static`; `field.pub_read`
- parser.ml: let annotations go through parse_field_ty; `[]`/`[a, b]` and
  `{}` literals in expression position; `static const`/`static fn` in class
  bodies (one token of lookahead — `static` stays an identifier);
  `pub(read) field: T`; a `;` ends a statement on its own, so one-line
  guard bodies (`{ skip(...); return; }`) parse
- emit.ml: list/map literals lower to multi_new/map_new + one multi_push per
  element, element kinds from the destination's declared type (WO-E403 with
  no typed destination, same rule multi_new already had); `static fn` gets a
  receiverless method record (arg_cnt = params) and lowers `Cls.fn(args)`
  through emit_direct with no `self`; a static can satisfy no interface
- types.ml: a written `let` annotation is now the authority for the binding's
  type (the only thing that types `[]`/`{}`/`nil`); `static_method_of` +
  static-call return types; method_info.is_static is wired from the AST
- owner.ml: container literals are fresh owned values, elements read in place
  (inherits push()'s open borrowed-element gap, noted in the code)
- plan doc: `Spec:` header line so planboard's lint accepts the plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:22:47 +02:00
1ba035397d feat(compiler): iteration 5 Tasks 1-4 — modules, language surface, switch, typedef records + enum variants
- Modules: `use`/`pub`, directory-as-module, per-module symbol resolution (a
  flat first-wins merge silently ran the wrong `pub fn` body), six reserved
  stdlib namespaces typed UNKNOWN-BUT-RESERVED.
- Surface: `and`/`or` (own precedence tier, short-circuit, Bool-only), `${}`
  interpolation desugared at parse time, `const`, break/continue with
  drop-correct exits, do-while, inline-fn rejection.
- switch expr/stmt: required `default` over scalars/Text, arm unification,
  EQ/EQS+JZ lowering, per-arm drop scopes with N-way JOIN-DROP; `default`
  sorted last by a shared lowering order (textual order made arms dead).
- typedef records: structural, same shape = one class entry; `?name: T`
  nullable-by-shape; emit_ctor fills omitted defaults; `type` as field name.
- Enum variants: all-bare unions = int ordinals; any-payload = one class
  entry per variant, tag IS the header class_id (no header field, no format
  bump); exhaustive switch without `default`; arity checked both directions.
- Payload escape modeled as move-out (pointer-kind fields only — a scalar
  escape is a copy); caller reaps owned heap temps passed by borrow: two
  unbounded LSan-blind leaks, 10.5 MB -> 1.5 MB flat over 300k iterations.
- Fixed en route, each with a RED repro: dead E209 builtin-arg check and
  `int_to_text` missing from both types.ml builtin tables (both segfaulted
  wovm), multi-file phantom double-report, emit_ctor's field temp clobbering
  dst in tail position (pre-existing), warnings swallowed without an error.
- Two fenced VM builtins: `int_to_text` (13), `variant_tag` (14).
- 14+565 unit (was 14+401), corpus 71 (was 32) plain and under wovm_asan,
  wovm-test + cli_smoke green. Log-watcher 307 -> 93 diagnostics (85 E101 /
  4 E207 / 1 E208 / 3 W202); the 5 non-E101 residuals await Task 7 grammar.
2026-08-12 14:40:07 +02:00
79605cbb4f feat: milestone 1 complete — .wob emitter, conformance corpus, single binary; GC redesign specced
- `woc` now emits `.wob` that `wovm` runs: emit.ml lowers the typed,
  owner-annotated AST (scope-stack registers with a >64 WO-E401 diagnostic,
  Lua-style call windows, ICALL by slot, dedup const pool, drop maps, line
  tables, implicit terminators); disasm.ml backs `--dump-bc` goldens.
- Ownership lowering consumes the four owner tables verbatim; RESIDUAL is the
  only source of borrow ops, coalesced per operand. Review caught the emitter
  consuming only 2 of owner.ml's 4 residual producers — an assignment-anchored
  aliasing violation ran to exit 0 instead of trapping; fixed, plus a backstop
  raising WO-E404 for any residual region left unconsumed.
- Conformance harness `scripts/oop-e2e.sh` (`just oop-e2e`): four fixture
  kinds with exact outcomes — byte-exact stdout, one WO-E### anchored on
  `error CODE:`, numeric trap code, gc trace. 25 fixtures incl. pricing-demo
  logic, the ownership suite, and DB_STUB's parse-but-trap. `tests/` un-ignored
  so the corpus is actually tracked.
- `woc build` produces a self-contained binary: wovm copy + appended image +
  20-byte trailer, self-exec via /proc/self/exe. Verified relocated outside
  the repo, argless, and against adversarial trailer corruption.
- Milestone 1's five spec criteria all MET (`just oop-accept`). Criterion 3
  closed by WO-E405 — the entry must return `Int`, since program mode already
  says its return value is the exit code — which deletes the leak class
  without adding return-type metadata to the format. `gc/held-cycle` retired:
  an externally-held cycle is not expressible in a post-exit pump.
- New spec: inferred GC + incremental per-shard tri-color mark-sweep, retiring
  `@gc` and reference counting. Story gains iterations 7b (that work) and 9b
  (`@table`, relations, compiler-checked query); `.dev/reference` gains a
  sparse System.Linq checkout. Priority: 5→6→7 (log-watcher) then 7b, 8, 9, 9b.
2026-08-11 19:31:26 +02:00