Commit Graph

15 Commits

Author SHA1 Message Date
agra
c3bc6acd42 ERR/E1.7: reject bare failable calls in defer/onfail cleanup bodies
A `defer`/`onfail` body runs while the block is already exiting, so a
failable call there has nowhere to propagate its error. The parser
already bans `try`/`raise`/`return`/`break`/`continue` in cleanup bodies
(f9dd965); this adds the remaining sema rule — a bare (un-absorbed)
failable call must be absorbed locally with `catch` or `or <value>`.

Implemented in the shared error-flow pass (`checkCleanupBody` /
`checkCleanupNode` / `cleanupReject` in ir/lower.zig): when the walk hits
a `defer`/`onfail`, it scans the body transitively (through blocks, `if`,
loops, match arms, `catch` handlers; stopping at nested closures) and
flags any still-failable expression. `catch` / `or value` strip the
error channel, so `exprIsFailable` is false for them — only an unhandled
failable trips the check. This completes ERR PLAN E0–E5 plus the two
deferred E1 follow-ups (E1.7 + E1.8).

New regressions: 1048 (catch/or-value absorbed forms compile + run) and
1049 (bare failable in defer and onfail rejected, exit 1).

Filed issue 0065: a braced `defer { … }` / value-block body routes
through `parseExpr` (not `parseBlock` like `onfail`), so it can't parse a
destructure or `catch`-statement inside. Orthogonal to E1.7 — the spec'd
cleanup absorbers (`catch` / `or value`) parse fine in a `defer` body.

Gates: zig build, zig build test, run_examples.sh -> 340 passed, 0 failed.
2026-06-01 23:24:15 +03:00
agra
296c809d85 ERR/E1.8: path-sensitive value-slot liveness check
A `v, err := failable()` destructure now binds the value slot(s) "live
only where `err` is proven absent". Reading `v` where the compiler cannot
prove `err == null` is a compile error.

New diagnostic-only Pass 1e (`checkErrorFlow` in ir/lower.zig): a
structured, path-sensitive walk over each main-file function body. A
proven-null set is threaded across branches and joined by intersection
at each `if`'s merge. Proof shapes recognized:

  - `if !err { … v … }`           (proven inside the guard)
  - `if err { return/raise } … v` (proven on the fall-through)
  - `if err { … } else { … v … }` (proven in the else branch)
  - `!err and <reads v>`          (short-circuit refinement)

Error-set tag compares (`if err == error.X`) prove nothing about
absence — they narrow the tag only. Nested lambdas are analyzed as their
own boundaries. Library modules are trusted (skipped).

Migrated the canon value-failable examples (1011/1012/1018/1044) to read
their value slots under `if !err` guards — output unchanged. New
regressions: 1046 (every proof shape compiles + runs, exit 210) and 1047
(unproven reads rejected, exit 1).

Gates: zig build, zig build test, run_examples.sh -> 338 passed, 0 failed.
2026-06-01 23:14:24 +03:00
agra
5491acbcbb ERR/E5.4: add composition section to the errors smoke example
Extends 1036-errors-failable-smoke with an end-to-end Composition section
covering the E5.1 forms: a failable closure literal through a Closure(...)
param (try-propagated, caught), a non-failable closure literal widened
into a failable bare slot (∅-widening adapter), and generic ($T)
value-carrying failable composition. Completes E5.4 — the per-feature
examples (1039-1045) remain the focused units; this is the integrated
smoke.
2026-06-01 22:47:58 +03:00
agra
2e6e031233 ERR/E5.1: reject closure-value into bare function-pointer slot
A closure VALUE (a pre-bound variable) flowing into a bare (T)->U slot
was passed unsoundly: the bare ABI calls fn_ptr(ctx, args) with no env
channel, so the closure's underlying fn (which takes an env slot) had its
env dropped and args shifted — UB for a matching ABI, a wrong-tuple read
for the non-failable->failable widening (returned -1), and a segfault when
the closure captured.

coerceToType now rejects a .closure -> .function coercion with a
diagnostic pointing at the idiom (pass the literal directly, which gets
the static adapter, or type the parameter Closure(...) so the env is
carried). Closure LITERALS are unaffected — lowerLambda pre-adapts them to
a .function-typed value before coercion.

Regression: 1045-errors-closure-var-bare-slot-reject.sx.
2026-06-01 22:44:20 +03:00
agra
1c14383495 ERR/E5.1: verify generic failable composition (sub-feature 8); resolve 0062, file 0064
Generic value-carrying failable composition works with the documented
$T: Type generic form (catch / destructure / failure-propagation / a
second monomorphization at a different T). Issue 0062 was an invalid-repro
report — it used the non-generic T: type form, which is a plain Type-valued
param, not a generic type parameter. Marked 0062 resolved (not a bug).

