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:
parent
cb6ff0462a
commit
4f570a74e6
17 changed files with 330 additions and 61 deletions
|
|
@ -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;
|
||||
|
||||
()
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
WO-E213
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E212
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E211
|
||||
15
tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.wo
Normal file
15
tests/corpus/compile-fail/nullable-unnarrowed-use/fixture.wo
Normal 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)
|
||||
}
|
||||
6
tests/corpus/run/nullable-narrowing/fixture.out
Normal file
6
tests/corpus/run/nullable-narrowing/fixture.out
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
8
|
||||
-1
|
||||
17
|
||||
-1
|
||||
chain full: gt3
|
||||
chain empty: no
|
||||
36
tests/corpus/run/nullable-narrowing/fixture.wo
Normal file
36
tests/corpus/run/nullable-narrowing/fixture.wo
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue