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>
136 lines
4.6 KiB
Text
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;
|
|
}
|