The only real residual: a non-$ T: Type function param used as a type
silently resolves to an empty {} (renders T{}) instead of erroring. Filed
as 0064 (deferred, orthogonal to ERR — the $T idiom works).

Regression: 1044-errors-generic-failable-composition.sx.
2026-06-01 22:35:02 +03:00
agra
547148b8b6 fix(lower): free-fn UFCS auto-address-of + lazy lowering (issue 0063)
A free function called via UFCS (recv.fn(args)) whose first param is *T
was passed the receiver by value (LLVM "Call parameter type does not
match function signature"), and a function reached only via UFCS was
declared but never emitted (undefined symbol at link).

The bare-name UFCS fallback now mirrors the qualified-method path: it
lazily lowers the target body and calls fixupMethodReceiver +
coerceCallArgs, so the value receiver gets the same implicit address-of
as a struct-defined method and mutations through *T are visible.

Regression: 0039-basic-free-fn-ufcs-pointer-receiver.sx.
2026-06-01 22:28:15 +03:00
agra
a61685772d ERR/E5.1: lambda-specific raise-not-failable hint
A closure literal whose body raises but is annotated non-failable (or has
no ! in its return) now gets a lambda-specific diagnostic telling the user
to declare the failable return explicitly, instead of the generic "raise
is only valid inside a failable function". Failability is never inferred
for a lambda, so a raising lambda with no ! is a hard error that should
point at the fix.

New in_lambda_body flag (save/restore for nesting) set around the lambda
body lowering in lowerLambda; diagRaiseNotFailable branches on it.
Top-level functions keep the generic message.

Test: 1043-errors-lambda-raise-annotation-hint.sx.
2026-06-01 22:18:47 +03:00
agra
39c21468ee ERR/E5.1: program-wide inferred-! union per closure/fn shape
All occurrences of Closure(<sig>) -> (T, !) with a structurally identical
value-signature now share one inferred error-set node; every bare-!
closure literal of that shape unions its escape tags in, and a
`try slot(x)` against any matching-shape slot widens the caller's named
set against that union. This closes the gap where a slot call (no static
function name) skipped the widening check entirely.

- shape_inferred_sets keyed by closureShapeKey (params + value-return via
  mangleTypeName, error slot excluded) so bare-!, non-failable, .function
  and .closure of one value-sig collapse to a single key.
- convergeClosureShapeSets pre-pass (lowerRoot Pass 1d', after the
  name-keyed convergeInferredErrorSets): collectClosureShapes walks fn
  bodies through lambda boundaries; recordClosureShape resolves each
  concrete bare-! literal's shape and unions its raises (+ try named_fn()
  edges via calleeEscapeTags) into the shape node.
