writeonce/docs/examples/log-watcher/main.wo
shoney.arickathil 934780c54c 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>
2026-08-19 17:47:13 +02:00

136 lines
4.6 KiB
Text

-- main.wo — Main.hx, file for file: subcommand dispatch, config decode.
-- A free `fn main(args) -> Int` makes this project a PROGRAM (systems-track
-- spec Part 2): `wo run` executes it, the return value is the exit code,
-- blocking builtins are legal on the single shard.
--
-- Main.hx:5-7's `#if portable` linker pragma has no equivalent and needs
-- none: `woc build` produces a self-contained static binary by doctrine.
-- Util.hx is gone entirely: print flushes on newline (spec Part 2), which
-- is the only thing Util.say existed to do.
use fs
use env
use time
use json
-- config.json, decoded typed: Main.hx:50-61's Dynamic field-poking becomes
-- one checked decode — missing optional fields are fine, a shape mismatch
-- yields nil, never a trap. Field names are the wire format (camelCase);
-- values are seconds on the wire, ms internally.
typedef FileConfig = {
?pollInterval: Int
?quietPeriod: Int
?rescanInterval: Int
?detections: Text
?services: multi Text
?logs: multi Text
?mcp: McpConfig
}
typedef McpConfig = { ?port: Int, ?apiKey: Text }
fn main(args: multi Text) -> Int {
if len(args) >= 2 and args[0] == "watch" {
let quiet_s = 10;
if len(args) >= 3 {
let q = parse_int(args[2]);
if q != nil { quiet_s = q; }
}
let poll_s = 2;
if len(args) >= 4 {
let p = parse_int(args[3]);
if p != nil { poll_s = p; }
}
let w = Watcher { path: args[1], quiet_ms: quiet_s * 1000,
poll_ms: poll_s * 1000, live: true, activated_at: time.now() };
print("watching ${args[1]} (quiet ${quiet_s}s, poll ${poll_s}s)");
while true {
if env.stopping() { return 0; }
w.tick(time.now());
time.sleep(poll_s * 1000);
}
}
if len(args) >= 2 and args[0] == "run" {
let cfg = SupConfig {}; -- field defaults = Main.hx:21's literal
if len(args) >= 3 {
if load_config(args[2], cfg) == false { return 1; }
}
print("supervising ${args[1]} (poll ${cfg.poll_ms / 1000}s, quiet ${cfg.quiet_ms / 1000}s, rescan ${cfg.rescan_ms / 1000}s)");
let sup = Supervisor { cron_dir: args[1], cfg: cfg };
sup.init();
return sup.run();
}
if len(args) >= 3 and args[0] == "mcp" {
let raw = fs.read_all(args[2], 1048576);
if raw == nil {
print_err("config error: cannot read ${args[2]}");
return 1;
}
let j = json.decode(raw) as FileConfig;
if j == nil {
print_err("config error: ${args[2]} is not valid JSON");
return 1;
}
-- the key may live outside the config file (systemd EnvironmentFile / .env)
let api_key = env.get("LOG_WATCHER_API_KEY");
let port: ?Int = nil;
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 = [];
let jlogs = j.logs;
if jlogs != nil {
for p in jlogs { push(extra, p); }
}
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]})");
return mcp.serve(port);
}
print_err("usage:");
print_err(" log-watcher watch <logfile> [quietPeriod] [pollInterval] continuous service watch");
print_err(" log-watcher run <cron.d-dir> [config.json] supervisor (cron + services)");
print_err(" log-watcher mcp <cron.d-dir> <config.json> MCP server (127.0.0.1, Bearer auth)");
return 1;
}
fn load_config(path: Text, mut cfg: SupConfig) -> Bool {
let raw = fs.read_all(path, 1048576);
if raw == nil {
print_err("config error: cannot read ${path}");
return false;
}
let j = json.decode(raw) as FileConfig;
if j == nil {
print_err("config error: ${path} is not valid JSON");
return false;
}
-- ?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;
}