feat(compiler): ?T forced handling — WO-E211/E212/E213 + narrowing (iter 5)

The type system now keeps its nullability promise: a `?T` value cannot be
used, stored, or dereferenced as a plain `T` without narrowing. The canonical
evidence probe (return b.v where v: ?Int, fn -> Int) that compiled clean for
months now fails with WO-E211.

- WO-E211 (un-narrowed use): arithmetic and </<=/>/>= operands, and/or
  operands (?Bool), interpolation segments, for-iterables, and returns whose
  declared type is not nullable.
- WO-E212 (boundary): nil or ?T stored into a non-nullable slot — annotated
  let, assignment to a confidently-typed local (cenv, never the placeholder
  env — a placeholder target must stay silent) or a resolvable class field.
- WO-E213 (deref): field/index access through a possibly-nil base.
- Narrowing (locals only — a field place can be re-assigned between check
  and use, so chains bind to a local first): `if x != nil { }` narrows the
  branch; a DIVERGING then-branch (`if x == nil { return }`) narrows after
  the if; `x != nil and x.n > 3` narrows and/or right operands
  (short-circuit); `while x != nil` narrows the body. The narrow is
  un-applied when an else-less then-env leaks out un-diverged (the existing
  env-leak convention must not leak the narrow).
- No false positives by construction: env/cenv types are declared or
  confidently inferred; the placeholder fallbacks are plain scalars, never
  ?T. The whole golden suite passed untouched (540/0).
- Samples updated to the bind-then-narrow idiom (log-watcher config decode +
  supervisor lock/next_fire, gc-cycle ring print) — 22 genuine unnarrowed-nil
  sites; employee needed zero changes. All acceptances green.
- Corpus: compile-fail/{nullable-unnarrowed-use,nullable-nil-into-plain,
  nullable-deref-unchecked} + run/nullable-narrowing (all four forms) — 83/0.
- Catalog: E211/E212/E213 move from "Reserved, not yet emitted" to the main
  table; nullable-types-implementation.md status flipped to ENFORCED
  (historical record kept); plan 8 Task 6 ticked (boxed scalar cells
  superseded by WO_NIL_SCALAR); board updated.

Verified: woc-test 540/0 + test_diag 14/0; oop-e2e 83/0; oop-accept ALL MET;
log-watcher 7/0; employee 8/0; gc-cycle ring prints + reclaims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-19 17:47:13 +02:00
parent c1881fc9f3
commit 934780c54c
17 changed files with 330 additions and 61 deletions

View file

@ -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;
()

View file

@ -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 |

View file

@ -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.

View file

@ -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;
}

View file

@ -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}");
}
}

View file

@ -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}");
}
}

View file

@ -1,6 +1,6 @@
# Haxe-Parity Language Adoptions Implementation Plan
> **Status: ⏸ on hold behind [the executable plan](2026-08-14-logwatcher-executable.md)** (story iteration 5) — the grammar half of this plan is what let the driving workload compile, and the rest of it makes the language *refuse* more rather than making that program *run*, so it waits. Original status follows. — Tasks 1–4 ✅ shipped and review-verified: modules (`use`/`pub`), language surface (`and`/`or`, `${}` interpolation, `const`, break/continue, do-while), switch expressions, typedef records + enum payload variants. Task 5 (try/catch over the trap system) ✅ shipped 2026-08-14 — VM catch frames, expression and block catch arms, the `{code, line, method, msg}` record. Task 6 (`?T`) 🔶 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

View file

@ -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

View file

@ -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 <path>` 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

View file

@ -0,0 +1 @@
WO-E213

View file

@ -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
}

View file

@ -0,0 +1 @@
WO-E212

View file

@ -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
}

View file

@ -0,0 +1 @@
WO-E211

View file

@ -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)
}

View file

@ -0,0 +1,6 @@
8
-1
17
-1
chain full: gt3
chain empty: no

View file

@ -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
}