diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 438233d..0fac938 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -743,6 +743,51 @@ let rec unwrap_nullable (t : typ) : typ = | TNullable inner -> unwrap_nullable inner | other -> other +let is_nullable (t : typ) : bool = match t with TNullable _ -> true | _ -> false + +(* ?T narrowing facts (iteration 5 strictness, WO-E211/E212/E213): which + LOCAL names a condition proves non-nil when it is true, and when it is + false. Only plain identifiers narrow (Haxe's own rule): a field place + (`a.next`) can be re-assigned between the check and the use, so a chain + must go through a `let`. `and` propagates true-facts (both conjuncts + held), `or` propagates false-facts (both disjuncts failed). *) +let rec nil_facts (c : expr) : string list * string list = + match c.kind with + | Binary (Ne, { kind = Ident x; _ }, { kind = NilLit; _ }) + | Binary (Ne, { kind = NilLit; _ }, { kind = Ident x; _ }) -> ([ x ], []) + | Binary (Eq, { kind = Ident x; _ }, { kind = NilLit; _ }) + | Binary (Eq, { kind = NilLit; _ }, { kind = Ident x; _ }) -> ([], [ x ]) + | Binary (And, l, r) -> + let lt, _ = nil_facts l and rt, _ = nil_facts r in + (lt @ rt, []) + | Binary (Or, l, r) -> + let _, lf = nil_facts l and _, rf = nil_facts r in + ([], lf @ rf) + | _ -> ([], []) + +(* narrow the named locals from ?T to T in an environment (and its + confident twin); a name that is not nullable there is left alone *) +let narrow_env (names : string list) (m : typ StringMap.t) : typ StringMap.t = + List.fold_left + (fun acc n -> + match StringMap.find_opt n acc with + | Some (TNullable t) -> StringMap.add n t acc + | _ -> acc) + m names + +(* undo a narrow when a branch's environment flows past the branch (the + then-env leaks out of an else-less `if` by existing convention): restore + each narrowed name's original type so the narrow cannot escape its + guard *) +let unnarrow_env (names : string list) ~(orig : typ StringMap.t) + (m : typ StringMap.t) : typ StringMap.t = + List.fold_left + (fun acc n -> + match StringMap.find_opt n orig with + | Some t -> StringMap.add n t acc + | None -> acc) + m names + (* `ReqInt` accepts any non-`Text` builtin scalar (`Int`, `Bool`, `Timestamp`, `Id`), not literally the string "Int" -- `wob_kind_of_typ` (above) maps all four to the identical runtime representation, @@ -1161,6 +1206,49 @@ let typecheck_program ~file ~(module_of : string -> string) None in + (* ---- ?T forced handling (iteration 5 strictness; WO-E211/E212/E213) ---- + The env types here are declared or confidently inferred — the fallback + placeholders are plain `TScalar "Int"`/`"Bool"`, never `TNullable` — so a + `TNullable` result is always trustworthy and these checks cannot false- + positive off an underivable expression. `current_ret` is the enclosing + fn/method's declared return type, set by each body walk below. *) + let current_ret : typ option ref = ref None in + let report_nullable ~code (pos : pos) (msg : string) : unit = + Diag.Collector.add collector + (Diag.error ~code ~file ~line:pos.line ~col:pos.col ~message:msg ()) + in + let e211 (pos : pos) (what : string) : unit = + report_nullable ~code:nullable_used_without_check_code pos + (Printf.sprintf + "%s is possibly nil (`?T`) and is used where a plain value is required — narrow it first (`if x != nil { ... }`)" + what) + in + let e212 (pos : pos) (what : string) (target : string) : unit = + report_nullable ~code:nullable_assign_mismatch_code pos + (Printf.sprintf + "%s cannot be stored in %s — the target is not nullable; declare it `?T` or narrow the value first" + what target) + in + let e213 (pos : pos) (what : string) : unit = + report_nullable ~code:missing_nil_check_code pos + (Printf.sprintf + "%s is possibly nil (`?T`) — check it against `nil` before reaching through it" + what) + in + (* the boundary test every store/return/argument shares: value flows into a + non-nullable slot *) + let crosses_boundary ~(target : typ) (v : expr_type_result) : bool = + (not (is_nullable target)) && (v.is_nil || is_nullable v.typ) + in + let expr_label (e : expr) : string = + match e.kind with + | Ident n -> Printf.sprintf "`%s`" n + | Field (_, f) -> Printf.sprintf "field `%s`" f + | NilLit -> "`nil`" + | Call _ -> "this call's result" + | _ -> "this value" + in + let rec typecheck_expr (env : typ StringMap.t) (cenv : typ StringMap.t) (e : expr) : expr_type_result = match e.kind with @@ -1174,7 +1262,8 @@ let typecheck_program ~file ~(module_of : string -> string) with Not_found -> { typ = TScalar "Int"; is_nil = false }) | Field (base, field_name) -> let base_res = typecheck_expr env cenv base in - (match (match base_res.typ with TRef c -> TScalar c | other -> other) with + if is_nullable base_res.typ then e213 base.pos (expr_label base); + (match (match unwrap_nullable base_res.typ with TRef c -> TScalar c | other -> other) with | TScalar class_name -> (* Only a *declared* class can be checked for a missing field. typecheck_expr falls back to `TScalar "Int"` for everything @@ -1200,9 +1289,10 @@ let typecheck_program ~file ~(module_of : string -> string) | Index (base, idx) -> let base_res = typecheck_expr env cenv base in let _ = typecheck_expr env cenv idx in + if is_nullable base_res.typ then e213 base.pos (expr_label base); (* `xs[i]` yields the container's element type — a `multi C` indexed is a C (iteration 9b: query results are indexed to pick a row) *) - (match base_res.typ with + (match unwrap_nullable base_res.typ with | TMulti et -> { typ = et; is_nil = false } | TMap (_, vt) -> { typ = vt; is_nil = false } | _ -> { typ = TScalar "Int"; is_nil = false }) @@ -1262,19 +1352,22 @@ let typecheck_program ~file ~(module_of : string -> string) so this is the clean case the brief's own note anticipated, not the fallback ("reuse the invalid-operand pattern"). *) let _ = typecheck_expr env cenv left in - let _ = typecheck_expr env cenv right in + (* short-circuit narrowing: `x != nil and x.n > 0` — the right + operand only evaluates when the left held, so the left's facts + narrow it (true-facts for `and`, false-facts for `or`) *) + let lt, lf = nil_facts left in + let rnames = match op with And -> lt | _ -> lf in + let _ = typecheck_expr (narrow_env rnames env) (narrow_env rnames cenv) right in let op_name = match op with And -> "and" | _ -> "or" in let check_operand (operand : expr) = match confident_typ cenv operand with | None -> () (* underivable -- stay silent, no false positives *) | Some t -> - (* `unwrap_nullable` accepts a `?Bool` operand silently -- - no forced-handling diagnostic for the nil case, same - shape as the pre-existing, disclosed `?T`-enforcement - gap (docs/plan/compiler/nullable-types-implementation.md: - "?T is plumbed but not enforced"). Task 6's own - WO-E211/E212/E213 work should revisit this call site - too, not just field/return positions. *) + (* a `?Bool` operand is an unnarrowed nullable in a position + that consumes the bare value (WO-E211) — unless the operand + is itself a nil-comparison shape, which is the narrowing + idiom and types plain Bool anyway *) + (if is_nullable t then e211 operand.pos (expr_label operand)); if unwrap_nullable t <> TScalar "Bool" then Diag.Collector.add collector (Diag.error ~code:type_mismatch_code ~file ~line:operand.pos.line @@ -1327,6 +1420,8 @@ let typecheck_program ~file ~(module_of : string -> string) | Binary (((Add | Sub | Mul | Div | Mod) as op), left, right) -> let lres = typecheck_expr env cenv left in let rres = typecheck_expr env cenv right in + if is_nullable lres.typ || lres.is_nil then e211 left.pos (expr_label left); + if is_nullable rres.typ || rres.is_nil then e211 right.pos (expr_label right); (* `+` is arithmetic, never string addition (docs/plan/oop-vm/ 08-builtin-surface.md's operator table) — and a Text operand here is not a harmless type slip: the emitter would lower it to ADD on @@ -1354,12 +1449,19 @@ let typecheck_program ~file ~(module_of : string -> string) ()); { typ = (match confident_typ cenv left with Some t -> t | None -> TScalar "Int"); is_nil = false } + | Binary ((Lt | Le | Gt | Ge), left, right) -> + let lres = typecheck_expr env cenv left in + let rres = typecheck_expr env cenv right in + if is_nullable lres.typ || lres.is_nil then e211 left.pos (expr_label left); + if is_nullable rres.typ || rres.is_nil then e211 right.pos (expr_label right); + { typ = TScalar "Bool"; is_nil = false } | Binary (_, left, right) -> let _ = typecheck_expr env cenv left in let _ = typecheck_expr env cenv right in { typ = TScalar "Bool"; is_nil = false } | Interp inner -> - let _ = typecheck_expr env cenv inner in + let ir = typecheck_expr env cenv inner in + if is_nullable ir.typ || ir.is_nil then e211 inner.pos (expr_label inner); { typ = TScalar "Text"; is_nil = false } | Ctor (class_name, fields) -> (try @@ -1920,6 +2022,10 @@ let typecheck_program ~file ~(module_of : string -> string) everything else it is what the author declared the binding to be. Only an unannotated `let` falls back to inference. *) let declared = Option.map resolve_field_ty ty in + (match declared with + | Some t when crosses_boundary ~target:t val_res -> + e212 value.pos (expr_label value) (Printf.sprintf "`%s: %s`" name (typ_label t)) + | _ -> ()); let bound_typ = match declared with Some t -> t | None -> val_res.typ in let new_cenv = match (declared, confident_typ cenv value) with @@ -1930,19 +2036,64 @@ let typecheck_program ~file ~(module_of : string -> string) (StringMap.add name bound_typ env, new_cenv) | Assign { target; value } -> let _ = typecheck_expr env cenv target in - let _ = typecheck_expr env cenv value in + let vres = typecheck_expr env cenv value in + (* the boundary only where the target's type is CONFIDENTLY known, + never a placeholder: a local in cenv (env carries `TScalar "Int"` + fallbacks for unresolved initializers — `let k = env.get(...)` + must not read as an Int target), or a resolvable class field *) + let target_typ = + match target.kind with + | Ident n -> StringMap.find_opt n cenv + | Field (b, fname) -> ( + match Option.map unwrap_nullable (confident_typ cenv b) with + | Some (TScalar cn) | Some (TRef cn) -> ( + match StringMap.find_opt cn syms.classes with + | Some cls -> ( + match List.find_opt (fun (fn2, _, _, _) -> fn2 = fname) cls.fields with + | Some (_, fty, _, _) -> Some (resolve_field_ty fty) + | None -> None) + | None -> None) + | _ -> None) + | _ -> None + in + (match target_typ with + | Some t when crosses_boundary ~target:t vres -> + e212 value.pos (expr_label value) (expr_label target ^ " (`" ^ typ_label t ^ "`)") + | _ -> ()); (env, cenv) | If { cond; then_body; else_body } -> let _ = typecheck_expr env cenv cond in - let then_result = List.fold_left typecheck_stmt (env, cenv) then_body in + let tf, ff = nil_facts cond in + let then_result = + List.fold_left typecheck_stmt (narrow_env tf env, narrow_env tf cenv) then_body + in + (* a then-branch that cannot fall through (`if x == nil { return }`) + proves the false-facts for everything after the `if` *) + let diverges stmts = + match List.rev stmts with + | { s_kind = Return _; _ } :: _ | { s_kind = Break; _ } :: _ + | { s_kind = Continue; _ } :: _ -> true + | _ -> false + in (match else_body with - | Some (_, else_body) -> List.fold_left typecheck_stmt (env, cenv) else_body - | None -> then_result) + | Some (_, else_body) -> + List.fold_left typecheck_stmt (narrow_env ff env, narrow_env ff cenv) else_body + | None -> + if diverges then_body then (narrow_env ff env, narrow_env ff cenv) + else + (* existing convention: the then-env leaks out of an else-less + `if` — but the narrow must NOT leak with it (the else path + never proved it), so restore the guarded names *) + let te, tc = then_result in + (unnarrow_env tf ~orig:env te, unnarrow_env tf ~orig:cenv tc)) | While { cond; body } -> let _ = typecheck_expr env cenv cond in - List.fold_left typecheck_stmt (env, cenv) body + let tf, _ = nil_facts cond in + let _ = List.fold_left typecheck_stmt (narrow_env tf env, narrow_env tf cenv) body in + (env, cenv) | For { var; var2; iter; body } -> let iter_res = typecheck_expr env cenv iter in + if is_nullable iter_res.typ || iter_res.is_nil then e211 iter.pos (expr_label iter); (* `for k, v in m`: the names take the map's key and value types. The one-name form over a `multi` keeps the element type. *) let bind (env0 : typ StringMap.t) (t : typ option) : typ StringMap.t = @@ -1959,7 +2110,14 @@ let typecheck_program ~file ~(module_of : string -> string) let cenv_body = bind cenv (confident_typ cenv iter) in List.fold_left typecheck_stmt (env_body, cenv_body) body | Return opt_e -> - (match opt_e with Some e -> let _ = typecheck_expr env cenv e in () | None -> ()); + (match opt_e with + | Some e -> + let r = typecheck_expr env cenv e in + (match !current_ret with + | Some rt when crosses_boundary ~target:rt r -> + e211 e.pos (expr_label e) + | _ -> ()) + | None -> ()); (env, cenv) | ExprStmt { kind = Switch (subject, arms); _ } -> (* haxe-parity Task 3: the one place `want_value` is false -- @@ -1994,7 +2152,9 @@ let typecheck_program ~file ~(module_of : string -> string) let cenv_with_self = List.fold_left (fun acc (name, ty, _) -> StringMap.add name (resolve_field_ty ty) acc) (StringMap.singleton "self" (TScalar self_class)) m.params in + current_ret := Option.map resolve_field_ty m.ret; let _ = List.fold_left typecheck_stmt (env_with_self, cenv_with_self) m.body in + current_ret := None; false in @@ -2011,7 +2171,9 @@ let typecheck_program ~file ~(module_of : string -> string) StringMap.add name (resolve_field_ty ty) acc) StringMap.empty fn.params in let param_cenv = List.fold_left (fun acc (name, ty, _) -> StringMap.add name (resolve_field_ty ty) acc) StringMap.empty fn.params in - ignore (List.fold_left typecheck_stmt (param_env, param_cenv) fn.body) + current_ret := Option.map resolve_field_ty fn.ret; + ignore (List.fold_left typecheck_stmt (param_env, param_cenv) fn.body); + current_ret := None ) file_syms.free_fns; () diff --git a/docs/00-status.md b/docs/00-status.md index b57ddc2..0e672b2 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -81,9 +81,10 @@ Story slice: [`docs/stories/language-runtime-database/07-logwatcher-proof.md`](s **Deferred by name, with the measurement that says so:** -- Iteration 5's *strictness* half (`?T` forced handling, `pub(read)` write - enforcement, `using`, `#if`, reject rows) — it makes the language refuse - more; it does not make this program run. Plan 8 stays open for it. +- Iteration 5's *strictness* half — **`?T` forced handling landed 2026-08-18** + (WO-E211/212/213 + local narrowing; the samples were updated to the + bind-then-narrow idiom and stay green). Still open: `pub(read)` write + enforcement, `using`, `#if`, reject rows. Plan 8 stays open for those. - Everything `@gc`: iteration 7b, `set`'s `@gc` retention gap, iteration 4's `gc/held-cycle` leak. The sample declares **no `@gc` class** — 35 classes, none with the gc flag, 0 `RC_INC`/`RC_DEC` against 78 `DROP`s — so none of it @@ -111,7 +112,7 @@ that sequences its tasks. Read one, approve, then the next starts. | 2 | [VM core (`wovm`)](stories/language-runtime-database/02-vm-core.md) | ✅ | | 3 | [Compiler front (`woc`)](stories/language-runtime-database/03-compiler-front.md) | ✅ (known gaps below) | | 4 | [Single binary end-to-end](stories/language-runtime-database/04-single-binary-e2e.md) | ✅ (known gaps below) | -| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | 🔄 grammar done, strictness ⏸ deferred | +| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | 🔄 grammar done; **`?T` forced handling ✅ 2026-08-18** (WO-E211/212/213 + narrowing); `pub(read)`/`using`/`#if`/reject rows still ⏸ | | 6 | [Program mode + stdlib](stories/language-runtime-database/06-program-mode-stdlib.md) | ✅ (the surface log-watcher uses) | | 7 | [log-watcher proof](stories/language-runtime-database/07-logwatcher-proof.md) | 🔄 **runs; executable in progress** | | 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/07b-inferred-gc-mark-sweep.md) | ✅ **landed 2026-08-18** — `@gc` gone (WO-E104), GC-ness inferred, RC replaced by incremental mark-sweep, `.wob` v4; supersedes iteration 2's RC memory model | diff --git a/docs/examples/gc-cycle/main.wo b/docs/examples/gc-cycle/main.wo index 66b80fb..c835010 100644 --- a/docs/examples/gc-cycle/main.wo +++ b/docs/examples/gc-cycle/main.wo @@ -23,7 +23,14 @@ fn ring_demo() -> Int { b.next = c; c.next = a; -- closes the cycle; c.next aliases the same Node as `a` - print("ring ${a.label} -> ${a.next.label} -> ${a.next.next.label} -> ${a.next.next.next.label}"); + -- ?T enforcement: a field place (`a.next`) never narrows, so each hop + -- binds to a local and the guard narrows the locals. + let n1 = a.next; + let n2 = b.next; + let n3 = c.next; + if n1 != nil and n2 != nil and n3 != nil { + print("ring ${a.label} -> ${n1.label} -> ${n2.label} -> ${n3.label}"); + } -- prints: ring a -> b -> c -> a -- `a`, `b`, `c` go out of scope here. No DROP frees the Nodes (they are -- traced, not owned). The ring is now abandoned; a later slice collects it. diff --git a/docs/examples/log-watcher/main.wo b/docs/examples/log-watcher/main.wo index 20d00a9..82a5712 100644 --- a/docs/examples/log-watcher/main.wo +++ b/docs/examples/log-watcher/main.wo @@ -75,20 +75,24 @@ fn main(args: multi Text) -> Int { -- the key may live outside the config file (systemd EnvironmentFile / .env) let api_key = env.get("LOG_WATCHER_API_KEY"); let port: ?Int = nil; - if j.mcp != nil { - if j.mcp.apiKey != nil { api_key = j.mcp.apiKey; } - port = j.mcp.port; + let m = j.mcp; + if m != nil { + let k = m.apiKey; + if k != nil { api_key = k; } + port = m.port; } if port == nil or api_key == nil { print_err("config error: mcp.port and an api key (mcp.apiKey or LOG_WATCHER_API_KEY) are required"); return 1; } let extra: multi Text = []; - if j.logs != nil { - for p in j.logs { push(extra, p); } + let jlogs = j.logs; + if jlogs != nil { + for p in jlogs { push(extra, p); } } - if j.services != nil { - for s in j.services { push(extra, s); } + let jsvcs = j.services; + if jsvcs != nil { + for s in jsvcs { push(extra, s); } } let mcp = Mcp { tools: Tools { cron_dir: args[1], extra_logs: extra }, api_key: api_key }; print("mcp server on 127.0.0.1:${port} (${args[1]})"); @@ -113,12 +117,20 @@ fn load_config(path: Text, mut cfg: SupConfig) -> Bool { print_err("config error: ${path} is not valid JSON"); return false; } - if j.pollInterval != nil { cfg.poll_ms = j.pollInterval * 1000; } - if j.quietPeriod != nil { cfg.quiet_ms = j.quietPeriod * 1000; } - if j.rescanInterval != nil { cfg.rescan_ms = j.rescanInterval * 1000; } - if j.detections != nil { cfg.detections = j.detections; } - if j.services != nil { - for s in j.services { push(cfg.services, s); } + -- ?T enforcement (WO-E211/E213): a FIELD place never narrows — it could be + -- re-assigned between the check and the use — so each optional binds to a + -- local first, and the local narrows. + let pi = j.pollInterval; + if pi != nil { cfg.poll_ms = pi * 1000; } + let qp = j.quietPeriod; + if qp != nil { cfg.quiet_ms = qp * 1000; } + let ri = j.rescanInterval; + if ri != nil { cfg.rescan_ms = ri * 1000; } + let det = j.detections; + if det != nil { cfg.detections = det; } + let svcs = j.services; + if svcs != nil { + for s in svcs { push(cfg.services, s); } } return true; } diff --git a/docs/examples/log-watcher/mcp.wo b/docs/examples/log-watcher/mcp.wo index 2e06d58..5773c6b 100644 --- a/docs/examples/log-watcher/mcp.wo +++ b/docs/examples/log-watcher/mcp.wo @@ -59,10 +59,11 @@ class Mcp { let j = json.decode(req.body) as RpcReq; -- checked decode: ?RpcReq, never a trap if j == nil { return rpc_error(nil, -32700, "parse error"); } - if j.method == nil { return rpc_error(j.id, -32600, "invalid request: no method"); } + let mth = j.method; + if mth == nil { return rpc_error(j.id, -32600, "invalid request: no method"); } if j.id == nil { return HttpResp { status: 202, body: "" }; } -- notification - switch j.method { + switch mth { case "initialize": return rpc_result(j.id, "{\"protocolVersion\":\"${PROTOCOL}\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"log-watcher\",\"version\":\"0.1\"}}"); case "ping": @@ -72,7 +73,7 @@ class Mcp { case "tools/call": return self.call_tool(j.id, j.params); default: - return rpc_error(j.id, -32601, "unknown method: ${j.method}"); + return rpc_error(j.id, -32601, "unknown method: ${mth}"); } } diff --git a/docs/examples/log-watcher/supervisor.wo b/docs/examples/log-watcher/supervisor.wo index 6842fa8..026c4d1 100644 --- a/docs/examples/log-watcher/supervisor.wo +++ b/docs/examples/log-watcher/supervisor.wo @@ -78,17 +78,19 @@ class Supervisor { for log_path, cw in self.scheduled { if has(self.active, log_path) { continue; } - if cw.next_fire == nil { continue; } - if cw.lock_path != nil and cw.lock_probe == nil and now >= cw.next_fire - PROBE_LEAD_MS and now < cw.next_fire { - cw.lock_probe = Flock.held(cw.lock_path); + let nf = cw.next_fire; + if nf == nil { continue; } + let lp = cw.lock_path; + if lp != nil and cw.lock_probe == nil and now >= nf - PROBE_LEAD_MS and now < nf { + cw.lock_probe = Flock.held(lp); } - if now < cw.next_fire { continue; } + if now < nf { continue; } -- no probe taken (e.g. started past the fire) leaves locked false, -- so the watch runs — the safe direction let locked = cw.lock_probe == true; cw.lock_probe = nil; -- reset for the next window if locked { - print("SKIP-LOCKED ${log_path}: ${cw.lock_path} still held, window skipped"); + if lp != nil { print("SKIP-LOCKED ${log_path}: ${lp} still held, window skipped"); } cw.next_fire = compute_next(cw.schedules, now); } else { print("WATCH ${log_path}: activated"); @@ -158,7 +160,8 @@ class Supervisor { if has(self.scheduled, log_path) == false { let scheds = join(cw.schedules, " | "); let note = ""; - if cw.lock_path != nil { note = " (flock ${cw.lock_path})"; } + let flk = cw.lock_path; + if flk != nil { note = " (flock ${flk})"; } print("SCHEDULE ${log_path}: ${scheds}${note}"); } } diff --git a/docs/plan/compiler/2026-08-01-haxe-parity-language.md b/docs/plan/compiler/2026-08-01-haxe-parity-language.md index 8ae7cee..6cf83a9 100644 --- a/docs/plan/compiler/2026-08-01-haxe-parity-language.md +++ b/docs/plan/compiler/2026-08-01-haxe-parity-language.md @@ -1,6 +1,6 @@ # Haxe-Parity Language Adoptions Implementation Plan -> **Status: ⏸ on hold behind [the executable plan](2026-08-14-logwatcher-executable.md)** (story iteration 5) — the grammar half of this plan is what let the driving workload compile, and the rest of it makes the language *refuse* more rather than making that program *run*, so it waits. Original status follows. — Tasks 1–4 ✅ shipped and review-verified: modules (`use`/`pub`), language surface (`and`/`or`, `${}` interpolation, `const`, break/continue, do-while), switch expressions, typedef records + enum payload variants. Task 5 (try/catch over the trap system) ✅ shipped 2026-08-14 — VM catch frames, expression and block catch arms, the `{code, line, method, msg}` record. Task 6 (`?T`) 🔶 half: the representation, `nil`, comparisons and narrowing-free use all work — the forced-handling diagnostics (`WO-E211`–`E213`) do not exist. Task 7 🔶 half: `static` members and `pub(read)` syntax landed, `using` and the `pub(read)` write check did not. Task 8 (`#if` + reject rows) ⬜. Board: [00-status.md](../../00-status.md) +> **Status: ⏸ on hold behind [the executable plan](2026-08-14-logwatcher-executable.md)** (story iteration 5) — the grammar half of this plan is what let the driving workload compile, and the rest of it makes the language *refuse* more rather than making that program *run*, so it waits. Original status follows. — Tasks 1–4 ✅ shipped and review-verified: modules (`use`/`pub`), language surface (`and`/`or`, `${}` interpolation, `const`, break/continue, do-while), switch expressions, typedef records + enum payload variants. Task 5 (try/catch over the trap system) ✅ shipped 2026-08-14 — VM catch frames, expression and block catch arms, the `{code, line, method, msg}` record. Task 6 (`?T`) ✅ complete 2026-08-18 (branch `nullable-enforcement`): forced handling enforced — WO-E211/E212/E213 emit, locals narrow via `!= nil` guards / diverging early-return / `and`-chains / `while`; field places bind to a local first. Boxed scalar cells were superseded by `WO_NIL_SCALAR` before this task ran. Task 7 🔶 half: `static` members and `pub(read)` syntax landed, `using` and the `pub(read)` write check did not. Task 8 (`#if` + reject rows) ⬜. Board: [00-status.md](../../00-status.md) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > @@ -83,9 +83,9 @@ docs/plan/oop-vm/00-wob-format.md grows with the three VM changes **First task of this plan:** iteration 4 (plan 3 — emitter, corpus, `woc build`) precedes plan 8; within plan 8 this task goes first — `?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.` +- [x] Failing fixtures: narrowing goldens; un-narrowed-use must-fail; nil propagation through record optional fields; ~~boxed scalar optional round-trip~~ (superseded: `WO_NIL_SCALAR` landed earlier); assignment of null to plain `T` must-fail. (Shipped as `tests/corpus/{compile-fail/nullable-*,run/nullable-narrowing}`.) +- [x] Implement; green (2026-08-18 — corpus 83/0, samples clean under enforcement). +- [x] Committed on branch `nullable-enforcement`. ### Task 7: `static` members, `using` extensions, `pub(read)` accessors diff --git a/docs/plan/compiler/nullable-types-implementation.md b/docs/plan/compiler/nullable-types-implementation.md index c82efd4..ee78696 100644 --- a/docs/plan/compiler/nullable-types-implementation.md +++ b/docs/plan/compiler/nullable-types-implementation.md @@ -9,9 +9,15 @@ ## Status -`?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. +**ENFORCED (2026-08-18, branch `nullable-enforcement`).** `?T` forced +handling shipped: WO-E211 (un-narrowed use), WO-E212 (nil/`?T` across a +non-nullable boundary), WO-E213 (deref of a possibly-nil base) all emit, and +narrowing works on locals — `if x != nil` guards, diverging early-return +(`if x == nil { return }`), short-circuit `and`/`or` chains, and `while` +conditions. Field places never narrow (bind to a local first). The evidence +probe below now fails compile with WO-E211, corpus fixtures pin all three +codes plus the four narrowing forms, and all three samples compile clean +under enforcement. The historical record below is kept as written. | Component | Status | |-----------|--------| @@ -29,7 +35,7 @@ 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 +`woc` on this file **now exits 1 with WO-E211** (2026-08-18). Historically it exited **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 diff --git a/docs/plan/oop-vm/01-error-catalog.md b/docs/plan/oop-vm/01-error-catalog.md index b4e7f4a..0073b22 100644 --- a/docs/plan/oop-vm/01-error-catalog.md +++ b/docs/plan/oop-vm/01-error-catalog.md @@ -49,6 +49,9 @@ half of the story ("moved here" / "borrowed here" / etc.). | WO-E208 | haxe-parity Task 3 (`switch` as expression); the union exhaustiveness rule is haxe-parity Task 4's. Two subject regimes: (1) a **union-typed** subject (derived via `confident_typ`, the "stay silent when underivable" deriver — an underivable subject falls to regime 2) needs no `default` exactly when every variant is covered by some arm; a gap fires this code and names the missing variants, in declaration order. A `default` always satisfies it. (2) every **other** subject (scalars, Text, and anything underivable) keeps Task 3's unconditional rule: no `default` arm is always this error. A `?Union` subject is deliberately regime 2 until Task 6's forced-handling work legalizes narrowing it. | `` switch over `Kind` has no `default` arm and does not cover: Mid, Hi `` | | WO-E209 | hotfix (2026-08-11, round 2). A builtin call (`print`, `print_int`, `words`, `now`, `push`, `get`, `count`, `latest`, `set`, `has`, `multi_new`, `map_new`) given the wrong number of arguments, or an argument whose type is confidently known and does not match the builtin's signature (source of truth: [`08-builtin-surface.md`](08-builtin-surface.md)). Motivated by a real segfault: `print(7)` compiled clean and crashed `wovm` — `print` wants a `Text` (a heap-string pointer), and the VM's `str_check` dereferences whatever register it is handed as a `wo_str*` with no runtime tag to check first, so a bare `Int` was a wild pointer read. `types.ml`'s own `confident_typ` (deliberately narrower than the typechecker's regular `.typ` inference — see its doc comment) derives an argument's type from a literal, `self`, a parameter's declared type, a class field's declared type, a `Ctor` naming a real declared class, a `let` whose value was itself confidently typed, or (round 2 — round 1 missed this, a real second segfault repro: `print(takesSecret(box))` where `takesSecret` is declared `-> Int`) a `Call` whose declared signature is known: a free fn (resolved own-module-first-then-used-modules, never the flat whole-program symbol merge — the same Critical-1 bug shape haxe-parity Task 1 already fixed for the emitter), a class method off a confidently-typed receiver, an interface method's signature, or another builtin's own confident return type (`words`/`count` → `Int`, `now` → `Timestamp`, `has` → `Bool`, …). `ReqInt` accepts any non-`Text` builtin scalar (`Int`/`Bool`/`Timestamp`/`Id` all share the identical `WO_K_SCALAR` runtime representation — found as a real false positive against `print_int(has(...))` once builtin return-chasing went live). Anything still not chased (an unresolved name, an `Index`/`Binary` result, a qualified free-fn call through a `use` alias, an UNKNOWN-BUT-RESERVED stdlib call) is left unchecked rather than guessed at — see "Remaining unchecked surface" below. A user-declared free `fn` of the same name always wins over the builtin table (08-builtin-surface.md's shadowing rule; also resolved module-aware, not via the flat merge), so a shadowed name is never checked here either. Arity mismatches are also still caught later, at emission (`WO-E403`, unchanged) — this is an earlier, additional gate over the same contract, not a replacement. | `` builtin `print` expects Text, got `Int` `` | | WO-E210 | haxe-parity Task 1 (modules). A `Ctor`/bare-call name resolves — it's declared, somewhere — but not in this file's own module and not through any `use` edge either; the module it actually lives in is named as a hint. Reserved by Task 6's own brief, genuinely blocked until a module concept existed at all (see the nullable-types-implementation.md handoff) — this is its first real emission site. | `` `helper` is declared in module `shared/util`, which is not `use`d here `` | +| WO-E211 | iteration 5 strictness (`?T` forced handling, 2026-08-18). A possibly-nil value (`?T`, or a literal `nil`) used where a plain value is required, un-narrowed: an arithmetic or `<`/`<=`/`>`/`>=` operand, an `and`/`or` operand (`?Bool`), an interpolation segment, a `for` iterable, or a `return` whose declared type is not nullable. Narrowing legalizes it: `if x != nil { … }` narrows the LOCAL `x` to `T` inside the branch; a diverging then-branch (`if x == nil { return … }`) narrows after the `if`; `x != nil and x.n > 3` narrows the right operand; `while x != nil` narrows the body. Field places (`a.next`) never narrow — bind to a local first. Env types here are declared or confidently inferred (the fallback placeholders are plain scalars, never `?T`), so this cannot false-positive off an underivable expression. | `` `x` is possibly nil (`?T`) and is used where a plain value is required — narrow it first (`if x != nil { ... }`) `` | +| WO-E212 | iteration 5 strictness. `nil` or a `?T` value stored across the boundary into a slot whose DECLARED type is not nullable: an annotated `let`, an assignment to a local whose type is confidently known (cenv, never the placeholder env) or to a resolvable class field. | `` `nil` cannot be stored in `x: Int` — the target is not nullable; declare it `?T` or narrow the value first `` | +| WO-E213 | iteration 5 strictness. Reaching through a possibly-nil value — a field read, an index — without a nil check. The deref twin of WO-E211: the base itself is `?T`. | `` field `next` is possibly nil (`?T`) — check it against `nil` before reaching through it `` | | 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-E215 | a class, interface, free `fn`, or (haxe-parity Task 4) union name is declared more than once in the *same file* (`collect_declarations`'s own `StringMap.add` silently dropped the earlier one — Task 1 review, found while building the plan-3 emitter, fixed in Task 2). Task 4 also fires it for a *variant* name reused within a file's unions (inside one union or across two — variants share one flat value namespace, so a bare `Ok` reference could not otherwise pick a tag), reported at the reusing variant with the owning union as the related site. Reported at the later declaration, with the first as the related site — the same shape as WO-E214, one file instead of two; the symbol table keeps the first declaration. Class/interface names and free-fn names are separate namespaces, so a class and a fn sharing a name never collide here. | `class \`Dup\` already declared` | | WO-E216 | haxe-parity Task 1 (modules). A `use ` names something that is neither one of the six reserved stdlib namespaces (`fs`, `proc`, `net`, `time`, `json`, `env`) nor a directory this program actually discovers. | `` unknown module `nosuchmodule` — not a discovered project module and not a reserved stdlib namespace `` | @@ -60,14 +63,10 @@ half of the story ("moved here" / "borrowed here" / etc.). ### Reserved, not yet emitted -`unknown_fn_code` (WO-E204), -`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 this task 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. `module_not_imported_code` +`unknown_fn_code` (WO-E204) is declared in `types.ml` — the range is +reserved — but nothing in the front end raises it yet. (WO-E211/E212/E213 +left this list 2026-08-18: `?T` forced handling is enforced; see their rows +in the main table above.) `module_not_imported_code` (WO-E210) — the one member of this list Task 6's brief named that a *later* task, not a gap in Task 6's own shipped work, was blocking — has moved up into the table above: haxe-parity Task 1 gave it its first real emission diff --git a/tests/corpus/compile-fail/nullable-deref-unchecked/fixture.code b/tests/corpus/compile-fail/nullable-deref-unchecked/fixture.code new file mode 100644 index 0000000..4710b82 --- /dev/null +++ b/tests/corpus/compile-fail/nullable-deref-unchecked/fixture.code @@ -0,0 +1 @@ +WO-E213 diff --git a/tests/corpus/compile-fail/nullable-deref-unchecked/fixture.wo b/tests/corpus/compile-fail/nullable-deref-unchecked/fixture.wo new file mode 100644 index 0000000..252961d --- /dev/null +++ b/tests/corpus/compile-fail/nullable-deref-unchecked/fixture.wo @@ -0,0 +1,12 @@ +-- ?T deref (WO-E213): reaching through a possibly-nil value without a nil +-- check. A field place never narrows — the fix is `let n = a.next; if n != nil`. +class Node { + label: Text + next: ?Node +} + +fn main() -> Int { + let a = Node { label: "a", next: nil }; + print(a.next.label); + return 0 +} diff --git a/tests/corpus/compile-fail/nullable-nil-into-plain/fixture.code b/tests/corpus/compile-fail/nullable-nil-into-plain/fixture.code new file mode 100644 index 0000000..4a06228 --- /dev/null +++ b/tests/corpus/compile-fail/nullable-nil-into-plain/fixture.code @@ -0,0 +1 @@ +WO-E212 diff --git a/tests/corpus/compile-fail/nullable-nil-into-plain/fixture.wo b/tests/corpus/compile-fail/nullable-nil-into-plain/fixture.wo new file mode 100644 index 0000000..0adc49a --- /dev/null +++ b/tests/corpus/compile-fail/nullable-nil-into-plain/fixture.wo @@ -0,0 +1,6 @@ +-- ?T boundary (WO-E212): `nil` (or a ?T value) stored where the declared +-- type is not nullable. +fn main() -> Int { + let x: Int = nil; + return x +} diff --git a/tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.code b/tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.code new file mode 100644 index 0000000..b613119 --- /dev/null +++ b/tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.code @@ -0,0 +1 @@ +WO-E211 diff --git a/tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.wo b/tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.wo new file mode 100644 index 0000000..5c57b47 --- /dev/null +++ b/tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.wo @@ -0,0 +1,15 @@ +-- ?T forced handling (WO-E211): a possibly-nil value used where a plain +-- value is required, with no narrowing — the canonical evidence probe from +-- docs/plan/compiler/nullable-types-implementation.md, finally enforced. +class Box { + v: ?Int +} + +fn take_it(b: Box) -> Int { + return b.v +} + +fn main() -> Int { + let b = Box { v: 3 }; + return take_it(b) +} diff --git a/tests/corpus/run/nullable-narrowing/fixture.out b/tests/corpus/run/nullable-narrowing/fixture.out new file mode 100644 index 0000000..c3548f4 --- /dev/null +++ b/tests/corpus/run/nullable-narrowing/fixture.out @@ -0,0 +1,6 @@ +8 +-1 +17 +-1 +chain full: gt3 +chain empty: no diff --git a/tests/corpus/run/nullable-narrowing/fixture.wo b/tests/corpus/run/nullable-narrowing/fixture.wo new file mode 100644 index 0000000..5c57eb5 --- /dev/null +++ b/tests/corpus/run/nullable-narrowing/fixture.wo @@ -0,0 +1,36 @@ +-- ?T narrowing, all four shipped forms: the if-guard, the early-return +-- (a diverging then-branch proves the false facts after the `if`), the +-- short-circuit `and` chain, and the while condition. Each narrows a LOCAL +-- from ?Int to Int; field places never narrow by design. +class Box { + v: ?Int +} + +fn guard(b: Box) -> Int { + let x = b.v; + if x != nil { return x + 1; } + return -1 +} + +fn early(b: Box) -> Int { + let x = b.v; + if x == nil { return -1; } + return x + 10 +} + +fn chain(b: Box) -> Bool { + let x = b.v; + return x != nil and x > 3 +} + +fn main() -> Int { + let full = Box { v: 7 }; + let empty = Box { v: nil }; + print_int(guard(full)); + print_int(guard(empty)); + print_int(early(full)); + print_int(early(empty)); + if chain(full) { print("chain full: gt3"); } + if chain(empty) == false { print("chain empty: no"); } + return 0 +}