Add a top-level §12 Error Handling distilling the locked error design +
surface syntax: failable signatures (-> (T,!) / -> ! / multi-value),
named `error { }` + inferred `!` sets, raise/try/catch/or/onfail, the
path-marker rule, set widening, error.X as a value, discard rejection +
flow-check, closures-with-!, return traces, and the u32-last-slot ABI.
Renumber Grammar §12→§13 and Open Questions §13→§14 (insert sits after
§10.5, so §3/§10.5 — the only section numbers referenced from CLAUDE.md
— stay valid). Cross-link the `!` channel from the Keywords list,
Operator Precedence, Function Definition, and §11 Program Structure;
extend the §13 grammar with error_decl, raise_stmt, onfail_stmt, a
catch_expr tier, `try` in unary, and failable type productions.
Pure docs; no compiler change. Gates: build, test, run_examples (293/0).
The implicit address-of that gives `*T` params reference semantics only
fired for plain identifiers (`mut(v)`). For a field-access / index /
deref lvalue (`make_move(self.board, m)`, `mut(w.s)`), the branch was
skipped: the arg was loaded into a temporary and the callee mutated a
throwaway copy — silent data loss, with the type check satisfied through
the temp so no diagnostic fired.
Now compound lvalues auto-ref too: take the real lvalue address via
`lowerExprAsPtr`, normalizing the "place" ref to `*T` exactly as
`@field_access` does. Mutations through the pointer are now visible to
the caller, matching the identifier case.
Regression: examples/255-autoref-compound-lvalue.sx.
`lowerDerefExpr` left the deref's result type `.unresolved` when the
operand wasn't a pointer (e.g. a stale `value.*` after a parameter
changed from `*T` to `T`), and emitted the `.deref` anyway. That
unresolved type slipped through to emit_llvm's "unresolved type reached
LLVM emission" panic with no source location.
Now it emits a clean diagnostic at the deref site
("cannot dereference with `.*`: 'T' is not a pointer") and recovers.
Regression: examples/254-deref-non-pointer-reject.sx.
VSCode disables the breakpoint gutter for a language unless an extension
declares breakpoints are valid for it. The sx extension registered the
language but never contributed breakpoints, so clicking the gutter in a
.sx file did nothing. Add the breakpoints contribution so users can set
breakpoints in .sx files without the per-workspace
debug.allowBreakpointsEverywhere hack.
A source path with no directory component (`sx build main.sx` from the
project dir — what the chess app does) made `diFileFor` emit a `DIFile`
with an empty `directory:`, so the compile unit's `DW_AT_comp_dir` was
"". Apple's ld then silently drops the *entire* object's debug map (0
N_OSO) and the binary is undebuggable — lldb resolves no sx source.
Builds whose path had any directory (`.sx-tmp/x.sx`, `examples/x.sx`)
were unaffected, which is why small repros + the stepping smoke passed
and only the bundled chess app hit it.
Fix: diFileFor falls back to "." (and "/" for a root-level file) when
the path has no directory component, so comp_dir is never empty.
Verified: chess (`sx build --target macos --emit-obj main.sx`) now
links with OSO=1 and lldb resolves `frame at main.sx:82:8`. Regression
guard added to the DWARF unit test (asserts `DIFile(... directory: ".")`
for a bare filename). Gates: zig build, zig build test, run_examples.sh
-> 291 passed, debug-stepping smoke ok.
Closes out E3's stepping-verification ladder to the extent possible
headlessly.
- Verified `sx build --target ios-sim --emit-obj` produces an
arm64-ios-simulator Mach-O that runs under `simctl spawn` and steps
in lldb (the backtrace shows a dyld_sim frame — the sim runtime).
- Verified the device-applicable .dSYM path: dsymutil collects the
DWARF, and after removing the .o lldb still resolves source via the
.dSYM.
- debug_stepping_smoke.sh gains an optional iOS-sim rung that reuses an
already-booted simulator (never boots one — single-sim policy) and
exercises the .dSYM path; skips cleanly when no sim is booted.
- docs/debugger.md: rungs 1-2 marked verified; the iOS-device rung is
documented as a manual checklist (needs hardware + get-task-allow
signing) — no compiler gap, --emit-obj + standard Apple tools suffice.
E3 is functionally complete and verified across macOS + iOS-simulator.
`sx build --emit-obj` keeps the DWARF-bearing object so a debugger can
step the binary, completing the deep-debug half of the trace story.
- --emit-obj flag + TargetConfig.emit_obj. Implies -O0 (DWARF only
emits at opt none/less); keeps the object at its link-time path
.sx-tmp/main.o so the binary's debug map resolves to it; skips the
Level-1 binary cache; reports the object path. macOS resolves via the
debug map -> .o; Linux carries DWARF in the binary. Build-flow only,
no runtime/codegen change.
- tests/debug_stepping_smoke.sh (3e rung 1; macOS, lldb, not in
run_examples): builds with --emit-obj, drives an lldb file:line
breakpoint, asserts resolution + a source-mapped backtrace. Passing —
proves the slice 1-2 DWARF drives real source-level stepping.
(Also normalizes the 253 .exit trailing newline from the 3c --update.)
Gates: zig build, zig build test, run_examples.sh -> 291 passed.
Each trace frame now shows the offending source line with a `^` caret
under the column — in the catch-handler formatter, the failable-main C
reporter, and the comptime path.
The source line is embedded at compile time as a 5th Frame field
(line_text), not read from disk at runtime: the file field is a
basename and a runtime read would add a filesystem dependency that
fails under the test harness and on locked-down targets.
- errors.lineAt(src, offset): shared helper for the whole source line.
- Frame gains line_text (mirrored in emit_llvm getFrameStructType,
trace.sx Frame, sx_trace.c SxFrame). emitTraceFrame embeds it; the
interp .trace_resolve extracts it from the source map.
- trace.sx (new spaces helper) and the C reporter render the line +
a col-aligned caret, guarded on a non-empty line_text.
Snapshots 243/244/247/253 regenerated. Gates: zig build, zig build
test, run_examples.sh -> 291 passed.
#run failures now print the same `func at file:line:col` trace as
runtime, resolved in-process via the interpreter's IR/source tables.
- Read-side context-split op `.trace_resolve` (mirror of .trace_frame),
lowered from a name-recognized `__trace_resolve_frame(u64) -> Frame`.
- emit_llvm: inttoptr the operand to *Frame + load (the value
.trace_frame stamped in).
- interp: unpack (func_id << 32 | span.start); resolve func/file from
module.functions and line/col via SourceLoc.compute over a new
source_map (setSourceMap wired at every production interp site).
- trace.sx: frame_at -> u64; to_string routes each frame through
__trace_resolve_frame, so one source works in both machines.
Compiled path behavior unchanged (243/244/247 identical; it now loads
via the op). New examples/253-comptime-trace.sx exercises the comptime
path. Gates: zig build, zig build test, run_examples.sh -> 291 passed.
Return-trace frames now resolve to real `func at file:line:col`
in-process — no DWARF, no symbolizer.
- New niladic, span-stamped `.trace_frame` IR op (mirrors is_comptime):
carries no operands; each backend derives the frame from context.
lower.zig's placeholderTraceFrame emits it; the existing
sx_trace_push call consumes it.
- emit_llvm: resolve the op's span + current function to
{file(basename), line, col, func}, build an interned Frame global
({string,i32,i32,string}, strings cached by content), push its
address (ptrtoint).
- interp: pack (func_id << 32 | span.start) for the comptime resolver
(slice 3b); never a pointer.
- sx_trace.c report_unhandled derefs SxFrame; trace.sx gains the Frame
struct, frame_at -> *Frame, and field-reading to_string. Layout
mirrored in 3 places with cross-ref comments.
Verified JIT + AOT. Snapshots 243/244/247 regenerated (placeholder ->
func at file:line:col). Gates: zig build, zig build test,
run_examples.sh -> 290 passed.
Architecture spec for the debugging subsystem: error return traces
(embedded Frame table, niladic context-split push op, the thread-local
ring buffer), DWARF debug info as a debugger-only artifact, the exact
wiring (file/function map + trace and DWARF data flows), the rationale
for choosing embedded locations over PC+DWARF symbolization, the
runtime-artifacts split, and the macOS -> iOS-sim -> iOS-device stepping
verification ladder.
Attach LLVM debug metadata so a captured return-address PC resolves to
file:line:col (the runtime half E3.3 needs) and sx binaries become
debuggable in lldb/gdb.
- llvm_api.zig: bind llvm-c/DebugInfo.h (DIBuilder C API was unbound).
- emit_llvm.zig: DIBuilder + one DICompileUnit/DIFile on the main file,
a DISubprogram per function (LLVMSetSubprogram), and a DILocation per
instruction from Inst.span (errors.SourceLoc.compute, scoped to the
subprogram). Plus the "Debug Info Version"/"Dwarf Version" module
flags and LLVMDIBuilderFinalize.
- Gated on opt none/less + a wired source map (setDebugContext from
core.zig), mirroring lower.zig's tracesEnabled; release strips it.
Verified: sx ir/sx asm --opt none show correct DILocations + .loc
directives; the 290-example JIT suite (-O0 -> debug on) verifies and
runs unchanged. +2 DWARF unit tests.
Foundation for DWARF line-info (E3.0). The `Inst.span` field existed but was
never populated — `emit()` always passed the empty `{0,0}` default, so every
instruction had no source location (the lone reader, the interp's comptime
bail-offset, was always 0).
- Builder gains a `current_span`; `emit`/`emitVoid` stamp it onto each
instruction.
- `lowerExpr` / `lowerStmt` set `current_span` from the AST node's span on
entry and restore it on exit (save/restore), so a parent's later emits keep
the parent's span after a child lowers; the empty default is skipped so
synthetic nodes don't reset a meaningful enclosing span.
Behavior-neutral: codegen never reads spans, and the only consumer (the interp
bail-offset) merely gains real offsets. 290 examples pass unchanged, no `.ir`
snapshot drift. New unit test asserts an emitted `add` carries its `a + b` span.
Next (slice 2): bind `llvm-c/DebugInfo.h`, emit DICompileUnit / DISubprogram /
DIFile / DILocation from these spans, gate on debug/trace mode.
The last E4 item: a comptime call-frame dump.
- New nullary `interp_print_frames` IR op (inst/print). The interpreter
maintains a `call_chain` side-stack (push/pop a FuncId around each sx-bodied
`call`, freed in deinit) and `printInterpFrames` appends the chain to its
output — most-recent-last, with the dump frame itself skipped. emit_llvm
makes the op a no-op: compiled code has no interpreter stack, and the only
caller is `process.exit`'s dead `is_comptime()` branch.
- Lowered from a name-recognized `__interp_print_frames()` builtin
(tryLowerReflectionCall + inferExprType → void).
- `trace.print_interpreter_frames()` wraps the builtin; wired into
`process.exit`'s comptime branch (process.sx now imports trace.sx).
- Frame source locations await IR-offset resolution (the comptime analog of
DWARF), so only function names print today.
examples/252-interp-frames.sx (top-level `#run` drives the dump; exit 0).
Phase E4 (entry-point + stdlib error story) is now 100% complete.
Finishes Phase E4. `process.exit` / `assert` now report the caller's location.
#caller_location + Source_Location:
- new `hash_caller_location` token (lexer) + a leaf `caller_location` AST node
(parser primary-expr; sema + lsp arms).
- `Source_Location :: struct { file; line; col; func }` in std.sx.
- expandCallDefaults rewrites a `#caller_location` param default to a marker
carrying the CALL site's span + source_file.
- lowerCallerLocation synthesizes the struct: file + line:col via
errors.SourceLoc.compute over the diagnostics file→source map, stamped with
the enclosing (caller) function name. inferExprType resolves it to
Source_Location. Explicitly forwarding a Source_Location through an inner
call preserves the outermost site.
namespaced default-param expansion (pre-existing crash): expandCallDefaults
bailed on field_access callees, so `mod.fn(args)` with an omitted defaulted
param passed too few args → LLVM "incorrect number of arguments". Now resolves
the namespace fd (by field / qualified name); method-on-value calls (where
`self` shifts the count) stay excluded. Prerequisite for process.exit/assert
(always called namespaced) taking `loc = #caller_location`.
comptime flush: interp.callForeign flushes the interpreter's buffered print
output before invoking any host symbol, so a comptime diagnostic emitted just
before a terminating `_exit` (process.exit at comptime) survives.
process.exit/assert take `loc: Source_Location = #caller_location`; assert
prints `ASSERTION FAILED at <file>:<line>: <msg>`. examples 250 (assert
file:line), 251 (caller-location + forwarding). The two ffi-objc *.ir
snapshots are regenerated — adding Source_Location to std.sx renumbers the
global string pool the type/field-name tables index (benign, identical IR).
Stdlib slice of Phase E4, plus the noreturn codegen fix that enables it.
noreturn codegen (the enabling bug): E1.4c made `noreturn` type-system-only;
this is its first backend consumer and it crashed LLVM verification. Fixed:
- lower.zig: a `-> noreturn` body lowers as statements ending in `unreachable`
(ensureTerminator emits unreachable; the two body-lowering sites no longer
treat the last expr as a `ret`).
- emit_llvm.zig: a `void`/`noreturn` call result stays unnamed (direct +
foreign call sites) — LLVM rejects a named void value.
- finishCatchHandler: a `noreturn` value-carrying catch body (which is not an
IR terminator) closes the handler with `unreachable` instead of feeding a
bad value into the merge phi. Shared by lowerCatch + lowerCatchOverChain.
is_comptime(): new nullary `.is_comptime` IR op (inst/print/interp/emit_llvm) —
interp evaluates true, emit_llvm emits constant false, so `if is_comptime()`
dead-codes out of compiled binaries. Recognized by name in
tryLowerReflectionCall + inferExprType (no std.sx decl, which would emit a
spurious `declare @is_comptime` into every module).
library/modules/log.sx: warn/info/debug/err — interpolate like print, write
`LEVEL: <msg>` to stderr. (`error` is reserved → the level is `log.err`.)
process.exit(code) -> noreturn + assert(cond, msg) in process.sx. `exit` is
POSIX `_exit(2)` (immediate, no cleanup; sx print is unbuffered so nothing is
lost), bound to "_exit" which also avoids a link-level clash with the sx `exit`
function's own name.
examples 248 (exit 0), 249 (exit 42), 250 (exit 1). #caller_location, the
comptime-exit diagnostic flush, and trace.print_interpreter_frames deferred to
E4.1b.
`lhs or rhs` with failable operands now lowers as a full short-circuit
chain (was a loud bail). Each failing attempt routes to the next operand;
the chain resolves when an operand succeeds or a value terminator absorbs;
total failure propagates to the function — or, when the chain is the operand
of a `catch`, to the handler. All in ir/lower.zig.
- Dispatch (lowerBinaryOp .or_op): structural `orIsFailableChain` (an operand
is a `try`, error-channel-typed, or a nested failable `or` chain) instead of
the type-only `exprIsFailable(lhs)`, which missed nested chains (a try-chain's
value type is non-failable T).
- inferExprType .or_op: a failable chain reports its success type via
`orChainSuccessType` (was `.bool`).
- lowerFailableOr rewritten: flatten the left-assoc chain, lower operands
left-to-right. Non-final failure → push frame + fall to next operand block
(no function exit, so onfail doesn't fire). Success → clear trace + merge.
Final failure → push frame + route to a `catch` target (chain_fail_target
field) if set, else propagate (cleanup + error return). Value terminator →
clear + merge the terminator value. Subsumes the E2.4a path. Widening
factored into `checkEscapeWidening`, checked only at a propagating final
operand.
- Catch-over-chain: lowerCatchOverChain sets chain_fail_target so the chain's
total failure reaches the handler (binds the final tag, may inspect the
trace, clears on non-diverging exit).
Verified JIT + AOT: 2-/3-operand chains, bare chain + value terminator, void
chains, all-fail propagation (exit 1 + trace), catch-over-chain, trace
clear-on-absorb, onfail gating. examples/246-failable-or-chain.sx (exit 120),
247-failable-or-chain-propagate.sx (exit 1 + trace).
Extends the failable-main entry-point wrapper to a value-carrying main.
`main :: () -> (int, !)` now exits the integer value on success (truncated
to u8, like a plain integer main) and reports the header + trace to stderr
+ exits 1 on an escaping error (same reporter as the pure `-> !` form).
- lower.zig validateMainSignature: accept a 2-field `{int, error_set}`
tuple return (set needs_trace_runtime) instead of rejecting it. Multi-
value `-> (T1, T2, !)` and non-integer value slots still reject — there's
no single integer exit code to map them to (sharpened diagnostic).
- emit_llvm.zig: the `.ret` arm detects a value-carrying main (tuple ending
in `.error_set`) and extracts `{value, tag}` (extractvalue 0/1) before
calling emitFailableMainRet, now generalized to take an optional `value`
(null → pure `-> !`, success exits 0; present → success exits the value).
C reporter unchanged.
All E4.2 entry-point shapes (void / int / `-> !` / `-> (int, !)`) now done.
examples/245-failable-main-value.sx (exit 64); 239 comment refreshed.
A pure-failable `main` (`-> !` / `-> !Named`) that lets an error reach the
function boundary now exits 1 and prints `error: unhandled error reached
main: error.<tag>` + the return trace to stderr, instead of returning the
raw tag id truncated as the exit code with no diagnostic. Success exits 0;
a `catch`-absorbed error exits 0 (buffer cleared).
Codegen wrapper so JIT and AOT behave identically (no host-side special-
casing):
- emit_llvm.zig: the `.ret` arm detects a failable main and routes to
new `emitFailableMainRet` — `icmp ne tag, 0` → success block `ret i32 0`
/ error block GEPs the tag name out of the always-linked tag-name table,
calls `sx_trace_report_unhandled`, `ret i32 1`. main's bare-u32 returns
(success `ret(0)` + each raise's `ret(tag)`) all funnel through it.
- sx_trace.c: new `sx_trace_report_unhandled(tag, name, name_len)` prints
the header + surviving frames to stderr (placeholder frame format mirrors
trace.sx until DWARF/E3.0). Lives next to the buffer it reads.
- lower.zig validateMainSignature: the pure-failable arm sets
needs_trace_runtime so the AOT path auto-links sx_trace.c even when the
body emits no other push/clear.
Value-carrying `-> (T, !)` main stays gate-rejected (multi-slot wrapper is
a separate slice). examples/244-failable-main.sx.
The trace formatter, unblocked now that 0057 is fixed.
- library/modules/trace.sx: to_string() walks the trace buffer (sx_trace_len /
frame_at / truncated) and renders "error return trace ..." with one line per
frame; print_current() writes it to stderr (libc write(2, ...)). Frame
locations are "<location pending DWARF>" until E3.0 resolves PCs; count +
ordering + the overflow note are already meaningful.
- Catch-clear timing fix (lowerCatch): move the absorption clear from
runCatchBody ENTRY to the handler's non-diverging EXIT (both the pure and
value-carrying paths). This reconciles the two PLAN-ERR statements that
conflicted — §clear-points "buffer cleared before the catch body" vs
§catch-over-or "frames still in the buffer when the body runs". Exit-clear
satisfies both: the handler can inspect the trace (trace.print_current()
shows the chain), and the buffer is empty once the handler completes. A
diverging body (raise/return) keeps/discards on its own path.
- examples/243-trace-format.sx: catch handler prints the tag + the 2-frame
trace, then shows the buffer is empty after. examples/241 updated: the
handler now observes len=2 (was 0 under the buggy entry-clear).
Gates: zig build, zig build test, bash tests/run_examples.sh (280 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
An `xx <int>` argument to a variadic `format`/`print` (a comptime `..$args`
pack) segfaulted when the call was inside an imported-module function. Root
cause: lowerPackCall lowered each pack arg with whatever self.target_type was
set to from the surrounding context. A bare arg is unaffected (inferExprType
ignores target_type), but `xx <expr>`'s result type IS target_type — so
`format("…", xx i)` inside a `-> string` fn cast the int to `string`,
monomorphized __pack_string, and ABI-coerced the 4-byte int as a 16-byte string
fat pointer → corruption. Inline it worked only because target_type was null
there; the imported-module path left it set.
Fix: save/clear/restore self.target_type around the pack-arg lowering loop. A
pack arg is independently typed — comptime `..$args` auto-boxes to Any; a value
pack takes its declared element/protocol type — never a leftover outer target.
examples/242-xx-any-pack-cross-module.sx (+ companion fmt.sx) is the regression.
issues/0057 marked resolved. Unblocks ERR E3.3 (the trace.sx formatter formats
frames with `xx frame`).
Gates: zig build, zig build test, bash tests/run_examples.sh (279 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
Connect the E3.1 buffer to codegen. Push sites: `raise` (always escapes — push
before cleanup) and `try`'s propagation branch (the failure that escapes to the
caller). Clear sites: `catch` handler entry (via runCatchBody, error path only),
the `or value` terminator's failure branch, and a destructure that binds a
failable's error slot — so an absorbed failure leaves no residue.
Helpers in lower.zig: emitTracePush / emitTraceClear (call getTraceFids, no-op
when traces are off), tracesEnabled (opt_level == .none/.less — `sx run`
defaults to -O0, so on in dev; .default/.aggressive are release → off, zero
overhead), and placeholderTraceFrame (a nonzero u64 until DWARF/E3.0 supplies
real PCs and E3.3 resolves them).
Verified end-to-end via a #foreign sx_trace_len probe: catch/or/multi-slot-
destructure drive len back to 0; release (--opt default) emits no push/clear at
all (debug showed a residual where release showed 0).
examples/241-error-trace-buffer.sx is a focused regression (white-box: reads
sx_trace_len directly, pending E3.3's public trace.print_current).
KNOWN GAP (documented, deferred to the E1.8 flow-check binding-site work): a
single-binding capture of a PURE failable (`er := pure_failable()`, not a
comma destructure) goes through lowerVarDecl, not lowerDestructureDecl, so it
doesn't clear — the trace over-retains until the next absorbing site. Harmless
today (nothing reads the buffer at function exit yet) but wrong per spec.
Gates: zig build, zig build test, bash tests/run_examples.sh (278 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
Add the trace buffer that raise/try push to and catch/or/destructure clear,
following the JNI-TLS precedent exactly (a thread_local IR global doesn't work
under the ORC JIT, which doesn't init TLS for AddObjectFile'd objects).
- library/vendors/sx_trace_runtime/sx_trace.c: a `_Thread_local` fixed-cap ring
(32 frames) of opaque u64s + C API (push / clear / len / truncated /
frame_at). Overflow keeps the newest CAP frames and latches `truncated`
(Zig-style); frame_at returns oldest-to-newest. The frame is opaque — the
E3.3 formatter dispatches on context (PC at runtime, packed (func_id, offset)
at comptime).
- build.zig: link the .c into the compiler so the JIT resolves sx_trace_* via
dlsym (and so the unit test links against it).
- src/runtime_trace.test.zig: exercises push / overflow-survives-newest / clear
/ len / truncated / ordering against the linked C — grounds the buffer logic
without shipping throwaway sx builtins.
- lower.zig getTraceFids(): lazily declares the sx_trace_push/clear externs +
sets needs_trace_runtime. Declared now; the raise/try push sites and the
absorbing clear sites get wired at E3.2.
- core.zig: auto-injects the .c as a #source for AOT when needs_trace_runtime,
mirroring the JNI env runtime.
Gates: zig build, zig build test (incl. the new buffer tests), bash
tests/run_examples.sh (277 passed; no codegen change this step — lone failure
is the user's uncommitted 213-canonical-map pack WIP).
`{}` on an error-set value printed `<?>` (any_to_string had no error_set
category). Now it renders the tag name (`BadDigit`), reusing the existing
any_to_string dispatch.
Pieces:
- New `error_tag_name_get` IR op (UnaryOp): tag id -> name. Lowered from a new
`error_tag_name(e) -> string #builtin` (std.sx). Handled across inst.zig
(op def), print.zig, interp.zig (comptime: tags.getName), and emit_llvm.zig.
- emit_llvm getOrBuildTagNameArray: an always-linked `[N x {ptr,i64}]` global
of tag names indexed by global tag id (the TagRegistry namespace, slot 0 =
""). error_tag_name_get zext's the u32 tag value and GEPs into it. Built once;
not trace-gated, so it works in release too (per the spec's "tag-name table
always shipped").
- resolveTypeCategoryTags gains an `error_set` category so the
`case error_set:` arm in any_to_string matches; that arm coerces the Any to
u32 (`xx val`) and calls error_tag_name. (cast(type) didn't recover the tag
id for error-set values; the u32 coercion does.)
examples/240-error-tag-interpolation.sx: bound tags + a catch-bound tag print
their names. Regenerated ffi-objc-call-06-sret-return.ir — pure block-renumber
drift from adding one if-arm to the shared any_to_string (verified
semantically identical after collapsing block numbers).
Gates: zig build, zig build test, bash tests/run_examples.sh (277 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
Add validateMainSignature (lowerRoot Pass 4a). main must take no parameters
and have a single-slot return — void, an integer (POSIX exit code), or `-> !`
/ `-> !Named` (the error tag rides the single return register, which the JIT's
`() -> i32` main call handles directly). Other shapes are now clean
diagnostics instead of silent miscompiles:
- `main :: () -> string` previously SEGFAULTED (the i32 return register was
read as a string) — now a clear "return type must be void, an integer, or
`!`" error.
- `main :: (x: ...)` previously ran silently (param ignored) — now rejected.
- `main :: () -> f64` / non-failable tuple / etc. — rejected.
The value-carrying failable `-> (T, !)` is rejected for now: its multi-slot
{value, error} return ABI-mismatches the entry-point call and segfaults. That
shape needs the E4.2 entry-point wrapper (gated on E3 return traces); rejecting
loudly beats miscompiling. `-> !` (no value) IS accepted — single-slot, works
today (success exits 0; a raise exits nonzero, trace/tag story pending E3).
examples/239-main-signature-reject.sx covers the `-> string` rejection (exit 1).
Accepted shapes are exercised elsewhere (238 for integer-exit truncation; the
existing suite for void/int main). Gates: zig build, zig build test, bash
tests/run_examples.sh (276 passed; lone failure is the user's uncommitted
213-canonical-map pack WIP).
A non-failable integer `main :: () -> T` must exit with its return value
truncated to u8 (matching C main / the OS exit-status byte), so `sx run`
(JIT) and an AOT binary agree. runJITMain clamped instead: any value outside
0..255 returned exit 1, so `return 1105` exited 1 (not 81), `return -1` exited
1 (not 255), and `return 256` exited 1 (not 0).
Fix: bit-cast the i32 return to u32 and @truncate to u8 — negatives wrap as
their two's-complement low byte rather than being clamped. The AOT path
already gets OS truncation, so it was already correct; this makes JIT match.
examples/238-main-exit-truncation.sx returns 1105 -> exit 81. Values <=255
(42, 200) still pass through unchanged.
Gates: zig build, zig build test, bash tests/run_examples.sh (275 passed; the
lone failure is the user's uncommitted 213-canonical-map pack WIP).
A defer or onfail body runs while the block/function is already exiting, so it
has no target to transfer control to. `raise` was already rejected (E1.3); this
adds the rest of the locked set — `return` / `break` / `continue` / `try`.
In parseStmt, the return/break/continue/try parse sites now call a new
rejectInCleanup() helper, gated on in_onfail_body || in_defer_body (the existing
flags, whose doc-comments already scoped this follow-up). The ban is transitive
through nested catch bodies and loops, but parseLambda clears both flags for the
closure body — a closure is its own function boundary, so a `return` from a
closure created inside a cleanup body stays legal. The diagnostic names the
cleanup kind ("an `onfail`" / "a `defer`").
examples/237-cleanup-body-restrictions.sx covers the rejected forms (exit 1);
six inline parser tests cover each banned exit, the transitive-through-loop
case, the closure-boundary exception, and flag-restore after the defer.
Note: examples/213-canonical-map.sx is the user's uncommitted heterogeneous-
variadic-pack WIP (prints 40 vs expected 42); it fails on the committed parser
too, independent of this change, and is left unstaged.
Gates: zig build, zig build test (288 pass), bash tests/run_examples.sh (all
green except the unrelated 213 WIP).
The error slot of a value-carrying failable can no longer be silently dropped
on a bare destructure. In lowerDestructureDecl, when the RHS is failable
(errorChannelOf(ty) != null), the error slot (always the last tuple field)
must be bound to a non-`_` name. Reject when it is omitted entirely (fewer
names than slots — e.g. `a, c := inc(5)` for `inc: -> (s32,s32,!E)`) or bound
to `_` (`v, _ := parse(5)`).
The `try` / `catch` / `or value` consumer forms all strip the error channel
(their result type is non-failable), so the check never fires on them — only a
bare failable destructure is rejected. Value-slot `_` discards stay legal
(`a, _, ae := pair()` binds the error).
This is the discard-rejection slice of E1.8; the path-sensitive flow-check
(value live only where err==null is provable) is a separate follow-up.
examples/236-failable-discard-reject.sx covers both rejected shapes (exit 1).
Gates: zig build, zig build test, 274/274 examples.
The preceding parser fix (parenthesized match-arm value vs payload capture)
fully enables `catch e == { case .X: (tuple) }` — both scalar and tuple arm
values. Tuple literals in statement/binding position already worked, so the
match-body form runs end-to-end.
Add a `classify` to examples/235 exercising multi-value catch match-body with
per-tag value-tuple arms; exit 164 -> 170. Regenerate the snapshot.
(Corrects an earlier note that wrongly claimed a separate "issue 0059" blocked
the tuple match-body form — no such issue exists; the capture-parse bug was the
whole problem.)
Gates: zig build, zig build test, 273/273 examples.
A match arm `case PAT: (expr)` — e.g. `case 0: (5)` — failed to parse:
parseMatchBody unconditionally consumed an `(` after `case PAT:` as a
payload-capture `(ident)`, so a non-identifier first token produced
"expected capture name".
Disambiguate: treat `(` as a capture only when it encloses exactly a lone
identifier — `( ident )` — via a new isLoneIdentParen() helper (peekTag-based
two-token lookahead). Otherwise the parens belong to the arm-body expression.
Payload capture (`case .b: (v) { ... }`, examples/128) still binds.
This fixes the scalar paren arm value (`case 0: (5)` now parses and runs).
The tuple arm-value form (`case .X: (a, b)`) additionally needs a tuple
literal in statement/binding position, tracked separately as issue 0059.
Tests: two inline parser unit tests (paren arm value is not a capture; lone
`(ident)` still binds). Gates: zig build, zig build test, 273/273 examples.
Generalize the single-value `-> (T, !)` error-channel ABI to any value
arity. Retire the five `fields.len == 2` bails (lowerFailableSuccessReturn,
lowerTry, lowerCatch, lowerFailableOr, and the inferExprType try/catch/or
arms); lowerRaise + emitErrorReturn already looped over N value slots.
New helpers centralize "value-part = every slot but the last (error) one":
failableSuccessType (lone value type, or a value-tuple), extractSuccessValue,
extractErrorSlot.
Fix one latent bug the feature surfaced: coerceToType had no tuple->tuple
arm, so a value-tuple flowing into a differently-typed success slot (e.g.
(s64,s64) catch body into (s32,s32)) fell through unchanged. Add element-wise
coercion. No lowerTupleLiteral change is needed: a `return (a, b)` literal
against a 3-field failable target already gets target_fields=null via the
arity mismatch, so it types as a plain value-tuple that
lowerFailableSuccessReturn consumes.
examples/235-multi-value-failable.sx exercises producer return/raise,
destructure (binding every slot incl. the error tag), multi-value try
(success + propagation), catch (bare-expr tuple body), and or-tuple
terminator. Match-body tuple arms are left out: `(` after `case PAT:` is
parsed as a payload capture (a pre-existing, multi-value-unrelated parser
bug). Gates: zig build, zig build test, 273/273 examples.
`onfail [e] BODY` runs cleanup only when an error LEAVES the enclosing block
(a `raise` or a propagating `try`), and is skipped on success — unlike `defer`,
which runs on every exit. On an error exit, defers and onfails run interleaved
in reverse declaration order; `onfail e` binds the in-flight error tag.
- Cleanup stack: defer_stack now holds CleanupEntry { body, is_onfail, binding }
(one declaration-ordered stack so defer/onfail interleave). lowerDefer pushes
a defer entry; lowerOnFail (new `.onfail_stmt` arm) pushes an onfail entry,
rejecting `onfail` outside a failable function.
- emitBlockDefers (success exits — return / normal block exit) now emits only
`defer` entries and discards onfails.
- emitErrorCleanup (new; wired at the error exits — lowerRaise pure +
value-carrying, lowerTry propagation) emits both kinds interleaved in reverse,
binding the in-flight tag for `onfail e`.
Block-rooted: an error propagating to the function drains all enclosing blocks'
onfails; a block that exits normally discards its onfails. Per-attempt-`try`
gating is moot for now (no compilable `or` chain can absorb a mid-block try
failure yet — E2.4b). Body restrictions beyond the parser's raise-in-onfail
ban are deferred.
Tests: examples/233-onfail.sx (interleave order on error vs success + binding;
deterministic trace), examples/234-onfail-reject.sx (onfail outside a failable
fn rejected; exit 1). Gates: zig build, zig build test, 272/272 examples.
`lhs or value` where `lhs` is a value-carrying failable (`-> (T, !E)`): on
success the result is the LHS value, on failure the LHS error is discarded and
the result is the terminator value — the whole expression is non-failable (T).
Unblocked by the value ABI (E2.1); needs no fallback-routing (it's a 2-operand,
non-chained `or`).
- lowerBinaryOp `.or_op`: a failable LHS now routes to lowerFailableOr instead
of the E1.4a loud bail; non-failable `or` (boolean / optional-unwrap)
unchanged.
- lowerFailableOr: chain form (a `try`-marked LHS, whose own type is its
success value, or a failable RHS) bails → E2.4b (fallback routing). Pure
failable `or value` rejected ("no success value to fall back to — use
catch"). Value-carrying: tuple_get the value/error, condBr, merge the LHS
value (success) or the terminator (failure) through a block-param phi.
Multi-value bails (E2).
- inferExprType `.or_op`: a failable `or value` types as the LHS success type
(was always `.bool`); non-failable `or` still `.bool`.
Tests: examples/231-failable-or.sx (success + Bad + Empty terminators; exit
116), examples/232-failable-or-reject.sx (pure-failable `or value` rejected;
exit 1). Gates: zig build, zig build test, 270/270 examples.
The consumer side of the error-channel tuple ABI. A value-carrying `-> (T, !E)`
failable can now be consumed by `try` and `catch` (not just destructured).
Single-value; multi-value `-> (T1, T2, !)` consumers bail (E2).
- lowerTry: a value-carrying callee returns `{v, err}`. Extract `err`
(tuple_get field 1), branch; on success the try value is `tuple_get(field 0)`,
on error propagate via emitErrorReturn (pure caller → `ret(tag)`;
value-carrying caller → `ret {undef..., tag}`). Widening now runs for
value-carrying callees too. Retires the two value-carrying bails.
- lowerCatch: a value-carrying LHS merges through a block-param phi — the
success edge feeds `tuple_get(field 0)`, the handler edge feeds the body's
value (coerced to the success type). runCatchBody factors the bound-tag body
lowering (force_block_value for the value case). Pure-failable catch
unchanged.
- A non-diverging value-carrying catch body that yields no value is now a
clean diagnostic ("`catch` body must produce a value … or diverge") instead
of coercing `void` into a bad ref / failing LLVM verification — caught by an
adversarial review of the lowering.
Tests: examples/229-value-failable-consume.sx (try in value-carrying + pure
callers, catch block/bare/match-body/diverging bodies; exit 32),
examples/230-value-failable-reject.sx (void catch body rejected; exit 1).
Gates: zig build, zig build test, 268/268 examples.
The producer side of the error-channel tuple ABI for value-carrying `-> (T, !)`
functions. A failable that returns a value OR an error now lowers correctly;
the result is consumed via destructure (`v, err := f()`). Single-value
`-> (T, !)`; multi-value `-> (T1, T2, !)` and the value-carrying try/catch
consumers (E2.1b) follow.
- lowerReturn: a value-carrying failable's `return v;` assembles the success
tuple `{v, 0}` (compiler appends the no-error slot) via lowerFailableSuccessReturn
(tuple_init). Forwarding a full failable tuple (`return other_failable()` /
explicit `return (v, e)`) returns as-is. Multi-value returns bail loudly (E2).
- lowerRaise: the value-carrying branch (previously a loud bail) now builds
`{undef value slots..., tag}` (constUndef per value slot + the error tag) and
returns it — any arity.
- helpers: buildFailableTuple (tuple_init from value refs + tag) + emitTupleRet
(return honoring inline-comptime targets).
Value-carrying `try` / `catch` still bail (E2.1b). Tests:
examples/228-value-failable.sx (return value + both raises, consumed by
destructure; exit 60). Gates: zig build, zig build test, 266/266 examples.
`expr catch [e] BODY` consumes a failable's error inline. Pure-failable slice
(value-carrying `-> (T, !)` catch deferred to E2's tuple ABI).
- lowerExpr `.catch_expr` -> lowerCatch; inferExprType `.catch_expr` ->
operand's success type (void for pure-failable).
- lowerCatch: operand must be failable (else "catch requires a failable
expression"); pure-failable LHS only (value-carrying bails to E2). Eval
operand -> err tag; condBr to handle (error) / merge (success). In handle:
child scope binds `e` to the tag (typed as the error set), lower body
(block or expr); if the body didn't diverge, br merge. Result is void.
`catch` needs no failable enclosing function — it handles the error locally.
- All four body forms work: block, no-binding `catch { }`, bare-expr, and
the match-body `catch e == { case ... }`. Re-raise (`raise e`) and diverging
bodies (`return`) rely on E1.3 / E1.4c.
Also: lowerMatch now supports error-set subjects — `case .X` resolves to the
global tag id (was the arm index, dispatching wrong), and the switch operand
is the error-set value (its u32 tag) directly rather than via enumTag. This
is what the catch match-body form (and a plain `if e == { case .X }`) needs.
Tests: examples/226-catch.sx (block / no-binding / match-body / re-raise /
diverging body / success-skip; exit 18), examples/227-catch-rejections.sx
(operand-not-failable; exit 1). Gates: zig build, zig build test,
265/265 examples.
A match (`if subject == { case ... }`) whose arms all diverge (each
`return`s / `raise`s) failed LLVM verification with a `void` phi plus
"Terminator found in the middle of a basic block". Two causes in lowerMatch:
- The value-arm path did `lowerBlockValue(arm.body) orelse constInt(0, …)`,
emitting the fallback `const` into a block the body had ALREADY terminated
(a diverging arm), so `currentBlockHasTerminator()` then saw the const (not
the `ret`) and emitted a `br merge` after the terminator. Fix: materialize
the fallback value + branch only when the block hasn't terminated.
- A fully-diverging match infers `result_type == .noreturn` yet still built a
value-merge phi. Fix: `has_value_merge` excludes `.noreturn`, so such a
match builds no phi; its arms terminate and the merge block is unreachable.
Also: inferMatchResultType now skips `.noreturn` arms (a diverging arm doesn't
decide the result type) and reports `.noreturn` only when EVERY arm diverges —
so a mixed match (some arms yield values, some diverge) infers the value type.
This unblocks ERR E1.5's `catch` match-body form (`x catch e == { case .A:
return …; else: raise e; }`), which desugars to an all-diverging match.
Regression: examples/225-match-diverging-arms.sx (all-diverging + mixed,
exit 134). Gates: zig build, zig build test, 263/263 examples.
Type the divergence shapes as `noreturn` so a `catch` body that diverges
(E1.5) unifies with the failable's success type. The plan's original
"E1.4b", renumbered E1.4c (the SCC slice took the "E1.4b" label).
- inferExprType: `return` / `raise` / `break` / `continue` -> .noreturn
(removed `.return_stmt` from the statements-are-`.void` group)
- if-else unification: a `.noreturn` branch yields the other branch's type;
both diverging -> `.noreturn`
- block-ending-in-divergence propagates `.noreturn` (existing block arm)
- calls to `-> noreturn` already type via Function.ret (verified)
- made inferExprType pub for the unit test
Scope: the essential divergence shapes. Deferred `unreachable` (not a
keyword in sx — a separate feature, no current consumer) and infinite-loop
`noreturn` detection (rare). No observable consumer until E1.5's catch body,
so validated by a unit test, not an example.
Tests: unit test `E1.4c noreturn typing` in lower.test.zig (each shape ->
noreturn; block propagation; if-else unification). Gates: zig build,
zig build test, 262/262 examples (no new examples).
The type-convergence side of E1.4 (the SCC slice). A bare `-> !` function's
error set is now converged whole-program from its literal raises plus the
sets of the pure-failable functions it `try`s.
- convergeInferredErrorSets: a pre-lowering fix-point pass (lowerRoot Pass
1d, after scanDecls / before body lowering) that walks each top-level
bare-`!` function's body AST (collectErrorSites, stopping at nested-fn
boundaries) for literal `raise error.X` tags + pure `try g()` edges, then
unions each set with its edges' sets until stable. Stored in a side map
`inferred_error_sets` (fn name -> sorted []u32) — sidesteps the name-only
error-set interning collision (the shared `!` placeholder stays empty).
- lowerTry widening: a named caller `try`-ing a bare-`!` callee now checks
the callee's converged set (previously a false-negative — the empty
placeholder was trivially a subset). Factored diagTagsNotInSet out of
checkErrorSetSubset.
- empty-inferred warning: a top-level non-main bare-`!` function with an
empty converged set warns. Not user-visible yet (the compile driver
renders diagnostics only on failure — a LANG follow-up), so unit-tested
on the DiagnosticList.
- corrected two now-stale bail messages (failable-`or` -> E2.4;
value-carrying `try` -> E2).
Deferred to E2.4: failable-`or` chains / value-terminators (and `try`
fallback routing) — gated on the value-carrying tuple ABI.
Tests: examples/223-inferred-error-sets.sx (transitive convergence +
widening passes, exit 7), examples/224-inferred-widening-reject.sx
(transitive widening rejection, exit 1), unit test in lower.test.zig.
Gates: zig build, zig build test, 262/262 examples.
`raise EXPR` now terminates a failable function via the error channel.
Scope (Option 2): full raise sema checks + lowering for the pure-failable
shape (`-> !` / `-> !Named`); the value-carrying `-> (T..., !)` shape bails
loudly, deferred to E2's error-channel tuple ABI.
- lowerStmt + tryLowerAsExpr: `.raise_stmt` -> lowerRaise (also routes a
raise that is a block's last statement, which previously hit unknown_expr)
- lowerRaise: failable-context check (effectiveReturnType + errorChannelOf);
literal membership via lowerErrorTagLiteral; variable form subset-checked
via checkErrorSetSubset; pure-failable emits ret(tag)
- lowerErrorTagLiteral skips membership for the bare-`!` inferred placeholder
- plain `return;` in a pure-failable fn emits ret(0) (success / no error)
- parser: in_defer_body flag rejects `raise` inside a `defer` body
Tests: examples/219-raise.sx (positive, exit 8),
examples/220-raise-rejections.sx (3 sema rejections, exit 1), inline parser
test for raise-in-defer. Gates: zig build, zig build test, 258/258 examples.
Adds the `.error_type_expr` arm to type_bridge.resolveAstType (the gating site
that still returned `.unresolved`):
- `!Named` → resolveTypeName(name) → the declared error set (E1.1).
- bare `!` → a shared inferred placeholder error set (reserved name "!", empty
tags), refined per failable function by the E1.4 SCC pass.
The error channel then falls out of the existing multi-return + tuple
machinery: `-> (s32, !Named)` is a tuple_type_expr whose last field is the
error_type_expr → resolves to a tuple {s32, error_set} — exactly the locked
ABI (error slot = last return slot, u32). `-> !Named` resolves to the set.
Verified end-to-end via scratch: `parse :: (n) -> (s32, !ParseErr) { ...;
return (n, e); }` compiles + runs, `v, err := parse(5)` destructures (err typed
as the error set), `err == error.X` works; `-> !Named` single return too.
3 unit tests in type_bridge.test.zig (!Named, bare ! placeholder, tuple ending
in the error set). No examples/ — the only current usage path (return
(value, error)) will be flow-check-rejected at E1.8; the blessed example waits
for E1.3 (raise) + try/catch consumption.
zig build, zig build test (275), and 256/256 examples green.
Completes E1.1. All in ir/lower.zig (the IR layer, per slice 1's finding).
- lowerFieldAccess intercepts `error.X` (parsed as field_access(identifier
"error", X)) → lowerErrorTagLiteral: interns the tag; when target_type is a
named error set, types the value as that set and validates X ∈ set (out-of-set
→ diagnostic); otherwise emits the raw u32 global tag id (the spec's
context-free default — not a silent guess).
- tryLowerErrorSetEquality (early branch in lowerBinaryOp) + errorSetTypeOf /
isErrorTagLiteralNode: an error-set value or `error.X` literal forces the other
operand to be one too, else a diagnostic ("compares only with an error.X tag or
another error-set value; coerce with `xx`"). Both sides lower under the set type
as context (error.X resolves + membership-checks); two bare tag literals with no
context compare as global u32 ids. Handles both operand orders.
First ERR examples (end-to-end): 217-error-sets.sx (declared set + error.X +
== true/false + u32 coercion → "error-set result: 25", exit 25) and
218-error-set-typing.sx (out-of-set literal + tag-vs-raw-int → 2 diagnostics).
Failable `!`/`!Named` signatures and raise/try/catch/onfail semantics remain
(E1.2+). zig build, zig build test, and 256/256 examples green.
First sema/types step. Implemented in the IR layer (ir/types.zig +
type_bridge.zig + lower.zig), NOT src/sema.zig — lowering doesn't consume
sema; the frontend Type is LSP-only. Mirrors how enums are handled.
- ir/types.zig: new `.error_set` TypeInfo kind (ErrorSetInfo {name, tags:
[]u32}; identity = name, like enum) with a u32 runtime layout (size/align
4, LLVM i32) per the locked error-slot ABI. New TagRegistry on TypeTable
(global tag pool: name -> u32, monotonic, id 0 reserved for "no error").
internTag/getTagName/errorSetType helpers; `.error_set` arms in all 7
exhaustive switches + findByName.
- emit_llvm: toLLVMTypeInfo -> i32. print: writeType -> set name.
- type_bridge: resolveInlineErrorSet (mirrors resolveInlineUnion) +
.error_set_decl arm.
- lower.zig: registerErrorSetDecl (rejects empty `error { }` with a
diagnostic) wired into both top-level decl switches + the block-local one.
- tests: ir/types.test (TagRegistry 0-reserved + identity; errorSetType u32
layout + named display + dedup; sorted storage) and ir/type_bridge.test
(decl -> type + tag interning + re-resolve dedup).
End-to-end: `Foo :: error { A, B }` + main compiles + runs (exit 0) — first
ERR syntax to survive the full pipeline; empty set rejects with a diagnostic.
Inferred bare `!`, error.X value, and == typing deferred to slice 2 / E1.2.
zig build, zig build test, and 254/254 examples green.
Fills the E0.3 coverage gaps E0.1/E0.2's inline tests hadn't hit and adds
an end-to-end integration parse. Test-only; no production code change.
- `try` in statement position (`try must_init();`).
- `try` over a parenthesized or-chain (`try (foo() or boo())`) — distinct
from `try foo() or try boo()`.
- `or` value-terminator (`parse(s) or 0`).
- Integration: a full `parse :: (s) -> (s32, !ParseErr) { onfail / try / or /
catch / if { raise } / return }` — asserts the trailing `!ParseErr`, the
five body statement kinds, and that `in_onfail_body` is correctly scoped
(the later if-block `raise` is allowed).
Tests stay inline in parser.zig (consistent with the existing 24 + E0.1/E0.2
inline tests). 37 ERR parser tests total; every new AST node has a round-trip
test. zig build test (268) and 254/254 examples green.
Parser-only second step of the error-handling stream. No sema/codegen.
- token: 4 keywords — `raise`, `try`, `catch`, `onfail`.
- ast: RaiseStmt, TryExpr, CatchExpr {operand, binding?, body, is_match_body},
OnFailStmt {binding?, body}.
- parser:
- `try` is a unary prefix (binds tighter than `or`; right-recursive so it
stacks under `xx`/`@`/etc).
- `or` is already left-associative (precedence-climbing loop) — no change.
- `catch` is a postfix with four body shapes (no-binding block / block /
bare-expr / `== { case }` match-body, the latter reusing parseMatchBody
with the binding as subject).
- `raise EXPR;` and `onfail [e] { } | onfail EXPR;` statements; `error`
parses in expression position so `raise error.X` works; raise rejected
in expression position and inside an onfail body (in_onfail_body flag).
- consumer-aware `|>`: pipes the LHS into the head call through a
try/catch/or wrapper, preserving the wrapper.
- print: printExpr + match-arm printing for round-trips (anyerror!void to
break the printExpr<->printMatchArms inferred-error-set loop).
- sema/lsp: exhaustive switch arms for the 4 nodes + 4 keyword tokens.
- tests: ~22 inline parser tests (precedence, all catch forms, both
rejections, pipe cases, round-trip prints incl. match-body).
zig build, zig build test (264), and 254/254 examples green.
Reuse the compiler's lowering pass instead of re-implementing its checks
in sema. A module can't be lowered standalone — lowering only type-checks
functions reachable from a root — so the open file alone misses errors
like a *Move passed into a by-value method parameter. Drive the workspace
entry (main.sx) through parse → resolveImports → lowerToIR, then attribute
each diagnostic back to its file via source_file and publish per file
(clearing files whose errors are gone).
Runs on didOpen/didSave (disk-based); sema stays the live per-keystroke
layer. Advertise textDocumentSync.save so the editor sends didSave.
collectProjectDiagnostics is split out (transport-free) and covered by a
hermetic temp-project test.