Commit Graph

540 Commits

Author SHA1 Message Date
agra
ba3c094283 fix(lower): infer no-annotation return type with params in scope (issue 0059)
A function with no explicit return type (arrow `=> expr`, or a block whose
`return <v>` drives the type) has its return type inferred from the body — but
the body references the function's own params. resolveReturnType ran that
inference before the params were pushed into self.scope (they're bound later, at
body lowering), so inferExprType couldn't resolve them and yielded .unresolved,
which reached LLVM emission and panicked. It only worked when a same-named
binding lingered in scope from earlier lowering (e.g. inside the big smoke file).

Bind the function's plain annotated value params into a temporary scope during
return-type inference. Resolve their types via resolveTypeWithBindings rather
than resolveParamType — the latter does variadic/pack bookkeeping that must run
exactly once, at body lowering; calling it here too corrupted the format/index
path. Variadic/pack/comptime/unannotated params are skipped (no by-name return
dependency; their types come from substitution).

Regression: examples/0308-closures-arrow-inferred-return.sx (arrow + block
inferred-return, top-level + local). Resolves issue 0059. Suite: 293 passed.
2026-06-01 19:28:35 +03:00
agra
70b4341682 issues: file 0059 — expr-bodied lambda inferred return type panics LLVM emission
An expression-bodied lambda `f :: (x: s32) => x * 2;` without an explicit
return type reaches emit_llvm declareFunction with func.ret = .unresolved and
trips the emission guard panic. Explicit `-> s32` works; the same lambda inside
a large module (the old 50-smoke.sx) resolves fine — so inferred-return
resolution for => lambdas runs only conditionally. Repro co-located.

Surfaced while splitting 50-smoke.sx (test-layout migration); the functions
section uses this exact construct, so the split is paused per the impassable
rule (no workaround).
2026-06-01 19:17:21 +03:00
agra
4e942b5373 test: migrate examples to XXXX-category-name layout + split expected streams
Rename all example tests/companions to the XXXX-category-test-name scheme
(per-category 100-blocks: basic 0010, types 0100, ... errors 1000,
diagnostics 1100, ffi 1200, ffi-objc 1300, ffi-jni 1400, vectors 1500,
platform 1600). Companions and dir/C fixtures move in lockstep with their
parent test; #import/#source/#include paths rewritten to match.

Expected output now lives in examples/expected/ (a sibling dir of the
tests) split into three streams per the new convention:
  <name>.exit / <name>.stdout / <name>.stderr  (+ optional <name>.ir)

run_examples.sh rewritten: scans examples/ and issues/ for an
expected/<name>.exit marker, captures stdout and stderr separately (no
more 2>&1), compares each stream + exit + optional IR snapshot.

