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>
This commit is contained in:
shoney.arickathil 2026-08-20 19:23:54 +02:00
parent 5521d21a84
commit d24c705860
77 changed files with 3352 additions and 423 deletions

View file

@ -269,6 +269,13 @@ let merge_symbols (syms_list : Woc_lib.Types.symbols list) : Woc_lib.Types.symbo
let duplicate_symbol_code = Woc_lib.Diag.types_prefix ^ "14" (* WO-E214 *) let duplicate_symbol_code = Woc_lib.Diag.types_prefix ^ "14" (* WO-E214 *)
(* iteration 17: WO-E108 — a consumer `use` reached into a dependency's
`internal/`. A use-resolution diagnostic, so it lives in the E1xx band with
the other path/import errors and rides the normal collector path (exit 1),
unlike the manifest errors WO-E106/E107/E109 which print directly and
exit 2. *)
let dep_internal_code = Woc_lib.Diag.parsing_prefix ^ "08"
let report_collision (collector : Woc_lib.Diag.Collector.t) ~(kind : string) ~(name : string) let report_collision (collector : Woc_lib.Diag.Collector.t) ~(kind : string) ~(name : string)
~(file : string) ~(pos : Woc_lib.Ast.pos) ~(first_file : string) ~(file : string) ~(pos : Woc_lib.Ast.pos) ~(first_file : string)
~(first_pos : Woc_lib.Ast.pos) : unit = ~(first_pos : Woc_lib.Ast.pos) : unit =
@ -503,7 +510,48 @@ let compile_image ?(deps : (string * string) list = []) path =
deps deps
in in
match owner with match owner with
| None -> (f, prog) | None ->
(* iteration 17: the dependency-privacy boundary. A CONSUMER file
may not `use` a dep module whose path contains the segment
`internal` — Go's rule, and a pure use-resolution check, which
is why it needs no keyword and no syntax. Consumer-only by
design (spec §3): the dep's own `use internal` is prefixed by
the Some arm below and stays legal, so a library can organize
its interior freely.
Matched on a whole SEGMENT, never a substring: a dep module
named `internals` or `my_internal_thing` is ordinary public
surface. The root project's own `internal/` directories never
fire this either — the first segment has to name a DEP.
The use is left in place rather than dropped: the collector's
has-error path already stops emission, and dropping it would
turn one clear diagnostic into a cascade of
unknown-type errors from the same file. *)
List.iter
(fun d ->
match d with
| Woc_lib.Ast.Use u -> (
match u.Woc_lib.Ast.segments with
| seg :: rest
when List.exists (fun (dname, _) -> dname = seg) deps
&& List.exists (fun s -> s = "internal") rest ->
let pos = u.Woc_lib.Ast.pos in
Woc_lib.Diag.Collector.add collector
(Woc_lib.Diag.error ~code:dep_internal_code ~file:f
~line:pos.Woc_lib.Ast.line ~col:pos.Woc_lib.Ast.col
~message:
(Printf.sprintf
"`%s` is internal to the dependency `%s` — a module under \
`internal/` is the library's own business and cannot be \
imported across the `[deps]` boundary"
(String.concat "/" u.Woc_lib.Ast.segments)
seg)
())
| _ -> ())
| _ -> ())
prog.Woc_lib.Ast.decls;
(f, prog)
| Some (dname, _) -> | Some (dname, _) ->
let redecl = function let redecl = function
| Woc_lib.Ast.Use u -> | Woc_lib.Ast.Use u ->
@ -518,6 +566,12 @@ let compile_image ?(deps : (string * string) list = []) path =
parsed parsed
in in
let syms, module_syms = typecheck_all collector ~root:path ~deps parsed in let syms, module_syms = typecheck_all collector ~root:path ~deps parsed in
(* haxe-parity Task 7: typecheck recorded every `using` extension call;
rewrite them into plain free-fn calls (receiver first) so the owner
and emit passes below need no using-awareness at all *)
let parsed =
List.map (fun (f, prog) -> (f, Woc_lib.Types.apply_using_rewrites ~file:f prog)) parsed
in
let units = let units =
List.map List.map
(fun (f, prog) -> (fun (f, prog) ->
@ -629,54 +683,11 @@ let default_runtime_path () : string =
if Sys.file_exists sibling && not (Sys.is_directory sibling) then sibling if Sys.file_exists sibling && not (Sys.is_directory sibling) then sibling
else "runtime/wovm" else "runtime/wovm"
let build_mode ?(deps : (string * string) list = []) ~(runtime : string option) (* RELOCATED (iteration 17): manifest_parse used to sit below build_mode.
(path : string) (out : string) : unit = build_mode now reads the manifest itself, to tell "you forgot `main`"
let collector, lookup, image = compile_image ~deps path in from "this is a library" in the no-entry error, and OCaml has no
if Woc_lib.Diag.Collector.has_error collector then finish collector lookup forward reference across top-level `let`s. Nothing in the block
else begin changed; only its position did. *)
if String.get_int32_le image wob_off_entry = -1l then begin
Printf.eprintf
"woc: %s: no `main` entry point found; `build` requires a zero-argument free fn named \
`main`\n"
path;
exit 2
end;
let rt_path = match runtime with Some p -> p | None -> default_runtime_path () in
if (not (Sys.file_exists rt_path)) || Sys.is_directory rt_path then begin
Printf.eprintf "woc: runtime binary not found at '%s' -- build it with: make -C runtime wovm\n"
rt_path;
exit 2
end;
let rt_bytes =
match read_source rt_path with
| Ok s -> strip_existing_trailer s
| Error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2
in
let tmp = out ^ ".woc-build.tmp" in
(* stale tmp from an interrupted earlier build must not survive: its
permission bits would leak through, since Open_creat on an
existing inode does not apply the requested mode *)
(try Sys.remove tmp with Sys_error _ -> ());
(try
let oc = open_out_gen [ Open_wronly; Open_creat; Open_trunc; Open_binary ] 0o755 tmp in
output_string oc rt_bytes;
output_string oc image;
output_bytes oc
(trailer_bytes ~payload_off:(String.length rt_bytes) ~payload_len:(String.length image));
close_out oc
with Sys_error msg ->
(try Sys.remove tmp with Sys_error _ -> ());
Printf.eprintf "woc: %s\n" msg;
exit 2);
(try Sys.rename tmp out
with Sys_error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2);
finish collector lookup
end
(* ---- manifest build --------------------------------------------------- (* ---- manifest build ---------------------------------------------------
`woc <dir>` where <dir>/wo.toml exists is a BUILD, not a check: the `woc <dir>` where <dir>/wo.toml exists is a BUILD, not a check: the
manifest names the application, so pointing woc at the project is enough manifest names the application, so pointing woc at the project is enough
@ -807,7 +818,11 @@ let manifest_parse (path : string) : (string * string) list =
let v = String.sub v 1 (String.length v - 2) in let v = String.sub v 1 (String.length v - 2) in
let known = let known =
match (!section, key) with match (!section, key) with
| "", ("name" | "version" | "description") -> true (* iteration 17: `kind` says whether this project is a program or
a library. Explicit, not inferred from the presence of `main` —
a forgotten entry and a deliberate library must not look the
same in an error message. Validated in manifest_build. *)
| "", ("name" | "version" | "description" | "kind") -> true
| "runtime", "wo" -> true (* minimum toolchain version; enforced in manifest_build *) | "runtime", "wo" -> true (* minimum toolchain version; enforced in manifest_build *)
| "build", ("runtime" | "target") -> true | "build", ("runtime" | "target") -> true
| _ -> false | _ -> false
@ -815,7 +830,8 @@ let manifest_parse (path : string) : (string * string) list =
if not known then if not known then
fail !lineno fail !lineno
(if !section = "" then (if !section = "" then
Printf.sprintf "unknown key `%s` (name, version, description exist)" key Printf.sprintf "unknown key `%s` (name, version, description, kind exist)"
key
else else
Printf.sprintf "unknown key `%s` in [%s]" key !section); Printf.sprintf "unknown key `%s` in [%s]" key !section);
kvs := ((if !section = "" then key else !section ^ "." ^ key), v) :: !kvs kvs := ((if !section = "" then key else !section ^ "." ^ key), v) :: !kvs
@ -823,6 +839,69 @@ let manifest_parse (path : string) : (string * string) list =
with End_of_file -> close_in ic); with End_of_file -> close_in ic);
!kvs !kvs
let build_mode ?(deps : (string * string) list = []) ~(runtime : string option)
(path : string) (out : string) : unit =
let collector, lookup, image = compile_image ~deps path in
if Woc_lib.Diag.Collector.has_error collector then finish collector lookup
else begin
if String.get_int32_le image wob_off_entry = -1l then begin
Printf.eprintf
"woc: %s: no `main` entry point found; `build` requires a zero-argument free fn named \
`main`\n"
path;
(* iteration 17: if the project DECLARES itself a library, say so — the
two failures are different problems and deserve different answers.
Read only when the manifest exists; a malformed one still fails the
way it does today, through manifest_parse's own path. *)
let mf = Filename.concat path "wo.toml" in
if Sys.file_exists mf then begin
match List.assoc_opt "kind" (manifest_parse mf) with
| Some "library" ->
Printf.eprintf
"woc: %s: this project declares `kind = \"library\"` — add a `main` for a demo \
binary, or check it with `woc %s`\n"
mf path
| _ -> ()
end;
exit 2
end;
let rt_path = match runtime with Some p -> p | None -> default_runtime_path () in
if (not (Sys.file_exists rt_path)) || Sys.is_directory rt_path then begin
Printf.eprintf "woc: runtime binary not found at '%s' -- build it with: make -C runtime wovm\n"
rt_path;
exit 2
end;
let rt_bytes =
match read_source rt_path with
| Ok s -> strip_existing_trailer s
| Error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2
in
let tmp = out ^ ".woc-build.tmp" in
(* stale tmp from an interrupted earlier build must not survive: its
permission bits would leak through, since Open_creat on an
existing inode does not apply the requested mode *)
(try Sys.remove tmp with Sys_error _ -> ());
(try
let oc = open_out_gen [ Open_wronly; Open_creat; Open_trunc; Open_binary ] 0o755 tmp in
output_string oc rt_bytes;
output_string oc image;
output_bytes oc
(trailer_bytes ~payload_off:(String.length rt_bytes) ~payload_len:(String.length image));
close_out oc
with Sys_error msg ->
(try Sys.remove tmp with Sys_error _ -> ());
Printf.eprintf "woc: %s\n" msg;
exit 2);
(try Sys.rename tmp out
with Sys_error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2);
finish collector lookup
end
(* [runtime] wo = ">= X.Y" — a minimum-toolchain-version constraint. Only `>=` (* [runtime] wo = ">= X.Y" — a minimum-toolchain-version constraint. Only `>=`
and a bare version are interpreted; any other operator is accepted untouched and a bare version are interpreted; any other operator is accepted untouched
(forward-compatible — don't hard-fail on a constraint syntax not grokked (forward-compatible — don't hard-fail on a constraint syntax not grokked
@ -1036,6 +1115,32 @@ let manifest_build ?(update_deps = false) (dir : string) : unit =
Printf.eprintf "woc: %s: `name` is required\n" mf; Printf.eprintf "woc: %s: `name` is required\n" mf;
exit 2 exit 2
in in
(* iteration 17: `kind` decides what `woc <dir>` MEANS for this project.
A library is checked whole with no entry required; a program builds, as
it always has. Absent is "program", so every existing manifest behaves
byte-identically. An unrecognized value is a manifest error rather than
a silent default — a typo'd kind would otherwise build the wrong thing,
the same reasoning manifest_parse's unknown-key rejection follows. *)
(match get "kind" with
| None | Some "program" -> ()
| Some "library" ->
(* Check mode. `[deps]` are resolved and the `[runtime]` constraint
enforced exactly as a build does — a library must be checkable
offline once locked, or "it checks here" means nothing. The full
pipeline runs (parse, typecheck, interface satisfaction, ownership,
GC inference) because compile_image runs it; the in-memory image is
simply discarded, so no target/ appears and no file is written. An
entry-less image is already legal on that path — the `--emit`
precedent. *)
let collector, lookup, _image = compile_image ~deps dir in
if Woc_lib.Diag.Collector.has_error collector then finish collector lookup;
exit 0
| Some other ->
Printf.eprintf
"woc: %s: error WO-E109: unknown `kind` value `%s` — expected \"program\" (the default) \
or \"library\"\n"
mf other;
exit 2);
(* paths in the manifest are the PROJECT's, so they resolve against the (* paths in the manifest are the PROJECT's, so they resolve against the
manifest's directory — `woc .` inside the project and `woc path/to/it` manifest's directory — `woc .` inside the project and `woc path/to/it`
from anywhere must build the same thing *) from anywhere must build the same thing *)
@ -1050,7 +1155,22 @@ let manifest_build ?(update_deps = false) (dir : string) : unit =
build_mode ~deps ~runtime dir (Filename.concat target name) build_mode ~deps ~runtime dir (Filename.concat target name)
let () = let () =
match Sys.argv with (* haxe-parity Task 8: `-D name` defines a build flag (`#if name` keeps
its section). Accepted anywhere on the command line in every mode, so
it is peeled off BEFORE the fixed-shape mode match below. *)
let rec peel_defines acc = function
| "-D" :: name :: rest when name <> "" && name.[0] <> '-' ->
Woc_lib.Lexer.defines :=
Woc_lib.Lexer.StringSet.add name !Woc_lib.Lexer.defines;
peel_defines acc rest
| "-D" :: _ ->
Printf.eprintf "woc: -D needs a flag name (woc -D portable ...)\n";
exit 2
| a :: rest -> peel_defines (a :: acc) rest
| [] -> List.rev acc
in
let argv = Array.of_list (peel_defines [] (Array.to_list Sys.argv)) in
match argv with
| [| _; "--dump-tokens"; path |] -> dump_tokens path | [| _; "--dump-tokens"; path |] -> dump_tokens path
| [| _; "--dump-ast"; path |] -> dump_ast path | [| _; "--dump-ast"; path |] -> dump_ast path
| [| _; "--dump-owner"; path |] -> dump_owner path | [| _; "--dump-owner"; path |] -> dump_owner path

View file

@ -147,6 +147,19 @@ crash): emit_return's place test now sees through `Interp` the same way,
while bare Ident/Field/Index behavior there is unchanged. Both flavors while bare Ident/Field/Index behavior there is unchanged. Both flavors
pinned by `tests/corpus/run/interp-borrowed-field`. pinned by `tests/corpus/run/interp-borrowed-field`.
The iteration-5 strictness closeout (2026-08-20) added three seams worth
knowing: `pub(read)` rides the field annotation list as a synthetic
"pub_read" marker and is enforced in the Assign case that already resolves
the target's class (WO-E219, `current_self` names the checking class —
class-owned writes, sibling instances included); `using` extensions are a
TYPECHECK-TIME rewrite — `types.ml` records (file, call-id) → fn name in
`using_rewrites` and `apply_using_rewrites` rewrites `recv.ext(a)` to
`ext(recv, a)` before owner/emit, which therefore carry zero
using-awareness (collision with a real method is WO-E220 — never a silent
win either way); `#if` is a token-stream filter at the end of
`Lexer.tokenize` (`Lexer.defines` filled by `woc -D`, WO-E003 for misuse)
— the parser never sees a directive.
Two rules the measurements imposed, both easy to get backwards: Two rules the measurements imposed, both easy to get backwards:
- **Never drop an argument register after a `CALL`.** The callee's frame - **Never drop an argument register after a `CALL`.** The callee's frame
@ -171,3 +184,79 @@ Two rules the measurements imposed, both easy to get backwards:
- `./compiler/_build/default/bin/woc --emit docs/examples/log-watcher -o /tmp/lw.wob` - `./compiler/_build/default/bin/woc --emit docs/examples/log-watcher -o /tmp/lw.wob`
— the acceptance workload. It must compile with zero diagnostics, and — the acceptance workload. It must compile with zero diagnostics, and
`runtime/wovm /tmp/lw.wob watch <file> 2 1` must tail a live file and alert. `runtime/wovm /tmp/lw.wob watch <file> 2 1` must tail a live file and alert.
## Float and Bytes (iteration 19)
- **A digit run is an Int unless a fraction or an exponent follows.** The
lexer requires a DIGIT after `.` before committing to a Float, which is what
keeps `0..10` a range rather than `Float 0.` followed by `.10`, and checks
the exponent form (`e`, optional sign, at least one digit) before consuming
anything, so `2eggs` is still `Int 2` then an ident. `c` in that branch is
PEEKED, not consumed — the scan loop reads it, and adding it to the buffer
first double-counts the leading digit (a real bug this went through).
- **The no-mixing rule lives in the typechecker, not the emitter.** The
emitter picks the arithmetic opcode from whether EITHER side is a Float, so
an unreported `1 + 2.5` would lower to integer ADD over f64 bits and produce
a plausible wrong number with no diagnostic. `check_numeric_mix` reports the
mix (WO-E201) off confident types only, keeping this file's stay-silent-when-
underivable contract; `%` on a Float is rejected outright.
- **`Float`/`Bytes` are builtin scalars but not Int-shaped.**
`is_scalar_shaped` excludes both by name alongside `Text`, or
`print_int(price)` prints f64 bits as a huge integer and `trunc(digest)`
reinterprets a pointer — the representation mismatch that predicate exists
for.
- **Bytes is a heap-owned scalar, so every ownership rule that named `Text` by
string had to name a predicate instead.** `Types.is_heap_scalar` is that
predicate (owner.ml's four sites) and `is_heap_kind` is its emitter twin
(kind 3 or 7, six sites). Miss one and a Bytes temp never drops, or a Bytes
stored into a container aliases where a Text would copy.
- **`?Float` needs its own nil constant.** `nil_const_for` picks it, and the
bit pattern is emitted as a FLOAT pool constant because it is far outside
OCaml's 63-bit native int — `const_int` cannot express it at all. Float
constants dedupe on BITS, since `0.0` and `-0.0` are `=`-equal in OCaml but
must stay distinct, and NaN is not `=`-equal to itself.
- **A Float `order by` key uses `float_cmp`, not `op_lt`.** Raw-bit ordering
puts negatives backwards (the sign bit makes `-1.0` compare greater than
`1.0` as an integer) and leaves NaN wherever the comparison sequence drops
it. `float-table-column` in the corpus pins the ascending order that a
bit compare gets wrong.
- **Three parallel builtin tables must agree**: `Types.builtin_signatures`
(arity + arg kinds), `Types.builtin_confident_ret` and its emitter twin
`builtin_ret` (a missing entry for a fresh-heap result is a LEAK, not just a
lost type), and `is_builtin_name` plus the id mapping. The loader's arity
table and the OCaml twin in `compiler/test/runner.ml` are a fourth and fifth.
## Library kind and the `internal/` boundary (iteration 17)
Every part of this lives in the driver (`compiler/bin/main.ml`). No lexer,
parser, typechecker, VM, `.wob`, or GC change — `internal` is a path shape, not
a keyword, and visibility is name resolution at compile time.
- **`kind` is declared, not inferred.** `wo.toml`'s top-level `kind` is
`"program"` (the default, so every existing manifest is byte-identical) or
`"library"`; anything else is WO-E109 at exit 2. Go infers library-ness from
the absence of `main`, which makes "you forgot the entry" and "this is a
library" the same error — the whole reason to spend a manifest key here.
- **Check mode reuses `compile_image` whole.** The library branch resolves
`[deps]`, enforces the `[runtime]` constraint, runs the full pipeline, and
discards the in-memory image; no `target/` is created and no file is written.
An entry-less image was already legal on that path (the `--emit` precedent),
so "checks clean" means what "builds clean" means.
- **`manifest_parse` was RELOCATED above `build_mode`** so the no-entry error
can read the manifest and say "this project declares itself a library"
instead of only "no `main`". OCaml has no forward reference across top-level
`let`s; types.ml solved the same problem the same way. `woc build <dir> -o
<out>` never goes through `manifest_build`, so reading it inside `build_mode`
is the only placement that covers the explicit-build path.
- **WO-E108 is consumer-only, and keys on the FIRST segment naming a dep.**
That single condition is what makes the root project's own `internal/`
directories immune, and the dep-owned branch (which prefixes `use internal`
to `<dep>/internal`) is untouched, so a library imports its own interior
freely. The match is on a whole path SEGMENT — a module named `internals` is
ordinary public surface.
- **The offending `use` is left in the AST, not dropped.** The collector's
has-error path already stops emission; removing the use would replace one
clear diagnostic with a cascade of unknown-type errors from the same file.
- **Exit-code bands stay split**: WO-E108 is a diagnostic through the normal
collector path (exit 1); WO-E106/E107/E109 are manifest errors printed
directly (exit 2).

View file

@ -201,6 +201,10 @@ type expr = {
select)". *) select)". *)
and expr_kind = and expr_kind =
| IntLit of int | IntLit of int
(* iteration 19: a Float literal, carried as OCaml's own f64. Separate from
IntLit all the way down — there is no implicit coercion anywhere, so the
typechecker must be able to tell `1` from `1.0` at every use site. *)
| FloatLit of float
| StrLit of string | StrLit of string
| BoolLit of bool | BoolLit of bool
(* haxe-parity Task 6: `nil`, the absent value of a `?T`. One (* haxe-parity Task 6: `nil`, the absent value of a `?T`. One
@ -553,6 +557,11 @@ type use_decl = {
id : int; id : int;
pos : pos; pos : pos;
segments : string list; segments : string list;
(* haxe-parity Task 7: `using shared/textutil` — a use PLUS extension
registration: the module's pub free fns whose first parameter matches
a receiver's type become callable as methods on it. Compile-time only
(types.ml resolves and rewrites); false for a plain `use`. *)
is_using : bool;
} }
(* haxe-parity Task 4: one variant of a union declaration (* haxe-parity Task 4: one variant of a union declaration

View file

@ -149,6 +149,16 @@ let ins_str (i : int) (pc : int) : string =
jumps are — absolute, so a disassembly can be read against the pc column. *) jumps are — absolute, so a disassembly can be read against the pc column. *)
| 32 -> Printf.sprintf "TRY r%d, handler -> %04d" a target | 32 -> Printf.sprintf "TRY r%d, handler -> %04d" a target
| 33 -> "ENDTRY" | 33 -> "ENDTRY"
(* iteration 19: the f64 world. Rendered with the same three-register shape
as their Int counterparts so a disassembly reads the same. *)
| 34 -> Printf.sprintf "FADD r%d, r%d, r%d" a b c
| 35 -> Printf.sprintf "FSUB r%d, r%d, r%d" a b c
| 36 -> Printf.sprintf "FMUL r%d, r%d, r%d" a b c
| 37 -> Printf.sprintf "FDIV r%d, r%d, r%d" a b c
| 38 -> Printf.sprintf "FNEG r%d, r%d" a b
| 39 -> Printf.sprintf "FEQ r%d, r%d, r%d" a b c
| 40 -> Printf.sprintf "FLT r%d, r%d, r%d" a b c
| 41 -> Printf.sprintf "FLE r%d, r%d, r%d" a b c
| op -> Printf.sprintf "?OP%d" op | op -> Printf.sprintf "?OP%d" op
(* ---- the dump ---- *) (* ---- the dump ---- *)
@ -156,13 +166,17 @@ let ins_str (i : int) (pc : int) : string =
type kconst = type kconst =
| KInt of int64 | KInt of int64
| KText of string | KText of string
| KFloat of float (* iteration 19 *)
let dump (img : string) : string = let dump (img : string) : string =
let out = Buffer.create 4096 in let out = Buffer.create 4096 in
let line fmt = Buffer.add_string out (fmt ^ "\n") in let line fmt = Buffer.add_string out (fmt ^ "\n") in
if u32 img 0 <> magic then raise (Bad "bad magic"); if u32 img 0 <> magic then raise (Bad "bad magic");
let ver = u32 img 4 in let ver = u32 img 4 in
if ver <> 4 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); (* iteration 19 bumped the format to v5 (Float constant tag, kinds 6/7,
opcodes 34-41). The disassembler tracks the emitter, not a range: an old
image is a different format and reading it as this one would misrender. *)
if ver <> 5 then raise (Bad (Printf.sprintf "unsupported version %d" ver));
let coff = u32 img 8 and ccnt = u32 img 12 in let coff = u32 img 8 and ccnt = u32 img 12 in
let koff = u32 img 16 and kcnt = u32 img 20 in let koff = u32 img 16 and kcnt = u32 img 20 in
let ioff = u32 img 24 and icnt = u32 img 28 in let ioff = u32 img 24 and icnt = u32 img 28 in
@ -186,17 +200,29 @@ let dump (img : string) : string =
consts.(i) <- KText (String.sub img !o n); consts.(i) <- KText (String.sub img !o n);
o := !o + n o := !o + n
end end
else if tag = 2 then begin
(* iteration 19: a Float constant. Rendered as OCaml's hex-float so the
disassembly names the exact bits — a decimal here would make golden
files depend on printf rounding. *)
consts.(i) <- KFloat (Int64.float_of_bits (i64 img !o));
o := !o + 8
end
else raise (Bad (Printf.sprintf "constant %d: unknown tag %d" i tag)) else raise (Bad (Printf.sprintf "constant %d: unknown tag %d" i tag))
done; done;
let kname i = let kname i =
if i >= ccnt then Printf.sprintf "<k%d?>" i if i >= ccnt then Printf.sprintf "<k%d?>" i
else match consts.(i) with KText s -> s | KInt n -> Int64.to_string n else
match consts.(i) with
| KText s -> s
| KInt n -> Int64.to_string n
| KFloat x -> Printf.sprintf "%h" x
in in
line "== CONSTANTS =="; line "== CONSTANTS ==";
for i = 0 to ccnt - 1 do for i = 0 to ccnt - 1 do
match consts.(i) with match consts.(i) with
| KInt n -> line (Printf.sprintf "k%-3d INT %Ld" i n) | KInt n -> line (Printf.sprintf "k%-3d INT %Ld" i n)
| KText s -> line (Printf.sprintf "k%-3d TEXT %s" i (quote s)) | KText s -> line (Printf.sprintf "k%-3d TEXT %s" i (quote s))
| KFloat x -> line (Printf.sprintf "k%-3d FLT %h" i x) (* iteration 19 *)
done; done;
(* classes *) (* classes *)
line "== CLASSES =="; line "== CLASSES ==";

View file

@ -26,6 +26,9 @@ let kind_label (k : Token.kind) : string =
match k with match k with
| Token.Ident s -> Printf.sprintf "IDENT(%s)" s | Token.Ident s -> Printf.sprintf "IDENT(%s)" s
| Token.Int n -> Printf.sprintf "INT(%d)" n | Token.Int n -> Printf.sprintf "INT(%d)" n
(* iteration 19: hex-float, so the golden file records the exact bits and
does not depend on decimal formatting *)
| Token.Float x -> Printf.sprintf "FLOAT(%h)" x
| Token.Str s -> Printf.sprintf "STR(%s)" s | Token.Str s -> Printf.sprintf "STR(%s)" s
| Token.InterpStr segs -> | Token.InterpStr segs ->
let part_str = function let part_str = function
@ -52,6 +55,7 @@ let kind_label (k : Token.kind) : string =
| Token.KwSelect -> "KW_SELECT" | Token.KwSelect -> "KW_SELECT"
| Token.KwUse -> "KW_USE" | Token.KwUse -> "KW_USE"
| Token.KwSpawn -> "KW_SPAWN" | Token.KwSpawn -> "KW_SPAWN"
| Token.KwUsing -> "KW_USING"
| Token.KwPub -> "KW_PUB" | Token.KwPub -> "KW_PUB"
| Token.KwBreak -> "KW_BREAK" | Token.KwBreak -> "KW_BREAK"
| Token.KwContinue -> "KW_CONTINUE" | Token.KwContinue -> "KW_CONTINUE"
@ -99,6 +103,9 @@ let kind_label (k : Token.kind) : string =
| Token.PlusEq -> "PLUSEQ" | Token.PlusEq -> "PLUSEQ"
| Token.MinusEq -> "MINUSEQ" | Token.MinusEq -> "MINUSEQ"
| Token.Newline -> "NEWLINE" | Token.Newline -> "NEWLINE"
| Token.HashIf -> "#if"
| Token.HashElse -> "#else"
| Token.HashEnd -> "#end"
| Token.Eof -> "EOF" | Token.Eof -> "EOF"
(* Multi-file dump layout (Task 8, bin/main.ml). Every dump_* function (* Multi-file dump layout (Task 8, bin/main.ml). Every dump_* function
@ -212,6 +219,10 @@ let dbstub_tokens_str (toks : Token.t list) : string =
let rec expr_str (e : Ast.expr) : string = let rec expr_str (e : Ast.expr) : string =
match e.Ast.kind with match e.Ast.kind with
| Ast.IntLit n -> string_of_int n | Ast.IntLit n -> string_of_int n
(* iteration 19: `%h` is OCaml's hex-float — exact, short, and unambiguous
in a golden file. A decimal rendering here would make the golden test
depend on printf rounding, which is not what these fixtures check. *)
| Ast.FloatLit f -> Printf.sprintf "%h" f
| Ast.StrLit s -> "\"" ^ s ^ "\"" | Ast.StrLit s -> "\"" ^ s ^ "\""
| Ast.BoolLit b -> if b then "true" else "false" | Ast.BoolLit b -> if b then "true" else "false"
| Ast.Ident s -> s | Ast.Ident s -> s

View file

@ -151,11 +151,20 @@ let stdlib_not_linked_code = Diag.emitter_prefix ^ "06"
============================================================ *) ============================================================ *)
let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *)
let wob_version = 4 (* v4 (iteration 7b): RC opcodes retired; gc mask = GC roots *)
(* v5 (iteration 19): the Float constant tag, field kinds 6/7, opcodes 34-41,
builtins 70-83. v4 (iteration 7b): RC opcodes retired; gc mask = GC roots *)
let wob_version = 5
let wob_hdr_size = 44 let wob_hdr_size = 44
let wob_none = 0xFFFFFFFF let wob_none = 0xFFFFFFFF
let k_int = 0 let k_int = 0
let k_text = 1 let k_text = 1
(* iteration 19: tag byte then the f64's IEEE bits as an LE u64. OCaml's
`float` IS an f64, so Int64.bits_of_float is a bit reinterpretation, not a
conversion — a literal reaches the VM exactly as written. *)
let k_float = 2
let max_regs = 64 let max_regs = 64
let classf_gc = 0x01 let classf_gc = 0x01
@ -167,6 +176,16 @@ let op_sub = 4
let op_mul = 5 let op_mul = 5
let op_div = 6 let op_div = 6
let op_neg = 7 let op_neg = 7
(* iteration 19: the f64 world. Separate opcodes, not a mode bit — see wob.h. *)
let op_fadd = 34
let op_fsub = 35
let op_fmul = 36
let op_fdiv = 37
let op_fneg = 38
let op_feq = 39
let op_flt = 40
let op_fle = 41
let op_concat = 8 let op_concat = 8
let op_eq = 9 let op_eq = 9
let op_lt = 10 let op_lt = 10
@ -241,6 +260,22 @@ let b_trim = 24
let b_to_lower = 25 let b_to_lower = 25
let b_char_of = 26 let b_char_of = 26
let b_parse_int = 27 let b_parse_int = 27
(* iteration 19: Float bridges then Bytes (wob.h ids 70-83) *)
let b_float_of_int = 70
let b_trunc = 71
let b_parse_float = 72
let b_float_to_text = 73
let b_float_cmp = 74
let b_bytes_len = 75
let b_bytes_at = 76
let b_bytes_slice = 77
let b_bytes_eq = 78
let b_bytes_concat = 79
let b_base64_encode = 80
let b_base64_decode = 81
let b_bytes_of_text = 82
let b_text_of_bytes = 83
let b_split = 28 let b_split = 28
let b_split_ws = 29 let b_split_ws = 29
let b_join = 30 let b_join = 30
@ -432,7 +467,12 @@ type pctx = {
(* constant pool, deduplicated *) (* constant pool, deduplicated *)
p_kints : (int, int) Hashtbl.t; p_kints : (int, int) Hashtbl.t;
p_ktexts : (string, int) Hashtbl.t; p_ktexts : (string, int) Hashtbl.t;
mutable p_consts : [ `Int of int | `Text of string ] list; (* rev *) (* iteration 19: Float constants dedupe on BITS, not on value. Two reasons,
both load-bearing: 0.0 and -0.0 are `=`-equal in OCaml but must stay
distinct constants, and NaN is not `=`-equal to itself, so a value-keyed
table would grow one entry per NaN literal forever. *)
p_kfloats : (int64, int) Hashtbl.t;
mutable p_consts : [ `Int of int | `Text of string | `Float of int64 ] list; (* rev *)
mutable p_nconsts : int; mutable p_nconsts : int;
} }
@ -446,6 +486,18 @@ let const_int (p : pctx) (v : int) : int =
p.p_nconsts <- i + 1; p.p_nconsts <- i + 1;
i i
(* iteration 19 *)
let const_float (p : pctx) (v : float) : int =
let bits = Int64.bits_of_float v in
match Hashtbl.find_opt p.p_kfloats bits with
| Some i -> i
| None ->
let i = p.p_nconsts in
Hashtbl.replace p.p_kfloats bits i;
p.p_consts <- `Float bits :: p.p_consts;
p.p_nconsts <- i + 1;
i
let const_text (p : pctx) (s : string) : int = let const_text (p : pctx) (s : string) : int =
match Hashtbl.find_opt p.p_ktexts s with match Hashtbl.find_opt p.p_ktexts s with
| Some i -> i | Some i -> i
@ -762,6 +814,8 @@ let kind_byte : Types.wob_kind -> int = function
| Types.WO_K_TEXT -> 3 | Types.WO_K_TEXT -> 3
| Types.WO_K_MULTI -> 4 | Types.WO_K_MULTI -> 4
| Types.WO_K_MAP -> 5 | Types.WO_K_MAP -> 5
| Types.WO_K_FLOAT -> 6 (* iteration 19 *)
| Types.WO_K_BYTES -> 7
(* `?T` has no kind byte of its own in the v1 format (kinds run 0..5; (* `?T` has no kind byte of its own in the v1 format (kinds run 0..5;
the loader rejects 6). It needs none: a nullable field stores what the loader rejects 6). It needs none: a nullable field stores what
T stores and spells nil as 0, and every drop plan in T stores and spells nil as 0, and every drop plan in
@ -773,6 +827,16 @@ let kind_byte : Types.wob_kind -> int = function
let field_kind (p : pctx) (ft : Ast.field_ty) : int = let field_kind (p : pctx) (ft : Ast.field_ty) : int =
kind_byte (Types.wob_kind_of_typ p.p_syms (Types.typ_of_field_ty (unwrap ft))) kind_byte (Types.wob_kind_of_typ p.p_syms (Types.typ_of_field_ty (unwrap ft)))
(* iteration 19: "is this a str-shaped heap value the holder owns?" — kind 3
(Text/json.Value) or kind 7 (Bytes). Every copy-on-boundary and drop-the-
fresh-temp site asks this; before Bytes existed the six sites each spelled
`= 3` inline, and leaving them that way would have meant a Bytes temp never
dropped and a Bytes stored into a container aliased instead of copied.
WO_B_TEXT_COPY preserves the kind, so one predicate covers both. *)
let is_heap_kind (p : pctx) (ft : Ast.field_ty) : bool =
let k = field_kind p ft in
k = 3 || k = 7
let class_of_name (p : pctx) (n : string) : int option = SM.find_opt n p.p_class_id let class_of_name (p : pctx) (n : string) : int option = SM.find_opt n p.p_class_id
(* iteration 9b: a @table class's instances are row ids, so field access on (* iteration 9b: a @table class's instances are row ids, so field access on
@ -943,6 +1007,16 @@ let builtin_ret (name : string) (argty : Ast.field_ty option) : Ast.field_ty opt
match argty with Some t -> ( match unwrap t with Map (k, _) -> Some (Scalar k) | _ -> None) | None -> None) match argty with Some t -> ( match unwrap t with Map (k, _) -> Some (Scalar k) | _ -> None) | None -> None)
| "val_at" -> ( | "val_at" -> (
match argty with Some t -> ( match unwrap t with Map (_, v) -> Some (Scalar v) | _ -> None) | None -> None) match argty with Some t -> ( match unwrap t with Map (_, v) -> Some (Scalar v) | _ -> None) | None -> None)
(* iteration 19 — mirrors Types.builtin_confident_ret. The fresh-heap
results (`float_to_text`, `base64_encode`, `text_of_bytes`, the three
that return a fresh Bytes) MUST be listed or their `let` never gets a
drop: a missing entry here is a leak, per this table's own contract. *)
| "float" | "parse_float" -> Some (Scalar "Float")
| "trunc" | "float_cmp" | "bytes_len" | "bytes_at" -> Some (Scalar "Int")
| "float_to_text" | "base64_encode" | "text_of_bytes" -> Some (Scalar "Text")
| "bytes_eq" -> Some (Scalar "Bool")
| "bytes_slice" | "bytes_concat" | "bytes_of_text" -> Some (Scalar "Bytes")
| "base64_decode" -> Some (Nullable (Scalar "Bytes"))
| _ -> None | _ -> None
let is_builtin_name (n : string) = let is_builtin_name (n : string) =
@ -954,7 +1028,11 @@ let is_builtin_name (n : string) =
"substr"; "trim"; "to_lower"; "char_of"; "parse_int"; "split"; "split_ws"; "join"; "slice"; "substr"; "trim"; "to_lower"; "char_of"; "parse_int"; "split"; "split_ws"; "join"; "slice";
"pop"; "shift"; "sort"; "reverse"; "remove"; "key_at"; "val_at"; "pop"; "shift"; "sort"; "reverse"; "remove"; "key_at"; "val_at";
(* the concurrency arc *) (* the concurrency arc *)
"send" ] "send";
(* iteration 19: Float bridges and Bytes surface *)
"float"; "trunc"; "parse_float"; "float_to_text"; "float_cmp"; "bytes_len"; "bytes_at";
"bytes_slice"; "bytes_eq"; "bytes_concat"; "base64_encode"; "base64_decode";
"bytes_of_text"; "text_of_bytes" ]
(* ---- unions and variants (haxe-parity Task 4) ------------------------ (* ---- unions and variants (haxe-parity Task 4) ------------------------
@ -1005,6 +1083,7 @@ let query_elem_scalar (p : pctx) (q : Ast.query) ~(src : string) : string =
let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option = let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option =
match e.kind with match e.kind with
| IntLit _ -> Some (Scalar "Int") | IntLit _ -> Some (Scalar "Int")
| FloatLit _ -> Some (Scalar "Float") (* iteration 19 *)
| StrLit _ -> Some (Scalar "Text") | StrLit _ -> Some (Scalar "Text")
| BoolLit _ -> Some (Scalar "Bool") | BoolLit _ -> Some (Scalar "Bool")
(* Same rule as owner.ml's expr_ty: a non-empty list literal knows its (* Same rule as owner.ml's expr_ty: a non-empty list literal knows its
@ -1209,6 +1288,26 @@ and emit_binding_ty_of_arm (p : pctx) (f : fstate) (subject : Ast.expr) (arm : A
let is_text (p : pctx) (f : fstate) (e : Ast.expr) : bool = let is_text (p : pctx) (f : fstate) (e : Ast.expr) : bool =
match ty_of_expr p f e with Some t -> ( match unwrap t with Scalar "Text" -> true | _ -> false) | None -> false match ty_of_expr p f e with Some t -> ( match unwrap t with Scalar "Text" -> true | _ -> false) | None -> false
(* iteration 19: does this expression hold f64 bits? Every operator that has
both an Int and a Float lowering asks this to pick the opcode. A literal is
answered directly because `1.5 + x` has a FloatLit on the left whose
`ty_of_expr` may not resolve, and picking integer ADD there would compute
garbage silently — the whole failure mode WO-E2xx's no-mixing rule exists to
prevent. The typechecker has already rejected genuinely mixed operands, so
one Float side is enough to select the Float opcode. *)
let is_float (p : pctx) (f : fstate) (e : Ast.expr) : bool =
match e.Ast.kind with
| Ast.FloatLit _ -> true
| _ -> (
match ty_of_expr p f e with
| Some t -> ( match unwrap t with Scalar "Float" -> true | _ -> false)
| None -> false)
let is_bytes (p : pctx) (f : fstate) (e : Ast.expr) : bool =
match ty_of_expr p f e with
| Some t -> ( match unwrap t with Scalar "Bytes" -> true | _ -> false)
| None -> false
(* ============================================================ (* ============================================================
Lowering Lowering
============================================================ *) ============================================================ *)
@ -1387,6 +1486,16 @@ let wob_field_nil_bool = 0xFFFFFFFB (* a `?Bool` field: NIL_SCALAR nil + bool en
WO_NIL_SCALAR exactly. *) WO_NIL_SCALAR exactly. *)
let nil_scalar_word = -4611686018427387904 let nil_scalar_word = -4611686018427387904
let wob_field_nil_float = 0xFFFFFFFA (* a `?Float` field: WO_NIL_FLOAT nil *)
(* iteration 19: nil for a `?Float`. It cannot be the zero word (+0.0) and it
cannot be nil_scalar_word (whose bits ARE -2.0), so it is a reserved quiet
NaN — see runtime/src/wob.h's WO_NIL_FLOAT for why that costs nothing real.
Carried as an Int64 and emitted as a FLOAT constant, because the bit pattern
is far outside OCaml's 63-bit native int and could not be written as one. *)
let nil_float_bits = 0x7FF8000000000EE1L
let nil_float_value = Int64.float_of_bits nil_float_bits
(* is this a `?scalar` — an optional whose representation is a plain register, (* is this a `?scalar` — an optional whose representation is a plain register,
so its nil has to be the sentinel rather than the zero word? *) so its nil has to be the sentinel rather than the zero word? *)
let is_nullable_scalar (p : pctx) (ty : Ast.field_ty) : bool = let is_nullable_scalar (p : pctx) (ty : Ast.field_ty) : bool =
@ -1394,9 +1503,28 @@ let is_nullable_scalar (p : pctx) (ty : Ast.field_ty) : bool =
| Ast.Nullable inner -> field_kind p inner = 0 (* WO_K_SCALAR *) | Ast.Nullable inner -> field_kind p inner = 0 (* WO_K_SCALAR *)
| _ -> false | _ -> false
(* iteration 19: a `?Float` is word-shaped like a `?scalar` but takes its own
sentinel, so it needs its own predicate rather than widening the one above
(whose callers all pair it with nil_scalar_word). *)
let is_nullable_float (p : pctx) (ty : Ast.field_ty) : bool =
match ty with
| Ast.Nullable inner -> field_kind p inner = 6 (* WO_K_FLOAT *)
| _ -> false
(* The constant index of the right nil for a destination type: a `?Float`'s
reserved NaN, a `?scalar`'s sentinel, or the zero word for everything else
(heap-shaped optionals, where a null pointer is unambiguous). One place, so
the four sites that write a nil cannot drift apart. *)
let nil_const_for (p : pctx) (dest : Ast.field_ty option) : int =
match dest with
| Some t when is_nullable_float p t -> const_float p nil_float_value
| Some t when is_nullable_scalar p t -> const_int p nil_scalar_word
| _ -> const_int p 0
let field_class_meta (p : pctx) (ty : Ast.field_ty) : int = let field_class_meta (p : pctx) (ty : Ast.field_ty) : int =
let name_of t = match t with Ast.Scalar n -> Some n | _ -> None in let name_of t = match t with Ast.Scalar n -> Some n | _ -> None in
if is_nullable_scalar p ty then if is_nullable_float p ty then wob_field_nil_float (* iteration 19 *)
else if is_nullable_scalar p ty then
(match ty with (match ty with
| Ast.Nullable (Ast.Scalar "Bool") -> wob_field_nil_bool | Ast.Nullable (Ast.Scalar "Bool") -> wob_field_nil_bool
| _ -> wob_field_nil_scalar) | _ -> wob_field_nil_scalar)
@ -1510,14 +1638,14 @@ let copy_place_text (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit =
fresh int_to_text that must not be re-copied *) fresh int_to_text that must not be re-copied *)
let is_place = is_borrowed_value_t p f e && not (is_container_read e) in let is_place = is_borrowed_value_t p f e && not (is_container_read e) in
let is_text = let is_text =
match ty_of_expr p f e with Some t -> field_kind p t = 3 (* WO_K_TEXT *) | None -> false match ty_of_expr p f e with Some t -> is_heap_kind p t | None -> false
in in
if is_place && is_text then put f (ins_abc op_builtin reg reg b_text_copy) if is_place && is_text then put f (ins_abc op_builtin reg reg b_text_copy)
let drop_fresh_text ?keep (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit = let drop_fresh_text ?keep (p : pctx) (f : fstate) (reg : int) (e : Ast.expr) : unit =
let is_place = is_borrowed_value_t p f e && not (is_container_read e) in let is_place = is_borrowed_value_t p f e && not (is_container_read e) in
let is_text = let is_text =
match ty_of_expr p f e with Some t -> field_kind p t = 3 (* WO_K_TEXT *) | None -> false match ty_of_expr p f e with Some t -> is_heap_kind p t | None -> false
in in
if (match keep with Some k -> k <> reg | None -> true) && (not is_place) && is_text then if (match keep with Some k -> k <> reg | None -> true) && (not is_place) && is_text then
put f (ins_abc op_drop reg 0 0) put f (ins_abc op_drop reg 0 0)
@ -1530,6 +1658,10 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
f.f_cur_line <- e.pos.line; f.f_cur_line <- e.pos.line;
(match e.kind with (match e.kind with
| IntLit n -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p n))) | IntLit n -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p n)))
| FloatLit x ->
(* iteration 19: the literal's bits go into the pool and LOADK copies the
word. No decimal round-trip anywhere between source and register. *)
put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_float p x)))
| BoolLit b -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p (if b then 1 else 0)))) | BoolLit b -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p (if b then 1 else 0))))
| StrLit s -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_text p s))) | StrLit s -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_text p s)))
(* haxe-parity Task 6: `nil` is the zero word for a heap-shaped `?T` and the (* haxe-parity Task 6: `nil` is the zero word for a heap-shaped `?T` and the
@ -1539,10 +1671,7 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
is the safe answer (a heap slot). *) is the safe answer (a heap slot). *)
| NilLit -> | NilLit ->
let dest = match expected with Some _ -> expected | None -> f.f_ret in let dest = match expected with Some _ -> expected | None -> f.f_ret in
let word = put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (nil_const_for p dest)))
match dest with Some t when is_nullable_scalar p t -> nil_scalar_word | _ -> 0
in
put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p word)))
(* Container literals lower to exactly what `multi_new()`/`map_new()` (* Container literals lower to exactly what `multi_new()`/`map_new()`
lower to — the element kinds are the destination's, never guessed lower to — the element kinds are the destination's, never guessed
(docs/plan/oop-vm/08-builtin-surface.md) — plus one `multi_push` per (docs/plan/oop-vm/08-builtin-surface.md) — plus one `multi_push` per
@ -1739,11 +1868,14 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
drop_fresh_text ~keep:dst p f (w + 1) idx; drop_fresh_text ~keep:dst p f (w + 1) idx;
(* a Text read out of a container is COPIED: the container keeps owning (* a Text read out of a container is COPIED: the container keeps owning
its element, the reader owns the copy (see owner.ml's copies_out) *) its element, the reader owns the copy (see owner.ml's copies_out) *)
if (match ty_of_expr p f e with Some t -> field_kind p t = 3 | None -> false) then if (match ty_of_expr p f e with Some t -> is_heap_kind p t | None -> false) then
put f (ins_abc op_builtin dst dst b_text_copy)) put f (ins_abc op_builtin dst dst b_text_copy))
| Unary (Neg, o) -> | Unary (Neg, o) ->
let b = emit_operand p f v o in let b = emit_operand p f v o in
put f (ins_abc op_neg dst b 0) (* iteration 19: FNEG flips the sign bit, so `-0.0` is reachable and
`-x` on an infinity gives the other infinity. Integer NEG on f64 bits
would produce a different number entirely. *)
put f (ins_abc (if is_float p f o then op_fneg else op_neg) dst b 0)
| Binary (op, l, r) -> emit_binary p f v ~dst op l r | Binary (op, l, r) -> emit_binary p f v ~dst op l r
| Ctor (cn, fields) -> emit_ctor p f v ~dst e cn fields | Ctor (cn, fields) -> emit_ctor p f v ~dst e cn fields
| Spawn (cn, fields) -> | Spawn (cn, fields) ->
@ -1815,12 +1947,17 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
match unwrap t with match unwrap t with
| Scalar "Text" -> emit_expr p f v ~dst inner | Scalar "Text" -> emit_expr p f v ~dst inner
| Scalar "Int" -> emit_builtin p f v ~dst e "int_to_text" [ inner ] | Scalar "Int" -> emit_builtin p f v ~dst e "int_to_text" [ inner ]
(* iteration 19: `"total: ${price}"` is the first thing anyone writes
after adding a Float column, so it renders here rather than forcing
an explicit float_to_text at every call site. Same renderer as
json.encode, so the two never disagree. *)
| Scalar "Float" -> emit_builtin p f v ~dst e "float_to_text" [ inner ]
| other -> | other ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message: ~message:
(Printf.sprintf (Printf.sprintf
"cannot interpolate a value of type `%s` in \"${...}\" — only Text and Int are \ "cannot interpolate a value of type `%s` in \"${...}\" — only Text, Int, and \
supported" Float are supported"
(Dump.field_ty_str other)); (Dump.field_ty_str other));
put f (ins_abx op_loadk dst (const_int p 0))) put f (ins_abx op_loadk dst (const_int p 0)))
| None -> | None ->
@ -1911,11 +2048,16 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
f.f_temp <- save f.f_temp <- save
in in
let nil_compare o = nil_compare_into p f v ~dst o l r in let nil_compare o = nil_compare_into p f v ~dst o l r in
(* iteration 19: one Float operand selects the Float opcode. The typechecker
has already rejected a genuine Int/Float mix (WO-E201), so reaching here
with only one Float side means the other is an underivable expression of
the same type — never an Int to be silently reinterpreted. *)
let fl = is_float p f l || is_float p f r in
match op with match op with
| Add -> simple op_add | Add -> simple (if fl then op_fadd else op_add)
| Sub -> simple op_sub | Sub -> simple (if fl then op_fsub else op_sub)
| Mul -> simple op_mul | Mul -> simple (if fl then op_fmul else op_mul)
| Div -> simple op_div | Div -> simple (if fl then op_fdiv else op_div)
| Concat -> | Concat ->
(* CONCAT allocates a new Text and leaves its operands untouched, so an (* CONCAT allocates a new Text and leaves its operands untouched, so an
operand that was itself freshly built — the partial result of a longer operand that was itself freshly built — the partial result of a longer
@ -1929,10 +2071,10 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
put f (ins_abc op_concat dst a b); put f (ins_abc op_concat dst a b);
drop_fresh_text ~keep:dst p f a l; drop_fresh_text ~keep:dst p f a l;
drop_fresh_text ~keep:dst p f b r drop_fresh_text ~keep:dst p f b r
| Lt -> simple op_lt | Lt -> simple (if fl then op_flt else op_lt)
| Le -> simple op_le | Le -> simple (if fl then op_fle else op_le)
| Gt -> swapped op_lt | Gt -> swapped (if fl then op_flt else op_lt)
| Ge -> swapped op_le | Ge -> swapped (if fl then op_fle else op_le)
(* A comparison against `nil` is a WORD compare, never a content compare: (* A comparison against `nil` is a WORD compare, never a content compare:
EQS would dereference nil as a `wo_str*` (the VM's str_check traps on EQS would dereference nil as a `wo_str*` (the VM's str_check traps on
that, so an `x != nil` guard would trap instead of answering). The literal that, so an `x != nil` guard would trap instead of answering). The literal
@ -1942,6 +2084,10 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
| Eq -> | Eq ->
if is_nil_lit l || is_nil_lit r then nil_compare op_eq if is_nil_lit l || is_nil_lit r then nil_compare op_eq
else if is_text p f l || is_text p f r then simple op_eqs else if is_text p f l || is_text p f r then simple op_eqs
(* iteration 19: FEQ, not EQ. A word compare would make `NaN == NaN` true
(identical bits) and `0.0 == -0.0` false (differing bits) — both
backwards from IEEE, which is the stated contract. *)
else if fl then simple op_feq
else simple op_eq else simple op_eq
| Ne -> | Ne ->
(* no NE opcode in the v1 set: `a != b` is `(a == b) == 0`. The (* no NE opcode in the v1 set: `a != b` is `(a == b) == 0`. The
@ -1956,7 +2102,12 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
else begin else begin
let a = emit_operand p f v l in let a = emit_operand p f v l in
let b = emit_operand p f v r in let b = emit_operand p f v r in
put f (ins_abc (if is_text p f l || is_text p f r then op_eqs else op_eq) t a b); put f
(ins_abc
(if is_text p f l || is_text p f r then op_eqs
else if fl then op_feq (* iteration 19: `a != b` on Floats is IEEE *)
else op_eq)
t a b);
(* the same reap `simple` does — `headers["authorization"] != (* the same reap `simple` does — `headers["authorization"] !=
"Bearer ${key}"` abandoned both sides, once per MCP request *) "Bearer ${key}"` abandoned both sides, once per MCP request *)
drop_fresh_owned ~keep:t p f a l; drop_fresh_owned ~keep:t p f a l;
@ -2647,7 +2798,15 @@ and emit_query (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr)
`x.name` sees x unbound, returns None, and a Text key silently falls `x.name` sees x unbound, returns None, and a Text key silently falls
to the pointer-comparing op_lt (the wrong-order bug) *) to the pointer-comparing op_lt (the wrong-order bug) *)
let key_is_text = let key_is_text =
match ty_of_expr p f key with Some t -> field_kind p t = 3 | None -> false match ty_of_expr p f key with Some t -> field_kind p t = 3 | None -> false (* Text keys only: order-by on Bytes is out of scope *)
in
(* iteration 19: a Float order-by key uses the TOTAL order (float_cmp),
not op_lt over the raw bits. Bits get negatives backwards (the sign
bit makes -1.0 compare greater than 1.0 as an integer) and leave NaN
wherever the comparison sequence happens to drop it; an order-by must
be a total order or the result depends on input order. *)
let key_is_float =
match ty_of_expr p f key with Some t -> field_kind p t = 6 | None -> false
in in
let kj = alloc_temp p f e.pos in let kj = alloc_temp p f e.pos in
emit_expr p f v ~dst:kj key; emit_expr p f v ~dst:kj key;
@ -2666,6 +2825,18 @@ and emit_query (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr)
put f (ins_abc op_builtin cmp w b_str_lt); put f (ins_abc op_builtin cmp w b_str_lt);
f.f_temp <- save f.f_temp <- save
end end
else if key_is_float then begin
(* cmp = float_cmp(a, b) < 0 *)
let save = f.f_temp in
let w = alloc_temps p f e.pos 2 in
put f (ins_abc op_move w a 0);
put f (ins_abc op_move (w + 1) b 0);
put f (ins_abc op_builtin cmp w b_float_cmp);
let z = alloc_temp p f e.pos in
put f (ins_abx op_loadk z (check_bx p f e.pos "constant" (const_int p 0)));
put f (ins_abc op_lt cmp cmp z);
f.f_temp <- save
end
else put f (ins_abc op_lt cmp a b) else put f (ins_abc op_lt cmp a b)
in in
if desc then lt kb kj else lt kj kb; if desc then lt kb kj else lt kj kb;
@ -2783,7 +2954,8 @@ and emit_insert (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr)
f.f_temp <- save f.f_temp <- save
| None -> | None ->
let nil_word = let nil_word =
if is_nullable_scalar p fty then const_int p nil_scalar_word if is_nullable_float p fty then const_float p nil_float_value
else if is_nullable_scalar p fty then const_int p nil_scalar_word
else const_int p 0 else const_int p 0
in in
put f (ins_abx op_loadk (base + 1 + idx) (check_bx p f e.pos "constant" nil_word)))) put f (ins_abx op_loadk (base + 1 + idx) (check_bx p f e.pos "constant" nil_word))))
@ -2827,7 +2999,7 @@ and emit_default_value (p : pctx) (f : fstate) ~(dst : int) ~(fty : Ast.field_ty
put f put f
(ins_abx op_loadk dst (ins_abx op_loadk dst
(check_bx p f pos "constant" (check_bx p f pos "constant"
(const_int p (if is_nullable_scalar p fty then nil_scalar_word else 0)))) (nil_const_for p (Some fty))))
(* `= {}` — a fresh empty container of the field's own declared type, (* `= {}` — a fresh empty container of the field's own declared type,
the same rule `[]` above follows *) the same rule `[]` above follows *)
| [ Token.LBrace; Token.RBrace ] -> ( | [ Token.LBrace; Token.RBrace ] -> (
@ -3233,7 +3405,7 @@ and call_window (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(recv : Ast.
let fresh_borrowed_value (a : Ast.expr) : bool = let fresh_borrowed_value (a : Ast.expr) : bool =
is_fresh_owned_temp p f a is_fresh_owned_temp p f a
|| ((not (is_borrowed_value_t p f a) || is_container_read a) || ((not (is_borrowed_value_t p f a) || is_container_read a)
&& match ty_of_expr p f a with Some t -> field_kind p t = 3 | None -> false) && match ty_of_expr p f a with Some t -> is_heap_kind p t | None -> false)
in in
let owned_heap_temp (a : Ast.expr) : bool = let owned_heap_temp (a : Ast.expr) : bool =
(match a.kind with (match a.kind with
@ -3353,6 +3525,10 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e :
|| id = b_len || id = b_print_err || id = b_trim || id = b_to_lower || id = b_char_of || id = b_len || id = b_print_err || id = b_trim || id = b_to_lower || id = b_char_of
|| id = b_parse_int || id = b_split_ws || id = b_pop || id = b_shift || id = b_sort || id = b_parse_int || id = b_split_ws || id = b_pop || id = b_shift || id = b_sort
|| id = b_reverse || id = b_reverse
(* iteration 19, one argument *)
|| id = b_float_of_int || id = b_trunc || id = b_parse_float || id = b_float_to_text
|| id = b_bytes_len || id = b_base64_encode || id = b_base64_decode
|| id = b_bytes_of_text || id = b_text_of_bytes
then 1 then 1
else if else if
id = b_multi_push || id = b_multi_get || id = b_map_get || id = b_map_has id = b_multi_push || id = b_multi_get || id = b_map_get || id = b_map_has
@ -3361,8 +3537,10 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e :
|| id = b_byte_at || id = b_starts_with || id = b_ends_with || id = b_index_of || id = b_byte_at || id = b_starts_with || id = b_ends_with || id = b_index_of
|| id = b_last_index_of || id = b_split || id = b_join || id = b_map_remove || id = b_last_index_of || id = b_split || id = b_join || id = b_map_remove
|| id = b_map_key_at || id = b_map_val_at || id = b_map_key_at || id = b_map_val_at
(* iteration 19, two arguments *)
|| id = b_float_cmp || id = b_bytes_at || id = b_bytes_eq || id = b_bytes_concat
then 2 then 2
else 3 else 3 (* b_bytes_slice lands here with substr's shape: (value, start, len) *)
in in
let container_id first_arg on_multi on_map = let container_id first_arg on_multi on_map =
match ty_of_expr p f first_arg with match ty_of_expr p f first_arg with
@ -3461,6 +3639,21 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e :
| "remove" -> fixed b_map_remove | "remove" -> fixed b_map_remove
| "key_at" -> fixed b_map_key_at | "key_at" -> fixed b_map_key_at
| "val_at" -> fixed b_map_val_at | "val_at" -> fixed b_map_val_at
(* iteration 19 *)
| "float" -> fixed b_float_of_int
| "trunc" -> fixed b_trunc
| "parse_float" -> fixed b_parse_float
| "float_to_text" -> fixed b_float_to_text
| "float_cmp" -> fixed b_float_cmp
| "bytes_len" -> fixed b_bytes_len
| "bytes_at" -> fixed b_bytes_at
| "bytes_slice" -> fixed b_bytes_slice
| "bytes_eq" -> fixed b_bytes_eq
| "bytes_concat" -> fixed b_bytes_concat
| "base64_encode" -> fixed b_base64_encode
| "base64_decode" -> fixed b_base64_decode
| "bytes_of_text" -> fixed b_bytes_of_text
| "text_of_bytes" -> fixed b_text_of_bytes
| "multi_new" | "map_new" -> | "multi_new" | "map_new" ->
let is_map = name = "map_new" in let is_map = name = "map_new" in
if args <> [] then bad (Printf.sprintf "builtin `%s` takes no arguments" name) if args <> [] then bad (Printf.sprintf "builtin `%s` takes no arguments" name)
@ -3490,7 +3683,7 @@ and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e :
| Some id -> | Some id ->
fixed id; fixed id;
if (match builtin_ret name (match args with x :: _ -> ty_of_expr p f x | [] -> None) with if (match builtin_ret name (match args with x :: _ -> ty_of_expr p f x | [] -> None) with
| Some t -> field_kind p t = 3 | Some t -> is_heap_kind p t
| None -> false) | None -> false)
then put f (ins_abc op_builtin dst dst b_text_copy) then put f (ins_abc op_builtin dst dst b_text_copy)
| None -> bad "builtin `get` needs a `multi` or a `map` as its first argument") | None -> bad "builtin `get` needs a `multi` or a `map` as its first argument")
@ -4381,7 +4574,12 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols)
let col_of n = ref_index_of_name fnames n in let col_of n = ref_index_of_name fnames n in
let is_indexable (fl : Ast.field) = let is_indexable (fl : Ast.field) =
match Types.wob_kind_of_typ p_syms_for_indexes (Types.typ_of_field_ty (unwrap fl.Ast.ty)) with match Types.wob_kind_of_typ p_syms_for_indexes (Types.typ_of_field_ty (unwrap fl.Ast.ty)) with
| Types.WO_K_SCALAR | Types.WO_K_TEXT -> true (* iteration 19: Float is indexable — the engine keys it on
the TOTAL order's canonical bits (table.c's
idx_float_key), so -0.0 and +0.0 are one key and all
NaNs are one key. Bytes stays out: ordering it beyond
equality is out of scope. Mirrors loader.c. *)
| Types.WO_K_SCALAR | Types.WO_K_TEXT | Types.WO_K_FLOAT -> true
| _ -> false | _ -> false
in in
let table_indexes = let table_indexes =
@ -4398,7 +4596,7 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols)
if List.mem "unique" fl.Ast.annotations then begin if List.mem "unique" fl.Ast.annotations then begin
if not (is_indexable fl) then if not (is_indexable fl) then
index_col_err := Some (c.Ast.pos, Printf.sprintf index_col_err := Some (c.Ast.pos, Printf.sprintf
"`@unique` on `%s.%s`: only scalar and Text fields can be indexed" "`@unique` on `%s.%s`: only scalar, Text, and Float fields can be indexed"
c.Ast.name fl.Ast.name); c.Ast.name fl.Ast.name);
[ (true, [| col_of fl.Ast.name |]) ] [ (true, [| col_of fl.Ast.name |]) ]
end end
@ -4419,7 +4617,7 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols)
| Some fl -> | Some fl ->
if not (is_indexable fl) then if not (is_indexable fl) then
index_col_err := Some (c.Ast.pos, Printf.sprintf index_col_err := Some (c.Ast.pos, Printf.sprintf
"`@table(index: ...)` on `%s`: `%s` is not a scalar or Text field" "`@table(index: ...)` on `%s`: `%s` is not a scalar, Text, or Float field"
c.Ast.name cn)) c.Ast.name cn))
cols) cols)
cfg.Ast.indexes cfg.Ast.indexes
@ -4600,6 +4798,7 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols)
p_iface_id = !iface_id; p_method_id = !method_id; p_methods; p_uses; p_iface_id = !iface_id; p_method_id = !method_id; p_methods; p_uses;
p_module_syms = module_syms; p_module_of = module_of; p_colliding = colliding; p_module_syms = module_syms; p_module_of = module_of; p_colliding = colliding;
p_kints = Hashtbl.create 32; p_kints = Hashtbl.create 32;
p_kfloats = Hashtbl.create 16; (* iteration 19 *)
p_ktexts = Hashtbl.create 32; p_consts = []; p_nconsts = 0 } p_ktexts = Hashtbl.create 32; p_consts = []; p_nconsts = 0 }
in in
(* names are constants; interning them first keeps the pool's low (* names are constants; interning them first keeps the pool's low
@ -4672,7 +4871,11 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols)
| `Text s -> | `Text s ->
Buf.u8 consts k_text; Buf.u8 consts k_text;
Buf.u32 consts (String.length s); Buf.u32 consts (String.length s);
Buf.str consts s) Buf.str consts s
| `Float bits ->
(* iteration 19: the bits, exactly as OCaml holds them *)
Buf.u8 consts k_float;
Buf.i64 consts bits)
(List.rev p.p_consts); (List.rev p.p_consts);
let cls = Buf.create () in let cls = Buf.create () in
Array.iteri Array.iteri

View file

@ -53,6 +53,15 @@ let unknown_char_code = Diag.lexing_prefix ^ "01" (* WO-E001 *)
oversight; pinned by the plain-unterminated-string-reports-nothing oversight; pinned by the plain-unterminated-string-reports-nothing
assertion in compiler/test/runner.ml. *) assertion in compiler/test/runner.ml. *)
let unterminated_escape_code = Diag.lexing_prefix ^ "02" (* WO-E002 *) let unterminated_escape_code = Diag.lexing_prefix ^ "02" (* WO-E002 *)
let directive_code = Diag.lexing_prefix ^ "03" (* WO-E003: #if/#else/#end misuse *)
(* haxe-parity Task 8: build flags. `woc -D name` fills this before any
tokenize call; undefined flags are false. A module-level ref because the
compiler is a single-shot process — tests that care set it explicitly
and reset to empty. *)
module StringSet = Set.Make (String)
let defines : StringSet.t ref = ref StringSet.empty
type lexer = { type lexer = {
src : string; src : string;
@ -126,6 +135,7 @@ let keyword_kind = function
| "false" -> Some Token.KwFalse | "false" -> Some Token.KwFalse
| "use" -> Some Token.KwUse | "use" -> Some Token.KwUse
| "spawn" -> Some Token.KwSpawn | "spawn" -> Some Token.KwSpawn
| "using" -> Some Token.KwUsing
| "pub" -> Some Token.KwPub | "pub" -> Some Token.KwPub
| "break" -> Some Token.KwBreak | "break" -> Some Token.KwBreak
| "continue" -> Some Token.KwContinue | "continue" -> Some Token.KwContinue
@ -205,6 +215,59 @@ let read_interp_expr lx =
done; done;
Buffer.contents buf Buffer.contents buf
(* haxe-parity Task 8: the #if filter, run over the in-order token list at
the end of tokenize. A `#if <flag>` section is kept when the flag is
defined AND every enclosing section is kept; `#else` flips the section;
`#end` closes it. Nesting allowed; flag NAMES only (no expression
language — the spec's limit); undefined flags are false. Misuse is
WO-E003: a #if without a flag name, a second #else, a stray #else/#end,
or a #if left open at end of file. Eof always survives so the parser
still terminates after a reported error. *)
let preprocess (collector : Diag.Collector.t) ~(file : string)
(toks : Token.t list) : Token.t list =
let err line col msg =
Diag.Collector.add collector
(Diag.error ~code:directive_code ~file ~line ~col ~message:msg ())
in
(* frame: (emitting, seen_else, opening line, opening col) *)
let stack : (bool * bool * int * int) list ref = ref [] in
let emitting () = List.for_all (fun (e, _, _, _) -> e) !stack in
let out = ref [] in
let rec go = function
| [] -> (
match !stack with
| (_, _, l, c) :: _ -> err l c "#if left open — missing #end"
| [] -> ())
| { Token.kind = Token.HashIf; line; col } :: rest -> (
match rest with
| { Token.kind = Token.Ident flag; _ } :: rest2 ->
stack := (StringSet.mem flag !defines, false, line, col) :: !stack;
go rest2
| _ ->
err line col "#if needs a flag name (`#if portable`)";
stack := (false, false, line, col) :: !stack;
go rest)
| { Token.kind = Token.HashElse; line; col } :: rest ->
(match !stack with
| (e, false, l, c) :: tl -> stack := (not e, true, l, c) :: tl
| (_, true, _, _) :: _ -> err line col "second #else in one #if section"
| [] -> err line col "#else outside any #if");
go rest
| { Token.kind = Token.HashEnd; line; col } :: rest ->
(match !stack with
| _ :: tl -> stack := tl
| [] -> err line col "#end outside any #if");
go rest
| ({ Token.kind = Token.Eof; _ } as t) :: rest ->
out := t :: !out;
go rest
| t :: rest ->
if emitting () then out := t :: !out;
go rest
in
go toks;
List.rev !out
let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) : let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
Token.t list = Token.t list =
let lx = make src in let lx = make src in
@ -321,16 +384,99 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| segs -> emit (Token.InterpStr segs) line col) | segs -> emit (Token.InterpStr segs) line col)
end end
else if is_digit c then begin else if is_digit c then begin
(* iteration 19: one scanner for both numeric worlds. The integer run
is scanned into a buffer as well as accumulated, because a fraction
or an exponent turns the whole thing into a Float and OCaml's
float_of_string wants the original text.
A digit run stays an Int unless it is followed by:
- '.' AND a digit -> `1.5`. The digit requirement is what keeps
`0..10` a range (Dot Dot after Int 0) and leaves any future
`1.method()` reachable; without it `0..10` would lex as
Float 0. followed by `.10`.
- 'e'/'E' with an optional sign AND a digit -> `2e10`. Checked
before consuming, so `2eggs` is still Int 2 then Ident. *)
(* `c` is PEEKED, not consumed — the loop below reads it. Adding it to
the buffer here as well would count the first digit twice. *)
let buf = Buffer.create 16 in
let n = ref 0 in let n = ref 0 in
let scanning = ref true in let scanning = ref true in
while !scanning do while !scanning do
match peek lx with match peek lx with
| Some d when is_digit d -> | Some d when is_digit d ->
n := (!n * 10) + (Char.code d - Char.code '0'); n := (!n * 10) + (Char.code d - Char.code '0');
Buffer.add_char buf d;
ignore (advance lx) ignore (advance lx)
| _ -> scanning := false | _ -> scanning := false
done; done;
emit (Token.Int !n) line col let is_float = ref false in
(match (peek lx, peek_at lx 1) with
| Some '.', Some d when is_digit d ->
is_float := true;
Buffer.add_char buf '.';
ignore (advance lx);
let frac = ref true in
while !frac do
match peek lx with
| Some d when is_digit d ->
Buffer.add_char buf d;
ignore (advance lx)
| _ -> frac := false
done
| _ -> ());
(* exponent, on an integer run (`2e10`) or after a fraction (`1.5e-3`) *)
(match (peek lx, peek_at lx 1, peek_at lx 2) with
| Some ('e' | 'E'), Some d, _ when is_digit d -> is_float := true
| Some ('e' | 'E'), Some ('+' | '-'), Some d when is_digit d -> is_float := true
| _ -> ());
if !is_float then begin
(match peek lx with
| Some (('e' | 'E') as e) ->
Buffer.add_char buf e;
ignore (advance lx);
(match peek lx with
| Some (('+' | '-') as s) ->
Buffer.add_char buf s;
ignore (advance lx)
| _ -> ());
let ex = ref true in
while !ex do
match peek lx with
| Some d when is_digit d ->
Buffer.add_char buf d;
ignore (advance lx)
| _ -> ex := false
done
| _ -> ());
(* float_of_string cannot fail here: the buffer is a well-formed
decimal by construction. Overflow is not an error either — it
yields infinity, which is a legitimate Float per IEEE quiet
semantics (`1e400` is `inf`, not a compile error). *)
emit (Token.Float (float_of_string (Buffer.contents buf))) line col
end
else emit (Token.Int !n) line col
end
else if c = '#' then begin
(* haxe-parity Task 8: `#if` / `#else` / `#end` build-flag
directives. Names only — anything else after '#' is WO-E003. *)
ignore (advance lx);
let name =
match peek lx with
| Some d when is_ident_start d -> read_ident_chars lx
| _ -> ""
in
match name with
| "if" -> emit Token.HashIf line col
| "else" -> emit Token.HashElse line col
| "end" -> emit Token.HashEnd line col
| other ->
Diag.Collector.add collector
(Diag.error ~code:directive_code ~file ~line ~col
~message:
(Printf.sprintf
"unknown directive `#%s` — the build-flag directives are #if <flag>, #else, #end"
other)
())
end end
else if is_ident_start c then begin else if is_ident_start c then begin
let name = read_ident_chars lx in let name = read_ident_chars lx in
@ -446,4 +592,4 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
report_unknown line col other) report_unknown line col other)
done; done;
emit Token.Eof lx.line lx.col; emit Token.Eof lx.line lx.col;
List.rev !out preprocess collector ~file (List.rev !out)

View file

@ -406,7 +406,8 @@ let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass =
per rescan, which is a plain malloc and so ASan-visible). A Text read out per rescan, which is a plain malloc and so ASan-visible). A Text read out
of a PLACE is still a borrow — analyze_let's own place logic decides of a PLACE is still a borrow — analyze_let's own place logic decides
that, exactly as it does for a record field. *) that, exactly as it does for a record field. *)
| Scalar n when n = "Text" || n = Types.json_value_type -> Owned (* iteration 19: Bytes joins Text here — see Types.is_heap_scalar *)
| Scalar n when Types.is_heap_scalar n -> Owned
| Scalar n -> | Scalar n ->
if Types.is_builtin_scalar n then Copy if Types.is_builtin_scalar n then Copy
else if Types.is_gc_class ctx.syms n then Gc else if Types.is_gc_class ctx.syms n then Gc
@ -497,6 +498,7 @@ let variant_union_ty (ctx : ctx) (n : string) : Ast.field_ty option =
let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option = let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
match e.kind with match e.kind with
| IntLit _ -> Some (Scalar "Int") | IntLit _ -> Some (Scalar "Int")
| FloatLit _ -> Some (Scalar "Float") (* iteration 19 *)
| StrLit _ -> Some (Scalar "Text") | StrLit _ -> Some (Scalar "Text")
| BoolLit _ -> Some (Scalar "Bool") | BoolLit _ -> Some (Scalar "Bool")
(* A non-empty list literal knows its element type, so an unannotated (* A non-empty list literal knows its element type, so an unannotated
@ -1073,7 +1075,7 @@ let escape (ctx : ctx) (l : local) ~(pos : Ast.pos) ~message
let stores_by_copy (ctx : ctx) (p : place) : bool = let stores_by_copy (ctx : ctx) (p : place) : bool =
match place_ty ctx p with match place_ty ctx p with
| Some t -> ( match unwrap_nullable t with | Some t -> ( match unwrap_nullable t with
| Scalar n -> n = "Text" || n = Types.json_value_type | Scalar n -> Types.is_heap_scalar n
| _ -> false) | _ -> false)
| None -> false | None -> false
@ -1130,7 +1132,10 @@ type access = {
let rec read_expr (ctx : ctx) (e : Ast.expr) : unit = let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
match e.kind with match e.kind with
| IntLit _ | StrLit _ | BoolLit _ -> () (* iteration 19: a Float literal owns nothing — it is a word in a register,
exactly like an Int. (A Bytes value DOES own its heap object, but Bytes
has no literal form, so nothing new lands in this arm.) *)
| IntLit _ | FloatLit _ | StrLit _ | BoolLit _ -> ()
| Ident _ | Field _ | Index _ -> | Ident _ | Field _ | Index _ ->
(match place_of e with Some p -> use_place ctx p | None -> ()); (match place_of e with Some p -> use_place ctx p | None -> ());
read_place_parts ctx e read_place_parts ctx e
@ -1652,7 +1657,7 @@ and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
let cursor (n : string) (t : Ast.field_ty) : local = let cursor (n : string) (t : Ast.field_ty) : local =
let copied = let copied =
match unwrap_nullable t with match unwrap_nullable t with
| Scalar cn -> cn = "Text" || cn = Types.json_value_type | Scalar cn -> Types.is_heap_scalar cn
| _ -> false | _ -> false
in in
if copied then if copied then
@ -1788,7 +1793,7 @@ and analyze_let (ctx : ctx) (s : Ast.stmt) (name : string) (ty : Ast.field_ty op
| _ -> false | _ -> false
in in
let copies_out = reads_container && (match unwrap_nullable vty with let copies_out = reads_container && (match unwrap_nullable vty with
| Scalar n -> n = "Text" || n = Types.json_value_type | Scalar n -> Types.is_heap_scalar n
| _ -> false) in | _ -> false) in
let vplace = if copies_out then None else place_of value in let vplace = if copies_out then None else place_of value in
(* A `@gc` value read out of a container is neither a copy nor a new (* A `@gc` value read out of a container is neither a copy nor a new

View file

@ -1184,6 +1184,15 @@ and parse_primary (st : state) : Ast.expr =
let id = fresh_id st in let id = fresh_id st in
ignore (advance st); ignore (advance st);
{ Ast.id; pos; kind = Ast.IntLit n } { Ast.id; pos; kind = Ast.IntLit n }
| Token.Float f ->
(* iteration 19. Negative literals are Unary(Neg, FloatLit) exactly as
they are for Int — and that is what makes -0.0 reachable, since the
emitter lowers the negation to WOP_FNEG (a sign flip), not to
`0.0 - x` (which would give +0.0). *)
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
{ Ast.id; pos; kind = Ast.FloatLit f }
| Token.Str s -> | Token.Str s ->
let pos = peek_pos st in let pos = peek_pos st in
let id = fresh_id st in let id = fresh_id st in
@ -1597,13 +1606,22 @@ let parse_const_literal (st : state) : Ast.expr =
| Token.Int n -> | Token.Int n ->
ignore (advance st); ignore (advance st);
{ Ast.id; pos; kind = Ast.IntLit n } { Ast.id; pos; kind = Ast.IntLit n }
| Token.Float f ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.FloatLit f }
| Token.Dash -> ( | Token.Dash -> (
ignore (advance st); ignore (advance st);
match peek st with match peek st with
| Token.Int n -> | Token.Int n ->
ignore (advance st); ignore (advance st);
{ Ast.id; pos; kind = Ast.IntLit (-n) } { Ast.id; pos; kind = Ast.IntLit (-n) }
| _ -> unexpected st "an integer literal after '-'") | Token.Float f ->
(* a `const` initializer is folded here rather than emitted as a
negation, so -0.0 needs the sign to survive the fold: OCaml's unary
minus on a float flips the sign bit, which is exactly right. *)
ignore (advance st);
{ Ast.id; pos; kind = Ast.FloatLit (-.f) }
| _ -> unexpected st "a numeric literal after '-'")
| Token.Str s -> | Token.Str s ->
ignore (advance st); ignore (advance st);
{ Ast.id; pos; kind = Ast.StrLit s } { Ast.id; pos; kind = Ast.StrLit s }
@ -1854,18 +1872,18 @@ let parse_interface ?(pub = false) (st : state) : Ast.interface_decl =
statement is (`end_of_stmt`: optional `;`, then newline/EOF — there statement is (`end_of_stmt`: optional `;`, then newline/EOF — there
is no enclosing block at top level, but end_of_stmt's RBrace arm is is no enclosing block at top level, but end_of_stmt's RBrace arm is
harmless dead code here, never reached). *) harmless dead code here, never reached). *)
let parse_use_decl (st : state) : Ast.use_decl = let parse_use_decl ?(is_using = false) (st : state) : Ast.use_decl =
let pos = peek_pos st in let pos = peek_pos st in
let id = fresh_id st in let id = fresh_id st in
ignore (advance st); ignore (advance st);
(* 'use' *) (* 'use' or 'using' — `using` is a use PLUS extension registration *)
let first = expect_ident st "module name" in let first = expect_ident st "module name" in
let segments = ref [ first ] in let segments = ref [ first ] in
while accept st Token.Slash do while accept st Token.Slash do
segments := expect_ident st "module path segment" :: !segments segments := expect_ident st "module path segment" :: !segments
done; done;
end_of_stmt st; end_of_stmt st;
{ Ast.id; pos; segments = List.rev !segments } { Ast.id; pos; segments = List.rev !segments; is_using }
(* ---- top-level program --------------------------------------------------- (* ---- top-level program ---------------------------------------------------
@ -1900,6 +1918,7 @@ let parse_program (st : state) : Ast.program =
| Token.KwInterface -> decls := Ast.Interface (parse_interface st) :: !decls | Token.KwInterface -> decls := Ast.Interface (parse_interface st) :: !decls
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl ~pub:false st) :: !decls | Token.KwFn -> decls := Ast.Fn (parse_fn_decl ~pub:false st) :: !decls
| Token.KwUse -> decls := Ast.Use (parse_use_decl st) :: !decls | Token.KwUse -> decls := Ast.Use (parse_use_decl st) :: !decls
| Token.KwUsing -> decls := Ast.Use (parse_use_decl ~is_using:true st) :: !decls
| Token.KwConst -> decls := Ast.Const (parse_const_decl st) :: !decls | Token.KwConst -> decls := Ast.Const (parse_const_decl st) :: !decls
| Token.KwInline -> | Token.KwInline ->
let ipos = peek_pos st in let ipos = peek_pos st in
@ -1967,7 +1986,7 @@ module StringSet = Set.Make (String)
let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : Ast.expr) : Ast.expr = let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : Ast.expr) : Ast.expr =
match e.Ast.kind with match e.Ast.kind with
| Ast.IntLit _ | Ast.StrLit _ | Ast.BoolLit _ | Ast.DbStub _ -> e | Ast.IntLit _ | Ast.FloatLit _ | Ast.StrLit _ | Ast.BoolLit _ | Ast.DbStub _ -> e
| Ast.Ident name -> | Ast.Ident name ->
if StringSet.mem name bound then e if StringSet.mem name bound then e
else ( match StringMap.find_opt name consts with Some v -> { e with Ast.kind = v.Ast.kind } | None -> e) else ( match StringMap.find_opt name consts with Some v -> { e with Ast.kind = v.Ast.kind } | None -> e)

View file

@ -28,6 +28,12 @@ type kind =
(* literals *) (* literals *)
| Ident of string | Ident of string
| Int of int | Int of int
(* iteration 19: a Float literal. OCaml's `float` is an IEEE f64, the same
type the VM's registers hold, so the value is carried unchanged from
source to `.wob` (Int64.bits_of_float at emit time). A bare digit run is
still Token.Int — only a fraction or an exponent makes a Float, so every
pre-existing fixture lexes byte-identically. *)
| Float of float
| Str of string | Str of string
(* haxe-parity Task 2: a string literal containing at least one (* haxe-parity Task 2: a string literal containing at least one
`${expr}` interpolation. Alternating text/expr segments, in source `${expr}` interpolation. Alternating text/expr segments, in source
@ -66,6 +72,7 @@ type kind =
(* the concurrency arc (iterations 8+11): `spawn Cls { ... }`. `send` (* the concurrency arc (iterations 8+11): `spawn Cls { ... }`. `send`
is deliberately NOT a keyword — it is a builtin free-fn name. *) is deliberately NOT a keyword — it is a builtin free-fn name. *)
| KwSpawn | KwSpawn
| KwUsing
| KwPub | KwPub
(* haxe-parity Task 2 (small control surface): break/continue/do-while, (* haxe-parity Task 2 (small control surface): break/continue/do-while,
const values, and/or booleans, and inline-fn rejection (the haxe const values, and/or booleans, and inline-fn rejection (the haxe
@ -141,6 +148,12 @@ type kind =
| GtEq | GtEq
| PlusEq | PlusEq
| MinusEq | MinusEq
(* haxe-parity Task 8: `#if name / #else / #end` build-flag directives.
They exist only between the scanner and the preprocessor filter at the
end of Lexer.tokenize — the parser never sees one. *)
| HashIf
| HashElse
| HashEnd
(* meta *) (* meta *)
| Newline | Newline
| Eof | Eof

View file

@ -30,7 +30,12 @@ type wob_kind =
| WO_K_TEXT (* 3 *) | WO_K_TEXT (* 3 *)
| WO_K_MULTI (* 4 *) | WO_K_MULTI (* 4 *)
| WO_K_MAP (* 5 *) | WO_K_MAP (* 5 *)
| WO_K_NULLABLE (* 6 *) | WO_K_FLOAT (* 6 — iteration 19 *)
| WO_K_BYTES (* 7 — iteration 19 *)
(* Not a .wob kind byte: `?T` emits T's kind (emit.ml's kind_byte unwraps
first). It kept the value 6 in this list until iteration 19 gave 6 a real
meaning; the constructor order here has never been the wire order. *)
| WO_K_NULLABLE
(* ============================================================ (* ============================================================
Symbol tables (Pass 1 output, Pass 2 input) Symbol tables (Pass 1 output, Pass 2 input)
@ -161,11 +166,36 @@ let static_method_of (syms : symbols) (cls_name : string) (m_name : string) : me
| Some cls -> List.find_opt (fun (m : method_info) -> m.name = m_name && m.is_static) cls.methods | Some cls -> List.find_opt (fun (m : method_info) -> m.name = m_name && m.is_static) cls.methods
| None -> None | None -> None
(* Builtin scalars *) (* Builtin scalars. `Float` and `Bytes` joined in iteration 19 — see
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"] docs/stories/language-runtime-database/19-missing-scalar-types.md. Both are
real, distinct types with no implicit conversion to or from anything:
`float(i)` / `trunc(f)` bridge the two numeric worlds, and
`bytes_of_text` / `text_of_bytes` bridge the two byte carriers. *)
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"; "Float"; "Bytes"]
let is_builtin_scalar name = List.mem name builtin_scalars let is_builtin_scalar name = List.mem name builtin_scalars
(* The two numeric types. Arithmetic and comparison are legal within each and
an error ACROSS them — the check needs one predicate, not scattered string
compares (`Timestamp`/`Id` are Int-shaped conveniences, so they answer
`Int` here: `t + 1` on a Timestamp has always been legal). *)
(* Scalars whose VALUE is a heap object the slot owns: storing one copies, a
`let` holding one gets a drop, and reading one out of a container copies it
out. `Text` and `json.Value` were the whole list until iteration 19 added
`Bytes`, which is a wo_str in every respect but its class id — so every
ownership rule that named Text by string had to name this instead, or a
Bytes would silently never be dropped. Defined here so owner.ml and emit.ml
share one answer. (`json_value_type` is bound further down, so the
comparison is spelled out rather than referencing it.) *)
let is_heap_scalar (name : string) : bool =
name = "Text" || name = "Bytes" || name = "json.Value"
let numeric_world (t : string) : [ `Int | `Float | `Other ] =
match t with
| "Int" | "Timestamp" | "Id" -> `Int
| "Float" -> `Float
| _ -> `Other
(* haxe-parity Task 1 (modules) / gap-closure amendment: six reserved (* haxe-parity Task 1 (modules) / gap-closure amendment: six reserved
stdlib namespaces, not the plan text's five — `use fs`/`proc`/`net`/ stdlib namespaces, not the plan text's five — `use fs`/`proc`/`net`/
`time`/`json`/`env` must resolve now (their members arrive in plan `time`/`json`/`env` must resolve now (their members arrive in plan
@ -354,6 +384,13 @@ let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind =
came from, which json.encode emits back verbatim. Kinding it TEXT came from, which json.encode emits back verbatim. Kinding it TEXT
is what makes it drop correctly and pass through concatenation. *) is what makes it drop correctly and pass through concatenation. *)
if name = "Text" || name = json_value_type then WO_K_TEXT if name = "Text" || name = json_value_type then WO_K_TEXT
(* iteration 19. Float is word-shaped like a scalar but needs its own
kind so json, the WAL, and printing know the word is f64 bits and
not an integer. Bytes is heap-shaped like Text and MUST have its own
kind for the same reason WO_K_TEXT exists — the drop plan frees it —
plus the distinct id keeps a Text builtin from accepting one. *)
else if name = "Float" then WO_K_FLOAT
else if name = "Bytes" then WO_K_BYTES
else if is_stdlib_scalar_type name then WO_K_SCALAR else if is_stdlib_scalar_type name then WO_K_SCALAR
else if is_builtin_scalar name then WO_K_SCALAR else if is_builtin_scalar name then WO_K_SCALAR
else if StringMap.mem name syms.unions then else if StringMap.mem name syms.unions then
@ -395,6 +432,18 @@ let nullable_used_without_check_code = Diag.types_prefix ^ "11"
let nullable_assign_mismatch_code = Diag.types_prefix ^ "12" let nullable_assign_mismatch_code = Diag.types_prefix ^ "12"
let spawn_no_receive_code = Diag.types_prefix ^ "21" (* WO-E221: spawn target lacks fn receive(msg: M); E219/E220 are taken on the language-surface-strictness branch *) let spawn_no_receive_code = Diag.types_prefix ^ "21" (* WO-E221: spawn target lacks fn receive(msg: M); E219/E220 are taken on the language-surface-strictness branch *)
let traced_send_code = Diag.types_prefix ^ "22" (* WO-E222: traced(-containing) type in an actor message or actor state — aliased graphs cannot cross heap boundaries *) let traced_send_code = Diag.types_prefix ^ "22" (* WO-E222: traced(-containing) type in an actor message or actor state — aliased graphs cannot cross heap boundaries *)
let pub_read_write_code = Diag.types_prefix ^ "19" (* WO-E219: pub(read) field written outside its class *)
let using_collision_code = Diag.types_prefix ^ "20" (* WO-E220: using extension collides with a real method *)
(* haxe-parity Task 7: `using` extension-call rewrites, recorded during
typecheck and applied to the AST before the owner/emit passes (which
then see a plain free-fn call with the receiver as the first argument
and need no using-awareness at all). Keyed (file, call-expr id): expr
ids restart per parsed file, so the id alone is ambiguous. The value is
the bare extension fn name (the module is `use`d — `using` implies it —
so the emitter's unqualified cross-module resolution finds it). A
single-shot table for a single-shot compiler process. *)
let using_rewrites : (string * int, string) Hashtbl.t = Hashtbl.create 64
let missing_nil_check_code = Diag.types_prefix ^ "13" let missing_nil_check_code = Diag.types_prefix ^ "13"
(* haxe-parity Task 1 (modules). module_not_imported_code (WO-E210, (* haxe-parity Task 1 (modules). module_not_imported_code (WO-E210,
@ -456,7 +505,12 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
List.iter (function List.iter (function
| Ast.Class c -> | Ast.Class c ->
let fields = List.map (fun (f : Ast.field) -> let fields = List.map (fun (f : Ast.field) ->
(f.name, f.ty, f.default, f.annotations) (* pub(read) rides the annotation list as a synthetic marker so the
symbol shape stays put; the write check (WO-E219) reads it *)
( f.name,
f.ty,
f.default,
if f.pub_read then "pub_read" :: f.annotations else f.annotations )
) c.fields in ) c.fields in
let methods = List.map (fun (m : Ast.method_decl) -> let methods = List.map (fun (m : Ast.method_decl) ->
{ name = m.name; { name = m.name;
@ -702,6 +756,11 @@ let rec typ_equal (syms : symbols) (a : typ) (b : typ) : bool =
type builtin_arg_req = type builtin_arg_req =
| ReqText | ReqText
| ReqInt | ReqInt
(* iteration 19: the two new scalars need their own requirements, because
"any word" would let `trunc(count)` through and silently reinterpret an
integer as f64 bits — the exact mixing this iteration outlaws. *)
| ReqFloat
| ReqBytes
| ReqMulti | ReqMulti
| ReqMap | ReqMap
| ReqContainer (* multi or map, e.g. `count`/`get` resolve on either *) | ReqContainer (* multi or map, e.g. `count`/`get` resolve on either *)
@ -750,6 +809,27 @@ let builtin_signatures : (string * int * builtin_arg_req list) list =
("remove", 2, [ ReqMap; ReqAny ]); ("remove", 2, [ ReqMap; ReqAny ]);
("key_at", 2, [ ReqMap; ReqInt ]); ("key_at", 2, [ ReqMap; ReqInt ]);
("val_at", 2, [ ReqMap; ReqInt ]); ("val_at", 2, [ ReqMap; ReqInt ]);
(* iteration 19: the Float bridges and surface. `float`/`trunc` are the
ONLY way between the numeric worlds; `float_to_text` backs both
interpolation and json.encode. *)
("float", 1, [ ReqInt ]);
("trunc", 1, [ ReqFloat ]);
("parse_float", 1, [ ReqText ]);
("float_to_text", 1, [ ReqFloat ]);
("float_cmp", 2, [ ReqFloat; ReqFloat ]);
(* iteration 19: Bytes. `bytes_len`/`bytes_at`/`bytes_slice` are spelled
out rather than overloading `len`/`byte_at`/`substr`, because the point
of a distinct Bytes type is that a Text builtin never accepts one —
overloading would put the two carriers back in one namespace. *)
("bytes_len", 1, [ ReqBytes ]);
("bytes_at", 2, [ ReqBytes; ReqInt ]);
("bytes_slice", 3, [ ReqBytes; ReqInt; ReqInt ]);
("bytes_eq", 2, [ ReqBytes; ReqBytes ]);
("bytes_concat", 2, [ ReqBytes; ReqBytes ]);
("base64_encode", 1, [ ReqBytes ]);
("base64_decode", 1, [ ReqText ]);
("bytes_of_text", 1, [ ReqText ]);
("text_of_bytes", 1, [ ReqBytes ]);
] ]
let rec unwrap_nullable (t : typ) : typ = let rec unwrap_nullable (t : typ) : typ =
@ -833,21 +913,31 @@ let unnarrow_env (names : string list) ~(orig : typ StringMap.t)
chasing builtin return types. `Text` stays its own, narrower case: chasing builtin return types. `Text` stays its own, narrower case:
it is the one builtin scalar with a genuinely different it is the one builtin scalar with a genuinely different
representation. *) representation. *)
let is_scalar_shaped (name : string) : bool = is_builtin_scalar name && name <> "Text" (* iteration 19: `Float` and `Bytes` are builtin scalars but NOT Int-shaped.
Leaving them in would have let `print_int(price)` and `trunc(digest)`
through — the first prints f64 bits as a huge integer, the second
reinterprets a pointer. Both are exactly the representation mismatch this
predicate exists to catch, so both are excluded by name alongside Text. *)
let is_scalar_shaped (name : string) : bool =
is_builtin_scalar name && name <> "Text" && name <> "Float" && name <> "Bytes"
let matches_req (req : builtin_arg_req) (t : typ) : bool = let matches_req (req : builtin_arg_req) (t : typ) : bool =
match req, unwrap_nullable t with match req, unwrap_nullable t with
| ReqAny, _ -> true | ReqAny, _ -> true
| ReqText, TScalar "Text" -> true | ReqText, TScalar "Text" -> true
| ReqInt, TScalar name -> is_scalar_shaped name | ReqInt, TScalar name -> is_scalar_shaped name
| ReqFloat, TScalar "Float" -> true
| ReqBytes, TScalar "Bytes" -> true
| ReqMulti, TMulti _ -> true | ReqMulti, TMulti _ -> true
| ReqMap, TMap _ -> true | ReqMap, TMap _ -> true
| ReqContainer, (TMulti _ | TMap _) -> true | ReqContainer, (TMulti _ | TMap _) -> true
| (ReqText | ReqInt | ReqMulti | ReqMap | ReqContainer), _ -> false | (ReqText | ReqInt | ReqFloat | ReqBytes | ReqMulti | ReqMap | ReqContainer), _ -> false
let req_label = function let req_label = function
| ReqText -> "Text" | ReqText -> "Text"
| ReqInt -> "Int" | ReqInt -> "Int"
| ReqFloat -> "Float" (* iteration 19 *)
| ReqBytes -> "Bytes"
| ReqMulti -> "a `multi`" | ReqMulti -> "a `multi`"
| ReqMap -> "a `map`" | ReqMap -> "a `map`"
| ReqContainer -> "a `multi` or `map`" | ReqContainer -> "a `multi` or `map`"
@ -936,6 +1026,17 @@ let builtin_confident_ret (name : string) (arg0 : typ option) : typ option =
| "pop" | "shift" -> ( match arg0 with Some (TMulti e) -> Some e | _ -> None) | "pop" | "shift" -> ( match arg0 with Some (TMulti e) -> Some e | _ -> None)
| "key_at" -> ( match arg0 with Some (TMap (k, _)) -> Some k | _ -> None) | "key_at" -> ( match arg0 with Some (TMap (k, _)) -> Some k | _ -> None)
| "val_at" -> ( match arg0 with Some (TMap (_, v)) -> Some v | _ -> None) | "val_at" -> ( match arg0 with Some (TMap (_, v)) -> Some v | _ -> None)
(* iteration 19. `parse_float` returns a plain Float, not a `?Float`:
unparseable input is NaN, which already means "not a number" and needs no
second absence channel — unlike `parse_int`, where every bit pattern is a
valid integer so nil had to be borrowed from `?Int`. *)
| "float" | "parse_float" -> Some (TScalar "Float")
| "trunc" | "float_cmp" | "bytes_len" | "bytes_at" -> Some (TScalar "Int")
| "float_to_text" | "base64_encode" | "text_of_bytes" -> Some (TScalar "Text")
| "bytes_eq" -> Some (TScalar "Bool")
| "bytes_slice" | "bytes_concat" | "bytes_of_text" -> Some (TScalar "Bytes")
(* malformed base64 is nil, not a trap: it arrives from the network *)
| "base64_decode" -> Some (TNullable (TScalar "Bytes"))
| _ -> None | _ -> None
(* `use_edge`/`uses_of_program`/`path_str` -- relocated here (hotfix) (* `use_edge`/`uses_of_program`/`path_str` -- relocated here (hotfix)
@ -949,6 +1050,7 @@ type use_edge = {
ue_alias : string; ue_alias : string;
ue_segments : string list; ue_segments : string list;
ue_is_stdlib : bool; ue_is_stdlib : bool;
ue_is_using : bool; (* haxe-parity Task 7: `using` extension import *)
} }
let uses_of_program (prog : program) : use_edge list = let uses_of_program (prog : program) : use_edge list =
@ -959,7 +1061,12 @@ let uses_of_program (prog : program) : use_edge list =
let is_stdlib = let is_stdlib =
match u.segments with [ s ] -> is_stdlib_module s | _ -> false match u.segments with [ s ] -> is_stdlib_module s | _ -> false
in in
Some { ue_pos = u.pos; ue_alias = alias; ue_segments = u.segments; ue_is_stdlib = is_stdlib } Some
{ ue_pos = u.pos;
ue_alias = alias;
ue_segments = u.segments;
ue_is_stdlib = is_stdlib;
ue_is_using = u.is_using }
| Class _ | Interface _ | Fn _ | Const _ | Union _ -> None) | Class _ | Interface _ | Fn _ | Const _ | Union _ -> None)
prog.decls prog.decls
@ -1060,6 +1167,25 @@ let typecheck_program ~file ~(module_of : string -> string)
| [ fi ] -> Some fi | [ fi ] -> Some fi
| _ -> None) | _ -> None)
in in
(* haxe-parity Task 7: `using` extension candidates for a method-shaped
call — this file's using'd modules' pub free fns named [mname] whose
FIRST declared parameter type equals the receiver's type exactly.
Compile-time only; the rewrite (using_rewrites) is what the emitter
sees, never a dispatch table. *)
let usings_resolved =
List.filter (fun ((u : use_edge), _) -> u.ue_is_using) uses_resolved
in
let using_candidates (mname : string) (recv : typ) : free_fn_info list =
List.filter_map
(fun (_, (msyms : symbols)) ->
match StringMap.find_opt mname msyms.free_fns with
| Some fi when fi.pub -> (
match fi.params with
| (_, pty, _) :: _ when typ_of_field_ty pty = recv -> Some fi
| _ -> None)
| _ -> None)
usings_resolved
in
(* WO-E209's own type deriver -- deliberately NOT typecheck_expr's (* WO-E209's own type deriver -- deliberately NOT typecheck_expr's
`.typ` below, and deliberately narrower. typecheck_expr hands back `.typ` below, and deliberately narrower. typecheck_expr hands back
@ -1107,6 +1233,7 @@ let typecheck_program ~file ~(module_of : string -> string)
let rec confident_typ (cenv : typ StringMap.t) (e : expr) : typ option = let rec confident_typ (cenv : typ StringMap.t) (e : expr) : typ option =
match e.kind with match e.kind with
| IntLit _ -> Some (TScalar "Int") | IntLit _ -> Some (TScalar "Int")
| FloatLit _ -> Some (TScalar "Float") (* iteration 19 *)
| StrLit _ -> Some (TScalar "Text") | StrLit _ -> Some (TScalar "Text")
| BoolLit _ -> Some (TScalar "Bool") | BoolLit _ -> Some (TScalar "Bool")
(* A non-empty list literal is confident about its element type; an (* A non-empty list literal is confident about its element type; an
@ -1292,6 +1419,10 @@ let typecheck_program ~file ~(module_of : string -> string)
positive off an underivable expression. `current_ret` is the enclosing positive off an underivable expression. `current_ret` is the enclosing
fn/method's declared return type, set by each body walk below. *) fn/method's declared return type, set by each body walk below. *)
let current_ret : typ option ref = ref None in let current_ret : typ option ref = ref None in
(* the class whose method body is being checked — None in a free fn.
pub(read) writes are legal only when this names the field's declaring
class (Haxe's (default, null): the CLASS owns writes, not the instance) *)
let current_self : string option ref = ref None in
let report_nullable ~code (pos : pos) (msg : string) : unit = let report_nullable ~code (pos : pos) (msg : string) : unit =
Diag.Collector.add collector Diag.Collector.add collector
(Diag.error ~code ~file ~line:pos.line ~col:pos.col ~message:msg ()) (Diag.error ~code ~file ~line:pos.line ~col:pos.col ~message:msg ())
@ -1314,6 +1445,61 @@ let typecheck_program ~file ~(module_of : string -> string)
"%s is possibly nil (`?T`) — check it against `nil` before reaching through it" "%s is possibly nil (`?T`) — check it against `nil` before reaching through it"
what) what)
in in
(* iteration 19: Int and Float are two worlds with two instruction sets and
two failure modes. Mixing them in one operator is always an error, never
a promotion — the storefront that prices in cents did so because there
was no Float, and silently widening an Int into an f64 is how the next
rounding bug gets written. `float(i)` and `trunc(f)` say it out loud.
Only reports when BOTH sides have confident types: this file's standing
contract is to stay silent rather than guess (an underivable operand
means no diagnostic, not a false one). *)
let check_numeric_mix (cenv : typ StringMap.t) (pos : pos) (opname : string)
(left : expr) (right : expr) : unit =
let world (e : expr) =
match confident_typ cenv e with
| Some t -> ( match unwrap_nullable t with TScalar n -> numeric_world n | _ -> `Other)
| None -> `Other
in
match (world left, world right) with
| `Int, `Float | `Float, `Int ->
let int_side = if world left = `Int then "left" else "right" in
Diag.Collector.add collector
(Diag.error ~code:type_mismatch_code ~file ~line:pos.line ~col:pos.col
~message:
(Printf.sprintf
"`%s` mixes Int and Float — there is no implicit conversion; wrap the \
%s side in `float(...)`, or `trunc(...)` the Float side to stay in Int"
opname int_side)
())
| _ -> ()
in
let mod_on_float (cenv : typ StringMap.t) (pos : pos) (left : expr) (right : expr) : unit =
let is_float (e : expr) =
match confident_typ cenv e with
| Some t -> ( match unwrap_nullable t with TScalar n -> numeric_world n = `Float | _ -> false)
| None -> false
in
if is_float left || is_float right then
Diag.Collector.add collector
(Diag.error ~code:type_mismatch_code ~file ~line:pos.line ~col:pos.col
~message:
"`%` has no Float meaning — integer remainder is not IEEE remainder, and this \
language has no `fmod`; `trunc(...)` first if integer remainder is what you want"
())
in
let e219 (pos : pos) (fname : string) (cls : string) : unit =
report_nullable ~code:pub_read_write_code pos
(Printf.sprintf
"field `%s` of class `%s` is pub(read) — readable anywhere, writable only inside `%s`"
fname cls cls)
in
let e220 (pos : pos) (mname : string) (recv : string) : unit =
report_nullable ~code:using_collision_code pos
(Printf.sprintf
"`%s` is both a real method of `%s` and a `using` extension — rename one; an extension never overrides a method"
mname recv)
in
(* the boundary test every store/return/argument shares: value flows into a (* the boundary test every store/return/argument shares: value flows into a
non-nullable slot *) non-nullable slot *)
let crosses_boundary ~(target : typ) (v : expr_type_result) : bool = let crosses_boundary ~(target : typ) (v : expr_type_result) : bool =
@ -1356,6 +1542,7 @@ let typecheck_program ~file ~(module_of : string -> string)
expr_type_result = expr_type_result =
match e.kind with match e.kind with
| IntLit _ -> { typ = TScalar "Int"; is_nil = false } | IntLit _ -> { typ = TScalar "Int"; is_nil = false }
| FloatLit _ -> { typ = TScalar "Float"; is_nil = false } (* iteration 19 *)
| StrLit _ -> { typ = TScalar "Text"; is_nil = false } | StrLit _ -> { typ = TScalar "Text"; is_nil = false }
| BoolLit _ -> { typ = TScalar "Bool"; is_nil = false } | BoolLit _ -> { typ = TScalar "Bool"; is_nil = false }
| Ident name -> | Ident name ->
@ -1412,11 +1599,25 @@ let typecheck_program ~file ~(module_of : string -> string)
| Field (base, mname) -> ( | Field (base, mname) -> (
match Option.map unwrap_nullable (confident_typ cenv base) with match Option.map unwrap_nullable (confident_typ cenv base) with
| Some (TScalar cn) -> ( | Some (TScalar cn) -> (
(* haxe-parity Task 7: a method-shaped call may be a `using`
extension — a real method always wins AND collides loudly
(WO-E220); exactly one candidate on a method-less receiver
records the rewrite the emitter will see. *)
let cands = using_candidates mname (TScalar cn) in
let record_rewrite (fi : free_fn_info) =
Hashtbl.replace using_rewrites (file, e.id) fi.name;
Some (List.tl fi.params)
in
match StringMap.find_opt cn syms.classes with match StringMap.find_opt cn syms.classes with
| Some cls -> ( | Some cls -> (
match List.find_opt (fun (m : method_info) -> m.name = mname) cls.methods with match List.find_opt (fun (m : method_info) -> m.name = mname) cls.methods with
| Some m -> Some m.params | Some m ->
| None -> None) (match cands with
| _ :: _ -> e220 callee.pos mname cn
| [] -> ());
Some m.params
| None -> (
match cands with [ fi ] -> record_rewrite fi | _ -> None))
| None -> ( | None -> (
match StringMap.find_opt cn syms.interfaces with match StringMap.find_opt cn syms.interfaces with
| Some iface -> ( | Some iface -> (
@ -1424,8 +1625,10 @@ let typecheck_program ~file ~(module_of : string -> string)
List.find_opt (fun (s : method_sig_info) -> s.name = mname) iface.methods List.find_opt (fun (s : method_sig_info) -> s.name = mname) iface.methods
with with
| Some sg -> Some sg.params | Some sg -> Some sg.params
| None -> None) | None -> (
| None -> None)) match cands with [ fi ] -> record_rewrite fi | _ -> None))
| None -> (
match cands with [ fi ] -> record_rewrite fi | _ -> None)))
| _ -> ( | _ -> (
match base.kind with match base.kind with
| Ident head -> ( | Ident head -> (
@ -1604,6 +1807,9 @@ let typecheck_program ~file ~(module_of : string -> string)
ul.u_name ur.u_name) ul.u_name ur.u_name)
()) ())
| _ -> ()); | _ -> ());
(* iteration 19: `==` across the divide is explicitly out of scope as
an implicit conversion, so it is an error like every other mix. *)
check_numeric_mix cenv e.pos "==" left right;
{ typ = TScalar "Bool"; is_nil = false } { typ = TScalar "Bool"; is_nil = false }
| Binary (((Add | Sub | Mul | Div | Mod) as op), left, right) -> | Binary (((Add | Sub | Mul | Div | Mod) as op), left, right) ->
let lres = typecheck_expr env cenv left in let lres = typecheck_expr env cenv left in
@ -1635,13 +1841,33 @@ let typecheck_program ~file ~(module_of : string -> string)
| Div -> "/" | Div -> "/"
| _ -> "%")) | _ -> "%"))
()); ());
(* iteration 19: the two numeric worlds never mix implicitly. Caught
here rather than left to the emitter because the emitter picks the
opcode from the LEFT operand's type — `1 + 2.5` would lower to
integer ADD over f64 bits and produce a garbage number with no
diagnostic at all. Reported off confident types only, the same
stay-silent-when-underivable contract as the Text check above.
`%` is rejected outright for Float: fmod is not an operator this
language has, and silently meaning integer remainder would be
worse than saying no. *)
let opname =
match op with Add -> "+" | Sub -> "-" | Mul -> "*" | Div -> "/" | _ -> "%"
in
check_numeric_mix cenv e.pos opname left right;
if op = Mod then mod_on_float cenv e.pos left right;
{ typ = (match confident_typ cenv left with Some t -> t | None -> TScalar "Int"); { typ = (match confident_typ cenv left with Some t -> t | None -> TScalar "Int");
is_nil = false } is_nil = false }
| Binary ((Lt | Le | Gt | Ge), left, right) -> | Binary (((Lt | Le | Gt | Ge) as op), left, right) ->
let lres = typecheck_expr env cenv left in let lres = typecheck_expr env cenv left in
let rres = typecheck_expr env cenv right in let rres = typecheck_expr env cenv right in
if is_nullable lres.typ || lres.is_nil then e211 left.pos (expr_label left); if is_nullable lres.typ || lres.is_nil then e211 left.pos (expr_label left);
if is_nullable rres.typ || rres.is_nil then e211 right.pos (expr_label right); if is_nullable rres.typ || rres.is_nil then e211 right.pos (expr_label right);
(* iteration 19: ordering across the divide is the same error as
arithmetic across it — `cents < price` compares an integer against
f64 bits and answers nonsense. *)
check_numeric_mix cenv e.pos
(match op with Lt -> "<" | Le -> "<=" | Gt -> ">" | _ -> ">=")
left right;
{ typ = TScalar "Bool"; is_nil = false } { typ = TScalar "Bool"; is_nil = false }
| Binary (_, left, right) -> | Binary (_, left, right) ->
let _ = typecheck_expr env cenv left in let _ = typecheck_expr env cenv left in
@ -1940,10 +2166,19 @@ let typecheck_program ~file ~(module_of : string -> string)
(`type_mismatch_code`) — the same code the arm-unification check (`type_mismatch_code`) — the same code the arm-unification check
below uses — per the review's own instruction ("wire through below uses — per the review's own instruction ("wire through
E201 like arm mismatch"). *) E201 like arm mismatch"). *)
let repr_kind (t : typ) : [ `Text | `Scalar | `Other ] = (* iteration 19: FLOAT and BYTES get their own answers rather than folding
into `Scalar`/`Text`. Folding Float into `Scalar` would let a Float
subject switch against Int labels with no diagnostic and lower to an
integer compare over f64 bits; folding Bytes into `Text` would let a
Bytes subject match Text labels. Both are distinct representations to
this check, so a mismatch is reported and a same-kind switch is left
alone (emit.ml picks FEQ for a Float subject). *)
let repr_kind (t : typ) : [ `Text | `Scalar | `Float | `Bytes | `Other ] =
match wob_kind_of_typ syms t with match wob_kind_of_typ syms t with
| WO_K_TEXT -> `Text | WO_K_TEXT -> `Text
| WO_K_SCALAR -> `Scalar | WO_K_SCALAR -> `Scalar
| WO_K_FLOAT -> `Float
| WO_K_BYTES -> `Bytes
| WO_K_OWNED | WO_K_GCREF | WO_K_MULTI | WO_K_MAP | WO_K_NULLABLE -> `Other | WO_K_OWNED | WO_K_GCREF | WO_K_MULTI | WO_K_MAP | WO_K_NULLABLE -> `Other
in in
(* haxe-parity Task 4: a union-typed subject switches the arms from (* haxe-parity Task 4: a union-typed subject switches the arms from
@ -2282,7 +2517,12 @@ let typecheck_program ~file ~(module_of : string -> string)
match StringMap.find_opt cn syms.classes with match StringMap.find_opt cn syms.classes with
| Some cls -> ( | Some cls -> (
match List.find_opt (fun (fn2, _, _, _) -> fn2 = fname) cls.fields with match List.find_opt (fun (fn2, _, _, _) -> fn2 = fname) cls.fields with
| Some (_, fty, _, _) -> Some (resolve_field_ty fty) | Some (_, fty, _, annots) ->
(* WO-E219: a pub(read) field is written only from inside
its declaring class's own methods *)
if List.mem "pub_read" annots && !current_self <> Some cn then
e219 target.pos fname cn;
Some (resolve_field_ty fty)
| None -> None) | None -> None)
| None -> None) | None -> None)
| _ -> None) | _ -> None)
@ -2388,8 +2628,10 @@ let typecheck_program ~file ~(module_of : string -> string)
StringMap.add name (resolve_field_ty ty) acc) StringMap.add name (resolve_field_ty ty) acc)
(StringMap.singleton "self" (TScalar self_class)) m.params in (StringMap.singleton "self" (TScalar self_class)) m.params in
current_ret := Option.map resolve_field_ty m.ret; current_ret := Option.map resolve_field_ty m.ret;
current_self := Some self_class;
let _ = List.fold_left typecheck_stmt (env_with_self, cenv_with_self) m.body in let _ = List.fold_left typecheck_stmt (env_with_self, cenv_with_self) m.body in
current_ret := None; current_ret := None;
current_self := None;
false false
in in
@ -2565,7 +2807,7 @@ and walk_stmt (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (s : s
and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : expr) : unit = and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : expr) : unit =
visit bound e; visit bound e;
match e.kind with match e.kind with
| IntLit _ | StrLit _ | BoolLit _ | Ident _ -> () | IntLit _ | FloatLit _ | StrLit _ | BoolLit _ | Ident _ -> ()
| Field (b, _) -> walk_expr bound visit b | Field (b, _) -> walk_expr bound visit b
| Index (b, i) -> | Index (b, i) ->
walk_expr bound visit b; walk_expr bound visit b;
@ -2884,4 +3126,85 @@ let dump_symbols (syms : symbols) : string =
(Printf.sprintf "%s FN %s" (pos_str fn.pos) fn.name) :: acc (Printf.sprintf "%s FN %s" (pos_str fn.pos) fn.name) :: acc
) syms.free_fns [] in ) syms.free_fns [] in
String.concat "\n" (class_lines @ interface_lines @ fn_lines) String.concat "\n" (class_lines @ interface_lines @ fn_lines)
(* haxe-parity Task 7: rewrite `recv.ext(args)` into `ext(recv, args)` for
every call typecheck resolved as a `using` extension (using_rewrites).
Runs between typecheck and the owner/emit passes (bin/main.ml), so those
passes see an ordinary free-fn call — borrowed receiver as the first
argument — and carry zero using-awareness of their own. *)
let apply_using_rewrites ~(file : string) (prog : program) : program =
if Hashtbl.length using_rewrites = 0 then prog
else begin
let rec rx (e : expr) : expr =
let kind =
match e.kind with
| Call (({ kind = Field (base, _); _ } as callee), args)
when Hashtbl.mem using_rewrites (file, e.id) ->
let fn = Hashtbl.find using_rewrites (file, e.id) in
Call ({ callee with kind = Ident fn }, rx base :: List.map rx args)
| Call (callee, args) -> Call (rx callee, List.map rx args)
| Field (b, f) -> Field (rx b, f)
| Index (b, i) -> Index (rx b, rx i)
| Unary (op, o) -> Unary (op, rx o)
| Binary (op, l, r) -> Binary (op, rx l, rx r)
| Ctor (n, fs) -> Ctor (n, List.map (fun (k, v) -> (k, rx v)) fs)
| Spawn (n, fs) -> Spawn (n, List.map (fun (k, v) -> (k, rx v)) fs)
| Insert (n, fs) -> Insert (n, List.map (fun (k, v) -> (k, rx v)) fs)
| Delete d -> Delete (rx d)
| Interp inner -> Interp (rx inner)
| Switch (scrut, arms) ->
Switch
( rx scrut,
List.map
(fun (a : switch_arm) ->
{ a with values = List.map rx a.values; body = List.map rs a.body })
arms )
| ListLit items -> ListLit (List.map rx items)
| As (inner, t) -> As (rx inner, t)
| Try { body; ename; handler } ->
Try { body = rx body; ename; handler = List.map rs handler }
| Query q ->
Query
{ q with
q_wheres = List.map rx q.q_wheres;
q_group = Option.map (fun (g, k) -> (g, rx k)) q.q_group;
q_order = Option.map (fun (k, d) -> (rx k, d)) q.q_order;
q_take = Option.map rx q.q_take;
q_select = rx q.q_select }
| ( IntLit _ | FloatLit _ | StrLit _ | BoolLit _ | NilLit | Ident _ | MapLit
| DbStub _ ) as k ->
k
in
{ e with kind }
and rs (s : stmt) : stmt =
let k =
match s.s_kind with
| Let l -> Let { l with value = rx l.value }
| Assign { target; value } -> Assign { target = rx target; value = rx value }
| If { cond; then_body; else_body } ->
If
{ cond = rx cond;
then_body = List.map rs then_body;
else_body = Option.map (fun (p, b) -> (p, List.map rs b)) else_body }
| While { cond; body } -> While { cond = rx cond; body = List.map rs body }
| For f -> For { f with iter = rx f.iter; body = List.map rs f.body }
| DoWhile { cond; body } -> DoWhile { cond = rx cond; body = List.map rs body }
| Return e -> Return (Option.map rx e)
| ExprStmt e -> ExprStmt (rx e)
| (Break | Continue) as k -> k
in
{ s with s_kind = k }
in
let rd (d : decl) : decl =
match d with
| Class c ->
Class
{ c with
methods =
List.map (fun (m : method_decl) -> { m with body = List.map rs m.body }) c.methods }
| Fn f -> Fn { f with body = List.map rs f.body }
| (Interface _ | Use _ | Const _ | Union _) as d -> d
in
{ decls = List.map rd prog.decls }
end

View file

@ -1221,14 +1221,21 @@ let typecheck_str ~file src =
(syms, collector) (syms, collector)
let () = let () =
(* Money/SKU/Float carry no special status -- all three are ordinary (* Money/SKU carry no special status -- ordinary unknown types (WO-E225
unknown types now (WO-E225 fires on them as fields). Float went for fires on them as fields). Float was removed for the same phantom-scalar
the same phantom-scalar reason Money/SKU did: no float-literal syntax reason once, and iteration 19 EARNED IT BACK: there is float-literal
in the lexer and no float kind in wob, so no Float value could ever syntax in the lexer, a WO_K_FLOAT kind in wob, f64 opcodes in the VM, and
be written or represented. Timestamp stays a real builtin. *) a WAL slot -- so a Float value can now be written, stored, and replayed.
Money stays out (cents-as-Int holds until a workload proves otherwise);
Bytes came in with Float. Timestamp stays a real builtin. *)
check "Money is no longer a builtin scalar" (not (Types.is_builtin_scalar "Money")); check "Money is no longer a builtin scalar" (not (Types.is_builtin_scalar "Money"));
check "SKU is no longer a builtin scalar" (not (Types.is_builtin_scalar "SKU")); check "SKU is no longer a builtin scalar" (not (Types.is_builtin_scalar "SKU"));
check "Float is not a builtin scalar" (not (Types.is_builtin_scalar "Float")); check "Float is a builtin scalar (iteration 19)" (Types.is_builtin_scalar "Float");
check "Bytes is a builtin scalar (iteration 19)" (Types.is_builtin_scalar "Bytes");
(* the no-mixing rule's own predicate: Float must NOT be Int-shaped, or
`print_int(price)` would print f64 bits as a huge integer *)
check "Float is not Int-shaped" (not (Types.is_scalar_shaped "Float"));
check "Bytes is not Int-shaped" (not (Types.is_scalar_shaped "Bytes"));
check "Timestamp is a builtin scalar" (Types.is_builtin_scalar "Timestamp") check "Timestamp is a builtin scalar" (Types.is_builtin_scalar "Timestamp")
let () = let () =
@ -2271,7 +2278,7 @@ let validate_image (img : string) : string list =
let u64 o = if ok 8 o then String.get_int64_le img o else 0L in let u64 o = if ok 8 o then String.get_int64_le img o else 0L in
let none = 0xFFFFFFFF in let none = 0xFFFFFFFF in
if u32 0 <> 0x31424F57 then fail "bad magic"; if u32 0 <> 0x31424F57 then fail "bad magic";
if u32 4 <> 4 then fail "unsupported version"; if u32 4 <> 5 then fail "unsupported version"; (* v5: iteration 19 *)
let coff = u32 8 and ccnt = u32 12 in let coff = u32 8 and ccnt = u32 12 in
let koff = u32 16 and kcnt = u32 20 in let koff = u32 16 and kcnt = u32 20 in
let ioff = u32 24 and icnt = u32 28 in let ioff = u32 24 and icnt = u32 28 in
@ -2287,7 +2294,10 @@ let validate_image (img : string) : string list =
let tag = u8 !o in let tag = u8 !o in
incr o; incr o;
ctag.(i) <- tag; ctag.(i) <- tag;
if tag = 0 then o := !o + 8 (* tag 2 = WOB_K_FLOAT (iteration 19): same 8-byte payload as an Int,
read as f64 bits. Every bit pattern is a legal f64, so nothing to
validate beyond the length. *)
if tag = 0 || tag = 2 then o := !o + 8
else if tag = 1 then begin else if tag = 1 then begin
let n = u32 !o in let n = u32 !o in
o := !o + 4; o := !o + 4;
@ -2310,7 +2320,8 @@ let validate_image (img : string) : string list =
class_fields.(i) <- fcnt; class_fields.(i) <- fcnt;
let kco = !o in (* the kind bytes' offset: the v3 index walk re-reads them *) let kco = !o in (* the kind bytes' offset: the v3 index walk re-reads them *)
for j = 0 to fcnt - 1 do for j = 0 to fcnt - 1 do
if u8 (!o + j) > 5 then fail (Printf.sprintf "class %d field %d: bad kind" i j) (* WO_K_MAX is 7 since iteration 19 (6 = FLOAT, 7 = BYTES) *)
if u8 (!o + j) > 7 then fail (Printf.sprintf "class %d field %d: bad kind" i j)
done; done;
o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4); o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4);
(* v2: three u32 arrays of per-field metadata — names (a Text constant or (* v2: three u32 arrays of per-field metadata — names (a Text constant or
@ -2321,8 +2332,12 @@ let validate_image (img : string) : string list =
if nmk <> 0xFFFFFFFF && not (text_const nmk) then if nmk <> 0xFFFFFFFF && not (text_const nmk) then
fail (Printf.sprintf "class %d field %d: bad name constant" i j); fail (Printf.sprintf "class %d field %d: bad name constant" i j);
let fc = u32 (!o + ((fcnt + j) * 4)) in let fc = u32 (!o + ((fcnt + j) * 4)) in
if fc <> 0xFFFFFFFF && fc <> 0xFFFFFFFE && fc >= kcnt then (* NONE, JSON_RAW, NIL_SCALAR, BOOL, NIL_BOOL, and (iteration 19)
fail (Printf.sprintf "class %d field %d: field class out of range" i j) NIL_FLOAT are markers, not class ids — same list as loader.c *)
if
fc <> 0xFFFFFFFF && fc <> 0xFFFFFFFE && fc <> 0xFFFFFFFD && fc <> 0xFFFFFFFC
&& fc <> 0xFFFFFFFB && fc <> 0xFFFFFFFA && fc >= kcnt
then fail (Printf.sprintf "class %d field %d: field class out of range" i j)
done; done;
o := !o + (fcnt * 12); o := !o + (fcnt * 12);
(* v3: the index tail — flags (bit0 only), col_cnt 1..8, columns in (* v3: the index tail — flags (bit0 only), col_cnt 1..8, columns in
@ -2340,8 +2355,11 @@ let validate_image (img : string) : string list =
o := !o + 4; o := !o + 4;
if col >= fcnt then fail (Printf.sprintf "class %d index %d: column out of range" i x); if col >= fcnt then fail (Printf.sprintf "class %d index %d: column out of range" i x);
let kind = u8 (kco + col) in let kind = u8 (kco + col) in
if kind <> 0 && kind <> 3 then (* iteration 19: FLOAT (6) is indexable — the engine orders it by the
fail (Printf.sprintf "class %d index %d: column %d is not scalar or Text" i x c) total order (NaN last). BYTES (7) is not, this iteration. *)
if kind <> 0 && kind <> 3 && kind <> 6 then
fail
(Printf.sprintf "class %d index %d: column %d is not scalar, Text, or Float" i x c)
done done
done; done;
if !o > len then fail (Printf.sprintf "class %d: truncated" i) if !o > len then fail (Printf.sprintf "class %d: truncated" i)
@ -2493,13 +2511,15 @@ let validate_image (img : string) : string list =
golden lowering suite actually emits; 61 = DB_INSERT (arity 1: golden lowering suite actually emits; 61 = DB_INSERT (arity 1:
the class-id slot — field slots are runtime-validated, same as the class-id slot — field slots are runtime-validated, same as
the C loader) *) the C loader) *)
if c > 12 && (c < 61 || c > 67) then (* iteration 19 widened the accepted band to 70-83 (the Float
bridges and the Bytes surface) alongside 61-67 *)
if c > 12 && (c < 61 || c > 67) && (c < 70 || c > 83) then
fail (Printf.sprintf "method %d pc %d: builtin out of range" i pc) fail (Printf.sprintf "method %d pc %d: builtin out of range" i pc)
else if c = 4 then begin else if c = 4 then begin
if b > 5 then fail (Printf.sprintf "method %d pc %d: bad element kind" i pc) if b > 7 then fail (Printf.sprintf "method %d pc %d: bad element kind" i pc)
end end
else if c = 9 then begin else if c = 9 then begin
if b land 0x0F > 5 || b lsr 4 > 5 then if b land 0x0F > 7 || b lsr 4 > 7 then
fail (Printf.sprintf "method %d pc %d: bad key/value kind" i pc) fail (Printf.sprintf "method %d pc %d: bad key/value kind" i pc)
end end
else begin else begin
@ -2516,6 +2536,11 @@ let validate_image (img : string) : string list =
| 65 -> 3 | 65 -> 3
| 66 -> 3 | 66 -> 3
| 67 -> 2 | 67 -> 2
(* iteration 19: float bridges and Bytes, mirroring
loader.c's b_arity table *)
| 70 | 71 | 72 | 73 | 75 | 80 | 81 | 82 | 83 -> 1
| 74 | 76 | 78 | 79 -> 2
| 77 -> 3
| _ -> 0 | _ -> 0
in in
if arity > 0 then begin if arity > 0 then begin
@ -2524,6 +2549,15 @@ let validate_image (img : string) : string list =
end end
end end
| 30 | 31 -> () | 30 | 31 -> ()
(* iteration 19: FNEG is two registers, the rest are three — the same
shapes as their Int counterparts (opcodes 7 and 3-6/9-11) *)
| 38 ->
rchk pc a;
rchk pc b
| 34 | 35 | 36 | 37 | 39 | 40 | 41 ->
rchk pc a;
rchk pc b;
rchk pc c
| _ -> fail (Printf.sprintf "method %d pc %d: unknown opcode %d" i pc op)) | _ -> fail (Printf.sprintf "method %d pc %d: unknown opcode %d" i pc op))
code; code;
if ninstr > 0 then begin if ninstr > 0 then begin

View file

@ -25,8 +25,12 @@ static const wo_classdesc *g_classes;
static void db_val_free(uint8_t kind, uint64_t v) { static void db_val_free(uint8_t kind, uint64_t v) {
if (!v) return; if (!v) return;
switch (kind) { switch (kind) {
case WO_K_SCALAR: return; /* iteration 19: FLOAT is a word in the slot, nothing to free. BYTES is
case WO_K_TEXT: free((db_text *)(uintptr_t)v); return; stored in the same db_text blob a Text is, so the same free serves. */
case WO_K_SCALAR:
case WO_K_FLOAT: return;
case WO_K_TEXT:
case WO_K_BYTES: free((db_text *)(uintptr_t)v); return;
case WO_K_OWNED: db_rec_free((db_rec *)(uintptr_t)v, g_classes); return; case WO_K_OWNED: db_rec_free((db_rec *)(uintptr_t)v, g_classes); return;
case WO_K_MULTI: { case WO_K_MULTI: {
db_multi *m = (db_multi *)(uintptr_t)v; db_multi *m = (db_multi *)(uintptr_t)v;
@ -53,8 +57,14 @@ static uint64_t db_val_encode(const wo_classdesc *classes, uint8_t kind, uint64_
int *ok, const char **msg) { int *ok, const char **msg) {
*ok = 1; *ok = 1;
switch (kind) { switch (kind) {
case WO_K_SCALAR: return v; /* iteration 19: the f64's bits go in the slot unexamined. NaN, the
case WO_K_TEXT: { infinities, and -0.0 all store and read back bit-exact because nothing
here interprets the word — the kind byte is what tells json and the WAL
how to read it later. */
case WO_K_SCALAR:
case WO_K_FLOAT: return v;
case WO_K_TEXT:
case WO_K_BYTES: {
if (!v) return 0; if (!v) return 0;
const wo_str *s = (const wo_str *)(uintptr_t)v; const wo_str *s = (const wo_str *)(uintptr_t)v;
db_text *t = malloc(sizeof(db_text) + s->len); db_text *t = malloc(sizeof(db_text) + s->len);
@ -140,11 +150,17 @@ static uint64_t db_val_decode(wo_rt *rt, uint8_t kind, uint64_t v, int *ok,
const char **msg) { const char **msg) {
*ok = 1; *ok = 1;
switch (kind) { switch (kind) {
case WO_K_SCALAR: return v; case WO_K_SCALAR:
case WO_K_TEXT: { case WO_K_FLOAT: return v; /* iteration 19: bits back out unchanged */
case WO_K_TEXT:
case WO_K_BYTES: {
if (!v) return 0; if (!v) return 0;
const db_text *t = (const db_text *)(uintptr_t)v; const db_text *t = (const db_text *)(uintptr_t)v;
wo_str *s = wo_str_new(rt, t->bytes, t->len); /* the out-gate decides the KIND: a Bytes column must hand back a
Bytes, or a Text builtin would happily accept the row's value and
the distinct type would be a fiction at the storage boundary */
wo_str *s = kind == WO_K_BYTES ? wo_bytes_new(rt, t->bytes, t->len)
: wo_str_new(rt, t->bytes, t->len);
if (!s) goto oom; if (!s) goto oom;
return (uint64_t)(uintptr_t)s; return (uint64_t)(uintptr_t)s;
} }
@ -268,6 +284,20 @@ static void hdel(db_table *t, uint64_t id) {
/* ---- secondary indexes (Task 4) ---------------------------------------- */ /* ---- secondary indexes (Task 4) ---------------------------------------- */
/* iteration 19: an index key for a FLOAT column is the value's CANONICAL
* bits, not its raw bits. Two values that the total order calls equal must
* hash and compare equal, and raw bits break that twice: -0.0 and +0.0 are
* equal but differ in the sign bit, and two NaNs with different payloads are
* equal (both "last") but differ everywhere. Without this a `unique` Float
* column would accept both -0.0 and 0.0, and a probe for one would miss a row
* stored as the other. */
static uint64_t idx_float_key(uint64_t bits) {
double d = wo_f64(bits);
if (d != d) return 0x7FF8000000000000ull; /* every NaN -> the canonical one */
if (d == 0.0) return 0; /* -0.0 -> +0.0 */
return bits;
}
/* hash of one row's index columns: kind-driven, never trusted for equality */ /* hash of one row's index columns: kind-driven, never trusted for equality */
static uint64_t idx_hash(const wo_classdesc *c, const db_index *ix, const db_row *r) { static uint64_t idx_hash(const wo_classdesc *c, const db_index *ix, const db_row *r) {
uint64_t h = 0x9e3779b97f4a7c15ull; uint64_t h = 0x9e3779b97f4a7c15ull;
@ -282,6 +312,8 @@ static uint64_t idx_hash(const wo_classdesc *c, const db_index *ix, const db_row
else th = 0; else th = 0;
v = th; v = th;
} }
else if (c->kinds[col] == WO_K_FLOAT)
v = idx_float_key(v); /* iteration 19 */
h ^= hmix(v + i); h ^= hmix(v + i);
} }
return h ? h : 1; /* 0 marks an empty bucket */ return h ? h : 1; /* 0 marks an empty bucket */
@ -298,6 +330,9 @@ static int idx_cols_equal(const wo_classdesc *c, const db_index *ix, const db_ro
if (x != y) return 0; if (x != y) return 0;
} else if (x->len != y->len || memcmp(x->bytes, y->bytes, x->len) != 0) } else if (x->len != y->len || memcmp(x->bytes, y->bytes, x->len) != 0)
return 0; return 0;
} else if (c->kinds[col] == WO_K_FLOAT) {
/* iteration 19: compare canonicalized, matching idx_hash */
if (idx_float_key(a->slots[col]) != idx_float_key(b->slots[col])) return 0;
} else if (a->slots[col] != b->slots[col]) } else if (a->slots[col] != b->slots[col])
return 0; return 0;
} }

View file

@ -98,8 +98,15 @@ static uint64_t rd_u64(rbuf *r) {
static void enc_val(wbuf *w, const wo_classdesc *classes, uint8_t kind, uint64_t v) { static void enc_val(wbuf *w, const wo_classdesc *classes, uint8_t kind, uint64_t v) {
switch (kind) { switch (kind) {
case WO_K_SCALAR: wput_u64(w, v); return; /* iteration 19: a Float is one word on the wire, its raw IEEE bits — no
case WO_K_TEXT: { decimal rendering anywhere in the durability path, so replay is
bit-exact for NaN, the infinities, and -0.0 alike. A Bytes is the same
length-prefixed blob a Text is; the class table's kind byte is what
says which one comes back out. */
case WO_K_SCALAR:
case WO_K_FLOAT: wput_u64(w, v); return;
case WO_K_TEXT:
case WO_K_BYTES: {
if (!v) { if (!v) {
wput_u32(w, WAL_NIL_TEXT); wput_u32(w, WAL_NIL_TEXT);
return; return;
@ -160,13 +167,15 @@ static void enc_val(wbuf *w, const wo_classdesc *classes, uint8_t kind, uint64_t
static int dec_val(rbuf *r, wo_db *db, uint8_t kind, uint64_t *out) { static int dec_val(rbuf *r, wo_db *db, uint8_t kind, uint64_t *out) {
*out = 0; *out = 0;
switch (kind) { switch (kind) {
case WO_K_SCALAR: { case WO_K_SCALAR:
case WO_K_FLOAT: { /* iteration 19: the same word back, uninterpreted */
uint64_t v = rd_u64(r); uint64_t v = rd_u64(r);
if (r->bad) return -1; if (r->bad) return -1;
*out = v; *out = v;
return 0; return 0;
} }
case WO_K_TEXT: { case WO_K_TEXT:
case WO_K_BYTES: {
uint32_t len = rd_u32(r); uint32_t len = rd_u32(r);
if (r->bad) return -1; if (r->bad) return -1;
if (len == WAL_NIL_TEXT) return 0; if (len == WAL_NIL_TEXT) return 0;

View file

@ -7,9 +7,83 @@ and the plans it amends; its Phase 1–4 roadmap is retired in favour of the
approved story iterations. See [`00-status.md`](00-status.md) for current approved story iterations. See [`00-status.md`](00-status.md) for current
status. status.
## Standing critique (undated, author unrecorded)
Native speed — the big one. Everything is interpreted: ~40× behind Go on raw compute, no JIT, no AOT-to-native. The scheduling primitives win benchmarks; a compute-bound handler loses them all back. There is also no Float type at all (the storefront prices in cents for a reason), no SIMD story, and fixed interpreter ceilings (4096 register slots, 256 frames, ~42 KiB per fiber until growable contexts land). Native speed — the big one. Everything is interpreted: ~40× behind Go on raw compute, no JIT, no AOT-to-native. The scheduling primitives win benchmarks; a compute-bound handler loses them all back. There is also no Float type at all (the storefront prices in cents for a reason), no SIMD story, and fixed interpreter ceilings (4096 register slots, 256 frames, ~42 KiB per fiber until growable contexts land).
- Language expressiveness. No generics — the cache stores Text and tells you to json.encode; no function values or closures (doctrine, but it's why every handler is a class with one method); byte-based strings with no Unicode awareness; no Result-style error values (traps + try only); pattern matching is a switch, not destructuring. Some of this is deliberate rejection, but "deliberate" doesn't make the expressiveness appear. - Language expressiveness. No generics — the cache stores Text and tells you to json.encode; no function values or closures (doctrine, but it's why every handler is a class with one method); byte-based strings with no Unicode awareness; no Result-style error values (traps + try only); pattern matching is a switch, not destructuring. Some of this is deliberate rejection, but "deliberate" doesn't make the expressiveness appear.
- Concurrency holes the arc hasn't closed. send is one-way — no reply/request-response primitive (my own benchmarks couldn't await the actor and had to sleep); no supervision, links, or actor death (actors live until process end); unbounded mailboxes with zero backpressure; no timers beyond sleep; round-robin placement with no work stealing; multi-shard DB access still traps (stage 3 unbuilt); accept lives on one shard. - Concurrency holes the arc hasn't closed. send is one-way — no reply/request-response primitive (my own benchmarks couldn't await the actor and had to sleep); no supervision, links, or actor death (actors live until process end); unbounded mailboxes with zero backpressure; no timers beyond sleep; round-robin placement with no work stealing; multi-shard DB access still traps (stage 3 unbuilt); accept lives on one shard.
- Production plumbing. No TLS anywhere (proxy-mandated forever), no HTTP/2 or WebSockets yet, no crypto primitives (blocked on the bit-ops-vs-builtins fork), observability is print/stderr — no metrics, tracing, or profiler; no debugger, no LSP (discussed, never built); deps are git-rev-only with no registry, no transitive resolution, no semver; blue-green deploy and schema migrations are recorded futures, not features. - Production plumbing. No TLS anywhere (proxy-mandated forever), no HTTP/2 or WebSockets yet, no crypto primitives (blocked on the bit-ops-vs-builtins fork), observability is print/stderr — no metrics, tracing, or profiler; no debugger, no LSP (discussed, never built); deps are git-rev-only with no registry, no transitive resolution, no semver; blue-green deploy and schema migrations are recorded futures, not features.
- Proof maturity. 22's benchmark battery has never run — every number so far is a scratch measurement on one machine; TSan covers one demo; no fuzzing, no CI beyond local just, and the whole ecosystem is one framework, five samples, and one committed consumer. The honest summary: the architecture is ahead of the product — the doctrine bets (ownership+inference, actors, one binary, io_uring) are landing and measurable, while the surface a developer touches daily (types, tooling, ecosystem) is years behind the languages it benchmarks against. - Proof maturity. 22's benchmark battery has never run — every number so far is a scratch measurement on one machine; TSan covers one demo; no fuzzing, no CI beyond local just, and the whole ecosystem is one framework, five samples, and one committed consumer. The honest summary: the architecture is ahead of the product — the doctrine bets (ownership+inference, actors, one binary, io_uring) are landing and measurable, while the surface a developer touches daily (types, tooling, ecosystem) is years behind the languages it benchmarks against.
## Verification 2026-08-20
Every claim above was checked against the tree. **26 of 27 hold. One number
does not, and two problems are worse than stated.**
### Rejected: "~40× behind Go on raw compute"
Unsourced. Nothing in the repo measures compute against Go. The only Go
comparison on record runs the other way and on a different workload:
[`plan/exploration/c-runtime/00-plan.md`](plan/exploration/c-runtime/00-plan.md)
records the C prototype at **908,916 reads/s and 643,250 fsync-acked
commits/s on 8 shards vs Go `net/http` at 495k/355k with ~8× worse p99** on a
20-core box — I/O-bound serving, not compute. `runtime/bench/goref/` holds a
Go reference program, but no compute-bound result from it is written down
anywhere.
The figure also contradicts this document's own closing sentence: the
doctrine bets cannot be "measurable" while iteration 22 has never run. Drop
the number or produce the benchmark.
### Understated
- **`map<K,V>` lookup is a linear scan.** `runtime/src/cont.h`: parallel
key/value arrays, "linear scan lookup — deliberate milestone-1 KISS". Every
`get`/`has`/`set` is O(n). For a language whose framework routes requests
and whose planned cache is keyed, this outranks the missing `Float` as a
compute problem — and the compute paragraph never mentions it.
- **The multi-shard DB gap is structural, not a missing flag.**
`wo_engine_start` (`runtime/src/vm.c`) `memset`s each worker VM to zero, so
`rt.db` and `rt.wal` are NULL by construction off the primary;
`wo_builtin_db` then returns `WO_T_DB "database engine not initialized"`. It
is a clean trap rather than a crash — but any multi-shard program that
touches the database is broken today.
### Confirmed, with corrections to the numbers
| Claim | Evidence |
| --- | --- |
| interpreted only, no JIT, no AOT | no `jit` anywhere; `specs/2026-08-01-oop-compiler-vm-design.md` records AOT-to-C as rejected |
| no `Float`, no `Bytes` | absent from `compiler/src/types.ml`; no float builtin; `net.read` returns `Text` |
| no SIMD | nothing in `runtime/src` or `compiler/src` |
| fixed interpreter ceilings | **mislabeled**: 4096 is the value **stack** (`WO_STACK_SLOTS`), registers are 64 per frame (`WO_MAX_REGS`), frames 256 (`WO_MAX_FRAMES`) — all `runtime/src/wob.h`. Unlisted: `WO_MAX_SHARDS 64`, `WO_ARENA_MAX_CLASS 1024`, `WO_MAX_CATCH 64`. ~42 KiB/fiber matches the arc spec |
| no generics | `multi T` / `map<K,V>` are runtime-provided native classes by design |
| no function values or closures | no such form in the lexer keyword set or typechecker |
| byte-based strings, no Unicode | `WO_B_BYTE_AT`, ASCII `WO_B_TO_LOWER`; `json.c` decodes BMP only |
| no Result-style errors | traps + `try`/`catch` only |
| pattern matching is a switch | `KwSwitch`/`KwCase`; no destructuring form |
| `send` is one-way | `WO_B_SEND=69` is the last builtin (`WO_B_MAX 69u`) — no ask/reply opcode |
| no supervision, links, actor death | nothing in the runtime |
| unbounded mailboxes, no backpressure | `vm.h`: `msgs` is a "FIFO ring, growable"; `mcap` only grows |
| no timers beyond sleep | `time.sleep` is the only one; no `timerfd` in the runtime |
| round-robin placement, no work stealing | `eng_rr` cursor, `vm.c` |
| accept on one shard | one listener, `SO_REUSEADDR` only — no `SO_REUSEPORT` |
| no TLS | the only `tls` in the runtime is thread-local storage (`wo_tls_vm`) |
| no HTTP/2 or WebSockets | framework `http/` is parse/serve/types/auth/multipart; h2c parked per `00-status.md` |
| no crypto primitives | none |
| observability is print/stderr | `WO_B_PRINT`, `PRINT_INT`, `PRINT_ERR`; no counters, tracing, or profiler |
| no debugger, no LSP | neither exists |
| deps git-rev only | no semver, registry, or transitive resolution in the compiler |
| blue-green + migrations are futures | iteration 26 still pending |
| 22's battery never run | 22 is ⬜ "needs a spec first"; no `bench/baseline.json`, no `just db-bench`; `runtime/bench/` is the retired C prototype's harness |
| TSan covers one demo | only `scripts/fibers-accept.sh` builds and runs `wovm_tsan` |
| no fuzzing, no CI | no `.github/`, no fuzz target |
| one framework, five samples, one consumer | exact: `writeonce-framework`; employee, employee-list, fibers, gc-cycle, log-watcher; `web-app` |
### Consequence
The iteration order in
[`stories/language-runtime-database/00-story.md`](stories/language-runtime-database/00-story.md)
was re-sequenced against these findings on 2026-08-20 — Seq only, no `#`
renumbered, no file moved. See that table's second re-sequencing note.

154
docs/00-link-audit.md Normal file
View file

@ -0,0 +1,154 @@
# Markdown link audit — 2026-08-20
Scope: every `*.md` in the repo (`.git` excluded).
External URLs were not fetched (no network verification performed).
| | files | relative links | broken paths | bad anchors |
|---|---|---|---|---|
| first scan | 207 | 574 | 97 | 0 |
| after section A fixes | 206 | 569 | **88** | 0 |
Section A is repaired and verified. Sections B–F are pre-existing rot and
still open — every one of the remaining 88 lives there.
Re-check with `just linkcheck`.
Tool: `linkcheck.py` — walks the tree, strips fenced/inline code, extracts inline
links and reference definitions, resolves each relative target, and validates
`#fragment` against GitHub-style heading slugs of the target file.
---
## A. Regressions from the in-flight renumber — FIXED 2026-08-20
All nine broke because files moved in the working tree; each had a known
successor. Repaired:
| Source | Was | Now |
|---|---|---|
| `docs/00-status.md:167` | `stories/language-runtime-database/05-language-surface.md` | `…/done/05-language-surface.md` |
| `docs/00-status.md:187` | `stories/language-runtime-database/18-memory-db-features.md` | `…/hold/18-memory-db-features.md` |
| `docs/stories/language-runtime-database/00-story.md:60` | `05-language-surface.md` | `done/05-language-surface.md` |
| `docs/stories/language-runtime-database/00-story.md:69` | `18-memory-db-features.md` | `hold/18-memory-db-features.md` |
| `docs/stories/language-runtime-database/25-http-service.md:4` | `../00-story.md` | `00-story.md` |
| `docs/stories/language-runtime-database/26-blue-green-deploy.md:4` | `../00-story.md` | `00-story.md` |
| `.../refine/08-shard-actor-runtime.md:98` | `../hold/09e-durability-throughput-scale.md` | `22-durability-throughput-scale.md` |
| `.../refine/08-shard-actor-runtime.md:100` | `09f-io-uring-commit.md` | `23-io-uring-commit.md` |
| `.../refine/20-cross-program-tables.md:143` | `../hold/09d-keypair-attach-auth.md` | `21-keypair-attach-auth.md` |
The `25`/`26` pair used `../00-story.md` while `00-story.md` is a sibling — the
`refine/`-relative form pasted into files one level up.
Link labels were renumbered with their targets, since the old IDs contradicted
the new paths: `9e`→`22` and `9f`→`23` in `refine/08` (both the "Gated by the
benchmark" note and settled decision 4, "Order: 22 → the 8+11 arc → 23").
## B. Dead era: the old flat `docs/plan/NN-*.md` numbering (48 links)
`docs/plan/` now holds only `compiler/`, `exploration/`, `oop-vm/`,
`discarded.md`, `learnings.md`. Every flat-numbered plan doc is gone, and no
successor path was recorded. Missing targets, by inbound count:
- `09-concurrency-scaleout.md` — 12
- `11-wal-and-recovery.md` — 9
- `12-engine-disk-cutover.md` — 8
- `10-storage-foundations.md` — 8
- `done/02-event-loop-epoll.md` — 4
- `13-class-model-live-pricing.md` — 3
- `07-inotify-content-watcher.md` — 3
- `08-sendfile-static-assets.md` — 2
- `15-mcp-streamable-http.md`, `16-postgres-mirror.md`,
`done/03-hand-rolled-http.md`, `done/04-cutover-remove-tokio-axum.md` — 1 each
Inbound from: all of `docs/plan/exploration/{linux,postgresql,c-runtime,assembly}/`,
plus `docs/00-principles.md:57,77,78`, `runtime/README.md:47`,
`.dev/reference/README.md:55,56,58`.
**Decision needed** — these exploration docs still cite a plan structure that no
longer exists. Either map each to its story successor
(e.g. concurrency-scaleout → `stories/.../refine/08-shard-actor-runtime.md`,
wal/storage → `refine/22-durability-throughput-scale.md`,
io_uring → `refine/23-io-uring-commit.md`) or strip the links and keep prose.
## C. Dead era: the `docs/runtime/database/` tree (7 links)
`docs/runtime/` does not exist. Missing targets:
- `03-inmemory-engine.md` — 5 (incl. one `#recovery` anchor)
- `02-wo-language.md` — 2 (incl. one `#concurrency-model` anchor)
- `07-wo-seg-migration.md` — 1
Inbound from `docs/plan/exploration/linux/{07-io_uring,08-mmap,11-memfd_create}.md`,
`docs/plan/exploration/{assembly/02-writeonce-stance,c-runtime/02-single-binary}.md`,
`runtime/README.md:43`, `.dev/reference/README.md:31`.
## D. Never-created / removed siblings (5 links)
| Source | Target | Note |
|---|---|---|
| `docs/plan/exploration/linux/06-sendfile.md:10` | `./07-splice.md` | slot 07 is `07-io_uring.md`; no splice doc was written |
| `docs/plan/exploration/assembly/00-overview.md:19` | `../../../.dev/reference/go/src/runtime/atomic_amd64.s` | wrong depth **and** file absent from the vendored Go tree |
| `docs/00-principles.md:87` | `examples/blog/README.md` | `docs/examples/blog/` never existed |
| `.dev/reference/rest/README.md:76` | `../../docs/examples/blog/README.md` | same missing example |
| `.dev/reference/README.md:41,59` | `../docs/plan/exploration/colibri/00-colibri-and-mixtral.md` | `exploration/colibri/` absent (2 links) |
## E. `prototypes/` tree gone (4 links)
`prototypes/` is not in the repo. Referenced as `prototypes/wo-db/` from
`docs/plan/exploration/c-runtime/00-plan.md:88`, `02-single-binary.md:83`,
`runtime/README.md:5`, and `prototypes/llama-moe-stream` from
`.dev/reference/README.md:59`.
## F. Vendored skill copies — not ours to fix (13 links)
`.dev/skills/` holds flattened copies of plugin skills. The originals ship as
directories with sibling reference files; flattening dropped them.
- `.dev/skills/context-mode/context-mode.md:297-300` → `./references/{patterns-javascript,patterns-python,patterns-shell,anti-patterns}.md`
- `.dev/skills/superpowers/requesting-code-review.md:34,95` → `code-reviewer.md`
- `.dev/skills/superpowers/subagent-driven-development.md:232,300,345,400,410` → `implementer-prompt.md`, `task-reviewer-prompt.md`, `re-review-prompt.md` (×2), `../requesting-code-review/code-reviewer.md`
- `.dev/skills/superpowers/test-driven-development.md:206` → `writing-good-tests.md`
- `.dev/skills/superpowers/writing-skills.md:12,587` → `../using-superpowers/references/{codex,gemini}-tools.md`, `testing-skills-with-subagents.md`
Leave as-is, or re-vendor the skills with their `references/` subdirectories.
---
## Structural problems found alongside the links
1. **Iteration 19 was double-booked — RESOLVED.**
`refine/19-chat-websocket-workload.md` and `refine/24-chat-websocket-workload.md`
were the same document, differing only in the `# Iteration NN` heading, while
`19-missing-scalar-types.md` also claimed 19. `00-story.md`'s mapping line
(`24←19(chat)`) and table row 20 make **24 canonical**, so the 19 copy was
deleted. `refine/11-fibers.md:13` had been pointing at the 19 copy — repointed
to 24 first, so the delete broke nothing. Prose in `refine/08` that named
"iteration 19" for chat now says 24 (4 places).
2. **`08-shard-actor-runtime.md` existed twice — RESOLVED.**
58 lines at the stories root vs 110 in `refine/`. The `refine/` copy supersedes
it outright: same acceptance criteria plus the 2026-08-20 settled decisions, the
inferred-GC restatement (7b retired `@gc`, which the root copy still required),
and the corrected substrate path (the root copy cited `runtime/wo-rt.c`, removed
with the Rust runtime). Root copy deleted; the one inbound link,
`docs/00-status.md:171`, now points at `refine/`. Six other referrers already did.
3. **Unresolved merge-conflict markers were committed** into
`refine/20-cross-program-tables.md:139-145` — `<<<<<<<< HEAD:… / ======== /
>>>>>>>> language-surface-strictness:…/hold/09c-cross-program-tables.md`, from a
rename-conflicted merge. This is what produced that file's broken `09d` link:
the stale side was still in the file. Resolved in favour of HEAD (the renumbered
`21` text). `grep` confirms no other conflict markers under `docs/`.
## Still open
- Sections B–F above: 88 broken links, all pre-existing.
- `docs/plan/discarded.md` and `docs/plan/learnings.md` are the only survivors of
the old flat plan layout, which is why B and C have no successor map. A rename
table in one of them would let the exploration docs be repaired mechanically
rather than by guesswork.
- `docs/00-status.md:171` still shows iteration 8 as ⬜ while `00-story.md:68`
records arc stages 1+2 as landed 2026-08-20. Not a link problem — a status
disagreement between the two index docs. Left alone.
- `refine/23-io-uring-commit.md:26` still quotes the old order as
"9e → 8+11 → 9f" in a dated note. No link involved; left as historical record.

View file

@ -164,13 +164,14 @@ that sequences its tasks. Read one, approve, then the next starts.
| 2 | [VM core (`wovm`)](stories/language-runtime-database/done/02-vm-core.md) | ✅ | | 2 | [VM core (`wovm`)](stories/language-runtime-database/done/02-vm-core.md) | ✅ |
| 3 | [Compiler front (`woc`)](stories/language-runtime-database/done/03-compiler-front.md) | ✅ (known gaps below) | | 3 | [Compiler front (`woc`)](stories/language-runtime-database/done/03-compiler-front.md) | ✅ (known gaps below) |
| 4 | [Single binary end-to-end](stories/language-runtime-database/done/04-single-binary-e2e.md) | ✅ (known gaps below) | | 4 | [Single binary end-to-end](stories/language-runtime-database/done/04-single-binary-e2e.md) | ✅ (known gaps below) |
| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | 🔄 grammar done; **`?T` forced handling ✅ + reject rows ✅ + WO-E205 ✅ (2026-08-18)**; `pub(read)`/`using`/`#if` still ⏸ | | 5 | [Language surface](stories/language-runtime-database/done/05-language-surface.md) | 🔄 grammar done; **`?T` forced handling ✅ + reject rows ✅ + WO-E205 ✅ (2026-08-18)**; `pub(read)`/`using`/`#if` still ⏸ |
| 6 | [Program mode + stdlib](stories/language-runtime-database/done/06-program-mode-stdlib.md) | ✅ (the surface log-watcher uses) | | 6 | [Program mode + stdlib](stories/language-runtime-database/done/06-program-mode-stdlib.md) | ✅ (the surface log-watcher uses) |
| 7 | [log-watcher proof](stories/language-runtime-database/done/07-logwatcher-proof.md) | ✅ **landed 2026-08-15** — executable, not merely compilable: zero ASan leaks in all three modes, SIGTERM ends parked syscalls, fds flat, `LW_SOAK` gate; `just log-watcher` 7/0 | | 7 | [log-watcher proof](stories/language-runtime-database/done/07-logwatcher-proof.md) | ✅ **landed 2026-08-15** — executable, not merely compilable: zero ASan leaks in all three modes, SIGTERM ends parked syscalls, fds flat, `LW_SOAK` gate; `just log-watcher` 7/0 |
| 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/done/07b-inferred-gc-mark-sweep.md) | ✅ **landed 2026-08-18** — `@gc` gone (WO-E104), GC-ness inferred, RC replaced by incremental mark-sweep, `.wob` v4; supersedes iteration 2's RC memory model | | 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/done/07b-inferred-gc-mark-sweep.md) | ✅ **landed 2026-08-18** — `@gc` gone (WO-E104), GC-ness inferred, RC replaced by incremental mark-sweep, `.wob` v4; supersedes iteration 2's RC memory model |
| 8 | [Shard-actor runtime](stories/language-runtime-database/08-shard-actor-runtime.md) | ⬜ | | 8 | [Shard-actor runtime](stories/language-runtime-database/refine/08-shard-actor-runtime.md) | ⬜ |
| 9 | [Database engine](stories/language-runtime-database/done/09-database-engine.md) | 🔄 engine complete (storage/WAL/indexes/insert-update-delete); reads land with 9b | | 9 | [Database engine](stories/language-runtime-database/done/09-database-engine.md) | 🔄 engine complete (storage/WAL/indexes/insert-update-delete); reads land with 9b |
| 9b | [`@table`, relations, query](stories/language-runtime-database/done/09b-table-relations-query.md) | 🔄 query surface + relations + FK done (branch query-surface); group-by parked | | 9b | [`@table`, relations, query](stories/language-runtime-database/done/09b-table-relations-query.md) | 🔄 query surface + relations + FK done (branch query-surface); group-by parked |
| 19 | [Float + Bytes](stories/language-runtime-database/done/19-missing-scalar-types.md) | ✅ **landed 2026-08-20** — `.wob` v5: Float constant tag, field kinds 6/7, opcodes 34-41 (IEEE-quiet f64), builtins 70-83. Full stack: literals, arithmetic, `@table` column, WAL bit-exact replay, json fractions in / shortest-round-trip out, `?Float` reserved-NaN nil, total-order index (NaN last, `-0.0` == `+0.0`), Bytes + base64. No implicit Int/Float mixing (WO-E201); `float`/`trunc` are the only bridges. Proof: web-app price is a real Float (`{"price":9.99}`), `just web-app` 23/0; corpus 103/0 |
| 20 | [Cross-program tables](stories/language-runtime-database/refine/20-cross-program-tables.md) | 🔄 channel done (branch ipc-attach); manifest+binding pending | | 20 | [Cross-program tables](stories/language-runtime-database/refine/20-cross-program-tables.md) | 🔄 channel done (branch ipc-attach); manifest+binding pending |
| 21 | [Keypair attach auth](stories/language-runtime-database/refine/21-keypair-attach-auth.md) | 🔄 crypto+handshake done (branch keypair-auth); manifest pending | | 21 | [Keypair attach auth](stories/language-runtime-database/refine/21-keypair-attach-auth.md) | 🔄 crypto+handshake done (branch keypair-auth); manifest pending |
| 22 | [Durability, throughput, scale](stories/language-runtime-database/refine/22-durability-throughput-scale.md) | ⬜ needs a spec first | | 22 | [Durability, throughput, scale](stories/language-runtime-database/refine/22-durability-throughput-scale.md) | ⬜ needs a spec first |
@ -183,8 +184,8 @@ that sequences its tasks. Read one, approve, then the next starts.
| 14 | [skillhost host workload](stories/language-runtime-database/refine/28-skillhost-host-workload.md) | ⬜ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration | | 14 | [skillhost host workload](stories/language-runtime-database/refine/28-skillhost-host-workload.md) | ⬜ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration |
| 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/done/15-deps-package-manager.md) | ✅ **landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 | | 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/done/15-deps-package-manager.md) | ✅ **landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 |
| 16 | [web framework](stories/language-runtime-database/done/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (§C) behind 8/23/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route. **Auth-in-core landed 2026-08-20**: http/auth.wo (Bearer/Basic, ct_eq, req.principal), web-app dogfoods BearerAuth, gate 17/0 | | 16 | [web framework](stories/language-runtime-database/done/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (§C) behind 8/23/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route. **Auth-in-core landed 2026-08-20**: http/auth.wo (Bearer/Basic, ct_eq, req.principal), web-app dogfoods BearerAuth, gate 17/0 |
| 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | ⏸ **PARKED 2026-08-20** (developer directive; framework v1 first) — forks settled, spec + plan approved and ready on branch `library-internal`: kind = "library" key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design | | 17 | [library projects + `internal/`](stories/language-runtime-database/done/17-library-projects-internal.md) | ✅ **landed 2026-08-20** — `kind = "library"` in `wo.toml` (default `program`, so every existing manifest is byte-identical; unknown value = WO-E109 exit 2); `woc <dir>` on a library runs the FULL pipeline entry-less and writes nothing, retiring iteration 16's `--emit` workaround; the no-entry build error names the kind; lib+bin dual works. Go's `internal/` rule as **WO-E108** at the consumer's own `use`, dep-boundary-only — the library imports its own interior freely. Framework reorganized: `internal/{parse,serve}.wo` behind the line, `http/form.wo` split out to keep `media_type`/`form_values` public. Driver-only change; VM/`.wob`/GC untouched. `just web-app` **26/0** (3 new checks), every standing gate unchanged |
| 18 | [framework v2: memory-rich features](stories/language-runtime-database/18-memory-db-features.md) | 🔄 **spec APPROVED 2026-08-20, plan next** ([spec](superpowers/specs/2026-08-20-memory-db-features-design.md)): TTL cache + @table flags + durable job queue (drain-on-request) + `transaction { }` over the WAL's staged batch; pub/sub REJECTED until 8/11 | | 18 | [framework v2: memory-rich features](stories/language-runtime-database/hold/18-memory-db-features.md) | 🔄 **spec APPROVED 2026-08-20, plan next** ([spec](superpowers/specs/2026-08-20-memory-db-features-design.md)): TTL cache + @table flags + durable job queue (drain-on-request) + `transaction { }` over the WAL's staged batch; pub/sub REJECTED until 8/11 |
--- ---
@ -192,7 +193,7 @@ that sequences its tasks. Read one, approve, then the next starts.
| Track | Item | Where | | Track | Item | Where |
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- | | -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Language | nothing active — the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 20/21's forks | [order](#implementation-order-re-sequenced-2026-08-20--framework-goal) | | Language | nothing active — the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 20/21's forks | [order](#implementation-order-re-sequenced-2026-08-20--code-review-pass) |
Off-goal work is parked; the goal (2026-08-20) is the web framework as a Off-goal work is parked; the goal (2026-08-20) is the web framework as a
polished micro-framework v1 — iteration 17 (library kind + `internal/`) is polished micro-framework v1 — iteration 17 (library kind + `internal/`) is
@ -359,48 +360,53 @@ The C proving-ground work (`exploration/c-runtime/`, phases A–F: 859k reads/s,
## Pending ## Pending
### Implementation order (re-sequenced 2026-08-20 — framework goal) ### Implementation order (re-sequenced 2026-08-20 — code-review pass)
The goal is the web framework as a first-class library, so the framework Replaces the framework-goal ordering. Basis: the verified findings in
line leads and the workload-driven extras (14, 27) demote behind it. [`00-code-review.md`](00-code-review.md) — measure before optimizing, close
Dependency rules that force the shape: 23 explicitly after 8 + 22; 20 correctness holes before adding surface, stop stacking features on
"precedes iteration 25"; 21's plan folds into 20's; 12 only after 9 + 10 unmeasured ground. IDs below are post-renumber; the authoritative table with
(catalog to diff, HTTP to build on); 11 rides 8's shard scheduler; h2c per-row reasoning is
parked behind 8/23/11; the post-12 parked list stays parked by the [`00-story.md`](stories/language-runtime-database/00-story.md).
2026-08-08 scope directive. (Iteration 7 dropped from this list — landed
2026-08-15.)
1. ~~**17**~~ — **PARKED 2026-08-20** (developer directive: framework v1 Dependency rules that still force the shape: 23 explicitly after 8 + 22;
first); spec + plan approved, ready on branch `library-internal` for 21's plan folds into 20's; 26 only after 9 + 25; 11 rides 8's shard
whenever it unparks — slots anywhere after 16. The framework v1 slices scheduler; h2c parked behind 8/23/11.
took its place and landed the same day (branch framework-v1,
`just web-app` 21/0). 1. **22** — the measurement backbone, and now first: it has never run, so
2. **18** — framework v2 (transaction{} + cache/flags/jobs); spec every performance claim on this project is unsourced. No
APPROVED 2026-08-20, the only all-green spec-approved node in the `bench/baseline.json`, no `just db-bench`; `runtime/bench/` is the
[dependency graph](00-dependency-graph.md) — plan next, then retired C prototype's harness.
implement. 2. **8+11 stage 3** — transparent DB RPC, then 22 re-run for the
3. **20 then 21** — finish the half-done branches (ipc-attach: manifest + concurrency delta. Reframed as a correctness fix: worker VMs are
binding; keypair-auth: manifest) before they rot; 21 folds into 20's zero-initialized, so a DB statement off the primary traps `WO_T_DB`.
plan; both must precede 10. A multi-shard program that touches the database is broken today.
4. **22** — the measurement backbone; baseline single-shard BEFORE the 3. **30** (new) — observability, CI, fuzz: runtime counters + a profiler
runtime restructure so 8/23/11 sign against real numbers. hook, 22's harness run per change instead of by hand, a fuzz target on
5. **8** — shard-actor; the framework's multi-core serving story; unblocks the parser and `.wob` loader. No iteration covered any of this.
23, 11, h2c. 4. **19** — Float + Bytes; small, and it gates 24 (WS frames) and the
6. **23** — io_uring group-commit; explicitly after 8 + 22. crypto fork (digests).
7. **11** — fibers; retires the framework's disclosed keep-alive limit (an 5. **31** (new) — actor lifecycle: request/response (`send` is one-way and
idle connection starving accept forced close-when-idle in iteration 16 — callers `sleep` to await), bounded mailboxes (the FIFO only grows),
parked fds fix it properly); with 8 + 23 done, **h2c unparks** (spec §C) actor death/supervision, timers beyond `time.sleep`.
as the framework's HTTP/2 slice. 6. **24** — chat, the arc's acceptance; honest only after 19 + 31.
8. **10** — HTTP service layer; after 20 by its own precedence note; 7. **23** — io_uring group-commit; explicitly after 8 + 22.
`service` blocks lower onto the framework instead of a parallel stack. 8. **25** — HTTP service layer; `service` blocks lower onto the framework
9. **12** — blue-green; prerequisites 9 + 10 now exist; completes the instead of a parallel stack.
framework's deploy story. 9. **18** — framework v2 (transaction{} + cache/flags/jobs); spec APPROVED
10. **27 then 14** — demoted with the goal shift: skillhost (28) is no longer the 2026-08-20 but **demoted from first**: more surface on a framework with
driving workload; 27 likely collapses to "confirm `len(query)` + add one consumer, and its cache stores `Text` because there are no generics.
`exists`" and precedes 28 when they run. 10. **27, then 26** — query grammar from corpora (likely collapses to
11. **13 + parked drain** — metaprogramming (spec first), then group-by, "confirm `len(query)` + add `exists`"), then blue-green.
`pub(read)`/`using`/`#if`, ADT roster, WO-E225 — held behind 26 by the 11. **20 then 21** — demoted hard: new distribution surface while there is
scope directive. no TLS, no crypto primitives, and the multi-shard DB still traps. The
half-done branches (ipc-attach, keypair-auth) keep their manifests.
12. **28, then 29 + parked drain** — skillhost is no longer the driving
workload; then metaprogramming (spec first), group-by, ADT roster,
WO-E225, held by the 2026-08-08 scope directive.
✅ **17** — landed 2026-08-20 (unparked and executed): `kind = "library"`,
check mode, and the `internal/` dep boundary (WO-E108). Driver-only.
### Language track — sequenced, on the critical path ### Language track — sequenced, on the critical path
@ -418,7 +424,7 @@ parked behind 8/23/11; the post-12 parked list stays parked by the
| 23 | io_uring group-commit write path — batched durability overlapped on shard threads, fsync fallback | **no spec yet** — brainstorm after iterations 8 + 22 | | 23 | io_uring group-commit write path — batched durability overlapped on shard threads, fsync fallback | **no spec yet** — brainstorm after iterations 8 + 22 |
| 27 | Query grammar from real embedded-DB corpora — whole-query count + correlated exists, driven by the skillhost SQL catalogue; add only what a corpus uses | **no spec yet** — three forks; may collapse to "confirm len(query) + add exists" | | 27 | Query grammar from real embedded-DB corpora — whole-query count + correlated exists, driven by the skillhost SQL catalogue; add only what a corpus uses | **no spec yet** — three forks; may collapse to "confirm len(query) + add exists" |
| 14 | skillhost host workload — port skillhost (MCP host + confined script runner) to writeonce; drives the missing host capabilities into the open (bounded subprocess, stdin/stdout transport, fs metadata, FFI-vs-out-of-process) | **no spec yet** — gaps recorded in the iteration; each gap brainstormed on demand, bounded-subprocess first | | 14 | skillhost host workload — port skillhost (MCP host + confined script runner) to writeonce; drives the missing host capabilities into the open (bounded subprocess, stdin/stdout transport, fs metadata, FFI-vs-out-of-process) | **no spec yet** — gaps recorded in the iteration; each gap brainstormed on demand, bounded-subprocess first |
| 17 | library projects + dependency privacy — `wo.toml` kind = "library" (checkable without entry, dual lib+bin) + Go-style `internal/` at the [deps] boundary; framework reorg demonstrates both | **forks settled 2026-08-20** — decisions + framework/compiler/VM/GC impact in the iteration; spec/plan next | | 17 | library projects + dependency privacy — `wo.toml` kind = "library" (checkable without entry, dual lib+bin) + Go-style `internal/` at the [deps] boundary; framework reorg demonstrates both | ✅ **landed 2026-08-20** — [spec](superpowers/specs/2026-08-20-library-kind-internal-design.md) · [plan](superpowers/plans/2026-08-20-library-kind-internal.md) |
| 10 | HTTP service layer | [plan 6](superpowers/plans/2026-08-01-http-service-layer.md) | | 10 | HTTP service layer | [plan 6](superpowers/plans/2026-08-01-http-service-layer.md) |
| 11 | Fibers | vision §3, [blue-green exploration](plan/exploration/blue-green-vm/00-vision.md) | | 11 | Fibers | vision §3, [blue-green exploration](plan/exploration/blue-green-vm/00-vision.md) |
| 12 | Blue-green deploy | [spec](superpowers/specs/2026-08-03-blue-green-vm-design.md) — plan authored after iterations 9 + 25 | | 12 | Blue-green deploy | [spec](superpowers/specs/2026-08-03-blue-green-vm-design.md) — plan authored after iterations 9 + 25 |

View file

@ -9,10 +9,10 @@ use framework/http
use framework/router use framework/router
-- decode target for POST /products, encode shape for every product answer -- decode target for POST /products, encode shape for every product answer
typedef ProductView = { name: Text, price: Int, stock: Int } typedef ProductView = { name: Text, price: Float, stock: Int }
typedef NewOrder = { product: Text, qty: Int } typedef NewOrder = { product: Text, qty: Int }
fn view_json(name: Text, price: Int, stock: Int) -> Text { fn view_json(name: Text, price: Float, stock: Int) -> Text {
return json.encode(ProductView { name: name, price: price, stock: stock }); return json.encode(ProductView { name: name, price: price, stock: stock });
} }
@ -44,7 +44,7 @@ class ShowProduct {
-- Accepts THREE bodies: multipart/form-data (curl -F), a form post -- Accepts THREE bodies: multipart/form-data (curl -F), a form post
-- (application/x-www-form-urlencoded), and JSON — same insert either way. -- (application/x-www-form-urlencoded), and JSON — same insert either way.
fn create_product(name: Text, price: Int, stock: Int) -> Resp { fn create_product(name: Text, price: Float, stock: Int) -> Resp {
let made = try insert Product { name: name, price: price, stock: stock } let made = try insert Product { name: name, price: price, stock: stock }
catch (e) nil; catch (e) nil;
if made == nil { return conflict("product name already exists"); } if made == nil { return conflict("product name already exists"); }
@ -63,8 +63,10 @@ class CreateProduct {
if pstr == nil { return bad_request("multipart needs name, price, stock"); } if pstr == nil { return bad_request("multipart needs name, price, stock"); }
let sstr = part_named(ps, "stock"); let sstr = part_named(ps, "stock");
if sstr == nil { return bad_request("multipart needs name, price, stock"); } if sstr == nil { return bad_request("multipart needs name, price, stock"); }
let price = parse_int(pstr); -- parse_float answers NaN for unparseable input rather than a ?Float:
if price == nil { return bad_request("price must be a number"); } -- "not a number" is already a Float value, and NaN != NaN is the test
let price = parse_float(pstr);
if price != price { return bad_request("price must be a number"); }
let stock = parse_int(sstr); let stock = parse_int(sstr);
if stock == nil { return bad_request("stock must be a number"); } if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock); return create_product(name, price, stock);
@ -78,8 +80,8 @@ class CreateProduct {
if ps == nil { return bad_request("form needs name, price, stock"); } if ps == nil { return bad_request("form needs name, price, stock"); }
let ss = f["stock"]; let ss = f["stock"];
if ss == nil { return bad_request("form needs name, price, stock"); } if ss == nil { return bad_request("form needs name, price, stock"); }
let price = parse_int(ps); let price = parse_float(ps);
if price == nil { return bad_request("price must be a number"); } if price != price { return bad_request("price must be a number"); }
let stock = parse_int(ss); let stock = parse_int(ss);
if stock == nil { return bad_request("stock must be a number"); } if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock); return create_product(name, price, stock);

View file

@ -6,7 +6,10 @@
@table(name: "products", index: [name]) @table(name: "products", index: [name])
class Product { class Product {
name: Text @unique name: Text @unique
price: Int -- cents -- iteration 19: a real Float price, not cents-as-Int. This column IS the
-- iteration's living proof — `{"price": 9.99}` posted here used to fail the
-- whole checked decode because the language had no Float at all.
price: Float
stock: Int stock: Int
orders: backlink Order.product orders: backlink Order.product

View file

@ -10,7 +10,7 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
## What it is ## What it is
- **HTTP/1.1** server core (`http/`): request parsing (`Content-Length` - **HTTP/1.1** server core: request parsing (`Content-Length`
bodies, %-decoded paths and query strings), response serialization, a bodies, %-decoded paths and query strings), response serialization, a
blocking serve loop that answers 400 to malformed requests, 500 to blocking serve loop that answers 400 to malformed requests, 500 to
trapping handlers (and survives), closes every fd, and honors SIGTERM. trapping handlers (and survives), closes every fd, and honors SIGTERM.
@ -143,6 +143,30 @@ first (pure `.wo` cannot express it yet).
| SHA-256 · SHA-512 · HMAC · CRC32 | 🔧 the language has NO bitwise operators — these are C runtime builtins (libc-only doctrine permits hand-rolled crypto in the runtime) or the language grows bit ops first; the fork goes to a brainstorm before the slice | | SHA-256 · SHA-512 · HMAC · CRC32 | 🔧 the language has NO bitwise operators — these are C runtime builtins (libc-only doctrine permits hand-rolled crypto in the runtime) or the language grows bit ops first; the fork goes to a brainstorm before the slice |
| Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ framework slices AFTER the hash primitives exist; **hard stop there** — no RS256, no JOSE zoo | | Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ framework slices AFTER the hash primitives exist; **hard stop there** — no RS256, no JOSE zoo |
## Layout and privacy (iteration 17)
This project declares `kind = "library"` in `wo.toml`, so `woc <dir>` runs the
FULL pipeline over it — parse, typecheck, interface satisfaction, ownership, GC
inference — with no `fn main` required, and writes nothing. That retired
iteration 16's `woc --emit` verification workaround. `woc build` on it fails
naming the kind, unless a demo `main` is added (lib+bin is allowed).
- `http/` — the public surface: `Req`/`Resp` and the response builders
(`types.wo`), auth (`auth.wo`), multipart (`multipart.wo`), and the
body-inspection pair `media_type`/`form_values` (`form.wo`).
- `router/` — the route table and `Logging`.
- `app.wo` — `App`, the registration helpers, the dispatch loop.
- **`internal/` — not importable by a consumer.** The connection-level request
parser and carry-state record (`parse.wo`) and the serve loop, status text,
and response serializer (`serve.wo`) live here. A consuming app that writes
`use writeonce-framework/internal` gets **WO-E108** at that `use`. The rule
is Go's: a path segment named `internal` is refused across the `[deps]`
boundary only — the framework's own modules import it freely.
One honest disclosure: privacy restricts NAMING, not code size. `internal/`
modules still compile into the consumer's single image (there is no dead-code
elimination); a consumer simply cannot name them.
## The consuming sample ## The consuming sample
`docs/examples/web-app` — a small storefront importing this framework `docs/examples/web-app` — a small storefront importing this framework

View file

@ -1,5 +1,5 @@
-- app.wo — the assembly: an App holds the middleware chain and the route -- app.wo — the assembly: an App holds the middleware chain and the route
-- table, satisfies http's Dispatcher interface, and serves. -- table, satisfies internal's Dispatcher interface, and serves.
-- --
-- let app = App { middleware: [], routes: [] }; -- let app = App { middleware: [], routes: [] };
-- app.use_mw(Mw { m: Auth { token: t } }); -- app.use_mw(Mw { m: Auth { token: t } });
@ -12,6 +12,9 @@
-- and the server survives. -- and the server survives.
use http use http
use router use router
-- iteration 17: the serve loop is library-internal now (internal/serve.wo).
-- Legal here: the `internal/` boundary refuses CONSUMERS, not the library.
use internal
pub class App { pub class App {
middleware: multi Mw middleware: multi Mw
@ -67,6 +70,6 @@ pub class App {
} }
fn serve(host: Text, port: Int) -> Int { fn serve(host: Text, port: Int) -> Int {
return http.serve(host, port, self); return internal.serve(host, port, self);
} }
} }

View file

@ -0,0 +1,28 @@
-- http/form.wo — the public body-inspection surface: what media type a
-- request carries, and form-encoded bodies as decoded pairs.
--
-- PUBLIC by design (iteration 17). It sits here rather than in
-- `internal/parse.wo` with the rest of the parsing code because these two
-- functions are exactly what a consuming app calls; the decoders they lean on
-- stay behind the privacy line. `use internal` is legal INSIDE the library —
-- the boundary is consumer-only.
use internal
-- The request's media type: the content-type header lowercased with any
-- parameters ("; charset=...") stripped; "" when the header is absent.
pub fn media_type(req: Req) -> Text {
let ct = req.headers["content-type"];
if ct == nil { return ""; }
let semi = index_of(ct, ";");
if semi >= 0 { return to_lower(trim(substr(ct, 0, semi))); }
return to_lower(trim("${ct}"));
}
-- Form-encoded body -> decoded pairs ('+' as space, %XX), the body-parsing
-- hook for application/x-www-form-urlencoded. nil when the content-type
-- says the body is something else — a JSON body is not silently misread
-- as one giant form key.
pub fn form_values(req: Req) -> ?map<Text, Text> {
if media_type(req) != "application/x-www-form-urlencoded" { return nil; }
return parse_query(req.body);
}

View file

@ -1,4 +1,10 @@
-- http/parse.wo — HTTP/1.1 request parsing over a net connection. -- internal/parse.wo — HTTP/1.1 request parsing over a net connection.
--
-- INTERNAL (iteration 17): a consumer cannot `use` this module — the
-- `internal/` segment makes that WO-E108. The connection-level parser, the
-- carry-state record, and the %XX/query decoders are the framework's own
-- business; `media_type`/`form_values` are the public half and live in
-- `http/form.wo`.
-- --
-- Bounded reads only (`net.read`), so requests are buffered to the header -- Bounded reads only (`net.read`), so requests are buffered to the header
-- terminator, then the body to exactly Content-Length. Keep-alive means -- terminator, then the body to exactly Content-Length. Keep-alive means
@ -10,6 +16,7 @@
-- ok=false malformed request — answer 400 and close -- ok=false malformed request — answer 400 and close
-- ok=true, req non-nil one complete request -- ok=true, req non-nil one complete request
use net use net
use http -- Req lives in the public module now
const BODY_MAX = 1048576 const BODY_MAX = 1048576
@ -56,7 +63,7 @@ pub fn url_decode(t: Text, plus_space: Bool) -> Text {
} }
-- "a=1&b=hello+world" -> decoded pairs; a bare key maps to "" -- "a=1&b=hello+world" -> decoded pairs; a bare key maps to ""
fn parse_query(qs: Text) -> map<Text, Text> { pub fn parse_query(qs: Text) -> map<Text, Text> {
let q: map<Text, Text> = {}; let q: map<Text, Text> = {};
if qs == "" { return q; } if qs == "" { return q; }
for pair in split(qs, "&") { for pair in split(qs, "&") {
@ -71,25 +78,6 @@ fn parse_query(qs: Text) -> map<Text, Text> {
return q; return q;
} }
-- The request's media type: the content-type header lowercased with any
-- parameters ("; charset=...") stripped; "" when the header is absent.
pub fn media_type(req: Req) -> Text {
let ct = req.headers["content-type"];
if ct == nil { return ""; }
let semi = index_of(ct, ";");
if semi >= 0 { return to_lower(trim(substr(ct, 0, semi))); }
return to_lower(trim("${ct}"));
}
-- Form-encoded body -> decoded pairs ('+' as space, %XX), the body-parsing
-- hook for application/x-www-form-urlencoded. nil when the content-type
-- says the body is something else — a JSON body is not silently misread
-- as one giant form key.
pub fn form_values(req: Req) -> ?map<Text, Text> {
if media_type(req) != "application/x-www-form-urlencoded" { return nil; }
return parse_query(req.body);
}
fn malformed(rest: Text) -> Parsed { fn malformed(rest: Text) -> Parsed {
return Parsed { closed: false, ok: false, req: nil, rest: rest }; return Parsed { closed: false, ok: false, req: nil, rest: rest };
} }

View file

@ -1,4 +1,4 @@
-- http/serve.wo — response serialization + the blocking keep-alive serve -- internal/serve.wo — response serialization + the blocking keep-alive serve
-- loop. The dispatch seam is the Dispatcher interface (the router's App -- loop. The dispatch seam is the Dispatcher interface (the router's App
-- satisfies it, Task 3); it is wrapped in `try`, so a trapping handler -- satisfies it, Task 3); it is wrapped in `try`, so a trapping handler
-- answers 500 and the loop lives — a bad request must never kill the -- answers 500 and the loop lives — a bad request must never kill the
@ -7,6 +7,10 @@
-- in a blocking call already unwinds cleanly — the runtime's stop story). -- in a blocking call already unwinds cleanly — the runtime's stop story).
use net use net
use env use env
-- iteration 17: serve.wo moved under internal/, so Req/Resp and the response
-- builders are no longer same-module — they live in the public `http` module.
use http
use internal
pub interface Dispatcher { pub interface Dispatcher {
fn dispatch(mut req: Req) -> Resp fn dispatch(mut req: Req) -> Resp

View file

@ -1,10 +1,12 @@
name = "writeonce-framework" name = "writeonce-framework"
kind = "library"
version = "0.1.0" version = "0.1.0"
description = "A web framework written in writeonce: HTTP/1.1 keep-alive server core, router with :param captures, Handler/Middleware structural interfaces (iteration 16)" description = "A web framework written in writeonce: HTTP/1.1 keep-alive server core, router with :param captures, Handler/Middleware structural interfaces (iteration 16)"
[runtime] [runtime]
wo = ">= 0.1" wo = ">= 0.1"
# A LIBRARY project: no `fn main` here — the consuming app owns the entry. # A LIBRARY project (declared above since iteration 17): no `fn main` here —
# the consuming app owns the entry. `woc <dir>` typechecks the whole project.
# Apps import this repo through `wo.toml [deps]` (iteration 15) and # Apps import this repo through `wo.toml [deps]` (iteration 15) and
# `use writeonce-framework` / `use writeonce-framework/http` / `.../router`. # `use writeonce-framework` / `use writeonce-framework/http` / `.../router`.

View file

@ -1,6 +1,6 @@
# Haxe-Parity Language Adoptions Implementation Plan # Haxe-Parity Language Adoptions Implementation Plan
> **Status: ⏸ on hold behind [the executable plan](2026-08-14-logwatcher-executable.md)** (story iteration 5) — the grammar half of this plan is what let the driving workload compile, and the rest of it makes the language *refuse* more rather than making that program *run*, so it waits. Original status follows. — Tasks 1–4 ✅ shipped and review-verified: modules (`use`/`pub`), language surface (`and`/`or`, `${}` interpolation, `const`, break/continue, do-while), switch expressions, typedef records + enum payload variants. Task 5 (try/catch over the trap system) ✅ shipped 2026-08-14 — VM catch frames, expression and block catch arms, the `{code, line, method, msg}` record. Task 6 (`?T`) ✅ complete 2026-08-18 (branch `nullable-enforcement`): forced handling enforced — WO-E211/E212/E213 emit, locals narrow via `!= nil` guards / diverging early-return / `and`-chains / `while`; field places bind to a local first. Boxed scalar cells were superseded by `WO_NIL_SCALAR` before this task ran. Task 7 🔶 half: `static` members and `pub(read)` syntax landed, `using` and the `pub(read)` write check did not. Task 8: reject rows ✅ 2026-08-18 (WO-E105 doctrine diagnostics at class headers, expression heads, top-level heads; `Dynamic`/`untyped` type names via WO-E225's doctrine message; corpus `compile-fail/reject-*`); `#if` build flags still ⬜. Board: [00-status.md](../../00-status.md) > **Status: ✅ COMPLETE 2026-08-20** (story iteration 5, branch `language-surface-strictness`) — every task closed. Tasks 1–4 ✅ (modules, small surface, switch expressions, records+variants); Task 5 ✅ try/catch (2026-08-14); Task 6 ✅ `?T` forced handling (2026-08-18, WO-E211/212/213 + narrowing); Task 7 ✅ statics + `pub(read)` syntax (2026-08-14), write enforcement WO-E219 and `using` extensions with WO-E220 collision (2026-08-20 — compile-time rewrite to a free-fn call, owner/emit untouched); Task 8 ✅ reject rows WO-E105 (2026-08-18) and `#if` build flags (`woc -D name`, token-level, WO-E003 misuse; 2026-08-20). `is`/`throw` cut, `abstract` rejected. Board: [00-status.md](../../00-status.md)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> >

View file

@ -11,17 +11,17 @@
All integers little-endian; offsets are absolute file offsets. All integers little-endian; offsets are absolute file offsets.
**Header (44 bytes):** magic `"WOB1"`, version 4, then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). **Header (44 bytes):** magic `"WOB1"`, version 5 (iteration 19; see "v5: Float and Bytes" below), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none).
**Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL). **Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL); tag 2 = f64 as its IEEE 754 bit pattern in an LE u64 (v5). There is no Bytes tag: Bytes has no literal form.
**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order: **Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order:
1. `field_names[i]` — constant index of the field's name, or all-ones for "not recorded" (what a hand-built test image writes). 1. `field_names[i]` — constant index of the field's name, or all-ones for "not recorded" (what a hand-built test image writes).
2. `field_class[i]` — the class id the field refers to: its own class for an OWNED/GCREF field, its *element's* class for a container of records; `0xFFFFFFFE` marks a `json.Value` field, whose Text holds a raw JSON slice; `0xFFFFFFFD` a nullable scalar (`WO_NIL_SCALAR` nil); `0xFFFFFFFC` a plain `Bool` (json encodes `true`/`false`); `0xFFFFFFFB` a `?Bool` (both); all-ones for none. 2. `field_class[i]` — the class id the field refers to: its own class for an OWNED/GCREF field, its *element's* class for a container of records; `0xFFFFFFFE` marks a `json.Value` field, whose Text holds a raw JSON slice; `0xFFFFFFFD` a nullable scalar (`WO_NIL_SCALAR` nil); `0xFFFFFFFC` a plain `Bool` (json encodes `true`/`false`); `0xFFFFFFFB` a `?Bool` (both); `0xFFFFFFFA` a `?Float` (v5 — nil is `WO_NIL_FLOAT`, not `WO_NIL_SCALAR`); all-ones for none.
3. `field_elem[i]` — a container field's element kinds: a MULTI's element kind, or a MAP's key kind in the low nibble and value kind in the next; 0 otherwise. 3. `field_elem[i]` — a container field's element kinds: a MULTI's element kind, or a MAP's key kind in the low nibble and value kind in the next; 0 otherwise.
Field kinds: 0 SCALAR, 1 OWNED, 2 GCREF, 3 TEXT, 4 MULTI, 5 MAP. Runtime object layout: 16-byte header then one 8-byte slot per field, in declaration order. Field kinds: 0 SCALAR, 1 OWNED, 2 GCREF, 3 TEXT, 4 MULTI, 5 MAP, **6 FLOAT, 7 BYTES** (v5). Runtime object layout: 16-byte header then one 8-byte slot per field, in declaration order.
The metadata exists for exactly one reason: `json.encode`/`json.decode` are runtime services driven by class metadata (`runtime/src/json.c`) rather than per-type generated code, so the names a JSON object needs and the shapes a decode has to build must live in the image. Every other part of the runtime ignores it. The metadata exists for exactly one reason: `json.encode`/`json.decode` are runtime services driven by class metadata (`runtime/src/json.c`) rather than per-type generated code, so the names a JSON object needs and the shapes a decode has to build must live in the image. Every other part of the runtime ignores it.
@ -53,6 +53,8 @@ The metadata exists for exactly one reason: `json.encode`/`json.decode` are runt
| 31 | TRAP Bx | explicit trap with code Bx | | 31 | TRAP Bx | explicit trap with code Bx |
| 32 | TRY A sBx | push a catch frame for this frame and window: handler at pc + sBx, error record register A (haxe-parity Task 5) | | 32 | TRY A sBx | push a catch frame for this frame and window: handler at pc + sBx, error record register A (haxe-parity Task 5) |
| 33 | ENDTRY | pop the innermost catch frame — the try region completed without trapping | | 33 | ENDTRY | pop the innermost catch frame — the try region completed without trapping |
| 34–38 | FADD/FSUB/FMUL/FDIV/FNEG | f64 arithmetic on the register's bits (v5). **None of these trap**: IEEE 754 quiet semantics, so `x/0.0` is ±Inf and `0.0/0.0` is NaN. FNEG flips the sign bit, so `-0.0` is reachable |
| 39–41 | FEQ/FLT/FLE | f64 IEEE compares, result 0/1 — so any comparison involving NaN is 0, and `0.0 == -0.0` is 1. Not a total order; indexes and order-by use the `float_cmp` builtin instead |
**try/catch (Task 5).** A trap raised while a catch frame is live unwinds every frame *above* the catching one exactly as an uncaught trap does (drop maps run, registers null), then releases what the try region owned in the catching frame — the difference between the drop entry at the trapping instruction and the one at the handler pc — and resumes at the handler instead of leaving the VM. A frame that returns takes its still-open catch frames with it, so a `return` out of a try region cannot leave a handler pointing at a dead window. With no catch frame live, a trap behaves byte-for-byte as it did before v2. The catch arm's error record is an ordinary compiler-allocated object filled by the `err_fill` builtin (field order: 0 code, 1 line, 2 method, 3 msg). **try/catch (Task 5).** A trap raised while a catch frame is live unwinds every frame *above* the catching one exactly as an uncaught trap does (drop maps run, registers null), then releases what the try region owned in the catching frame — the difference between the drop entry at the trapping instruction and the one at the handler pc — and resumes at the handler instead of leaving the VM. A frame that returns takes its still-open catch frames with it, so a `return` out of a try region cannot leave a handler pointing at a dead window. With no catch frame live, a trap behaves byte-for-byte as it did before v2. The catch arm's error record is an ordinary compiler-allocated object filled by the `err_fill` builtin (field order: 0 code, 1 line, 2 method, 3 msg).
@ -70,6 +72,72 @@ The metadata exists for exactly one reason: `json.encode`/`json.decode` are runt
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT, IO (a syscall the source cannot prevent said no — errno's message rides in the error record). **Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT, IO (a syscall the source cannot prevent said no — errno's message rides in the error record).
## v5: Float and Bytes (iteration 19)
The version bump is real: an image written before this iteration is rejected,
and so is one written after it by an older runtime. Both directions are
deliberate — the new kind bytes and the new constant tag would be silently
misread otherwise, and a misread f64 is a plausible-looking wrong number
rather than a crash.
**What v5 adds**
- Constant tag `2` — an f64 as its raw IEEE 754 bits in an LE u64. Bits, not
a decimal rendering, so a literal reaches the VM exactly as written and no
parse/print round trip sits between source and register.
- Field kind `6 FLOAT` — word-shaped like SCALAR (the slot holds f64 bits), but
its own kind because three services cannot guess from a register alone: json
(a Float field must emit `9.99`, not `4621...`), the WAL (replay must not
reinterpret the word), and printing.
- Field kind `7 BYTES` — pointer-shaped like TEXT and sharing the `wo_str`
object layout byte for byte, differing only in the header's `class_id`
(`WO_CLS_BYTES`). That distinct id is what lets a Text builtin refuse a Bytes
and vice versa; `WO_B_TEXT_COPY` preserves the kind, so every
copy-on-ownership-boundary already handles both.
- `field_class` marker `0xFFFFFFFA` — a `?Float`. Nil is `WO_NIL_FLOAT`, a
reserved quiet NaN (`0x7FF8000000000EE1`), because neither of the existing
sentinels works: the zero word is `+0.0`, and `WO_NIL_SCALAR`'s bit pattern
*is* `-2.0`. A computed NaN is the platform's canonical quiet NaN, so it never
collides; the cost is one NaN payload out of 2^51, exactly as `?Int` costs one
absurd integer.
- Opcodes `34–41` — the f64 arithmetic and compare set (table above).
- Builtins `70–83` — `float`/`trunc`/`parse_float`/`float_to_text`/`float_cmp`,
then the Bytes surface (`bytes_len`/`bytes_at`/`bytes_slice`/`bytes_eq`/
`bytes_concat`/`base64_encode`/`base64_decode`/`bytes_of_text`/
`text_of_bytes`).
**Two numeric worlds, no implicit crossing.** Int keeps its DIV0 trap; Float
never traps. There is no coercion in either direction — not in arithmetic, not
in comparison, not in `==` — and the typechecker reports a mix as WO-E201
rather than letting the emitter pick an opcode from one side and misread the
other. `float(i)` and `trunc(f)` are the only bridges; `trunc` traps
`WO_T_BOUNDS` on NaN, ±Inf, and anything outside i64, because the Int world has
no value to hand back and returning 0 is how currency bugs start.
**One deliberate deviation from IEEE: the total order.** `FLT`/`FLE`/`FEQ` are
IEEE, so `NaN < 1.0` is false and `NaN == NaN` is false. But an index and an
`order by` *require* a total order — otherwise a sort's result depends on the
comparison sequence and a B-tree walk loses rows. The `float_cmp` builtin
provides it: `-Inf < finite < +Inf < NaN`, with `-0.0` equal to `+0.0`. Index
keys are canonicalized to match (`table.c`'s `idx_float_key`: every NaN maps to
the canonical one, `-0.0` maps to `+0.0`), so a `unique` Float column treats
`-0.0` and `0.0` as the same key and a probe for one finds a row stored as the
other. FLOAT is therefore an indexable kind; BYTES is not, this iteration.
**Rendering.** One renderer (`wo_float_text`) serves interpolation,
`float_to_text`, and `json.encode`, so the three can never disagree. It picks
the fewest significant digits that reparse to the same *bits*, then prefers
fixed notation over exponential across the range `1e-6 … 1e21` — "shortest"
alone would render a price of `900.0` as `9e+02`. A rendering always carries a
`.` or an exponent, so a Float never prints as `1` where an Int would.
**json boundaries.** A Float field accepts the whole JSON number grammar,
fractions and exponents included (an Int field's strictness is unchanged — a
fraction there still fails the decode whole). A non-finite Float encodes as
`null`, because JSON has no `nan`/`inf` literal and emitting one would be
invalid JSON. A Bytes field crosses as a base64 string, matching
`base64_encode`'s alphabet exactly.
## Enum payload variants (haxe-parity compiler Task 4) ## Enum payload variants (haxe-parity compiler Task 4)
`.wob` v1 is unchanged — no new section, no new header field, no version `.wob` v1 is unchanged — no new section, no new header field, no version

View file

@ -51,13 +51,33 @@ rows in landing order, then the pending rows in implementation order.
File names keep their IDs — every board, spec, and plan references File names keep their IDs — every board, spec, and plan references
iterations by number, so numbers never renumber. iterations by number, so numbers never renumber.
RE-SEQUENCED 2026-08-20 (second pass) against the verified findings in
[`docs/00-code-review.md`](../../00-code-review.md). **Seq changed; no `#`
changed and no file moved** — that is what an immutable ID is for. The
rule applied: measure before optimizing, close correctness holes before
adding surface, and stop stacking features on unmeasured ground.
- **22 → first.** It has never run. `bench/baseline.json` does not exist,
there is no `just db-bench`, and `runtime/bench/` is the retired C
prototype's harness plus a Go reference. Until 22 runs, no performance
statement about this project is sourced.
- **The arc's stage 3 → second, reframed as correctness.** `wo_engine_start`
zero-initializes worker VMs, so `rt.db` is NULL off the primary and any DB
statement there traps `WO_T_DB "database engine not initialized"`. A
multi-shard program that touches the database is broken today.
- **New 30 (observability, CI, fuzz) and new 31 (actor lifecycle).** The
review's two largest gaps had no iteration at all: nothing to run the
proof automatically, and no request/response, backpressure, supervision,
or timers behind `spawn`/`send`.
- **18, 20, 21 demoted.** All three add surface; none answer a named gap.
| Seq | # | Iteration | Delivers | | Seq | # | Iteration | Delivers |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | 1 | [Principles doc](done/01-principles-doc.md) | `docs/00-principles.md` — the doctrine page every later slice links back to | | 1 | 1 | [Principles doc](done/01-principles-doc.md) | `docs/00-principles.md` — the doctrine page every later slice links back to |
| 2 | 2 | [VM core](done/02-vm-core.md) | `wovm`: `.wob` loader, register interpreter, arena, borrow word, `@gc` collector | | 2 | 2 | [VM core](done/02-vm-core.md) | `wovm`: `.wob` loader, register interpreter, arena, borrow word, `@gc` collector |
| 3 | 3 | [Compiler front](done/03-compiler-front.md) | `woc`: lexer → parser → typechecker → ownership pass, diagnostics | | 3 | 3 | [Compiler front](done/03-compiler-front.md) | `woc`: lexer → parser → typechecker → ownership pass, diagnostics |
| 4 | 4 | [Single binary end-to-end](done/04-single-binary-e2e.md) | emitter + conformance corpus + `woc build` self-contained binary | | 4 | 4 | [Single binary end-to-end](done/04-single-binary-e2e.md) | emitter + conformance corpus + `woc build` self-contained binary |
| 5 | 5 | [Language surface](05-language-surface.md) | Haxe-parity adoptions, grammar + strictness halves (landed in waves through 2026-08-20) | | 5 | 5 | [Language surface](done/05-language-surface.md) | Haxe-parity adoptions, grammar + strictness halves (landed in waves through 2026-08-20) |
| 6 | 6 | [Program mode + stdlib](done/06-program-mode-stdlib.md) | `fn main`, exit codes, `fs`/`proc`/`net`/`time`/`json` builtins | | 6 | 6 | [Program mode + stdlib](done/06-program-mode-stdlib.md) | `fn main`, exit codes, `fs`/`proc`/`net`/`time`/`json` builtins |
| 7 | 7 | [log-watcher proof](done/07-logwatcher-proof.md) | the driving workload compiled, executable, soak-proven (landed 2026-08-15) | | 7 | 7 | [log-watcher proof](done/07-logwatcher-proof.md) | the driving workload compiled, executable, soak-proven (landed 2026-08-15) |
| 8 | 7b | [Inferred GC + mark-sweep](done/07b-inferred-gc-mark-sweep.md) | `@gc` removed; GC-ness inferred; RC replaced by incremental per-shard tri-color mark-sweep | | 8 | 7b | [Inferred GC + mark-sweep](done/07b-inferred-gc-mark-sweep.md) | `@gc` removed; GC-ness inferred; RC replaced by incremental per-shard tri-color mark-sweep |
@ -65,20 +85,22 @@ iterations by number, so numbers never renumber.
| 10 | 9b | [`@table`, relations, query](done/09b-table-relations-query.md) | `@table` real storage; `ref`/`backlink`/`multi`; compiler-checked queries | | 10 | 9b | [`@table`, relations, query](done/09b-table-relations-query.md) | `@table` real storage; `ref`/`backlink`/`multi`; compiler-checked queries |
| 11 | 15 | [deps: `wo.toml [deps]`](done/15-deps-package-manager.md) | exact-rev git deps + `wo.lock` + `.wo-deps`; flat-only, offline once locked | | 11 | 15 | [deps: `wo.toml [deps]`](done/15-deps-package-manager.md) | exact-rev git deps + `wo.lock` + `.wo-deps`; flat-only, offline once locked |
| 12 | 16 | [web framework](done/16-web-framework.md) | the `.wo` framework v1 (router, middleware, auth, all three body hooks) consumed via `[deps]` | | 12 | 16 | [web framework](done/16-web-framework.md) | the `.wo` framework v1 (router, middleware, auth, all three body hooks) consumed via `[deps]` |
| 13 | 8+11 | [Shard-actor runtime](08-shard-actor-runtime.md) · [Fibers](refine/11-fibers.md) | THE ARC (stages 1+2 landed 2026-08-20: fibers/budget/actors/io_uring plane; pinned shards, envelope sends, home-routed frees, WO-E222); stage 3 = transparent DB RPC + 22 re-run | | 13 | 22 | [Durability, throughput, scale](refine/22-durability-throughput-scale.md) | restart-persistence proof, benchmarks, ~1M rows — the baseline the arc and 23 sign against. **Promoted to first pending (was seq 18)**: it has never run, so every performance claim on this project is currently unsourced. *(was 9e)* |
| 14 | 18 | [framework v2: memory-rich features](18-memory-db-features.md) | spec+plan approved: TTL cache, @table flags, durable job queue, `transaction { }` over the WAL's staged batch | | 14 | 8+11 | [Shard-actor runtime](refine/08-shard-actor-runtime.md) · [Fibers](refine/11-fibers.md) | THE ARC (stages 1+2 landed 2026-08-20: fibers/budget/actors/io_uring plane; pinned shards, envelope sends, home-routed frees, WO-E222); stage 3 = transparent DB RPC + 22 re-run. **Stage 3 is a correctness hole, not an optimization**: worker shards are zero-initialized, so a DB statement off the primary traps `WO_T_DB`. |
| 15 | 19 | [Float + Bytes](19-missing-scalar-types.md) | the missing scalars, full stack: IEEE-quiet f64 through literals/VM/@table/WAL/json + Bytes as the binary carrier — feeds 24 (WS frames) and the crypto fork (digests). *(was 20)* | | 15 | 30 | Observability, CI, fuzz *(no story file yet)* | **NEW** — runtime counters + a profiler hook, 22's harness wired to run per change instead of by hand, and a fuzz target on the parser and `.wob` loader. The whole proof-maturity gap had no iteration to point at. |
| 16 | 20 | [Cross-program tables](refine/20-cross-program-tables.md) | attach to a running program's database over local IPC; owner stays the single writer (channel half-built). *(was 9c)* | | 16 | 19 | [Float + Bytes](done/19-missing-scalar-types.md) | **LANDED 2026-08-20** — `.wob` v5; the full stack: IEEE-quiet f64 through literals/VM/@table/WAL/json + Bytes as the binary carrier, no implicit mixing, total-order indexes. Unblocks 24 (WS frames) and the crypto fork (digests). *(was 20)* |
| 17 | 21 | [Keypair attach auth](refine/21-keypair-attach-auth.md) | program identity is a keypair; mutual challenge–response at attach (crypto half-built; plan folds into 20's). *(was 9d)* | | 17 | 31 | Actor lifecycle *(no story file yet)* | **NEW** — request/response (today `send` is one-way and callers `sleep` to await), bounded mailboxes with backpressure (today the FIFO just grows), actor death/supervision, and timers beyond `time.sleep`. 24 cannot be written honestly without these. |
| 18 | 22 | [Durability, throughput, scale](refine/22-durability-throughput-scale.md) | restart-persistence proof, benchmarks, ~1M rows — the baseline the arc and 23 sign against. *(was 9e)* | | 18 | 24 | [chat: WebSocket workload](refine/24-chat-websocket-workload.md) | the arc's acceptance: WS upgrade + frames (SHA-1 via crypto fork, Bytes via 19), rooms/broadcast, 1k clients, drain-clean. *(was 19)* |
| 19 | 23 | [io_uring group-commit](refine/23-io-uring-commit.md) | WAL WRITE+FSYNC chains on the arc's per-shard rings; fsync fallback kept (after 22 + the arc). *(was 9f)* | | 19 | 23 | [io_uring group-commit](refine/23-io-uring-commit.md) | WAL WRITE+FSYNC chains on the arc's per-shard rings; fsync fallback kept (after 22 + the arc). *(was 9f)* |
| 20 | 24 | [chat: WebSocket workload](refine/24-chat-websocket-workload.md) | the arc's acceptance: WS upgrade + frames (SHA-1 via crypto fork, Bytes via 19), rooms/broadcast, 1k clients, drain-clean. *(was 19)* | | 20 | 25 | [HTTP service layer](25-http-service.md) | `service` blocks lower onto the framework (after 9b + 20 by their own precedence notes). *(was 10)* |
| 21 | 25 | [HTTP service layer](25-http-service.md) | `service` blocks lower onto the framework (after 9b + 20 by their own precedence notes). *(was 10)* | | 21 | 18 | [framework v2: memory-rich features](hold/18-memory-db-features.md) | spec+plan approved: TTL cache, @table flags, durable job queue, `transaction { }` over the WAL's staged batch. **Demoted from seq 14**: more surface on a framework with one consumer, and the cache still stores `Text` because there are no generics |
| 22 | 26 | [Blue-green deploy](26-blue-green-deploy.md) | two VM slots, in-runtime compile, atomic switch, resident rollback (plan authored after 9 + 25). *(was 12)* | | 22 | 27 | [Query grammar corpus](refine/27-query-grammar-corpus.md) | grow the query grammar from real corpora; likely collapses to "confirm `len(query)` + add `exists`"; precedes 28. *(was 9g)* |
| 23 | 27 | [Query grammar corpus](refine/27-query-grammar-corpus.md) | grow the query grammar from real corpora; likely collapses to "confirm `len(query)` + add `exists`"; precedes 28. *(was 9g)* | | 23 | 26 | [Blue-green deploy](26-blue-green-deploy.md) | two VM slots, in-runtime compile, atomic switch, resident rollback (plan authored after 9 + 25). *(was 12)* |
| 24 | 28 | [skillhost host workload](refine/28-skillhost-host-workload.md) | host-shaped driving workload naming runtime gaps — demoted with the framework goal. *(was 14)* | | 24 | 20 | [Cross-program tables](refine/20-cross-program-tables.md) | attach to a running program's database over local IPC; owner stays the single writer (channel half-built). **Demoted from seq 16**: new distribution surface while there is no TLS, no crypto, and the multi-shard DB still traps. *(was 9c)* |
| 25 | 29 | [Compile-time metaprogramming](refine/29-compile-time-metaprogramming.md) | `@derive(...)` from class-table metadata; held with the parked drain by the 2026-08-08 scope directive. *(was 13)* | | 25 | 21 | [Keypair attach auth](refine/21-keypair-attach-auth.md) | program identity is a keypair; mutual challenge–response at attach (crypto half-built; plan folds into 20's). **Demoted with 20** — and it needs crypto primitives that do not exist. *(was 9d)* |
| ⏸ | 17 | [library projects + `internal/`](17-library-projects-internal.md) | **PARKED** (spec + plan approved, branch `library-internal`) — `wo.toml` kind = "library" + Go's `internal/` rule; slots anywhere after 16 on directive | | 26 | 28 | [skillhost host workload](refine/28-skillhost-host-workload.md) | host-shaped driving workload naming runtime gaps — demoted with the framework goal. *(was 14)* |
| 27 | 29 | [Compile-time metaprogramming](refine/29-compile-time-metaprogramming.md) | `@derive(...)` from class-table metadata; held with the parked drain by the 2026-08-08 scope directive. *(was 13)* |
| ✅ | 17 | [library projects + `internal/`](done/17-library-projects-internal.md) | **LANDED 2026-08-20** — `kind = "library"` + entry-less check mode (retires the `--emit` workaround) and Go's `internal/` rule as WO-E108 at the consumer's `use`; driver-only, VM/GC untouched. `just web-app` 26/0 |
Review protocol: the developer reads one iteration, approves or amends; Review protocol: the developer reads one iteration, approves or amends;

View file

@ -1,58 +0,0 @@
# Iteration 8 — shard-actor runtime
> Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md).
## Goals
- The runtime scales past one core the doctrine way: pinned thread-per-core
shards, each owning its own heap and event loop; cross-shard
communication is a message send that **moves ownership** — shared mutable
state never exists.
- The language grows `spawn` and message send; garbage collection stays
per-shard, so no global pause appears at any core count.
## Acceptance Criteria
- What to achieve?
- **Given** a program spawning actors across shards,
- **when** an owned object is sent to another shard,
- **then** the sender can no longer touch it (compile-time move), the
receiver owns it, and its eventual free routes back to its
allocation-home arena.
- What to achieve?
- **Given** debug builds with shard-ownership asserts,
- **when** the deterministic actor corpus runs under ASan and TSan,
- **then** zero races, zero leaks, and identical output across runs.
- What to achieve?
- **Given** a `@gc` reference,
- **when** code attempts to send it cross-shard,
- **then** the compiler rejects it — aliased references cannot cross
heap boundaries.
## Out Of Scope
- Fibers/green threads (recorded in the blue-green vision §3; extends this
scheduler later).
- Cross-shard transactions (the database iteration's 2PC concern, later).
## Info
- The C reference (`runtime/wo-rt.c`, phases A–F) is the substrate: epoll
loops, eventfd mail, the machinery this iteration lifts into `wovm`.
- The VM's object header has carried a shard id since iteration 2 — no
relayout.
- **Gated by the benchmark (2026-08-15):** this is the "optimize
multithreading" lever of the performance arc — thread-per-core is a
throughput/scale claim, so landing it means re-running iteration
[22](refine/22-durability-throughput-scale.md) at the connection/concurrency
scale it unlocks and recording the before/after delta. It is also where
the io_uring write path ([23](refine/23-io-uring-commit.md)) gets a thread to
overlap durability against.
## Proposed Solution
- Execute the existing plan: `docs/superpowers/plans/2026-08-01-shard-actor-vm-runtime.md`
(pinned-worker scheduler, shard-stamped heaps, MPSC mailbox rings + mail
eventfds, send-as-move with home-routed frees, gc pacing per tick,
spawn/send surface, actor corpus).

View file

@ -1,19 +1,23 @@
# Iteration 5 — language surface (Haxe-parity adoptions) # Iteration 5 — language surface (Haxe-parity adoptions)
> Format: `product/story-iteration-template`. Part of > Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md). > [Story — one language, one runtime, one database, one binary](../00-story.md).
> **Status (2026-08-14):** the *grammar* half landed — modules, `and`/`or`, > **Status: ✅ COMPLETE 2026-08-20.** The *grammar* half landed 2026-08-14 —
> interpolation, `const`, loop control, switch expressions, typedef records, > modules, `and`/`or`, interpolation, `const`, loop control, switch
> enum payloads, try/catch, `nil`/`?T`, statics, `pub(read)` syntax, container > expressions, typedef records, enum payloads, try/catch, `nil`/`?T`,
> literals, `for k, v in m`, and `as` — which is what let the driving workload > statics, `pub(read)` syntax, container literals, `for k, v in m`, `as`.
> compile. The *strictness* half (`?T` forced handling `WO-E211`–`E213`, > The *strictness* half landed in three waves: `?T` forced handling
> `pub(read)` write enforcement, `using`, `#if`, reject-row diagnostics) is > (WO-E211/212/213 + narrowing) and reject rows (WO-E105) on 2026-08-18;
> **deliberately deferred** behind > the final three on 2026-08-20 (branch `language-surface-strictness`,
> [`plan/compiler/2026-08-14-logwatcher-executable.md`](../../plan/compiler/2026-08-14-logwatcher-executable.md): > developer directive overriding the post-12 park) — **`pub(read)` write
> it makes the language refuse more, not the program run. Plan 8 stays open > enforcement** (WO-E219, class-owned writes, corpus-pinned),
> for it. > **`using` static extensions** (compile-time rewrite to a free-fn call,
> WO-E220 on method collision, zero owner/emit awareness), and **`#if`
> build flags** (`woc -D name`, token-level filter, WO-E003 misuse).
> `is`/`throw` stay cut (0 workload uses); `abstract` is a reject row.
> Plan 8 is closed.
## Goals ## Goals

View file

@ -1,7 +1,7 @@
# Iteration 17 — library projects and dependency privacy (`kind`, `internal/`) # Iteration 17 — library projects and dependency privacy (`kind`, `internal/`)
> Format: `product/story-iteration-template`. Part of > Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md). > [Story — one language, one runtime, one database, one binary](../00-story.md).
> >
> **Inserted 2026-08-20, needs further refinement** (developer decision: keep > **Inserted 2026-08-20, needs further refinement** (developer decision: keep
> as an iteration, do not implement yet). > as an iteration, do not implement yet).
@ -10,10 +10,36 @@
> changed). See "Settled decisions" and "Impact analysis" below. > changed). See "Settled decisions" and "Impact analysis" below.
> >
> **⏸ PARKED 2026-08-20** (developer directive: framework v1 work first). > **⏸ PARKED 2026-08-20** (developer directive: framework v1 work first).
> The spec and plan were written and approved before parking; both sit ready > The spec and plan were written and approved before parking
> on branch `library-internal`
> (`docs/superpowers/specs/2026-08-20-library-kind-internal-design.md`, > (`docs/superpowers/specs/2026-08-20-library-kind-internal-design.md`,
> `docs/superpowers/plans/2026-08-20-library-kind-internal.md`). > `docs/superpowers/plans/2026-08-20-library-kind-internal.md`).
>
> **LANDED 2026-08-20** — unparked and executed against that plan, all six
> tasks. Every change is in the driver (`compiler/bin/main.ml`); the VM,
> `.wob`, and GC are untouched exactly as the impact analysis predicted.
> Reasoning-under-the-code in `compiler/src/CODE-LOGIC.md`.
>
> Gates: `just web-app` **26/0** (three new checks — library check mode,
> WO-E108 at the boundary, WO-E109 on a bad kind), and `just woc-test`,
> `just oop-e2e` (103/0), `just deps-accept`, `just log-watcher`,
> `just employee` all unchanged.
>
> Two deviations from the plan, both because the framework grew after the plan
> was written:
>
> 1. **`http/parse.wo` was SPLIT, not moved whole.** The plan said move it
> under `internal/`, but the framework-v1 slices had since added
> `media_type` and `form_values` to that file and the web-app calls both —
> moving the file whole would have put public surface behind the privacy
> line and broken the consumer. The parsing plumbing (`Parsed`,
> `parse_request`, `url_decode`, `parse_query`) is now `internal/parse.wo`;
> the two public functions are `http/form.wo`, which does `use internal`
> (legal inside the library).
> 2. **The gate is 26/0, not the plan's 17/0.** `just web-app` had grown from
> 14 to 23 checks (framework v1 plus iteration 19's Float price) before this
> iteration started; the three new checks make 26. The plan's numbers were
> written against a 14-check gate. Acceptance criterion 3 below still holds
> in substance: no pre-existing check changed.
## Why this iteration exists ## Why this iteration exists

View file

@ -1,11 +1,31 @@
# Iteration 19 — the missing scalar types: Float and Bytes # Iteration 19 — the missing scalar types: Float and Bytes
> Format: `product/story-iteration-template`. Part of > Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md). > [Story — one language, one runtime, one database, one binary](../00-story.md).
> >
> **Inserted 2026-08-20, forks settled the same day** (developer > **Inserted 2026-08-20, forks settled the same day** (developer
> decisions below). Next: spec, then plan — a new storage kind touches > decisions below). Next: spec, then plan — a new storage kind touches
> the `.wob`/WAL formats, so this one earns its written spec. > the `.wob`/WAL formats, so this one earns its written spec.
>
> **LANDED 2026-08-20.** Both scalars shipped full-stack as `.wob` v5. The
> format decisions the story asked a spec for are recorded normatively in
> [`docs/plan/oop-vm/00-wob-format.md`](../../../plan/oop-vm/00-wob-format.md)
> §"v5: Float and Bytes" — written in the same change as the code, per this
> iteration's own last acceptance criterion — with the reasoning-under-the-code
> in `runtime/src/CODE-LOGIC.md` and `compiler/src/CODE-LOGIC.md`. No separate
> spec document was authored; the deviation is deliberate and noted here.
>
> Gates: corpus **103/0** (four new fixtures — `float-arithmetic`,
> `bytes-carrier`, `float-json-storage`, `float-table-column`),
> `test_wal` **156/0** (bit-exact Float/Bytes replay), `just web-app`
> **23/0** with the storefront price a real Float, `just oop-accept` ALL
> CRITERIA MET, and every other sample gate unchanged.
>
> One judgment call worth flagging: the shortest-round-trip renderer prefers
> FIXED notation over exponential across `1e-6 … 1e21`. Pure "shortest" is
> what `%g` does, and it renders a price of `900.0` as `9e+02` — correct and
> useless. Both forms carry the same significant digits, so round-tripping is
> unaffected.
## Why this iteration exists ## Why this iteration exists

View file

@ -2,12 +2,12 @@
> **Scope label (2026-08-20): this iteration is FRAMEWORK V2.** Framework > **Scope label (2026-08-20): this iteration is FRAMEWORK V2.** Framework
> v1 is the transport/routing/body/security surface tracked in the > v1 is the transport/routing/body/security surface tracked in the
> [framework README's status ledger](../../examples/writeonce-framework/README.md); > [framework README's status ledger](../../../examples/writeonce-framework/README.md);
> v2 is what the embedded store adds on top. v1 gaps land before or > v2 is what the embedded store adds on top. v1 gaps land before or
> alongside v2 as slices, per the ledger. > alongside v2 as slices, per the ledger.
> Format: `product/story-iteration-template`. Part of > Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md). > [Story — one language, one runtime, one database, one binary](../00-story.md).
> >
> **Inserted 2026-08-20, forks settled the same day** (developer decisions > **Inserted 2026-08-20, forks settled the same day** (developer decisions
> below). Next step: spec + plan, implementation on approval — the > below). Next step: spec + plan, implementation on approval — the

View file

@ -0,0 +1,110 @@
# Iteration 8 — shard-actor runtime (the 8+11 concurrency arc, part 1)
> Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](../00-story.md).
>
> **REFINED 2026-08-20** (developer decisions, no code): iterations 8 and
> 11 are **one arc** — the scheduler, fibers on it, then serving — because
> the database-ownership decision below makes a fiberless multi-shard
> server block a whole thread per cross-shard call. The arc's driving
> workload is **iteration 24 (chat: WebSocket pub/sub)**. The pre-existing
> plan (`docs/superpowers/plans/2026-08-01-shard-actor-vm-runtime.md`)
> predates inferred GC (7b), the unified surface, and the DB decision —
> it is a source of ideas, NOT the plan of record; the arc starts with a
> fresh brainstorm → spec → plan.
## Settled decisions (2026-08-20)
1. **The database is an actor.** The engine (`wo_db` + WAL) lives on one
owner shard; every query/write from another shard is a message send,
and results come back materialized (queries already copy rows out —
the model was built for this). Doctrine-pure: no lock, no shared
mutable state. Consequence, accepted deliberately: callers must PARK
while the reply travels, which is why 8 and 11 ship as one arc.
(Rejected: a coarse engine lock — bends the doctrine and caps write
scaling anyway; partitioned tables — the real scale answer, but it
waits for a measured need, not v1.)
2. **One concurrency surface: everything is an actor address.** `spawn`
returns an address whether the spawnee lands on this shard (a fiber)
or another (placement policy's call); `send` always moves ownership;
a same-heap send skips the ring and is cheap. The language never
shows a fiber-vs-actor split. (This dissolves iteration 11's
"handle vs address" open question.)
3. **Driving workload: chat** (iteration 24) — rooms, broadcast, N
concurrent WebSocket clients, one binary. The arc's acceptance is the
chat sample's, not only synthetic corpora.
4. **Order: 22 → the 8+11 arc → 23.** The io_uring write path waits for
the arc (its batch boundary is the shard tick) and for 22's baseline.
## Goals
- The runtime scales past one core the doctrine way: pinned
thread-per-core shards, each owning its own heap and event loop;
cross-shard communication is a message send that **moves ownership** —
shared mutable state never exists.
- The language grows `spawn`/`send` (the unified address surface);
garbage collection stays per-shard (7b's collector is already
per-shard by construction), so no global pause appears at any core
count.
- The database keeps its single-writer truth by BEING an actor on its
owner shard.
## Acceptance Criteria
- What to achieve?
- **Given** a program spawning actors across shards,
- **when** an owned object is sent to another shard,
- **then** the sender can no longer touch it (compile-time move), the
receiver owns it, and its eventual free routes back to its
allocation-home arena.
- What to achieve?
- **Given** debug builds with shard-ownership asserts,
- **when** the deterministic actor corpus runs under ASan and TSan,
- **then** zero races, zero leaks, and identical output across runs.
- What to achieve?
- **Given** a reference to a TRACED object (GC-ness is inferred since
7b — there is no `@gc` to write),
- **when** code attempts to send it cross-shard,
- **then** the compiler rejects it: aliased references cannot cross
heap boundaries, and the diagnostic names the inferred-traced class
and why it is traced. (This criterion originally said `@gc`;
restated 2026-08-20 in inference terms — same rule, current
language.)
- What to achieve?
- **Given** handlers on serving shards querying and writing through
the DB-owner shard,
- **when** the employee/web-app matrices run multi-shard,
- **then** every answer is byte-identical to the single-shard run and
the WAL's ack-after-durable contract is unchanged.
## Out Of Scope
- Cross-shard transactions (2PC) — the DB actor serializes writers, so
iteration 18's `transaction { }` is unaffected; distributing it is a
later story.
- Fiber details beyond the shared scheduler substrate — part 2
([iteration 11](11-fibers.md)) owns them.
- WebSocket framing — the framework's (iteration 24's) job.
## Info
- The C proving ground (`docs/plan/exploration/c-runtime/`, phases A–F:
epoll loops, eventfd mail) is the substrate this lifts into `wovm`.
(Path restated 2026-08-20; the old `runtime/wo-rt.c` reference was
stale — that tree was removed with the Rust runtime.)
- The VM's object header has carried a shard id since iteration 2 — no
relayout.
- **Gated by the benchmark:** landing the arc means re-running
[22](22-durability-throughput-scale.md) at the concurrency
scale it unlocks and recording the before/after delta; it is also
where [23](23-io-uring-commit.md) gets a thread to overlap
durability against.
## Proposed Solution
Fresh brainstorm → spec → plan for the WHOLE arc (8+11), staged: the
pinned-worker scheduler + shard-stamped heaps + MPSC mailboxes + the
unified spawn/send surface and the traced-send rejection; fibers on that
scheduler (part 2's document); the DB-actor migration; then iteration 24
proves it. The 2026-08-01 plan is reference material for the mailbox and
heap-stamping shapes only.

View file

@ -1,7 +1,28 @@
# Iteration 11 — fibers (green threads on the shard scheduler) # Iteration 11 — fibers (the 8+11 concurrency arc, part 2)
> Format: `product/story-iteration-template`. Part of > Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](../00-story.md). > [Story — one language, one runtime, one database, one binary](../00-story.md).
>
> **REFINED 2026-08-20** (developer decisions, no code): 8 and 11 ship as
> **one arc** — the DB becomes an actor on an owner shard
> ([iteration 8](08-shard-actor-runtime.md)'s decision), so serving
> shards must PARK on cross-shard replies, which is this iteration.
> The spawn-surface question below is SETTLED: one unified actor-address
> surface (`spawn` returns an address, fiber or remote alike; `send`
> moves ownership; same-heap sends take the cheap path). The arc's
> driving workload is [iteration 24: chat](24-chat-websocket-workload.md)
> — fiber-per-WebSocket-connection is the serving model that retires the
> framework's close-when-idle keep-alive policy.
>
> **STAGE-1 SUBSTANCE LANDED 2026-08-20** (branch `concurrency-arc`):
> reduction-budget fibers (back-edge accounting — the livelock lesson is
> in the plan's deviations), `spawn`/`send`/`actor M`, one-message-at-a-
> time delivery, main-return reap, fiber-trap isolation, and parked
> `net`/`time` builtins on the io_uring-first per-shard I/O plane
> (`WO_IO=uring|epoll`, epoll fallback proven). Demonstrated by
> `docs/examples/fibers` (`just fibers` 8/0). The shard-context criteria
> (TID assertions, cross-shard sends, blue-green drain reuse) close with
> the arc's stage 2.
## Goals ## Goals
@ -42,10 +63,13 @@
as cleanly as trapped ones. (Iteration 26's blue-green drain reuses as cleanly as trapped ones. (Iteration 26's blue-green drain reuses
exactly this unwind path.) exactly this unwind path.)
- What to achieve? - What to achieve?
- **Given** `@gc` objects referenced only from a parked fiber's frames, - **Given** TRACED objects (GC-ness inferred since 7b) referenced only
- **when** the per-shard cycle collector scans, from a parked fiber's frames,
- **then** fiber stacks are roots — nothing live is collected, nothing - **when** the per-shard mark-sweep collector scans,
dead survives. - **then** parked fibers' frames are roots exactly as the live frame
stack is (`vm_gc_roots` grows fiber awareness) — nothing live is
collected, nothing dead survives. (Originally said `@gc`; restated
2026-08-20 in inference terms.)
## Out Of Scope ## Out Of Scope
@ -66,9 +90,11 @@
matches the stdlib posture). matches the stdlib posture).
- Vision origin: [blue-green vision §3](../../../plan/exploration/blue-green-vm/00-vision.md); - Vision origin: [blue-green vision §3](../../../plan/exploration/blue-green-vm/00-vision.md);
iteration 8's scheduler is the substrate this extends. iteration 8's scheduler is the substrate this extends.
- Open questions to settle in the spec: spawn surface (handle vs actor - Open questions to settle in the spec — REDUCED 2026-08-20: the spawn
address), budget size and check granularity, parked-fiber drop surface is settled (the unified actor address, iteration 8 decision 2).
semantics, run-queue fairness (FIFO v1). Still open for the arc's spec: budget size and check granularity,
parked-fiber drop semantics, run-queue fairness (FIFO v1), and how a
parked fiber's borrow state interacts with the shard's GC safepoints.
## Proposed Solution ## Proposed Solution

View file

@ -10,7 +10,27 @@
> would optimize a number nobody had measured, against a runtime that > would optimize a number nobody had measured, against a runtime that
> couldn't use it. > couldn't use it.
> >
> **No spec exists yet.** The forks in *Info* are genuine decisions. > **No spec exists yet.** ~~The forks in *Info* are genuine decisions.~~
>
> **REFINED 2026-08-20: the four forks are SETTLED as their recorded
> leanings** (developer confirmation, no code): (1) drop-in behind
> `wo_wal_commit` first, an async variant only if the arc's scheduler
> proves the blocking boundary is the bottleneck; (2) raw
> `io_uring_setup`/`io_uring_enter` syscalls — libc-only doctrine holds,
> ring layout documented normatively; (3) the batch boundary is the shard
> tick (the 8+11 arc's quantum), single-writer fallback batches whatever
> accumulated; (4) startup auto-probe + an env override so CI proves both
> paths on one kernel — AMENDED: the override is the arc-wide
> `WO_IO=uring|epoll` (the arc's T4 owns the probe and the per-shard
> ring; `WO_WAL_MODE` is subsumed). Position: AFTER the 8+11 arc (order
> settled 2026-08-20: 9e → 8+11 → 9f). AMENDED 2026-08-20 (io_uring-first
> directive): the WAL's WRITE+FSYNC chains ride the SAME per-shard ring
> T4 creates for fiber parking — one event loop per shard, readiness ops
> and durability ops together, exactly the linux reference project's
> "single event loop" card. One composition
> note added since iteration 18: a `transaction { }` already IS a staged
> batch — under io_uring it becomes exactly one submission, so the two
> features compose without either knowing the other.
## Goals ## Goals

View file

@ -1,8 +1,12 @@
# Iteration 17 — library kind + `internal/`: implementation plan # Iteration 17 — library kind + `internal/`: implementation plan
> **Status: ⏸ PARKED 2026-08-20** (developer directive: framework v1 work > **Status: ✅ LANDED 2026-08-20** — executed in full, all six tasks. Was
> proceeds instead; this plan stays ready on branch `library-internal`, > parked the same day (developer directive: framework v1 work first), then
> execution not started). Board: [docs/00-status.md](../../00-status.md). > unparked and run. Two disclosed deviations, both because the framework grew
> after this plan was written: `http/parse.wo` was SPLIT rather than moved
> whole (its `media_type`/`form_values` are public surface the web-app calls),
> and the gate reads 26/0 rather than 17/0 (`just web-app` was already at 23
> before this iteration). Board: [docs/00-status.md](../../00-status.md).
> **For agentic workers:** REQUIRED SUB-SKILL: Use > **For agentic workers:** REQUIRED SUB-SKILL: Use
> superpowers:subagent-driven-development (recommended) or > superpowers:subagent-driven-development (recommended) or
@ -28,7 +32,7 @@ build path grows a check branch, and the dep-use resolution walk in
**Spec:** [`../specs/2026-08-20-library-kind-internal-design.md`](../specs/2026-08-20-library-kind-internal-design.md) **Spec:** [`../specs/2026-08-20-library-kind-internal-design.md`](../specs/2026-08-20-library-kind-internal-design.md)
(normative). Story: (normative). Story:
[`17-library-projects-internal.md`](../../stories/language-runtime-database/17-library-projects-internal.md). [`17-library-projects-internal.md`](../../stories/language-runtime-database/done/17-library-projects-internal.md).
## Global Constraints ## Global Constraints

View file

@ -1,9 +1,10 @@
# Iteration 17 — library projects and dependency privacy: design # Iteration 17 — library projects and dependency privacy: design
> **Status: ⏸ PARKED 2026-08-20** (developer directive: framework v1 work > **Status: ✅ LANDED 2026-08-20** — implemented as specified; the impact
> proceeds instead; spec + plan stay ready on branch `library-internal`). > analysis held (driver-only, VM/`.wob`/GC untouched). Approved, then parked
> Approved before parking. Decisions were settled in > the same day by directive, then unparked and executed. Decisions were
> [the iteration](../../stories/language-runtime-database/17-library-projects-internal.md) > settled in
> [the iteration](../../stories/language-runtime-database/done/17-library-projects-internal.md)
> (four forks + impact analysis); this spec makes them buildable. The plan > (four forks + impact analysis); this spec makes them buildable. The plan
> follows after review. Board: [docs/00-status.md](../../00-status.md). > follows after review. Board: [docs/00-status.md](../../00-status.md).
> >

View file

@ -6,7 +6,7 @@
> >
> **Status: APPROVED 2026-08-20** (developer review). Plan next. > **Status: APPROVED 2026-08-20** (developer review). Plan next.
> Decisions were settled in > Decisions were settled in
> [the iteration](../../stories/language-runtime-database/18-memory-db-features.md); > [the iteration](../../stories/language-runtime-database/hold/18-memory-db-features.md);
> this spec makes them buildable. The plan follows after review. > this spec makes them buildable. The plan follows after review.
> Board: [docs/00-status.md](../../00-status.md). > Board: [docs/00-status.md](../../00-status.md).
> >

View file

@ -1,5 +1,10 @@
# writeonce — task runner. `just --list` shows all recipes. # writeonce — task runner. `just --list` shows all recipes.
# docs gate: every relative markdown link resolves, every #anchor exists.
# Report lands in docs/00-link-audit.md; this recipe is the re-check.
linkcheck:
python3 scripts/linkcheck.py .
# woc compiler front (compiler/): build the executable # woc compiler front (compiler/): build the executable
woc-build: woc-build:
dune build --root compiler dune build --root compiler

View file

@ -138,3 +138,46 @@ returns, and actor state / queued messages / the in-flight message are GC
roots scanned beside the fiber frames. spawn = BUILTIN 68 (instance + roots scanned beside the fiber frames. spawn = BUILTIN 68 (instance +
receive's method index, compile-time constant); send = BUILTIN 69 (the receive's method index, compile-time constant); send = BUILTIN 69 (the
message is excluded from the emitter's fresh-arg drops — ownership moved). message is excluded from the emitter's fresh-arg drops — ownership moved).
## Float and Bytes (iteration 19 — `.wob` v5)
The registers did not change shape: a Float IS the register's 64 bits read as
an f64, converted only by `wo_f64`/`wo_bits` in `wob.h` (memcpy, so
strict-aliasing-clean and free at -O1). Nothing else in the runtime knows the
difference, which is why the change is opcodes and kind bytes rather than a
layout.
- **Two failure worlds.** `WOP_DIV` traps DIV0; `WOP_FDIV` never traps. That
asymmetry is the contract, not an oversight — IEEE quiet semantics mean Inf
and NaN flow instead of raising, so a compute-bound handler cannot be killed
by data. `WOP_FNEG` flips the sign bit rather than subtracting from zero,
which is the only way `-0.0` is reachable.
- **IEEE compares are not the index's order.** `FEQ`/`FLT`/`FLE` are IEEE
(`NaN == NaN` is 0, `0.0 == -0.0` is 1). Indexes and `order by` need a total
order instead, so `wo_float_cmp` (`wob.h`) sorts NaN last and treats the two
zeros as equal, and `table.c`'s `idx_float_key` canonicalizes an index
column's bits to match. Skip that canonicalization and a `unique` Float
column accepts both `-0.0` and `0.0`, and a probe for one misses a row stored
as the other — the bug this pairing exists to prevent.
- **A `?Float`'s nil is a reserved quiet NaN** (`WO_NIL_FLOAT`), not the zero
word (`+0.0`) and not `WO_NIL_SCALAR` (whose bits are `-2.0`). Arithmetic
produces the platform's canonical quiet NaN, so a computed NaN never reads as
absence. Both json paths that write a nil word — the omitted-key prefill in
`jparse_object` and the explicit `null` in `jparse_value` — must know this;
either one alone leaves a `null` price reading back as zero.
- **Bytes is `wo_str` with a different `class_id`.** Same struct, same
allocator, same free (`gc.c` handles both ids), so lifetime handling can
never diverge. `WO_B_TEXT_COPY` preserves the id, which is what lets every
existing copy-on-ownership-boundary serve both carriers; copying a Bytes as a
Text would launder it into the wrong world, and the distinct id exists
precisely to stop that.
- **One float renderer, three callers.** `wo_float_text` backs
`float_to_text`, string interpolation, and `json.encode`. Shortest digits
that reparse to the same BITS (bits, not `==`: `-0.0 == 0.0` is true, so a
value comparison would let `0` stand in for `-0.0`), then fixed notation
preferred over exponential in `1e-6 … 1e21` — pure "shortest" renders a
price of 900.0 as `9e+02`.
- **The durability path never renders.** `wal.c` writes a Float as its raw
word and a Bytes as the same length-prefixed blob a Text uses, so replay is
bit-exact for NaN, ±Inf, and `-0.0`. `test_wal`'s `test_float_bytes_replay`
asserts on bits for exactly that reason.

View file

@ -5,6 +5,7 @@
#include "db.h" /* database/src — the engine's statement executors */ #include "db.h" /* database/src — the engine's statement executors */
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> /* strtod: the round-trip check in wo_float_text */
#include <string.h> #include <string.h>
#include <time.h> #include <time.h>
@ -25,6 +26,106 @@ static void *native_check(uint64_t v, uint32_t cls, const char **msg) {
return o; return o;
} }
/* ---- iteration 19: Float rendering (contract in builtin.h) ----
* Shortest round-trip, found by asking printf for 1..17 significant digits
* and stopping at the first rendering that strtod turns back into the SAME
* BITS. Bits, not `==`: -0.0 == 0.0 is true, so a value comparison would let
* "0" stand in for -0.0 and the iteration's own edge-case gate would fail.
*
* 17 digits always terminates the loop (%.17g round-trips every double), so
* the fallback after the loop is unreachable defensive code, not a policy. */
size_t wo_float_text(double d, char *out, size_t cap) {
if (d != d) return (size_t)snprintf(out, cap, "nan");
if (d > 1.7976931348623157e308) return (size_t)snprintf(out, cap, "inf");
if (d < -1.7976931348623157e308) return (size_t)snprintf(out, cap, "-inf");
/* Step 1: the fewest significant digits that reparse to the same bits. */
char tmp[WO_FLOAT_TEXT_CAP];
int sig = 17;
for (int prec = 1; prec <= 17; prec++) {
int n = snprintf(tmp, sizeof tmp, "%.*g", prec, d);
if (n > 0 && (size_t)n < sizeof tmp && wo_bits(strtod(tmp, NULL)) == wo_bits(d)) {
sig = prec;
break;
}
}
/* Step 2: fixed or exponential. "Shortest" alone is the wrong rule here —
* %g renders 900.0 as `9e+02` because that is two bytes shorter, and a
* price of `9e+02` in a JSON body is nobody's idea of a good answer. So
* fixed notation wins across the range humans read (and the range JSON
* bodies live in), and the exponent is kept only where fixed would be
* absurd: a 21-digit integer or twenty leading zeros. Same thresholds
* JavaScript's own number formatting uses, for the same reason.
* Correctness is unaffected: both forms carry the identical `sig`
* significant digits, so both reparse to the same bits. */
int exp10;
{
char e[WO_FLOAT_TEXT_CAP];
snprintf(e, sizeof e, "%.*e", sig - 1, d);
const char *ep = strchr(e, 'e');
exp10 = ep ? (int)strtol(ep + 1, NULL, 10) : 0;
}
int len;
if (exp10 >= -6 && exp10 < 21) {
int decimals = sig - 1 - exp10;
if (decimals < 1) decimals = 1; /* always one decimal: see below */
len = snprintf(tmp, sizeof tmp, "%.*f", decimals, d);
/* A Float must not print as `1` where an Int would: the two numeric
* worlds never mix implicitly, so the rendering says which one this
* is. `900.0` reparses to the same bits as `900`, so the trailing
* `.0` costs the round-trip nothing. */
}
else
len = snprintf(tmp, sizeof tmp, "%.*e", sig - 1, d);
if (len < 0 || (size_t)len >= sizeof tmp) /* defensive: cannot happen */
len = snprintf(tmp, sizeof tmp, "%.17g", d);
return (size_t)snprintf(out, cap, "%s", tmp);
}
/* iteration 19: base64, standard alphabet with '=' padding (RFC 4648 §4) —
* the alphabet the JSON boundary and every HTTP header this project will meet
* uses. No URL-safe variant until a workload needs one. */
static const char B64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static int b64_val(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
int wo_base64_to_bytes(wo_rt *rt, const char *p, uint32_t len, uint64_t *out) {
*out = 0;
if (len % 4u != 0u) return -1;
uint32_t pad = 0;
if (len) {
if (p[len - 1] == '=') pad++;
if (len >= 2 && p[len - 2] == '=') pad++;
}
wo_str *o = wo_bytes_alloc(rt, len / 4u * 3u - pad);
if (!o) return -2;
char *w = o->data;
for (uint32_t i = 0; i < len; i += 4u) {
uint32_t v = 0;
for (uint32_t j = 0; j < 4u; j++) {
char c = p[i + j];
int d = c == '=' ? 0 : b64_val(c);
/* '=' is legal only in the final quad, and only where pad said */
if (d < 0 || (c == '=' && i + 4u < len)) {
wo_str_free(rt, o);
return -1;
}
v = (v << 6) | (uint32_t)d;
}
uint32_t have = i + 4u == len ? 3u - pad : 3u;
if (have > 0) *w++ = (char)(v >> 16);
if (have > 1) *w++ = (char)(v >> 8);
if (have > 2) *w++ = (char)v;
}
*out = (uint64_t)(uintptr_t)o;
return 0;
}
/* ---- systems-stdlib helpers ------------------------------------------ /* ---- systems-stdlib helpers ------------------------------------------
* Text is bytes with an explicit length and no NUL, so every scan below is * Text is bytes with an explicit length and no NUL, so every scan below is
* length-driven; "whitespace" is the same four bytes WO_B_WORDS already * length-driven; "whitespace" is the same four bytes WO_B_WORDS already
@ -274,9 +375,21 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = 0; R[A] = 0;
return 0; return 0;
} }
const wo_str *src = native_check(R[B], WO_CLS_STR, msg); /* iteration 19: a copy PRESERVES the kind. Bytes is a wo_str with a
if (!src) return WO_T_BOUNDS; * different class id, and every ownership boundary that copies a Text
wo_str *cp = wo_str_new(rt, src->data, src->len); * (into a container, into a field, out of a borrow) copies a Bytes for
* the identical reason — so this one builtin serves both rather than
* growing a bytes_copy that the six emitter sites would have to choose
* between. Copying a Bytes as a Text would silently launder it into
* the wrong world, which is exactly what the distinct id prevents. */
const wo_hdr *h = (const wo_hdr *)(uintptr_t)R[B];
if (h->class_id != WO_CLS_STR && h->class_id != WO_CLS_BYTES) {
*msg = "wrong container type";
return WO_T_BOUNDS;
}
const wo_str *src = (const wo_str *)h;
wo_str *cp = h->class_id == WO_CLS_BYTES ? wo_bytes_new(rt, src->data, src->len)
: wo_str_new(rt, src->data, src->len);
if (!cp) { if (!cp) {
*msg = "out of memory"; *msg = "out of memory";
return WO_T_OOM; return WO_T_OOM;
@ -307,6 +420,195 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = (uint64_t)(uintptr_t)s; R[A] = (uint64_t)(uintptr_t)s;
return 0; return 0;
} }
/* ---- iteration 19: the Float bridges ---- */
case WO_B_FLOAT_OF_INT: {
R[A] = wo_bits((double)(int64_t)R[B]);
return 0;
}
case WO_B_TRUNC: {
double d = wo_f64(R[B]);
/* The Int world has no NaN, no Inf, and no 2^63. Yielding 0 or a
* wrapped bit pattern for those is exactly the silent-corruption
* class this iteration exists to remove, so it traps. Bound checked
* with >= 2^63 as a double (9223372036854775808.0 is exact). */
if (d != d || !(d > -9223372036854775808.0 && d < 9223372036854775808.0)) {
*msg = "trunc: float has no integer value";
return WO_T_BOUNDS;
}
R[A] = (uint64_t)(int64_t)d; /* C truncates toward zero, as specified */
return 0;
}
case WO_B_PARSE_FLOAT: {
const wo_str *s = native_check(R[B], WO_CLS_STR, msg);
if (!s) return WO_T_BOUNDS;
/* strtod needs a NUL and a Text has none. A number never needs more
* than a couple of dozen bytes; anything longer is not a number, and
* NaN ("not a number") is the honest answer for unparseable input —
* no `?Float` needed, which is why parse_float has no nullable form
* while parse_int leans on `?Int`'s nil. */
char buf[64];
if (s->len >= sizeof buf) {
R[A] = wo_bits(0.0 / 0.0);
return 0;
}
memcpy(buf, s->data, s->len);
buf[s->len] = '\0';
char *end = NULL;
double d = strtod(buf, &end);
/* Trailing garbage is a parse failure, not a prefix match: "1.5kg"
* must not silently become 1.5. Empty input fails the same way. */
R[A] = wo_bits(end == buf || *end != '\0' ? 0.0 / 0.0 : d);
return 0;
}
case WO_B_FLOAT_TO_TEXT: {
char buf[WO_FLOAT_TEXT_CAP];
size_t len = wo_float_text(wo_f64(R[B]), buf, sizeof buf);
wo_str *s = wo_str_new(rt, buf, (uint32_t)len);
if (!s) {
*msg = "out of memory";
return WO_T_OOM;
}
R[A] = (uint64_t)(uintptr_t)s;
return 0;
}
case WO_B_FLOAT_CMP: {
R[A] = (uint64_t)(int64_t)wo_float_cmp(wo_f64(R[B]), wo_f64(R[B + 1]));
return 0;
}
/* ---- iteration 19: Bytes ---- */
case WO_B_BYTES_LEN: {
const wo_str *b = native_check(R[B], WO_CLS_BYTES, msg);
if (!b) return WO_T_BOUNDS;
R[A] = b->len;
return 0;
}
case WO_B_BYTES_AT: {
const wo_str *b = native_check(R[B], WO_CLS_BYTES, msg);
if (!b) return WO_T_BOUNDS;
uint64_t i = R[B + 1];
if (i >= b->len) {
*msg = "bytes index out of range";
return WO_T_BOUNDS;
}
R[A] = (uint8_t)b->data[i];
return 0;
}
case WO_B_BYTES_SLICE: {
const wo_str *b = native_check(R[B], WO_CLS_BYTES, msg);
if (!b) return WO_T_BOUNDS;
/* Clamped, matching WO_B_SUBSTR: a slice past the end is the empty
* slice, not a trap. Signed reads because a computed start can be
* negative and must clamp to 0 rather than wrap to 2^64. */
int64_t start = (int64_t)R[B + 1], want = (int64_t)R[B + 2];
if (start < 0) start = 0;
if (start > (int64_t)b->len) start = b->len;
if (want < 0) want = 0;
if (want > (int64_t)b->len - start) want = (int64_t)b->len - start;
wo_str *out = wo_bytes_new(rt, b->data + start, (uint32_t)want);
if (!out) {
*msg = "out of memory";
return WO_T_OOM;
}
R[A] = (uint64_t)(uintptr_t)out;
return 0;
}
case WO_B_BYTES_EQ: {
const wo_str *x = native_check(R[B], WO_CLS_BYTES, msg);
const wo_str *y = x ? native_check(R[B + 1], WO_CLS_BYTES, msg) : NULL;
if (!y) return WO_T_BOUNDS;
R[A] = x->len == y->len && !memcmp(x->data, y->data, x->len) ? 1 : 0;
return 0;
}
case WO_B_BYTES_CONCAT: {
const wo_str *x = native_check(R[B], WO_CLS_BYTES, msg);
const wo_str *y = x ? native_check(R[B + 1], WO_CLS_BYTES, msg) : NULL;
if (!y) return WO_T_BOUNDS;
if ((uint64_t)x->len + y->len > 0xFFFFFFFFull) {
*msg = "bytes concat overflows length";
return WO_T_OOM;
}
wo_str *out = wo_bytes_alloc(rt, x->len + y->len);
if (!out) {
*msg = "out of memory";
return WO_T_OOM;
}
memcpy(out->data, x->data, x->len);
memcpy(out->data + x->len, y->data, y->len);
R[A] = (uint64_t)(uintptr_t)out;
return 0;
}
case WO_B_BASE64_ENCODE: {
const wo_str *b = native_check(R[B], WO_CLS_BYTES, msg);
if (!b) return WO_T_BOUNDS;
if ((uint64_t)b->len > 0xBFFFFFFFull) { /* 4/3 growth must not overflow u32 */
*msg = "base64: input too large";
return WO_T_OOM;
}
uint32_t olen = ((b->len + 2u) / 3u) * 4u;
wo_str *out = wo_str_alloc(rt, olen);
if (!out) {
*msg = "out of memory";
return WO_T_OOM;
}
char *o = out->data;
uint32_t i = 0;
for (; i + 3u <= b->len; i += 3u) {
uint32_t v = ((uint8_t)b->data[i] << 16) | ((uint8_t)b->data[i + 1] << 8) |
(uint8_t)b->data[i + 2];
*o++ = B64[(v >> 18) & 63];
*o++ = B64[(v >> 12) & 63];
*o++ = B64[(v >> 6) & 63];
*o++ = B64[v & 63];
}
if (i < b->len) { /* 1 or 2 trailing bytes, '=' padded */
uint32_t rem = b->len - i;
uint32_t v = (uint32_t)(uint8_t)b->data[i] << 16;
if (rem == 2u) v |= (uint32_t)(uint8_t)b->data[i + 1] << 8;
*o++ = B64[(v >> 18) & 63];
*o++ = B64[(v >> 12) & 63];
*o++ = rem == 2u ? B64[(v >> 6) & 63] : '=';
*o++ = '=';
}
R[A] = (uint64_t)(uintptr_t)out;
return 0;
}
case WO_B_BASE64_DECODE: {
const wo_str *s = native_check(R[B], WO_CLS_STR, msg);
if (!s) return WO_T_BOUNDS;
uint64_t bytes = 0;
int rc = wo_base64_to_bytes(rt, s->data, s->len, &bytes);
if (rc == -2) {
*msg = "out of memory";
return WO_T_OOM;
}
/* malformed decodes to nil, not a trap: base64 arrives from the
network, so a bad body is expected input — the same contract
json.decode keeps. rc == -1 leaves bytes at 0. */
R[A] = bytes;
return 0;
}
case WO_B_BYTES_OF_TEXT: {
const wo_str *s = native_check(R[B], WO_CLS_STR, msg);
if (!s) return WO_T_BOUNDS;
wo_str *out = wo_bytes_new(rt, s->data, s->len);
if (!out) {
*msg = "out of memory";
return WO_T_OOM;
}
R[A] = (uint64_t)(uintptr_t)out;
return 0;
}
case WO_B_TEXT_OF_BYTES: {
const wo_str *b = native_check(R[B], WO_CLS_BYTES, msg);
if (!b) return WO_T_BOUNDS;
wo_str *out = wo_str_new(rt, b->data, b->len);
if (!out) {
*msg = "out of memory";
return WO_T_OOM;
}
R[A] = (uint64_t)(uintptr_t)out;
return 0;
}
case WO_B_VARIANT_TAG: { /* haxe-parity compiler Task 4: enum payload variants */ case WO_B_VARIANT_TAG: { /* haxe-parity compiler Task 4: enum payload variants */
/* the tag IS the header's class_id (wob.h's convention). Null and /* the tag IS the header's class_id (wob.h's convention). Null and
* native-class receivers trap BOUNDS — same defense ICALL keeps; * native-class receivers trap BOUNDS — same defense ICALL keeps;

View file

@ -31,4 +31,26 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
/* json.encode / json.decode (runtime/src/json.c), same contract again. */ /* json.encode / json.decode (runtime/src/json.c), same contract again. */
int wo_builtin_json(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg); int wo_builtin_json(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
/* iteration 19: render a Float as text, SHORTEST form that reparses to the
* same bits, into `out` (cap must be >= WO_FLOAT_TEXT_CAP); returns the
* length. One renderer for three callers — WO_B_FLOAT_TO_TEXT, string
* interpolation, and json.encode — because three spellings of the same
* number is how a round-trip test starts passing while the product lies.
*
* The rendering always carries a '.' or an exponent, so a Float never prints
* as `1` where an Int would: the two numeric worlds stay visibly distinct,
* and `1.0` reparses to exactly the same bits as `1`. Non-finite values
* render `nan` / `inf` / `-inf`; json.encode does NOT use those (JSON has no
* such literals) and emits `null` instead, which it decides for itself. */
#define WO_FLOAT_TEXT_CAP 32u
size_t wo_float_text(double d, char *out, size_t cap);
/* iteration 19: decode base64 into a fresh Bytes. Two callers — the
* `base64_decode` builtin and json.decode's Bytes boundary — and they must
* agree byte for byte, so there is one implementation.
* 0 = ok (*out is the Bytes word), -1 = malformed input, -2 = OOM.
* Malformed is a return code rather than a trap because both callers treat
* bad base64 as expected input from the network. */
int wo_base64_to_bytes(wo_rt *rt, const char *p, uint32_t len, uint64_t *out);
#endif /* WO_BUILTIN_H */ #endif /* WO_BUILTIN_H */

View file

@ -81,6 +81,7 @@ void wo_drop_obj(wo_rt *rt, wo_hdr *o) {
if (!o) return; if (!o) return;
switch (o->class_id) { switch (o->class_id) {
case WO_CLS_STR: case WO_CLS_STR:
case WO_CLS_BYTES: /* iteration 19: same object shape, same free */
wo_str_free(rt, (wo_str *)o); wo_str_free(rt, (wo_str *)o);
return; return;
case WO_CLS_MULTI: case WO_CLS_MULTI:
@ -177,6 +178,7 @@ void wo_gc_scan_root(wo_rt *rt, wo_hdr *o) {
} }
switch (o->class_id) { switch (o->class_id) {
case WO_CLS_STR: case WO_CLS_STR:
case WO_CLS_BYTES: /* iteration 19: leaf bytes, nothing to scan */
return; return;
case WO_CLS_MULTI: { case WO_CLS_MULTI: {
wo_multi *m = (wo_multi *)o; wo_multi *m = (wo_multi *)o;

View file

@ -73,6 +73,36 @@ static void jb_int(jbuf *b, int64_t v) {
jb_put(b, tmp, (size_t)n); jb_put(b, tmp, (size_t)n);
} }
/* iteration 19: base64 body for a Bytes field, standard alphabet with '='
* padding — the same encoding WO_B_BASE64_ENCODE produces, so a Bytes column
* that leaves through json.encode comes back through base64_decode bit-exact.
* Written out here rather than shared with builtin.c because that one
* allocates a wo_str and this one appends to a growing buffer; the alphabet is
* the contract, and the corpus fixture compares both against it. */
static void jb_b64(jbuf *b, const uint8_t *p, uint32_t len) {
static const char A[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
uint32_t i = 0;
char q[4];
for (; i + 3u <= len; i += 3u) {
uint32_t v = ((uint32_t)p[i] << 16) | ((uint32_t)p[i + 1] << 8) | p[i + 2];
q[0] = A[(v >> 18) & 63];
q[1] = A[(v >> 12) & 63];
q[2] = A[(v >> 6) & 63];
q[3] = A[v & 63];
jb_put(b, q, 4);
}
if (i < len) {
uint32_t rem = len - i;
uint32_t v = (uint32_t)p[i] << 16;
if (rem == 2u) v |= (uint32_t)p[i + 1] << 8;
q[0] = A[(v >> 18) & 63];
q[1] = A[(v >> 12) & 63];
q[2] = rem == 2u ? A[(v >> 6) & 63] : '=';
q[3] = '=';
jb_put(b, q, 4);
}
}
/* JSON string body: quotes, backslashes and control bytes escaped; every /* JSON string body: quotes, backslashes and control bytes escaped; every
* other byte passes through, so UTF-8 stays UTF-8. */ * other byte passes through, so UTF-8 stays UTF-8. */
static void jb_text(jbuf *b, const wo_str *s) { static void jb_text(jbuf *b, const wo_str *s) {
@ -121,7 +151,10 @@ static void enc_object(jbuf *b, const wo_module *mod, const wo_hdr *o) {
} }
static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, uint32_t fclass) { static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, uint32_t fclass) {
if (!v && kind != WO_K_SCALAR) { /* iteration 19: FLOAT joins SCALAR in being exempt here — a zero word is
+0.0, a perfectly good value, not absence. A `?Float` spells nil as
WO_NIL_FLOAT and is handled in the FLOAT arm below. */
if (!v && kind != WO_K_SCALAR && kind != WO_K_FLOAT) {
jb_put(b, "null", 4); jb_put(b, "null", 4);
return; return;
} }
@ -136,6 +169,38 @@ static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, u
else else
jb_int(b, (int64_t)v); jb_int(b, (int64_t)v);
return; return;
case WO_K_FLOAT: {
/* iteration 19. Three cases, in order: a `?Float` holding its nil
sentinel is JSON null; a non-finite value has NO JSON literal (the
grammar has no nan/inf), so it is null too — the same choice every
mainstream encoder makes, and the alternative is emitting invalid
JSON; anything else is the shortest round-trip rendering, the SAME
renderer interpolation uses so the two can never disagree. */
if (fclass == WOB_FIELD_NIL_FLOAT && v == WO_NIL_FLOAT) {
jb_put(b, "null", 4);
return;
}
double d = wo_f64(v);
if (d != d || d > 1.7976931348623157e308 || d < -1.7976931348623157e308) {
jb_put(b, "null", 4);
return;
}
char buf[WO_FLOAT_TEXT_CAP];
size_t n = wo_float_text(d, buf, sizeof buf);
jb_put(b, buf, n);
return;
}
case WO_K_BYTES: {
/* iteration 19: JSON has no binary type, so the boundary is base64 —
spelled out here as the convention, matching base64_encode's
alphabet exactly so a value that goes out through json comes back
in through base64_decode unchanged. */
const wo_str *s = (const wo_str *)(uintptr_t)v;
jb_ch(b, '"');
jb_b64(b, (const uint8_t *)s->data, s->len);
jb_ch(b, '"');
return;
}
case WO_K_TEXT: { case WO_K_TEXT: {
const wo_str *s = (const wo_str *)(uintptr_t)v; const wo_str *s = (const wo_str *)(uintptr_t)v;
if (fclass == WOB_FIELD_JSON_RAW) jb_put(b, s->data, s->len); /* already JSON */ if (fclass == WOB_FIELD_JSON_RAW) jb_put(b, s->data, s->len); /* already JSON */
@ -329,10 +394,16 @@ static int jparse_object(jp *j, uint32_t class_id, uint64_t *out) {
/* NEW zeroes every slot, which is nil for a heap-shaped field but a real /* NEW zeroes every slot, which is nil for a heap-shaped field but a real
`0` for a scalar one — so a nullable scalar starts at its own nil word `0` for a scalar one — so a nullable scalar starts at its own nil word
(wob.h's WO_NIL_SCALAR) and stays there if the object omits the key. */ (wob.h's WO_NIL_SCALAR) and stays there if the object omits the key. */
for (uint32_t i = 0; i < c->field_cnt; i++) for (uint32_t i = 0; i < c->field_cnt; i++) {
if (c->field_class && (c->field_class[i] == WOB_FIELD_NIL_SCALAR || if (!c->field_class) break;
c->field_class[i] == WOB_FIELD_NIL_BOOL)) if (c->field_class[i] == WOB_FIELD_NIL_SCALAR || c->field_class[i] == WOB_FIELD_NIL_BOOL)
fs[i] = WO_NIL_SCALAR; fs[i] = WO_NIL_SCALAR;
/* iteration 19: a `?Float`'s nil is its own reserved NaN — the zero
word is +0.0, so an omitted key would read back as a real price of
zero rather than as absence. */
else if (c->field_class[i] == WOB_FIELD_NIL_FLOAT)
fs[i] = WO_NIL_FLOAT;
}
jskip_ws(j); jskip_ws(j);
if (j->p >= j->end || *j->p != '{') { if (j->p >= j->end || *j->p != '{') {
wo_drop_obj(j->rt, o); wo_drop_obj(j->rt, o);
@ -414,10 +485,10 @@ static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, ui
} }
char c = *j->p; char c = *j->p;
if (c == 'n') { /* null: this field's own nil word */ if (c == 'n') { /* null: this field's own nil word */
uint64_t nilw = uint64_t nilw = fclass == WOB_FIELD_NIL_FLOAT ? WO_NIL_FLOAT /* iteration 19 */
(fclass == WOB_FIELD_NIL_SCALAR || fclass == WOB_FIELD_NIL_BOOL) : (fclass == WOB_FIELD_NIL_SCALAR || fclass == WOB_FIELD_NIL_BOOL)
? WO_NIL_SCALAR ? WO_NIL_SCALAR
: 0; : 0;
return jskip_value(j) == 0 ? (*out = nilw, 0) : -1; return jskip_value(j) == 0 ? (*out = nilw, 0) : -1;
} }
if (c == '{') { if (c == '{') {
@ -520,6 +591,17 @@ static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, ui
*out = (uint64_t)(uintptr_t)s; *out = (uint64_t)(uintptr_t)s;
return 0; return 0;
} }
if (kind == WO_K_BYTES) {
/* iteration 19: the Bytes boundary is a base64 STRING (the same
convention encode writes). Malformed base64 decodes to nil, not
a whole-decode failure, matching base64_decode's own contract —
a bad body from the network is expected input. */
uint64_t bytes = 0;
if (wo_base64_to_bytes(j->rt, s->data, s->len, &bytes) != 0) bytes = 0;
wo_str_free(j->rt, s);
*out = bytes;
return 0;
}
wo_str_free(j->rt, s); /* a string where a number was declared: nil */ wo_str_free(j->rt, s); /* a string where a number was declared: nil */
*out = 0; *out = 0;
return 0; return 0;
@ -530,7 +612,47 @@ static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, ui
*out = kind == WO_K_SCALAR ? (uint64_t)truth : 0; *out = kind == WO_K_SCALAR ? (uint64_t)truth : 0;
return 0; return 0;
} }
/* number: i64 by truncation — the language has no float */ /* iteration 19: a FLOAT field takes the whole JSON number grammar —
fractions and exponents included. This is the hole the iteration exists
to close: `{"price": 9.99}` used to fail the entire decode. strtod does
the conversion (correctly rounded), and the span is bounded by the JSON
number grammar so a NUL-terminated scratch copy is always enough. */
if (kind == WO_K_FLOAT) {
const char *start = j->p;
if (j->p < j->end && (*j->p == '-' || *j->p == '+')) j->p++;
int digits = 0;
while (j->p < j->end && *j->p >= '0' && *j->p <= '9') {
j->p++;
digits++;
}
if (j->p < j->end && *j->p == '.') {
j->p++;
while (j->p < j->end && *j->p >= '0' && *j->p <= '9') {
j->p++;
digits++;
}
}
if (!digits) return -1;
if (j->p < j->end && (*j->p == 'e' || *j->p == 'E')) {
const char *save = j->p;
j->p++;
if (j->p < j->end && (*j->p == '-' || *j->p == '+')) j->p++;
int edigits = 0;
while (j->p < j->end && *j->p >= '0' && *j->p <= '9') {
j->p++;
edigits++;
}
if (!edigits) j->p = save; /* `1e` is not an exponent; stop before it */
}
size_t n = (size_t)(j->p - start);
char tmp[64];
if (n >= sizeof tmp) return -1; /* no real JSON number is this long */
memcpy(tmp, start, n);
tmp[n] = '\0';
*out = wo_bits(strtod(tmp, NULL));
return 0;
}
/* number: i64 by truncation — the language has no float in an Int slot */
{ {
int neg = 0; int neg = 0;
if (*j->p == '-') { if (*j->p == '-') {

View file

@ -76,6 +76,12 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
id to build */ id to build */
[WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, [WO_B_MAP_GET_OPT] = 2, [WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, [WO_B_MAP_GET_OPT] = 2,
[WO_B_TEXT_COPY] = 1, [WO_B_TEXT_COPY] = 1,
/* iteration 19: Float bridges, then Bytes */
[WO_B_FLOAT_OF_INT] = 1, [WO_B_TRUNC] = 1, [WO_B_PARSE_FLOAT] = 1,
[WO_B_FLOAT_TO_TEXT] = 1, [WO_B_FLOAT_CMP] = 2,
[WO_B_BYTES_LEN] = 1, [WO_B_BYTES_AT] = 2, [WO_B_BYTES_SLICE] = 3,
[WO_B_BYTES_EQ] = 2, [WO_B_BYTES_CONCAT] = 2, [WO_B_BASE64_ENCODE] = 1,
[WO_B_BASE64_DECODE] = 1, [WO_B_BYTES_OF_TEXT] = 1, [WO_B_TEXT_OF_BYTES] = 1,
}; };
static int vtab_cmp(const void *a, const void *b) { static int vtab_cmp(const void *a, const void *b) {
@ -142,6 +148,16 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
c.off += l; c.off += l;
m->consts[i].tag = tag; m->consts[i].tag = tag;
m->consts[i].s = s; m->consts[i].s = s;
} else if (tag == WOB_K_FLOAT) {
/* iteration 19: raw f64 bits. Stored in the same slot as an Int
constant because a register holds either as one word — the tag
is what says how to read it, and LOADK copies the word either
way. No validation is possible or wanted: every one of the 2^64
patterns is a legal f64 (NaN payloads included). */
uint64_t v;
if (rd_u64(&c, &v)) BAIL("constant %u: truncated", (unsigned)i);
m->consts[i].tag = tag;
m->consts[i].i = (int64_t)v;
} else { } else {
BAIL("constant %u: unknown tag %u", (unsigned)i, (unsigned)tag); BAIL("constant %u: unknown tag %u", (unsigned)i, (unsigned)tag);
} }
@ -197,7 +213,8 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
BAIL("class %u field %u: bad name constant", (unsigned)i, (unsigned)j); BAIL("class %u field %u: bad name constant", (unsigned)i, (unsigned)j);
uint32_t fc = m->metapool[meta_pool + fcnt + j]; uint32_t fc = m->metapool[meta_pool + fcnt + j];
if (fc != WOB_NONE && fc != WOB_FIELD_JSON_RAW && fc != WOB_FIELD_NIL_SCALAR && if (fc != WOB_NONE && fc != WOB_FIELD_JSON_RAW && fc != WOB_FIELD_NIL_SCALAR &&
fc != WOB_FIELD_BOOL && fc != WOB_FIELD_NIL_BOOL && fc >= kcnt) fc != WOB_FIELD_BOOL && fc != WOB_FIELD_NIL_BOOL &&
fc != WOB_FIELD_NIL_FLOAT /* iteration 19 */ && fc >= kcnt)
BAIL("class %u field %u: field class out of range", (unsigned)i, (unsigned)j); BAIL("class %u field %u: field class out of range", (unsigned)i, (unsigned)j);
} }
m->classes[i].name = name; m->classes[i].name = name;
@ -234,9 +251,13 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
if (rd_u32(&k, &col)) BAIL("class %u index %u: truncated column", (unsigned)i, (unsigned)x); if (rd_u32(&k, &col)) BAIL("class %u index %u: truncated column", (unsigned)i, (unsigned)x);
if (col >= fcnt) BAIL("class %u index %u: column out of range", (unsigned)i, (unsigned)x); if (col >= fcnt) BAIL("class %u index %u: column out of range", (unsigned)i, (unsigned)x);
uint8_t kind = m->kindpool[(uintptr_t)m->classes[i].kinds + col]; uint8_t kind = m->kindpool[(uintptr_t)m->classes[i].kinds + col];
if (kind != WO_K_SCALAR && kind != WO_K_TEXT) /* iteration 19: FLOAT joins the indexable kinds — the engine
BAIL("class %u index %u: column %u is not scalar or Text", (unsigned)i, orders it by WO_B_FLOAT_CMP's total order (NaN last), which
(unsigned)x, (unsigned)col); is precisely what an index requires. BYTES stays out: its
ordering beyond equality is out of scope this iteration. */
if (kind != WO_K_SCALAR && kind != WO_K_TEXT && kind != WO_K_FLOAT)
BAIL("class %u index %u: column %u is not scalar, Text, or Float",
(unsigned)i, (unsigned)x, (unsigned)col);
m->idxpool[idx_pool++] = col; m->idxpool[idx_pool++] = col;
} }
} }
@ -397,6 +418,7 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
break; break;
case WOP_MOVE: case WOP_MOVE:
case WOP_NEG: case WOP_NEG:
case WOP_FNEG: /* iteration 19 */
RCHK(A); RCHK(A);
RCHK(B); RCHK(B);
break; break;
@ -409,6 +431,16 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
case WOP_LT: case WOP_LT:
case WOP_LE: case WOP_LE:
case WOP_EQS: case WOP_EQS:
/* iteration 19: same shape as their Int counterparts — three
register operands, no immediate, nothing to range-check beyond
the registers. Every bit pattern is a legal f64. */
case WOP_FADD:
case WOP_FSUB:
case WOP_FMUL:
case WOP_FDIV:
case WOP_FEQ:
case WOP_FLT:
case WOP_FLE:
RCHK(A); RCHK(A);
RCHK(B); RCHK(B);
RCHK(C); RCHK(C);

View file

@ -171,9 +171,7 @@ int main(int argc, char **argv) {
VM.is_primary = 1; VM.is_primary = 1;
VM.rt.shard_id = 0; VM.rt.shard_id = 0;
VM.wake_efd = eventfd(0, EFD_NONBLOCK); VM.wake_efd = eventfd(0, EFD_NONBLOCK);
VM.in_mu = calloc(1, sizeof(pthread_mutex_t)); if (VM.wake_efd < 0 || wo_engine_primary_inbox(VM.wake_efd) != 0) {
if (!VM.in_mu || VM.wake_efd < 0
|| pthread_mutex_init((pthread_mutex_t *)VM.in_mu, NULL) != 0) {
fprintf(stderr, "wovm: cannot set up the primary shard\n"); fprintf(stderr, "wovm: cannot set up the primary shard\n");
wo_vm_destroy(&VM); wo_vm_destroy(&VM);
wo_module_free(&mod); wo_module_free(&mod);

View file

@ -175,6 +175,23 @@ wo_str *wo_str_new(wo_rt *rt, const char *bytes, uint32_t len) {
return s; return s;
} }
/* iteration 19: Bytes is a wo_str wearing a different class id. Allocating
* through wo_str_alloc and restamping is deliberate — one allocator, one
* arena accounting path, one free — so a Bytes can never diverge from a Text
* in lifetime handling. */
wo_str *wo_bytes_alloc(wo_rt *rt, uint32_t len) {
wo_str *s = wo_str_alloc(rt, len);
if (s) s->h.class_id = WO_CLS_BYTES;
return s;
}
wo_str *wo_bytes_new(wo_rt *rt, const char *bytes, uint32_t len) {
wo_str *s = wo_bytes_alloc(rt, len);
if (!s) return NULL;
memcpy(s->data, bytes, len);
return s;
}
wo_str *wo_str_concat(wo_rt *rt, const wo_str *a, const wo_str *b) { wo_str *wo_str_concat(wo_rt *rt, const wo_str *a, const wo_str *b) {
wo_str *s = wo_str_alloc(rt, a->len + b->len); wo_str *s = wo_str_alloc(rt, a->len + b->len);
if (!s) return NULL; if (!s) return NULL;

View file

@ -95,4 +95,11 @@ wo_str *wo_str_concat(wo_rt *rt, const wo_str *a, const wo_str *b);
int wo_str_eq(const wo_str *a, const wo_str *b); /* content equality */ int wo_str_eq(const wo_str *a, const wo_str *b); /* content equality */
void wo_str_free(wo_rt *rt, wo_str *s); /* no-op on WO_F_CONST */ void wo_str_free(wo_rt *rt, wo_str *s); /* no-op on WO_F_CONST */
/* iteration 19: Bytes. Byte-for-byte the wo_str object — same struct, same
* allocator, same free path (gc.c handles both class ids) — differing only in
* the header's class_id, which is what every Text builtin checks. There is no
* wo_bytes_free: wo_str_free is it. NULL = OOM on both. */
wo_str *wo_bytes_alloc(wo_rt *rt, uint32_t len); /* UNINITIALIZED bytes */
wo_str *wo_bytes_new(wo_rt *rt, const char *bytes, uint32_t len);
#endif /* WO_OBJ_H */ #endif /* WO_OBJ_H */

View file

@ -35,18 +35,30 @@ static _Thread_local wo_vm *tls_vm = NULL;
wo_vm *wo_tls_vm(void) { return tls_vm; } wo_vm *wo_tls_vm(void) { return tls_vm; }
void wo_tls_set(wo_vm *vm) { tls_vm = vm; } void wo_tls_set(wo_vm *vm) { tls_vm = vm; }
/* The cross-shard inbox lives OUTSIDE wo_vm, in engine-owned storage a
* worker's lazy vm-init can never wipe: the second TSan/ASan round found
* senders reading vm fields (in_mu, wake_efd, shard_id) through the
* late-init memset's zero window. Senders touch ONLY this array; the vm's
* own in_* fields are dead weight kept for layout stability. */
#define WO_ENG_MAX_SHARDS 64u
typedef struct {
pthread_mutex_t mu;
wo_envelope *head, *tail;
int efd; /* duplicate of the shard's wake_efd, sender-visible, never wiped */
} wo_inbox;
static wo_inbox INBOX[WO_ENG_MAX_SHARDS];
static int INBOX_READY[WO_ENG_MAX_SHARDS];
/* push an envelope into a shard's inbox and wake it (any thread) */ /* push an envelope into a shard's inbox and wake it (any thread) */
static void inbox_push(wo_vm *to, wo_envelope *e) { static void inbox_push_to(uint32_t shard, wo_envelope *e) {
pthread_mutex_t *mu = (pthread_mutex_t *)to->in_mu; wo_inbox *ib = &INBOX[shard % WO_ENG_MAX_SHARDS];
pthread_mutex_lock(mu); pthread_mutex_lock(&ib->mu);
e->next = NULL; e->next = NULL;
if (to->in_tail) to->in_tail->next = e; if (ib->tail) ib->tail->next = e;
else to->in_head = e; else ib->head = e;
to->in_tail = e; ib->tail = e;
/* capture the wake fd UNDER the lock: worker_late_init rewrites the int efd = ib->efd;
* whole vm under this same mutex (TSan caught the unlocked read) */ pthread_mutex_unlock(&ib->mu);
int efd = to->wake_efd;
pthread_mutex_unlock(mu);
if (efd >= 0) { if (efd >= 0) {
uint64_t one = 1; uint64_t one = 1;
ssize_t n = write(efd, &one, sizeof one); ssize_t n = write(efd, &one, sizeof one);
@ -62,10 +74,11 @@ static void fib_reap_all(wo_vm *vm);
/* the owning thread drains its inbox: adopt actors, deliver sends, /* the owning thread drains its inbox: adopt actors, deliver sends,
* execute home-routed frees. Returns how many envelopes were handled. */ * execute home-routed frees. Returns how many envelopes were handled. */
static int wo_vm_adopt(wo_vm *vm) { static int wo_vm_adopt(wo_vm *vm) {
pthread_mutex_lock((pthread_mutex_t *)vm->in_mu); wo_inbox *ib = &INBOX[vm->shard_id % WO_ENG_MAX_SHARDS];
wo_envelope *e = vm->in_head; pthread_mutex_lock(&ib->mu);
vm->in_head = vm->in_tail = NULL; wo_envelope *e = ib->head;
pthread_mutex_unlock((pthread_mutex_t *)vm->in_mu); ib->head = ib->tail = NULL;
pthread_mutex_unlock(&ib->mu);
int n = 0; int n = 0;
while (e) { while (e) {
wo_envelope *nx = e->next; wo_envelope *nx = e->next;
@ -93,12 +106,11 @@ static int wo_vm_adopt(wo_vm *vm) {
* the header's shard id is not the current thread's) */ * the header's shard id is not the current thread's) */
void wo_route_free(wo_hdr *h) { void wo_route_free(wo_hdr *h) {
if (eng_teardown) return; /* arenas are torn down wholesale */ if (eng_teardown) return; /* arenas are torn down wholesale */
wo_vm *to = &wo_eng.shards[h->shard_id];
wo_envelope *e = calloc(1, sizeof *e); wo_envelope *e = calloc(1, sizeof *e);
if (!e) return; /* OOM on the free path: leak rather than crash */ if (!e) return; /* OOM on the free path: leak rather than crash */
e->kind = 2; e->kind = 2;
e->payload = (uint64_t)(uintptr_t)h; e->payload = (uint64_t)(uintptr_t)h;
inbox_push(to, e); inbox_push_to(h->shard_id, e);
} }
/* A worker's whole life in T5: pinned, parked on its wake eventfd until /* A worker's whole life in T5: pinned, parked on its wake eventfd until
@ -110,28 +122,20 @@ static size_t eng_heap_cap = 0;
* first envelope, not at boot (20 idle shards must stay ~free) */ * first envelope, not at boot (20 idle shards must stay ~free) */
static int worker_late_init(wo_vm *vm) { static int worker_late_init(wo_vm *vm) {
if (vm->rt.arena.base) return 0; if (vm->rt.arena.base) return 0;
/* under the inbox mutex: wo_vm_init memsets the whole vm, and a /* the memset here is now HARMLESS to senders: every field they touch
* concurrent inbox_push would race the in_head/in_tail wipe (TSan * lives in the engine-owned INBOX array, never in the vm (the second
* caught exactly this). The mutex OBJECT is malloc'd and stable; * TSan/ASan round found them reading through this wipe's zero window) */
* pushers block on it while the fields are rebuilt. */
void *mu = vm->in_mu;
pthread_mutex_lock((pthread_mutex_t *)mu);
const wo_module *mod = vm->mod; const wo_module *mod = vm->mod;
uint32_t id = vm->shard_id; uint32_t id = vm->shard_id;
int efd = vm->wake_efd; int efd = vm->wake_efd;
wo_envelope *h = vm->in_head, *t = vm->in_tail;
int rc = wo_vm_init(vm, mod, eng_heap_cap); int rc = wo_vm_init(vm, mod, eng_heap_cap);
if (rc == 0) { if (rc == 0) {
vm->shard_id = id; vm->shard_id = id;
vm->rt.shard_id = (uint16_t)id; vm->rt.shard_id = (uint16_t)id;
vm->is_primary = 0; vm->is_primary = 0;
vm->wake_efd = efd; vm->wake_efd = efd;
vm->in_mu = mu;
vm->in_head = h;
vm->in_tail = t;
tls_vm = vm; tls_vm = vm;
} }
pthread_mutex_unlock((pthread_mutex_t *)mu);
return rc; return rc;
} }
@ -174,6 +178,18 @@ static void *shard_main(void *arg) {
return NULL; return NULL;
} }
/* register the PRIMARY's inbox row (main.c calls it once its wake fd
* exists); workers register theirs in wo_engine_start */
int wo_engine_primary_inbox(int wake_efd) {
if (!INBOX_READY[0]) {
if (pthread_mutex_init(&INBOX[0].mu, NULL) != 0) return -1;
INBOX_READY[0] = 1;
}
INBOX[0].head = INBOX[0].tail = NULL;
INBOX[0].efd = wake_efd;
return 0;
}
int wo_engine_start(const wo_module *mod, size_t heap_cap, uint32_t nshards) { int wo_engine_start(const wo_module *mod, size_t heap_cap, uint32_t nshards) {
wo_eng.nshards = nshards; wo_eng.nshards = nshards;
eng_heap_cap = heap_cap; eng_heap_cap = heap_cap;
@ -193,9 +209,15 @@ int wo_engine_start(const wo_module *mod, size_t heap_cap, uint32_t nshards) {
sv->is_primary = 0; sv->is_primary = 0;
sv->wake_efd = eventfd(0, EFD_NONBLOCK); sv->wake_efd = eventfd(0, EFD_NONBLOCK);
if (sv->wake_efd < 0) return -1; if (sv->wake_efd < 0) return -1;
sv->in_mu = calloc(1, sizeof(pthread_mutex_t)); {
if (!sv->in_mu || pthread_mutex_init((pthread_mutex_t *)sv->in_mu, NULL) != 0) wo_inbox *ib = &INBOX[i % WO_ENG_MAX_SHARDS];
return -1; if (!INBOX_READY[i % WO_ENG_MAX_SHARDS]) {
if (pthread_mutex_init(&ib->mu, NULL) != 0) return -1;
INBOX_READY[i % WO_ENG_MAX_SHARDS] = 1;
}
ib->head = ib->tail = NULL;
ib->efd = sv->wake_efd;
}
if (pthread_create(&ts[i - 1], NULL, shard_main, sv) != 0) return -1; if (pthread_create(&ts[i - 1], NULL, shard_main, sv) != 0) return -1;
} }
(void)heap_cap; /* consumed at lazy init (T6) */ (void)heap_cap; /* consumed at lazy init (T6) */
@ -219,11 +241,12 @@ void wo_engine_stop(void) {
* raised by the destroys below into a no-op, so no teardown ordering * raised by the destroys below into a no-op, so no teardown ordering
* can lock a freed mutex (the ASan SEGV this replaces). */ * can lock a freed mutex (the ASan SEGV this replaces). */
eng_teardown = 1; eng_teardown = 1;
for (uint32_t i = 0; i < wo_eng.nshards; i++) { for (uint32_t i = 0; i < wo_eng.nshards && i < WO_ENG_MAX_SHARDS; i++) {
wo_vm *sv = &wo_eng.shards[i]; if (!INBOX_READY[i]) continue;
if (!sv->in_mu) continue; wo_inbox *ib = &INBOX[i];
wo_envelope *e = sv->in_head; wo_envelope *e = ib->head;
sv->in_head = sv->in_tail = NULL; ib->head = ib->tail = NULL;
ib->efd = -1;
while (e) { while (e) {
wo_envelope *nx = e->next; wo_envelope *nx = e->next;
if (e->kind == 1 && e->actor) { if (e->kind == 1 && e->actor) {
@ -238,27 +261,11 @@ void wo_engine_stop(void) {
close(wo_eng.shards[i].wake_efd); close(wo_eng.shards[i].wake_efd);
if (wo_eng.shards[i].rt.arena.base) /* lazily init'ed only */ if (wo_eng.shards[i].rt.arena.base) /* lazily init'ed only */
wo_vm_destroy(&wo_eng.shards[i]); wo_vm_destroy(&wo_eng.shards[i]);
free(wo_eng.shards[i].in_mu);
wo_eng.shards[i].in_mu = NULL;
} }
/* the primary's inbox: same discard (main destroys its vm right after) */ /* the primary's wake fd (its inbox row was drained in the loop above) */
{ {
wo_vm *pv = &wo_eng.shards[0]; wo_vm *pv = &wo_eng.shards[0];
if (pv->in_mu) {
wo_envelope *e = pv->in_head;
pv->in_head = pv->in_tail = NULL;
while (e) {
wo_envelope *nx = e->next;
if (e->kind == 1 && e->actor) {
free(e->actor->msgs);
free(e->actor);
}
free(e);
e = nx;
}
free(pv->in_mu);
pv->in_mu = NULL;
}
if (pv->wake_efd >= 0) { if (pv->wake_efd >= 0) {
close(pv->wake_efd); close(pv->wake_efd);
pv->wake_efd = -1; pv->wake_efd = -1;
@ -455,7 +462,7 @@ int wo_vm_actor_spawn(wo_vm *vm, uint64_t instance, uint32_t method_idx,
} }
e->kind = 1; e->kind = 1;
e->actor = a; e->actor = a;
inbox_push(&wo_eng.shards[home], e); inbox_push_to(home, e);
} }
*out_addr = (uint64_t)(uintptr_t)a; *out_addr = (uint64_t)(uintptr_t)a;
return 0; return 0;
@ -483,7 +490,7 @@ int wo_vm_actor_send(wo_vm *vm, uint64_t addr, uint64_t msg_val, const char **ms
e->kind = 0; e->kind = 0;
e->actor = a; e->actor = a;
e->payload = msg_val; e->payload = msg_val;
inbox_push(&wo_eng.shards[a->home], e); inbox_push_to(a->home, e);
return 0; return 0;
} }
if (actor_push(a, msg_val) != 0) { if (actor_push(a, msg_val) != 0) {
@ -758,7 +765,7 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
* cur/queued/parked). */ * cur/queued/parked). */
#define NEXT_RUNNABLE() \ #define NEXT_RUNNABLE() \
do { \ do { \
if (vm->in_mu) (void)wo_vm_adopt(vm); \ if (INBOX_READY[vm->shard_id % WO_ENG_MAX_SHARDS]) (void)wo_vm_adopt(vm); \
vm->cur = fib_dequeue(vm); \ vm->cur = fib_dequeue(vm); \
while (!vm->cur) { \ while (!vm->cur) { \
if (!vm->is_primary && !vm->parked) { \ if (!vm->is_primary && !vm->parked) { \
@ -785,7 +792,7 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
} \ } \
return -1; \ return -1; \
} \ } \
if (vm->in_mu) (void)wo_vm_adopt(vm); \ if (INBOX_READY[vm->shard_id % WO_ENG_MAX_SHARDS]) (void)wo_vm_adopt(vm); \
vm->cur = fib_dequeue(vm); \ vm->cur = fib_dequeue(vm); \
} \ } \
vm->budget = vm->budget0; \ vm->budget = vm->budget0; \
@ -847,6 +854,11 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
[WOP_RELEASE_X] = &&L_RELEASE_X, [WOP_BUILTIN] = &&L_BUILTIN, [WOP_RELEASE_X] = &&L_RELEASE_X, [WOP_BUILTIN] = &&L_BUILTIN,
[WOP_DB_STUB] = &&L_DB_STUB, [WOP_TRAP] = &&L_TRAP, [WOP_DB_STUB] = &&L_DB_STUB, [WOP_TRAP] = &&L_TRAP,
[WOP_TRY] = &&L_TRY, [WOP_ENDTRY] = &&L_ENDTRY, [WOP_TRY] = &&L_TRY, [WOP_ENDTRY] = &&L_ENDTRY,
/* iteration 19: the f64 world */
[WOP_FADD] = &&L_FADD, [WOP_FSUB] = &&L_FSUB,
[WOP_FMUL] = &&L_FMUL, [WOP_FDIV] = &&L_FDIV,
[WOP_FNEG] = &&L_FNEG, [WOP_FEQ] = &&L_FEQ,
[WOP_FLT] = &&L_FLT, [WOP_FLE] = &&L_FLE,
}; };
#define CASE(name) L_##name #define CASE(name) L_##name
#define NEXT() \ #define NEXT() \
@ -867,8 +879,11 @@ dispatch:
CASE(LOADK) : { CASE(LOADK) : {
const wo_const *k = &mod->consts[wo_ins_bx(ins)]; const wo_const *k = &mod->consts[wo_ins_bx(ins)];
R[wo_ins_a(ins)] = k->tag == WOB_K_INT ? (uint64_t)k->i /* WOB_K_TEXT is the only pointer-shaped constant; INT and (iteration
: (uint64_t)(uintptr_t)k->s; * 19) FLOAT both live in the same word, differing only in how the
* ops that read them interpret it. */
R[wo_ins_a(ins)] = k->tag == WOB_K_TEXT ? (uint64_t)(uintptr_t)k->s
: (uint64_t)k->i;
NEXT(); NEXT();
} }
@ -920,6 +935,50 @@ dispatch:
NEXT(); NEXT();
} }
/* iteration 19: f64 arithmetic. Registers are u64, so each op bitcasts in
* and out (wo_f64/wo_bits — memcpy-based, the only strict-aliasing-clean
* way). Nothing here traps: IEEE 754 quiet semantics are the contract, so
* x/0.0 yields ±Inf and 0.0/0.0 yields NaN instead of raising. The FPU's
* own exception flags are left alone — the language never reads them. */
CASE(FADD) : {
R[wo_ins_a(ins)] = wo_bits(wo_f64(R[wo_ins_b(ins)]) + wo_f64(R[wo_ins_c(ins)]));
NEXT();
}
CASE(FSUB) : {
R[wo_ins_a(ins)] = wo_bits(wo_f64(R[wo_ins_b(ins)]) - wo_f64(R[wo_ins_c(ins)]));
NEXT();
}
CASE(FMUL) : {
R[wo_ins_a(ins)] = wo_bits(wo_f64(R[wo_ins_b(ins)]) * wo_f64(R[wo_ins_c(ins)]));
NEXT();
}
CASE(FDIV) : {
R[wo_ins_a(ins)] = wo_bits(wo_f64(R[wo_ins_b(ins)]) / wo_f64(R[wo_ins_c(ins)]));
NEXT();
}
CASE(FNEG) : {
/* sign flip, not 0.0 - x: only this reaches -0.0 from +0.0, and the
* iteration's edge-case gate stores -0.0 and reads it back. */
R[wo_ins_a(ins)] = wo_bits(-wo_f64(R[wo_ins_b(ins)]));
NEXT();
}
/* IEEE comparisons, NOT the total order: every one of these is false when
* either side is NaN, which is what makes `NaN != NaN` true in the
* language. Indexes and order-by need a total order instead and call
* WO_B_FLOAT_CMP for it. */
CASE(FEQ) : {
R[wo_ins_a(ins)] = wo_f64(R[wo_ins_b(ins)]) == wo_f64(R[wo_ins_c(ins)]) ? 1 : 0;
NEXT();
}
CASE(FLT) : {
R[wo_ins_a(ins)] = wo_f64(R[wo_ins_b(ins)]) < wo_f64(R[wo_ins_c(ins)]) ? 1 : 0;
NEXT();
}
CASE(FLE) : {
R[wo_ins_a(ins)] = wo_f64(R[wo_ins_b(ins)]) <= wo_f64(R[wo_ins_c(ins)]) ? 1 : 0;
NEXT();
}
CASE(JMP) : { CASE(JMP) : {
if (wo_ins_sbx(ins) < 0) { if (wo_ins_sbx(ins) < 0) {
GC_SAFEPOINT(); /* loop back-edge */ GC_SAFEPOINT(); /* loop back-edge */

View file

@ -162,6 +162,7 @@ void wo_tls_set(wo_vm *vm);
/* Start shards 1..n-1 (0 is the caller's, already init'ed in shards[0]). /* Start shards 1..n-1 (0 is the caller's, already init'ed in shards[0]).
* 0 ok. Stop joins every worker and destroys their vms. */ * 0 ok. Stop joins every worker and destroys their vms. */
int wo_engine_start(const wo_module *mod, size_t heap_cap, uint32_t nshards); int wo_engine_start(const wo_module *mod, size_t heap_cap, uint32_t nshards);
int wo_engine_primary_inbox(int wake_efd);
void wo_engine_stop(void); void wo_engine_stop(void);
/* Spawn a fiber that will run method_idx(args) — the runtime half the /* Spawn a fiber that will run method_idx(args) — the runtime half the

View file

@ -9,11 +9,15 @@
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <string.h> /* memcpy: the f64 <-> u64 bitcast (iteration 19) */
/* ---- file header (44 bytes, absolute offsets) ---- */ /* ---- file header (44 bytes, absolute offsets) ---- */
#define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */ #define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */
#define WOB_VERSION 4u /* v4 (iteration 7b): opcodes 27-28 (RC_INC/RC_DEC) retired; #define WOB_VERSION 5u /* v5 (iteration 19): the two missing scalars. New
* the drop table's gc mask now means "GC roots at this pc" */ * constant tag WOB_K_FLOAT, field kinds WO_K_FLOAT/WO_K_BYTES (WO_K_MAX 5->7),
* opcodes 34-41 (the f64 arithmetic/compare set), builtins 70-82.
* v4 (iteration 7b): opcodes 27-28 (RC_INC/RC_DEC) retired; the drop table's
* gc mask now means "GC roots at this pc" */
#define WOB_HDR_SIZE 44u #define WOB_HDR_SIZE 44u
#define WOB_OFF_MAGIC 0u #define WOB_OFF_MAGIC 0u
#define WOB_OFF_VERSION 4u #define WOB_OFF_VERSION 4u
@ -56,6 +60,11 @@
* (nil spelled WO_NIL_SCALAR, exactly like NIL_SCALAR, plus bool encoding). */ * (nil spelled WO_NIL_SCALAR, exactly like NIL_SCALAR, plus bool encoding). */
#define WOB_FIELD_BOOL 0xFFFFFFFCu #define WOB_FIELD_BOOL 0xFFFFFFFCu
#define WOB_FIELD_NIL_BOOL 0xFFFFFFFBu #define WOB_FIELD_NIL_BOOL 0xFFFFFFFBu
/* iteration 19: a `?Float` field. Marks WHICH nil sentinel the slot uses —
* WO_NIL_FLOAT, not WO_NIL_SCALAR. A plain `Float` needs no marker at all
* (WO_K_FLOAT is a real kind byte, unlike Bool). `?Bytes` needs none either:
* it is pointer-shaped, so the zero word is unambiguous absence. */
#define WOB_FIELD_NIL_FLOAT 0xFFFFFFFAu
/* nil for a nullable scalar: -(2^62). Not INT64_MIN, deliberately — the /* nil for a nullable scalar: -(2^62). Not INT64_MIN, deliberately — the
* compiler's own integers are OCaml's 63-bit native ints, so INT64_MIN is not * compiler's own integers are OCaml's 63-bit native ints, so INT64_MIN is not
@ -63,9 +72,25 @@
* inside a `?Int`; that is the whole cost of the choice. */ * inside a `?Int`; that is the whole cost of the choice. */
#define WO_NIL_SCALAR ((uint64_t)(int64_t)(-4611686018427387904LL)) #define WO_NIL_SCALAR ((uint64_t)(int64_t)(-4611686018427387904LL))
/* nil for a `?Float` (iteration 19). WO_NIL_SCALAR cannot serve: its bit
* pattern IS -2.0 as an f64, and -2.0 is an ordinary price delta. Nor can the
* zero word: that is +0.0. So nil is a quiet NaN carrying a reserved payload.
* Arithmetic on this platform produces the CANONICAL quiet NaN
* (0x7FF8000000000000), so a computed NaN never collides with it — the
* iteration's own edge-case gate stores NaN and reads NaN back. The whole cost
* of the choice is that one NaN payload out of 2^51 is unavailable inside a
* `?Float`, exactly as one absurd integer is unavailable inside a `?Int`. */
#define WO_NIL_FLOAT 0x7FF8000000000EE1ull
/* ---- constant pool tags ---- */ /* ---- constant pool tags ---- */
#define WOB_K_INT 0u /* tag byte, then i64 */ #define WOB_K_INT 0u /* tag byte, then i64 */
#define WOB_K_TEXT 1u /* tag byte, then u32 len + bytes (no NUL) */ #define WOB_K_TEXT 1u /* tag byte, then u32 len + bytes (no NUL) */
/* iteration 19: tag byte, then the f64's IEEE 754 bit pattern as an LE u64.
* Bits, not a decimal rendering — a literal must reach the VM bit-exact, and
* the emitter is OCaml (whose float IS an f64) so no conversion happens at
* all. There is no Bytes constant tag: Bytes has no literal form by design
* (settled decision 3 — it is built from base64/net/slices). */
#define WOB_K_FLOAT 2u
/* ---- field kinds (one byte per field in the class table) ---- */ /* ---- field kinds (one byte per field in the class table) ---- */
enum { enum {
@ -75,8 +100,16 @@ enum {
WO_K_TEXT = 3, WO_K_TEXT = 3,
WO_K_MULTI = 4, WO_K_MULTI = 4,
WO_K_MAP = 5, WO_K_MAP = 5,
/* iteration 19. FLOAT is word-shaped like SCALAR — the slot holds f64
* bits — but it needs its own kind for three services that cannot guess
* from a register: json (a Float field emits 9.99, not 4621...), the WAL
* (replay must not reinterpret bits), and printing. BYTES is
* pointer-shaped like TEXT and shares the wo_str object layout, with its
* own class-id sentinel so no Text builtin silently accepts one. */
WO_K_FLOAT = 6,
WO_K_BYTES = 7,
}; };
#define WO_K_MAX 5u #define WO_K_MAX 7u
/* ---- loader-enforced limits ---- */ /* ---- loader-enforced limits ---- */
#define WO_MAX_REGS 64u #define WO_MAX_REGS 64u
@ -118,6 +151,12 @@ _Static_assert(sizeof(wo_hdr) == 16, "object header must be exactly 16 bytes");
#define WO_CLS_STR 0xFFFFFFFCu #define WO_CLS_STR 0xFFFFFFFCu
#define WO_CLS_MAP 0xFFFFFFFDu #define WO_CLS_MAP 0xFFFFFFFDu
#define WO_CLS_MULTI 0xFFFFFFFEu #define WO_CLS_MULTI 0xFFFFFFFEu
/* iteration 19: Bytes reuses the wo_str object layout byte for byte (header,
* len, bytes) and differs ONLY in this header class_id. That is deliberate:
* every allocation, drop, and copy path already handles the shape, while the
* distinct id is what lets a Text builtin refuse a Bytes and vice versa —
* settled decision 3, "Text goes back to meaning text". */
#define WO_CLS_BYTES 0xFFFFFFFBu
/* borrow word states */ /* borrow word states */
#define WO_BORROW_FREE 0u #define WO_BORROW_FREE 0u
@ -193,8 +232,23 @@ enum {
* today's surface. */ * today's surface. */
WOP_TRY = 32, /* A sBx: push catch frame, handler at pc + sBx */ WOP_TRY = 32, /* A sBx: push catch frame, handler at pc + sBx */
WOP_ENDTRY = 33, /* pop the innermost catch frame */ WOP_ENDTRY = 33, /* pop the innermost catch frame */
/* iteration 19: the f64 world. Registers stay u64 — these ops bitcast,
* compute, and bitcast back, so there is no layout change anywhere. They
* are separate opcodes rather than a mode bit on ADD/DIV because the
* compiler always knows the static type and because the two worlds have
* different failure semantics: WOP_DIV traps DIV0, WOP_FDIV never traps
* (IEEE quiet — Inf and NaN flow). No opcode here mixes an Int operand
* with a Float one; `float(i)` and `trunc(f)` are the only bridges. */
WOP_FADD = 34, /* A B C: f64 */
WOP_FSUB = 35,
WOP_FMUL = 36,
WOP_FDIV = 37, /* never traps: x/0.0 is ±Inf, 0.0/0.0 is NaN */
WOP_FNEG = 38, /* A B — sign flip, so -0.0 is reachable */
WOP_FEQ = 39, /* A B C: IEEE equality, so NaN == NaN is 0 */
WOP_FLT = 40, /* IEEE ordered <: any comparison with NaN is 0 */
WOP_FLE = 41,
}; };
#define WOP_MAX 33u #define WOP_MAX 41u
/* ---- builtin ids (WOP_BUILTIN operand C) ---- */ /* ---- builtin ids (WOP_BUILTIN operand C) ---- */
enum { enum {
@ -341,9 +395,44 @@ enum {
* order-by on a Text key; scalars use the LT opcode) */ * order-by on a Text key; scalars use the LT opcode) */
WO_B_SPAWN = 68, /* (instance, receive_method_idx) -> actor address (arc) */ WO_B_SPAWN = 68, /* (instance, receive_method_idx) -> actor address (arc) */
WO_B_SEND = 69, /* (address, msg) — msg moves to the runtime (arc) */ WO_B_SEND = 69, /* (address, msg) — msg moves to the runtime (arc) */
/* ---- iteration 19: the Float bridges and surface. There is NO implicit
* coercion anywhere, so every crossing between the two numeric worlds is
* one of these calls, visible in the source. ---- */
WO_B_FLOAT_OF_INT = 70, /* (i64) -> f64 bits. `float(i)`. Exact below 2^53,
* round-to-nearest above — the hardware's rule */
WO_B_TRUNC = 71, /* (f64) -> i64 toward zero. `trunc(f)`. NaN, ±Inf,
* and anything outside i64 trap WO_T_BOUNDS: the
* Int world has no value to give back, and
* silently yielding 0 is how currency bugs start */
WO_B_PARSE_FLOAT = 72, /* (text) -> f64 bits; unparseable is NaN, which is
* exactly "not a number" and needs no ?Float */
WO_B_FLOAT_TO_TEXT = 73, /* (f64) -> fresh Text, SHORTEST round-trip form
* (%.17g trimmed to the shortest that reparses
* equal). Drives interpolation and json.encode */
WO_B_FLOAT_CMP = 74, /* (a, b) -> -1/0/1 TOTAL order: -Inf < finite <
* +Inf < NaN, and -0.0 == +0.0. Not IEEE — an
* index and an order-by REQUIRE a total order, so
* this is the one deliberate deviation, and it
* lives in its own builtin rather than bending
* WOP_FLT (which stays IEEE for the language) */
/* ---- iteration 19: Bytes. Length-carrying, content-comparable, no
* literal form; every accessor refuses a Text and every Text builtin
* refuses a Bytes (WO_T_BOUNDS on the wrong class id). ---- */
WO_B_BYTES_LEN = 75, /* (bytes) -> i64 */
WO_B_BYTES_AT = 76, /* (bytes, i) -> i64 byte; out of range traps BOUNDS */
WO_B_BYTES_SLICE = 77, /* (bytes, start, len) -> fresh Bytes, clamped */
WO_B_BYTES_EQ = 78, /* (a, b) -> 1/0 by content */
WO_B_BYTES_CONCAT = 79, /* (a, b) -> fresh Bytes */
WO_B_BASE64_ENCODE = 80, /* (bytes) -> fresh Text, standard alphabet + pad */
WO_B_BASE64_DECODE = 81, /* (text) -> ?Bytes; malformed is nil, not a trap,
* because base64 arrives from the network */
WO_B_BYTES_OF_TEXT = 82, /* (text) -> fresh Bytes, the bytes as they are */
WO_B_TEXT_OF_BYTES = 83, /* (bytes) -> fresh Text, verbatim. The caller
* asserts the bytes are text; no validation,
* because Unicode is explicitly out of scope */
}; };
#define WO_B_MAX 69u #define WO_B_MAX 83u
/* ids at or above this one live in sysio.c, not builtin.c */ /* ids at or above this one live in sysio.c, not builtin.c */
#define WO_B_SYS_FIRST WO_B_FS_EXISTS #define WO_B_SYS_FIRST WO_B_FS_EXISTS
@ -369,6 +458,37 @@ static inline uint8_t wo_ins_c(uint32_t i) { return (uint8_t)((i >> 24) & 0xFFu)
static inline uint16_t wo_ins_bx(uint32_t i) { return (uint16_t)(i >> 16); } static inline uint16_t wo_ins_bx(uint32_t i) { return (uint16_t)(i >> 16); }
static inline int32_t wo_ins_sbx(uint32_t i) { return (int32_t)wo_ins_bx(i) - 32768; } static inline int32_t wo_ins_sbx(uint32_t i) { return (int32_t)wo_ins_bx(i) - 32768; }
/* ---- iteration 19: the f64 <-> register bitcast, and the total order ----
* A register is a u64 and a Float is an f64 in it. memcpy is the only
* strict-aliasing-clean cast; every compiler this project targets folds these
* to zero instructions (the value already sits in the right register class or
* is one movq away). Do NOT reintroduce a union or a pointer cast here. */
static inline double wo_f64(uint64_t bits) {
double d;
memcpy(&d, &bits, sizeof d);
return d;
}
static inline uint64_t wo_bits(double d) {
uint64_t b;
memcpy(&b, &d, sizeof b);
return b;
}
/* The TOTAL order (WO_B_FLOAT_CMP, indexes, order-by): -Inf < finite < +Inf <
* NaN, with -0.0 equal to +0.0. IEEE's own comparisons are not a total order —
* NaN is unordered against everything, which would make a sort's result depend
* on the comparison sequence and a B-tree walk lose rows. Sorting NaN last is
* therefore not a preference but a requirement, and it is the ONE place this
* iteration deviates from raw IEEE. The language's own `<` (WOP_FLT) keeps
* IEEE semantics, so `NaN < 1.0` is still false in source. */
static inline int wo_float_cmp(double a, double b) {
int an = a != a, bn = b != b; /* NaN is the only value unequal to itself */
if (an || bn) return an && bn ? 0 : (an ? 1 : -1);
if (a < b) return -1;
if (a > b) return 1;
return 0; /* covers -0.0 vs +0.0: they compare equal, deliberately */
}
/* ---- class descriptor shared by loader and runtime ---- */ /* ---- class descriptor shared by loader and runtime ---- */
typedef struct wo_classdesc { typedef struct wo_classdesc {
uint32_t name; /* constant index of the class name */ uint32_t name; /* constant index of the class name */

View file

@ -264,11 +264,71 @@ static void test_crash_battery(void) {
} }
} }
/* iteration 19: a Float column and a Bytes column survive a WAL round trip
* BIT-EXACT. Bit-exact is the whole assertion — the durability path must not
* render a float as decimal anywhere, or NaN, the infinities and -0.0 would
* each come back as something else. Bytes goes through the same length-
* prefixed blob a Text does and must come back as a Bytes, not a Text. */
static const uint8_t fb_kinds[] = {WO_K_FLOAT, WO_K_BYTES};
static const wo_classdesc FB_CLASSES[] = {
{.name = 0, .flags = 0, .field_cnt = 2, .kinds = fb_kinds},
};
static void test_float_bytes_replay(void) {
char path[128];
snprintf(path, sizeof path, "%s/floatbytes.wal", g_dir);
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 20, FB_CLASSES, 1), 0);
wo_db db;
T_EQ(wo_db_init(&db, FB_CLASSES, 1, 0, 1), 0);
wo_wal w;
T_EQ(wo_wal_open(&w, path, 1 << 16), 0);
const char *msg = "";
/* the values a decimal round trip would destroy, plus a NUL-bearing blob
* that a NUL-terminated string path would truncate */
const double vals_f[] = {9.99, 0.0 / 0.0, 1.0 / 0.0, -1.0 / 0.0, -0.0, 1e308};
const char blob[] = {'a', '\0', 'b'};
enum { N = sizeof vals_f / sizeof vals_f[0] };
uint64_t ids[N];
for (int i = 0; i < N; i++) {
wo_str *b = wo_bytes_new(&rt, blob, sizeof blob);
T_CHECK(b != NULL);
uint64_t vals[2] = {wo_bits(vals_f[i]), (uint64_t)(uintptr_t)b};
ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL);
T_CHECK(ids[i] != 0);
T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0);
wo_str_free(&rt, b);
}
T_EQ(wo_wal_commit(&w), 0);
wo_wal_close(&w);
wo_db_destroy(&db);
wo_db db2;
T_EQ(wo_db_init(&db2, FB_CLASSES, 1, 0, 1), 0);
T_EQ(wo_wal_replay(path, &db2), N);
for (int i = 0; i < N; i++) {
uint64_t out[2];
T_EQ(wo_row_read(&db2, &rt, 0, ids[i], out, &msg), 0);
/* BITS, not value: NaN != NaN and -0.0 == 0.0, so a value comparison
* would pass while silently having lost the payload or the sign */
T_EQ(out[0], wo_bits(vals_f[i]));
wo_str *b = (wo_str *)(uintptr_t)out[1];
T_CHECK(b != NULL);
T_EQ(b->h.class_id, WO_CLS_BYTES); /* a Bytes column yields a Bytes */
T_CHECK(b->len == sizeof blob && memcmp(b->data, blob, sizeof blob) == 0);
wo_str_free(&rt, b);
}
wo_db_destroy(&db2);
wo_rt_destroy(&rt);
}
int main(void) { int main(void) {
snprintf(g_dir, sizeof g_dir, "/tmp/wo-wal-test-XXXXXX"); snprintf(g_dir, sizeof g_dir, "/tmp/wo-wal-test-XXXXXX");
if (!mkdtemp(g_dir)) return 1; if (!mkdtemp(g_dir)) return 1;
test_roundtrip_replay(); test_roundtrip_replay();
test_torn_tail(); test_torn_tail();
test_float_bytes_replay();
test_crash_battery(); test_crash_battery();
/* leave the dir for a failed run's forensics only */ /* leave the dir for a failed run's forensics only */
if (!t_fail) { if (!t_fail) {

111
scripts/linkcheck.py Normal file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Scan every .md in repo, extract links, report broken local targets + bad anchors."""
import os, re, sys, urllib.parse
from collections import defaultdict
ROOT = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
SKIP_DIRS = {".git", "node_modules", "_build", "target", "dist"}
INLINE = re.compile(r'(?<!!)\[([^\]\n]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)')
REFDEF = re.compile(r'^\s{0,3}\[([^\]]+)\]:\s*<?(\S+)>?', re.M)
FENCE = re.compile(r'(^```.*?^```|^~~~.*?^~~~)', re.M | re.S)
INLINE_CODE = re.compile(r'`[^`\n]*`')
ATX = re.compile(r'^(#{1,6})\s+(.*?)\s*#*\s*$', re.M)
HTML_ANCHOR = re.compile(r'<a\s+[^>]*(?:name|id)=["\']([^"\']+)["\']', re.I)
def strip_code(text):
text = FENCE.sub(lambda m: "\n" * m.group(0).count("\n"), text)
return INLINE_CODE.sub("", text)
def slugify(heading):
# GitHub-style slug
h = re.sub(r'<[^>]+>', '', heading)
h = re.sub(r'!?\[([^\]]*)\]\([^)]*\)', r'\1', h) # links -> text
h = h.replace('`', '').replace('*', '').replace('_', '')
h = h.strip().lower()
h = re.sub(r'[^\w\- ]', '', h, flags=re.UNICODE)
return h.replace(' ', '-')
md_files = []
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for f in filenames:
if f.lower().endswith((".md", ".markdown")):
md_files.append(os.path.join(dirpath, f))
md_files.sort()
anchors = {}
def get_anchors(path):
if path in anchors:
return anchors[path]
try:
raw = open(path, encoding="utf-8", errors="replace").read()
except OSError:
anchors[path] = set()
return anchors[path]
body = strip_code(raw)
s = set()
seen = defaultdict(int)
for _, head in ATX.findall(body):
base = slugify(head)
if not base:
continue
n = seen[base]
seen[base] += 1
s.add(base if n == 0 else f"{base}-{n}")
s |= set(HTML_ANCHOR.findall(raw))
anchors[path] = s
return s
broken = []
anchor_bad = []
stats = defaultdict(int)
for md in md_files:
raw = open(md, encoding="utf-8", errors="replace").read()
body = strip_code(raw)
lines = body.split("\n")
targets = []
for m in INLINE.finditer(body):
targets.append((body[:m.start()].count("\n") + 1, m.group(2)))
for m in REFDEF.finditer(body):
targets.append((body[:m.start()].count("\n") + 1, m.group(2)))
for lineno, target in targets:
t = target.strip()
if not t:
continue
low = t.lower()
if low.startswith(("http://", "https://", "mailto:", "ftp://", "tel:", "data:")):
stats["external"] += 1
continue
if t.startswith("#"):
stats["anchor-local"] += 1
frag = urllib.parse.unquote(t[1:])
if frag and frag not in get_anchors(md):
anchor_bad.append((md, lineno, t))
continue
stats["local"] += 1
pathpart, _, frag = t.partition("#")
pathpart = urllib.parse.unquote(pathpart)
frag = urllib.parse.unquote(frag)
if pathpart.startswith("/"):
cand = os.path.join(ROOT, pathpart.lstrip("/"))
else:
cand = os.path.normpath(os.path.join(os.path.dirname(md), pathpart))
if not os.path.exists(cand):
broken.append((md, lineno, t))
continue
if frag and cand.lower().endswith((".md", ".markdown")):
if frag not in get_anchors(cand):
anchor_bad.append((md, lineno, t))
rel = lambda p: os.path.relpath(p, ROOT)
print(f"files={len(md_files)} links: local={stats['local']} external={stats['external']} same-file-anchor={stats['anchor-local']}")
print(f"\n== BROKEN PATHS ({len(broken)}) ==")
for md, ln, t in broken:
print(f"{rel(md)}:{ln} -> {t}")
print(f"\n== BAD ANCHORS ({len(anchor_bad)}) ==")
for md, ln, t in anchor_bad:
print(f"{rel(md)}:{ln} -> {t}")

View file

@ -3,7 +3,8 @@
# chain at run time, network-free: a temp git remote is built from # chain at run time, network-free: a temp git remote is built from
# docs/examples/writeonce-framework, its file:// URL is substituted into a # docs/examples/writeonce-framework, its file:// URL is substituted into a
# temp copy of docs/examples/web-app, then: fetch -> lock -> build -> serve -> # temp copy of docs/examples/web-app, then: fetch -> lock -> build -> serve ->
# the storefront matrix -> SIGTERM -> restart persistence. The repo itself # the storefront matrix -> SIGTERM -> restart persistence, plus iteration 17's
# library-kind and internal/-boundary checks. The repo itself
# never carries .wo-deps/wo.lock artifacts. # never carries .wo-deps/wo.lock artifacts.
set -uo pipefail set -uo pipefail
@ -49,6 +50,37 @@ else
exit 1 exit 1
fi fi
# ---- iteration 17: library kind + the internal/ dep boundary ----
# The framework copy at $W/fw carries `kind = "library"` and has no `fn main`.
# Checking it entry-less is what retired iteration 16's `--emit` workaround.
if out="$("$WOC" "$W/fw" 2>&1)" && [[ -z "$out" ]]; then
ok "library check mode: the framework typechecks entry-less"
else
bad "library-check" "exit=$? out=$(printf '%s' "$out" | head -1)"
fi
# A consumer reaching past the privacy line is WO-E108 at its own `use`.
cp -r "$W/app" "$W/app-internal"
rm -rf "$W/app-internal/target" "$W/app-internal/wo.lock" "$W/app-internal/.wo-deps"
sed -i '1i use framework/internal' "$W/app-internal/main.wo"
out="$("$WOC" "$W/app-internal" 2>&1)"; rc=$?
if [[ "$rc" == "1" ]] && printf '%s' "$out" | grep -q 'WO-E108'; then
ok "dep boundary: importing framework/internal is WO-E108"
else
bad "internal-boundary" "exit=$rc out=$(printf '%s' "$out" | head -1)"
fi
# An unknown `kind` is a manifest error (exit 2), not a silent default.
cp -r "$W/app" "$W/app-badkind"
rm -rf "$W/app-badkind/target" "$W/app-badkind/wo.lock" "$W/app-badkind/.wo-deps"
sed -i '1i kind = "junk"' "$W/app-badkind/wo.toml"
out="$("$WOC" "$W/app-badkind" 2>&1)"; rc=$?
if [[ "$rc" == "2" ]] && printf '%s' "$out" | grep -q 'WO-E109'; then
ok "manifest: an unknown kind is WO-E109 at exit 2"
else
bad "kind-validation" "exit=$rc out=$(printf '%s' "$out" | head -1)"
fi
DATA="$W/data"; mkdir -p "$DATA" DATA="$W/data"; mkdir -p "$DATA"
WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >"$W/srv.out" 2>&1 & WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >"$W/srv.out" 2>&1 &
SRV=$! SRV=$!
@ -105,19 +137,27 @@ PYEOF
[[ "$wt" == "401" ]] && ok "401 on a wrong bearer token (ct_eq)" \ [[ "$wt" == "401" ]] && ok "401 on a wrong bearer token (ct_eq)" \
|| bad "wrong-token" "got $wt" || bad "wrong-token" "got $wt"
expect "empty list" "$(hit GET /products)" 200 "[]" expect "empty list" "$(hit GET /products)" 200 "[]"
expect "create product (201)" "$(hit POST /products '{"name":"mug","price":900,"stock":5}')" 201 '"name":"mug"' # iteration 19: a REAL decimal price. `{"price": 9.99}` is the acceptance
expect "duplicate name is 409 (@unique)" "$(hit POST /products '{"name":"mug","price":1,"stock":1}')" 409 # criterion — before Float existed this body failed the whole checked decode.
expect "create product (201, fractional price)" \
"$(hit POST /products '{"name":"mug","price":9.99,"stock":5}')" 201 '"price":9.99'
expect "duplicate name is 409 (@unique)" "$(hit POST /products '{"name":"mug","price":1.0,"stock":1}')" 409
# an integer-shaped JSON number is a legal Float too, and comes back as one
expect "integer-shaped price decodes as Float" \
"$(hit POST /products '{"name":"plate","price":12,"stock":1}')" 201 '"price":12.0'
expect "malformed json is 400" "$(hit POST /products '{oops')" 400 expect "malformed json is 400" "$(hit POST /products '{oops')" 400
expect "form-encoded create (201, + and %XX decoded)" \ expect "form-encoded create (201, + and %XX decoded)" \
"$(hit POST /products 'name=form+kettle&price=1250&stock=2' yes 'application/x-www-form-urlencoded; charset=UTF-8')" 201 '"name":"form kettle"' "$(hit POST /products 'name=form+kettle&price=12.50&stock=2' yes 'application/x-www-form-urlencoded; charset=UTF-8')" 201 '"name":"form kettle"'
expect "form with a non-numeric price is 400" \ expect "form with a non-numeric price is 400" \
"$(hit POST /products 'name=x&price=abc&stock=1' yes 'application/x-www-form-urlencoded')" 400 "$(hit POST /products 'name=x&price=abc&stock=1' yes 'application/x-www-form-urlencoded')" 400
MP=$'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nmp teapot\r\n--BXB\r\ncontent-disposition: form-data; name="price"\r\n\r\n700\r\n--BXB\r\ncontent-disposition: form-data; name="stock"\r\n\r\n3\r\n--BXB--\r\n' MP=$'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nmp teapot\r\n--BXB\r\ncontent-disposition: form-data; name="price"\r\n\r\n7.05\r\n--BXB\r\ncontent-disposition: form-data; name="stock"\r\n\r\n3\r\n--BXB--\r\n'
expect "multipart create (201, curl -F shape)" \ expect "multipart create (201, curl -F shape)" \
"$(hit POST /products "$MP" yes 'multipart/form-data; boundary=BXB')" 201 '"name":"mp teapot"' "$(hit POST /products "$MP" yes 'multipart/form-data; boundary=BXB')" 201 '"name":"mp teapot"'
expect "multipart without the closing marker is 400" \ expect "multipart without the closing marker is 400" \
"$(hit POST /products $'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nx\r\n' yes 'multipart/form-data; boundary=BXB')" 400 "$(hit POST /products $'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nx\r\n' yes 'multipart/form-data; boundary=BXB')" 400
expect "list shows the product" "$(hit GET /products)" 200 '"price":900' # the fractional price survives storage and comes back byte-identical
expect "list shows the fractional price" "$(hit GET /products)" 200 '"price":9.99'
expect "list shows the form price" "$(hit GET /products)" 200 '"price":12.5'
expect "show by :name capture" "$(hit GET /products/mug)" 200 '"stock":5' expect "show by :name capture" "$(hit GET /products/mug)" 200 '"stock":5'
expect "unknown product is 404" "$(hit GET /products/none)" 404 expect "unknown product is 404" "$(hit GET /products/none)" 404
expect "create order (FK)" "$(hit POST /orders '{"product":"mug","qty":2}')" 201 expect "create order (FK)" "$(hit POST /orders '{"product":"mug","qty":2}')" 201

View file

@ -0,0 +1 @@
WO-E003

View file

@ -0,0 +1,6 @@
-- a #if left open at end of file is WO-E003
fn main() -> Int {
#if portable
return 1;
}

View file

@ -0,0 +1 @@
WO-E219

View file

@ -0,0 +1,17 @@
-- pub(read): readable anywhere, writable only inside the declaring class.
-- A write from outside (here: main) is WO-E219.
class Counter {
pub(read) count: Int
label: Text
fn bump() {
self.count = self.count + 1;
}
}
fn main() -> Int {
let c = Counter { count: 0, label: "hits" };
c.count = 99;
return c.count;
}

View file

@ -0,0 +1,3 @@
pub fn label(b: Box) -> Text {
return "extension";
}

View file

@ -0,0 +1 @@
WO-E220

View file

@ -0,0 +1,16 @@
-- a using extension colliding with a real method is WO-E220 — an
-- extension never silently overrides (or loses to) a method
using ext
class Box {
n: Int
fn label() -> Text {
return "method";
}
}
fn main() -> Int {
let b = Box { n: 1 };
print(b.label());
return 0;
}

View file

@ -0,0 +1,16 @@
9
119
write
once
0
1
0
write!
d3JpdGVvbmNl
YWI=
YQ==
1
3
0
1
1

View file

@ -0,0 +1,43 @@
-- Iteration 19: Bytes, the binary carrier. Length-carrying, content-
-- comparable, no literal form — built from text or from base64, which is how
-- it will arrive once WebSocket frames and digests exist (iteration 24 and
-- the crypto fork). Text goes back to meaning text.
fn main() {
let raw = bytes_of_text("writeonce")
print_int(bytes_len(raw))
print_int(bytes_at(raw, 0))
-- slicing is clamped like substr, and produces a fresh Bytes
let head = bytes_slice(raw, 0, 5)
print(text_of_bytes(head))
print(text_of_bytes(bytes_slice(raw, 5, 99)))
print_int(bytes_len(bytes_slice(raw, 99, 3)))
-- content equality, not identity
print_int(bytes_eq(raw, bytes_of_text("writeonce")))
print_int(bytes_eq(raw, head))
-- concat
print(text_of_bytes(bytes_concat(head, bytes_of_text("!"))))
-- base64 round trip, including the two padding shapes
print(base64_encode(raw))
print(base64_encode(bytes_of_text("ab")))
print(base64_encode(bytes_of_text("a")))
let back = base64_decode("d3JpdGVvbmNl")
if back != nil {
print_int(bytes_eq(back, raw))
}
-- binary safety: a NUL byte in the middle survives, which is the whole
-- reason Text-as-bytes was a hole
let nul = base64_decode("YQBi")
if nul != nil {
print_int(bytes_len(nul))
print_int(bytes_at(nul, 1))
}
-- malformed base64 is nil, not a trap: it arrives from the network
print_int(base64_decode("!!!!") == nil)
print_int(base64_decode("abc") == nil)
}

View file

@ -0,0 +1,19 @@
9.99 0.125 20000000000.0 0.0015
0.30000000000000004
29.97
-0.009999999999999787
inf -inf nan
0
1
-0.0
1
3.5
9
-9
3.5 nan
1
1
0
-1
1
0

View file

@ -0,0 +1,54 @@
-- Iteration 19: Float, the language half. Literal forms, f64 arithmetic,
-- IEEE-quiet division (no DIV0 trap where Int would trap), the explicit
-- bridges in both directions, and the shortest-round-trip rendering that
-- interpolation and json.encode share.
fn main() {
-- literal forms: fraction, leading zero, exponent (both signs)
let price = 9.99
let tiny = 0.125
let big = 2e10
let small = 1.5e-3
print("${price} ${tiny} ${big} ${small}")
-- arithmetic is f64, not integer: 0.1 + 0.2 is famously not 0.3, and
-- printing the truth here is the point of a shortest-round-trip renderer
print("${0.1 + 0.2}")
print("${price * 3.0}")
print("${price - 10.0}")
-- IEEE quiet division: Int would trap DIV0, Float yields infinities and
-- NaN and keeps running
print("${1.0 / 0.0} ${-1.0 / 0.0} ${0.0 / 0.0}")
-- NaN is not equal to itself; that is the contract, not a bug
let nan = 0.0 / 0.0
print_int(nan == nan)
print_int(nan != nan)
-- -0.0 is reachable and distinct in its bits, while IEEE equality says
-- it equals +0.0
let negzero = -0.0
print("${negzero}")
print_int(negzero == 0.0)
-- the explicit bridges: no implicit conversion exists in either direction
let n = 7
print("${float(n) / 2.0}")
print_int(trunc(9.99))
print_int(trunc(-9.99))
-- parse_float: unparseable is NaN, which is what "not a number" means,
-- so there is no ?Float to narrow
print("${parse_float("3.5")} ${parse_float("nope")}")
-- ordering uses IEEE comparisons in the language
print_int(1.5 < 2.5)
print_int(2.5 <= 2.5)
print_int(nan < 1.0)
-- float_cmp is the TOTAL order instead: NaN sorts last, -0.0 == +0.0.
-- This is what an index and an order-by use.
print_int(float_cmp(1.0, 2.0))
print_int(float_cmp(nan, 1.0))
print_int(float_cmp(negzero, 0.0))
}

View file

@ -0,0 +1,10 @@
{"name":"mug","price":9.99,"discount":null,"blob":"aGk="}
9.99
0.25
hi
{"name":"mug","price":9.99,"discount":0.25,"blob":"aGk="}
0.0025 0
null discount read back as nil
42.0
int fraction still rejected
{"name":"inf","price":null,"discount":null,"blob":""}

View file

@ -0,0 +1,65 @@
-- Iteration 19, the forcing function: `{"price": 9.99}` used to fail the
-- whole checked decode because the language had no Float. Now a Float field
-- decodes fractions and exponents, encodes shortest-round-trip, and an Int
-- field's strictness is UNCHANGED (a fraction there is still malformed).
-- A ?Float carries its own nil sentinel, and a Bytes field crosses the JSON
-- boundary as base64.
use json
class Item {
name: Text
price: Float
discount: ?Float
blob: Bytes
}
class Counted {
n: Int
}
fn main() -> Int {
-- encode: shortest form, a nil ?Float is null, Bytes is base64
print(json.encode(Item {
name: "mug",
price: 9.99,
discount: nil,
blob: bytes_of_text("hi")
}))
-- decode a fraction into a Float field: the hole this iteration closes
let a = json.decode("{\"name\":\"mug\",\"price\":9.99,\"discount\":0.25,\"blob\":\"aGk=\"}") as Item
if a != nil {
print("${a.price}")
let d = a.discount
if d != nil { print("${d}") }
print(text_of_bytes(a.blob))
print(json.encode(a))
}
-- exponent and integer-shaped forms both land in a Float field
let b = json.decode("{\"name\":\"x\",\"price\":2.5e-3,\"discount\":null,\"blob\":\"\"}") as Item
if b != nil {
print("${b.price} ${bytes_len(b.blob)}")
-- a JSON null in a ?Float field must read back as nil, which is the whole
-- job of the reserved-NaN sentinel: the zero word would be +0.0
let bd = b.discount
if bd == nil { print("null discount read back as nil") }
}
let c = json.decode("{\"name\":\"x\",\"price\":42,\"discount\":null,\"blob\":\"\"}") as Item
if c != nil { print("${c.price}") }
-- Int strictness is untouched: a fraction in an Int field is still
-- malformed, so the checked decode fails whole
let bad = json.decode("{\"n\":3.7}") as Counted
if bad == nil { print("int fraction still rejected") }
-- a non-finite Float has no JSON literal, so it encodes as null rather
-- than as invalid JSON
print(json.encode(Item {
name: "inf",
price: 1.0 / 0.0,
discount: 0.0 / 0.0,
blob: bytes_of_text("")
}))
return 0
}

View file

@ -0,0 +1,18 @@
-2.5 a
0.25 c
1.5 b
--
1.5 b
0.25 c
-2.5 a
--
-2.5 a
-0.0 negzero
0.25 c
1.5 b
inf inf
nan nan
--
1
ZERO-SIGN-DUP-REFUSED
2

View file

@ -0,0 +1,40 @@
-- Iteration 19, the storage half: a Float is a real @table column. It is
-- stored, read back bit-exact, indexed (unique, on the TOTAL order — so
-- -0.0 and +0.0 are the same key), and order-by sorts by that total order
-- rather than by raw bits, which would put negatives backwards and drop NaN
-- wherever the comparison sequence happened to leave it.
@table(name: "readings", index: [at])
class Reading {
at: Float
label: Text
}
class Priced {
cost: Float @unique
}
fn main() {
insert Reading { at: 1.5, label: "b" }
insert Reading { at: -2.5, label: "a" }
insert Reading { at: 0.25, label: "c" }
-- ascending: -2.5 < 0.25 < 1.5. Raw-bit ordering would put -2.5 last.
for r in from x in Reading order by x.at select x { print("${r.at} ${r.label}") }
print("--")
for r in from x in Reading order by x.at desc select x { print("${r.at} ${r.label}") }
print("--")
-- edge values survive storage and come back bit-exact
insert Reading { at: 1.0 / 0.0, label: "inf" }
insert Reading { at: 0.0 / 0.0, label: "nan" }
insert Reading { at: -0.0, label: "negzero" }
for r in from x in Reading order by x.at select x { print("${r.at} ${r.label}") }
print("--")
-- a unique Float index keys on the total order: -0.0 and +0.0 are the
-- SAME key, so the second insert is refused
print_int(insert Priced { cost: -0.0 })
let dup = try insert Priced { cost: 0.0 } catch (e) nil
if dup == nil { print("ZERO-SIGN-DUP-REFUSED") }
print_int(insert Priced { cost: 0.5 })
}

View file

@ -0,0 +1,3 @@
native
neither
done

View file

@ -0,0 +1,31 @@
-- #if build flags: undefined flags are false, #else flips, nesting works.
-- The corpus runs woc with NO -D, so only the #else/undefined paths emit.
fn label() -> Text {
#if portable
return "portable";
#else
return "native";
#end
}
fn nested() -> Text {
#if outer
#if inner
return "both";
#end
return "outer only";
#else
return "neither";
#end
}
fn main() -> Int {
print(label());
print(nested());
#if debug
print("debug build");
#end
print("done");
return 0;
}

View file

@ -0,0 +1,2 @@
a=2 b=5
after reset b=0

View file

@ -0,0 +1,27 @@
-- pub(read) golden: the declaring class writes (its own instance AND a
-- sibling instance — class-owned, not instance-owned), everyone reads.
class Counter {
pub(read) count: Int
fn bump() {
self.count = self.count + 1;
}
fn reset(mut other: Counter) {
other.count = 0;
}
}
fn main() -> Int {
let a = Counter { count: 0 };
let b = Counter { count: 5 };
a.bump();
a.bump();
print("a=${a.count} b=${b.count}");
a.reset(b);
print("after reset b=${b.count}");
if a.count != 2 { return 1; }
if b.count != 0 { return 1; }
return 0;
}

View file

@ -0,0 +1,4 @@
ha!
hahaha
plain!
box untouched 7

View file

@ -0,0 +1,18 @@
-- `using` static extensions (iter 5, task 7): s.shout() rewrites to
-- shout(s) at compile time — no dispatch table, receiver borrowed like
-- any first argument. The plain call form keeps working.
using textutil
class Box {
n: Int
}
fn main() -> Int {
let s = "ha";
print(s.shout());
print(s.reps(3));
print(shout("plain"));
let b = Box { n: 7 };
print("box untouched ${b.n}");
return 0;
}

View file

@ -0,0 +1,20 @@
-- the extension module: pub free fns whose first parameter names the
-- receiver type become methods via `using textutil`
pub fn shout(s: Text) -> Text {
return "${s}!";
}
pub fn reps(s: Text, n: Int) -> Text {
let out = "";
let i = 0;
while i < n {
out = "${out}${s}";
i = i + 1;
}
return out;
}
-- not pub: never a candidate
fn hidden(s: Text) -> Text {
return s;
}