feat(compiler): MVS ownership pass + woc driver; drop Money/SKU/Float, reject abstract

Completes plan 2 Tasks 7-8. owner.ml: mutable-value-semantics flow analysis
producing the four plan-3 emitter tables (moves, drops incl. LIVE-MASK for trap
unwinding, rc with elision, residual borrow sites) plus WO-E301-304 two-site
diagnostics. Alias questions run over canonicalized places, so a double-mut
reached through let-bound aliases lands in the residual table like the direct
form; dump.ml's contract notes the emitter must coalesce guards per operand.
main.ml: directory discovery, cross-file programs (symbols merge before bodies
check), diagnostics ordered by (file,line,col), new WO-E214 for a name declared
in two files. New docs/plan/oop-vm/01-error-catalog.md (14 emitted + 10 reserved
codes), un-ignored so both plan tracks can cite it; justfile regains woc-*.

builtin_scalars is now the five that work: Int, Bool, Text, Timestamp, Id.
Money/SKU/Float and the abstract_types allowlist are gone — `abstract` never
lexed, and Float had no literal syntax and no wob kind, so no value could exist.
Fixtures and samples retype Money->Int, SKU->Text. The abstract newtype feature
is rejected outright (verdict row adopt->reject); haxe-parity Task 7 keeps `is`.

nullable-types-implementation.md corrected: ?T is plumbed but UNENFORCED
(E211-213 declared, never emitted; probe exits 0), handed to haxe-parity Task 6
as next work item. Records all 10 dead codes incl. E205 — interface satisfaction
is unchecked. crates/rt keeps its Money/SKU fixtures (opaque strings, Stage 2).

Gate: build warning-clean, 14 + 264 checks 0 failures, pricing golden exit 0,
docs/examples histograms unchanged (13/70, zero WO-E225).
This commit is contained in:
shoney.arickathil 2026-08-10 21:24:50 +02:00
parent d2b855b3d1
commit 2de724df11
57 changed files with 3785 additions and 344 deletions

5
.gitignore vendored
View file

@ -69,4 +69,9 @@ docs/examples/agent-loop/
docs/examples/mcp-think/ docs/examples/mcp-think/
docs/plan/exploration/ docs/plan/exploration/
docs/plan/oop-vm/ docs/plan/oop-vm/
# docs/plan/oop-vm/ carries the compiler<->VM normative contracts
# (.wob format, error catalog) both plan tracks cite -- carve it back
# out of the blanket docs/plan/ ignore above; the other rules here are
# untouched.
!docs/plan/oop-vm/
docs/superpowers/plans/ docs/superpowers/plans/

View file

