Commit Graph

294 Commits

Author SHA1 Message Date
agra
bdd0e96d78 feat(lang): block value requires no trailing ; (Rust-style)
A block's value is now its last statement ONLY when that statement is a
trailing expression with no `;`. A trailing `;` discards the value,
leaving the block void. This makes value-vs-statement explicit and lets
the compiler reject "this block was supposed to produce a value".

Compiler:
- Parser records `Block.produces_value` (last stmt is a no-`;` trailing
  expression) + `Block.discarded_semi` (the `;` that discarded a value),
  via `expectSemicolonAfter`. A trailing expression before `}` may now
  omit its `;` (previously a parse error). Match-arm and else-arm bodies
  are built value-producing regardless of the arm `;` (arms are exempt —
  the `;` is an arm terminator).
- Lowering: `lowerBlockValue` / the block-expr path / `inferExprType`
  respect `produces_value`. A value-position block that discards its value
  is a hard error (`lowerValueBody` for function bodies; the value-context
  `.block` path for if/else branches, `catch` bodies, value bindings,
  match arms). Pure-failable `-> !` bodies (value rides the error channel)
  and a value-if whose branches are void are handled without false errors.
- `defer`/`onfail` cleanup bodies lower as statements (void), so a
  trailing `;` there is fine.

Migration (behavior-preserving — output unchanged):
- stdlib + ~210 examples: dropped the trailing `;` on value-position last
  expressions. `format` now ends with an explicit `#insert "return
  result;"` (it relied on `#insert`-as-block-value, which `;` discards).
- Two `main :: () -> s32` examples that relied on the old silent
  default-return got an explicit trailing `0`.
- Rejection snapshots 0412 / 1013 regenerated (their quoted source lines
  lost a `;`); the diagnostics themselves are unchanged.

Docs/tests: specs.md "Block values" section; examples 0040 (rules) + 0041
(rejection); 3 parser unit tests. Filed issue 0066 (pre-existing
match-arm negated-literal phi-width quirk, surfaced not caused here).

Gates: zig build, zig build test, run_examples.sh -> 343 passed,
cross_compile.sh -> 7 passed (also refreshed its stale example names).
2026-06-02 09:23:50 +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
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
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
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
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
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
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
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
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
14d1d9d3a8 lower: generalise the *T-where-T-expected diagnostic to any pointer
The check only caught `for xs: (*m)` loop captures; passing a `*T`
parameter or any pointer local where `T` is expected still slipped through
to the LLVM verifier. Key the diagnostic on the lowered argument's type
instead of the capture, so a `*Move` parameter forwarded into a by-value
parameter is reported the same way. Ref-capture wording is preserved.

Add example 216 (pointer-parameter case) alongside 215 (loop capture).
2026-05-31 14:17:25 +03:00
agra
39d51fc26d lower: diagnose passing a by-ref loop capture where a value is expected
`for xs: (*m)` binds `m` to a `*T`. Passing it directly to a parameter
that wants `T` produced invalid IR that only LLVM's verifier caught, with
the opaque 'Call parameter type does not match function signature'. Detect
it at the call site and emit a clear error with a fix-it suggesting `m.*`.

Add example 215 + expected output as a regression test.
2026-05-31 13:56:45 +03:00
agra
5c9d8c23ca lang: for-loop over List(T); deref a *T method receiver
The collection for-loop now iterates a List(T)-like struct ({ items: [*]T, len, … }) — and a *List — by viewing it as items[0..len]. So 'for legal: (m)' / 'for pieces: (*p)' work like iterating a slice, with by-ref captures writing back into the backing.

fixupMethodReceiver also derefs a *T receiver when the method takes T by value, so a 'for xs: (*x)' capture can call value-self methods (x.method()). Regression: examples/for-list.sx.
2026-05-31 11:13:57 +03:00
agra
185df9afb7 lang: for-loop by-ref element capture (for xs: (*x))
(*x) binds x to a pointer into the collection (index_gep) instead of a per-element value copy: passing it on (e.g. to a *T param) is zero-copy and mutations write back. In a value position x auto-derefs — a binary-op operand loads the element, a pointer-typed slot keeps the pointer, and an 'if x == {...}' match derefs the pointee for its tag/payload. Arrays GEP through their storage so writes hit the original. Regression test: examples/for-by-ref-capture.sx.
2026-05-31 10:29:16 +03:00
agra
fb8a5399f1 objc: remove ns_string/c_string helpers
ns_string's only caller was impl Into(*NSString) for string, so +stringWithUTF8String: is inlined there. c_string's one use (NSBundle.resourcePath in uikit) becomes rsrc.UTF8String() with resourcePath retyped *NSString. ffi-objc-call-06 and ffi-objc-dsl-07 .ir snapshots regenerated — they only drop the now-absent extern declares.
2026-05-30 18:01:27 +03:00
agra
8e3c3ae981 objc: NSString type + Into(*NSString) for string
Adds an NSString foreign class and impl Into(*NSString) for string so a string literal flows into any *NSString slot via xx. uikit's keyboard userInfo lookups now read objectForKey(xx "...") instead of ns_string("...".ptr), and objectForKey's key param is retyped *NSString.

