Commit Graph

739 Commits

Author SHA1 Message Date
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
agra
e88ee66953 ERR/E0.1: error-set decls + ! / !Named type exprs (parser)
Parser-only first step of the error-handling stream. No sema/codegen.

- token: `kw_error` keyword (`!` reuses existing `.bang`).
- ast: `ErrorSetDecl { name, tag_names }` + `ErrorTypeExpr { name: ?[] }`
  (null = inferred `!`, non-null = `!Named`); wired into Node.Data and
  declName.
- parser: `parseErrorSetDecl` (comma-separated tags, optional trailing
  comma/`;`) dispatched from parseConstBinding; `!` / `!Named` parsed in
  parseTypeExpr; result-list loop enforces error type as trailing-only;
  hasFnBodyAfterArrow skips `.bang` so failable-return fns are recognised.
- print: new focused AST round-trip printer (decls + type exprs); loud
  `error.UnsupportedNode` otherwise. Registered in root.zig.
- sema/lsp: exhaustive switch arms for the two new nodes.
- tests: 11 inline parser unit tests (shapes + 3 round-trip prints + 2
  trailing-position rejections).

zig build, zig build test, and 254/254 examples green.
2026-05-31 16:40:22 +03:00