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 *)
(* 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)
~(file : string) ~(pos : Woc_lib.Ast.pos) ~(first_file : string)
~(first_pos : Woc_lib.Ast.pos) : unit =
@ -503,7 +510,48 @@ let compile_image ?(deps : (string * string) list = []) path =
deps
in
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, _) ->
let redecl = function
| Woc_lib.Ast.Use u ->
@ -518,6 +566,12 @@ let compile_image ?(deps : (string * string) list = []) path =
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 =
List.map
(fun (f, prog) ->
@ -629,54 +683,11 @@ let default_runtime_path () : string =
if Sys.file_exists sibling && not (Sys.is_directory sibling) then sibling
else "runtime/wovm"
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;
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
(* RELOCATED (iteration 17): manifest_parse used to sit below build_mode.
build_mode now reads the manifest itself, to tell "you forgot `main`"
from "this is a library" in the no-entry error, and OCaml has no
forward reference across top-level `let`s. Nothing in the block
changed; only its position did. *)
(* ---- manifest build ---------------------------------------------------
`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
@ -807,7 +818,11 @@ let manifest_parse (path : string) : (string * string) list =
let v = String.sub v 1 (String.length v - 2) in
let known =
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 *)
| "build", ("runtime" | "target") -> true
| _ -> false
@ -815,7 +830,8 @@ let manifest_parse (path : string) : (string * string) list =
if not known then
fail !lineno
(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
Printf.sprintf "unknown key `%s` in [%s]" key !section);
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);
!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 `>=`
and a bare version are interpreted; any other operator is accepted untouched
(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;
exit 2
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
manifest's directory — `woc .` inside the project and `woc path/to/it`
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)
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-ast"; path |] -> dump_ast 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
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:
- **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`
— 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.
## 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)". *)
and expr_kind =
| 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
| BoolLit of bool
(* haxe-parity Task 6: `nil`, the absent value of a `?T`. One
@ -553,6 +557,11 @@ type use_decl = {
id : int;
pos : pos;
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

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. *)
| 32 -> Printf.sprintf "TRY r%d, handler -> %04d" a target
| 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
(* ---- the dump ---- *)
@ -156,13 +166,17 @@ let ins_str (i : int) (pc : int) : string =
type kconst =
| KInt of int64
| KText of string
| KFloat of float (* iteration 19 *)
let dump (img : string) : string =
let out = Buffer.create 4096 in
let line fmt = Buffer.add_string out (fmt ^ "\n") in
if u32 img 0 <> magic then raise (Bad "bad magic");
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 koff = u32 img 16 and kcnt = u32 img 20 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);
o := !o + n
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))
done;
let kname 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
line "== CONSTANTS ==";
for i = 0 to ccnt - 1 do
match consts.(i) with
| KInt n -> line (Printf.sprintf "k%-3d INT %Ld" i n)
| 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;
(* classes *)
line "== CLASSES ==";

View file