@ -1,8 +1,8 @@
# compiler/ — the OCaml `woc` compiler # compiler/ — the OCaml `woc` compiler
Lexer → parser → typechecker → ownership pass → bytecode emitter for `.wo`. OCaml stdlib only (no Menhir, no ppx); dune is the build runner. Sibling of the C `wovm` bytecode VM (`runtime/`) — the two halves of the OOP track's spec (`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`) meet at plan 3 (`.wob` emission). Lexer → parser → typechecker → ownership pass, for `.wo`. OCaml stdlib only (no Menhir, no ppx); dune is the build runner. Sibling of the C `wovm` bytecode VM (`runtime/`) — the two halves of the OOP track's spec (`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`) meet at plan 3 (`.wob` emission; not built yet).
**Stage: Task 1 scaffold.** `src/` is currently an empty library and `bin/main.ml` is a CLI stub that only wires the exit-code contract below — no lexing, parsing, or checking happens yet. Tasks 2–8 (`compiler/plan/2026-08-01-woc-compiler-front.md`) fill in diagnostics, lexer, parser, typechecker, and the ownership pass in that order. **Stage: plan 2 (`docs/plan/compiler/2026-08-01-woc-compiler-front.md`) complete, Tasks 1–8.** Diagnostics, lexer, parser (declarations + statements/expressions), typechecker (symbols, field kinds, structural interfaces, `?T` nullable), and the MVS ownership pass are all implemented and wired into the `woc` executable. Bytecode emission and `.wob` output are plan 3 — not started.
## Requirements ## Requirements
@ -12,19 +12,31 @@ OCaml 4.14.1, dune 3.14.0 — Ubuntu 24.04 apt packages (`sudo apt install ocaml
```bash ```bash
cd compiler && dune build # -> _build/default/bin/woc cd compiler && dune build # -> _build/default/bin/woc
cd compiler && dune runtest # golden suite (empty until Task 3 adds test/dune) cd compiler && dune runtest # test_diag unit checks + runner golden/CLI-smoke suite
just woc-build # same, from the repo root just woc-build # same, from the repo root
just woc-test # same, from the repo root just woc-test # same, from the repo root
``` ```
`woc` with no arguments (or the wrong number of arguments) prints usage to stderr and exits 2; given one path argument it exits 0 if the path exists, 2 if it doesn't. Exit-code contract, established now and enforced fully once diagnostics land in Task 2: **0** clean compile, **1** diagnostics reported, **2** usage/IO failure. `WOC_BLESS=1 dune runtest` (from `compiler/`) rewrites golden `.expected` files to match current output — use it once, by hand, to seed or intentionally update a fixture.
## Running `woc`
```
woc <path> # compile (lex, parse, typecheck, ownership-check); nothing prints on success
woc --dump-tokens <path> # stdout: one line per lexed token
woc --dump-ast <path> # stdout: the declaration + body AST, indented
woc --dump-owner <path> # stdout: the ownership pass's four tables (moves, drops, rc, residual)
```
`<path>` is a single `.wo` file or a directory. A directory is discovered recursively for every `.wo` file under it — same contract as `wo run` (`crates/rt/src/lib.rs::discover`): dot-prefixed entries and `target`/`data`/`node_modules` are skipped, results are sorted by path. Every discovered file compiles as one program (declarations in one file resolve for bodies in another, regardless of discovery order); diagnostics from every file and every stage print sorted by `(file, line, col)`. For multi-file `--dump-*` output, each file's dump is preceded by a `=== path ===` header line (`compiler/src/dump.ml`'s `file_header`) — a single-file run never prints one.
Diagnostics render as `file:line:col: severity CODE: message` plus a source excerpt with a caret; every shipped code is cataloged in `docs/plan/oop-vm/01-error-catalog.md`. Exit codes: **0** clean compile, **1** diagnostics reported, **2** usage/IO failure.
## Layout ## Layout
- `src/` — one module per stage, added as each task lands: diag, token, lexer, ast, parser, types, owner, dump - `src/` — one module per stage: `diag` (diagnostics, collector, exit-code decision), `token`/`lexer`, `ast`/`parser`, `types` (typechecker), `owner` (MVS ownership pass), `dump` (stable text dumps for all of the above)
- `bin/` — the `woc` executable (check / emit / build modes land in later tasks) - `bin/` — the `woc` executable: CLI parsing, file discovery, the multi-file/cross-file driver
- `test/` — golden runner; `test/golden/` holds fixtures per stage (Task 3 onward) - `test/` — `runner.ml` (golden runner + CLI smoke) and `test_diag.ml` (diag.ml unit checks); `test/golden/<stage>/` holds one-file-per-fixture goldens (`tokens`, `ast`, `owner`, `owner-err`); `test/fixtures/driver/` holds the multi-file CLI-smoke fixtures (directory discovery, cross-file symbols, diagnostic ordering) that don't fit the one-`.wo`-file-per-fixture golden shape
- `plan/` — compiler-track docs: [`architecture.md`](plan/architecture.md) (pipeline, module contracts, reference-study map) + plans 2, 3, 8
Governing docs: spec `docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`; plans 2, 3, 8 in `compiler/plan/`. Format contract: `docs/plan/oop-vm/00-wob-format.md`. Governing docs (all under `docs/`, not here — this file stays an orientation README): spec `docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`; plan `docs/plan/compiler/2026-08-01-woc-compiler-front.md` (+ `architecture.md`, `nullable-types-implementation.md`, `2026-08-01-haxe-parity-language.md`, `2026-08-01-wob-emit-e2e-single-binary.md` in the same directory). Format contract: `docs/plan/oop-vm/00-wob-format.md`. Error catalog: `docs/plan/oop-vm/01-error-catalog.md`.

View file

@ -7,30 +7,53 @@
(compiler/src/{ast,parser}.ml) behind --dump-ast, printing a stable, (compiler/src/{ast,parser}.ml) behind --dump-ast, printing a stable,
golden-diffed AST dump (compiler/src/dump.ml) to stdout. Tasks 5-7 golden-diffed AST dump (compiler/src/dump.ml) to stdout. Tasks 5-7
add statement/expression parsing, the typechecker, and the ownership add statement/expression parsing, the typechecker, and the ownership
pass behind their own --dump-* flags; Task 8 adds directory pass behind their own --dump-* flags. Task 8 adds directory
discovery and multi-file programs, at which point the bare discovery and multi-file programs: <path> may now be a single .wo
`woc <path>` form below starts actually compiling instead of just file or a directory, recursively discovered the same way `wo run`
checking existence. discovers a project (compiler/bin/main.ml's discover_dir mirrors
crates/rt/src/lib.rs::discover — skips dot-prefixed entries and
target/data/node_modules, keeps .wo files, sorted by path relative
to the root). Declarations are collected across every discovered
file before any file's bodies are checked, so symbols span files;
every diagnostic from every stage lands in one Diag.Collector, whose
(file, line, col) sort (diag.ml, Task 2) is what actually gives the
final ordering — not the discovery order files happen to be visited
in. The bare `woc <path>` form now runs the full pipeline (lex,
parse, typecheck, ownership-check) instead of only checking that the
path exists.
0 = clean compile 0 = clean compile
1 = diagnostics reported (reachable now: --dump-tokens on source 1 = diagnostics reported
with an unknown character, or --dump-ast on source with a
broken declaration, reports WO-E0xx/WO-E1xx and exits 1)
2 = usage or IO failure 2 = usage or IO failure
With no arguments, this prints usage to stderr and exits 2. Given a With no arguments, this prints usage to stderr and exits 2.
single path argument (no flag), it exits 0 if the path exists and 2
(with a stderr message) if it does not — this bare-path form ---- Multi-file dump layout ----
predates any real compilation and stays as-is until Task 8. *)
For a single discovered file, every --dump-* flag's stdout output is
byte-identical to before Task 8 (no header, nothing changed). When a
path resolves to more than one file, each file's dump is preceded by
a Woc_lib.Dump.file_header line naming that file, and files appear
in the same sorted discovery order used everywhere else — see
compiler/src/dump.ml's doc comment on file_header for the exact
format. *)
let usage_msg = let usage_msg =
"usage: woc <path>\n\ "usage: woc <path>\n\
usage: woc --dump-tokens <file.wo>\n\ usage: woc --dump-tokens <path>\n\
usage: woc --dump-ast <file.wo>\n\ usage: woc --dump-ast <path>\n\
usage: woc --dump-owner <path>\n\
\n\ \n\
Compiles writeonce (.wo) source. <path> is a single .wo file or a\n\ Compiles writeonce (.wo) source. <path> is a single .wo file or a\n\
directory to discover .wo files under (directory discovery lands in\n\ directory: a directory is discovered recursively for every .wo file\n\
a later task; compilation itself has not landed yet either).\n\ under it (dot-prefixed entries and target/data/node_modules are\n\
skipped, same as `wo run`), sorted by path so discovery order is\n\
deterministic. Multiple discovered files compile as one program —\n\
declarations in one file are visible to bodies in another.\n\
\n\
With no flag, <path> is fully compiled (lexed, parsed, typechecked,\n\
ownership-checked) and nothing is printed on success; diagnostics,\n\
if any, print to stderr.\n\
\n\ \n\
--dump-tokens prints one line per lexed token to stdout, in source\n\ --dump-tokens prints one line per lexed token to stdout, in source\n\
order (\"LINE:COL KIND\" or \"LINE:COL KIND(payload)\"), ending with\n\ order (\"LINE:COL KIND\" or \"LINE:COL KIND(payload)\"), ending with\n\
@ -41,6 +64,16 @@ let usage_msg =
statement/expression parsing inside method and free-fn bodies);\n\ statement/expression parsing inside method and free-fn bodies);\n\
parsing diagnostics, if any, print to stderr.\n\ parsing diagnostics, if any, print to stderr.\n\
\n\ \n\
--dump-owner runs the lexer, parser, typechecker and ownership pass,\n\
then prints the ownership pass's four emitter tables to stdout in\n\
source order (moves, scope-end drops, @gc rc sites, residual borrow\n\
sites — see compiler/src/dump.ml for the format); lexing, parsing,\n\
type and ownership (WO-E3xx) diagnostics print to stderr.\n\
\n\
For a directory (or otherwise multi-file) path, every --dump-* flag\n\
prints each file's own dump in turn, separated by a header line — see\n\
compiler/src/dump.ml's file_header doc comment.\n\
\n\
Exit codes: 0 clean, 1 diagnostics reported, 2 usage/IO failure.\n" Exit codes: 0 clean, 1 diagnostics reported, 2 usage/IO failure.\n"
let read_source path = let read_source path =
@ -52,59 +85,238 @@ let read_source path =
Ok s Ok s
with Sys_error msg -> Error msg with Sys_error msg -> Error msg
let dump_tokens path = (* ---- File discovery (Task 8) ----------------------------------------
if not (Sys.file_exists path) then begin
Printf.eprintf "woc: no such file or directory: %s\n" path; Mirrors wo run's discovery contract (crates/rt/src/lib.rs::discover)
exit 2 exactly: recursive, skips any entry (file or directory) whose name
end; starts with '.' or is literally "target", "data", or "node_modules",
match read_source path with keeps only ".wo"-suffixed files, and returns them sorted by path
relative to the root directory (so nested directories don't disturb
the order a flat listing would give). A single file argument is
returned as the one-element list [path], no filtering applied — that
matches the bare-path form's pre-Task-8 behavior of accepting
whatever file it's given. *)
let skip_name (name : string) : bool =
(String.length name > 0 && name.[0] = '.')
|| name = "target" || name = "data" || name = "node_modules"
let discover_dir (root : string) : string list =
let rec walk (dir : string) (rel : string) : (string * string) list =
Sys.readdir dir |> Array.to_list
|> List.concat_map (fun name ->
if skip_name name then []
else
let full = Filename.concat dir name in
let rel' = if rel = "" then name else Filename.concat rel name in
if Sys.is_directory full then walk full rel'
else if Filename.check_suffix name ".wo" then [ (rel', full) ]
else [])
in
walk root "" |> List.sort (fun (a, _) (b, _) -> compare (a : string) b) |> List.map snd
let discover_files (path : string) : (string list, string) result =
if not (Sys.file_exists path) then
Error (Printf.sprintf "no such file or directory: %s" path)
else if Sys.is_directory path then Ok (discover_dir path)
else Ok [ path ]
let discover_and_read (path : string) : (string * string) list =
match discover_files path with
| Error msg -> | Error msg ->
Printf.eprintf "woc: %s\n" msg; Printf.eprintf "woc: %s\n" msg;
exit 2 exit 2
| Ok src -> | Ok files ->
let collector = Woc_lib.Diag.Collector.create () in List.map
let toks = Woc_lib.Lexer.tokenize collector ~file:path src in (fun f ->
print_string (Woc_lib.Dump.dump_tokens toks); match read_source f with
| Ok src -> (f, src)
| Error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2)
files
let build_lookup (sources : (string * string) list) : Woc_lib.Diag.source_lookup =
let tbl = Hashtbl.create (List.length sources) in
List.iter (fun (f, src) -> Hashtbl.replace tbl f src) sources;
fun f -> Hashtbl.find_opt tbl f
let finish (collector : Woc_lib.Diag.Collector.t) (lookup : Woc_lib.Diag.source_lookup) : unit =
if Woc_lib.Diag.Collector.has_error collector then begin if Woc_lib.Diag.Collector.has_error collector then begin
let lookup f = if f = path then Some src else None in
prerr_string (Woc_lib.Diag.Collector.render_all collector lookup); prerr_string (Woc_lib.Diag.Collector.render_all collector lookup);
prerr_newline (); prerr_newline ();
exit 1 exit 1
end end
else exit 0 else exit 0
let dump_ast path = (* ---- Cross-file symbol resolution (Task 8) ---------------------------
if not (Sys.file_exists path) then begin
Printf.eprintf "woc: no such file or directory: %s\n" path; Types.collect_declarations / Types.typecheck_program are already
exit 2 split into a declare-pass and a check-pass (Task 6); that split is
end; exactly what multi-file needs, so the driver spans files by calling
match read_source path with each pass once per file and merging the declare-pass output before
| Error msg -> any file's check-pass runs — types.ml itself needs no change. Each
Printf.eprintf "woc: %s\n" msg; file's own collect_declarations call still gets that file's own
exit 2 `~file`, so its own diagnostics (e.g. WO-W201) tag the right file;
| Ok src -> only the merged `symbols` value, not any single file's, is what
check-pass calls see, so a class declared in one file resolves for a
field/constructor/etc. in another regardless of discovery order. *)
let parse_all (collector : Woc_lib.Diag.Collector.t) (sources : (string * string) list) :
(string * Woc_lib.Ast.program) list =
List.map
(fun (f, src) ->
let toks = Woc_lib.Lexer.tokenize collector ~file:f src in
let prog = Woc_lib.Parser.parse collector ~file:f toks in
(f, prog))
sources
let merge_symbols (syms_list : Woc_lib.Types.symbols list) : Woc_lib.Types.symbols =
let module SM = Woc_lib.Types.StringMap in
let keep_first _key a _b = Some a in
List.fold_left
(fun (acc : Woc_lib.Types.symbols) (s : Woc_lib.Types.symbols) ->
Woc_lib.Types.{
classes = SM.union keep_first acc.classes s.classes;
interfaces = SM.union keep_first acc.interfaces s.interfaces;
free_fns = SM.union keep_first acc.free_fns s.free_fns;
typedefs = SM.union keep_first acc.typedefs s.typedefs;
modules = acc.modules @ s.modules;
})
Woc_lib.Types.{
classes = SM.empty; interfaces = SM.empty; free_fns = SM.empty;
typedefs = SM.empty; modules = [];
}
syms_list
(* ---- Cross-file symbol collision (review follow-up, Important 2) -----
merge_symbols's first-wins StringMap.union was silent: a same-named
class/interface declared again in a later file doesn't disappear —
it's just dropped from the merged table, so that file's own methods
still get typechecked, but against the *winning* file's field list.
That is a real wrong-shape bug (spurious unknown-field/missing-field
on otherwise-correct code, or a silent pass against the wrong
shape), not merely an untested edge. Reported once per collision, at
merge time, before the losing declaration's own position is gone —
the first-wins merge behavior itself is unchanged; only the silence
is fixed. *)
let duplicate_symbol_code = Woc_lib.Diag.types_prefix ^ "14" (* WO-E214 *)
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 =
Woc_lib.Ast.(
Woc_lib.Diag.Collector.add collector
(Woc_lib.Diag.error ~code:duplicate_symbol_code ~file ~line:pos.line ~col:pos.col
~message:(Printf.sprintf "%s `%s` already declared in `%s`" kind name first_file)
~related:
[ Woc_lib.Diag.related_site ~file:first_file ~line:first_pos.line ~col:first_pos.col
~label:(Printf.sprintf "`%s` first declared here" name)
]
()))
(* Walks (file, symbols) pairs in discovery order, per kind (class,
interface), remembering the first file/pos to declare each name and
reporting every later redeclaration against it. free_fns/typedefs
aren't checked: nothing downstream resolves them by cross-file
lookup the way class/interface satisfaction does, so a same-name
free fn isn't the silent-wrong-shape hazard this exists for — out of
scope for this fix, not something the review asked for. *)
let check_symbol_collisions (collector : Woc_lib.Diag.Collector.t)
(per_file : (string * Woc_lib.Types.symbols) list) : unit =
let seen_classes : (string, string * Woc_lib.Ast.pos) Hashtbl.t = Hashtbl.create 16 in
let seen_interfaces : (string, string * Woc_lib.Ast.pos) Hashtbl.t = Hashtbl.create 16 in
List.iter
(fun (file, (syms : Woc_lib.Types.symbols)) ->
Woc_lib.Types.(
StringMap.iter
(fun name (c : class_info) ->
match Hashtbl.find_opt seen_classes name with
| Some (first_file, first_pos) ->
report_collision collector ~kind:"class" ~name ~file ~pos:c.pos ~first_file ~first_pos
| None -> Hashtbl.add seen_classes name (file, c.pos))
syms.classes;
StringMap.iter
(fun name (i : interface_info) ->
match Hashtbl.find_opt seen_interfaces name with
| Some (first_file, first_pos) ->
report_collision collector ~kind:"interface" ~name ~file ~pos:i.pos ~first_file
~first_pos
| None -> Hashtbl.add seen_interfaces name (file, i.pos))
syms.interfaces))
per_file
let typecheck_all (collector : Woc_lib.Diag.Collector.t)
(parsed : (string * Woc_lib.Ast.program) list) : Woc_lib.Types.symbols =
let per_file_syms =
List.map
(fun (f, prog) -> (f, Woc_lib.Types.collect_declarations ~file:f prog collector))
parsed
in
check_symbol_collisions collector per_file_syms;
let syms = merge_symbols (List.map snd per_file_syms) in
List.iter
(fun (f, prog) -> Woc_lib.Types.typecheck_program ~file:f prog syms collector)
parsed;
syms
let dump_tokens path =
let sources = discover_and_read path in
let collector = Woc_lib.Diag.Collector.create () in let collector = Woc_lib.Diag.Collector.create () in
let toks = Woc_lib.Lexer.tokenize collector ~file:path src in let multi = List.length sources > 1 in
let prog = Woc_lib.Parser.parse collector ~file:path toks in List.iter
print_string (Woc_lib.Dump.dump_ast prog); (fun (f, src) ->
if Woc_lib.Diag.Collector.has_error collector then begin let toks = Woc_lib.Lexer.tokenize collector ~file:f src in
let lookup f = if f = path then Some src else None in if multi then print_string (Woc_lib.Dump.file_header f);
prerr_string (Woc_lib.Diag.Collector.render_all collector lookup); print_string (Woc_lib.Dump.dump_tokens toks))
prerr_newline (); sources;
exit 1 finish collector (build_lookup sources)
end
else exit 0 let dump_ast path =
let sources = discover_and_read path in
let collector = Woc_lib.Diag.Collector.create () in
let multi = List.length sources > 1 in
let parsed = parse_all collector sources in
List.iter
(fun (f, prog) ->
if multi then print_string (Woc_lib.Dump.file_header f);
print_string (Woc_lib.Dump.dump_ast prog))
parsed;
finish collector (build_lookup sources)
let dump_owner path =
let sources = discover_and_read path in
let collector = Woc_lib.Diag.Collector.create () in
let multi = List.length sources > 1 in
let parsed = parse_all collector sources in
let syms = typecheck_all collector parsed in
List.iter
(fun (f, prog) ->
let tables = Woc_lib.Owner.analyze ~file:f prog syms collector in
if multi then print_string (Woc_lib.Dump.file_header f);
print_string (Woc_lib.Dump.dump_owner tables))
parsed;
finish collector (build_lookup sources)
(* The bare `woc <path>` form (Task 8): runs the full pipeline with no
dump — check-only. Nothing is printed to stdout on success, matching
the exit-code contract's "0 = clean compile". *)
let check_only path =
let sources = discover_and_read path in
let collector = Woc_lib.Diag.Collector.create () in
let parsed = parse_all collector sources in
let syms = typecheck_all collector parsed in
List.iter (fun (f, prog) -> ignore (Woc_lib.Owner.analyze ~file:f prog syms collector)) parsed;
finish collector (build_lookup sources)
let () = let () =
match Sys.argv with match Sys.argv with
| [| _; "--dump-tokens"; path |] -> dump_tokens path | [| _; "--dump-tokens"; path |] -> dump_tokens path
| [| _; "--dump-ast"; path |] -> dump_ast path | [| _; "--dump-ast"; path |] -> dump_ast path
| [| _; path |] -> | [| _; "--dump-owner"; path |] -> dump_owner path
if Sys.file_exists path then exit 0 | [| _; path |] -> check_only path
else begin
Printf.eprintf "woc: no such file or directory: %s\n" path;
exit 2
end
| _ -> | _ ->
prerr_string usage_msg; prerr_string usage_msg;
exit 2 exit 2

View file

@ -58,6 +58,7 @@ let lexing_prefix = "WO-E0"
let parsing_prefix = "WO-E1" let parsing_prefix = "WO-E1"
let types_prefix = "WO-E2" let types_prefix = "WO-E2"
let ownership_prefix = "WO-E3" let ownership_prefix = "WO-E3"
let warning_prefix = "WO-W"
type severity = type severity =
| Error | Error

View file

@ -77,6 +77,16 @@ let kind_label (k : Token.kind) : string =
| Token.Newline -> "NEWLINE" | Token.Newline -> "NEWLINE"
| Token.Eof -> "EOF" | Token.Eof -> "EOF"
(* Multi-file dump layout (Task 8, bin/main.ml). Every dump_* function
above still renders exactly one file's contribution — that contract
doesn't change. When a --dump-* flag's <path> resolves to more than
one discovered file, main.ml prints each file's dump_* output one
after another in sorted discovery order, each preceded by this
separator line naming the file. For a single file, main.ml never
calls this, so every dump's stdout stays byte-identical to every
pre-Task-8 golden fixture. *)
let file_header (path : string) : string = Printf.sprintf "=== %s ===\n" path
let dump_tokens (toks : Token.t list) : string = let dump_tokens (toks : Token.t list) : string =
let lines = let lines =
List.map List.map
@ -273,3 +283,152 @@ let dump_decl : Ast.decl -> string list = function
let dump_ast (prog : Ast.program) : string = let dump_ast (prog : Ast.program) : string =
let lines = List.concat_map dump_decl prog.decls in let lines = List.concat_map dump_decl prog.decls in
match lines with [] -> "" | _ -> String.concat "\n" lines ^ "\n" match lines with [] -> "" | _ -> String.concat "\n" lines ^ "\n"
(* dump_owner — the ownership pass's four emitter tables (Task 7).
Four fixed sections in a fixed order, each listing its entries in
source order (LINE:COL first, same convention as every other dump
here); a section with no entries still prints its header, so the
format is stable and a golden diff shows an emptied table as a real
change. Node ids are not printed — the tables carry them for plan 3
(Owner.move_site.mv_node and friends), but ids churn goldens exactly
the way dump_ast's module doc describes, so positions are the
rendering surface.
== MOVES == one line per real ownership transfer
"LINE:COL MOVE <place> <how>", where <how> is
LET / ASSIGN / RETURN / ARG(param) / CTOR(field).
Copy-classed and @gc-classed transfers are absent
by design (a register copy and an rc site
respectively, not a transfer).
== DROPS == deterministic destruction, plus the frame's drop
map at trap-capable sites:
SCOPE <label> [..] owned locals of a scope that
are live where it ends
RETURN [..] owned locals to drop before
this return leaves the frame
OVERWRITE <place> the value an assignment
replaces (absent when the
assignment's target and value
are the same storage, e.g.
`a = a`: dropping there would
destroy what was just stored)
JOIN-DROP <label> [..]
join normalization: locals the
*other* branch of an `if` moved
and this one did not, dropped at
the named branch's end so both
paths leave the merge in the one
state the join records. Without
these, a conditionally moved
value would leak on the path
that kept it
LIVE-MASK [..] everything live at a call /
DB_STUB, `:gc` tagging the
entries that need a decrement
rather than a DROP
Lists are in destruction order (innermost scope
first, reverse declaration order inside a scope).
A SCOPE entry is anchored at its *construct's* own
position (the `fn`/`if`/`else`/`while`/`for` token):
this AST carries no end positions at all (ast.ml's
single-point convention), so a SCOPE line can sort
ahead of the lines for sites inside that same scope.
Label plus construct position is what identifies the
block; source order is only here to keep the
rendering deterministic.
== RC == "LINE:COL ACQUIRE|RELEASE <place> ELIDED|KEPT" for
@gc reference counting. ELIDED marks a pair the
emitter may skip because increment and decrement
are provably balanced inside one scope.
== RESIDUAL == "LINE:COL RESIDUAL <op> <place> vs <op> <place>" —
the sites static proof could not settle, so the
emitter wraps them in runtime borrow ops
(runtime/src/borrow.c) and the VM traps on a real
violation. Each <op> is BORROW_X (an exclusive
access: a `mut` argument, a mutating receiver, or an
assignment target) or BORROW_S (a live shared
borrow); at least one side is always BORROW_X, since
two shared readers never conflict. A move is never a
residual side — a move names a whole local, and a
whole local either provably overlaps another place or
is provably disjoint from it. Places are rendered
*canonically*: an access written through a borrow
binding (`r`, from `let r = bag.items[i]`) prints as
the storage it names (`bag.items[i]`), so two
bindings into one container read as the aliasing pair
they are. The operand node ids in the table
(Owner.rs_a_node / rs_b_node) are the precise key.
Several entries may name the *same* canonical
operand: a reborrow chain produces more than one
genuine unprovable pair over one statement (an
exclusive access against the pairwise partner, and
again against a shared borrow still live through the
chain). The emitter MUST therefore coalesce guards
**per operand**, not emit one acquire/release pair per
table entry — doing the latter asks for both
wo_borrow_excl and wo_borrow_shared on the same
object and self-traps on legal code, the case where
the runtime indices differ after all. *)
let move_kind_str : Owner.move_kind -> string = function
| Owner.MvLet -> "LET"
| Owner.MvAssign -> "ASSIGN"
| Owner.MvReturn -> "RETURN"
| Owner.MvArg name -> Printf.sprintf "ARG(%s)" name
| Owner.MvCtorField name -> Printf.sprintf "CTOR(%s)" name
let drop_item_str (i : Owner.drop_item) : string =
match i.Owner.di_kind with
| Owner.LOwned -> i.Owner.di_name
| Owner.LGc -> i.Owner.di_name ^ ":gc"
let drop_items_str (items : Owner.drop_item list) : string =
"[" ^ String.concat ", " (List.map drop_item_str items) ^ "]"
let acc_kind_str : Owner.acc_kind -> string = function
| Owner.AShared -> "BORROW_S"
| Owner.AExcl -> "BORROW_X"
| Owner.AMove -> "MOVE"
let owner_pos_str (p : Ast.pos) : string = Printf.sprintf "%d:%d" p.Ast.line p.Ast.col
let dump_owner (t : Owner.tables) : string =
let move_line (m : Owner.move_site) =
Printf.sprintf "%s MOVE %s %s" (owner_pos_str m.Owner.mv_pos) m.Owner.mv_place
(move_kind_str m.Owner.mv_kind)
in
let drop_line (d : Owner.drop_site) =
let pos = owner_pos_str d.Owner.dr_pos in
match d.Owner.dr_kind with
| Owner.DScope label ->
Printf.sprintf "%s SCOPE %s %s" pos label (drop_items_str d.Owner.dr_items)
| Owner.DReturn -> Printf.sprintf "%s RETURN %s" pos (drop_items_str d.Owner.dr_items)
| Owner.DOverwrite ->
Printf.sprintf "%s OVERWRITE %s" pos
(String.concat ", " (List.map (fun (i : Owner.drop_item) -> i.Owner.di_name) d.Owner.dr_items))
| Owner.DBranchJoin label ->
Printf.sprintf "%s JOIN-DROP %s %s" pos label (drop_items_str d.Owner.dr_items)
| Owner.DLiveMask -> Printf.sprintf "%s LIVE-MASK %s" pos (drop_items_str d.Owner.dr_items)
in
let rc_line (r : Owner.rc_site) =
Printf.sprintf "%s %s %s %s" (owner_pos_str r.Owner.rc_pos)
(match r.Owner.rc_op with Owner.RcAcquire -> "ACQUIRE" | Owner.RcRelease -> "RELEASE")
r.Owner.rc_place
(if r.Owner.rc_elided then "ELIDED" else "KEPT")
in
let res_line (r : Owner.residual_site) =
Printf.sprintf "%s RESIDUAL %s %s vs %s %s" (owner_pos_str r.Owner.rs_pos)
(acc_kind_str r.Owner.rs_a_kind) r.Owner.rs_a (acc_kind_str r.Owner.rs_b_kind) r.Owner.rs_b
in
let section header lines = (header :: lines) in
let lines =
section "== MOVES ==" (List.map move_line t.Owner.moves)
@ section "== DROPS ==" (List.map drop_line t.Owner.drops)
@ section "== RC ==" (List.map rc_line t.Owner.rcs)
@ section "== RESIDUAL ==" (List.map res_line t.Owner.residuals)
in
String.concat "\n" lines ^ "\n"

View file

@ -2,4 +2,4 @@
; OCaml stdlib only: no Menhir, no ppx, no opam libraries. ; OCaml stdlib only: no Menhir, no ppx, no opam libraries.
(library (library
(name woc_lib) (name woc_lib)
(modules diag token ast lexer parser types dump)) (modules diag token ast lexer parser types owner dump))

1429
compiler/src/owner.ml Normal file

File diff suppressed because it is too large Load diff

View file

@ -97,16 +97,51 @@ and symbols = {
} }
(* Builtin scalars *) (* Builtin scalars *)
let builtin_scalars = ["Int"; "Bool"; "Text"; "Money"; "Timestamp"; "Id"; "SKU"] let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
let is_builtin_scalar name = List.mem name builtin_scalars let is_builtin_scalar name = List.mem name builtin_scalars
let rec has_recursive_structure (cls : class_info) : bool =
List.exists (fun (_, ty, _, _) ->
match ty with
| Ast.Scalar name -> name = cls.name (* direct self-reference *)
| Ast.Ref name -> name = cls.name
| Ast.Multi name -> name = cls.name (* multi Self *)
| Ast.Map (k, v) -> k = cls.name || v = cls.name (* map<_, Self> / map<Self, _> *)
| Ast.Nullable inner -> has_recursive_structure_type inner cls.name
) cls.fields
and has_recursive_structure_type (ty : Ast.field_ty) (cls_name : string) : bool =
match ty with
| Ast.Scalar name -> name = cls_name
| Ast.Ref name -> name = cls_name
| Ast.Multi name -> name = cls_name
| Ast.Map (k, v) -> k = cls_name || v = cls_name
| Ast.Nullable inner -> has_recursive_structure_type inner cls_name
(* @unique field -> persistent identity (plan's "When NOT to emit": a
class with a @unique field should not get the @gc suggestion even if
it also has recursive/shared structure). Annotation *names* only, per
Ast.field's own doc comment -- "unique" is what parse_field stores for
a bare `@unique`. *)
let has_unique_field (cls : class_info) : bool =
List.exists (fun (_, _, _, anns) -> List.mem "unique" anns) cls.fields
let is_gc_class (syms : symbols) name = let is_gc_class (syms : symbols) name =
try try
let cls = StringMap.find name syms.classes in let cls = StringMap.find name syms.classes in
cls.is_gc cls.is_gc
with Not_found -> false with Not_found -> false
let gc_suggestion_code = Diag.warning_prefix ^ "201" (* WO-W201 *)
let suggest_gc_annotation ~file (cls : class_info) (collector : Diag.Collector.t) : unit =
if not cls.is_gc && Option.is_none cls.table && not (has_unique_field cls)
&& has_recursive_structure cls then
Diag.Collector.add collector
(Diag.warning ~code:gc_suggestion_code ~file ~line:cls.pos.line ~col:cls.pos.col
~message:(Printf.sprintf "%s has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default)." cls.name) ())
(* wob_kind_of_typ: maps internal typ to .wob field kind *) (* wob_kind_of_typ: maps internal typ to .wob field kind *)
let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind = let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind =
let kind_of = function let kind_of = function
@ -140,11 +175,13 @@ let nullable_used_without_check_code = Diag.types_prefix ^ "11"
let nullable_assign_mismatch_code = Diag.types_prefix ^ "12" let nullable_assign_mismatch_code = Diag.types_prefix ^ "12"
let missing_nil_check_code = Diag.types_prefix ^ "13" let missing_nil_check_code = Diag.types_prefix ^ "13"
let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *)
(* ============================================================ (* ============================================================
Pass 1: Declaration Collection Pass 1: Declaration Collection
============================================================ *) ============================================================ *)
let collect_declarations (_prog : program) (_collector : Diag.Collector.t) : symbols = let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) : symbols =
let classes = ref StringMap.empty in let classes = ref StringMap.empty in
let interfaces = ref StringMap.empty in let interfaces = ref StringMap.empty in
let free_fns = ref StringMap.empty in let free_fns = ref StringMap.empty in
@ -176,7 +213,8 @@ let collect_declarations (_prog : program) (_collector : Diag.Collector.t) : sym
id = c.id; id = c.id;
pos = c.pos; pos = c.pos;
} in } in
classes := StringMap.add c.name info !classes classes := StringMap.add c.name info !classes;
suggest_gc_annotation ~file info collector
| Ast.Interface i -> | Ast.Interface i ->
let methods = List.map (fun (m : Ast.method_sig) -> let methods = List.map (fun (m : Ast.method_sig) ->
{ name = m.name; { name = m.name;
@ -203,7 +241,7 @@ let collect_declarations (_prog : program) (_collector : Diag.Collector.t) : sym
pos = f.pos; pos = f.pos;
} in } in
free_fns := StringMap.add f.name info !free_fns free_fns := StringMap.add f.name info !free_fns
) _prog.decls; ) prog.decls;
{ classes = !classes; interfaces = !interfaces; free_fns = !free_fns; { classes = !classes; interfaces = !interfaces; free_fns = !free_fns;
typedefs = !typedefs; modules = !modules } typedefs = !typedefs; modules = !modules }
@ -217,13 +255,47 @@ type expr_type_result = {
is_nil : bool; is_nil : bool;
} }
let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Collector.t) : unit = (* WO-E225's "known type" set: builtin, a declared class, or a declared
interface. *)
let is_known_type_name (syms : symbols) (name : string) : bool =
is_builtin_scalar name
|| StringMap.mem name syms.classes
|| StringMap.mem name syms.interfaces
let rec scalar_name_of (ft : field_ty) : string option =
match ft with
| Scalar name -> Some name
| Nullable inner -> scalar_name_of inner
| Ref _ | Multi _ | Map _ -> None
(* Checked once per field declaration (not at every access/use site), so
the diagnostic lands at the field's own declaration position and
never fires more than once for the same bad field. Runs over the raw
AST rather than `syms.classes` because class_info's fields tuple
doesn't carry a pos (see Ast.field for that); run from Pass 2 (after
collect_declarations has fully built `syms`) so a field typed with a
class declared later in the same file is not a false positive. *)
let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
(prog : program) : unit =
List.iter (function
| Ast.Class c ->
List.iter (fun (f : Ast.field) ->
match scalar_name_of f.ty with
| Some name when not (is_known_type_name syms name) ->
Diag.Collector.add collector
(Diag.error ~code:unknown_type_name_code ~file
~line:f.pos.line ~col:f.pos.col
~message:(Printf.sprintf "unknown type `%s`" name) ())
| _ -> ()
) c.fields
| Ast.Interface _ | Ast.Fn _ -> ()
) prog.decls
let typecheck_program ~file (prog : program) (syms : symbols) (collector : Diag.Collector.t) : unit =
check_field_types ~file syms collector prog;
let rec resolve_field_ty (ft : field_ty) : typ = let rec resolve_field_ty (ft : field_ty) : typ =
match ft with match ft with
| Scalar name -> | Scalar name -> TScalar name
if is_builtin_scalar name then TScalar name
else if is_gc_class syms name then TScalar name
else TScalar name
| Ref name -> TRef name | Ref name -> TRef name
| Multi inner_name -> TMulti (TScalar inner_name) | Multi inner_name -> TMulti (TScalar inner_name)
| Map (k_name, v_name) -> TMap (TScalar k_name, TScalar v_name) | Map (k_name, v_name) -> TMap (TScalar k_name, TScalar v_name)
@ -244,15 +316,26 @@ let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Coll
let base_res = typecheck_expr env base in let base_res = typecheck_expr env base in
(match base_res.typ with (match base_res.typ with
| TScalar class_name -> | TScalar class_name ->
(try (* Only a *declared* class can be checked for a missing field.
let cls = StringMap.find class_name syms.classes in typecheck_expr falls back to `TScalar "Int"` for everything
let (_, field_ty, _, _) = List.find (fun (fname, _, _, _) -> fname = field_name) cls.fields in it cannot type yet (an unresolved builtin call such as the
{ typ = resolve_field_ty field_ty; is_nil = false } spec's own `latest(...)`, an indexed element), so reporting
with Not_found -> on a non-class base turned every one of those placeholders
Diag.Collector.add _collector into a bogus "unknown field" error -- spec section 3's
(Diag.error ~code:unknown_field_code ~file:"" ~line:e.pos.line ~col:e.pos.col `latest(self.prices).amount` was one. Precision here comes
back when builtin signatures land; Task 7 surfaced this by
being the first stage to run the typechecker over a whole
method body from the CLI. *)
(match StringMap.find_opt class_name syms.classes with
| None -> { typ = TScalar "Int"; is_nil = false }
| Some cls ->
(match List.find_opt (fun (fname, _, _, _) -> fname = field_name) cls.fields with
| Some (_, field_ty, _, _) -> { typ = resolve_field_ty field_ty; is_nil = false }
| None ->
Diag.Collector.add collector
(Diag.error ~code:unknown_field_code ~file ~line:e.pos.line ~col:e.pos.col
~message:(Printf.sprintf "unknown field `%s` on `%s`" field_name class_name) ()); ~message:(Printf.sprintf "unknown field `%s` on `%s`" field_name class_name) ());
{ typ = TScalar "Int"; is_nil = false }) { typ = TScalar "Int"; is_nil = false }))
| _ -> { typ = TScalar "Int"; is_nil = false }) | _ -> { typ = TScalar "Int"; is_nil = false })
| Index (base, idx) -> | Index (base, idx) ->
let _ = typecheck_expr env base in let _ = typecheck_expr env base in
@ -272,14 +355,14 @@ let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Coll
let provided = List.map (fun (n, _) -> n) fields in let provided = List.map (fun (n, _) -> n) fields in
List.iter (fun (fname, _, _, _) -> List.iter (fun (fname, _, _, _) ->
if not (List.mem fname provided) then if not (List.mem fname provided) then
Diag.Collector.add _collector Diag.Collector.add collector
(Diag.error ~code:incomplete_ctor_code ~file:"" ~line:e.pos.line ~col:e.pos.col (Diag.error ~code:incomplete_ctor_code ~file ~line:e.pos.line ~col:e.pos.col
~message:(Printf.sprintf "missing field `%s` in constructor of `%s`" fname class_name) ()) ~message:(Printf.sprintf "missing field `%s` in constructor of `%s`" fname class_name) ())
) cls.fields; ) cls.fields;
{ typ = TScalar class_name; is_nil = false } { typ = TScalar class_name; is_nil = false }
with Not_found -> with Not_found ->
Diag.Collector.add _collector Diag.Collector.add collector
(Diag.error ~code:unknown_type_code ~file:"" ~line:e.pos.line ~col:e.pos.col (Diag.error ~code:unknown_type_code ~file ~line:e.pos.line ~col:e.pos.col
~message:(Printf.sprintf "unknown type `%s` in constructor" class_name) ()); ~message:(Printf.sprintf "unknown type `%s` in constructor" class_name) ());
{ typ = TScalar "Int"; is_nil = false }) { typ = TScalar "Int"; is_nil = false })
| DbStub _ -> { typ = TVoid; is_nil = false } | DbStub _ -> { typ = TVoid; is_nil = false }
@ -316,17 +399,20 @@ let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Coll
env env
in in
let typecheck_method (env : typ StringMap.t) (m : method_info) : bool = (* `self` is bound to the *enclosing class name*, not a literal "Self":
"Self" is not a declared class, so every `self.field` access used to
miss and report a bogus unknown-field error. *)
let typecheck_method ~(self_class : string) (env : typ StringMap.t) (m : method_info) : bool =
let param_env = List.fold_left (fun acc (name, ty, _) -> let param_env = List.fold_left (fun acc (name, ty, _) ->
StringMap.add name (resolve_field_ty ty) acc) env m.params in StringMap.add name (resolve_field_ty ty) acc) env m.params in
let env_with_self = StringMap.add "self" (TScalar "Self") param_env in let env_with_self = StringMap.add "self" (TScalar self_class) param_env in
let _ = List.fold_left typecheck_stmt env_with_self m.body in let _ = List.fold_left typecheck_stmt env_with_self m.body in
false false
in in
StringMap.iter (fun _name cls -> StringMap.iter (fun _name cls ->
let method_env = StringMap.empty in let method_env = StringMap.empty in
List.iter (fun m -> ignore (typecheck_method method_env m)) cls.methods List.iter (fun m -> ignore (typecheck_method ~self_class:cls.name method_env m)) cls.methods
) syms.classes; ) syms.classes;
StringMap.iter (fun _name (fn : free_fn_info) -> StringMap.iter (fun _name (fn : free_fn_info) ->
@ -341,9 +427,9 @@ let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Coll
Entry point Entry point
============================================================ *) ============================================================ *)
let typecheck (_prog : program) (_collector : Diag.Collector.t) : symbols * unit = let typecheck ~file (prog : program) (collector : Diag.Collector.t) : symbols * unit =
let syms = collect_declarations _prog _collector in let syms = collect_declarations ~file prog collector in
let () = typecheck_program _prog syms _collector in let () = typecheck_program ~file prog syms collector in
(syms, ()) (syms, ())
(* ============================================================ (* ============================================================

View file

@ -22,10 +22,16 @@
; target at the same relative path inside the sandbox) guarantees ; target at the same relative path inside the sandbox) guarantees
; "../bin/woc" resolves from this test's cwd exactly the way runner.ml ; "../bin/woc" resolves from this test's cwd exactly the way runner.ml
; assumes. ; assumes.
; Task 8: (source_tree fixtures) is the same freshness/dependency need
; as (source_tree golden) above, for compiler/test/fixtures/driver/ --
; the multi-file CLI-smoke fixtures (directory discovery, cross-file
; symbols, diagnostic ordering) that run_cli exercises against the
; actual woc binary rather than the single-.wo-file golden framework.
(test (test
(name runner) (name runner)
(modules runner) (modules runner)
(libraries woc_lib) (libraries woc_lib)
(deps (deps
(source_tree golden) (source_tree golden)
(source_tree fixtures)
../bin/woc)) ../bin/woc))

View file

@ -0,0 +1,3 @@
class Dup {
n: Int
}

View file

@ -0,0 +1,3 @@
class Dup {
s: Text
}

View file

@ -0,0 +1,3 @@
class Holder {
box: Box
}

View file

@ -0,0 +1,3 @@
class Box {
n: Int
}

View file

@ -0,0 +1,13 @@
class Box {
n: Int
}
fn consume(take b: Box) -> Int {
return b.n
}
fn run(take b: Box) -> Int {
let x = consume(b)
let y = consume(b)
return x + y
}

View file

@ -0,0 +1 @@
$

View file

@ -1,24 +1,24 @@
1:1 INTERFACE Priced 1:1 INTERFACE Priced
2:3 METHOD current_price() -> Money 2:3 METHOD current_price() -> Int
6:1 CLASS Product @table(name="products", index=[sku]) 6:1 CLASS Product @table(name="products", index=[sku])
7:3 FIELD id: Id 7:3 FIELD id: Id
8:3 FIELD sku: SKU @unique 8:3 FIELD sku: Text @unique
9:3 FIELD name: Text 9:3 FIELD name: Text
10:3 FIELD prices: multi Price 10:3 FIELD prices: multi Price
11:3 FIELD owner: ref Customer 11:3 FIELD owner: ref Customer
13:3 METHOD current_price() -> Money 13:3 METHOD current_price() -> Int
14:5 RETURN latest(self.prices).amount 14:5 RETURN latest(self.prices).amount
17:3 METHOD rename(name: Text) 17:3 METHOD rename(name: Text)
18:5 ASSIGN self.name = name 18:5 ASSIGN self.name = name
21:3 METHOD set_price(mut amount: Money) 21:3 METHOD set_price(mut amount: Int)
22:5 ASSIGN self.prices = amount 22:5 ASSIGN self.prices = amount
25:3 METHOD adopt(take other: Product) -> Product 25:3 METHOD adopt(take other: Product) -> Product
26:5 RETURN other 26:5 RETURN other
31:1 CLASS PriceCache @gc 31:1 CLASS PriceCache @gc
32:3 FIELD entries: map<SKU, Money> 32:3 FIELD entries: map<Text, Int>
35:1 TYPE Note 35:1 TYPE Note
36:3 FIELD id: Id 36:3 FIELD id: Id
37:3 FIELD body: Text 37:3 FIELD body: Text
38:3 FIELD created: Timestamp = now() 38:3 FIELD created: Timestamp = now()
41:1 METHOD discount(mut amount: Money, take pct: Int) -> Money 41:1 METHOD discount(mut amount: Int, take pct: Int) -> Int
42:3 RETURN amount 42:3 RETURN amount

View file

@ -1,16 +1,16 @@
interface Priced { interface Priced {
fn current_price() -> Money fn current_price() -> Int
} }
@table(name: "products", index: [sku]) @table(name: "products", index: [sku])
class Product { class Product {
id: Id id: Id
sku: SKU @unique sku: Text @unique
name: Text name: Text
prices: multi Price prices: multi Price
owner: ref Customer owner: ref Customer
fn current_price() -> Money { fn current_price() -> Int {
return latest(self.prices).amount; return latest(self.prices).amount;
} }
@ -18,7 +18,7 @@ class Product {
self.name = name; self.name = name;
} }
fn set_price(mut amount: Money) { fn set_price(mut amount: Int) {
self.prices = amount; self.prices = amount;
} }
@ -29,7 +29,7 @@ class Product {
@gc @gc
class PriceCache { class PriceCache {
entries: map<SKU, Money> entries: map<Text, Int>
} }
type Note { type Note {
@ -38,6 +38,6 @@ type Note {
created: Timestamp = now() created: Timestamp = now()
} }
fn discount(mut amount: Money, take pct: Int) -> Money { fn discount(mut amount: Int, take pct: Int) -> Int {
return amount; return amount;
} }

View file

@ -8,5 +8,5 @@ class Good {
} }
interface Broken2 { interface Broken2 {
fn oops(x Text) -> Money fn oops(x Text) -> Int
} }

View file

@ -0,0 +1,20 @@
owner-err/borrow-escape.wo:9:16: error WO-E304: borrow of `other` cannot be stored in `self.box` — borrows cannot outlive their scope
self.box = other
^
owner-err/borrow-escape.wo:8:12: `other` is borrowed here — declare it `take other: T` to pass ownership in
fn adopt(other: Box) {
^
owner-err/borrow-escape.wo:18:10: error WO-E304: borrow of `h.box` escapes `leak` — borrows cannot outlive their scope
return h.box
^
owner-err/borrow-escape.wo:17:9: `h` is borrowed here — declare it `take h: T` to pass ownership in
fn leak(h: Holder) -> Box {
^
owner-err/borrow-escape.wo:22:15: error WO-E304: borrow of `h.box` cannot be passed to `take b` — borrows cannot outlive their scope
return keep(h.box)
^
owner-err/borrow-escape.wo:21:10: `h` is borrowed here — declare it `take h: T` to pass ownership in
fn stash(h: Holder) -> Int {
^

View file

@ -0,0 +1,23 @@
class Box {
n: Int
}
class Holder {
box: Box
fn adopt(other: Box) {
self.box = other
}
}
fn keep(take b: Box) -> Int {
return 0
}
fn leak(h: Holder) -> Box {
return h.box
}
fn stash(h: Holder) -> Int {
return keep(h.box)
}

View file

@ -0,0 +1,13 @@
owner-err/borrowed-place-mutated.wo:15:16: error WO-E303: cannot mutate `h.box` while `h.box` is borrowed
return touch(h.box)
^
owner-err/borrowed-place-mutated.wo:14:3: `alias` borrows `h.box` here
let alias = h.box
^
owner-err/borrowed-place-mutated.wo:20:3: error WO-E303: cannot mutate `h.box` while `h.box` is borrowed
h.box = fresh
^
owner-err/borrowed-place-mutated.wo:19:3: `alias` borrows `h.box` here
let alias = h.box
^

View file

@ -0,0 +1,22 @@
class Box {
n: Int
}
class Holder {
box: Box
}
fn touch(mut b: Box) -> Int {
return 0
}
fn stale_arg(mut h: Holder) -> Int {
let alias = h.box
return touch(h.box)
}
fn stale_assign(mut h: Holder, take fresh: Box) -> Int {
let alias = h.box
h.box = fresh
return 0
}

View file

@ -0,0 +1,13 @@
owner-err/double-mut.wo:14:19: error WO-E303: cannot borrow `it` as `mut` twice in the same call
return swap(it, it)
^
owner-err/double-mut.wo:14:15: `it` first borrowed here
return swap(it, it)
^
owner-err/double-mut.wo:18:29: error WO-E303: cannot borrow `bag.items[i]` as `mut` twice in the same call
return swap(bag.items[i], bag.items[i])
^
owner-err/double-mut.wo:18:15: `bag.items[i]` first borrowed here
return swap(bag.items[i], bag.items[i])
^

View file

@ -0,0 +1,19 @@
class Item {
n: Int
}
class Bag {
items: multi Item
}
fn swap(mut a: Item, mut b: Item) -> Int {
return 0
}
fn same_local(take it: Item) -> Int {
return swap(it, it)
}
fn same_index(mut bag: Bag, i: Int) -> Int {
return swap(bag.items[i], bag.items[i])
}

View file

@ -0,0 +1,6 @@
owner-err/loop-move.wo:12:29: error WO-E301: use of `b` after it was moved on the previous loop iteration
total = total + consume(b)
^
owner-err/loop-move.wo:12:29: `b` is moved here, once per iteration
total = total + consume(b)
^

View file

@ -0,0 +1,15 @@
class Box {
n: Int
}
fn consume(take b: Box) -> Int {
return 0
}
fn drain(take b: Box, times: Int) -> Int {
let total = 0
while total < times {
total = total + consume(b)
}
return total
}

View file

@ -0,0 +1,6 @@
owner-err/move-while-borrowed.wo:15:18: error WO-E302: cannot move `b` while `b.inner` is borrowed
return consume(b)
^
owner-err/move-while-borrowed.wo:14:3: `alias` borrows `b.inner` here
let alias = b.inner
^

View file

@ -0,0 +1,16 @@
class Inner {
n: Int
}
class Box {
inner: Inner
}
fn consume(take b: Box) -> Int {
return 1
}
fn run(take b: Box) -> Int {
let alias = b.inner
return consume(b)
}

View file

@ -0,0 +1,6 @@
owner-err/use-after-move.wo:11:24: error WO-E301: use of `b` after it was moved
let second = consume(b)
^
owner-err/use-after-move.wo:10:23: `b` moved here
let first = consume(b)
^

View file

@ -0,0 +1,13 @@
class Box {
n: Int
}
fn consume(take b: Box) -> Int {
return b.n
}
fn run(take b: Box) -> Int {
let first = consume(b)
let second = consume(b)
return first + second
}

View file

@ -0,0 +1,23 @@
== MOVES ==
16:15 MOVE inner ASSIGN
51:20 MOVE a ARG(it)
57:7 MOVE a ASSIGN
62:18 MOVE a ARG(it)
== DROPS ==
12:3 SCOPE BODY [extra, spare]
16:7 OVERWRITE spare
18:5 SCOPE WHILE [tick]
21:5 SCOPE FOR [seen]
29:3 SCOPE THEN [tmp]
31:25 LIVE-MASK [tmp, b, a]
34:5 RETURN [b, a]
36:23 LIVE-MASK [b, a]
37:3 RETURN [b, a]
41:3 LIVE-MASK [r]
42:3 RETURN [r]
46:3 RETURN [it]
50:3 JOIN-DROP ELSE [a]
58:3 RETURN [a]
64:3 RETURN [a]
== RC ==
== RESIDUAL ==

View file

@ -0,0 +1,65 @@
class Item {
n: Int
}
fn size(it: Item) -> Int {
return it.n
}
class Bag {
items: multi Item
fn tidy(take spare: Item, flag: Bool) {
let extra = Item { n: 2 }
if flag {
let inner = Item { n: 3 }
spare = inner
}
while flag {
let tick = Item { n: 4 }
}
for it in self.items {
let seen = Item { n: 5 }
}
}
}
fn pick(take a: Item, take b: Item, flag: Bool) -> Int {
let total = 0
if flag {
let tmp = Item { n: 1 }
total = total + size(tmp)
}
if total > 0 {
return total
}
total = total + size(a)
return total
}
fn store(take r: Item) -> Int {
insert into rows values (1)
return 0
}
fn eat(take it: Item) -> Int {
return 0
}
fn conditional_move(take a: Item, flag: Bool) -> Int {
if flag {
let gone = eat(a)
}
return 0
}
fn self_assign(take a: Item) -> Int {
a = a
return 0
}
fn reinit_after_move(take a: Item) -> Int {
let gone = eat(a)
a = Item { n: 7 }
return 0
}

View file

@ -0,0 +1,11 @@
== MOVES ==
14:14 MOVE a LET
15:25 MOVE b CTOR(item)
16:17 MOVE c ARG(it)
17:10 MOVE bag RETURN
== DROPS ==
10:3 RETURN [it]
16:16 LIVE-MASK [bag, held]
17:3 RETURN [held]
== RC ==
== RESIDUAL ==

View file

@ -0,0 +1,18 @@
class Item {
n: Int
}
class Bag {
item: Item
}
fn stash(take it: Item) -> Int {
return 0
}
fn build(take a: Item, take b: Item, take c: Item) -> Bag {
let held = a
let bag = Bag { item: b }
let n = stash(c)
return bag
}

View file

@ -0,0 +1,6 @@
== MOVES ==
26:12 MOVE other RETURN
== DROPS ==
22:5 OVERWRITE self.prices
== RC ==
== RESIDUAL ==

View file

@ -0,0 +1,43 @@
interface Priced {
fn current_price() -> Int
}
@table(name: "products", index: [sku])
class Product {
id: Id
sku: Text @unique
name: Text
prices: multi Price
owner: ref Customer
fn current_price() -> Int {
return latest(self.prices).amount;
}
fn rename(name: Text) {
self.name = name;
}
fn set_price(mut amount: Int) {
self.prices = amount;
}
fn adopt(take other: Product) -> Product {
return other;
}
}
@gc
class PriceCache {
entries: map<Text, Int>
}
type Note {
id: Id
body: Text
created: Timestamp = now()
}
fn discount(mut amount: Int, take pct: Int) -> Int {
return amount;
}

View file

@ -0,0 +1,16 @@
== MOVES ==
== DROPS ==
16:15 LIVE-MASK [c:gc]
26:15 LIVE-MASK [c:gc]
35:18 LIVE-MASK [c:gc]
36:15 LIVE-MASK [c:gc]
== RC ==
15:3 ACQUIRE h.cache ELIDED
16:3 RELEASE c ELIDED
20:3 ACQUIRE h.cache KEPT
21:3 RELEASE c KEPT
21:10 ACQUIRE c KEPT
26:3 RELEASE c KEPT
34:3 ACQUIRE h.cache KEPT
36:3 RELEASE c KEPT
== RESIDUAL ==

View file

@ -0,0 +1,37 @@
@gc
class Cache {
n: Int
}
class Holder {
cache: Cache
}
fn touch(c: Cache) -> Int {
return c.n
}
fn balanced(h: Holder) -> Int {
let c = h.cache
return touch(c)
}
fn escaping(h: Holder) -> Cache {
let c = h.cache
return c
}
fn fresh() -> Int {
let c = Cache { n: 1 }
return touch(c)
}
fn replace(mut c: Cache) -> Int {
return 0
}
fn clobbered(h: Holder) -> Int {
let c = h.cache
let n = replace(h.cache)
return touch(c) + n
}

View file

@ -0,0 +1,7 @@
== MOVES ==
== DROPS ==
== RC ==
== RESIDUAL ==
22:22 RESIDUAL BORROW_X bag.items[i] vs BORROW_X bag.items[j]
24:24 RESIDUAL BORROW_X bag.items[i] vs BORROW_S bag.items[j]
32:14 RESIDUAL BORROW_X bag.items[i] vs BORROW_X bag.items[k]

View file

@ -0,0 +1,33 @@
class Item {
n: Int
}
class Bag {
items: multi Item
}
fn swap(mut a: Item, mut b: Item) -> Int {
return 0
}
fn bump(mut a: Item, b: Item) -> Int {
return 0
}
fn look(a: Item, b: Item) -> Int {
return 0
}
fn shuffle(mut bag: Bag, i: Int, j: Int) -> Int {
let dyn_pair = swap(bag.items[i], bag.items[j])
let const_pair = swap(bag.items[0], bag.items[1])
let mixed_pair = bump(bag.items[i], bag.items[j])
let shared_pair = look(bag.items[i], bag.items[j])
return dyn_pair + const_pair + mixed_pair + shared_pair
}
fn shuffle_via_aliases(mut bag: Bag, i: Int, k: Int) -> Int {
let r = bag.items[i]
let s = bag.items[k]
return swap(r, s)
}

View file

@ -37,6 +37,8 @@ module Lexer = Woc_lib.Lexer
module Ast = Woc_lib.Ast module Ast = Woc_lib.Ast
module Parser = Woc_lib.Parser module Parser = Woc_lib.Parser
module Dump = Woc_lib.Dump module Dump = Woc_lib.Dump
module Types = Woc_lib.Types
module Owner = Woc_lib.Owner
let read_file path = let read_file path =
let ic = open_in_bin path in let ic = open_in_bin path in
@ -777,11 +779,708 @@ let () =
check "cli smoke: --dump-ast stdout still carries the surviving decls on exit 1" check "cli smoke: --dump-ast stdout still carries the surviving decls on exit 1"
(stdout = Dump.dump_ast prog) (stdout = Dump.dump_ast prog)
(* ---- CLI smoke: multi-file driver (Task 8) ----------------------------
Everything above runs the CLI against a single file. These pin the
two new driver behaviors end to end, through the actual woc binary,
against fixtures under test/fixtures/driver/ rather than
test/golden/ -- see test/dune's comment on why: run_golden_dir's
one-.wo-file-per-fixture contract doesn't fit a fixture that *is*
several files compiling as one program. *)
let () =
(* Cross-file symbol resolution: a_uses.wo (discovered first,
alphabetically) types a field as `Box`, a class declared only in
b_declares.wo (discovered second). Compiled alone, a_uses.wo must
fail WO-E225 unknown-type -- the control proving this is a real
check, not one that would pass vacuously. Compiled as the
directory (both files, one program), it must be clean: every
file's declare-pass runs before any file's body-check, so
discovery order can't matter for whether the symbol resolves. *)
let alone = "fixtures/driver/crossfile/a_uses.wo" in
let exit_code, _, stderr = run_cli [ alone ] in
check "crossfile control: a_uses.wo alone fails (Box isn't declared here)"
(exit_code = 1);
check "crossfile control: it's WO-E225 unknown-type, not something else"
(find_substring ~needle:"WO-E225" stderr <> None
&& find_substring ~needle:"unknown type `Box`" stderr <> None);
let dir = "fixtures/driver/crossfile" in
let exit_code, _, stderr = run_cli [ dir ] in
check "crossfile: the directory (both files, merged symbols) compiles clean"
(exit_code = 0 && stderr = "")
let () =
(* Same directory, --dump-ast: proves both files were actually
discovered and parsed (not e.g. an empty file list "compiling
clean" vacuously), each under its own file_header. *)
let dir = "fixtures/driver/crossfile" in
let _, stdout, _ = run_cli [ "--dump-ast"; dir ] in
check "crossfile --dump-ast: both files' headers appear"
(find_substring ~needle:"=== fixtures/driver/crossfile/a_uses.wo ===" stdout <> None
&& find_substring ~needle:"=== fixtures/driver/crossfile/b_declares.wo ===" stdout <> None);
check "crossfile --dump-ast: both classes actually got dumped"
(find_substring ~needle:"CLASS Holder" stdout <> None
&& find_substring ~needle:"CLASS Box" stdout <> None)
let () =
(* Diagnostic ordering, discriminating (review follow-up, Important
3): the old version of this fixture had both files erroring at
the *same* pipeline stage (lexing), with the driver already
visiting files in sorted order for that stage -- insertion order
and (file, line, col) order coincided, so a broken sort could have
passed unnoticed. Here aaa_ownership.wo (sorts FIRST) has only a
*late*-stage error (WO-E301, found during the owner-analysis pass,
which runs over every file only after parse_all and typecheck_all
have both finished for every file) and zzz_lex.wo (sorts SECOND)
has only an *early*-stage error (WO-E001, found during parse_all,
the very first per-file pass). That means zzz_lex.wo's diagnostic
is *inserted into the collector first*, chronologically -- raw
insertion order is [zzz, aaa], the exact reverse of the required
[aaa, zzz] output order. Only a real (file, line, col) sort, not
insertion order, can produce the required order here. *)
let dir = "fixtures/driver/order" in
let exit_code, _, stderr = run_cli [ dir ] in
check "diagnostic order: exits 1 (one ownership error, one lex error)"
(exit_code = 1);
let aaa_idx = find_substring ~needle:"aaa_ownership.wo:11:19: error WO-E301" stderr in
let zzz_idx = find_substring ~needle:"zzz_lex.wo:1:1: error WO-E001" stderr in
check "diagnostic order: both files' diagnostics are present"
(aaa_idx <> None && zzz_idx <> None);
check
"diagnostic order: aaa_ownership.wo's *later-inserted* ownership error still prints \
first (file-sorts-first wins over insertion order)"
(match (aaa_idx, zzz_idx) with Some a, Some z -> a < z | _ -> false)
let () =
(* Cross-file symbol collision (review follow-up, Important 2):
a_first.wo and b_second.wo both declare `class Dup`, with
different fields, so a silent first-wins merge would let
b_second.wo's own field (`s: Text`) typecheck against
a_first.wo's shape without anyone being told the two `Dup`s were
never the same class. b_second.wo sorts *after* a_first.wo, so it
is the one reported (declaring second is what makes it the
collision), with a_first.wo as the related "first declared here"
site -- deterministic, not order-of-Hashtbl-iteration dependent,
since the outer walk is over the same sorted-by-discovery file
list every other multi-file check relies on. *)
let dir = "fixtures/driver/collision" in
let exit_code, _, stderr = run_cli [ dir ] in
check "collision: exits 1" (exit_code = 1);
check "collision: WO-E214 reported at the second (later-declaring) file"
(find_substring ~needle:"b_second.wo:1:1: error WO-E214: class `Dup` already declared in"
stderr
<> None);
check "collision: names the first-declaring file by path"
(find_substring ~needle:"already declared in `fixtures/driver/collision/a_first.wo`" stderr
<> None);
check "collision: related site points back at a_first.wo's own declaration"
(find_substring ~needle:"fixtures/driver/collision/a_first.wo:1:1: `Dup` first declared here"
stderr
<> None)
(* ---- direct typechecker assertions (Task 6b) --------------------------
No golden "types" stage exists yet: that would need `--dump-types`
wired into bin/main.ml and a diagnostics-aware dump_symbols in
dump.ml, neither of which the nullable-types-implementation plan's
New Requirements section asks for (it only names WO-W201/WO-E225 and
the scalar-list correction) -- building that CLI/dump plumbing now
would be scope creep beyond this task. These assertions instead pin
the Types.typecheck contract directly against the Collector, the
same way the lexer/parser sections above do. *)
let typecheck_str ~file src =
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file src in
let prog = Parser.parse collector ~file toks in
let syms, () = Types.typecheck ~file prog collector in
(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. *)
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 "Timestamp is a builtin scalar" (Types.is_builtin_scalar "Timestamp")
let () =
(* class Node { next: Node } -- direct self-reference, no @gc, no
@table, no @unique field: WO-W201 must fire, at the class's own
(real) file/line/col, and a warning-only run must still exit 0
(Diag.Collector's severity-keyed exit-code contract). *)
let path = "node.wo" in
let _, collector = typecheck_str ~file:path "class Node {\n next: Node\n}\n" in
let diags = Diag.Collector.diagnostics collector in
check_eq "gc-suggestion: exactly one diagnostic (WO-W201)" ~expected:1
~actual:(List.length diags) string_of_int;
(match diags with
| [ d ] ->
check "gc-suggestion: code is WO-W201" (d.Diag.code = "WO-W201");
check "gc-suggestion: severity is Warning" (d.Diag.severity = Diag.Warning);
check "gc-suggestion: real file/line/col (node.wo:1:1, the `class` token)"
(d.Diag.site.Diag.file = path && d.Diag.site.Diag.line = 1 && d.Diag.site.Diag.col = 1)
| _ -> check "gc-suggestion: exactly one diagnostic" false);
check_eq "gc-suggestion: a warning-only run exits 0, not 1" ~expected:0
~actual:(Diag.Collector.exit_code collector) string_of_int
let () =
(* @gc class Cache { next: Cache } -- same recursive shape as above,
but already @gc: WO-W201 must NOT fire. *)
let _, collector = typecheck_str ~file:"cache.wo" "@gc\nclass Cache {\n next: Cache\n}\n" in
check_eq "gc-suggestion: @gc class reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* @table(...) class Node2 { next: Node2 } -- recursive, but DB-backed
via @table: WO-W201 must NOT fire (plan: "@table -> must be owned"). *)
let _, collector =
typecheck_str ~file:"node2.wo"
"@table(name: \"nodes\")\nclass Node2 {\n next: Node2\n}\n"
in
check_eq "gc-suggestion: @table class reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Node3 { id: Id @unique; next: Node3 } -- recursive, but has a
@unique field (persistent identity): WO-W201 must NOT fire (plan's
"When NOT to emit" list, second bullet). *)
let _, collector =
typecheck_str ~file:"node3.wo"
"class Node3 {\n id: Id @unique\n next: Node3\n}\n"
in
check_eq "gc-suggestion: class with a @unique field reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Point { x: Int; y: Int } -- a plain data struct, no
recursive/shared fields: WO-W201 must NOT fire either. *)
let _, collector =
typecheck_str ~file:"point.wo" "class Point {\n x: Int\n y: Int\n}\n"
in
check_eq "gc-suggestion: simple data struct reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Calc { items: multi Item } (golden/ast/body-statements.wo's own
shape) -- a `multi` field of an UNRELATED type, not `multi Self`.
has_recursive_structure must key off self-reference, not "any multi
field": a bare `Ast.Multi _ -> true` would spuriously fire WO-W201
on every plain data class that merely holds a collection. *)
let _, collector =
typecheck_str ~file:"calc.wo" "class Calc {\n items: multi Item\n}\n"
in
check_eq "gc-suggestion: unrelated `multi Item` field reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Bucket { entries: map<Text, Item> } -- same over-trigger risk
for `map`, neither side self-referential. *)
let _, collector =
typecheck_str ~file:"bucket.wo" "class Bucket {\n entries: map<Text, Item>\n}\n"
in
check_eq "gc-suggestion: unrelated `map<Text, Item>` field reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Tree { children: multi Tree } -- `multi Self` must still fire
(the plan's own literal example of the heuristic). *)
let path = "tree.wo" in
let _, collector =
typecheck_str ~file:path "class Tree {\n children: multi Tree\n}\n"
in
let diags = Diag.Collector.diagnostics collector in
check_eq "gc-suggestion: `multi Self` still fires WO-W201" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] -> check "gc-suggestion: `multi Self` diagnostic is WO-W201" (d.Diag.code = "WO-W201")
| _ -> check "gc-suggestion: `multi Self` exactly one diagnostic" false
let () =
(* class BadExample { code: INVALID_TYPE } -- INVALID_TYPE is not a
builtin, class, or interface: WO-E225 must fire, at
the field's own real file/line/col, and this (an actual Error) must
exit 1. *)
let path = "bad-example.wo" in
let _, collector =
typecheck_str ~file:path "class BadExample {\n code: INVALID_TYPE\n}\n"
in
let diags = Diag.Collector.diagnostics collector in
check_eq "unknown-type: exactly one diagnostic (WO-E225)" ~expected:1
~actual:(List.length diags) string_of_int;
(match diags with
| [ d ] ->
check "unknown-type: code is WO-E225" (d.Diag.code = "WO-E225");
check "unknown-type: severity is Error" (d.Diag.severity = Diag.Error);
check "unknown-type: real file/line/col (bad-example.wo:2:3, the `code` field)"
(d.Diag.site.Diag.file = path && d.Diag.site.Diag.line = 2 && d.Diag.site.Diag.col = 3)
| _ -> check "unknown-type: exactly one diagnostic" false);
check_eq "unknown-type: an error run exits 1" ~expected:1
~actual:(Diag.Collector.exit_code collector) string_of_int
let () =
(* class Product { id: Id; sku: SKU; price: Money } -- SKU/Money are
ordinary unknown types (not a builtin, class, or interface), so both
fields must trip WO-E225. *)
let _, collector =
typecheck_str ~file:"product.wo"
"class Product {\n id: Id\n sku: SKU\n price: Money\n}\n"
in
let diags = Diag.Collector.diagnostics collector in
check_eq "unknown-type fields (SKU, Money): exactly two diagnostics" ~expected:2
~actual:(List.length diags) string_of_int;
check "unknown-type fields (SKU, Money): both are WO-E225"
(List.for_all (fun d -> d.Diag.code = "WO-E225") diags)
let () =
(* class Ring { next: ?Ring } -- INVALID_TYPE's sibling case through the
?T nullable wrapper this plan is named after: an unknown type inside
`?T` must still be caught, and a *known* one (here, Ring itself)
must not be a false positive. Also exercises forward references: B
is declared after A and must resolve since Pass 2 runs after all of
Pass 1 has completed. *)
let _, collector =
typecheck_str ~file:"ring.wo"
"class A {\n b: B\n}\nclass B {\n x: Int\n}\n"
in
check_eq "forward reference (A.b: B, B declared later): reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int;
let _, collector2 =
typecheck_str ~file:"nullable-unknown.wo" "class Ring {\n next: ?GHOST\n}\n"
in
let diags2 = Diag.Collector.diagnostics collector2 in
check_eq "unknown type inside ?T: exactly one WO-E225" ~expected:1
~actual:(List.length diags2) string_of_int;
match diags2 with
| [ d ] -> check "unknown type inside ?T: code is WO-E225" (d.Diag.code = "WO-E225")
| _ -> check "unknown type inside ?T: exactly one diagnostic" false
(* ---- direct ownership-pass assertions (Task 7) ------------------------
golden/owner-err/ already pins the *rendered* text of every must-fail
fixture (code, message, both sites, source excerpts). These assertions
pin the parts a rendered blob cannot state as a contract: that exactly
the intended WO-E3xx code fires, that the second site is really a
`related` entry on the same diagnostic rather than a separate one, that
the exemptions (@gc, scalars) produce nothing at all, and that the four
emitter tables carry real AST node ids (positions are what goldens
pin, ids are what plan 3 keys on). *)
let owner_str ~file src =
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file src in
let prog = Parser.parse collector ~file toks in
let syms, () = Types.typecheck ~file prog collector in
let tables = Owner.analyze ~file prog syms collector in
(tables, collector)
let is_ownership_code (code : string) =
String.length code >= 5 && String.sub code 0 5 = Diag.ownership_prefix
let ownership_diags collector =
Diag.Collector.diagnostics collector
|> List.filter (fun (d : Diag.t) -> is_ownership_code d.Diag.code)
let all_diags collector = Diag.Collector.diagnostics collector
(* Analyzes a fixture from golden/owner-err/ and returns its ownership
diagnostics; also asserts no *other* stage complained, so a fixture can
never pass its ownership assertions while quietly tripping a WO-E2xx. *)
let owner_err_fixture name =
let path = "golden/owner-err/" ^ name ^ ".wo" in
let _, collector = owner_str ~file:path (read_file path) in
let all = Diag.Collector.diagnostics collector in
let own = List.filter (fun (d : Diag.t) -> is_ownership_code d.Diag.code) all in
check_eq (name ^ ": every diagnostic is an ownership diagnostic")
~expected:(List.length all) ~actual:(List.length own) string_of_int;
own
(* A two-site ownership error: the code, the primary site's line/col, and
the single related site's line/col. *)
let check_site tag ~code ~line ~col ~rel_line ~rel_col (d : Diag.t) =
check (tag ^ ": code is " ^ code) (d.Diag.code = code);
check (tag ^ ": severity is Error") (d.Diag.severity = Diag.Error);
check_eq (tag ^ ": primary line") ~expected:line ~actual:d.Diag.site.Diag.line string_of_int;
check_eq (tag ^ ": primary col") ~expected:col ~actual:d.Diag.site.Diag.col string_of_int;
match d.Diag.related with
| [ r ] ->
check_eq (tag ^ ": related line") ~expected:rel_line ~actual:r.Diag.site.Diag.line
string_of_int;
check_eq (tag ^ ": related col") ~expected:rel_col ~actual:r.Diag.site.Diag.col string_of_int;
check (tag ^ ": related site carries a label") (r.Diag.label <> "")
| rs ->
check_eq (tag ^ ": exactly one related site") ~expected:1 ~actual:(List.length rs)
string_of_int
let single tag (ds : Diag.t list) (f : Diag.t -> unit) =
match ds with
| [ d ] -> f d
| _ ->
check_eq (tag ^ ": exactly one ownership error") ~expected:1 ~actual:(List.length ds)
string_of_int
(* Same shape for table entries: assert there is exactly one, then assert
things about it. *)
let single_site tag (xs : 'a list) (f : 'a -> unit) =
match xs with
| [ x ] -> f x
| _ -> check_eq (tag ^ ": exactly one") ~expected:1 ~actual:(List.length xs) string_of_int
let () =
(* `consume(b)` twice: the second one uses a moved value. Sites are the
two `b` argument tokens, 11:24 and 10:23. *)
single "use-after-move" (owner_err_fixture "use-after-move") (fun d ->
check_site "use-after-move" ~code:"WO-E301" ~line:11 ~col:24 ~rel_line:10 ~rel_col:23 d)
let () =
(* `let alias = b.inner` borrows into b; `consume(b)` then moves b out
from under that borrow. Primary at the moved argument (15:18),
related at the borrowing `let` (14:3). *)
single "move-while-borrowed" (owner_err_fixture "move-while-borrowed") (fun d ->
check_site "move-while-borrowed" ~code:"WO-E302" ~line:15 ~col:18 ~rel_line:14 ~rel_col:3 d)
let () =
(* Two provable aliases: `swap(it, it)` (same root) and
`swap(bag.items[i], bag.items[i])` (same root *and* the same runtime
index expression, so the analysis proves the alias rather than
deferring to a runtime check — contrast golden/owner/residual.wo). *)
match owner_err_fixture "double-mut" with
| [ a; b ] ->
check_site "double-mut (same local)" ~code:"WO-E303" ~line:14 ~col:19 ~rel_line:14 ~rel_col:15 a;
check_site "double-mut (same runtime index)" ~code:"WO-E303" ~line:18 ~col:29 ~rel_line:18
~rel_col:15 b
| ds ->
check_eq "double-mut: exactly two ownership errors" ~expected:2 ~actual:(List.length ds)
string_of_int
let () =
(* The same rule across statements rather than inside one call: a
let-bound alias keeps its borrow alive, so the borrowed place may be
neither exclusively re-borrowed (`touch(h.box)`) nor assigned to
(`h.box = fresh`) while the alias is in scope. *)
match owner_err_fixture "borrowed-place-mutated" with
| [ arg; assign ] ->
check_site "borrowed-place-mutated (`mut` argument)" ~code:"WO-E303" ~line:15 ~col:16
~rel_line:14 ~rel_col:3 arg;
check_site "borrowed-place-mutated (assignment)" ~code:"WO-E303" ~line:20 ~col:3 ~rel_line:19
~rel_col:3 assign
| ds ->
check_eq "borrowed-place-mutated: exactly two ownership errors" ~expected:2
~actual:(List.length ds) string_of_int
let () =
(* The three ways a borrow can escape (spec rule 3): stored into a
field, returned, moved out to a `take` parameter. *)
match owner_err_fixture "borrow-escape" with
| [ store; ret; take ] ->
check_site "borrow-escape (stored in a field)" ~code:"WO-E304" ~line:9 ~col:16 ~rel_line:8
~rel_col:12 store;
check_site "borrow-escape (returned)" ~code:"WO-E304" ~line:18 ~col:10 ~rel_line:17 ~rel_col:9
ret;
check_site "borrow-escape (moved to a `take` parameter)" ~code:"WO-E304" ~line:22 ~col:15
~rel_line:21 ~rel_col:10 take;
(* spec section 6's own wording for this diagnostic *)
check "borrow-escape: names the place and the function it escapes"
(Option.is_some (find_substring ~needle:"borrow of `h.box` escapes `leak`" ret.Diag.message))
| ds ->
check_eq "borrow-escape: exactly three ownership errors" ~expected:3 ~actual:(List.length ds)
string_of_int
let () =
(* The loop fixpoint's reason for existing: the move is legal on the
first iteration and a use-after-move on every later one, so both
sites land on the same token — the message says so. *)
single "loop-move" (owner_err_fixture "loop-move") (fun d ->
check_site "loop-move" ~code:"WO-E301" ~line:12 ~col:29 ~rel_line:12 ~rel_col:29 d;
check "loop-move: message names the previous iteration"
(Option.is_some (find_substring ~needle:"previous loop iteration" d.Diag.message)))
let () =
(* @gc is exempt from all of it (spec rule 5): the exact shape that is
WO-E301 above is silent here, and produces no move-table entries
either — a @gc transfer is an rc site, not a move. *)
let src =
"@gc\n\
class Cache {\n\
\ n: Int\n\
}\n\
\n\
fn keep(take c: Cache) -> Int {\n\
\ return 0\n\
}\n\
\n\
fn twice(take c: Cache) -> Int {\n\
\ let a = keep(c)\n\
\ let b = keep(c)\n\
\ return a + b\n\
}\n"
in
let tables, coll = owner_str ~file:"gc-exempt.wo" src in
check_eq "@gc exemption: no ownership diagnostics" ~expected:0
~actual:(List.length (ownership_diags coll)) string_of_int;
check_eq "@gc exemption: no move-table entries" ~expected:0
~actual:(List.length tables.Owner.moves) string_of_int
let () =
(* Scalars are copied, never moved: same shape, nothing reported and
nothing in any table. *)
let src =
"fn add(take n: Int) -> Int {\n\
\ return n\n\
}\n\
\n\
fn twice(take n: Int) -> Int {\n\
\ let a = add(n)\n\
\ let b = add(n)\n\
\ return a + b\n\
}\n"
in
let tables, coll = owner_str ~file:"scalar-exempt.wo" src in
check_eq "scalar exemption: no ownership diagnostics" ~expected:0
~actual:(List.length (ownership_diags coll)) string_of_int;
check_eq "scalar exemption: no move-table entries" ~expected:0
~actual:(List.length tables.Owner.moves) string_of_int;
check_eq "scalar exemption: no drop-table entries" ~expected:0
~actual:(List.length tables.Owner.drops) string_of_int
let () =
(* `?T` carries T's ownership exactly — nil is just a value, so the
use-after-move fires through the nullable wrapper too. *)
let src =
"class Box {\n\
\ n: Int\n\
}\n\
\n\
fn consume(take b: ?Box) -> Int {\n\
\ return 0\n\
}\n\
\n\
fn run(take b: ?Box) -> Int {\n\
\ let x = consume(b)\n\
\ let y = consume(b)\n\
\ return x + y\n\
}\n"
in
let tables, coll = owner_str ~file:"nullable.wo" src in
single "?T ownership" (ownership_diags coll) (fun d ->
check "?T ownership: `?Box` is moved and use-after-move fires"
(d.Diag.code = "WO-E301" && d.Diag.site.Diag.line = 11));
check_eq "?T ownership: the first `?Box` pass is a real transfer" ~expected:1
~actual:(List.length tables.Owner.moves) string_of_int
let () =
(* Every decision golden must be completely clean — no ownership error,
and no WO-E2xx/WO-W2xx either, so a table golden can never drift into
documenting the output of a broken program. *)
List.iter
(fun name ->
let path = "golden/owner/" ^ name ^ ".wo" in
let _, coll = owner_str ~file:path (read_file path) in
check_eq ("decision golden " ^ name ^ ": reports nothing") ~expected:0
~actual:(List.length (all_diags coll)) string_of_int)
[ "moves"; "drops"; "rc"; "residual"; "pricing-demo" ]
let () =
(* The tables are keyed by AST node id for plan 3 (goldens can only pin
positions — ids churn), so assert the ids are actually populated. *)
let path = "golden/owner/moves.wo" in
let tables, _ = owner_str ~file:path (read_file path) in
check_eq "moves table: four transfers (LET, CTOR field, `take` arg, RETURN)" ~expected:4
~actual:(List.length tables.Owner.moves) string_of_int;
check "moves table: every entry carries a real AST node id"
(List.for_all (fun (m : Owner.move_site) -> m.Owner.mv_node > 0) tables.Owner.moves);
check "drops table: every entry carries a real AST node id"
(List.for_all (fun (d : Owner.drop_site) -> d.Owner.dr_node > 0) tables.Owner.drops);
check "drops table: every listed local names its declaring node (names alone shadow)"
(List.for_all
(fun (d : Owner.drop_site) ->
List.for_all (fun (i : Owner.drop_item) -> i.Owner.di_node > 0) d.Owner.dr_items)
tables.Owner.drops);
let rc_path = "golden/owner/rc.wo" in
let rc_tables, _ = owner_str ~file:rc_path (read_file rc_path) in
check "rc table: every entry carries a real AST node id"
(List.for_all (fun (r : Owner.rc_site) -> r.Owner.rc_node > 0) rc_tables.Owner.rcs);
check "rc table: the balanced pair is elided, the escaping one kept"
(List.exists (fun (r : Owner.rc_site) -> r.Owner.rc_elided) rc_tables.Owner.rcs
&& List.exists (fun (r : Owner.rc_site) -> not r.Owner.rc_elided) rc_tables.Owner.rcs);
let res_path = "golden/owner/residual.wo" in
let res_tables, _ = owner_str ~file:res_path (read_file res_path) in
check_eq "residual table: only the runtime-index pairs are residual" ~expected:3
~actual:(List.length res_tables.Owner.residuals) string_of_int;
check "residual table: every entry names its region and both operand nodes"
(List.for_all
(fun (r : Owner.residual_site) ->
r.Owner.rs_node > 0 && r.Owner.rs_a_node > 0 && r.Owner.rs_b_node > 0
&& r.Owner.rs_a_node <> r.Owner.rs_b_node)
res_tables.Owner.residuals)
(* ---- review follow-ups (Task 7 review, 1 critical + 5 important) ------
Each block below pins one reviewed defect at the level the golden text
cannot state: an *absent* table entry, or a verdict (ELIDED vs KEPT)
that would still render as a plausible-looking line if it flipped. *)
let drop_sites_of tables kind_matches =
List.filter (fun (d : Owner.drop_site) -> kind_matches d.Owner.dr_kind) tables.Owner.drops
let names_of (d : Owner.drop_site) =
List.map (fun (i : Owner.drop_item) -> i.Owner.di_name) d.Owner.dr_items
let () =
(* CRITICAL: a double-`mut` reached through two `let`-bound aliases used to
compare the syntactic roots `r` and `s`, conclude Disjoint, and emit
neither a diagnostic nor a residual site — so nobody, compiler or VM,
enforced the rule. Canonicalized places make it identical to the direct
`swap(bag.items[i], bag.items[k])` form. *)
let path = "golden/owner/residual.wo" in
let tables, coll = owner_str ~file:path (read_file path) in
check_eq "aliased double-mut: no diagnostic (unprovable, so the VM decides)" ~expected:0
~actual:(List.length (ownership_diags coll)) string_of_int;
let via_alias =
List.filter (fun (r : Owner.residual_site) -> r.Owner.rs_pos.Ast.line = 32)
tables.Owner.residuals
in
single_site "aliased double-mut: exactly one residual site" via_alias (fun r ->
check "aliased double-mut: both sides exclusive"
(r.Owner.rs_a_kind = Owner.AExcl && r.Owner.rs_b_kind = Owner.AExcl);
check "aliased double-mut: rendered canonically, not as the alias names"
(r.Owner.rs_a = "bag.items[i]" && r.Owner.rs_b = "bag.items[k]"));
check "residual table: a move is never a residual side (a whole local always decides)"
(List.for_all
(fun (r : Owner.residual_site) ->
r.Owner.rs_a_kind <> Owner.AMove && r.Owner.rs_b_kind <> Owner.AMove)
tables.Owner.residuals);
check "residual table: at least one side of every pair is exclusive"
(List.for_all
(fun (r : Owner.residual_site) ->
r.Owner.rs_a_kind = Owner.AExcl || r.Owner.rs_b_kind = Owner.AExcl)
tables.Owner.residuals)
let () =
(* Using a borrow alongside the container it borrows from is what the
binding is for, so it must stay silent — the guard that keeps the
critical fix above from turning every `for` cursor into a conflict. *)
let src =
"class Item {\n\
\ n: Int\n\
}\n\
\n\
class Bag {\n\
\ items: multi Item\n\
\n\
\ fn eat(mut e: Item) -> Int {\n\
\ self.items = self.items\n\
\ return 0\n\
\ }\n\
}\n\
\n\
fn cursor_reuse(mut bag: Bag) -> Int {\n\
\ let total = 0\n\
\ for it in bag.items {\n\
\ total = total + bag.eat(it)\n\
\ }\n\
\ return total\n\
}\n"
in
let tables, coll = owner_str ~file:"cursor.wo" src in
let in_loop =
List.filter (fun (d : Diag.t) -> d.Diag.site.Diag.line = 17) (ownership_diags coll)
in
check_eq "cursor reuse: passing the cursor to a method on its own container is silent"
~expected:0 ~actual:(List.length in_loop) string_of_int;
check_eq "cursor reuse: and needs no runtime borrow either" ~expected:0
~actual:(List.length tables.Owner.residuals) string_of_int
let () =
let path = "golden/owner/drops.wo" in
let tables, _ = owner_str ~file:path (read_file path) in
(* IMPORTANT: conditionally moved value. The join records Moved, so the
path that did *not* move it must drop it at that branch's end or the
value leaks. `conditional_move` has no `else`, so the drop is anchored
at the `if` itself (line 50). *)
let joins = drop_sites_of tables (function Owner.DBranchJoin _ -> true | _ -> false) in
single_site "join normalization: exactly one JOIN-DROP in this fixture" joins (fun d ->
check "join normalization: on the implicit else branch"
(d.Owner.dr_kind = Owner.DBranchJoin "ELSE");
check_eq "join normalization: anchored at the `if`" ~expected:50
~actual:d.Owner.dr_pos.Ast.line string_of_int;
check "join normalization: drops the value the then-branch moved" (names_of d = [ "a" ]));
check "join normalization: and the value is not dropped again on the moving path"
(not
(List.exists
(fun (d : Owner.drop_site) ->
d.Owner.dr_kind = Owner.DReturn && d.Owner.dr_pos.Ast.line = 53)
tables.Owner.drops));
(* IMPORTANT: `a = a` used to record OVERWRITE for the value it replaces
*and* keep `a` live — two drops of one value. *)
let overwrites = drop_sites_of tables (fun k -> k = Owner.DOverwrite) in
check "self-assignment: records no OVERWRITE (target and value are one storage)"
(not (List.exists (fun (d : Owner.drop_site) -> d.Owner.dr_pos.Ast.line = 57) overwrites));
check "self-assignment: the value is still dropped exactly once, at the return"
(List.exists
(fun (d : Owner.drop_site) ->
d.Owner.dr_kind = Owner.DReturn && d.Owner.dr_pos.Ast.line = 58 && names_of d = [ "a" ])
tables.Owner.drops);
(* IMPORTANT: re-initialising a moved-out local makes it live again, so it
must reappear in a later drop set (self-found bug 1, now pinned). *)
check "re-init after move: the reassigned local is dropped at the return"
(List.exists
(fun (d : Owner.drop_site) ->
d.Owner.dr_kind = Owner.DReturn && d.Owner.dr_pos.Ast.line = 64 && names_of d = [ "a" ])
tables.Owner.drops);
check "re-init after move: no OVERWRITE, since the moved-out local held nothing"
(not (List.exists (fun (d : Owner.drop_site) -> d.Owner.dr_pos.Ast.line = 63) overwrites))
let () =
(* IMPORTANT: a `mut` argument means the callee may replace what the place
holds. For a @gc place that invalidates rc elision — the elided
increment would leave the alias as the last reference to a freed
object. The clobber therefore has to happen before the ownership class
is consulted, since @gc arguments create no access entry at all. *)
let path = "golden/owner/rc.wo" in
let tables, _ = owner_str ~file:path (read_file path) in
let at line =
List.filter (fun (r : Owner.rc_site) -> r.Owner.rc_pos.Ast.line = line) tables.Owner.rcs
in
single_site "rc: `balanced` has one ACQUIRE" (at 15) (fun r ->
check "rc: an alias whose source is never clobbered is ELIDED" r.Owner.rc_elided);
single_site "rc: `clobbered` has one ACQUIRE" (at 34) (fun r ->
check "rc: an alias whose source root is passed `mut` is KEPT"
(not r.Owner.rc_elided))
let () =
let path = "golden/owner/moves.wo" in
let exit_code, stdout, stderr = run_cli [ "--dump-owner"; path ] in
check "cli smoke: --dump-owner on a clean file exits 0" (exit_code = 0);
check "cli smoke: clean-file --dump-owner writes nothing to stderr" (stderr = "");
let tables, _ = owner_str ~file:path (read_file path) in
check "cli smoke: --dump-owner stdout matches the in-process table dump exactly"
(stdout = Dump.dump_owner tables)
let () =
let path = "golden/owner-err/use-after-move.wo" in
let exit_code, stdout, stderr = run_cli [ "--dump-owner"; path ] in
check "cli smoke: an ownership-error file exits 1, not 0" (exit_code = 1);
check "cli smoke: the WO-E301 diagnostic goes to stderr"
(Option.is_some (find_substring ~needle:"WO-E301" stderr));
check "cli smoke: stdout still carries the tables on exit 1"
(Option.is_some (find_substring ~needle:"== RESIDUAL ==" stdout))
(* ---- golden-directory walk ------------------------------------------ *) (* ---- golden-directory walk ------------------------------------------ *)
(* Each stage directory under golden/ names one `woc --dump-*` flag. (* Each stage directory under golden/ names one `woc --dump-*` flag.
"tokens" (Task 3) and "ast" (Task 4) exist today; later tasks add "tokens" (Task 3), "ast" (Task 4) and "owner" (Task 7) exist today; a
"types", "owner" alongside their own dump function in dump.ml. *) later task may add "types" alongside its own dump function in dump.ml.
"owner-err" is the one stage whose produced text is *not* a dump: it is
the fully rendered diagnostic report (the same text --dump-owner writes
to stderr, source excerpts and related sites included). The ownership
must-fail suite's whole contract is the message — both sites, the right
code, no unrelated noise from earlier stages — so the golden has to pin
the rendering, not a table. *)
let run_stage ~stage ~file ~src : string = let run_stage ~stage ~file ~src : string =
match stage with match stage with
| "tokens" -> | "tokens" ->
@ -793,6 +1492,13 @@ let run_stage ~stage ~file ~src : string =
let toks = Lexer.tokenize collector ~file src in let toks = Lexer.tokenize collector ~file src in
let prog = Parser.parse collector ~file toks in let prog = Parser.parse collector ~file toks in
Dump.dump_ast prog Dump.dump_ast prog
| "owner" ->
let tables, _ = owner_str ~file src in
Dump.dump_owner tables
| "owner-err" ->
let _, collector = owner_str ~file src in
let lookup f = if f = file then Some src else None in
Diag.Collector.render_all collector lookup ^ "\n"
| other -> | other ->
failwith (Printf.sprintf "runner: unknown golden stage directory %S" other) failwith (Printf.sprintf "runner: unknown golden stage directory %S" other)

View file

@ -15,11 +15,11 @@ type Order {
line_items: [{ line_items: [{
product: ref Product product: ref Product
qty: Int @check(> 0) qty: Int @check(> 0)
unit_price: Money -- captured at checkout time unit_price: Int -- captured at checkout time
}] }]
-- Computed: re-evaluated on read. Flip to `@materialized` if it gets hot. -- Computed: re-evaluated on read. Flip to `@materialized` if it gets hot.
total: Money = sum(line_items.*.qty * line_items.*.unit_price) total: Int = sum(line_items.*.qty * line_items.*.unit_price)
placed_at: Timestamp = now() placed_at: Timestamp = now()
paid_at: Timestamp? paid_at: Timestamp?

View file

@ -5,9 +5,9 @@
type Product { type Product {
id: Id id: Id
sku: SKU @unique sku: Text @unique
name: Text name: Text
price: Money -- minor units (cents); `Money` is a stdlib scalar price: Int -- minor units (cents)
meta: { -- embedded document meta: { -- embedded document
description: Markdown description: Markdown

View file

@ -11,7 +11,7 @@ type Purchase link Customer -> Product {
order: ref Order -- FK so you can query order: ref Order -- FK so you can query
-- `Customer.purchased{ order.status == Paid }` -- `Customer.purchased{ order.status == Paid }`
qty: Int @check(> 0) qty: Int @check(> 0)
unit_price: Money -- captured at checkout time unit_price: Int -- captured at checkout time
at: Timestamp = now() at: Timestamp = now()
policy read for role Admin policy read for role Admin

View file

@ -14,13 +14,13 @@
class Price { class Price {
id: Id id: Id
product: ref Product -- owning product (foreign key) product: ref Product -- owning product (foreign key)
amount: Money -- minor units (cents); stdlib scalar amount: Int -- minor units (cents)
currency: Text = "EUR" currency: Text = "EUR"
at: Timestamp = now() at: Timestamp = now()
-- Method: implicit `self` receiver, like Go methods — no class hierarchy, -- Method: implicit `self` receiver, like Go methods — no class hierarchy,
-- just behavior attached to a row. Pure computation, so no `in txn`. -- just behavior attached to a row. Pure computation, so no `in txn`.
fn discounted(pct: Int) -> Money { fn discounted(pct: Int) -> Int {
return self.amount * (100 - pct) / 100; return self.amount * (100 - pct) / 100;
} }

View file

@ -7,13 +7,13 @@
class Product { class Product {
id: Id id: Id
sku: SKU @unique sku: Text @unique
name: Text name: Text
prices: multi Price -- append-only price history prices: multi Price -- append-only price history
-- Row-scoped transactional method (13b): runs inside a snapshot of the -- Row-scoped transactional method (13b): runs inside a snapshot of the
-- receiving row, exposed as POST /api/products/:id/current_price. -- receiving row, exposed as POST /api/products/:id/current_price.
fn current_price() -> Money in txn { fn current_price() -> Int in txn {
return latest(self.prices).amount; return latest(self.prices).amount;
} }
@ -21,7 +21,7 @@ class Product {
-- commit or roll back together (one WAL frame); the commit pushes one -- commit or roll back together (one WAL frame); the commit pushes one
-- delta to every subscriber of this product (13c) and patches every open -- delta to every subscriber of this product (13c) and patches every open
-- pricing screen (13d). -- pricing screen (13d).
fn set_price(amount: Money) in txn { fn set_price(amount: Int) in txn {
assert amount > 0 otherwise abort "price must be positive" assert amount > 0 otherwise abort "price must be positive"
insert Price { product: self.id, amount: amount }; insert Price { product: self.id, amount: amount };
} }

View file

@ -4,7 +4,7 @@
> >
> **Style rule (user convention):** concept, reason, and required behavior in words only; the executor writes the code. > **Style rule (user convention):** concept, reason, and required behavior in words only; the executor writes the code.
**Goal:** Plan 8 — implement every **adopt** row of the systems-track spec's Haxe keyword verdict table: the language grows switch expressions, records, optionals, try/catch/throw, enum payloads, abstracts, static members, using-extensions, modules, `is`, `pub(read)`, build flags, and interpolation — with the reject rows enforced as diagnostics. **Goal:** Plan 8 — implement every **adopt** row of the systems-track spec's Haxe keyword verdict table: the language grows switch expressions, records, optionals, try/catch/throw, enum payloads, static members, using-extensions, modules, `is`, `pub(read)`, build flags, and interpolation — with the reject rows enforced as diagnostics. (`abstract` was an adopt row until 2026-08-10; it is now a reject row — see Task 7.)
**Architecture:** Plan 8 of the roadmap. Depends on OOP plans 1–3 (woc + wovm + corpus). Overwhelmingly compiler work in `compiler/src/`; the VM changes are exactly three, called out in their tasks: catch frames (try/catch), variant objects (enum payloads), and boxed optionals for scalars. Everything else lowers onto existing opcodes. The spec's verdict table (`docs/superpowers/specs/2026-08-01-systems-track-design.md` Part 1) is normative — this plan sequences it. **Architecture:** Plan 8 of the roadmap. Depends on OOP plans 1–3 (woc + wovm + corpus). Overwhelmingly compiler work in `compiler/src/`; the VM changes are exactly three, called out in their tasks: catch frames (try/catch), variant objects (enum payloads), and boxed optionals for scalars. Everything else lowers onto existing opcodes. The spec's verdict table (`docs/superpowers/specs/2026-08-01-systems-track-design.md` Part 1) is normative — this plan sequences it.
@ -77,17 +77,19 @@ docs/plan/oop-vm/00-wob-format.md grows with the three VM changes
**Concept & reason:** the null-safety story. `?T` admits nil; `T` never does — the diagnostic-enforced boundary. Representation: heap kinds use the zero word (the VM's existing null checks already trap on it — optionals make those unreachable by typing); scalar optionals box into a one-field cell (the VM piece — obj.c gains the box; format doc notes the convention). Narrowing: comparing against `null` narrows in the branch (`if x != null` makes `x` a `T` inside — Haxe's exact idiom); using a `?T` un-narrowed where `T` is required diagnoses. Stdlib returns (plan 9) and record `?fields` (Task 4) type as `?T` from here on. **Concept & reason:** the null-safety story. `?T` admits nil; `T` never does — the diagnostic-enforced boundary. Representation: heap kinds use the zero word (the VM's existing null checks already trap on it — optionals make those unreachable by typing); scalar optionals box into a one-field cell (the VM piece — obj.c gains the box; format doc notes the convention). Narrowing: comparing against `null` narrows in the branch (`if x != null` makes `x` a `T` inside — Haxe's exact idiom); using a `?T` un-narrowed where `T` is required diagnoses. Stdlib returns (plan 9) and record `?fields` (Task 4) type as `?T` from here on.
**Next work item:** this task is next up — `?T` is plumbed (lexer/token/AST/parser/dump) but unenforced (E211/E212/E213 dead, probe exits 0 with zero diagnostics per `docs/plan/compiler/nullable-types-implementation.md`), and it blocks the log-watcher port (story iterations 5–6), which uses optionals throughout in place of the Haxe original's sentinel values.
- [ ] Failing fixtures: narrowing goldens; un-narrowed-use must-fail; nil propagation through record optional fields; boxed scalar optional round-trip; assignment of null to plain `T` must-fail. - [ ] Failing fixtures: narrowing goldens; un-narrowed-use must-fail; nil propagation through record optional fields; boxed scalar optional round-trip; assignment of null to plain `T` must-fail.
- [ ] Implement; green. - [ ] Implement; green.
- [ ] Record commit draft: `feat: ?T optionals — null-narrowing control flow, forced handling diagnostics, zero-word heap nil + boxed scalar cells; record ?fields and future stdlib returns typed ?T.` - [ ] Record commit draft: `feat: ?T optionals — null-narrowing control flow, forced handling diagnostics, zero-word heap nil + boxed scalar cells; record ?fields and future stdlib returns typed ?T.`
### Task 7: `abstract` newtypes + `is` ### Task 7: `is`
**Concept & reason:** type-safety sugar pair, compiler-only. Abstracts: `abstract Money = Int` — a distinct compile-time type over a scalar representation, zero-cost at runtime (registers hold the raw scalar); mixing `Money` and `Int` diagnoses unless the declaration lists explicit `from`/`to` conversions; the existing stdlib scalars (Money, SKU) re-declare in-language, removing magic. `is`: runtime test on union values (variant membership — reads the Task-4 tag) and interface values (vtable membership — reuses the loader's satisfaction data); statically-decidable `is` diagnoses as always-true/false instead of compiling to a runtime check. **Concept & reason:** runtime type test, compiler-only. `is`: runtime test on union values (variant membership — reads the Task-4 tag) and interface values (vtable membership — reuses the loader's satisfaction data); statically-decidable `is` diagnoses as always-true/false instead of compiling to a runtime check. `abstract` newtypes were this task's other half; dropped — see the systems-track verdict table (adopt → reject, 2026-08-10 money-sku-float-removal change): a distinct scalar type adds a conversion surface without buying safety this language needs, and the compiler's own Money/SKU stopgap allowlist proved the cost was real (compiler/src/types.ml). Domain scalars stay plain `Int`/`Text`.
- [ ] Failing fixtures: abstract mixing must-fail + explicit-conversion golden; zero-cost proof (disassembly golden shows raw scalar ops); `is` on unions/interfaces; statically-known `is` must-fail. - [ ] Failing fixtures: `is` on unions/interfaces; statically-known `is` must-fail.
- [ ] Implement; green. - [ ] Implement; green.
- [ ] Record commit draft: `feat(compiler): abstract newtypes (zero-cost, explicit from/to; Money/SKU de-magicked) + is on unions/interfaces with static-decidability diagnostic.` - [ ] Record commit draft: `feat(compiler): is on unions/interfaces with static-decidability diagnostic.`
### Task 8: `static` members, `using` extensions, `pub(read)` accessors ### Task 8: `static` members, `using` extensions, `pub(read)` accessors
@ -99,7 +101,7 @@ docs/plan/oop-vm/00-wob-format.md grows with the three VM changes
### Task 9: `#if` build flags + reject-row enforcement + closeout ### Task 9: `#if` build flags + reject-row enforcement + closeout
**Concept & reason:** last adoptions and the table's other half. Build flags: `woc -D name` defines flags; `#if name / #else / #end` sections include/exclude at the token stream level (flag names only, no expression language — the spec's limit); undefined flags are false; nesting allowed. Reject enforcement: the keywords that would otherwise parse get targeted diagnostics with the table's reasons — `extends`/`implements`/`super`/`override` on class declarations, `cast`, `Dynamic`/`untyped` as type/expression, `macro`, `extern`, `operator` — each cites the spec section. Closeout: error catalog complete for the track, keyword table in the spec annotated with shipped status, `just oop-accept` runs the grown corpus, CLAUDE.md language notes synced. **Concept & reason:** last adoptions and the table's other half. Build flags: `woc -D name` defines flags; `#if name / #else / #end` sections include/exclude at the token stream level (flag names only, no expression language — the spec's limit); undefined flags are false; nesting allowed. Reject enforcement: the keywords that would otherwise parse get targeted diagnostics with the table's reasons — `extends`/`implements`/`super`/`override` on class declarations, `cast`, `Dynamic`/`untyped` as type/expression, `macro`, `extern`, `operator` — each cites the spec section. `abstract` joins this reject row too, but needs no diagnostic of its own: the keyword never lexes, so it is absent by construction, the same as `macro`/`extern`. Closeout: error catalog complete for the track, keyword table in the spec annotated with shipped status, `just oop-accept` runs the grown corpus, CLAUDE.md language notes synced.
- [ ] Failing fixtures: #if inclusion/exclusion goldens (portable-style flag), nesting, undefined-flag default; one must-fail per reject keyword with the doctrine message. - [ ] Failing fixtures: #if inclusion/exclusion goldens (portable-style flag), nesting, undefined-flag default; one must-fail per reject keyword with the doctrine message.
- [ ] Implement; full corpus green; docs synced. - [ ] Implement; full corpus green; docs synced.
@ -109,6 +111,6 @@ docs/plan/oop-vm/00-wob-format.md grows with the three VM changes
## Plan self-review notes ## Plan self-review notes
- **Spec coverage (Part 1 + success criterion 1):** every adopt row has a task (modules T1, control/const/interp T2, switch T3, typedef+enum T4, try/catch/throw T5, optionals T6, abstract+is T7, static/using/pub(read) T8, #if T9); every reject row enforced in T9 or absent by construction. Criterion 1's "corpus coverage per row" is each task's fixture requirement. - **Spec coverage (Part 1 + success criterion 1):** every adopt row has a task (modules T1, control/const/interp T2, switch T3, typedef+enum T4, try/catch/throw T5, optionals T6, `is` T7, static/using/pub(read) T8, #if T9); every reject row enforced in T9 or absent by construction — `abstract` moved from adopt to reject on 2026-08-10, so T7 lost its other half. Criterion 1's "corpus coverage per row" is each task's fixture requirement.
- **VM changes fenced:** exactly three (catch frames, variant objects, boxed scalar optionals), each with a format-doc update in its task; everything else is lowering. - **VM changes fenced:** exactly three (catch frames, variant objects, boxed scalar optionals), each with a format-doc update in its task; everything else is lowering.
- **Order rationale:** modules first (everything imports), data shapes before optionals (records carry ?fields), try/catch after switch (arms reuse unified-type machinery), rejects last when all parse paths exist to hang diagnostics on. - **Order rationale:** modules first (everything imports), data shapes before optionals (records carry ?fields), try/catch after switch (arms reuse unified-type machinery), rejects last when all parse paths exist to hang diagnostics on.

View file

@ -73,7 +73,7 @@ justfile oop-e2e, oop-accept recipes (Tasks
**Files:** add `tests/corpus/run/` and `tests/corpus/trap/` fixtures derived from `docs/examples/pricing/`. **Files:** add `tests/corpus/run/` and `tests/corpus/trap/` fixtures derived from `docs/examples/pricing/`.
**Concept & reason:** the spec names the pricing demo's logic subset as the milestone-1 workload — it becomes executable truth here. Fixtures: the pure `discounted` computation; `current_price` through a `multi` with `latest`; container round-trips (multi push/count, map set/get over SKU keys); text handling (`words`, concat); and `set_price` — whose `insert` lowers to DB_STUB — as a trap fixture expecting the DB code (the spec's parse-but-trap story, proven end to end). Where the original demo files use surface not in milestone 1, the fixture carries the minimal adaptation with a comment naming what was trimmed — the corpus never silently diverges from the sample it mirrors. **Concept & reason:** the spec names the pricing demo's logic subset as the milestone-1 workload — it becomes executable truth here. Fixtures: the pure `discounted` computation; `current_price` through a `multi` with `latest`; container round-trips (multi push/count, map set/get over Text keys); text handling (`words`, concat); and `set_price` — whose `insert` lowers to DB_STUB — as a trap fixture expecting the DB code (the spec's parse-but-trap story, proven end to end). Where the original demo files use surface not in milestone 1, the fixture carries the minimal adaptation with a comment naming what was trimmed — the corpus never silently diverges from the sample it mirrors.
- [ ] Add fixtures; corpus green; ASan-built wovm run of the whole corpus stays clean. - [ ] Add fixtures; corpus green; ASan-built wovm run of the whole corpus stays clean.
- [ ] Record commit draft: `test(corpus): pricing-demo logic subset — discounted, current_price via multi/latest, container + text builtins, set_price DB_STUB trap fixture.` - [ ] Record commit draft: `test(corpus): pricing-demo logic subset — discounted, current_price via multi/latest, container + text builtins, set_price DB_STUB trap fixture.`

View file

@ -108,7 +108,7 @@ The emitter consumes: (1) the typed AST; (2) a per-class field-kind table using
**Files:** create `compiler/src/types.ml`; extend `dump.ml` (typed-info dump); golden + must-fail (WO-E2xx) fixtures. **Files:** create `compiler/src/types.ml`; extend `dump.ml` (typed-info dump); golden + must-fail (WO-E2xx) fixtures.
**Concept & reason:** three products. (1) **Symbols and field kinds:** a two-pass walk (declare all, then check bodies) builds class/interface/free-fn tables and derives each field's `.wob` kind — Int/Bool/Money/Timestamp/Id scalars → SCALAR, Text-like → TEXT, class-typed field → OWNED, `@gc`-class-typed → GCREF, `multi` → MULTI, `map` → MAP, and `ref T` → SCALAR (it is an id link, per spec section 3 rule 4). (2) **Structural interface satisfaction:** a class satisfies an interface exactly when it has a method matching every signature (name, arity, parameter types, return type); the satisfaction set — with the concrete method chosen per slot — is recorded for the emitter's vtables, and calling an interface method on a non-satisfying class is an error naming the missing/mismatched signature. (3) **Expression typing:** every expression node gets a type; checks cover operator operand types, call arity/types, field existence, constructor completeness (every field initialized or defaulted), method receiver rules, and builtin signatures (now, latest, count, words, print, print_int and the container operations) matching the VM's builtin table in the format doc. `self` types as the enclosing class; whether a method *mutates* (writes any field of self, directly or via a mut call) is computed here and recorded — the spec says self mutability is inferred, and the owner pass consumes the flag. **Concept & reason:** three products. (1) **Symbols and field kinds:** a two-pass walk (declare all, then check bodies) builds class/interface/free-fn tables and derives each field's `.wob` kind — Int/Bool/Timestamp/Id scalars → SCALAR, Text-like → TEXT, class-typed field → OWNED, `@gc`-class-typed → GCREF, `multi` → MULTI, `map` → MAP, and `ref T` → SCALAR (it is an id link, per spec section 3 rule 4). (2) **Structural interface satisfaction:** a class satisfies an interface exactly when it has a method matching every signature (name, arity, parameter types, return type); the satisfaction set — with the concrete method chosen per slot — is recorded for the emitter's vtables, and calling an interface method on a non-satisfying class is an error naming the missing/mismatched signature. (3) **Expression typing:** every expression node gets a type; checks cover operator operand types, call arity/types, field existence, constructor completeness (every field initialized or defaulted), method receiver rules, and builtin signatures (now, latest, count, words, print, print_int and the container operations) matching the VM's builtin table in the format doc. `self` types as the enclosing class; whether a method *mutates* (writes any field of self, directly or via a mut call) is computed here and recorded — the spec says self mutability is inferred, and the owner pass consumes the flag.
- [ ] Failing fixtures: typed-dump goldens for the pricing shapes; must-fail suite — type mismatch, unknown field, bad arity, unsatisfied interface (message names the missing method), incomplete constructor — each asserting its WO-E2xx code. - [ ] Failing fixtures: typed-dump goldens for the pricing shapes; must-fail suite — type mismatch, unknown field, bad arity, unsatisfied interface (message names the missing method), incomplete constructor — each asserting its WO-E2xx code.
- [ ] Implement; green. - [ ] Implement; green.

View file

@ -1,264 +1,450 @@
# Nullable Types (`?T`) Implementation Plan # Nullable Types (`?T`) + Constrained `@gc` Implementation Plan
## Current State > **Rewritten 2026-08-10** as an honest status record plus a handoff, after an
> audit found this doc's own status table claiming `?T` was "Implemented
> with nullable handling" when the enforcement semantics do not exist. See
> `.dev/commit.md` (subject: `refactor(compiler): drop Money/SKU/Float and
> the abstract-type allowlist; correct the nullable-types plan doc`) for the
> change that produced this rewrite.
| Component 10 of the plan mentions "05-language-surface.md" and "07-logwatcher-proof.md" which might think "wait, there's no types.ml yet" - that's Task 6, which is where nullable types will be properly handled. ## Status
### Existing Files `?T` is **plumbed but not enforced**. Every stage that carries the syntax
- **Lexer** (`lexer.ml`): ✅ Has `Question` token (`?`) - line 266 through the pipeline shipped; the one stage that would give it meaning —
- **Token** (`token.ml`): Has `Question` kind - line 60 forced handling in the typechecker — did not.
- **AST** (`ast.ml`): `field_ty` has `Scalar`, `Ref`, `Multi`, `Map` - **missing `Nullable`**
- **Parser** (`parser.ml`): `parse_field_ty` handles `ref`, `multi`, `map`, scalar - **doesn't handle `?` prefix** | Component | Status |
- **Dump** (`dump.ml`): Handles existing field types - needs update |-----------|--------|
- **Typechecker** (`types.ml`): Not yet created (Task 6) | **Lexer** (`lexer.ml`) | Shipped — has `Question` token (`?`) |
| **Token** (`token.ml`) | Shipped — has `Question` kind |
| **AST** (`ast.ml`) | Shipped — `field_ty` has a `Nullable` variant |
| **Parser** (`parser.ml`) | Shipped — parses the `?` prefix in all three positions: field types, return types, parameters |
| **Dump** (`dump.ml`) | Shipped — renders `?T` as `?` + inner type |
| **Typechecker semantics** (`types.ml`) | **NOT shipped.** `?T` round-trips through every stage above as inert syntax. The typechecker never enforces the `?T`/`T` boundary: no null-narrowing, no forced-handling diagnostic, no distinction in practice between a `?T` field and a `T` field. |
**Evidence (re-run 2026-08-10):**
```wo
class Box { v: ?Int }
fn take_it(b: Box) -> Int { return b.v; }
```
`woc` on this file exits **0** with **zero diagnostics**. `take_it` returns
`b.v` — a `?Int` — from a function declared to return `Int`, with no null
check anywhere. This is the entire point of `?T` (forced handling: you may
not use a possibly-nil value where a never-nil value is required), and it is
completely unenforced.
## Handoff
`?T` forced handling — null-narrowing control flow, the `WO-E211`/`WO-E212`/
`WO-E213` diagnostics below, and boxed scalar cells for nullable scalars — is
owned by `docs/plan/compiler/2026-08-01-haxe-parity-language.md` **Task 6**,
not this doc. That task is the **next work item** on the compiler track: it
blocks the log-watcher port (story iterations 5–6), which uses `?T`
throughout in place of the Haxe original's sentinel values. This doc stops
claiming ownership of that work.
## Dead-code register
Ten `WO-E2xx` codes are declared as named constants in `types.ml` with no
call site anywhere in the front end — `grep '~code:'` finds exactly five
sites (`WO-W201`, `WO-E202`, `WO-E206`, `WO-E207`, `WO-E225`); the other ten
declared constants are never referenced by a `Diag.error`/`Diag.warning`
call. `docs/plan/oop-vm/01-error-catalog.md`'s "Reserved, not yet emitted"
section already lists all ten; this table adds *why* each is dead and who,
if anyone, is expected to wire it:
| Code | Meaning | Why it's dead |
|------|---------|----------------|
| `WO-E201` `type_mismatch` | operand/assignment type mismatch | Named in `docs/plan/compiler/2026-08-01-woc-compiler-front.md` Task 6's own must-fail list ("type mismatch, unknown field, bad arity, unsatisfied interface, incomplete constructor") — a gap in already-shipped work, not blocked on any future feature. `Binary`/`Unary` in `types.ml` don't check operand types at all. |
| `WO-E203` `bad_arity` | wrong argument count at a call | Same Task 6 brief, same gap: `Call (_callee, args)` in `types.ml` type-checks each argument expression but never compares the count (or types) against the callee's signature. |
| `WO-E204` `unknown_fn` | call names a free function that doesn't resolve | `Call`'s callee is never looked up in `syms.free_fns` at all — not blocked on modules; even a same-file unresolved call goes unchecked today. Gap in shipped work. |
| `WO-E205` `unsatisfied_interface` | a class doesn't structurally satisfy an interface | **Called out explicitly**: this is the most consequential of the ten. `types.ml`'s own header comment (line 5) claims the pass "Produces typed AST + per-class field-kind table + interface satisfaction set," and the Task 6 brief names this as a required check ("calling an interface method on a non-satisfying class is an error naming the missing/mismatched signature") — but no code path ever calls `Diag.error ~code:unsatisfied_interface_code`. Structural interface satisfaction is entirely unenforced. Gap in shipped work, not deferred to a future plan. |
| `WO-E208` `non_exhaustive_switch` | a `switch` expression doesn't cover every case | Genuinely blocked: no `switch` keyword exists in `token.ml`/`lexer.ml`/`parser.ml` yet. Owned by haxe-parity Task 3 (`switch` as expression). |
| `WO-E209` `invalid_builtin` | a builtin call (`now`, `latest`, `count`, `words`, `print`, …) used with the wrong signature | Task 6's brief promises builtin-signature checking "matching the VM's builtin table," but `Call` has zero builtin special-casing — every callee is treated identically. Gap in shipped work. |
| `WO-E210` `module_not_imported` | a name used from a module that was never `use`d | Genuinely blocked: no `use`/module concept exists anywhere in the parser yet. Owned by haxe-parity Task 1 (Modules). |
| `WO-E211` `nullable_used_without_check` | a `?T` value used where `T` is required, unnarrowed | Genuinely blocked on the `?T`/`T` enforcement this doc hands off above. Owned by haxe-parity Task 6. |
| `WO-E212` `nullable_assign_mismatch` | assigning across the `?T`/`T` boundary without narrowing | Same as `WO-E211`. Owned by haxe-parity Task 6. |
| `WO-E213` `missing_nil_check` | narrowing control flow itself | Same as `WO-E211`/`WO-E212`. Owned by haxe-parity Task 6. |
Five of the ten (`E201`, `E203`, `E204`, `E205`, `E209`) need **no new
language feature** — they are checks the shipped Task 6 typechecker's own
brief already promised and never wired. The other five (`E208`, `E210`,
`E211`, `E212`, `E213`) are genuinely blocked on features that don't exist
yet, each owned by a specific haxe-parity task as noted above.
## Narrowing notes
- **`WO-W201` shipped a self-reference-only heuristic.** The "Heuristic for
`has_recursive_structure`" section below lists four bullets; the actual
`has_recursive_structure` in `types.ml` implements only the first two
(a field of the class's own type, directly or through `ref`/`multi`/
`map<_, Self>`). The other two — a cycle *through other classes*, and "used
as `@gc` ref elsewhere in the same module" — are not implemented. The
function only ever compares a field's referenced type name against the
class's own name; it has no transitive/cross-class analysis.
- **`WO-E225` covers bare class fields only.** `check_field_types` walks
`Ast.Class` fields exclusively (this does include `type X {}` declarations,
which parse to `Ast.Class` too — but never method parameters, return
types, or the element types of `multi T`/`map<K,V>`, because
`scalar_name_of` returns `None` for `Ref | Multi | Map`). A method
returning `Money`, a parameter typed `SKU`, or a field typed
`map<SKU, Money>` never triggered `WO-E225` even before this change removed
those names — and the same asymmetry holds for any future unknown type
name today.
- **The "not typedef" clause is meaningless.** `is_known_type_name` has no
"or a declared typedef" disjunct, and adding one would be a no-op:
`typedefs` is declared in the `symbols` record, initialized to an empty
map, and threaded from pass 1 into pass 2 — but nothing ever populates it.
`type X { ... }` parses to `Ast.Class`, the same declaration form `class`
uses, so typedef-shaped declarations are already resolved through the
classes clause instead. There is no code path that could ever populate
`typedefs`, so no fix is missing here — the field itself is dead weight.
## Additions this doc missed
- **`WO-E214`** (cross-file collision) exists and is emitted — from the
*driver* (`compiler/bin/main.ml`), not `types.ml`, when the same class or
interface name is declared in two files a directory discovers. It reuses
the `WO-E2xx` range because it's a symbol-table concern, not a lexing,
parsing, or ownership one. Shipped as part of driver polish
(`docs/plan/compiler/2026-08-01-woc-compiler-front.md` Task 8); this doc
never mentioned it.
- **A Task 7 ownership pass exists.** `compiler/src/owner.ml` (MVS flow
analysis, `WO-E3xx` two-site diagnostics, the moves/drops/rc/residual
tables behind `--dump-owner`) is fully implemented. This doc predates it
and never referenced it.
--- ---
## Required Changes ## New Requirements
### 1. AST (`ast.ml`) - Add Nullable Variant ### 1. Diagnostic-Assisted `@gc` Inference (WO-W201)
**Minimal Change (Option A):** **Goal:** Keep explicit `@gc` but add compiler diagnostic when borrow checker cannot prove safety.
```ocaml
type field_ty = **New Diagnostic:**
| Scalar of string ```
| Ref of string WO-W201: <ClassName> has recursive/shared structure that borrow checker cannot prove.
| Multi of string Consider adding @gc if this is an ephemeral in-memory cache.
| Map of string * string If this maps to a database table, keep owned (default).
| Nullable of field_ty (* NEW: ?T wrapper *)
``` ```
**Full Refactor (Option B - Recommended):** **When to emit:**
```ocaml - Class has fields that form recursive structures (e.g., `map<K, V>`, `multi T` where T is the same class)
type field_ty = - Class has fields that are borrowed in ways the borrow checker cannot prove safe
| Scalar of string - Class is used in patterns typical of caches/registries (stored in `map`, passed as `@gc` ref)
| Ref of string
| Multi of string
| Map of string * string
| Nullable of field_ty (* NEW: ?T wrapper *)
(* Update these to use field_ty for consistency *) **When NOT to emit:**
type param = { - Class has `@table` annotation → must be owned (DB-backed)
id : int; - Class has `@unique` field → persistent identity
pos : pos; - Class is a simple data struct (no recursive/shared patterns)
name : string;
conv : param_conv;
ty : field_ty; (* was: string *)
}
type method_sig = { Shipped, with the narrower heuristic recorded above under "Narrowing notes."
id : int;
pos : pos;
name : string;
params : param list;
ret : field_ty option; (* was: string option *)
}
type method_decl = {
...
ret : field_ty option; (* was: string option *)
}
```
**Decision**: **Option B** - Full refactor for consistency. The typechecker (Task 6) needs resolved types anyway.
--- ---
#### 3. Parser (`parser.ml`) - Parse `?` Prefix ### 2. Scalar Type Corrections
**Changes needed in `parse_field_ty` (line 312):** **Current (Wrong):**
```ocaml ```ocaml
let parse_field_ty (st : state) : Ast.field_ty = let builtin_scalars = ["Int"; "Bool"; "Text"; "Money"; "Timestamp"; "Id"; "SKU"]
let nullable = ref false in
if accept st Token.Question then nullable := true;
let base_ty =
match peek st with
| Token.Ident "ref" ->
ignore (advance st);
Ast.Ref (expect_ident st "ref target type")
| Token.Ident "multi" ->
ignore (advance st);
Ast.Multi (expect_ident st "multi target type")
| Token.Ident "map" ->
ignore (advance st);
expect st Token.Lt "'<'";
let k = expect_ident st "map key type" in
expect st Token.Comma "','";
let v = expect_ident st "map value type" in
expect st Token.Gt "'>'";
Ast.Map (k, v)
| Token.Ident name ->
ignore (advance st);
Ast.Scalar name
| _ -> unexpected st "a field type"
in
if !nullable then Ast.Nullable base_ty else base_ty
``` ```
**Parse `?` prefix for return types (line 442):** > **Historical record — do not edit to match current reality.** This block
> documents the bug Task 6b found (`Money`/`SKU` as bare builtins, no
> `Float`), not the code as it exists today. Its correction below has since
> been corrected again — see the note that follows.
**Corrected (as of this change, 2026-08-10):**
```ocaml ```ocaml
let parse_ret_type (st : state) : Ast.field_ty option = let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
let nullable = ref false in
if accept st Token.Question then nullable := true;
if accept st Token.Arrow then begin
let t = parse_field_ty st in (* parse_field_ty now returns field_ty *)
Some (if !nullable then Ast.Nullable t else t)
end else None
``` ```
**Parse `?` prefix for parameters:** **Changes, in order:**
```ocaml 1. Task 6b: removed `Money`, `SKU` (they had no `abstract` declaration to
let parse_param (st : state) : Ast.param = back them — magic strings) and added `Float` (documented as "IEEE 754
let pos = peek_pos st in double / f64").
let conv = 2. This change (2026-08-10): removed `Float` too. It had the identical
if accept st Token.KwMut then Ast.Mut phantom-scalar defect from the opposite direction — `token.ml` has no
else if accept st Token.KwTake then Ast.Take float-literal kind and `wob.h` has no float representation, so `ratio:
else Ast.Borrow Float` typechecked while no `Float` value could ever be written or
in represented. `Money`/`SKU` also stay removed; the stopgap allowlist that
let name = expect_ident st "parameter name" in let them resolve (`abstract_types`/`is_abstract_type`) is deleted
expect st Token.Colon "':'"; outright, not emptied. `builtin_scalars` is now exactly the five names
let ty = parse_field_ty st in (* now returns field_ty *) that work end to end: `Int`, `Bool`, `Text`, `Timestamp`, `Id`.
{ Ast.id = fresh_id st; pos; name; conv; ty }
```
#### 4. Dump (`dump.ml`) - Render Nullable Types **The `abstract` feature itself is rejected**, not deferred. The
systems-track verdict table's `abstract` row flips **adopt → reject**
(`docs/superpowers/specs/2026-08-01-systems-track-design.md`): a distinct
scalar type adds a conversion surface without buying safety this language
needs, and the compiler's own `Money`/`SKU` stopgap allowlist is the
concrete proof the cost was real. Domain scalars are plain `Int`/`Text`.
Haxe-parity Task 7 keeps only `is`. No allowlist has a future to be revived
into — re-adding `Float` requires float literals in the lexer *and* a float
kind in `.wob` landing together; re-adding `abstract` requires the keyword
itself to lex and parse, which plan 8 Task 9's reject-row enforcement now
actively blocks.
---
### Updated Built-in Scalar List (in `types.ml`)
```ocaml ```ocaml
let rec field_ty_str : Ast.field_ty -> string = function let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
| Ast.Scalar s -> s
| Ast.Ref s -> "ref " ^ s
| Ast.Multi s -> "multi " ^ s
| Ast.Map (k, v) -> "map<" ^ k ^ ", " ^ v ^ ">"
| Ast.Nullable t -> "?" ^ field_ty_str t (* NEW *)
``` ```
--- ---
### Typechecker Integration (Task 6 - `types.ml`) ### Updated Built-in Scalar Table
When Task 6 creates `types.ml`, it must handle: | Type | Description | Runtime Representation |
|------|-------------|------------------------|
| `Int` | 64-bit signed integer | i64 |
| `Bool` | Boolean | i64 (0/1) |
| `Text` | UTF-8 string | pointer + length |
| `Timestamp` | Milliseconds since epoch | i64 |
| `Id` | Opaque identifier | i64 |
1. **Field-kind derivation**: (`Float`'s row is deleted — see the Scalar Type Corrections section above
- `Nullable t` → `WO_K_NULLABLE` (new .wob kind = 6) for why.)
- Payload kind = `t`'s kind (SCALAR, OWNED, GCREF, TEXT, MULTI, MAP)
2. **Expression typing**:
- Optional chaining: `x?.field` → requires `x : ?T`
- Nil checks: `if x != nil then ...` narrows type from `?T` to `T`
- Null coalescing: `x ?? default` → requires `x : ?T`, `default : T`
3. **Builtin signatures** (update for nullable returns):
- `map_get` → returns `?V`
- `fs.stat` → returns `?{size, inode, mtime}`
- `json.decode` → returns `?T`
- `env.get` → returns `?Text`
- `proc.run` → returns `?{code, out, err}`
4. **Type compatibility rules**:
- `T` → `?T` (implicit upcast)
- `?T` → `?T` (exact match)
- `?T` → `T` (requires explicit nil check, WO-E2xx if missing)
--- ---
### .wob Format Changes (Plan 3) ## Updated Plan
**In `runtime/src/wob.h`:** ### New Tasks Added
```c
enum { #### Task A: Diagnostic WO-W201 for `@gc` Suggestion
WO_K_SCALAR = 0,
WO_K_OWNED = 1, **File:** `compiler/src/types.ml` (Pass 2 - typechecker)
WO_K_GCREF = 2,
WO_K_TEXT = 3, **Implementation:**
WO_K_MULTI = 4, ```ocaml
WO_K_MAP = 5, (* In typechecker, after analyzing class structure *)
WO_K_NULLABLE = 6, // NEW let suggest_gc_annotation (cls : class_info) : unit =
}; if cls.is_gc then () (* Already @gc *)
#define WO_K_MAX 6u else if cls.table.is_some then () (* Has @table -> must be owned *)
else if has_recursive_structure cls then
Diag.Collector.add collector
(Diag.warning ~code:"WO-W201" ~file:cls.pos.file ~line:cls.pos.line ~col:cls.pos.col
~message:(Printf.sprintf "%s has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default)." cls.name) ())
``` ```
**Runtime representation:** **Heuristic for `has_recursive_structure`** (as originally scoped — see
- Nullable field = 2 slots: discriminant (uint32_t: 0=null, 1=present) + payload (T's kind) "Narrowing notes" above for which two of these four actually shipped):
- For SCALAR payload: 16 bytes total (discriminant + i64) - Class has a field of its own type (direct recursion)
- For OWNED/GCREF payload: 16 bytes total (discriminant + pointer) - Class has `multi Self` or `map<_, Self>` field
- For TEXT/MULTI/MAP payload: 16 bytes total (discriminant + pointer) - Class fields form a cycle through other classes
- Class is used as `@gc` ref elsewhere in the same module
--- ---
## Implementation Tasks #### Task B: Fix Built-in Scalar List
### Phase 1: AST & Parser (Immediate) **File:** `compiler/src/types.ml`
- [ ] **Task 1a**: Update `ast.ml` **Change:**
- Add `Nullable of field_ty` to `field_ty` ```ocaml
- Change `param.ty : string` → `field_ty` (* Before *)
- Change `method_sig.ret : string option` → `field_ty option` let builtin_scalars = ["Int"; "Bool"; "Text"; "Money"; "Timestamp"; "Id"; "SKU"]
- Change `method_decl.ret : string option` → `field_ty option`
- Update `param_conv` documentation
- [ ] **Task 1b**: Update `parser.ml` (* After *)
- Modify `parse_field_ty` to handle `?` prefix let builtin_scalars = ["Int"; "Bool"; "Text"; "Float"; "Timestamp"; "Id"]
- Modify `parse_ret_type` to use `parse_field_ty` and handle `?` ```
- Modify `parse_param` to use `parse_field_ty`
- Update `parse_sig_head` to handle new return type
- [ ] **Task 1c**: Update `dump.ml` > **Historical record — do not edit to match current reality.** This is
- Update `field_ty_str` to handle `Nullable` > Task 6b's own correction at the time it was made; `Float` has since been
- Update `param_str`, `sig_str`, `dump_method_sig` for new types > removed too (this change, 2026-08-10), for the reason given in the
> Scalar Type Corrections section above. The true current list is
> `["Int"; "Bool"; "Text"; "Timestamp"; "Id"]`.
### Phase 2: Typechecker (Task 6) `abstract` types are rejected outright (see above) — there is no allowlist
to add validation for, stopgap or otherwise. An unknown scalar name (not a
- [ ] **Task 2a**: Create `types.ml` with: builtin, not a declared class, not a declared interface) is simply
- Two-pass symbol collection `WO-E225`, unconditionally.
- Field-kind derivation including `WO_K_NULLABLE`
- Expression typing with nullable handling
- Structural interface satisfaction
- Self mutability inference
- [ ] **Task 2b**: Add WO-E2xx error codes for nullable violations:
- WO-E211: Nullable type used without nil check
- WO-E212: Non-nullable assigned nullable without check
- WO-E213: Missing nil check before field access on nullable
### Phase 3: Tests
- [ ] **Task 3a**: Add golden fixtures in `test/golden/ast/`:
- `nullable-field.wo` - `field: ?Text`
- `nullable-return.wo` - `fn foo() -> ?Int`
- `nullable-param.wo` - `fn foo(x: ?Int)`
- `nullable-nested.wo` - `?multi ?Text`, `?map<Int, ?Text>`
- [ ] **Task 3b**: Add must-fail fixtures in `test/golden/types/` (when typechecker exists):
- Missing nil check
- Type mismatch with nullable
--- ---
## Migration Notes ### Updated Typechecker Tasks
### Files to Modify #### Task 2a (Updated): Typechecker with New Diagnostics
1. `compiler/src/ast.ml` - Core type definitions
2. `compiler/src/parser.ml` - Parsing logic
3. `compiler/src/dump.ml` - Debug output
4. `compiler/src/types.ml` - New file (Task 6)
5. `compiler/src/dune` - Add `types` module
### Breaking Changes **File:** `compiler/src/types.ml`
- `param.ty` changes from `string` to `field_ty`
- `method_sig.ret` changes from `string option` to `field_ty option`
- `method_decl.ret` changes from `string option` to `field_ty option`
- Any code constructing `Ast.param`, `Ast.method_sig`, `Ast.method_decl` directly must be updated
### Compatibility **New WO-E Codes:**
- Parser changes are backward compatible (existing code without `?` still works) | Code | Trigger |
- AST changes require updating downstream consumers (typechecker, emitter) |------|---------|
- Dump format changes are additive | WO-E225 | Unknown type: not a builtin, not a declared class, not a declared interface |
| WO-W201 | Class has recursive/shared structure, consider `@gc` |
**Implementation Order (as shipped):**
1. `builtin_scalars` correction (now the five that work — see above)
2. WO-W201 diagnostic in class analysis pass
3. WO-E225 for unknown scalar names
(The stopgap `is_abstract_type` check that used to sit between steps 1 and 2
is gone — see the Scalar Type Corrections section.)
---
### What Task 6b actually tested
No `test/golden/types/` directory exists, and `woc` has no dump flag for the
types stage at all — its dump flags are `--dump-tokens`, `--dump-ast`, and
`--dump-owner`, nothing more. None of `gc-suggestion.wo`, `sku-scalar.wo`,
`unknown-type.wo`, or `float-example.wo` (all named in an earlier version
of this doc) was ever created as a fixture file. Task 6b instead asserted
these behaviors directly in `compiler/test/runner.ml`, via `typecheck_str`
over inline `.wo` source strings:
- **WO-W201 (gc-suggestion):** checks named `"gc-suggestion: exactly one
diagnostic (WO-W201)"`, `"gc-suggestion: code is WO-W201"`, etc., run over
inline sources for a self-referential `Node`, an `@gc`-annotated `Cache`
(must NOT fire), an `@table`-annotated `Node2` (must NOT fire), a
`@unique`-fielded `Node3` (must NOT fire), a plain `Point` struct (must NOT
fire), unrelated `multi`/`map` fields on `Calc`/`Bucket` (must NOT fire —
the over-trigger risk), and a `multi Self`-fielded `Tree` (must fire).
- **Scalar list:** `"Money is no longer a builtin scalar"`, `"SKU is no
longer a builtin scalar"`, `"Timestamp is a builtin scalar"`, and (as of
this change) `"Float is not a builtin scalar"`.
- **WO-E225 (unknown type):** `"unknown-type: exactly one diagnostic
(WO-E225)"` over `class BadExample { code: INVALID_TYPE }`, and (as of
this change) `"unknown-type fields (SKU, Money): exactly two
diagnostics"` over `class Product { id: Id; sku: SKU; price: Money }`.
### Updated Files
What actually changed, across both Task 6b and this change:
| File | Changes |
|------|---------|
| `compiler/src/types.ml` | `builtin_scalars` corrections (Task 6b: −Money/SKU +Float; this change: −Float too); WO-W201 diagnostic; WO-E225 unknown-type diagnostic; the `abstract_types`/`is_abstract_type` stopgap allowlist added by Task 6b, then deleted outright by this change |
| `compiler/src/owner.ml` | `oclass_of`'s builtin-scalar branch (this change: dropped the `is_abstract_type` disjunct — behavior-neutral, the `else Copy` fallthrough already caught unknown names) |
| `compiler/src/diag.ml` | `WO-W201` warning prefix; `WO-E225` (and the other reserved `WO-E2xx` codes) |
| `compiler/test/runner.ml` | Direct assertions for WO-W201 and WO-E225 (see "What Task 6b actually tested" above); no fixture files |
| `compiler/test/golden/ast/pricing-demo.{wo,expected}`, `compiler/test/golden/owner/pricing-demo.wo`, `compiler/test/golden/ast/two-error-recovery.wo` | This change: `Money` → `Int`, `SKU` → `Text` (pure rename; owner's golden `.expected` is byte-identical) |
| `docs/plan/compiler/nullable-types-implementation.md` | This document |
---
### Verifying today
There is no dump flag for the types stage to demonstrate any of this with.
What actually exists:
```bash
just woc-test # dune runtest: test_diag + runner goldens/assertions
compiler/_build/default/bin/woc <file.wo> # real compiler, real exit code + diagnostics
```
To see the `?T` gap directly, run the probe under "Status" above through
`woc` — exit 0, no diagnostics, despite returning a nullable value from a
non-nullable-typed function.
---
### Updated `.wob` Format (Plan 3)
No changes needed - abstract types compile to their underlying representation at runtime.
---
## Abstract Data Types — the container roster (recorded 2026-08-08; FUTURE — post story iteration 11)
> **Scope note (2026-08-08):** the story's critical path is *compile and
> run log-watcher* (iterations 3–7). log-watcher needs only `multi`, `map`,
> and `Text`. Nothing in this roster is scheduled before story iteration 11
> completes; it is the recorded candidate pool, not work.
Two different "abstract" notions live in this plan; keep them apart:
- **Abstract newtypes** (`abstract Money = Int`) — zero-cost compile-time
wrappers over a scalar representation. Covered above; verdict-table adopt
row.
- **Abstract data types (ADTs)** — behavioral specifications of containers:
an ADT says *what operations exist and their semantics*; a data structure
says *how they are implemented*. A map is an ADT; a hash table and a
red-black tree are two data structures implementing it.
Doctrine holds: containers are **runtime-provided native classes
implemented in C**, not user-definable generics (OOP spec §3). The VM picks
the backing data structure; the language exposes only the ADT's operations.
Milestone 1 ships `multi` (list) and `map`. The globally accepted ADT
roster below is the candidate pool for later milestones — names and
semantics only, implementation deliberately unspecified:
**Linear**
| ADT | Semantics |
|-----|-----------|
| List | ordered sequence, indexable, duplicates allowed — **shipped as `multi`** |
| Stack | LIFO: push, pop, peek |
| Queue | FIFO: enqueue, dequeue |
| Deque | insert/remove at both ends |
| Priority queue | retrieve highest-priority element first |
**Associative**
| ADT | Semantics |
|-----|-----------|
| Set | unordered collection of unique elements |
| Multiset (bag) | like a set, but counts duplicates |
| Map (dictionary) | key → value lookups — **shipped as `map`** |
| Multimap | one key maps to multiple values |
**Hierarchical / connected**
| ADT | Semantics |
|-----|-----------|
| Tree | nodes with parent-child relations |
| Binary search tree | ordered tree: search, insert, delete |
| Heap | partial ordering; the usual backing for a priority queue |
| Graph | vertices plus edges, directed or undirected |
| Trie | prefix tree for strings |
**Other**
| ADT | Semantics |
|-----|-----------|
| String | sequence of characters — **shipped as `Text`** |
| Matrix / array | fixed-dimension indexed storage |
| Union-find (disjoint set) | track partitions, merge groups |
| Stream / iterator | sequential access to a lazily produced sequence |
Selection rules when a later milestone adopts one:
1. Only globally accepted ADTs from this roster — no bespoke container
inventions.
2. Adoption is demand-driven: a sample workload must need it first
("samples force the grammar", principles doc #8).
3. Each adopted ADT lands as a native class with the same machinery `multi`
and `map` already use: header sentinel class id, kind-tagged elements,
builtin-table operations, drop/GC integration via `wo_drop_kind`.
4. The ADT's operation set is normative in the format doc; the backing
structure stays a VM implementation detail and may change without a
language-surface change.
---
## Summary of Changes to Existing Plan
| Section | Change |
|---------|--------|
| `builtin_scalars` | Task 6b: remove `"Money"`, `"SKU"`; add `"Float"`. This change (2026-08-10): remove `"Float"` too. Final list: `["Int"; "Bool"; "Text"; "Timestamp"; "Id"]`. |
| Typechecker | Shipped: WO-W201 (`@gc` suggestion, self-reference-only heuristic) + WO-E225 (unknown type, bare class fields only). Still dead: ten reserved `WO-E2xx` codes — see "Dead-code register" above. |
| `abstract` types | **Rejected**, not adopted. Verdict-table row flips adopt → reject; haxe-parity Task 7 keeps only `is`. No `Money`/`SKU`/any newtype re-declaration is coming. |
| `?T` semantics | **Not implemented.** Plumbed through lexer/token/AST/parser/dump; typechecker enforcement (narrowing, forced handling, `WO-E211`–`WO-E213`) owned by haxe-parity Task 6 — the next work item. |
| ADT roster | Globally accepted container ADTs recorded as the candidate pool for future native classes (section above); `multi`/`map`/`Text` mapped to List/Map/String |
| Test fixtures | None added under `test/golden/types/` — Task 6b asserted behavior directly in `runner.ml` instead (see "What Task 6b actually tested") |
| Documentation | This document, rewritten 2026-08-10 |
--- ---
## References ## References
- [Task 6 Plan](2026-08-01-woc-compiler-front.md#task-6-typechecker) - Lines 107-115 - [Error catalog](../oop-vm/01-error-catalog.md) — every `WO-E`/`WO-W` code `woc` actually emits, plus the "Reserved, not yet emitted" section this doc's dead-code register expands on
- [OOP Compiler VM Design](superpowers/specs/2026-08-01-oop-compiler-vm-design.md) - Section 3, "Nullable types" - [Haxe-Parity Language plan](2026-08-01-haxe-parity-language.md) — Task 6 owns `?T` forced handling (the handoff above); Task 7 is reduced to `is` only
- [Systems Track Design](superpowers/specs/2026-08-01-systems-track-design.md) - Part 1, "Null<T> → ?T optional types" - [OOP Compiler VM Design](../../superpowers/specs/2026-08-01-oop-compiler-vm-design.md) - Section 3
- [.wob Format](plan/oop-vm/00-wob-format.md) - Field kinds - [Systems Track Design](../../superpowers/specs/2026-08-01-systems-track-design.md) - Part 1; the `abstract` row (adopt → reject)

View file

@ -0,0 +1,49 @@
# The `.wob` format v1 — normative reference
> Copied verbatim from the normative section of
> [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../../superpowers/plans/2026-08-01-wob-format-and-vm-core.md)
> (plan 1 of the approved spec
> [`2026-08-01-oop-compiler-vm-design.md`](../../superpowers/specs/2026-08-01-oop-compiler-vm-design.md)).
> The machine-readable twin is [`runtime/src/wob.h`](../../../runtime/src/wob.h) —
> constants there and prose here must never disagree. The test-side assembler
> `runtime/test/wob_build.c` is a second, independent encoding; builder/loader
> disagreements surface as test failures.
All integers little-endian; offsets are absolute file offsets.
**Header (44 bytes):** magic `"WOB1"`, version 1, 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).
**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. 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.
**Interface section** — per interface: name constant index, method count. Global *slot ids* are assigned sequentially across interfaces in declaration order. Then a vtable row count and rows: class id, interface id, one method index per interface method.
**Method table** — per method: name constant index, class id (all-ones = free fn), arg count u8, register count u8, reserved u16, code length in bytes (multiple of 4), the u32 instructions, a line table (count + ascending pc→line pairs), and a drop table (count + ascending entries of pc, owned-register bitmask u64, gc-register bitmask u64). Drop-table lookup = last entry with pc ≤ current pc; no entry means nothing live.
**Instructions** — fixed 32-bit, Lua-style fields: opcode byte, A byte, then either B and C bytes or a 16-bit Bx (signed jumps encode as Bx − 32768).
| op | name | semantics (in words) |
| --- | --- | --- |
| 0 | NOP | nothing |
| 1 | LOADK A Bx | register A = constant Bx (int inline; text = pointer to interned const string) |
| 2 | MOVE A B | copy register; for owned values this IS the move — compiler guarantees the source is dead |
| 3–7 | ADD/SUB/MUL/DIV/NEG | i64 arithmetic, two's-complement wrapping (no signed-overflow UB); DIV traps on zero divisor and on INT64_MIN ÷ −1 |
| 8 | CONCAT A B C | new owned text from two texts |
| 9–12 | EQ/LT/LE/EQS | i64 compares and text-content equality, result 0/1 |
| 13–14 | JMP / JZ | relative jump (JZ when register A is zero) |
| 15 | CALL A Bx | call method Bx; callee's register window starts at caller base + A (register-window overlap, Lua-style); args sit at A, A+1, …; return value lands back in slot A |
| 16 | ICALL A Bx | interface call by global slot id Bx; receiver in A; vtable lookup by the receiver's class |
| 17–18 | RET A / RET0 | return value from register A (or zero), pop frame |
| 19 | NEW A Bx | new zeroed instance of class Bx |
| 20–21 | GETF / SETF | field read/write with runtime null/native/bounds checks (trap T_BOUNDS); overwriting a non-scalar field does NOT auto-drop the old value — the compiler emits the drop |
| 22 | DROP A | recursively drop the owned value in A per its class drop plan, null the register |
| 23–26 | BORROW_S/BORROW_X/RELEASE_S/RELEASE_X | borrow-word ops on the object in A; violation traps T_BORROW |
| 27–28 | RC_INC / RC_DEC | refcount ops on the `@gc` object in A |
| 29 | BUILTIN A B C | register A = builtin C applied to args starting at register B (fixed arity per builtin; `multi_new`/`map_new` carry kind immediates in B instead) |
| 30 | DB_STUB | trap T_DB "engine not linked" (spec: SQL-layer statements in milestone 1) |
| 31 | TRAP Bx | explicit trap with code Bx |
**Builtins:** now (ms), print (text), print_int, words (whitespace token count), multi_new/multi_push/multi_get/count/latest, map_new/map_set/map_get/map_has.
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT.

View file

@ -0,0 +1,94 @@
# The `woc` diagnostic catalog — normative reference
Every `WO-E###`/`WO-W###` code the `woc` front end (`compiler/`) actually
emits, as of plan 2 tasks 2–8. Code ranges are reserved per stage
(`compiler/src/diag.ml`): `WO-E0xx` lexing, `WO-E1xx` parsing, `WO-E2xx`
types, `WO-E3xx` ownership, `WO-W2xx` warnings from the types stage. This
is an enumeration of codes already in use, not an archaeology dig — see
"Completeness method" below for how that was verified, and "Reserved,
not yet emitted" for codes the source declares but no check yet raises.
One code (WO-E214) is emitted by the driver (`compiler/bin/main.ml`),
not one of the four stage modules — a Task 8 review finding — see its
row in the types table below for why it still uses that range.
Every diagnostic renders as `file:line:col: <severity> <code>: <message>`,
the source line, and a caret under the column (`compiler/src/diag.ml`);
ownership errors (`WO-E3xx`) add a second, indented site for the other
half of the story ("moved here" / "borrowed here" / etc.).
## WO-E0xx — lexing (Task 3, `compiler/src/lexer.ml`)
| code | meaning | example message |
| --- | --- | --- |
| WO-E001 | an input byte the lexer doesn't recognize as the start of any token. Reported once per bad byte, which is then skipped — one bad byte never stops the whole file. | `unknown character '$'` |
| WO-E002 | a string literal's backslash escape is the last byte of the file, with no character left to escape (a plain unterminated string with no dangling backslash is *not* an error — rt parity). | `unterminated string escape` |
## WO-E1xx — parsing (Tasks 4–5, `compiler/src/parser.ml`)
| code | meaning | example message |
| --- | --- | --- |
| WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` |
| WO-E102 | an invalid `@table(...)` configuration: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` |
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`)
| code | meaning | example message |
| --- | --- | --- |
| WO-W201 *(warning)* | a class has recursive/shared structure (a field, directly or through `ref`/`multi`/`map`/`?`, refers back to its own class) that the ownership pass cannot prove disjoint, has no `@table`, and has no `@unique` field — suggests `@gc`. | `Node has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default).` |
| WO-E202 | a `.field` access names a field that the base's class (a *declared* class — an unresolved/placeholder expression type never triggers this) doesn't have. | `unknown field \`price\` on \`Product\`` |
| WO-E206 | a constructor literal (`ClassName { ... }`) omits a field the class declares (no default). | `missing field \`sku\` in constructor of \`Product\`` |
| WO-E207 | a constructor literal names a class that isn't declared anywhere in the (possibly multi-file) program. | `unknown type \`Widget\` in constructor` |
| WO-E214 | a class or interface name is declared more than once across the files a directory discovers (one program, multiple files — Task 8). Reported at the *later*-discovered declaration (sorted by path), with the first declaration as the related site; the merged symbol table keeps the first one, so this is what stops that silent keep from also hiding a real shape conflict. Driver-level, not `types.ml` — reuses the `types_prefix` range because it's a symbol-table concern, not a lexing/parsing/ownership one. | `class \`Dup\` already declared in \`a_first.wo\`` |
| WO-E225 | a field's declared type name isn't a builtin scalar, a declared class, or a declared interface. Checked once per field declaration, at the field's own position. | `unknown type \`Wdiget\`` |
### Reserved, not yet emitted
`type_mismatch_code` (WO-E201), `bad_arity_code` (WO-E203),
`unknown_fn_code` (WO-E204), `unsatisfied_interface_code` (WO-E205),
`non_exhaustive_switch_code` (WO-E208), `invalid_builtin_code` (WO-E209),
`module_not_imported_code` (WO-E210), `nullable_used_without_check_code`
(WO-E211), `nullable_assign_mismatch_code` (WO-E212), and
`missing_nil_check_code` (WO-E213) are declared in `types.ml` — the
range is reserved — but as of Task 7 nothing in the front end ever
raises them; there is no call site and therefore no real example
message to catalog. They read like placeholders for checks Task 6's own
plan brief named (type mismatch, bad arity, unsatisfied interface, …)
that the shipped typechecker doesn't yet implement. Listed here so a
conformance fixture (plan 3) or a future reader doesn't assume one of
these codes is reachable today; move a code up into the table above in
the same commit that wires its first real emission site.
## WO-E3xx — ownership / MVS (Task 7, `compiler/src/owner.ml`)
Every ownership diagnostic carries a second site (the module doc's
"two-site errors are the product") — the example messages below are the
primary message only; the related site's label (e.g. "`b` moved here")
renders indented beneath it.
| code | meaning | example message |
| --- | --- | --- |
| WO-E301 | a place is read (or moved again) after its value was already moved — by assignment, `take` argument passing, constructor field init, or `return`. | `use of \`b\` after it was moved` |
| WO-E302 | a place is moved while a live borrow of it, or of an overlapping place, still exists. | `cannot move \`bag.items\` while \`r\` is borrowed` |
| WO-E303 | two exclusive (`mut`) accesses of the same place, or two accesses the analysis can *prove* overlap, conflict in one region (e.g. two `mut` element accesses through the same provable index, or the same place borrowed and then mutated). Cases the analysis can't prove either way become a residual site for the VM to guard at runtime, not this diagnostic. | `cannot borrow \`bag.items[i]\` as \`mut\` twice in the same call` |
| WO-E304 | a borrow is returned or stored somewhere that outlives the scope it borrowed from. `@gc`-typed values are exempt (freely aliased by design). | `borrow of \`x\` returned — borrows cannot outlive their scope` |
## Completeness method
Every code in this catalog was found the same way: grep every
`Diag.error`/`Diag.warning` call site across `compiler/src/*.ml` (lexer,
parser, types, owner — dump.ml never constructs a diagnostic) and
`compiler/bin/main.ml` (the driver — added after a Task 8 review found
WO-E214 living there, outside the original src/*.ml-only sweep), then
cross-reference each `~code:` argument back to the `let <name>_code = ...`
constant it names. Every constant with at least one such call site is
in the table above; every constant with zero call sites is listed under
"Reserved, not yet emitted" instead of silently omitted. `parser.ml`'s
`fail`/`unexpected`, `owner.ml`'s `report`/`escape`/`check_against_borrows`,
and `main.ml`'s `report_collision` are the only indirection layers
between a bare `~code:` argument and the `Diag.error` call — each was
read to confirm which named constant ultimately reaches the collector,
not just the arity-generic wrapper name. This is why the catalog is an
enumeration, not a dig: `diag.ml` already reserves the numeric ranges,
so the only open question per stage was *which* reserved codes actually
fire — answered by exhaustive grep, not inspection of a handful of
samples.

View file

@ -0,0 +1,16 @@
# docs/plan/oop-vm/ — OOP + systems track contracts
The normative contract documents both stacks cite. Landed by their named plan tasks:
| doc | contract | plan |
| --- | --- | --- |
| `00-wob-format.md` | `.wob` bytecode format (compiler↔VM) | 1 |
| `01-error-catalog.md` | every `WO-E###` code | 2, grows 3/8 |
| `02-corpus.md` | how to add conformance fixtures | 3 |
| `03-shard-actor.md` | shard ownership, mailboxes, send-as-move | 4 |
| `04-db-binding.md` | row format, WAL records, query subset | 5 |
| `05-http-service.md` | route section, trap→HTTP table, JSON subset | 6 |
| `06-ui-live.md` | delta frames, subscribe protocol, wo:live | 7 |
| `07-systems-stdlib.md` | per-function nil-vs-trap contracts | 9 |
Specs and plans: `docs/superpowers/{specs,plans}/`. Repo map: `docs/08-project-structure.md`.

View file

@ -71,6 +71,19 @@ list, and a pointer to the plan document that already sequences its tasks.
- Acceptance criteria live in each iteration file; this story frames the - Acceptance criteria live in each iteration file; this story frames the
outcome. outcome.
- **Critical path (locked 2026-08-08): compile and run log-watcher.**
Iterations 3 → 4 → 5 → 6 → 7 are the committed line; nothing off that
line lands before iteration 7's acceptance. Iterations 8–11 follow.
- **Future iterations, after iteration 11** (parked 2026-08-08 — recorded,
not scheduled):
- WO-W201 `@gc`-suggestion diagnostic refinement (self-reference-only
heuristic shipped; shared-structure analysis deferred).
- WO-E225 unknown-type validation broadened to `ref`/`multi`/`map`
element types and method/fn signatures.
- ADT container roster adoption (Stack, Queue, Set, Tree, Graph, … —
see the roster section in
`docs/plan/compiler/nullable-types-implementation.md`); log-watcher
needs only `multi`/`map`, so no ADT lands before iteration 7.
- Recorded future stories, deliberately outside this one: the web framework - Recorded future stories, deliberately outside this one: the web framework
as a `.wo` library over the recipe-box runtime; UI (`##ui` SSR + live as a `.wo` library over the recipe-box runtime; UI (`##ui` SSR + live
patches); script-based destructive schema migrations; MCP/agent wrapper patches); script-based destructive schema migrations; MCP/agent wrapper

View file

@ -91,17 +91,17 @@ Grammar stays plan-13 compatible — `class` = fields + `fn`, no inheritance. Ne
```wo ```wo
interface Priced { interface Priced {
fn current_price() -> Money fn current_price() -> Int
} }
@table(name: "products") @table(name: "products")
class Product { -- default: owned, borrow-checked class Product { -- default: owned, borrow-checked
id: Id id: Id
sku: SKU @unique sku: Text @unique
name: Text name: Text
prices: multi Price prices: multi Price
fn current_price() -> Money { -- satisfies Priced structurally fn current_price() -> Int { -- satisfies Priced structurally
return latest(self.prices).amount; return latest(self.prices).amount;
} }
@ -112,7 +112,7 @@ class Product { -- default: owned, borrow-checked
@gc @gc
class PriceCache { -- reference semantics, freely aliased class PriceCache { -- reference semantics, freely aliased
entries: map<SKU, Money> entries: map<Text, Int>
} }
``` ```
@ -163,7 +163,7 @@ struct wo_hdr {
> Normative format reference (pinned by plan 1): [`docs/plan/oop-vm/00-wob-format.md`](../../plan/oop-vm/00-wob-format.md) · machine-readable twin: [`runtime/src/wob.h`](../../../runtime/src/wob.h) > Normative format reference (pinned by plan 1): [`docs/plan/oop-vm/00-wob-format.md`](../../plan/oop-vm/00-wob-format.md) · machine-readable twin: [`runtime/src/wob.h`](../../../runtime/src/wob.h)
**Registers:** untyped 64-bit slots. The language is statically typed — the compiler knows every slot's type, so no tagging and no NaN-boxing. Scalars inline (`Int`/`Money`/`Timestamp` = i64, `Bool`), heap values as pointers (the header supplies the class at runtime for interface dispatch and traps). **Registers:** untyped 64-bit slots. The language is statically typed — the compiler knows every slot's type, so no tagging and no NaN-boxing. Scalars inline (`Int`/`Timestamp` = i64, `Bool`), heap values as pointers (the header supplies the class at runtime for interface dispatch and traps).
**`.wob` module format:** magic + version, then sections — constant pool (texts, numerics), class table (field layout, size, `@gc` bit, drop plan), interface table, per-(class, interface) vtables, method code (arg count, register count, bytecode), line table (for error reporting). The loader `mmap`s the file, bounds-validates every index once, and links class ids. **`.wob` module format:** magic + version, then sections — constant pool (texts, numerics), class table (field layout, size, `@gc` bit, drop plan), interface table, per-(class, interface) vtables, method code (arg count, register count, bytecode), line table (for error reporting). The loader `mmap`s the file, bounds-validates every index once, and links class ids.

View file

@ -44,7 +44,7 @@ Every Haxe keyword (plus the contextual ones), one verdict each: **have** (write
| `break` / `continue` | **adopt** | loop control | | `break` / `continue` | **adopt** | loop control |
| `do` (do-while) | **adopt** | parity, trivial | | `do` (do-while) | **adopt** | parity, trivial |
| `static` | **adopt** | class-level `fn`/`const` — namespaced functions without instances (`Flock.held`, `Pgrep.alive` pattern) | | `static` | **adopt** | class-level `fn`/`const` — namespaced functions without instances (`Flock.held`, `Pgrep.alive` pattern) |
| `abstract` | **adopt** | newtype over a scalar, zero-cost, unit-safe (`Money`, `SKU` become in-language, not magic stdlib scalars); `from`/`to` conversion rules explicit | | `abstract` | **reject** | a distinct scalar type adds a conversion surface without buying safety this language needs; domain scalars are plain `Int`/`Text` |
| `using` | **adopt** | static extension methods — doctrine-safe reuse (composition sugar, the inheritance substitute) | | `using` | **adopt** | static extension methods — doctrine-safe reuse (composition sugar, the inheritance substitute) |
| `import` / `package` | **adopt** | `use` + directory-as-module; stdlib namespaces (`fs`, `proc`, `net`, `time`, `env`, `json`) | | `import` / `package` | **adopt** | `use` + directory-as-module; stdlib namespaces (`fs`, `proc`, `net`, `time`, `env`, `json`) |
| `is` | **adopt** | runtime type test restricted to union variants and interface values; a compile error on statically-known types | | `is` | **adopt** | runtime type test restricted to union variants and interface values; a compile error on statically-known types |

View file

@ -26,6 +26,14 @@ rt-c-demo port="8085" threads="4":
curl -s "$base/api/notes"; echo curl -s "$base/api/notes"; echo
echo "--- spread:"; curl -s "$base/"; echo echo "--- spread:"; curl -s "$base/"; echo
# woc compiler front (compiler/): build the executable
woc-build:
dune build --root compiler
# woc gate: unit tests (test_diag) + golden suite (runner, WOC_BLESS=1 to update)
woc-test:
dune runtest --root compiler
# phase-F benchmark: reads, durable writes, 10k idle conns (scaled geometry) # phase-F benchmark: reads, durable writes, 10k idle conns (scaled geometry)
rt-c-bench port="8085" threads="8" conns="64": rt-c-bench port="8085" threads="8" conns="64":
#!/usr/bin/env bash #!/usr/bin/env bash