ffi-objc-call-06 .ir snapshot regenerated: declaring the NSString type adds its reflection thunks (struct_to_string/pointer_to_string), same as the existing NSObject/NSDictionary. Runtime output unchanged.
2026-05-30 17:39:38 +03:00
agra
29a4891374 imports: dedup flat decl list by node identity (issue 0056 FIXED)
Impl blocks are anonymous (no declName), so a parameterised-protocol impl in a module reached via a diamond import was appended once per path and registered twice — 'duplicate impl Into for source s64'. mergeFlat and the directory-import merge loop now also dedup by node pointer; a physical AST node is lowered once regardless of how many import paths reach it.

Regression: examples/issue-0056-diamond-param-impl.sx.
2026-05-30 17:36:35 +03:00
agra
ac7f1d10e5 lang: extend operand-type check to ordering + bitwise/shift (issue 0055 follow-up)
The arithmetic-only check from the previous commit shared a hole with the
comparison and bitwise/shift ops: lowerBinaryOp derives the result type
from the LHS, so `s64 < string` fed mismatched types to `icmp` (LLVM
verifier failure) and `s64 & string` reinterpreted the string's bytes.

Add isOrderingOperand (numeric / enum / pointer / bool / vector) and
isBitwiseOperand (integer / enum / bool / vector), and route `< <= > >=`
and `& | ^ << >>` through them alongside the existing arithmetic check, all
sharing one diagnostic + placeholder-sentinel path. Flags-enum bitwise
(`.read | .write`, `perm & .read`), enum/pointer comparison, and int
literals stay legal (50-smoke unaffected).

Equality `== / !=` is deliberately left unchecked — its path is heavily
special-cased (str_eq, Any unbox, optional == null); folding a check in
without regressing those is a separate change, noted in the issue.

Regression test renamed arith→binop and broadened to cover `+ * < & <<`
against a string operand: examples/214-binop-operand-type-check.sx.
2026-05-30 10:30:57 +03:00
agra
6016b08712 lang: reject mismatched operand types in scalar arithmetic (issue 0055)
lowerBinaryOp derived the result type from the LHS alone and emitted
add/sub/mul/div/mod without checking the RHS, so `s64 + string` lowered
as `add : s64` and reinterpreted the string's bytes — printing garbage
instead of erroring.

Add isArithOperand (int / float / vector / pointer, plus custom int
widths) and, for `+ - * / %`, diagnose `cannot apply '<op>' to operands
of type '<lhs>' and '<rhs>'` and return a placeholder sentinel instead of
the corrupting op. `.unresolved` operands pass through so a type we
couldn't infer is never falsely rejected; the existing optional-unwrap
and int×float promotion are accounted for before the check.

Ordering (`< <= > >=`) and bitwise/shift (`& | ^ << >>`) ops share the
same LHS-derived-type hole and are left as a noted follow-up in the issue.

Regression: examples/214-arith-operand-type-check.sx (s64 + string, and
non-numeric LHS string * s64).
2026-05-30 09:56:32 +03:00
agra
8e74e4acb2 lang F1 Phase 6: canonical heterogeneous map — $R inference through closure params
The full canonical `map` now compiles and runs (examples/213 → 42):

    map :: (mapper: Closure(..sources.T) -> $R, ..sources: VL) -> VL($R)

Final piece: infer a pack-fn's generic return `$R` from a closure-typed
prefix param's lowered return type.

- collectGenericNames descends into closure_type_expr (params + return),
  so `$R` in `Closure(..) -> $R` registers as a function type-param.
- matchTypeParam/extractTypeParam descend into closures: `$R` is extracted
  from the lowered mapper's closure `.ret`.
- lowerPackFnCall infers type-param bindings from the lowered prefix args,
  folds them into the mangle, and threads them into monomorphizePackFn,
  which installs self.type_bindings for return-type resolution + body
  lowering (`-> VL($R)` ⇒ VL(s64); `Combined($R, ..)` ⇒ Combined(s64, ..)).