- checkEscapeWidening falls back to shapeKeyOfCallee for bare-! slot calls
  (computed from the callee expr's .function/.closure type). Empty union
  is silently allowed (sub-feature 6).

Scope: concrete shapes only (generic lambdas skipped); closure-to-closure
try edges are not fix-pointed (under-approximation = a missed diagnostic,
never a miscompile).

Tests: 1041 (positive — union composes, runs), 1042 (reject — two
widening diagnostics, exit 1).
2026-06-01 22:01:38 +03:00
agra
0e1afa3eba fix(lower): drop dead statements after a return/raise terminator (issue 0061)
A bare `return X;` / `raise` in the middle of a block closed the current
LLVM basic block, but lowerBlock / lowerBlockValue only stopped the
statement loop on the `block_terminated` flag — which lowerReturn
deliberately never sets (it would leak past an `if cond { return }` merge
block). So trailing dead statements were emitted into the already-closed
block, tripping the LLVM verifier with "Terminator found in the middle of
a basic block".

Fix: also stop the statement loop when currentBlockHasTerminator() is
true. That is CFG-level termination of the *current* block, which is
naturally false at an if / inline-if merge block, so conditional returns
still fall through to their trailing statements.

This unblocks ERR E5.1: the canonical failable-closure form
`closure((x) -> (s32,!) { raise error.X; return x; })` has a dead
`return x;` after the unconditional raise and tripped the verifier.

Regression: examples/0038-basic-dead-code-after-terminator.sx.
2026-06-01 21:42:20 +03:00
agra
b113e03fa3 ERR/E5.1: bare failable fn-type param resolution + non-failable->failable widening
Two more E5.1 composition pieces:
- inferExprType .call: a callee that's a local variable of bare  type
  () now resolves to its declared return type (only
  was handled before), so  /  on the call see the failable result
  instead of .
- createClosureToBareFnAdapter now widens: when a NON-failable closure literal
  flows into a failable bare slot (∅ ⊆ slot set, success type matches), the
  adapter wraps the value into the slot's  tuple via
  lowerFailableSuccessReturn — previously rejected. The failable->non-failable
  and capturing->bare crossings stay rejected.

Adapter generation fires for closure LITERALS flowing into a bare-fn slot; a
pre-bound closure VARIABLE into a bare-fn slot is a separate coercion-site path,
still unhandled (noted in CHECKPOINT-ERR). Regression:
examples/1040-errors-failable-closure-composition. Suite: 329 passed.
2026-06-01 20:56:10 +03:00
agra
06e2685350 fix(lower): closure literals compose with bare function-type slots (issue 0060)
A closure's underlying function carries a hidden env arg that a bare (T)->U slot
doesn't pass, so a closure flowing into a bare function-type slot dropped the
env — the first user arg landed in the env slot and the rest read garbage
(apply(closure((x)->s64 { x*2 })) returned 192 instead of 10; non-failable too).

- createClosureToBareFnAdapter: a capture-free closure into a bare (T)->U slot is
  bridged by a generated adapter carrying the bare ABI (forwards a null env);
  lowerLambda returns its func_ref. Rejected (no silent miscompile): a capturing
  closure into a bare slot (env has nowhere to live) and a failable closure into
  a non-failable slot (the ERR E5.1 FFI-boundary rule).
- Arrow-body failable closures (-> (T,!) => expr) now wrap the bare success value
  into {value, 0} via lowerFailableSuccessReturn (the implicit return previously
  returned a malformed tuple → caught value read as 0).

The isLambda .bang parser fix (failable closure literals parse) already landed in
485b4fa. Regressions: examples/0309-closures-literal-as-bare-fn-param (non-
failable, block + arrow, called in callee) + 1039-errors-failable-closure-literal
(failable, block + arrow, direct + Closure(...) param). Resolves issue 0060
(remaining E5.1 follow-ups noted in the .md). Suite: 328 passed.
2026-06-01 20:35:25 +03:00
agra
549f97c731 ERR/E5.2: comptime #run of an escaping failable → diagnostic + halt
A bare failable `#run` (no catch/or) whose error escapes used to segfault (const
form `x :: #run f()`) or silently succeed (statement form `#run f();`). Now the
compiler reports the raised tag name + the resolved return trace at the #run site
and halts with a non-zero exit.

- lower.zig: a failable #run's comptime function returns the full failable tuple
  (so the error slot is inspectable) while the global is typed as the success
  value; failable side-effects return the tuple instead of void.
- emit_llvm.zig: read the always-on comptime trace buffer (extern sx_trace_*);
  comptimeErrChannel + checkComptimeFailable split the result (non-zero tag →
  reportComptimeEscape + comptime_failed flag; success → value part). Wired into
  emitGlobals (const) and runComptimeSideEffects (statement, now filtered by the
  __run name; buffer cleared before each eval).
- core.zig: generateCode returns error.ComptimeError when comptime_failed, so the
  driver aborts before JIT/link.

catch / or / onfail compose at comptime exactly as at runtime; a successful bare
#run yields the value. Regressions: examples/1037-errors-comptime-run-escape
(diagnostic, exit 1) + 1038-errors-comptime-run-handled (exit 164). Suite: 326.
2026-06-01 20:04:17 +03:00
agra
e12f817e52 test: split 50-smoke.sx into per-section examples + add errors smoke
Break the monolithic examples/50-smoke.sx into 30 focused per-section examples,
filed into their category blocks (basic/types/comptime/memory/protocols/ffi),
each carrying only the top-level decls its section references (the protocols
section keeps the full preamble — its deps flow through UFCS method calls that
name-based extraction can't see). Outputs verified identical to the original
section blocks.

Add examples/1036-errors-failable-smoke.sx — an end-to-end error-handling example
(the E5.4 work): named + inferred error sets consumed via destructure, try (in
helpers), catch (bare-expr / match-body / diverging / no-binding), or
value-terminator, onfail+defer interleave, and error.X value + {} tag
interpolation.

Remove examples/50-smoke.sx. Suite: 324 passed, 0 failed.
2026-06-01 19:34:21 +03:00
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
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