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:
parent
d2b855b3d1
commit
2de724df11
57 changed files with 3785 additions and 344 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -69,4 +69,9 @@ docs/examples/agent-loop/
|
|||
docs/examples/mcp-think/
|
||||
docs/plan/exploration/
|
||||
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/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -12,19 +12,31 @@ OCaml 4.14.1, dune 3.14.0 — Ubuntu 24.04 apt packages (`sudo apt install ocaml
|
|||
|
||||
```bash
|
||||
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-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
|
||||
|
||||
- `src/` — one module per stage, added as each task lands: diag, token, lexer, ast, parser, types, owner, dump
|
||||
- `bin/` — the `woc` executable (check / emit / build modes land in later tasks)
|
||||
- `test/` — golden runner; `test/golden/` holds fixtures per stage (Task 3 onward)
|
||||
- `plan/` — compiler-track docs: [`architecture.md`](plan/architecture.md) (pipeline, module contracts, reference-study map) + plans 2, 3, 8
|
||||
- `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: CLI parsing, file discovery, the multi-file/cross-file driver
|
||||
- `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
|
||||
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -7,30 +7,53 @@
|
|||
(compiler/src/{ast,parser}.ml) behind --dump-ast, printing a stable,
|
||||
golden-diffed AST dump (compiler/src/dump.ml) to stdout. Tasks 5-7
|
||||
add statement/expression parsing, the typechecker, and the ownership
|
||||
pass behind their own --dump-* flags; Task 8 adds directory
|
||||
discovery and multi-file programs, at which point the bare
|
||||
`woc <path>` form below starts actually compiling instead of just
|
||||
checking existence.
|
||||
pass behind their own --dump-* flags. Task 8 adds directory
|
||||
discovery and multi-file programs: <path> may now be a single .wo
|
||||
file or a directory, recursively discovered the same way `wo run`
|
||||
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
|
||||
1 = diagnostics reported (reachable now: --dump-tokens on source
|
||||
with an unknown character, or --dump-ast on source with a
|
||||
broken declaration, reports WO-E0xx/WO-E1xx and exits 1)
|
||||
1 = diagnostics reported
|
||||
2 = usage or IO failure
|
||||
|
||||
With no arguments, this prints usage to stderr and exits 2. Given a
|
||||
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
|
||||
predates any real compilation and stays as-is until Task 8. *)
|
||||
With no arguments, this prints usage to stderr and exits 2.
|
||||
|
||||
---- Multi-file dump layout ----
|
||||
|
||||
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 =
|
||||
"usage: woc <path>\n\
|
||||
usage: woc --dump-tokens <file.wo>\n\
|
||||
usage: woc --dump-ast <file.wo>\n\
|
||||
usage: woc --dump-tokens <path>\n\
|
||||
usage: woc --dump-ast <path>\n\
|
||||
usage: woc --dump-owner <path>\n\
|
||||
\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\
|
||||
a later task; compilation itself has not landed yet either).\n\
|
||||
directory: a directory is discovered recursively for every .wo file\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\
|
||||
--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\
|
||||
|
|
@ -41,6 +64,16 @@ let usage_msg =
|
|||
statement/expression parsing inside method and free-fn bodies);\n\
|
||||
parsing diagnostics, if any, print to stderr.\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"
|
||||
|
||||
let read_source path =
|
||||
|
|
@ -52,59 +85,238 @@ let read_source path =
|
|||
Ok s
|
||||
with Sys_error msg -> Error msg
|
||||
|
||||
let dump_tokens path =
|
||||
if not (Sys.file_exists path) then begin
|
||||
Printf.eprintf "woc: no such file or directory: %s\n" path;
|
||||
exit 2
|
||||
end;
|
||||
match read_source path with
|
||||
(* ---- File discovery (Task 8) ----------------------------------------
|
||||
|
||||
Mirrors wo run's discovery contract (crates/rt/src/lib.rs::discover)
|
||||
exactly: recursive, skips any entry (file or directory) whose name
|
||||
starts with '.' or is literally "target", "data", or "node_modules",
|
||||
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 ->
|
||||
Printf.eprintf "woc: %s\n" msg;
|
||||
exit 2
|
||||
| Ok src ->
|
||||
let collector = Woc_lib.Diag.Collector.create () in
|
||||
let toks = Woc_lib.Lexer.tokenize collector ~file:path src in
|
||||
print_string (Woc_lib.Dump.dump_tokens toks);
|
||||
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_newline ();
|
||||
exit 1
|
||||
end
|
||||
else exit 0
|
||||
| Ok files ->
|
||||
List.map
|
||||
(fun f ->
|
||||
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
|
||||
prerr_string (Woc_lib.Diag.Collector.render_all collector lookup);
|
||||
prerr_newline ();
|
||||
exit 1
|
||||
end
|
||||
else exit 0
|
||||
|
||||
(* ---- Cross-file symbol resolution (Task 8) ---------------------------
|
||||
|
||||
Types.collect_declarations / Types.typecheck_program are already
|
||||
split into a declare-pass and a check-pass (Task 6); that split is
|
||||
exactly what multi-file needs, so the driver spans files by calling
|
||||
each pass once per file and merging the declare-pass output before
|
||||
any file's check-pass runs — types.ml itself needs no change. Each
|
||||
file's own collect_declarations call still gets that file's own
|
||||
`~file`, so its own diagnostics (e.g. WO-W201) tag the right file;
|
||||
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 multi = List.length sources > 1 in
|
||||
List.iter
|
||||
(fun (f, src) ->
|
||||
let toks = Woc_lib.Lexer.tokenize collector ~file:f src in
|
||||
if multi then print_string (Woc_lib.Dump.file_header f);
|
||||
print_string (Woc_lib.Dump.dump_tokens toks))
|
||||
sources;
|
||||
finish collector (build_lookup sources)
|
||||
|
||||
let dump_ast path =
|
||||
if not (Sys.file_exists path) then begin
|
||||
Printf.eprintf "woc: no such file or directory: %s\n" path;
|
||||
exit 2
|
||||
end;
|
||||
match read_source path with
|
||||
| Error msg ->
|
||||
Printf.eprintf "woc: %s\n" msg;
|
||||
exit 2
|
||||
| Ok src ->
|
||||
let collector = Woc_lib.Diag.Collector.create () in
|
||||
let toks = Woc_lib.Lexer.tokenize collector ~file:path src in
|
||||
let prog = Woc_lib.Parser.parse collector ~file:path toks in
|
||||
print_string (Woc_lib.Dump.dump_ast prog);
|
||||
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_newline ();
|
||||
exit 1
|
||||
end
|
||||
else exit 0
|
||||
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 () =
|
||||
match Sys.argv with
|
||||
| [| _; "--dump-tokens"; path |] -> dump_tokens path
|
||||
| [| _; "--dump-ast"; path |] -> dump_ast path
|
||||
| [| _; path |] ->
|
||||
if Sys.file_exists path then exit 0
|
||||
else begin
|
||||
Printf.eprintf "woc: no such file or directory: %s\n" path;
|
||||
exit 2
|
||||
end
|
||||
| [| _; "--dump-owner"; path |] -> dump_owner path
|
||||
| [| _; path |] -> check_only path
|
||||
| _ ->
|
||||
prerr_string usage_msg;
|
||||
exit 2
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ let lexing_prefix = "WO-E0"
|
|||
let parsing_prefix = "WO-E1"
|
||||
let types_prefix = "WO-E2"
|
||||
let ownership_prefix = "WO-E3"
|
||||
let warning_prefix = "WO-W"
|
||||
|
||||
type severity =
|
||||
| Error
|
||||
|
|
|
|||
|
|
@ -77,6 +77,16 @@ let kind_label (k : Token.kind) : string =
|
|||
| Token.Newline -> "NEWLINE"
|
||||
| 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 lines =
|
||||
List.map
|
||||
|
|
@ -273,3 +283,152 @@ let dump_decl : Ast.decl -> string list = function
|
|||
let dump_ast (prog : Ast.program) : string =
|
||||
let lines = List.concat_map dump_decl prog.decls in
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
; OCaml stdlib only: no Menhir, no ppx, no opam libraries.
|
||||
(library
|
||||
(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
1429
compiler/src/owner.ml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -97,16 +97,51 @@ and symbols = {
|
|||
}
|
||||
|
||||
(* 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 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 =
|
||||
try
|
||||
let cls = StringMap.find name syms.classes in
|
||||
cls.is_gc
|
||||
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 *)
|
||||
let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind =
|
||||
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 missing_nil_check_code = Diag.types_prefix ^ "13"
|
||||
|
||||
let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *)
|
||||
|
||||
(* ============================================================
|
||||
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 interfaces = 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;
|
||||
pos = c.pos;
|
||||
} in
|
||||
classes := StringMap.add c.name info !classes
|
||||
classes := StringMap.add c.name info !classes;
|
||||
suggest_gc_annotation ~file info collector
|
||||
| Ast.Interface i ->
|
||||
let methods = List.map (fun (m : Ast.method_sig) ->
|
||||
{ name = m.name;
|
||||
|
|
@ -203,7 +241,7 @@ let collect_declarations (_prog : program) (_collector : Diag.Collector.t) : sym
|
|||
pos = f.pos;
|
||||
} in
|
||||
free_fns := StringMap.add f.name info !free_fns
|
||||
) _prog.decls;
|
||||
) prog.decls;
|
||||
|
||||
{ classes = !classes; interfaces = !interfaces; free_fns = !free_fns;
|
||||
typedefs = !typedefs; modules = !modules }
|
||||
|
|
@ -217,13 +255,47 @@ type expr_type_result = {
|
|||
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 =
|
||||
match ft with
|
||||
| Scalar name ->
|
||||
if is_builtin_scalar name then TScalar name
|
||||
else if is_gc_class syms name then TScalar name
|
||||
else TScalar name
|
||||
| Scalar name -> TScalar name
|
||||
| Ref name -> TRef name
|
||||
| Multi inner_name -> TMulti (TScalar inner_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
|
||||
(match base_res.typ with
|
||||
| TScalar class_name ->
|
||||
(try
|
||||
let cls = StringMap.find class_name syms.classes in
|
||||
let (_, field_ty, _, _) = List.find (fun (fname, _, _, _) -> fname = field_name) cls.fields in
|
||||
{ typ = resolve_field_ty field_ty; is_nil = false }
|
||||
with Not_found ->
|
||||
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) ());
|
||||
{ typ = TScalar "Int"; is_nil = false })
|
||||
(* Only a *declared* class can be checked for a missing field.
|
||||
typecheck_expr falls back to `TScalar "Int"` for everything
|
||||
it cannot type yet (an unresolved builtin call such as the
|
||||
spec's own `latest(...)`, an indexed element), so reporting
|
||||
on a non-class base turned every one of those placeholders
|
||||
into a bogus "unknown field" error -- spec section 3's
|
||||
`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) ());
|
||||
{ typ = TScalar "Int"; is_nil = false }))
|
||||
| _ -> { typ = TScalar "Int"; is_nil = false })
|
||||
| Index (base, idx) ->
|
||||
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
|
||||
List.iter (fun (fname, _, _, _) ->
|
||||
if not (List.mem fname provided) then
|
||||
Diag.Collector.add _collector
|
||||
(Diag.error ~code:incomplete_ctor_code ~file:"" ~line:e.pos.line ~col:e.pos.col
|
||||
Diag.Collector.add collector
|
||||
(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) ())
|
||||
) cls.fields;
|
||||
{ typ = TScalar class_name; is_nil = false }
|
||||
with Not_found ->
|
||||
Diag.Collector.add _collector
|
||||
(Diag.error ~code:unknown_type_code ~file:"" ~line:e.pos.line ~col:e.pos.col
|
||||
Diag.Collector.add collector
|
||||
(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) ());
|
||||
{ typ = TScalar "Int"; is_nil = false })
|
||||
| DbStub _ -> { typ = TVoid; is_nil = false }
|
||||
|
|
@ -316,17 +399,20 @@ let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Coll
|
|||
env
|
||||
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, _) ->
|
||||
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
|
||||
false
|
||||
in
|
||||
|
||||
StringMap.iter (fun _name cls ->
|
||||
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;
|
||||
|
||||
StringMap.iter (fun _name (fn : free_fn_info) ->
|
||||
|
|
@ -341,9 +427,9 @@ let typecheck_program (_prog : program) (syms : symbols) (_collector : Diag.Coll
|
|||
Entry point
|
||||
============================================================ *)
|
||||
|
||||
let typecheck (_prog : program) (_collector : Diag.Collector.t) : symbols * unit =
|
||||
let syms = collect_declarations _prog _collector in
|
||||
let () = typecheck_program _prog syms _collector in
|
||||
let typecheck ~file (prog : program) (collector : Diag.Collector.t) : symbols * unit =
|
||||
let syms = collect_declarations ~file prog collector in
|
||||
let () = typecheck_program ~file prog syms collector in
|
||||
(syms, ())
|
||||
|
||||
(* ============================================================
|
||||
|
|
|
|||
|
|
@ -22,10 +22,16 @@
|
|||
; target at the same relative path inside the sandbox) guarantees
|
||||
; "../bin/woc" resolves from this test's cwd exactly the way runner.ml
|
||||
; 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
|
||||
(name runner)
|
||||
(modules runner)
|
||||
(libraries woc_lib)
|
||||
(deps
|
||||
(source_tree golden)
|
||||
(source_tree fixtures)
|
||||
../bin/woc))
|
||||
|
|
|
|||
3
compiler/test/fixtures/driver/collision/a_first.wo
vendored
Normal file
3
compiler/test/fixtures/driver/collision/a_first.wo
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Dup {
|
||||
n: Int
|
||||
}
|
||||
3
compiler/test/fixtures/driver/collision/b_second.wo
vendored
Normal file
3
compiler/test/fixtures/driver/collision/b_second.wo
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Dup {
|
||||
s: Text
|
||||
}
|
||||
3
compiler/test/fixtures/driver/crossfile/a_uses.wo
vendored
Normal file
3
compiler/test/fixtures/driver/crossfile/a_uses.wo
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Holder {
|
||||
box: Box
|
||||
}
|
||||
3
compiler/test/fixtures/driver/crossfile/b_declares.wo
vendored
Normal file
3
compiler/test/fixtures/driver/crossfile/b_declares.wo
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Box {
|
||||
n: Int
|
||||
}
|
||||
13
compiler/test/fixtures/driver/order/aaa_ownership.wo
vendored
Normal file
13
compiler/test/fixtures/driver/order/aaa_ownership.wo
vendored
Normal 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
|
||||
}
|
||||
1
compiler/test/fixtures/driver/order/zzz_lex.wo
vendored
Normal file
1
compiler/test/fixtures/driver/order/zzz_lex.wo
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
$
|
||||
|
|
@ -1,24 +1,24 @@
|
|||
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])
|
||||
7:3 FIELD id: Id
|
||||
8:3 FIELD sku: SKU @unique
|
||||
8:3 FIELD sku: Text @unique
|
||||
9:3 FIELD name: Text
|
||||
10:3 FIELD prices: multi Price
|
||||
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
|
||||
17:3 METHOD rename(name: Text)
|
||||
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
|
||||
25:3 METHOD adopt(take other: Product) -> Product
|
||||
26:5 RETURN other
|
||||
31:1 CLASS PriceCache @gc
|
||||
32:3 FIELD entries: map<SKU, Money>
|
||||
32:3 FIELD entries: map<Text, Int>
|
||||
35:1 TYPE Note
|
||||
36:3 FIELD id: Id
|
||||
37:3 FIELD body: Text
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
interface Priced {
|
||||
fn current_price() -> Money
|
||||
fn current_price() -> Int
|
||||
}
|
||||
|
||||
@table(name: "products", index: [sku])
|
||||
class Product {
|
||||
id: Id
|
||||
sku: SKU @unique
|
||||
sku: Text @unique
|
||||
name: Text
|
||||
prices: multi Price
|
||||
owner: ref Customer
|
||||
|
||||
fn current_price() -> Money {
|
||||
fn current_price() -> Int {
|
||||
return latest(self.prices).amount;
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ class Product {
|
|||
self.name = name;
|
||||
}
|
||||
|
||||
fn set_price(mut amount: Money) {
|
||||
fn set_price(mut amount: Int) {
|
||||
self.prices = amount;
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ class Product {
|
|||
|
||||
@gc
|
||||
class PriceCache {
|
||||
entries: map<SKU, Money>
|
||||
entries: map<Text, Int>
|
||||
}
|
||||
|
||||
type Note {
|
||||
|
|
@ -38,6 +38,6 @@ type Note {
|
|||
created: Timestamp = now()
|
||||
}
|
||||
|
||||
fn discount(mut amount: Money, take pct: Int) -> Money {
|
||||
fn discount(mut amount: Int, take pct: Int) -> Int {
|
||||
return amount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ class Good {
|
|||
}
|
||||
|
||||
interface Broken2 {
|
||||
fn oops(x Text) -> Money
|
||||
fn oops(x Text) -> Int
|
||||
}
|
||||
|
|
|
|||
20
compiler/test/golden/owner-err/borrow-escape.expected
Normal file
20
compiler/test/golden/owner-err/borrow-escape.expected
Normal 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 {
|
||||
^
|
||||
23
compiler/test/golden/owner-err/borrow-escape.wo
Normal file
23
compiler/test/golden/owner-err/borrow-escape.wo
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
^
|
||||
22
compiler/test/golden/owner-err/borrowed-place-mutated.wo
Normal file
22
compiler/test/golden/owner-err/borrowed-place-mutated.wo
Normal 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
|
||||
}
|
||||
13
compiler/test/golden/owner-err/double-mut.expected
Normal file
13
compiler/test/golden/owner-err/double-mut.expected
Normal 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])
|
||||
^
|
||||
19
compiler/test/golden/owner-err/double-mut.wo
Normal file
19
compiler/test/golden/owner-err/double-mut.wo
Normal 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])
|
||||
}
|
||||
6
compiler/test/golden/owner-err/loop-move.expected
Normal file
6
compiler/test/golden/owner-err/loop-move.expected
Normal 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)
|
||||
^
|
||||
15
compiler/test/golden/owner-err/loop-move.wo
Normal file
15
compiler/test/golden/owner-err/loop-move.wo
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
^
|
||||
16
compiler/test/golden/owner-err/move-while-borrowed.wo
Normal file
16
compiler/test/golden/owner-err/move-while-borrowed.wo
Normal 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)
|
||||
}
|
||||
6
compiler/test/golden/owner-err/use-after-move.expected
Normal file
6
compiler/test/golden/owner-err/use-after-move.expected
Normal 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)
|
||||
^
|
||||
13
compiler/test/golden/owner-err/use-after-move.wo
Normal file
13
compiler/test/golden/owner-err/use-after-move.wo
Normal 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
|
||||
}
|
||||
23
compiler/test/golden/owner/drops.expected
Normal file
23
compiler/test/golden/owner/drops.expected
Normal 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 ==
|
||||
65
compiler/test/golden/owner/drops.wo
Normal file
65
compiler/test/golden/owner/drops.wo
Normal 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
|
||||
}
|
||||
11
compiler/test/golden/owner/moves.expected
Normal file
11
compiler/test/golden/owner/moves.expected
Normal 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 ==
|
||||
18
compiler/test/golden/owner/moves.wo
Normal file
18
compiler/test/golden/owner/moves.wo
Normal 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
|
||||
}
|
||||
6
compiler/test/golden/owner/pricing-demo.expected
Normal file
6
compiler/test/golden/owner/pricing-demo.expected
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
== MOVES ==
|
||||
26:12 MOVE other RETURN
|
||||
== DROPS ==
|
||||
22:5 OVERWRITE self.prices
|
||||
== RC ==
|
||||
== RESIDUAL ==
|
||||
43
compiler/test/golden/owner/pricing-demo.wo
Normal file
43
compiler/test/golden/owner/pricing-demo.wo
Normal 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;
|
||||
}
|
||||
16
compiler/test/golden/owner/rc.expected
Normal file
16
compiler/test/golden/owner/rc.expected
Normal 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 ==
|
||||
37
compiler/test/golden/owner/rc.wo
Normal file
37
compiler/test/golden/owner/rc.wo
Normal 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
|
||||
}
|
||||
7
compiler/test/golden/owner/residual.expected
Normal file
7
compiler/test/golden/owner/residual.expected
Normal 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]
|
||||
33
compiler/test/golden/owner/residual.wo
Normal file
33
compiler/test/golden/owner/residual.wo
Normal 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)
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ module Lexer = Woc_lib.Lexer
|
|||
module Ast = Woc_lib.Ast
|
||||
module Parser = Woc_lib.Parser
|
||||
module Dump = Woc_lib.Dump
|
||||
module Types = Woc_lib.Types
|
||||
module Owner = Woc_lib.Owner
|
||||
|
||||
let read_file path =
|
||||
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"
|
||||
(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 ------------------------------------------ *)
|
||||
|
||||
(* Each stage directory under golden/ names one `woc --dump-*` flag.
|
||||
"tokens" (Task 3) and "ast" (Task 4) exist today; later tasks add
|
||||
"types", "owner" alongside their own dump function in dump.ml. *)
|
||||
"tokens" (Task 3), "ast" (Task 4) and "owner" (Task 7) exist today; a
|
||||
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 =
|
||||
match stage with
|
||||
| "tokens" ->
|
||||
|
|
@ -793,6 +1492,13 @@ let run_stage ~stage ~file ~src : string =
|
|||
let toks = Lexer.tokenize collector ~file src in
|
||||
let prog = Parser.parse collector ~file toks in
|
||||
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 ->
|
||||
failwith (Printf.sprintf "runner: unknown golden stage directory %S" other)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ type Order {
|
|||
line_items: [{
|
||||
product: ref Product
|
||||
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.
|
||||
total: Money = sum(line_items.*.qty * line_items.*.unit_price)
|
||||
total: Int = sum(line_items.*.qty * line_items.*.unit_price)
|
||||
|
||||
placed_at: Timestamp = now()
|
||||
paid_at: Timestamp?
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
|
||||
type Product {
|
||||
id: Id
|
||||
sku: SKU @unique
|
||||
sku: Text @unique
|
||||
name: Text
|
||||
price: Money -- minor units (cents); `Money` is a stdlib scalar
|
||||
price: Int -- minor units (cents)
|
||||
|
||||
meta: { -- embedded document
|
||||
description: Markdown
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type Purchase link Customer -> Product {
|
|||
order: ref Order -- FK so you can query
|
||||
-- `Customer.purchased{ order.status == Paid }`
|
||||
qty: Int @check(> 0)
|
||||
unit_price: Money -- captured at checkout time
|
||||
unit_price: Int -- captured at checkout time
|
||||
at: Timestamp = now()
|
||||
|
||||
policy read for role Admin
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@
|
|||
class Price {
|
||||
id: Id
|
||||
product: ref Product -- owning product (foreign key)
|
||||
amount: Money -- minor units (cents); stdlib scalar
|
||||
amount: Int -- minor units (cents)
|
||||
currency: Text = "EUR"
|
||||
at: Timestamp = now()
|
||||
|
||||
-- Method: implicit `self` receiver, like Go methods — no class hierarchy,
|
||||
-- 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
|
||||
class Product {
|
||||
id: Id
|
||||
sku: SKU @unique
|
||||
sku: Text @unique
|
||||
name: Text
|
||||
prices: multi Price -- append-only price history
|
||||
|
||||
-- Row-scoped transactional method (13b): runs inside a snapshot of the
|
||||
-- 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;
|
||||
}
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ class Product {
|
|||
-- commit or roll back together (one WAL frame); the commit pushes one
|
||||
-- delta to every subscriber of this product (13c) and patches every open
|
||||
-- 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"
|
||||
insert Price { product: self.id, amount: amount };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
>
|
||||
> **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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
**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.
|
||||
- [ ] 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.`
|
||||
|
||||
### 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.
|
||||
- [ ] 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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
**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.
|
||||
- [ ] 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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
||||
**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.
|
||||
- [ ] Record commit draft: `test(corpus): pricing-demo logic subset — discounted, current_price via multi/latest, container + text builtins, set_price DB_STUB trap fixture.`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**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.
|
||||
- [ ] Implement; green.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- **Lexer** (`lexer.ml`): ✅ Has `Question` token (`?`) - line 266
|
||||
- **Token** (`token.ml`): Has `Question` kind - line 60
|
||||
- **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**
|
||||
- **Dump** (`dump.ml`): Handles existing field types - needs update
|
||||
- **Typechecker** (`types.ml`): Not yet created (Task 6)
|
||||
`?T` is **plumbed but not enforced**. Every stage that carries the syntax
|
||||
through the pipeline shipped; the one stage that would give it meaning —
|
||||
forced handling in the typechecker — did not.
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| **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):**
|
||||
```ocaml
|
||||
type field_ty =
|
||||
| Scalar of string
|
||||
| Ref of string
|
||||
| Multi of string
|
||||
| Map of string * string
|
||||
| Nullable of field_ty (* NEW: ?T wrapper *)
|
||||
**Goal:** Keep explicit `@gc` but add compiler diagnostic when borrow checker cannot prove safety.
|
||||
|
||||
**New Diagnostic:**
|
||||
```
|
||||
WO-W201: <ClassName> 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).
|
||||
```
|
||||
|
||||
**Full Refactor (Option B - Recommended):**
|
||||
```ocaml
|
||||
type field_ty =
|
||||
| Scalar of string
|
||||
| Ref of string
|
||||
| Multi of string
|
||||
| Map of string * string
|
||||
| Nullable of field_ty (* NEW: ?T wrapper *)
|
||||
**When to emit:**
|
||||
- Class has fields that form recursive structures (e.g., `map<K, V>`, `multi T` where T is the same class)
|
||||
- Class has fields that are borrowed in ways the borrow checker cannot prove safe
|
||||
- Class is used in patterns typical of caches/registries (stored in `map`, passed as `@gc` ref)
|
||||
|
||||
(* Update these to use field_ty for consistency *)
|
||||
type param = {
|
||||
id : int;
|
||||
pos : pos;
|
||||
name : string;
|
||||
conv : param_conv;
|
||||
ty : field_ty; (* was: string *)
|
||||
}
|
||||
**When NOT to emit:**
|
||||
- Class has `@table` annotation → must be owned (DB-backed)
|
||||
- Class has `@unique` field → persistent identity
|
||||
- Class is a simple data struct (no recursive/shared patterns)
|
||||
|
||||
type method_sig = {
|
||||
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.
|
||||
Shipped, with the narrower heuristic recorded above under "Narrowing notes."
|
||||
|
||||
---
|
||||
|
||||
#### 3. Parser (`parser.ml`) - Parse `?` Prefix
|
||||
### 2. Scalar Type Corrections
|
||||
|
||||
**Changes needed in `parse_field_ty` (line 312):**
|
||||
**Current (Wrong):**
|
||||
```ocaml
|
||||
let parse_field_ty (st : state) : Ast.field_ty =
|
||||
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
|
||||
let builtin_scalars = ["Int"; "Bool"; "Text"; "Money"; "Timestamp"; "Id"; "SKU"]
|
||||
```
|
||||
|
||||
**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
|
||||
let parse_ret_type (st : state) : Ast.field_ty option =
|
||||
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
|
||||
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
|
||||
```
|
||||
|
||||
**Parse `?` prefix for parameters:**
|
||||
```ocaml
|
||||
let parse_param (st : state) : Ast.param =
|
||||
let pos = peek_pos st in
|
||||
let conv =
|
||||
if accept st Token.KwMut then Ast.Mut
|
||||
else if accept st Token.KwTake then Ast.Take
|
||||
else Ast.Borrow
|
||||
in
|
||||
let name = expect_ident st "parameter name" in
|
||||
expect st Token.Colon "':'";
|
||||
let ty = parse_field_ty st in (* now returns field_ty *)
|
||||
{ Ast.id = fresh_id st; pos; name; conv; ty }
|
||||
```
|
||||
**Changes, in order:**
|
||||
1. Task 6b: removed `Money`, `SKU` (they had no `abstract` declaration to
|
||||
back them — magic strings) and added `Float` (documented as "IEEE 754
|
||||
double / f64").
|
||||
2. This change (2026-08-10): removed `Float` too. It had the identical
|
||||
phantom-scalar defect from the opposite direction — `token.ml` has no
|
||||
float-literal kind and `wob.h` has no float representation, so `ratio:
|
||||
Float` typechecked while no `Float` value could ever be written or
|
||||
represented. `Money`/`SKU` also stay removed; the stopgap allowlist that
|
||||
let them resolve (`abstract_types`/`is_abstract_type`) is deleted
|
||||
outright, not emptied. `builtin_scalars` is now exactly the five names
|
||||
that work end to end: `Int`, `Bool`, `Text`, `Timestamp`, `Id`.
|
||||
|
||||
#### 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
|
||||
let rec field_ty_str : Ast.field_ty -> string = function
|
||||
| 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 *)
|
||||
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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**:
|
||||
- `Nullable t` → `WO_K_NULLABLE` (new .wob kind = 6)
|
||||
- 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)
|
||||
(`Float`'s row is deleted — see the Scalar Type Corrections section above
|
||||
for why.)
|
||||
|
||||
---
|
||||
|
||||
### .wob Format Changes (Plan 3)
|
||||
## Updated Plan
|
||||
|
||||
**In `runtime/src/wob.h`:**
|
||||
```c
|
||||
enum {
|
||||
WO_K_SCALAR = 0,
|
||||
WO_K_OWNED = 1,
|
||||
WO_K_GCREF = 2,
|
||||
WO_K_TEXT = 3,
|
||||
WO_K_MULTI = 4,
|
||||
WO_K_MAP = 5,
|
||||
WO_K_NULLABLE = 6, // NEW
|
||||
};
|
||||
#define WO_K_MAX 6u
|
||||
### New Tasks Added
|
||||
|
||||
#### Task A: Diagnostic WO-W201 for `@gc` Suggestion
|
||||
|
||||
**File:** `compiler/src/types.ml` (Pass 2 - typechecker)
|
||||
|
||||
**Implementation:**
|
||||
```ocaml
|
||||
(* In typechecker, after analyzing class structure *)
|
||||
let suggest_gc_annotation (cls : class_info) : unit =
|
||||
if cls.is_gc then () (* Already @gc *)
|
||||
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:**
|
||||
- Nullable field = 2 slots: discriminant (uint32_t: 0=null, 1=present) + payload (T's kind)
|
||||
- For SCALAR payload: 16 bytes total (discriminant + i64)
|
||||
- For OWNED/GCREF payload: 16 bytes total (discriminant + pointer)
|
||||
- For TEXT/MULTI/MAP payload: 16 bytes total (discriminant + pointer)
|
||||
**Heuristic for `has_recursive_structure`** (as originally scoped — see
|
||||
"Narrowing notes" above for which two of these four actually shipped):
|
||||
- Class has a field of its own type (direct recursion)
|
||||
- Class has `multi Self` or `map<_, Self>` field
|
||||
- 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`
|
||||
- Add `Nullable of field_ty` to `field_ty`
|
||||
- Change `param.ty : string` → `field_ty`
|
||||
- Change `method_sig.ret : string option` → `field_ty option`
|
||||
- Change `method_decl.ret : string option` → `field_ty option`
|
||||
- Update `param_conv` documentation
|
||||
**Change:**
|
||||
```ocaml
|
||||
(* Before *)
|
||||
let builtin_scalars = ["Int"; "Bool"; "Text"; "Money"; "Timestamp"; "Id"; "SKU"]
|
||||
|
||||
- [ ] **Task 1b**: Update `parser.ml`
|
||||
- Modify `parse_field_ty` to handle `?` prefix
|
||||
- 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
|
||||
(* After *)
|
||||
let builtin_scalars = ["Int"; "Bool"; "Text"; "Float"; "Timestamp"; "Id"]
|
||||
```
|
||||
|
||||
- [ ] **Task 1c**: Update `dump.ml`
|
||||
- Update `field_ty_str` to handle `Nullable`
|
||||
- Update `param_str`, `sig_str`, `dump_method_sig` for new types
|
||||
> **Historical record — do not edit to match current reality.** This is
|
||||
> Task 6b's own correction at the time it was made; `Float` has since been
|
||||
> 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)
|
||||
|
||||
- [ ] **Task 2a**: Create `types.ml` with:
|
||||
- Two-pass symbol collection
|
||||
- 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
|
||||
`abstract` types are rejected outright (see above) — there is no allowlist
|
||||
to add validation for, stopgap or otherwise. An unknown scalar name (not a
|
||||
builtin, not a declared class, not a declared interface) is simply
|
||||
`WO-E225`, unconditionally.
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
### Updated Typechecker Tasks
|
||||
|
||||
### Files to Modify
|
||||
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
|
||||
#### Task 2a (Updated): Typechecker with New Diagnostics
|
||||
|
||||
### Breaking Changes
|
||||
- `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
|
||||
**File:** `compiler/src/types.ml`
|
||||
|
||||
### Compatibility
|
||||
- Parser changes are backward compatible (existing code without `?` still works)
|
||||
- AST changes require updating downstream consumers (typechecker, emitter)
|
||||
- Dump format changes are additive
|
||||
**New WO-E Codes:**
|
||||
| Code | Trigger |
|
||||
|------|---------|
|
||||
| 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
|
||||
|
||||
- [Task 6 Plan](2026-08-01-woc-compiler-front.md#task-6-typechecker) - Lines 107-115
|
||||
- [OOP Compiler VM Design](superpowers/specs/2026-08-01-oop-compiler-vm-design.md) - Section 3, "Nullable types"
|
||||
- [Systems Track Design](superpowers/specs/2026-08-01-systems-track-design.md) - Part 1, "Null<T> → ?T optional types"
|
||||
- [.wob Format](plan/oop-vm/00-wob-format.md) - Field kinds
|
||||
- [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
|
||||
- [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
|
||||
- [OOP Compiler VM Design](../../superpowers/specs/2026-08-01-oop-compiler-vm-design.md) - Section 3
|
||||
- [Systems Track Design](../../superpowers/specs/2026-08-01-systems-track-design.md) - Part 1; the `abstract` row (adopt → reject)
|
||||
|
|
|
|||
49
docs/plan/oop-vm/00-wob-format.md
Normal file
49
docs/plan/oop-vm/00-wob-format.md
Normal 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.
|
||||
94
docs/plan/oop-vm/01-error-catalog.md
Normal file
94
docs/plan/oop-vm/01-error-catalog.md
Normal 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.
|
||||
16
docs/plan/oop-vm/README.md
Normal file
16
docs/plan/oop-vm/README.md
Normal 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`.
|
||||
|
|
@ -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
|
||||
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
|
||||
as a `.wo` library over the recipe-box runtime; UI (`##ui` SSR + live
|
||||
patches); script-based destructive schema migrations; MCP/agent wrapper
|
||||
|
|
|
|||
|
|
@ -91,17 +91,17 @@ Grammar stays plan-13 compatible — `class` = fields + `fn`, no inheritance. Ne
|
|||
|
||||
```wo
|
||||
interface Priced {
|
||||
fn current_price() -> Money
|
||||
fn current_price() -> Int
|
||||
}
|
||||
|
||||
@table(name: "products")
|
||||
class Product { -- default: owned, borrow-checked
|
||||
id: Id
|
||||
sku: SKU @unique
|
||||
sku: Text @unique
|
||||
name: Text
|
||||
prices: multi Price
|
||||
|
||||
fn current_price() -> Money { -- satisfies Priced structurally
|
||||
fn current_price() -> Int { -- satisfies Priced structurally
|
||||
return latest(self.prices).amount;
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ class Product { -- default: owned, borrow-checked
|
|||
|
||||
@gc
|
||||
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)
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ Every Haxe keyword (plus the contextual ones), one verdict each: **have** (write
|
|||
| `break` / `continue` | **adopt** | loop control |
|
||||
| `do` (do-while) | **adopt** | parity, trivial |
|
||||
| `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) |
|
||||
| `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 |
|
||||
|
|
|
|||
8
justfile
8
justfile
|
|
@ -26,6 +26,14 @@ rt-c-demo port="8085" threads="4":
|
|||
curl -s "$base/api/notes"; 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)
|
||||
rt-c-bench port="8085" threads="8" conns="64":
|
||||
#!/usr/bin/env bash
|
||||
|
|
|
|||
Loading…
Reference in a new issue