s64-elimination follow-through:

- An unbound generic `$R` resolves to `.unresolved` in resolveTypeWithBindings
  rather than fabricating an empty-struct stub (`R{}`).
- Lambda return-type inference skips an `.unresolved` target-closure ret and
  infers from the body, so the concrete return drives `$R`.
- The `.unresolved` codegen tripwire then caught a latent bug: a generic-struct
  source impl (`impl VL($R) for Combined($R, ..$Ts)`) was declaring its template
  method `Combined.get` (`-> $R`) as a standalone IR function. Fixed: a
  generic-struct source registers methods as TEMPLATES only (findable in
  fn_ast_map for per-instance monomorphization via createProtocolThunk), never
  declareFunction'd.

Feature 1 (heterogeneous variadic packs) all six phases complete.
248 examples + all unit tests green.
2026-05-30 03:46:46 +03:00
agra
1f6e27d8f2 lang F1 6: generic-struct -> parameterized-protocol erasure (issue 0054 FIXED)
Two fixes, root-caused from xx Combined -> VL(s64) trapping:
- instantiateGenericStruct binds the template name to the concrete instance
  (tb.put(tmpl.name, id)), so an impl method self: *Combined resolves self.field
  to the instance (Combined__s64_s64), not the 0-field generic stub. This was a
  general pre-existing bug: self.x on ANY generic-struct impl method failed.
- createProtocolThunk monomorphizes the template method for a generic-struct
  instance (Combined.get -> Combined__s64_s64.get with the instance bindings),
  so the erasure vtable dispatches instead of hitting an unreachable thunk.

xx c on a generic Combined now dispatches correctly (examples/212 -> 99).
247 examples + unit green.
2026-05-30 03:25:04 +03:00
agra
66c4ee168b lang F1 6: contextually type pack-fn prefix args (mapper lambda)
lowerPackFnCall lowered the runtime prefix args with no target_type, so a
lambda arg (mapper: Closure(...) -> ...) could not infer its param types.
Now set target_type to the param type while lowering each prefix arg. With
the existing value-projection call-arg spread, mapper(..sources.get) works:
the lambda is contextually typed and the projected values spread into the
call. examples/211 ((a,b)=>a+b over two sources -> 42). 246 + unit green.
2026-05-30 03:15:07 +03:00
agra
87ee3d3e65 lang F1 6: (..sources) materializes a pack into a protocol-typed tuple field
lowerTupleLiteral now coerces/erases each spliced spread element to the
contextual target tuple field type (computed even when a spread is present,
indexed by output position). New coerceOrErase: protocol target -> xx-erase
via buildProtocolErasure, else coerceToType. So c.sources = (..sources) on a
(..VL(Ts)) field erases each concrete pack element to its VL(Ti) slot.

examples/210 (build(IntCell, StrCell) -> 10 hi). 245 examples + unit green.
2026-05-30 03:11:55 +03:00
agra
e395a08331 lang F1 6: pack-spread in parameterized-type args (Combined($R, ..sources.T))
Parser now accepts a `..` spread in a parameterized-type arg list; in
instantiateGenericStruct a spread arg bound to the variadic type-param expands
via packTypeElems (so `..sources.T` projects each source pack element protocol
type-arg into ..$Ts). `Combined(s64, ..sources.T)` for a VL(s64) source
instantiates Combined(s64, s64). examples/209 (with explicit per-element xx
erase). 244 examples + unit green.

Next: (..sources) whole-pack materialization with per-element erasure into the
protocol-typed field (c.sources = (..sources) currently segfaults).
2026-05-30 03:06:03 +03:00
agra
39d77ff886 lang: tuple element assignment + named-tuple field names
Two fixes:
- Element assignment `t.0 = v` (the known Phase-4.2 gap): the lvalue path
  looked the element up by NAME via getStructFields, never matched a tuple
  (positional), and left field_ty .unresolved -> ptr(.unresolved) -> codegen
  panic. Added a tuple branch to the field-assignment lowering that indexes by
  position (numeric) or name (tup.names), mirroring the read path. Fixes
  `c.sources.0 = v` on a generic-instance pack field too.
- Named tuples: the parser dropped captured field names for a tuple TYPE
  `(x: T, y: U)` (passed field_names=null), and resolveTupleTypeWithBindings
  also nulled them. Both now preserve names (synthesizing _<i> for any unnamed
  slot), so `t.x` reads/writes by name and `.0` by position.

examples/208. 243 examples + unit green.
2026-05-30 03:00:58 +03:00