@ -26,6 +26,9 @@ let kind_label (k : Token.kind) : string =
match k with
| Token.Ident s -> Printf.sprintf "IDENT(%s)" s
| 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.InterpStr segs ->
let part_str = function
@ -52,6 +55,7 @@ let kind_label (k : Token.kind) : string =
| Token.KwSelect -> "KW_SELECT"
| Token.KwUse -> "KW_USE"
| Token.KwSpawn -> "KW_SPAWN"
| Token.KwUsing -> "KW_USING"
| Token.KwPub -> "KW_PUB"
| Token.KwBreak -> "KW_BREAK"
| Token.KwContinue -> "KW_CONTINUE"
@ -99,6 +103,9 @@ let kind_label (k : Token.kind) : string =
| Token.PlusEq -> "PLUSEQ"
| Token.MinusEq -> "MINUSEQ"
| Token.Newline -> "NEWLINE"
| Token.HashIf -> "#if"
| Token.HashElse -> "#else"
| Token.HashEnd -> "#end"
| Token.Eof -> "EOF"
(* 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 =
match e.Ast.kind with
| 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.BoolLit b -> if b then "true" else "false"
| 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_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_none = 0xFFFFFFFF
let k_int = 0
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 classf_gc = 0x01
@ -167,6 +176,16 @@ let op_sub = 4
let op_mul = 5
let op_div = 6
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_eq = 9
let op_lt = 10
@ -241,6 +260,22 @@ let b_trim = 24
let b_to_lower = 25
let b_char_of = 26
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_ws = 29
let b_join = 30
@ -432,7 +467,12 @@ type pctx = {
(* constant pool, deduplicated *)
p_kints : (int, 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;
}
@ -446,6 +486,18 @@ let const_int (p : pctx) (v : int) : int =
p.p_nconsts <- i + 1;
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 =
match Hashtbl.find_opt p.p_ktexts s with
| Some i -> i
@ -762,6 +814,8 @@ let kind_byte : Types.wob_kind -> int = function
| Types.WO_K_TEXT -> 3
| Types.WO_K_MULTI -> 4
| 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;
the loader rejects 6). It needs none: a nullable field stores what
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 =
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
(* 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)
| "val_at" -> (
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
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";
"pop"; "shift"; "sort"; "reverse"; "remove"; "key_at"; "val_at";
(* 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) ------------------------
@ -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 =
match e.kind with
| IntLit _ -> Some (Scalar "Int")
| FloatLit _ -> Some (Scalar "Float") (* iteration 19 *)
| StrLit _ -> Some (Scalar "Text")
| BoolLit _ -> Some (Scalar "Bool")
(* 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 =
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
============================================================ *)
@ -1387,6 +1486,16 @@ let wob_field_nil_bool = 0xFFFFFFFB (* a `?Bool` field: NIL_SCALAR nil + bool en
WO_NIL_SCALAR exactly. *)
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,
so its nil has to be the sentinel rather than the zero word? *)
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 *)
| _ -> 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 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
| Ast.Nullable (Ast.Scalar "Bool") -> wob_field_nil_bool
| _ -> 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 *)
let is_place = is_borrowed_value_t p f e && not (is_container_read e) in
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
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 is_place = is_borrowed_value_t p f e && not (is_container_read e) in
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
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)
@ -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;
(match e.kind with
| 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))))
| 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
@ -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). *)
| NilLit ->
let dest = match expected with Some _ -> expected | None -> f.f_ret in
let word =
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)))
put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (nil_const_for p dest)))
(* Container literals lower to exactly what `multi_new()`/`map_new()`
lower to — the element kinds are the destination's, never guessed
(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;
(* 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) *)
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))
| Unary (Neg, o) ->
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
| Ctor (cn, fields) -> emit_ctor p f v ~dst e 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
| Scalar "Text" -> emit_expr p f v ~dst 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 ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:
(Printf.sprintf
"cannot interpolate a value of type `%s` in \"${...}\" — only Text and Int are \
supported"
"cannot interpolate a value of type `%s` in \"${...}\" — only Text, Int, and \
Float are supported"
(Dump.field_ty_str other));
put f (ins_abx op_loadk dst (const_int p 0)))
| None ->
@ -1911,11 +2048,16 @@ and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop
f.f_temp <- save
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
| Add -> simple op_add
| Sub -> simple op_sub
| Mul -> simple op_mul
| Div -> simple op_div
| Add -> simple (if fl then op_fadd else op_add)
| Sub -> simple (if fl then op_fsub else op_sub)
| Mul -> simple (if fl then op_fmul else op_mul)
| Div -> simple (if fl then op_fdiv else op_div)
| Concat ->
(* CONCAT allocates a new Text and leaves its operands untouched, so an
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);
drop_fresh_text ~keep:dst p f a l;
drop_fresh_text ~keep:dst p f b r
| Lt -> simple op_lt
| Le -> simple op_le
| Gt -> swapped op_lt
| Ge -> swapped op_le
| Lt -> simple (if fl then op_flt else op_lt)
| Le -> simple (if fl then op_fle else op_le)
| Gt -> swapped (if fl then op_flt else op_lt)
| Ge -> swapped (if fl then op_fle else op_le)
(* 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
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 ->
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
(* 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
| Ne ->
(* 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
let a = emit_operand p f v l 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"] !=
"Bearer ${key}"` abandoned both sides, once per MCP request *)
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
to the pointer-comparing op_lt (the wrong-order bug) *)
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
let kj = alloc_temp p f e.pos in
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);
f.f_temp <- save
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)
in
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
| None ->
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
in
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
(ins_abx op_loadk dst
(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,
the same rule `[]` above follows *)
| [ 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 =
is_fresh_owned_temp p f 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
let owned_heap_temp (a : Ast.expr) : bool =
(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_parse_int || id = b_split_ws || id = b_pop || id = b_shift || id = b_sort
|| 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
else if
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_last_index_of || id = b_split || id = b_join || id = b_map_remove
|| 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
else 3
else 3 (* b_bytes_slice lands here with substr's shape: (value, start, len) *)
in
let container_id first_arg on_multi on_map =
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
| "key_at" -> fixed b_map_key_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" ->
let is_map = name = "map_new" in
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 ->
fixed id;
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)
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")
@ -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 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
| 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
in
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 not (is_indexable fl) then
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);
[ (true, [| col_of fl.Ast.name |]) ]
end
@ -4419,7 +4617,7 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols)
| Some fl ->
if not (is_indexable fl) then
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))
cols)
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_module_syms = module_syms; p_module_of = module_of; p_colliding = colliding;
p_kints = Hashtbl.create 32;
p_kfloats = Hashtbl.create 16; (* iteration 19 *)
p_ktexts = Hashtbl.create 32; p_consts = []; p_nconsts = 0 }
in
(* 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 ->
Buf.u8 consts k_text;
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);
let cls = Buf.create () in
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
assertion in compiler/test/runner.ml. *)
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 = {
src : string;
@ -126,6 +135,7 @@ let keyword_kind = function
| "false" -> Some Token.KwFalse
| "use" -> Some Token.KwUse
| "spawn" -> Some Token.KwSpawn
| "using" -> Some Token.KwUsing
| "pub" -> Some Token.KwPub
| "break" -> Some Token.KwBreak
| "continue" -> Some Token.KwContinue
@ -205,6 +215,59 @@ let read_interp_expr lx =
done;
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) :
Token.t list =
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)
end
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 scanning = ref true in
while !scanning do
match peek lx with
| Some d when is_digit d ->
n := (!n * 10) + (Char.code d - Char.code '0');
Buffer.add_char buf d;
ignore (advance lx)
| _ -> scanning := false
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
else if is_ident_start c then begin
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)
done;
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
of a PLACE is still a borrow — analyze_let's own place logic decides
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 ->
if Types.is_builtin_scalar n then Copy
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 =
match e.kind with
| IntLit _ -> Some (Scalar "Int")
| FloatLit _ -> Some (Scalar "Float") (* iteration 19 *)
| StrLit _ -> Some (Scalar "Text")
| BoolLit _ -> Some (Scalar "Bool")
(* 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 =
match place_ty ctx p 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)
| None -> false
@ -1130,7 +1132,10 @@ type access = {
let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
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 _ ->
(match place_of e with Some p -> use_place ctx p | None -> ());
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 copied =
match unwrap_nullable t with
| Scalar cn -> cn = "Text" || cn = Types.json_value_type
| Scalar cn -> Types.is_heap_scalar cn
| _ -> false
in
if copied then
@ -1788,7 +1793,7 @@ and analyze_let (ctx : ctx) (s : Ast.stmt) (name : string) (ty : Ast.field_ty op
| _ -> false
in
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
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

View file

@ -1184,6 +1184,15 @@ and parse_primary (st : state) : Ast.expr =
let id = fresh_id st in
ignore (advance st);
{ 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 ->
let pos = peek_pos st in
let id = fresh_id st in
@ -1597,13 +1606,22 @@ let parse_const_literal (st : state) : Ast.expr =
| Token.Int n ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.IntLit n }
| Token.Float f ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.FloatLit f }
| Token.Dash -> (
ignore (advance st);
match peek st with
| Token.Int n ->
ignore (advance st);
{ 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 ->
ignore (advance st);
{ 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
is no enclosing block at top level, but end_of_stmt's RBrace arm is
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 id = fresh_id st in
ignore (advance st);
(* 'use' *)
(* 'use' or 'using' — `using` is a use PLUS extension registration *)
let first = expect_ident st "module name" in
let segments = ref [ first ] in
while accept st Token.Slash do
segments := expect_ident st "module path segment" :: !segments
done;
end_of_stmt st;
{ Ast.id; pos; segments = List.rev !segments }
{ Ast.id; pos; segments = List.rev !segments; is_using }
(* ---- top-level program ---------------------------------------------------
@ -1900,6 +1918,7 @@ let parse_program (st : state) : Ast.program =
| Token.KwInterface -> decls := Ast.Interface (parse_interface 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.KwUsing -> decls := Ast.Use (parse_use_decl ~is_using:true st) :: !decls
| Token.KwConst -> decls := Ast.Const (parse_const_decl st) :: !decls
| Token.KwInline ->
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 =
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 ->
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)

View file

@ -28,6 +28,12 @@ type kind =
(* literals *)
| Ident of string
| 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
(* haxe-parity Task 2: a string literal containing at least one
`${expr}` interpolation. Alternating text/expr segments, in source
@ -66,6 +72,7 @@ type kind =
(* the concurrency arc (iterations 8+11): `spawn Cls { ... }`. `send`
is deliberately NOT a keyword — it is a builtin free-fn name. *)
| KwSpawn
| KwUsing
| KwPub
(* haxe-parity Task 2 (small control surface): break/continue/do-while,
const values, and/or booleans, and inline-fn rejection (the haxe
@ -141,6 +148,12 @@ type kind =
| GtEq
| PlusEq
| 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 *)
| Newline
| Eof

View file

@ -30,7 +30,12 @@ type wob_kind =
| WO_K_TEXT (* 3 *)
| WO_K_MULTI (* 4 *)
| 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)
@ -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
| None -> None
(* Builtin scalars *)
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
(* Builtin scalars. `Float` and `Bytes` joined in iteration 19 — see
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
(* 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
stdlib namespaces, not the plan text's five — `use fs`/`proc`/`net`/
`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
is what makes it drop correctly and pass through concatenation. *)
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_builtin_scalar name then WO_K_SCALAR
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 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 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"
(* 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
| Ast.Class c ->
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
let methods = List.map (fun (m : Ast.method_decl) ->
{ name = m.name;
@ -702,6 +756,11 @@ let rec typ_equal (syms : symbols) (a : typ) (b : typ) : bool =
type builtin_arg_req =
| ReqText
| 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
| ReqMap
| 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 ]);
("key_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 =
@ -833,21 +913,31 @@ let unnarrow_env (names : string list) ~(orig : typ StringMap.t)
chasing builtin return types. `Text` stays its own, narrower case:
it is the one builtin scalar with a genuinely different
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 =
match req, unwrap_nullable t with
| ReqAny, _ -> true
| ReqText, TScalar "Text" -> true
| ReqInt, TScalar name -> is_scalar_shaped name
| ReqFloat, TScalar "Float" -> true
| ReqBytes, TScalar "Bytes" -> true
| ReqMulti, TMulti _ -> true
| ReqMap, TMap _ -> true
| ReqContainer, (TMulti _ | TMap _) -> true
| (ReqText | ReqInt | ReqMulti | ReqMap | ReqContainer), _ -> false
| (ReqText | ReqInt | ReqFloat | ReqBytes | ReqMulti | ReqMap | ReqContainer), _ -> false
let req_label = function
| ReqText -> "Text"
| ReqInt -> "Int"
| ReqFloat -> "Float" (* iteration 19 *)
| ReqBytes -> "Bytes"
| ReqMulti -> "a `multi`"
| ReqMap -> "a `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)
| "key_at" -> ( match arg0 with Some (TMap (k, _)) -> Some k | _ -> 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
(* `use_edge`/`uses_of_program`/`path_str` -- relocated here (hotfix)
@ -949,6 +1050,7 @@ type use_edge = {
ue_alias : string;
ue_segments : string list;
ue_is_stdlib : bool;
ue_is_using : bool; (* haxe-parity Task 7: `using` extension import *)
}
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 =
match u.segments with [ s ] -> is_stdlib_module s | _ -> false
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)
prog.decls
@ -1060,6 +1167,25 @@ let typecheck_program ~file ~(module_of : string -> string)
| [ fi ] -> Some fi
| _ -> None)
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
`.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 =
match e.kind with
| IntLit _ -> Some (TScalar "Int")
| FloatLit _ -> Some (TScalar "Float") (* iteration 19 *)
| StrLit _ -> Some (TScalar "Text")
| BoolLit _ -> Some (TScalar "Bool")
(* 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
fn/method's declared return type, set by each body walk below. *)
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 =
Diag.Collector.add collector
(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"
what)
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
non-nullable slot *)
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 =
match e.kind with
| IntLit _ -> { typ = TScalar "Int"; is_nil = false }
| FloatLit _ -> { typ = TScalar "Float"; is_nil = false } (* iteration 19 *)
| StrLit _ -> { typ = TScalar "Text"; is_nil = false }
| BoolLit _ -> { typ = TScalar "Bool"; is_nil = false }
| Ident name ->
@ -1412,11 +1599,25 @@ let typecheck_program ~file ~(module_of : string -> string)
| Field (base, mname) -> (
match Option.map unwrap_nullable (confident_typ cenv base) with
| 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
| Some cls -> (
match List.find_opt (fun (m : method_info) -> m.name = mname) cls.methods with
| Some m -> Some m.params
| None -> None)
| Some m ->
(match cands with
| _ :: _ -> e220 callee.pos mname cn
| [] -> ());
Some m.params
| None -> (
match cands with [ fi ] -> record_rewrite fi | _ -> None))
| None -> (
match StringMap.find_opt cn syms.interfaces with
| 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
with
| 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
| Ident head -> (
@ -1604,6 +1807,9 @@ let typecheck_program ~file ~(module_of : string -> string)
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 }
| Binary (((Add | Sub | Mul | Div | Mod) as op), left, right) ->
let lres = typecheck_expr env cenv left in
@ -1635,13 +1841,33 @@ let typecheck_program ~file ~(module_of : string -> string)
| 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");
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 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 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 }
| Binary (_, left, right) ->
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
below uses — per the review's own instruction ("wire through
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
| WO_K_TEXT -> `Text
| 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
in
(* 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
| Some cls -> (
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)
@ -2388,8 +2628,10 @@ let typecheck_program ~file ~(module_of : string -> string)
StringMap.add name (resolve_field_ty ty) acc)
(StringMap.singleton "self" (TScalar self_class)) m.params in
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
current_ret := None;
current_self := None;
false
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 =
visit bound e;
match e.kind with
| IntLit _ | StrLit _ | BoolLit _ | Ident _ -> ()
| IntLit _ | FloatLit _ | StrLit _ | BoolLit _ | Ident _ -> ()
| Field (b, _) -> walk_expr bound visit b
| Index (b, i) ->
walk_expr bound visit b;
@ -2885,3 +3127,84 @@ let dump_symbols (syms : symbols) : string =
) syms.free_fns [] in
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)
let () =
(* Money/SKU/Float carry no special status -- all three are ordinary
unknown types now (WO-E225 fires on them as fields). Float went for
the same phantom-scalar reason Money/SKU did: no float-literal syntax
in the lexer and no float kind in wob, so no Float value could ever
be written or represented. Timestamp stays a real builtin. *)
(* Money/SKU carry no special status -- ordinary unknown types (WO-E225
fires on them as fields). Float was removed for the same phantom-scalar
reason once, and iteration 19 EARNED IT BACK: there is float-literal
syntax in the lexer, a WO_K_FLOAT kind in wob, f64 opcodes in the VM, and
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 "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")
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 none = 0xFFFFFFFF in
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 koff = u32 16 and kcnt = u32 20 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
incr o;
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
let n = u32 !o in
o := !o + 4;
@ -2310,7 +2320,8 @@ let validate_image (img : string) : string list =
class_fields.(i) <- fcnt;
let kco = !o in (* the kind bytes' offset: the v3 index walk re-reads them *)
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;
o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4);
(* 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
fail (Printf.sprintf "class %d field %d: bad name constant" i j);
let fc = u32 (!o + ((fcnt + j) * 4)) in
if fc <> 0xFFFFFFFF && fc <> 0xFFFFFFFE && fc >= kcnt then
fail (Printf.sprintf "class %d field %d: field class out of range" i j)
(* NONE, JSON_RAW, NIL_SCALAR, BOOL, NIL_BOOL, and (iteration 19)
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;
o := !o + (fcnt * 12);
(* 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;
if col >= fcnt then fail (Printf.sprintf "class %d index %d: column out of range" i x);
let kind = u8 (kco + col) in
if kind <> 0 && kind <> 3 then
fail (Printf.sprintf "class %d index %d: column %d is not scalar or Text" i x c)
(* iteration 19: FLOAT (6) is indexable — the engine orders it by the
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;
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:
the class-id slot — field slots are runtime-validated, same as
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)
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
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)
end
else begin
@ -2516,6 +2536,11 @@ let validate_image (img : string) : string list =
| 65 -> 3
| 66 -> 3
| 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
in
if arity > 0 then begin
@ -2524,6 +2549,15 @@ let validate_image (img : string) : string list =
end
end
| 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))
code;
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) {
if (!v) return;
switch (kind) {
case WO_K_SCALAR: return;
case WO_K_TEXT: free((db_text *)(uintptr_t)v); return;
/* iteration 19: FLOAT is a word in the slot, nothing to free. BYTES is
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_MULTI: {
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) {
*ok = 1;
switch (kind) {
case WO_K_SCALAR: return v;
case WO_K_TEXT: {
/* iteration 19: the f64's bits go in the slot unexamined. NaN, the
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;
const wo_str *s = (const wo_str *)(uintptr_t)v;
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) {
*ok = 1;
switch (kind) {
case WO_K_SCALAR: return v;
case WO_K_TEXT: {
case WO_K_SCALAR:
case WO_K_FLOAT: return v; /* iteration 19: bits back out unchanged */
case WO_K_TEXT:
case WO_K_BYTES: {
if (!v) return 0;
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;
return (uint64_t)(uintptr_t)s;
}
@ -268,6 +284,20 @@ static void hdel(db_table *t, uint64_t id) {
/* ---- 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 */
static uint64_t idx_hash(const wo_classdesc *c, const db_index *ix, const db_row *r) {
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;
v = th;
}
else if (c->kinds[col] == WO_K_FLOAT)
v = idx_float_key(v); /* iteration 19 */
h ^= hmix(v + i);
}
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;
} else if (x->len != y->len || memcmp(x->bytes, y->bytes, x->len) != 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])
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) {
switch (kind) {
case WO_K_SCALAR: wput_u64(w, v); return;
case WO_K_TEXT: {
/* iteration 19: a Float is one word on the wire, its raw IEEE bits — no
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) {
wput_u32(w, WAL_NIL_TEXT);
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) {
*out = 0;
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);
if (r->bad) return -1;
*out = v;
return 0;
}
case WO_K_TEXT: {
case WO_K_TEXT:
case WO_K_BYTES: {
uint32_t len = rd_u32(r);
if (r->bad) return -1;
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
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).
- 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.
- 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.
## 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) | ✅ |
| 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) |
| 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) |
| 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 |
| 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 |
| 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 |
| 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 |
@ -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 |
| 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 |
| 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 |
| 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 |
| 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/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 |
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| 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
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
### 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
line leads and the workload-driven extras (14, 27) demote behind it.
Dependency rules that force the shape: 23 explicitly after 8 + 22; 20
"precedes iteration 25"; 21's plan folds into 20's; 12 only after 9 + 10
(catalog to diff, HTTP to build on); 11 rides 8's shard scheduler; h2c
parked behind 8/23/11; the post-12 parked list stays parked by the
2026-08-08 scope directive. (Iteration 7 dropped from this list — landed
2026-08-15.)
Replaces the framework-goal ordering. Basis: the verified findings in
[`00-code-review.md`](00-code-review.md) — measure before optimizing, close
correctness holes before adding surface, stop stacking features on
unmeasured ground. IDs below are post-renumber; the authoritative table with
per-row reasoning is
[`00-story.md`](stories/language-runtime-database/00-story.md).
1. ~~**17**~~ — **PARKED 2026-08-20** (developer directive: framework v1
first); spec + plan approved, ready on branch `library-internal` for
whenever it unparks — slots anywhere after 16. The framework v1 slices
took its place and landed the same day (branch framework-v1,
`just web-app` 21/0).
2. **18** — framework v2 (transaction{} + cache/flags/jobs); spec
APPROVED 2026-08-20, the only all-green spec-approved node in the
[dependency graph](00-dependency-graph.md) — plan next, then
implement.
3. **20 then 21** — finish the half-done branches (ipc-attach: manifest +
binding; keypair-auth: manifest) before they rot; 21 folds into 20's
plan; both must precede 10.
4. **22** — the measurement backbone; baseline single-shard BEFORE the
runtime restructure so 8/23/11 sign against real numbers.
5. **8** — shard-actor; the framework's multi-core serving story; unblocks
23, 11, h2c.
6. **23** — io_uring group-commit; explicitly after 8 + 22.
7. **11** — fibers; retires the framework's disclosed keep-alive limit (an
idle connection starving accept forced close-when-idle in iteration 16 —
parked fds fix it properly); with 8 + 23 done, **h2c unparks** (spec §C)
as the framework's HTTP/2 slice.
8. **10** — HTTP service layer; after 20 by its own precedence note;
`service` blocks lower onto the framework instead of a parallel stack.
9. **12** — blue-green; prerequisites 9 + 10 now exist; completes the
framework's deploy story.
10. **27 then 14** — demoted with the goal shift: skillhost (28) is no longer the
driving workload; 27 likely collapses to "confirm `len(query)` + add
`exists`" and precedes 28 when they run.
11. **13 + parked drain** — metaprogramming (spec first), then group-by,
`pub(read)`/`using`/`#if`, ADT roster, WO-E225 — held behind 26 by the
scope directive.
Dependency rules that still force the shape: 23 explicitly after 8 + 22;
21's plan folds into 20's; 26 only after 9 + 25; 11 rides 8's shard
scheduler; h2c parked behind 8/23/11.
1. **22** — the measurement backbone, and now first: it has never run, so
every performance claim on this project is unsourced. No
`bench/baseline.json`, no `just db-bench`; `runtime/bench/` is the
retired C prototype's harness.
2. **8+11 stage 3** — transparent DB RPC, then 22 re-run for the
concurrency delta. Reframed as a correctness fix: worker VMs are
zero-initialized, so a DB statement off the primary traps `WO_T_DB`.
A multi-shard program that touches the database is broken today.
3. **30** (new) — observability, CI, fuzz: runtime counters + a profiler
hook, 22's harness run per change instead of by hand, a fuzz target on
the parser and `.wob` loader. No iteration covered any of this.
4. **19** — Float + Bytes; small, and it gates 24 (WS frames) and the
crypto fork (digests).
5. **31** (new) — actor lifecycle: request/response (`send` is one-way and
callers `sleep` to await), bounded mailboxes (the FIFO only grows),
actor death/supervision, timers beyond `time.sleep`.
6. **24** — chat, the arc's acceptance; honest only after 19 + 31.
7. **23** — io_uring group-commit; explicitly after 8 + 22.
8. **25** — HTTP service layer; `service` blocks lower onto the framework
instead of a parallel stack.
9. **18** — framework v2 (transaction{} + cache/flags/jobs); spec APPROVED
2026-08-20 but **demoted from first**: more surface on a framework with
one consumer, and its cache stores `Text` because there are no generics.
10. **27, then 26** — query grammar from corpora (likely collapses to
"confirm `len(query)` + add `exists`"), then blue-green.
11. **20 then 21** — demoted hard: new distribution surface while there is
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
@ -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 |
| 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 |
| 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) |
| 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 |

View file

@ -9,10 +9,10 @@ use framework/http
use framework/router
-- 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 }
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 });
}
@ -44,7 +44,7 @@ class ShowProduct {
-- Accepts THREE bodies: multipart/form-data (curl -F), a form post
-- (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 }
catch (e) nil;
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"); }
let sstr = part_named(ps, "stock");
if sstr == nil { return bad_request("multipart needs name, price, stock"); }
let price = parse_int(pstr);
if price == nil { return bad_request("price must be a number"); }
-- parse_float answers NaN for unparseable input rather than a ?Float:
-- "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);
if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock);
@ -78,8 +80,8 @@ class CreateProduct {
if ps == nil { return bad_request("form needs name, price, stock"); }
let ss = f["stock"];
if ss == nil { return bad_request("form needs name, price, stock"); }
let price = parse_int(ps);
if price == nil { return bad_request("price must be a number"); }
let price = parse_float(ps);
if price != price { return bad_request("price must be a number"); }
let stock = parse_int(ss);
if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock);

View file

@ -6,7 +6,10 @@
@table(name: "products", index: [name])
class Product {
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
orders: backlink Order.product

View file

@ -10,7 +10,7 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
## 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
blocking serve loop that answers 400 to malformed requests, 500 to
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 |
| 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
`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
-- table, satisfies http's Dispatcher interface, and serves.
-- table, satisfies internal's Dispatcher interface, and serves.
--
-- let app = App { middleware: [], routes: [] };
-- app.use_mw(Mw { m: Auth { token: t } });
@ -12,6 +12,9 @@
-- and the server survives.
use http
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 {
middleware: multi Mw
@ -67,6 +70,6 @@ pub class App {
}
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
-- terminator, then the body to exactly Content-Length. Keep-alive means
@ -10,6 +16,7 @@
-- ok=false malformed request — answer 400 and close
-- ok=true, req non-nil one complete request
use net
use http -- Req lives in the public module now
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 ""
fn parse_query(qs: Text) -> map<Text, Text> {
pub fn parse_query(qs: Text) -> map<Text, Text> {
let q: map<Text, Text> = {};
if qs == "" { return q; }
for pair in split(qs, "&") {
@ -71,25 +78,6 @@ fn parse_query(qs: Text) -> map<Text, Text> {
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 {
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
-- 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
@ -7,6 +7,10 @@
-- in a blocking call already unwinds cleanly — the runtime's stop story).
use net
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 {
fn dispatch(mut req: Req) -> Resp

View file

@ -1,10 +1,12 @@
name = "writeonce-framework"
kind = "library"
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)"
[runtime]
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
# `use writeonce-framework` / `use writeonce-framework/http` / `.../router`.

View file

@ -1,6 +1,6 @@
# 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.
>

View file

@ -11,17 +11,17 @@
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:
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.
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.
@ -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 |
| 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 |
| 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).
@ -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).
## 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)
`.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
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 |
| --- | --- | --- | --- |
| 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 |
| 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 |
| 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 |
| 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 |
@ -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 |
| 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]` |
| 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 |
| 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 |
| 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)* |
| 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)* |
| 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)* |
| 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)* |
| 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 | 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 | 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 | 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 | 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 | 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)* |
| 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)* |
| 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)* |
| 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)* |
| 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)* |
| 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)* |
| 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)* |
| ⏸ | 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 |
| 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 | 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 | 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 | 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 | 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)* |
| 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;

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)
> 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`,
> interpolation, `const`, loop control, switch expressions, typedef records,
> enum payloads, try/catch, `nil`/`?T`, statics, `pub(read)` syntax, container
> literals, `for k, v in m`, and `as` — which is what let the driving workload
> compile. The *strictness* half (`?T` forced handling `WO-E211`–`E213`,
> `pub(read)` write enforcement, `using`, `#if`, reject-row diagnostics) is
> **deliberately deferred** behind
> [`plan/compiler/2026-08-14-logwatcher-executable.md`](../../plan/compiler/2026-08-14-logwatcher-executable.md):
> it makes the language refuse more, not the program run. Plan 8 stays open
> for it.
> **Status: ✅ COMPLETE 2026-08-20.** The *grammar* half landed 2026-08-14 —
> modules, `and`/`or`, interpolation, `const`, loop control, switch
> expressions, typedef records, enum payloads, try/catch, `nil`/`?T`,
> statics, `pub(read)` syntax, container literals, `for k, v in m`, `as`.
> The *strictness* half landed in three waves: `?T` forced handling
> (WO-E211/212/213 + narrowing) and reject rows (WO-E105) on 2026-08-18;
> the final three on 2026-08-20 (branch `language-surface-strictness`,
> developer directive overriding the post-12 park) — **`pub(read)` write
> enforcement** (WO-E219, class-owned writes, corpus-pinned),
> **`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

View file

@ -1,7 +1,7 @@
# Iteration 17 — library projects and dependency privacy (`kind`, `internal/`)
> 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
> as an iteration, do not implement yet).
@ -10,10 +10,36 @@
> changed). See "Settled decisions" and "Impact analysis" below.
>
> **⏸ PARKED 2026-08-20** (developer directive: framework v1 work first).
> The spec and plan were written and approved before parking; both sit ready
> on branch `library-internal`
> The spec and plan were written and approved before parking
> (`docs/superpowers/specs/2026-08-20-library-kind-internal-design.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

View file

@ -1,11 +1,31 @@
# Iteration 19 — the missing scalar types: Float and Bytes
> 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 below). Next: spec, then plan — a new storage kind touches
> 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

View file

@ -2,12 +2,12 @@
> **Scope label (2026-08-20): this iteration is FRAMEWORK V2.** Framework
> 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
> alongside v2 as slices, per the ledger.
> 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
> 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
> [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
@ -42,10 +63,13 @@
as cleanly as trapped ones. (Iteration 26's blue-green drain reuses
exactly this unwind path.)
- What to achieve?
- **Given** `@gc` objects referenced only from a parked fiber's frames,
- **when** the per-shard cycle collector scans,
- **then** fiber stacks are roots — nothing live is collected, nothing
dead survives.
- **Given** TRACED objects (GC-ness inferred since 7b) referenced only
from a parked fiber's frames,
- **when** the per-shard mark-sweep collector scans,
- **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
@ -66,9 +90,11 @@
matches the stdlib posture).
- Vision origin: [blue-green vision §3](../../../plan/exploration/blue-green-vm/00-vision.md);
iteration 8's scheduler is the substrate this extends.
- Open questions to settle in the spec: spawn surface (handle vs actor
address), budget size and check granularity, parked-fiber drop
semantics, run-queue fairness (FIFO v1).
- Open questions to settle in the spec — REDUCED 2026-08-20: the spawn
surface is settled (the unified actor address, iteration 8 decision 2).
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

View file

@ -10,7 +10,27 @@
> would optimize a number nobody had measured, against a runtime that
> 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

View file

@ -1,8 +1,12 @@
# Iteration 17 — library kind + `internal/`: implementation plan
> **Status: ⏸ PARKED 2026-08-20** (developer directive: framework v1 work
> proceeds instead; this plan stays ready on branch `library-internal`,
> execution not started). Board: [docs/00-status.md](../../00-status.md).
> **Status: ✅ LANDED 2026-08-20** — executed in full, all six tasks. Was
> parked the same day (developer directive: framework v1 work first), then
> 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
> 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)
(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

View file

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

View file

@ -1,5 +1,10 @@
# 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-build:
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 +
receive's method index, compile-time constant); send = BUILTIN 69 (the
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 <stdio.h>
#include <stdlib.h> /* strtod: the round-trip check in wo_float_text */
#include <string.h>
#include <time.h>
@ -25,6 +26,106 @@ static void *native_check(uint64_t v, uint32_t cls, const char **msg) {
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 ------------------------------------------
* 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
@ -274,9 +375,21 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = 0;
return 0;
}
const wo_str *src = native_check(R[B], WO_CLS_STR, msg);
if (!src) return WO_T_BOUNDS;
wo_str *cp = wo_str_new(rt, src->data, src->len);
/* iteration 19: a copy PRESERVES the kind. Bytes is a wo_str with a
* different class id, and every ownership boundary that copies a Text
* (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) {
*msg = "out of memory";
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;
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 */
/* the tag IS the header's class_id (wob.h's convention). Null and
* 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. */
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 */

View file

@ -81,6 +81,7 @@ void wo_drop_obj(wo_rt *rt, wo_hdr *o) {
if (!o) return;
switch (o->class_id) {
case WO_CLS_STR:
case WO_CLS_BYTES: /* iteration 19: same object shape, same free */
wo_str_free(rt, (wo_str *)o);
return;
case WO_CLS_MULTI:
@ -177,6 +178,7 @@ void wo_gc_scan_root(wo_rt *rt, wo_hdr *o) {
}
switch (o->class_id) {
case WO_CLS_STR:
case WO_CLS_BYTES: /* iteration 19: leaf bytes, nothing to scan */
return;
case WO_CLS_MULTI: {
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);
}
/* 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
* other byte passes through, so UTF-8 stays UTF-8. */
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) {
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);
return;
}
@ -136,6 +169,38 @@ static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, u
else
jb_int(b, (int64_t)v);
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: {
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 */
@ -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
`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. */
for (uint32_t i = 0; i < c->field_cnt; i++)
if (c->field_class && (c->field_class[i] == WOB_FIELD_NIL_SCALAR ||
c->field_class[i] == WOB_FIELD_NIL_BOOL))
for (uint32_t i = 0; i < c->field_cnt; i++) {
if (!c->field_class) break;
if (c->field_class[i] == WOB_FIELD_NIL_SCALAR || c->field_class[i] == WOB_FIELD_NIL_BOOL)
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);
if (j->p >= j->end || *j->p != '{') {
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;
if (c == 'n') { /* null: this field's own nil word */
uint64_t nilw =
(fclass == WOB_FIELD_NIL_SCALAR || fclass == WOB_FIELD_NIL_BOOL)
? WO_NIL_SCALAR
: 0;
uint64_t nilw = fclass == WOB_FIELD_NIL_FLOAT ? WO_NIL_FLOAT /* iteration 19 */
: (fclass == WOB_FIELD_NIL_SCALAR || fclass == WOB_FIELD_NIL_BOOL)
? WO_NIL_SCALAR
: 0;
return jskip_value(j) == 0 ? (*out = nilw, 0) : -1;
}
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;
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 */
*out = 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;
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;
if (*j->p == '-') {

View file

@ -76,6 +76,12 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
id to build */
[WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, [WO_B_MAP_GET_OPT] = 2,
[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) {
@ -142,6 +148,16 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
c.off += l;
m->consts[i].tag = tag;
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 {
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);
uint32_t fc = m->metapool[meta_pool + fcnt + j];
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);
}
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 (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];
if (kind != WO_K_SCALAR && kind != WO_K_TEXT)
BAIL("class %u index %u: column %u is not scalar or Text", (unsigned)i,
(unsigned)x, (unsigned)col);
/* iteration 19: FLOAT joins the indexable kinds — the engine
orders it by WO_B_FLOAT_CMP's total order (NaN last), which
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;
}
}
@ -397,6 +418,7 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
break;
case WOP_MOVE:
case WOP_NEG:
case WOP_FNEG: /* iteration 19 */
RCHK(A);
RCHK(B);
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_LE:
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(B);
RCHK(C);

View file

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

View file

@ -35,18 +35,30 @@ static _Thread_local wo_vm *tls_vm = NULL;
wo_vm *wo_tls_vm(void) { return tls_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) */
static void inbox_push(wo_vm *to, wo_envelope *e) {
pthread_mutex_t *mu = (pthread_mutex_t *)to->in_mu;
pthread_mutex_lock(mu);
static void inbox_push_to(uint32_t shard, wo_envelope *e) {
wo_inbox *ib = &INBOX[shard % WO_ENG_MAX_SHARDS];
pthread_mutex_lock(&ib->mu);
e->next = NULL;
if (to->in_tail) to->in_tail->next = e;
else to->in_head = e;
to->in_tail = e;
/* capture the wake fd UNDER the lock: worker_late_init rewrites the
* whole vm under this same mutex (TSan caught the unlocked read) */
int efd = to->wake_efd;
pthread_mutex_unlock(mu);
if (ib->tail) ib->tail->next = e;
else ib->head = e;
ib->tail = e;
int efd = ib->efd;
pthread_mutex_unlock(&ib->mu);
if (efd >= 0) {
uint64_t one = 1;
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,
* execute home-routed frees. Returns how many envelopes were handled. */
static int wo_vm_adopt(wo_vm *vm) {
pthread_mutex_lock((pthread_mutex_t *)vm->in_mu);
wo_envelope *e = vm->in_head;
vm->in_head = vm->in_tail = NULL;
pthread_mutex_unlock((pthread_mutex_t *)vm->in_mu);
wo_inbox *ib = &INBOX[vm->shard_id % WO_ENG_MAX_SHARDS];
pthread_mutex_lock(&ib->mu);
wo_envelope *e = ib->head;
ib->head = ib->tail = NULL;
pthread_mutex_unlock(&ib->mu);
int n = 0;
while (e) {
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) */
void wo_route_free(wo_hdr *h) {
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);
if (!e) return; /* OOM on the free path: leak rather than crash */
e->kind = 2;
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
@ -110,28 +122,20 @@ static size_t eng_heap_cap = 0;
* first envelope, not at boot (20 idle shards must stay ~free) */
static int worker_late_init(wo_vm *vm) {
if (vm->rt.arena.base) return 0;
/* under the inbox mutex: wo_vm_init memsets the whole vm, and a
* concurrent inbox_push would race the in_head/in_tail wipe (TSan
* caught exactly this). The mutex OBJECT is malloc'd and stable;
* pushers block on it while the fields are rebuilt. */
void *mu = vm->in_mu;
pthread_mutex_lock((pthread_mutex_t *)mu);
/* the memset here is now HARMLESS to senders: every field they touch
* lives in the engine-owned INBOX array, never in the vm (the second
* TSan/ASan round found them reading through this wipe's zero window) */
const wo_module *mod = vm->mod;
uint32_t id = vm->shard_id;
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);
if (rc == 0) {
vm->shard_id = id;
vm->rt.shard_id = (uint16_t)id;
vm->is_primary = 0;
vm->wake_efd = efd;
vm->in_mu = mu;
vm->in_head = h;
vm->in_tail = t;
tls_vm = vm;
}
pthread_mutex_unlock((pthread_mutex_t *)mu);
return rc;
}
@ -174,6 +178,18 @@ static void *shard_main(void *arg) {
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) {
wo_eng.nshards = nshards;
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->wake_efd = eventfd(0, EFD_NONBLOCK);
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)
return -1;
{
wo_inbox *ib = &INBOX[i % WO_ENG_MAX_SHARDS];
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;
}
(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
* can lock a freed mutex (the ASan SEGV this replaces). */
eng_teardown = 1;
for (uint32_t i = 0; i < wo_eng.nshards; i++) {
wo_vm *sv = &wo_eng.shards[i];
if (!sv->in_mu) continue;
wo_envelope *e = sv->in_head;
sv->in_head = sv->in_tail = NULL;
for (uint32_t i = 0; i < wo_eng.nshards && i < WO_ENG_MAX_SHARDS; i++) {
if (!INBOX_READY[i]) continue;
wo_inbox *ib = &INBOX[i];
wo_envelope *e = ib->head;
ib->head = ib->tail = NULL;
ib->efd = -1;
while (e) {
wo_envelope *nx = e->next;
if (e->kind == 1 && e->actor) {
@ -238,27 +261,11 @@ void wo_engine_stop(void) {
close(wo_eng.shards[i].wake_efd);
if (wo_eng.shards[i].rt.arena.base) /* lazily init'ed only */
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];
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) {
close(pv->wake_efd);
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->actor = a;
inbox_push(&wo_eng.shards[home], e);
inbox_push_to(home, e);
}
*out_addr = (uint64_t)(uintptr_t)a;
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->actor = a;
e->payload = msg_val;
inbox_push(&wo_eng.shards[a->home], e);
inbox_push_to(a->home, e);
return 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). */
#define NEXT_RUNNABLE() \
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); \
while (!vm->cur) { \
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; \
} \
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->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_DB_STUB] = &&L_DB_STUB, [WOP_TRAP] = &&L_TRAP,
[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 NEXT() \
@ -867,8 +879,11 @@ dispatch:
CASE(LOADK) : {
const wo_const *k = &mod->consts[wo_ins_bx(ins)];
R[wo_ins_a(ins)] = k->tag == WOB_K_INT ? (uint64_t)k->i
: (uint64_t)(uintptr_t)k->s;
/* WOB_K_TEXT is the only pointer-shaped constant; INT and (iteration
* 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();
}
@ -920,6 +935,50 @@ dispatch:
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) : {
if (wo_ins_sbx(ins) < 0) {
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]).
* 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_primary_inbox(int wake_efd);
void wo_engine_stop(void);
/* Spawn a fiber that will run method_idx(args) — the runtime half the

View file

@ -9,11 +9,15 @@
#include <stddef.h>
#include <stdint.h>
#include <string.h> /* memcpy: the f64 <-> u64 bitcast (iteration 19) */
/* ---- file header (44 bytes, absolute offsets) ---- */
#define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */
#define WOB_VERSION 4u /* 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_VERSION 5u /* v5 (iteration 19): the two missing scalars. New
* 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_OFF_MAGIC 0u
#define WOB_OFF_VERSION 4u
@ -56,6 +60,11 @@
* (nil spelled WO_NIL_SCALAR, exactly like NIL_SCALAR, plus bool encoding). */
#define WOB_FIELD_BOOL 0xFFFFFFFCu
#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
* 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. */
#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 ---- */
#define WOB_K_INT 0u /* tag byte, then i64 */
#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) ---- */
enum {
@ -75,8 +100,16 @@ enum {
WO_K_TEXT = 3,
WO_K_MULTI = 4,
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 ---- */
#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_MAP 0xFFFFFFFDu
#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 */
#define WO_BORROW_FREE 0u
@ -193,8 +232,23 @@ enum {
* today's surface. */
WOP_TRY = 32, /* A sBx: push catch frame, handler at pc + sBx */
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) ---- */
enum {
@ -341,9 +395,44 @@ enum {
* order-by on a Text key; scalars use the LT opcode) */
WO_B_SPAWN = 68, /* (instance, receive_method_idx) -> actor address (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 */
#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 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 ---- */
typedef struct wo_classdesc {
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) {
snprintf(g_dir, sizeof g_dir, "/tmp/wo-wal-test-XXXXXX");
if (!mkdtemp(g_dir)) return 1;
test_roundtrip_replay();
test_torn_tail();
test_float_bytes_replay();
test_crash_battery();
/* leave the dir for a failed run's forensics only */
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
# docs/examples/writeonce-framework, its file:// URL is substituted into a
# 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.
set -uo pipefail
@ -49,6 +50,37 @@ else
exit 1
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"
WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >"$W/srv.out" 2>&1 &
SRV=$!
@ -105,19 +137,27 @@ PYEOF
[[ "$wt" == "401" ]] && ok "401 on a wrong bearer token (ct_eq)" \
|| bad "wrong-token" "got $wt"
expect "empty list" "$(hit GET /products)" 200 "[]"
expect "create product (201)" "$(hit POST /products '{"name":"mug","price":900,"stock":5}')" 201 '"name":"mug"'
expect "duplicate name is 409 (@unique)" "$(hit POST /products '{"name":"mug","price":1,"stock":1}')" 409
# iteration 19: a REAL decimal price. `{"price": 9.99}` is the acceptance
# 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 "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" \
"$(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)" \
"$(hit POST /products "$MP" yes 'multipart/form-data; boundary=BXB')" 201 '"name":"mp teapot"'
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
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 "unknown product is 404" "$(hit GET /products/none)" 404
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;
}