Behavior validated unchanged: every renamed test reproduces its prior
merged output + exit (diffs limited to file paths/basenames embedded in
diagnostics + traces, which correctly reflect the new names). Suite:
292 passed, 0 failed. 50-smoke.sx split + issue relocation + docs follow
in subsequent commits.
2026-06-01 19:05:15 +03:00
agra
e86e41b719 ERR/E5.3: specs.md §12 Error Handling (fold locked design into spec)
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).
2026-06-01 18:19:26 +03:00
agra
49dc622234 fix(lower): auto-ref compound lvalues passed to *T params
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.
2026-06-01 17:56:51 +03:00
agra
497d450ba7 fix(lower): diagnose .* on a non-pointer instead of codegen panic
`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.
2026-06-01 17:37:27 +03:00
agra
5d275ee274 vscode: declare contributes.breakpoints for sx (enable breakpoint gutter)
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.
2026-06-01 16:52:56 +03:00
agra
0d7f786db2 fix(dwarf): non-empty comp_dir so ld keeps the debug map (issue 0058)
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.
2026-06-01 16:47:51 +03:00
agra
b2ebf774bc ERR/E3.0 (slice 3e rung 2): iOS-simulator stepping verified
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.
2026-06-01 16:10:33 +03:00
agra
4cd641c946 ERR/E3.0 (slice 3d): --emit-obj + macOS lldb stepping verified
`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.
2026-06-01 15:55:05 +03:00
agra
178449b548 ERR/E3.0 (slice 3c): source snippet + caret in traces
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.
2026-06-01 15:43:22 +03:00
agra
b5241243e6 ERR/E3.0 (slice 3b): comptime trace resolution
#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.
2026-06-01 15:33:50 +03:00
agra
11f6377d9c docs: mark debugger slice 3a done (embedded Frame trace resolution) 2026-06-01 15:11:25 +03:00
agra
1b6cbc17e7 ERR/E3.0 (slice 3a): embedded Frame trace resolution
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.
2026-06-01 15:10:46 +03:00
agra
8b8ba3a1bf docs: add debugger.md — traces, DWARF, and stepping architecture
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.
2026-06-01 14:49:35 +03:00
agra
c32d694d57 ERR/E3.0 (slice 2): emit DWARF line-info
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.
2026-06-01 13:14:00 +03:00
agra
b44a5d05ef ERR/E3.0 (slice 1): thread source spans into IR instructions
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.
2026-06-01 12:52:14 +03:00
agra
d67fb7b9b3 ERR/E4.1: trace.print_interpreter_frames() — Phase E4 complete
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.
2026-06-01 12:22:23 +03:00
agra
e04bec488b ERR/E4.1b: #caller_location + Source_Location (+ namespaced default fix, comptime flush)
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).
2026-06-01 12:00:03 +03:00
agra
6f77c55613 ERR/E4.1 (slice 1): log + is_comptime + process.exit/assert (+ noreturn codegen)
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.
2026-06-01 11:11:56 +03:00
agra
1d6e26f011 ERR/E2.4b: failable or chains
`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).
2026-06-01 10:31:43 +03:00
agra
e898effb4b ERR/E4.2: value-carrying -> (int, !) main wrapper
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.
2026-06-01 10:00:03 +03:00
agra
210cf91e37 ERR/E4.2: failable-main wrapper (report + exit 1 on escaping error)
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.
2026-06-01 09:48:32 +03:00
agra
bb20339691 ERR/E3.3: trace formatting (library/modules/trace.sx) + catch-clear timing fix
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).
2026-06-01 08:58:57 +03:00
agra
a694d91bca fix(0057): clear target_type when lowering variadic pack args
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).
2026-06-01 08:51:44 +03:00
agra
ea40724b61 ERR/E3.2: wire trace push/clear into raise/try/catch/or/destructure
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).
2026-06-01 08:28:46 +03:00
agra
51f5277380 ERR/E3.1: thread-local error return-trace ring buffer (runtime)
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).
2026-06-01 08:13:12 +03:00
agra
a3ff503f47 ERR/E3: error-tag {} interpolation via an always-linked tag-name table
`{}` 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).
2026-06-01 07:47:32 +03:00
agra
6e32e6c63c ERR/E4.2: entry-point signature gate for main
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).
2026-06-01 07:23:31 +03:00
agra
94335f94d7 ERR/E4.2: truncate integer main's return to u8 for the JIT exit code
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).
2026-06-01 01:42:53 +03:00
agra
f9dd965b69 ERR/E1.7: ban return/break/continue/try in defer & onfail bodies
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).
2026-06-01 01:14:24 +03:00
agra
66740fa95b ERR/E1.8: reject dropping a failable's error slot on destructure
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.
2026-06-01 00:40:05 +03:00
agra
f96bcc4fe4 ERR: use the catch match-body form in examples/235
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.
2026-06-01 00:16:39 +03:00
agra
d4b1248f65 parser: parenthesized match-arm value vs payload capture
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.
2026-06-01 00:12:11 +03:00
agra
ae330365b4 ERR/E2: multi-value failables -> (T1, ..., !)
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.
2026-05-31 23:32:16 +03:00
agra
57d8e327cd ERR/E1.7: onfail — cleanup on error-exit, interleaved with defer
`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.
2026-05-31 22:29:40 +03:00
agra
50e5515080 ERR/E2.4a: failable or value-terminator
`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.
2026-05-31 22:16:28 +03:00
agra
a049e2940c ERR/E2.1b: value-carrying failable consumers (try / catch)
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.
2026-05-31 22:05:44 +03:00
agra
17c19d5d30 ERR/E2.1a: value-carrying failable producer (return value + raise → tuple ABI)
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.
2026-05-31 21:42:51 +03:00
agra
0bbff9d7fb ERR/E1.5: catch sema (pure-failable slice) + error-set match subjects
`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.
2026-05-31 21:10:56 +03:00
agra
28b18f812a fix(issue-0057): all-diverging match arms no longer fail LLVM verification
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.
2026-05-31 21:04:06 +03:00
agra
696a749bd5 ERR/E1.4c: noreturn plumbing for divergence shapes
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).
2026-05-31 20:33:13 +03:00
agra
d2cba4e460 ERR/E1.4b: whole-program inferred error sets + empty-inferred warning
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.
2026-05-31 20:21:44 +03:00
agra
aa1aa63bb3 ERR/E1.4a: standalone try sema + pure-failable propagation + named widening
`try f()` (standalone form) now propagates a failable callee's error to the
enclosing failable function. E1.4 was split: E1.4a = standalone try (failure
target = function-propagation); E1.4b = fallback-target routing +
failable-`or` + whole-program SCC for inferred sets + empty-inferred warning.

- lowerExpr: `.try_expr` -> lowerTry
- lowerTry: (1) try legal only inside a failable fn; (2) the sole
  failable-operand check (errorChannelOf(inferExprType(operand))); (3)
  named-caller widening (checkErrorSetSubset at the propagation site); (4)
  pure-failable lowering — condBr on tag != 0: propagate (run defers + ret
  the widened tag) vs continue on success
- inferExprType: `.try_expr` arm (success type: void for pure-failable)
- lowerBinaryOp .or_op: bail loudly on a failable LHS (exprIsFailable);
  the optional-`or` path is unchanged for non-failable LHS
- value-carrying callee/caller `try` bail loudly (pending E2's tuple ABI)

Tests: examples/221-try.sx (positive propagation, exit 5),
examples/222-try-rejections.sx (3 stable rejections: outside-failable,
non-failable operand, named-widening miss; exit 1). Gates: zig build,
zig build test, 260/260 examples.
2026-05-31 19:47:19 +03:00
agra
9984fa6b96 ERR/E1.3: raise sema + pure-failable lowering
`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.
2026-05-31 19:09:32 +03:00
agra
5a24a1177d ERR/E1.2: failable signatures — resolve the ! / !Named error channel
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.
2026-05-31 18:30:22 +03:00
agra
f5974e5846 ERR/E1.1 (slice 2): error.X value lowering + enum-like == typing
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.
2026-05-31 17:59:47 +03:00
agra
73232ce170 ERR/E1.1 (slice 1): error-set type + global tag registry + decl registration
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.
2026-05-31 17:39:11 +03:00
agra
fdeab0efd4 ERR/E0.3: parser test consolidation — Phase E0 complete
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.
2026-05-31 17:14:02 +03:00
agra
1b777dd6ab ERR/E0.2: raise / try / catch / onfail + precedence + consumer-aware pipe (parser)
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.
2026-05-31 17:07:49 +03:00