From d24c705860322955a421867a5c43b4205a59749c Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Thu, 20 Aug 2026 19:23:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20iterations=2019=20+=2017=20=E2=80=94=20?= =?UTF-8?q?Float/Bytes=20scalars=20(.wob=20v5),=20library=20kind=20+=20int?= =?UTF-8?q?ernal/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- compiler/bin/main.ml | 224 ++++++++--- compiler/src/CODE-LOGIC.md | 89 +++++ compiler/src/ast.ml | 9 + compiler/src/disasm.ml | 30 +- compiler/src/dump.ml | 11 + compiler/src/emit.ml | 269 +++++++++++-- compiler/src/lexer.ml | 150 +++++++- compiler/src/owner.ml | 15 +- compiler/src/parser.ml | 29 +- compiler/src/token.ml | 13 + compiler/src/types.ml | 355 +++++++++++++++++- compiler/test/runner.ml | 66 +++- database/src/table.c | 49 ++- database/src/wal.c | 17 +- docs/00-code-review.md | 74 ++++ docs/00-link-audit.md | 154 ++++++++ docs/00-status.md | 98 ++--- docs/examples/web-app/main.wo | 16 +- docs/examples/web-app/types.wo | 5 +- docs/examples/writeonce-framework/README.md | 26 +- docs/examples/writeonce-framework/app.wo | 7 +- .../examples/writeonce-framework/http/form.wo | 28 ++ .../{http => internal}/parse.wo | 30 +- .../{http => internal}/serve.wo | 6 +- docs/examples/writeonce-framework/wo.toml | 4 +- .../2026-08-01-haxe-parity-language.md | 2 +- docs/plan/oop-vm/00-wob-format.md | 76 +++- .../language-runtime-database/00-story.md | 50 ++- .../08-shard-actor-runtime.md | 58 --- .../{ => done}/05-language-surface.md | 26 +- .../17-library-projects-internal.md | 32 +- .../{ => done}/19-missing-scalar-types.md | 22 +- .../{ => hold}/18-memory-db-features.md | 4 +- .../refine/08-shard-actor-runtime.md | 110 ++++++ .../refine/11-fibers.md | 42 ++- .../refine/23-io-uring-commit.md | 22 +- .../plans/2026-08-20-library-kind-internal.md | 12 +- ...2026-08-20-library-kind-internal-design.md | 9 +- .../2026-08-20-memory-db-features-design.md | 2 +- justfile | 5 + runtime/src/CODE-LOGIC.md | 43 +++ runtime/src/builtin.c | 308 ++++++++++++++- runtime/src/builtin.h | 22 ++ runtime/src/gc.c | 2 + runtime/src/json.c | 140 ++++++- runtime/src/loader.c | 40 +- runtime/src/main.c | 4 +- runtime/src/obj.c | 17 + runtime/src/obj.h | 7 + runtime/src/vm.c | 177 ++++++--- runtime/src/vm.h | 1 + runtime/src/wob.h | 130 ++++++- runtime/test/test_wal.c | 60 +++ scripts/linkcheck.py | 111 ++++++ scripts/web-app-accept.sh | 52 ++- .../ifdef-unterminated/fixture.code | 1 + .../ifdef-unterminated/fixture.wo | 6 + .../pub-read-external-write/fixture.code | 1 + .../pub-read-external-write/fixture.wo | 17 + .../compile-fail/using-collision/ext/fns.wo | 3 + .../compile-fail/using-collision/fixture.code | 1 + .../compile-fail/using-collision/fixture.wo | 16 + tests/corpus/run/bytes-carrier/fixture.out | 16 + tests/corpus/run/bytes-carrier/fixture.wo | 43 +++ tests/corpus/run/float-arithmetic/fixture.out | 19 + tests/corpus/run/float-arithmetic/fixture.wo | 54 +++ .../corpus/run/float-json-storage/fixture.out | 10 + .../corpus/run/float-json-storage/fixture.wo | 65 ++++ .../corpus/run/float-table-column/fixture.out | 18 + .../corpus/run/float-table-column/fixture.wo | 40 ++ tests/corpus/run/ifdef-flags/fixture.out | 3 + tests/corpus/run/ifdef-flags/fixture.wo | 31 ++ .../corpus/run/pub-read-accessor/fixture.out | 2 + tests/corpus/run/pub-read-accessor/fixture.wo | 27 ++ tests/corpus/run/using-extension/fixture.out | 4 + tests/corpus/run/using-extension/fixture.wo | 18 + .../run/using-extension/textutil/util.wo | 20 + 77 files changed, 3352 insertions(+), 423 deletions(-) create mode 100644 docs/00-link-audit.md create mode 100644 docs/examples/writeonce-framework/http/form.wo rename docs/examples/writeonce-framework/{http => internal}/parse.wo (83%) rename docs/examples/writeonce-framework/{http => internal}/serve.wo (92%) delete mode 100644 docs/stories/language-runtime-database/08-shard-actor-runtime.md rename docs/stories/language-runtime-database/{ => done}/05-language-surface.md (69%) rename docs/stories/language-runtime-database/{ => done}/17-library-projects-internal.md (85%) rename docs/stories/language-runtime-database/{ => done}/19-missing-scalar-types.md (79%) rename docs/stories/language-runtime-database/{ => hold}/18-memory-db-features.md (98%) create mode 100644 docs/stories/language-runtime-database/refine/08-shard-actor-runtime.md create mode 100644 scripts/linkcheck.py create mode 100644 tests/corpus/compile-fail/ifdef-unterminated/fixture.code create mode 100644 tests/corpus/compile-fail/ifdef-unterminated/fixture.wo create mode 100644 tests/corpus/compile-fail/pub-read-external-write/fixture.code create mode 100644 tests/corpus/compile-fail/pub-read-external-write/fixture.wo create mode 100644 tests/corpus/compile-fail/using-collision/ext/fns.wo create mode 100644 tests/corpus/compile-fail/using-collision/fixture.code create mode 100644 tests/corpus/compile-fail/using-collision/fixture.wo create mode 100644 tests/corpus/run/bytes-carrier/fixture.out create mode 100644 tests/corpus/run/bytes-carrier/fixture.wo create mode 100644 tests/corpus/run/float-arithmetic/fixture.out create mode 100644 tests/corpus/run/float-arithmetic/fixture.wo create mode 100644 tests/corpus/run/float-json-storage/fixture.out create mode 100644 tests/corpus/run/float-json-storage/fixture.wo create mode 100644 tests/corpus/run/float-table-column/fixture.out create mode 100644 tests/corpus/run/float-table-column/fixture.wo create mode 100644 tests/corpus/run/ifdef-flags/fixture.out create mode 100644 tests/corpus/run/ifdef-flags/fixture.wo create mode 100644 tests/corpus/run/pub-read-accessor/fixture.out create mode 100644 tests/corpus/run/pub-read-accessor/fixture.wo create mode 100644 tests/corpus/run/using-extension/fixture.out create mode 100644 tests/corpus/run/using-extension/fixture.wo create mode 100644 tests/corpus/run/using-extension/textutil/util.wo diff --git a/compiler/bin/main.ml b/compiler/bin/main.ml index 872cac7..bf40073 100644 --- a/compiler/bin/main.ml +++ b/compiler/bin/main.ml @@ -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 ` where /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 ` 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 diff --git a/compiler/src/CODE-LOGIC.md b/compiler/src/CODE-LOGIC.md index 21b5877..00bebbe 100644 --- a/compiler/src/CODE-LOGIC.md +++ b/compiler/src/CODE-LOGIC.md @@ -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 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 -o + ` 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 `/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). diff --git a/compiler/src/ast.ml b/compiler/src/ast.ml index 728584c..8195584 100644 --- a/compiler/src/ast.ml +++ b/compiler/src/ast.ml @@ -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 diff --git a/compiler/src/disasm.ml b/compiler/src/disasm.ml index 56ec7a5..1a752eb 100644 --- a/compiler/src/disasm.ml +++ b/compiler/src/disasm.ml @@ -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 "" 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 =="; diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml index ba05fc7..d7bc0ab 100644 --- a/compiler/src/dump.ml +++ b/compiler/src/dump.ml @@ -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 diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 64f9432..698062e 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -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 diff --git a/compiler/src/lexer.ml b/compiler/src/lexer.ml index 49a78cb..c799c97 100644 --- a/compiler/src/lexer.ml +++ b/compiler/src/lexer.ml @@ -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 ` 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 , #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) diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml index 0b32329..135ee82 100644 --- a/compiler/src/owner.ml +++ b/compiler/src/owner.ml @@ -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 diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index 1510472..8c9c8e2 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -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) diff --git a/compiler/src/token.ml b/compiler/src/token.ml index 2dc098d..217b845 100644 --- a/compiler/src/token.ml +++ b/compiler/src/token.ml @@ -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 diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 7e08041..7ea7754 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -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; @@ -2884,4 +3126,85 @@ let dump_symbols (syms : symbols) : string = (Printf.sprintf "%s FN %s" (pos_str fn.pos) fn.name) :: acc ) syms.free_fns [] in - String.concat "\n" (class_lines @ interface_lines @ fn_lines) \ No newline at end of file + 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 diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index 752a980..3cde539 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -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 diff --git a/database/src/table.c b/database/src/table.c index a8dc324..82f60fa 100644 --- a/database/src/table.c +++ b/database/src/table.c @@ -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; } diff --git a/database/src/wal.c b/database/src/wal.c index 4f58161..2849e2c 100644 --- a/database/src/wal.c +++ b/database/src/wal.c @@ -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; diff --git a/docs/00-code-review.md b/docs/00-code-review.md index e380372..1674f2e 100644 --- a/docs/00-code-review.md +++ b/docs/00-code-review.md @@ -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` 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` 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. diff --git a/docs/00-link-audit.md b/docs/00-link-audit.md new file mode 100644 index 0000000..a159c67 --- /dev/null +++ b/docs/00-link-audit.md @@ -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. diff --git a/docs/00-status.md b/docs/00-status.md index a409ba2..36d79ad 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -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 ` 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 | diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo index 1345953..073ca00 100644 --- a/docs/examples/web-app/main.wo +++ b/docs/examples/web-app/main.wo @@ -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); diff --git a/docs/examples/web-app/types.wo b/docs/examples/web-app/types.wo index 520d9f0..c964d1b 100644 --- a/docs/examples/web-app/types.wo +++ b/docs/examples/web-app/types.wo @@ -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 diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md index bf75374..590e510 100644 --- a/docs/examples/writeonce-framework/README.md +++ b/docs/examples/writeonce-framework/README.md @@ -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 ` 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 diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-framework/app.wo index 2abb0cc..c6a1ac8 100644 --- a/docs/examples/writeonce-framework/app.wo +++ b/docs/examples/writeonce-framework/app.wo @@ -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); } } diff --git a/docs/examples/writeonce-framework/http/form.wo b/docs/examples/writeonce-framework/http/form.wo new file mode 100644 index 0000000..3efa2e3 --- /dev/null +++ b/docs/examples/writeonce-framework/http/form.wo @@ -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 { + if media_type(req) != "application/x-www-form-urlencoded" { return nil; } + return parse_query(req.body); +} diff --git a/docs/examples/writeonce-framework/http/parse.wo b/docs/examples/writeonce-framework/internal/parse.wo similarity index 83% rename from docs/examples/writeonce-framework/http/parse.wo rename to docs/examples/writeonce-framework/internal/parse.wo index 3f3d4a6..bb57b5a 100644 --- a/docs/examples/writeonce-framework/http/parse.wo +++ b/docs/examples/writeonce-framework/internal/parse.wo @@ -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 { +pub fn parse_query(qs: Text) -> map { let q: map = {}; if qs == "" { return q; } for pair in split(qs, "&") { @@ -71,25 +78,6 @@ fn parse_query(qs: Text) -> map { 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 { - 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 }; } diff --git a/docs/examples/writeonce-framework/http/serve.wo b/docs/examples/writeonce-framework/internal/serve.wo similarity index 92% rename from docs/examples/writeonce-framework/http/serve.wo rename to docs/examples/writeonce-framework/internal/serve.wo index 267d93b..a70c12e 100644 --- a/docs/examples/writeonce-framework/http/serve.wo +++ b/docs/examples/writeonce-framework/internal/serve.wo @@ -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 diff --git a/docs/examples/writeonce-framework/wo.toml b/docs/examples/writeonce-framework/wo.toml index c96f94d..0e12d31 100644 --- a/docs/examples/writeonce-framework/wo.toml +++ b/docs/examples/writeonce-framework/wo.toml @@ -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 ` typechecks the whole project. # Apps import this repo through `wo.toml [deps]` (iteration 15) and # `use writeonce-framework` / `use writeonce-framework/http` / `.../router`. diff --git a/docs/plan/compiler/2026-08-01-haxe-parity-language.md b/docs/plan/compiler/2026-08-01-haxe-parity-language.md index 78cae74..f4ff3e2 100644 --- a/docs/plan/compiler/2026-08-01-haxe-parity-language.md +++ b/docs/plan/compiler/2026-08-01-haxe-parity-language.md @@ -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. > diff --git a/docs/plan/oop-vm/00-wob-format.md b/docs/plan/oop-vm/00-wob-format.md index 74815e9..8b5cf83 100644 --- a/docs/plan/oop-vm/00-wob-format.md +++ b/docs/plan/oop-vm/00-wob-format.md @@ -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 diff --git a/docs/stories/language-runtime-database/00-story.md b/docs/stories/language-runtime-database/00-story.md index bd9c26c..175ce41 100644 --- a/docs/stories/language-runtime-database/00-story.md +++ b/docs/stories/language-runtime-database/00-story.md @@ -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; diff --git a/docs/stories/language-runtime-database/08-shard-actor-runtime.md b/docs/stories/language-runtime-database/08-shard-actor-runtime.md deleted file mode 100644 index f1bd162..0000000 --- a/docs/stories/language-runtime-database/08-shard-actor-runtime.md +++ /dev/null @@ -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). diff --git a/docs/stories/language-runtime-database/05-language-surface.md b/docs/stories/language-runtime-database/done/05-language-surface.md similarity index 69% rename from docs/stories/language-runtime-database/05-language-surface.md rename to docs/stories/language-runtime-database/done/05-language-surface.md index b986f34..a41212a 100644 --- a/docs/stories/language-runtime-database/05-language-surface.md +++ b/docs/stories/language-runtime-database/done/05-language-surface.md @@ -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 diff --git a/docs/stories/language-runtime-database/17-library-projects-internal.md b/docs/stories/language-runtime-database/done/17-library-projects-internal.md similarity index 85% rename from docs/stories/language-runtime-database/17-library-projects-internal.md rename to docs/stories/language-runtime-database/done/17-library-projects-internal.md index c4baab4..5ad09d0 100644 --- a/docs/stories/language-runtime-database/17-library-projects-internal.md +++ b/docs/stories/language-runtime-database/done/17-library-projects-internal.md @@ -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 diff --git a/docs/stories/language-runtime-database/19-missing-scalar-types.md b/docs/stories/language-runtime-database/done/19-missing-scalar-types.md similarity index 79% rename from docs/stories/language-runtime-database/19-missing-scalar-types.md rename to docs/stories/language-runtime-database/done/19-missing-scalar-types.md index 76339f3..1534af5 100644 --- a/docs/stories/language-runtime-database/19-missing-scalar-types.md +++ b/docs/stories/language-runtime-database/done/19-missing-scalar-types.md @@ -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 diff --git a/docs/stories/language-runtime-database/18-memory-db-features.md b/docs/stories/language-runtime-database/hold/18-memory-db-features.md similarity index 98% rename from docs/stories/language-runtime-database/18-memory-db-features.md rename to docs/stories/language-runtime-database/hold/18-memory-db-features.md index 8a19691..a308de8 100644 --- a/docs/stories/language-runtime-database/18-memory-db-features.md +++ b/docs/stories/language-runtime-database/hold/18-memory-db-features.md @@ -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 diff --git a/docs/stories/language-runtime-database/refine/08-shard-actor-runtime.md b/docs/stories/language-runtime-database/refine/08-shard-actor-runtime.md new file mode 100644 index 0000000..488a884 --- /dev/null +++ b/docs/stories/language-runtime-database/refine/08-shard-actor-runtime.md @@ -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. diff --git a/docs/stories/language-runtime-database/refine/11-fibers.md b/docs/stories/language-runtime-database/refine/11-fibers.md index 65180cc..a2ab400 100644 --- a/docs/stories/language-runtime-database/refine/11-fibers.md +++ b/docs/stories/language-runtime-database/refine/11-fibers.md @@ -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 diff --git a/docs/stories/language-runtime-database/refine/23-io-uring-commit.md b/docs/stories/language-runtime-database/refine/23-io-uring-commit.md index aa91d70..96de6cc 100644 --- a/docs/stories/language-runtime-database/refine/23-io-uring-commit.md +++ b/docs/stories/language-runtime-database/refine/23-io-uring-commit.md @@ -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 diff --git a/docs/superpowers/plans/2026-08-20-library-kind-internal.md b/docs/superpowers/plans/2026-08-20-library-kind-internal.md index a67ed1b..7133f2d 100644 --- a/docs/superpowers/plans/2026-08-20-library-kind-internal.md +++ b/docs/superpowers/plans/2026-08-20-library-kind-internal.md @@ -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 diff --git a/docs/superpowers/specs/2026-08-20-library-kind-internal-design.md b/docs/superpowers/specs/2026-08-20-library-kind-internal-design.md index c27de6d..16f595e 100644 --- a/docs/superpowers/specs/2026-08-20-library-kind-internal-design.md +++ b/docs/superpowers/specs/2026-08-20-library-kind-internal-design.md @@ -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). > diff --git a/docs/superpowers/specs/2026-08-20-memory-db-features-design.md b/docs/superpowers/specs/2026-08-20-memory-db-features-design.md index e77bc31..fb42675 100644 --- a/docs/superpowers/specs/2026-08-20-memory-db-features-design.md +++ b/docs/superpowers/specs/2026-08-20-memory-db-features-design.md @@ -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). > diff --git a/justfile b/justfile index 7d611e5..943fdd7 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/runtime/src/CODE-LOGIC.md b/runtime/src/CODE-LOGIC.md index f075ef5..b36d76f 100644 --- a/runtime/src/CODE-LOGIC.md +++ b/runtime/src/CODE-LOGIC.md @@ -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. diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c index b0c83b6..c30c765 100644 --- a/runtime/src/builtin.c +++ b/runtime/src/builtin.c @@ -5,6 +5,7 @@ #include "db.h" /* database/src — the engine's statement executors */ #include +#include /* strtod: the round-trip check in wo_float_text */ #include #include @@ -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; diff --git a/runtime/src/builtin.h b/runtime/src/builtin.h index 6c2bf70..4ed110c 100644 --- a/runtime/src/builtin.h +++ b/runtime/src/builtin.h @@ -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 */ diff --git a/runtime/src/gc.c b/runtime/src/gc.c index 9895b67..d0372a0 100644 --- a/runtime/src/gc.c +++ b/runtime/src/gc.c @@ -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; diff --git a/runtime/src/json.c b/runtime/src/json.c index f3ce7f4..9b488d3 100644 --- a/runtime/src/json.c +++ b/runtime/src/json.c @@ -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 == '-') { diff --git a/runtime/src/loader.c b/runtime/src/loader.c index cf27bec..81791b4 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -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); diff --git a/runtime/src/main.c b/runtime/src/main.c index c2b9fa9..63e10b5 100644 --- a/runtime/src/main.c +++ b/runtime/src/main.c @@ -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); diff --git a/runtime/src/obj.c b/runtime/src/obj.c index 92a2a8a..45dd283 100644 --- a/runtime/src/obj.c +++ b/runtime/src/obj.c @@ -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; diff --git a/runtime/src/obj.h b/runtime/src/obj.h index 95080ca..12f37cf 100644 --- a/runtime/src/obj.h +++ b/runtime/src/obj.h @@ -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 */ diff --git a/runtime/src/vm.c b/runtime/src/vm.c index 22add27..1aa3cfd 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -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 */ diff --git a/runtime/src/vm.h b/runtime/src/vm.h index 06a1c15..bf87791 100644 --- a/runtime/src/vm.h +++ b/runtime/src/vm.h @@ -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 diff --git a/runtime/src/wob.h b/runtime/src/wob.h index b578f1d..ac5613a 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -9,11 +9,15 @@ #include #include +#include /* 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 */ diff --git a/runtime/test/test_wal.c b/runtime/test/test_wal.c index 87bd03d..9950286 100644 --- a/runtime/test/test_wal.c +++ b/runtime/test/test_wal.c @@ -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) { diff --git a/scripts/linkcheck.py b/scripts/linkcheck.py new file mode 100644 index 0000000..60646a5 --- /dev/null +++ b/scripts/linkcheck.py @@ -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'(?]+)>?(?:\s+"[^"]*")?\s*\)') +REFDEF = re.compile(r'^\s{0,3}\[([^\]]+)\]:\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']*(?: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}") diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index 6914c7c..d41329b 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -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 diff --git a/tests/corpus/compile-fail/ifdef-unterminated/fixture.code b/tests/corpus/compile-fail/ifdef-unterminated/fixture.code new file mode 100644 index 0000000..e22fa12 --- /dev/null +++ b/tests/corpus/compile-fail/ifdef-unterminated/fixture.code @@ -0,0 +1 @@ +WO-E003 diff --git a/tests/corpus/compile-fail/ifdef-unterminated/fixture.wo b/tests/corpus/compile-fail/ifdef-unterminated/fixture.wo new file mode 100644 index 0000000..c60dd24 --- /dev/null +++ b/tests/corpus/compile-fail/ifdef-unterminated/fixture.wo @@ -0,0 +1,6 @@ +-- a #if left open at end of file is WO-E003 + +fn main() -> Int { +#if portable + return 1; +} diff --git a/tests/corpus/compile-fail/pub-read-external-write/fixture.code b/tests/corpus/compile-fail/pub-read-external-write/fixture.code new file mode 100644 index 0000000..eb4f22d --- /dev/null +++ b/tests/corpus/compile-fail/pub-read-external-write/fixture.code @@ -0,0 +1 @@ +WO-E219 diff --git a/tests/corpus/compile-fail/pub-read-external-write/fixture.wo b/tests/corpus/compile-fail/pub-read-external-write/fixture.wo new file mode 100644 index 0000000..7b408b3 --- /dev/null +++ b/tests/corpus/compile-fail/pub-read-external-write/fixture.wo @@ -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; +} diff --git a/tests/corpus/compile-fail/using-collision/ext/fns.wo b/tests/corpus/compile-fail/using-collision/ext/fns.wo new file mode 100644 index 0000000..79d8c5d --- /dev/null +++ b/tests/corpus/compile-fail/using-collision/ext/fns.wo @@ -0,0 +1,3 @@ +pub fn label(b: Box) -> Text { + return "extension"; +} diff --git a/tests/corpus/compile-fail/using-collision/fixture.code b/tests/corpus/compile-fail/using-collision/fixture.code new file mode 100644 index 0000000..aaf9fc8 --- /dev/null +++ b/tests/corpus/compile-fail/using-collision/fixture.code @@ -0,0 +1 @@ +WO-E220 diff --git a/tests/corpus/compile-fail/using-collision/fixture.wo b/tests/corpus/compile-fail/using-collision/fixture.wo new file mode 100644 index 0000000..26fb864 --- /dev/null +++ b/tests/corpus/compile-fail/using-collision/fixture.wo @@ -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; +} diff --git a/tests/corpus/run/bytes-carrier/fixture.out b/tests/corpus/run/bytes-carrier/fixture.out new file mode 100644 index 0000000..23d170c --- /dev/null +++ b/tests/corpus/run/bytes-carrier/fixture.out @@ -0,0 +1,16 @@ +9 +119 +write +once +0 +1 +0 +write! +d3JpdGVvbmNl +YWI= +YQ== +1 +3 +0 +1 +1 diff --git a/tests/corpus/run/bytes-carrier/fixture.wo b/tests/corpus/run/bytes-carrier/fixture.wo new file mode 100644 index 0000000..5e0f704 --- /dev/null +++ b/tests/corpus/run/bytes-carrier/fixture.wo @@ -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) +} diff --git a/tests/corpus/run/float-arithmetic/fixture.out b/tests/corpus/run/float-arithmetic/fixture.out new file mode 100644 index 0000000..b5f4c78 --- /dev/null +++ b/tests/corpus/run/float-arithmetic/fixture.out @@ -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 diff --git a/tests/corpus/run/float-arithmetic/fixture.wo b/tests/corpus/run/float-arithmetic/fixture.wo new file mode 100644 index 0000000..59dc6d7 --- /dev/null +++ b/tests/corpus/run/float-arithmetic/fixture.wo @@ -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)) +} diff --git a/tests/corpus/run/float-json-storage/fixture.out b/tests/corpus/run/float-json-storage/fixture.out new file mode 100644 index 0000000..0593476 --- /dev/null +++ b/tests/corpus/run/float-json-storage/fixture.out @@ -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":""} diff --git a/tests/corpus/run/float-json-storage/fixture.wo b/tests/corpus/run/float-json-storage/fixture.wo new file mode 100644 index 0000000..02d4cbc --- /dev/null +++ b/tests/corpus/run/float-json-storage/fixture.wo @@ -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 +} diff --git a/tests/corpus/run/float-table-column/fixture.out b/tests/corpus/run/float-table-column/fixture.out new file mode 100644 index 0000000..6b9a74f --- /dev/null +++ b/tests/corpus/run/float-table-column/fixture.out @@ -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 diff --git a/tests/corpus/run/float-table-column/fixture.wo b/tests/corpus/run/float-table-column/fixture.wo new file mode 100644 index 0000000..cba449d --- /dev/null +++ b/tests/corpus/run/float-table-column/fixture.wo @@ -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 }) +} diff --git a/tests/corpus/run/ifdef-flags/fixture.out b/tests/corpus/run/ifdef-flags/fixture.out new file mode 100644 index 0000000..1697946 --- /dev/null +++ b/tests/corpus/run/ifdef-flags/fixture.out @@ -0,0 +1,3 @@ +native +neither +done diff --git a/tests/corpus/run/ifdef-flags/fixture.wo b/tests/corpus/run/ifdef-flags/fixture.wo new file mode 100644 index 0000000..66b62e8 --- /dev/null +++ b/tests/corpus/run/ifdef-flags/fixture.wo @@ -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; +} diff --git a/tests/corpus/run/pub-read-accessor/fixture.out b/tests/corpus/run/pub-read-accessor/fixture.out new file mode 100644 index 0000000..e7c170b --- /dev/null +++ b/tests/corpus/run/pub-read-accessor/fixture.out @@ -0,0 +1,2 @@ +a=2 b=5 +after reset b=0 diff --git a/tests/corpus/run/pub-read-accessor/fixture.wo b/tests/corpus/run/pub-read-accessor/fixture.wo new file mode 100644 index 0000000..8c68408 --- /dev/null +++ b/tests/corpus/run/pub-read-accessor/fixture.wo @@ -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; +} diff --git a/tests/corpus/run/using-extension/fixture.out b/tests/corpus/run/using-extension/fixture.out new file mode 100644 index 0000000..18eb2d8 --- /dev/null +++ b/tests/corpus/run/using-extension/fixture.out @@ -0,0 +1,4 @@ +ha! +hahaha +plain! +box untouched 7 diff --git a/tests/corpus/run/using-extension/fixture.wo b/tests/corpus/run/using-extension/fixture.wo new file mode 100644 index 0000000..0e5a820 --- /dev/null +++ b/tests/corpus/run/using-extension/fixture.wo @@ -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; +} diff --git a/tests/corpus/run/using-extension/textutil/util.wo b/tests/corpus/run/using-extension/textutil/util.wo new file mode 100644 index 0000000..86d87af --- /dev/null +++ b/tests/corpus/run/using-extension/textutil/util.wo @@ -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; +}