Commit Graph

389 Commits

Author SHA1 Message Date
agra
54e404bca1 ffi any_to_string optional dispatch — expected-failing lock-in
`examples/178-any-to-string-optional.sx` prints a struct whose
three fields are `?s64` / `?string` / `?bool`, in both Some and
None form. The struct-print path goes through `field_value(s, i)
-> Any` and then `any_to_string(Any)`. Today: `any_to_string`
has no `case optional:` arm and `resolveTypeCategoryTags` has no
"optional" category — every optional field falls through to the
`<?>` default. Expected output captures the working post-fix
form (`a: 42`, `b: hi`, `c: true` for Some; `null` across the
board for None).

The next commit adds `optional_to_string` + `case optional:` to
std and "optional" to `resolveTypeCategoryTags`. Variadic
auto-unwrap (`packVariadicCallArgs`) keeps printing direct
`print(opt)` calls correctly today; this fix closes the gap for
struct fields, slice elements, and anywhere else an optional
flows through Any.
2026-05-28 07:50:59 +03:00
agra
432da229a7 ffi: fill inferExprType + inferGenericReturnType silent-default holes
Three general fixes to AST-level type inference that previously fell
through to `.s64`:

- `inferGenericReturnType` resolved the function's return type only
  when `tmp_bindings` was non-empty; otherwise it bailed to `.s64`,
  which silently mis-typed pack-fns with non-generic literal return
  types (e.g. `walk(..$args) -> string`). Always resolve via
  `resolveTypeWithBindings`, even with empty bindings.
- `inferExprType` `binary_op` arm: `.in_op` now returns `.bool`
  alongside the other comparison/logical ops. Previously the `else`
  branch returned the LHS type (e.g. `2 in (1,2,3)` → `s64`).
- `inferExprType` field-access call arm: when a namespace-qualified
  call (`pkg.hello()`) hasn't been lowered yet, consult `fn_ast_map`
  for the qualified name AND the bare field name (matches
  `lowerCall`'s effective-name resolution order). Without this,
  cross-module calls returned `.s64`.

Surfaces during the still-deferred print/format → `..$args`
migration where the pack mono's per-position type tag depends on
correct call-arg type inference. The fixes themselves are general
improvements that stand independently. 217/217.
2026-05-28 07:38:09 +03:00
agra
abfd30c44d ffi: drop legacy-variadic-form migration diagnostic
The special-case `return self.fail("legacy variadic syntax ...")`
in `parseParams` is gone. `parseTypeExpr` already errors naturally
on a leading `..` (now reported as "expected type name"), which
is enough — the language-level cutover happened in the previous
commit; no need for the parser to keep a migration breadcrumb.
2026-05-27 22:03:24 +03:00
agra
458868e2e3 ffi checkpoint: step 5 done — generic Into(Block) impl + 3 unblocking bug fixes 2026-05-27 22:01:09 +03:00
agra
2eaf932fcf ffi M5.A.next.5.3: delete hand-rolled __block_invoke trampolines
Removes `__block_invoke_void` / `__block_invoke_bool` and their
companion `Into(Block)` impls from
`library/modules/std/objc_block.sx`. The generic
`Into(Block) for Closure(..$args) -> $R` impl from step 5.2 now
covers both shapes (and every other closure shape) via per-mono
`#insert build_block_convert($args, $R)` source emission.

Net stdlib shrinkage: ~52 lines, two trampolines + two per-shape
impls down to zero. Adding a new block-shape consumer no longer
requires touching stdlib — the impl emits per-call-shape on
demand.

`examples/95-objc-block-noop.sx` (zero-arg closure) and
`examples/96-objc-block-multi-arg.sx` (user-declared per-shape
impl for `Closure(s32, *void) -> void`) still pass: 95 routes
through the new generic, 96 keeps its in-file impl as a
documentation example of the user-declares-their-own path.
Suite at 217/217.
2026-05-27 21:59:57 +03:00
agra
165b621ab3 ffi M5.A.next.5.2.B: generic Into(Block) impl — make-green
Adds the generic `impl Into(Block) for Closure(..$args) -> $R`
in `library/modules/std/objc_block.sx` alongside the existing
hand-rolled `Closure() -> void` and `Closure(bool) -> void`
impls. The convert body is a single
`#insert build_block_convert($args, $R);` — per-call-shape
monomorphisation re-runs the builder so each closure shape gets
its dedicated nested `callconv(.c)` trampoline + Block literal.

The impl-mono path threads pack types through
`pack_bindings[args]` and the single-type return through
`type_bindings[R]`. Both need to be visible to the body's
`$args` / `$R` expression-position references — the existing
lowering only consulted `pack_arg_types` (set by pack-fn mono,
not by tryPackImplMatch). Two small extensions:

- `lowerExpr`'s `.comptime_pack_ref` arm now consults
  `pack_arg_types` → `pack_bindings` → `type_bindings` in order,
  treating a `type_bindings` hit as a single `const_type(T)`
  value rather than the slice form.
- `resolveTypeArg` grows a `.comptime_pack_ref` arm that maps
  the same name through `type_bindings` so type-arg positions
  (e.g. inside `type_name(...)` in the builder body) resolve
  the bound single Type.
- `type_bridge.isTypeShapedAstNode` lists `comptime_pack_ref`
  and `pack_index_type_expr` as type-shaped so
  `buildTypeBindings`'s strategy-1 explicit-arg path picks
  them up when calling a `$T: Type`-generic fn.

`examples/177-generic-into-block.sx` flips green: a
`Closure(s64, s64) -> void` (no hand-rolled impl) is converted
through the generic impl, its block invoked via a typed
`callconv(.c)` fn-pointer, and the closure's side effects land
in the host globals. Hand-rolled impls remain for `()` and
`(bool)` shapes; 5.3 deletes those once a focused test covers
their behaviour through the generic path. Suite at 217/217.
2026-05-27 21:58:33 +03:00
agra
f5342e9fcc ffi M5.A.next.5.2.A: generic Into(Block) impl — xfail lock-in
`examples/177-generic-into-block.sx` exercises a closure shape
(`Closure(s64, s64) -> void`) that stdlib's hand-rolled
`Into(Block)` impls don't cover. Today: the focused diagnostic
"no `Into(Block) for cl_s64_s64__void` impl — add a
per-signature `__block_invoke_<sig>` trampoline + Into impl
alongside the existing ones in modules/std/objc_block.sx, or
declare it in your own code" fires at the `xx cl : Block` site.

The next commit adds the generic
`impl Into(Block) for Closure(..$args) -> $R` to
`library/modules/std/objc_block.sx` (wiring `#insert
build_block_convert($args, $R)` from step 5.1.B) plus the
lowering plumbing needed to make pack + single-type `$` refs
work inside the impl's monomorphisation. The test then flips
green — the per-shape trampoline emitted by build_block_convert
ferries (10, 20) through to the sx closure and the side-effect
stores land in g_a / g_b.
2026-05-27 21:57:47 +03:00
agra
aeb950b86f ffi M5.A.next.5.1.B: build_block_convert added to stdlib — make-green
`build_block_convert(args: []Type, $ret: Type) -> string` emits
the convert-body source for the generic `Into(Block) for
Closure(..$args) -> $R` impl (step 5.2):

  1. A nested `__invoke :: (block_self: *Block, arg0: T0, ...) ->
     R callconv(.c) { ... }` trampoline matching the per-shape
     Apple Block ABI.
  2. A `return Block.{ ... };` literal whose `invoke` slot points
     at the nested trampoline via `xx @__invoke`.

Void-returning shapes emit `typed_fn(block_self.sx_env, args...)`;
non-void emits `return typed_fn(...)`. Per-position arg names
follow `arg0`, `arg1`, ... in declaration order; the typed-fn
cast reconstructs the closure's call signature so the trampoline
hands control back to `sx_fn` with the right argument layout.

`examples/176-build-block-convert.sx` flips green (216/216).
2026-05-27 21:48:45 +03:00
agra
3bd6f26c96 ffi M5.A.next.5.1.A: build_block_convert — expected-failing lock-in
Step 5.1.A of the FFI plan (variadic heterogeneous type packs →
generic `Into(Block)` impl). The eventual step-5.2 impl body will
read `#insert build_block_convert($args, $R);` to emit a per-shape
`__invoke` `callconv(.c)` trampoline + Block literal. 5.1.A pins
the builder's expected output verbatim across three void-returning
pack shapes (0, 1, 2 args) plus one non-void shape (`f64 -> s32`)
that exercises the `return typed_fn(...)` branch.

Today: 4× "unresolved 'build_block_convert'" diagnostics — the
builder isn't in stdlib yet. The next commit adds it to
`library/modules/std/objc_block.sx` and the test flips green.

The per-position type names in the emitted source come from
`type_name(args[i])`; the slice itself is `[]Type` flowing through
the new-form variadic + bare-`$args` path that the recent
issues-0048/0049/0050 fixes unblocked.
2026-05-27 21:48:10 +03:00
agra
5316bf76e1 ffi issue-0050: monomorphizeFunction isolates pack-fn mono state
Adds the same save+null+defer-restore block at the top of
`monomorphizeFunction` that landed in `lazyLowerFunction` for
issue-0048. The outer pack-fn's `pack_arg_nodes` /
`pack_param_count` / `pack_arg_types` / `inline_return_target`
are now suppressed for the duration of the generic mono's body
lowering and restored on exit.

`examples/175-generic-fn-pack-state-leak.sx` flips green
(len=0/1/2/4 across the four pack shapes); suite stays at
215/215.
2026-05-27 21:45:15 +03:00
agra
ec2a99a1a3 ffi issue-0050: monomorphizeFunction leaks pack-fn state — xfail lock-in
A generic fn (with `$T: Type` type params) called from inside a
pack-fn mono inherits the outer pack maps during its OWN body
lowering. Same root cause as issue-0048 — the lowering helper
doesn't save/null `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` — but on the generic-mono path
(`monomorphizeFunction`, ~line 8718) rather than
`lazyLowerFunction`.

`examples/175-generic-fn-pack-state-leak.sx` calls
`build(args: []Type, $ret: Type)` from a four-shape pack-fn. The
expected output is `len=0 / 1 / 2 / 4`; today's run reports
`len=0` for every shape because `build__void` was first
monomorphised under `probe()`'s mono (N=0) and `args.len` got
constant-folded to 0 inside the cached body. The next commit
adds the same isolation pattern to `monomorphizeFunction`.

Step 5 of the FFI plan (generic `Into(Block)` impl) needs the
`build_block_convert(args: []Type, $ret: Type) -> string` builder,
which trips this leak directly.
2026-05-27 21:44:39 +03:00
agra
952dc0e161 ffi: drop legacy name: ..T variadic syntax
Parser hard-rejects the legacy `name: ..T` form with a one-line
migration message pointing at the new `..name: []T` shape. The
leading-`..` form is the one the lowering paths
(`resolveParamType` / `packVariadicCallArgs`) treat as canonical
post-issue-0049; leaving both forms accepted invited the same
class of cross-module emit crashes any time a `..T`-form decl in
stdlib crossed an import boundary.

`specs.md` updated alongside: the Variadic Functions section now
documents `..name: []T` as the surface form, with notes on
homogeneous vs `[]Any` boxing and the `..` spread at call sites.
Inline references to `args: ..Any` in §7 and §8 refreshed.
2026-05-27 21:32:45 +03:00
agra
5b3d86440b ffi: migrate remaining variadic decls to new ..name: []T form
Stdlib:
- `format` / `print` in std.sx — both move from `args: ..Any` to
  `..args: []Any`. The post-issue-0049 lowering makes this safe
  across module boundaries.
- `open` in fs.sx — `args: ..s32` → `..args: []s32`. Foreign
  C-variadic semantics are preserved (the trailing `, ...` lands
  in the generated `declare` regardless of which surface form is
  used).

Examples:
- `19-varargs.sx` — `sum` / `print_all` migrated.
- `20-any-varargs.sx` — `print_any` / `count` migrated.
- `50-smoke.sx` — `typed_sum` migrated.
- `120-interp-variadic-any.sx` — comment-only update referencing
  the new form.
- `ffi-foreign-cvariadic.sx` — three C-variadic foreign decls
  migrated; header comment refreshed.

Suite stays at 214/214. The legacy `name: ..T` surface form is
still accepted by the parser; rejection follows in a later commit
once specs.md catches up.
2026-05-27 21:30:48 +03:00
agra
b5301c4228 ffi issue-0049: resolveParamType + packVariadicCallArgs unwrap new-form slice
Both helpers now detect when a variadic param's declared type is
already a slice (`..name: []T`) and use it as the element-shape
container directly, instead of wrapping it once more. The legacy
form (`name: ..T`) still wraps as before. Without the unwrap, the
new-form `..parts: []string` ends up with a callee-side slot type
of `[]([]string)`, while the call-site marshal pack emits a
`[N x string]` array, and downstream LLVM emission crashes on
the resulting null Refs (`LLVMBuildExtractValue` inside
`emitStrCmp`).

`examples/121-ios-sim-bundle.sx` (which exercises stdlib's
migrated `path_join`) and the focused regression
`examples/174-new-form-variadic-cross-module.sx` both flip green;
suite stays at 214/214. The remaining stdlib decls (`format` /
`print` / `open`) and example fixtures land in the follow-up
migration commit.
2026-05-27 21:29:53 +03:00
agra
64dcbca06a ffi issue-0049: new-form variadic cross-module LLVM crash — xfail lock-in
Migrating stdlib's `path_join` to the new variadic syntax
(`(..parts: []string) -> string`) surfaces a latent compiler bug:
`resolveParamType` and `packVariadicCallArgs` treat the new-form
declaration the same as the legacy `parts: ..string` and wrap the
element type in `sliceOf` regardless of whether it already is one.
The new form's `[]string` becomes `[][]string`; the call-site
marshal pack emits `[N x string]` (correct) but the callee stores
its slice param into a `[]([]string)`-typed slot. The shape
mismatch propagates as null/undef Refs that crash
`LLVMBuildExtractValue` inside `emitStrCmp` during emission.

`examples/121-ios-sim-bundle.sx` (existing) and the new focused
`examples/174-new-form-variadic-cross-module.sx` both fail today
with the segfault. The next commit fixes `resolveParamType` +
`packVariadicCallArgs` so both flip green. Stdlib's `format` /
`print` / `open` and the example fixtures stay on the legacy form
in this commit — they migrate in the follow-up cleanup commit.
2026-05-27 21:29:08 +03:00
agra
0ede0973f4 ffi issue-0048: lazyLowerFunction isolates pack-fn mono state
`lazyLowerFunction` now saves and nulls `pack_arg_nodes`,
`pack_param_count`, `pack_arg_types`, and `inline_return_target`
before lowering the callee's body, then restores them via defer.

Same shape as the save/restore already in `createComptimeFunction`
(issue-0046 fix). Without this, a lazily lowered regular fn called
from inside a pack-fn mono inherited the outer pack maps, and the
`<pack_name>.len` intercept in `lowerFieldAccess` constant-folded
the callee's same-named param to the outer mono's arity.

`examples/173-pack-bare-args-cross-call.sx` now passes; previously-
green tests untouched. 213/213.
2026-05-27 21:09:56 +03:00
agra
8fcf352de8 ffi issue-0048: bare $args slice loses .len across call — xfail lock-in
Bare `$args` evaluated inside a pack-fn body has the right `.len` /
per-element types inline, but the moment the same slice is passed
as an argument to another function, the callee silently reads
length 0 and every element comes back as undef.

Cause (per issue file): `lazyLowerFunction` saves/restores builder
state but not `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` / `inline_return_target`. When a regular fn like
`describe(args: []Any)` is lazily lowered from inside a pack-fn
mono, the outer pack maps are still active; `lowerFieldAccess`'s
`<pack_name>.len` intercept fires on `describe`'s same-named param
and bakes the outer mono's arity as a constant into describe's IR.
Every subsequent shape's call to describe returns that constant.

`examples/173-pack-bare-args-cross-call.sx` exercises four shapes
(0, 1, 3, 5 elements) through the same `describe(args: []Any)`
walker. The expected output holds the per-position type names
(`[s64]`, `[s64, string, bool]`, etc); today's diff fails — the
walker reads `args.len = 0` for every shape and returns `[]`. The
next commit fixes `lazyLowerFunction`.
2026-05-27 21:09:25 +03:00
agra
1add93f083 ffi checkpoint: step 4A done — bare $args + dynamic reflection
Logs the five 4A.bare slices (c7926422162662):

- 4A.bare.1.A: parser-rejection lock-in for bare $args.
- 4A.bare.1.B: parser/AST/lowering — bare $args evaluates to
  a comptime []Type slice via new buildPackSliceValue helper +
  ComptimePackRef AST node.
- 4A.bare.4.A: silent-.s64 lock-in for dynamic type_name(list[i]).
- 4A.bare.4.B: tryLowerReflectionCall splits on isStaticTypeArg;
  dynamic args emit callBuiltin(.type_name, ...) for the
  interp's runtime arm.
- 4A.bare.5: end-to-end smoke (examples/172-pack-builder-smoke.sx).

Step 5 (generic Into(Block) impl in stdlib) is now fully
unblocked on the type-system side. Test count 212/212.

Remaining within step 4:
- 4B compile_error intrinsic.
- type_eq / has_impl dynamic-arg dispatch (same isStaticTypeArg
  split that type_name got).
- has_impl interp-time protocol-map snapshot.
2026-05-27 19:22:08 +03:00
agra
21626628cd ffi M5.A.next.4A.bare.5: end-to-end smoke for bare $args + dynamic reflection
Step 4A final-slice's smoke test. Exercises the FULL surface
step 5's generic Into(Block) impl needs to operate:

  1. A pack-fn binds $args (whole pack as []Type).
  2. The body walks `list := $args` at INTERP time.
  3. Per position, calls `type_name(list[i])` — the dynamic
     form that emits `callBuiltin(.type_name, ...)` at lower
     time, dispatched at interp time to read the runtime
     Value.type_tag and return the concrete type name.

`examples/172-pack-builder-smoke.sx` exercises four call
shapes via #run:
  describe()                      → []
  describe(42)                    → [s64]
  describe(42, "hi")              → [s64, string]
  describe(true, 3.14, "x", 99)   → [bool, f64, string, s64]

Each call shape builds its own [N x Any] slice of .type_tag
values at lowering time, the interp walks the slice, and the
per-element type names come out kind-honestly.

212/212 example tests + zig build test green.
2026-05-27 19:20:54 +03:00
agra
d99c0fdb2b ffi M5.A.next.4A.bare.4.B: tryLowerReflectionCall splits static vs dynamic
Fix for the silent .s64 fall-through in `type_name(<dynamic-arg>)`.
`tryLowerReflectionCall` now splits on `isStaticTypeArg(node)`:
- Static (type_expr / identifier / pack_index_type_expr / pointer
  / array / slice / optional / many_pointer / function_type_expr
  / tuple_literal / call) → fold to const_string at lower time
  (today's fast path).
- Dynamic (index_expr, field_access, runtime locals, anything
  else) → emit `callBuiltin(.type_name, [arg_ref])`. The interp's
  arm (commit 9600ba5) reads the runtime `.type_tag` Value and
  returns the per-position name.

`isStaticTypeArg(node)` is a new helper mirroring the explicit
arms of `resolveTypeArg`. Lives alongside resolveTypeArg in
lower.zig; documented to track shape changes together.

emit_llvm: the comptime reflection builtins (`type_name`,
`type_eq`, `has_impl`) now emit a silent undef-i64 placeholder.
Same reasoning as 4A.bare.1.B's relaxation of const_type's
emit_llvm arm: the JIT compiles the containing fn module-wide
even if main never calls it, so emit-time noise here is just
dead-from-main's-perspective code. Real misuse — passing a non-
Type value to one of these — is caught by the interp arm's
`asTypeId orelse bailDetail`.

`examples/171-pack-dynamic-type-name.sx` flips from "s64s64"
(silent .s64 fold per element) to "s64string" (per-position
correct via interp arm). Test runs `walk(42, "hi")` at `#run`
time so the dynamic path executes in the interp.

211/211 example tests + zig build test green.
2026-05-27 19:19:32 +03:00
agra
95e61d8a86 ffi M5.A.next.4A.bare.4.A: dynamic type_name(args[i]) — expected-failing lock-in
Step 4A final follow-up's lock-in. `type_name(<arg>)` where
<arg> is NOT a statically resolvable type expression (e.g.
`list[i]` indexing into a `$args`-derived `[]Type` slice)
silently folds to "s64" today because `resolveTypeArg`'s
index_expr fall-through returns `.s64` (the catch-all `else =>
.s64` at the bottom of the switch).

This is exactly the kind of silent unimplemented arm the
project's REJECTED PATTERNS section forbids — the user gets
"s64" for every element of an arbitrary pack, not the per-
position concrete type they expect.

`examples/171-pack-dynamic-type-name.sx` exercises a builder-
shaped fn: walks `$args` via runtime indexing, calls
`type_name(list[i])` per position, concatenates the results.
For `walk(42, "hi")` the expected output is "s64string".
Today's output is "s64s64" — the silent fold strikes twice.

Cadence shape 2: expected output is the WORKING shape; today's
diff fails. Next commit teaches `tryLowerReflectionCall` to
detect "arg not statically resolvable" and emit a builtin_call
to `.type_name` so the interp's runtime arm (wired in commit
9600ba5, M5.A.next.4.1) handles the dynamic case.

210/210 + 1 expected-failing = 211 total. zig build test green.
2026-05-27 19:16:19 +03:00
agra
5a4a19b3ab ffi M5.A.next.4A.bare.1.B: bare $args lowers to []Type slice value
Step 4A final-slice fix. Bare `$<pack_name>` (no `[<int>]`)
in expression position now parses + lowers to a comptime
`[]Type` slice value carrying one `const_type(TypeId)` per
pack element.

Plumbing:

- src/ast.zig: new `ComptimePackRef { pack_name }` node +
  `comptime_pack_ref` variant in Data.
- src/parser.zig: `parsePrimary`'s `$` arm makes `[` optional
  after the pack name. With `[<int>]` → existing
  `pack_index_type_expr` (single Type value). Without → new
  `comptime_pack_ref` (whole pack as []Type).
- src/sema.zig: adds the no-op switch arms for the new node
  in `analyzeNode` and `findNodeAtOffset`.
- src/ir/lower.zig: `lowerExpr` arm reads `pack_arg_types[name]`
  and calls `buildPackSliceValue(arg_tys)`. The helper allocas
  a `[N x Any]` array, emits one `const_type(arg_tys[i])` per
  slot, then a slice `{data_ptr, len}` aggregate. No active
  binding → focused diagnostic + null slice placeholder. The
  IR slice element type is `Any` (matches the today's
  `Type → .any` mapping in type_bridge); the interp stores
  raw `.type_tag` Values directly (NOT Any-boxed) so
  `args[i]` at interp time reads a Type value.
- src/ir/emit_llvm.zig: relaxed `const_type` to silently emit
  undef-i64 instead of the previous stderr-noisy bail. Storage
  of Type values in runtime aggregates is harmless (undef in,
  undef out). Use-site misuse is caught by the bails on
  type_name/type_eq/has_impl and the bitcast guard.

`examples/170-pack-bare-value.sx` flips from the parse-error
lock-in to "0/1/3/4" — four call shapes of `len_of(..$args) ->
s64 { list := $args; return list.len; }`. The slice's `.len`
field carries the per-mono pack arity.

210/210 example tests + `zig build test` green.

The remaining 4A.bare slices (4 and 5) — resolveTypeArg
silent-arm fix for index_expr + smoke test of a real builder
walking $args — are separate commits per the cadence rule.
2026-05-27 19:10:37 +03:00
agra
c792642d76 ffi M5.A.next.4A.bare.1.A: bare $args — expected-failing test
Step 4A final slice's lock-in. `$args` (whole pack) as a bare
expression should evaluate to a comptime `[]Type` slice value
— the whole pack passed through as data so builder fns can
walk it.

Today's parser arm (commit fd03b58, M5.A.next.4.3) requires
the `[<int_literal>]` form: bare `$<pack_name>` hits the
focused "expected '[' after '$<pack_name>'" diagnostic I added
when wiring the indexed access.

`examples/170-pack-bare-value.sx` exercises four call shapes
of a pack-fn whose body binds `list := $args` then returns
`list.len`. Expected output (post-fix) is "0/1/3/4" per call.
Today the parser rejection diff makes the test fail —
209/209 + 1 expected-failing = 210 total.

Cadence shape 2: expected output is the WORKING shape; pre-fix
the parser-error diff fails. Next commit lands the parser
extension + AST node + lowering and the test flips green.
2026-05-27 19:05:16 +03:00
agra
12b4824a0d ffi checkpoint: step 4 partial — .type_tag activated by the book
Logs the four 4.x slices (ac60d98fd03b58):

- 4.0 foundation: const_type opcode, asTypeId helper, cmp_eq
  arm, Zig unit tests.
- 4.1 reflection arms: type_name/type_eq interp implementations
  reading .type_tag values; has_impl bails (snapshot work pending).
- 4.2 audit + bitcast guard: box_any/unbox_any layout confirmed
  correct, bitcast guards against .type_tag mis-coercion.
- 4.3 source construction: parser accepts $args[$i] in
  expression position; lowering emits const_type with the
  bound TypeId; resolveTypeArg + tryConstBoolCondition fold
  through pack_arg_types.

End-to-end working: type_name($args[0]), inline-if type_eq
dispatch over $args[0] per-mono. Test count 209/209.

Remaining within step 4:
- 4B compile_error(fmt, args) intrinsic.
- 4A bare $args (whole pack as []Type) — step 5 needs this.
- has_impl interp-time wiring.

Step 5 (generic Into(Block) impl) needs only bare-$args from
the remaining list to be fully unblocked.
2026-05-27 18:54:13 +03:00
agra
fd03b5812f ffi M5.A.next.4.3: $args[$i] in expression position — source construction
Final slice of the .type_tag activation. Sx code can now
construct Type values through the `$<pack>[<int_literal>]`
syntax in expression position. Lowering emits the new
`const_type(TypeId)` opcode; the interp materialises
`Value.type_tag(TypeId)`; reflection intrinsics + cmp_eq
read it kind-honestly.

Plumbing:

- src/parser.zig: `parsePrimary` accepts `$<ident>[<int_literal>]`
  at the front of every expression. Emits a `pack_index_type_expr`
  AST node — same node already used in TYPE positions in step 3,
  now extended to expression positions.

- src/ir/lower.zig: two places teach the new node.
  - `lowerExpr` arm: looks up `pack_arg_types[name][index]`, emits
    `builder.constType(arg_tys[index])`. OOB / no-binding paths
    emit a focused diagnostic + a `constType(.void)` placeholder
    (loud failure preserves silent-error budget).
  - `resolveTypeArg` arm: the same lookup, but returns the
    TypeId directly. Used by the lower-time fast paths in
    `tryLowerReflectionCall` + `tryConstBoolCondition` so
    `type_name($args[0])`, `type_eq($args[0], s64)`, and
    `has_impl(...)` all see the bound TypeId rather than
    falling through to the `.s64` default that the silent-arm
    rule forbids.

The two arms ensure both runtime AND compile-time paths use
the same source-of-truth (`pack_arg_types`), so per-mono
dispatch via `inline if type_eq($args[0], s64) { ... }` folds
at compile time as expected.

`examples/169-pack-value-dispatch.sx` exercises both shapes:
- `type_name($args[0])` returns the per-mono concrete type
  name ("s64", "string", "f64").
- `inline if type_eq($args[0], s64) { ... }` ladder dispatches
  per-mono ("got s64", "got string", "got bool", "got other").

209/209 example tests + `zig build test` green.

What's now possible end-to-end:

  show :: (..$args) -> string => type_name($args[0]);
  show(42)    // "s64"
  show("hi")  // "string"

  describe :: (..$args) -> string {
      inline if type_eq($args[0], s64) { return "got s64"; }
      ...
  }

The "by the book" activation is complete:
- foundation (const_type opcode, interp variant, helpers) — 4.0
- interp reflection arms (type_name / type_eq / has_impl) — 4.1
- box_any/display audit + bitcast guard — 4.2
- source-language construction via $args[$i] — 4.3

Step 5 (generic Into(Block) impl in stdlib) is now fully
unblocked — its trampoline body can interpolate per-mono types
both in type positions AND in expression positions.
2026-05-27 18:52:41 +03:00
agra
55c72af68a ffi M5.A.next.4.2: audit box_any/unbox_any/display, guard bitcast
Step 6 + 7 of the .type_tag activation plan. Audit pass on the
Any-boxing and value-display paths to confirm `.type_tag`
flows cleanly OR fails loudly.

Audit findings:

- `box_any` (interp.zig:1168) stores fields[0] as `.int(TypeId)`
  for the Any-tag, fields[1] as the raw operand Value. A
  `.type_tag` operand becomes the value field — correct.
  Tag-field stays int-shaped across all Any boxes; value
  field can be any Value kind including type_tag.

- `unbox_any` (interp.zig:1176) returns fields[1] as-is —
  preserves whatever was stored. Correct for `.type_tag`.

- `any_to_string` (std.sx:316) has a `case type:` arm:
    case type: { s : string = xx val; result = s; }
  KNOWN GAP. Pre-`.type_tag`, the Any's value field was
  string-shaped (lower-time type_name folding to const_string).
  Now the value field will be `.type_tag(TypeId)`. The
  `xx val to string` cast becomes a shape mismatch. Deferred
  until source construction wires a path that surfaces this —
  the loud bitcast guard below catches the silent-fall-through
  case.

New guard:

- `bitcast` interp arm (interp.zig:664) now explicitly bails
  when source is `.type_tag` and target is anything OTHER than
  `.any` (boxing into Any) or the identity Type. Catches the
  case-type-arm scenario above + any other stale "xx val to
  string" path that would silently misinterpret a Type value.
  Diagnostic suggests using `type_name(val)` as the
  replacement.

No code changes in box_any / unbox_any (already correct).
208/208 example tests + `zig build test` green. No `.type_tag`
constructions exercised yet — the guards are dormant infrastructure
ready for when source construction surfaces them.
2026-05-27 18:47:32 +03:00
agra
9600ba5cdc ffi M5.A.next.4.1: interp arms for reflection builtins on .type_tag
Second slice of the .type_tag activation. The reflection
intrinsics (`type_name`, `type_eq`, `has_impl`) now have
interp-time implementations that read `.type_tag` Values
directly. Today's lower-time fast path (folding to
`const_string`/`const_bool` when the type arg is statically
resolvable) stays — these interp arms are the fallback path
for when lowering emits a real `builtin_call` because the
arg is interp-time-only (e.g. `args[i]` inside a builder body
where the pack element is bound at interp execution).

Plumbing:
- New BuiltinId entries: `type_name`, `type_eq`, `has_impl`.
- Interp arms in `execBuiltinInner`:
  - `type_name(t)`: reads `.type_tag` via `asTypeId`, looks up
    via `module.types.typeName`, dupes the slice into the
    interp allocator, returns `.string`. Non-`.type_tag` arg
    → `bailDetail` ("argument is not a Type value").
  - `type_eq(a, b)`: both args must be `.type_tag`; compares
    TypeIds. Either side missing → `bailDetail`.
  - `has_impl(P, T)`: bails with a "not yet wired" message —
    interp-time has_impl needs a queryable snapshot of the
    host's `protocol_thunk_map` + `param_impl_map`, which is
    its own follow-up slice. Static-arg has_impl still works
    via the lower-time `tryConstBoolCondition` fast path.
- emit_llvm: explicit arms for the three new builtins that
  log + map to undef-i64 (Type values are comptime-only; if
  one of these reaches LLVM emit, lowering produced wrong
  IR — the LLVM verifier downstream surfaces the offending
  site).

Three new Zig unit tests in interp.test.zig:
- `type_name builtin on type_tag` — emits a `builtin_call`
  to `type_name` with a `const_type(s64)` operand, asserts
  the result is the string "s64".
- `type_eq builtin on type_tag values` — two equal Type
  operands compare equal.
- (Pre-existing) `const_type yields type_tag` + `type_tag
  comparison` from 4.0 still pass.

208/208 example tests + `zig build test` green. No source-
language path constructs `.type_tag` yet — the foundation is
ready for the `$args`-in-expression-position slice that
turns it on for users.
2026-05-27 18:43:10 +03:00
agra
ac60d98f0e ffi M5.A.next.4.0: activate Value.type_tag — opcode + helper + cmp
Wires the dormant `Value.type_tag(TypeId)` variant in interp.zig
so Type values flow through the comptime interpreter as
first-class kind-distinguished entities. No source-language
construction path yet — that's a follow-up. This commit is the
infrastructure foundation.

Audit findings (from interp.zig switch-walk):
- Every `else =>` arm over Value is either already loud
  (`bailDetail` / `error.TypeError`) or a pass-through helper
  (`materializeCtxArg`, `materializeForCall`, `resolveSlotChain`)
  where transit-unchanged is semantically correct for type_tag.
  No new silent paths introduced by activating the variant.
- The three pre-existing `.type_tag => return bailDetail(...)`
  arms (store-at-raw-ptr, deref-non-pointer, unbox-non-aggregate)
  already cover the disallowed paths cleanly.

New plumbing:
- `Op.const_type: TypeId` — dedicated opcode. Never piggybacks
  on `const_int`. Result IR-type is `.any` to signal "untyped
  at runtime" so downstream coercions fail loudly.
- `Builder.constType(tid)` constructor.
- Interp arm emits `Value{ .type_tag = tid }` for the op.
- emit_llvm arm bails loudly + emits an undef-i64 placeholder
  (Type is comptime-only — if a Type ever reached LLVM emit,
  some upstream builder leaked through; the diagnostic + LLVM
  verifier downstream surface the offending site).
- `print.zig` arm prints `const type(<typeName>)`.
- `Value.asTypeId() ?TypeId` helper — the kind-honest accessor
  for Type values. asInt/asFloat/asBool/asString continue to
  return null for `.type_tag` (no silent coercion).
- `evalCmp` arm for `.type_tag, .type_tag` — TypeId equality.
  Mixed `.type_tag` vs `.int` deliberately falls through to
  the typeErrorDetail bail (a Type is not an int).

Tests (src/ir/interp.test.zig):
- `const_type yields type_tag` — confirms the variant is
  produced and that asTypeId/asInt distinguish correctly.
- `type_tag comparison` — exercises cmp_eq on equal and
  unequal pairs, asserts the right bool comes back.

208/208 example tests + `zig build test` green. No user-visible
behaviour change yet — `.type_tag` is constructible from Zig-
side IR builders but no sx-level syntax produces it. Next slice
wires `$args` lowering (or `$args[i]` in expression position)
to emit `const_type` per pack element.
2026-05-27 18:30:17 +03:00
agra
8990edbec8 ffi checkpoint: step 3 done — type-position $args[$i] + intrinsics
Logs the 4-commit step-3 batch (69dcee88b457ff):

- 3a.A/3a.B: parser + AST + resolver for `$args[$i]` in type
  positions (return, param, local-var annotation).
- 3a.C: extend resolution to fn-pointer type literals — the
  shape step 5's generic Into(Block) trampoline body needs.
- 3b: `type_eq` + `has_impl` comptime intrinsics (`type_name`
  already existed). Both fold via `tryConstBoolCondition` so
  `inline if type_eq/has_impl` collapses at lower time.

Step 5 (generic Into(Block) impl) is now type-system-unblocked.
Step 4 (#insert pack passthrough + compile_error) is the
smaller intermediate slice if needed before pushing into the
stdlib refactor.

Issue 0047 (`#run` stderr vs runtime stdout split) noted in
"Current state" — filed but not blocking.

Test count: 208/208.
2026-05-27 17:50:02 +03:00
agra
8b457ffc44 ffi M5.A.next.3b: type_eq + has_impl comptime intrinsics
Step 3 second slice. Adds two reflection builtins used by
pack-fn bodies to branch on type identity / protocol
membership at compile time. type_name already existed
(lower.zig:8693); reused as-is.

  type_eq(T1, T2)   -> bool   structural TypeId equality
  has_impl(P, T)    -> bool   T has a reachable impl for P

Both are wired through `tryConstBoolCondition` so the inline-if
ladder folds them at lower time — `inline if type_eq(...)` /
`inline if has_impl(...)` collapse to a single branch with no
runtime instructions, perfect for guard-based dispatch inside
pack-fn bodies.

`has_impl`'s protocol arg accepts two shapes:
- plain protocol name: `has_impl(Allocator, CAllocator)` →
  walks `protocol_thunk_map["Allocator\x00CAllocator"]`.
- parameterised call: `has_impl(Into(Block), s64)` →
  builds the param_impl_map key `"Into\x00Block\x00s64"`
  and checks containment. The protocol type-args resolve
  through `resolveTypeArg` so type aliases, generics, and
  pack-indexed types all work as protocol args.

`computeHasImpl` is the shared implementation between the
runtime builtin path and the `tryConstBoolCondition` fast
path so both branches stay in sync.

`examples/168-pack-reflection-intrinsics.sx` exercises every
shape:
- type_name for primitive types.
- type_eq with both equal + unequal cases, including pointer
  types (s64 vs *s64).
- inline-if folding type_eq.
- has_impl with a real plain-protocol impl
  (Allocator/CAllocator → true; Allocator/s64 → false).
- has_impl with a user-defined parameterised protocol
  (Wrap(s64)/s32 → true; mismatched target args → false).

208/208 example tests + `zig build test` green.

Caveat: plain-protocol has_impl uses `protocol_thunk_map`
which is lazily populated when an `xx` cast or protocol
dispatch creates the thunks. For a static check before any
dispatch, that could false-negative. Allocator/CAllocator
works in 168 because stdlib's startup uses CAllocator through
the Allocator protocol — the thunks already exist by the time
has_impl runs. A more robust static check (walk fn_ast_map for
"<T_name>.<method>" entries against the protocol's method
list) is deferred to a follow-up if needed.

LSP "undefined variable" warnings on type names in expression
position (s64, *s64, Wrap(s64), etc. passed to type_eq /
has_impl) are cosmetic — sema doesn't know these intrinsics
accept types as args. Tracked separately.
2026-05-27 17:48:39 +03:00
agra
9137f4158d ffi M5.A.next.3a.C: $args[$i] in fn-pointer type literals
Adds `resolveFunctionTypeWithBindings` so `function_type_expr`
in a binding-aware context — local var annotations, return
types, nested type expressions — recursively resolves through
the active pack bindings. Without this, the fall-through to
`type_bridge.resolveAstType` lost pack context and the new
`pack_index_type_expr` arm spammed the "outside pack-aware
context" diagnostic (the function still worked by accident
thanks to the `.s64` fallback).

Plumbing:
- `resolveTypeWithBindings` adds a `function_type_expr` case
  in both the bindings-active branch and the fallthrough
  switch (the same shape as `closure_type_expr`).
- `resolveFunctionTypeWithBindings` recursively resolves each
  param + return type with bindings, then calls
  `functionTypeCC` with the AST's calling convention.

`examples/167-pack-type-fnptr.sx` exercises the pattern step
5's trampoline needs:
  fp : (*void, $args[0]) -> $args[1] = double_s64;
  return fp(null, args[0]);
Output: 14 (= 7*2 via the typed fn-pointer).

207/207 example tests + `zig build test` green.
2026-05-27 17:26:27 +03:00
agra
3df58febb6 ffi M5.A.next.3a.B: $args[$i] in type positions — parser + resolver
Step 3 first slice. `$<pack>[<int_literal>]` now parses in
every type position and resolves against the active pack
binding (`pack_arg_types` map set up by `monomorphizePackFn`).

Plumbing:

- src/ast.zig: new `PackIndexTypeExpr { pack_name, index }`
  AST node + `pack_index_type_expr` variant in `Data`.
- src/parser.zig: in `parseTypeExpr`'s `$<ident>` arm, peek
  for `[`. If found, parse a non-negative `int_literal` index
  followed by `]` and emit a `pack_index_type_expr` node.
  Plain `$T` / `$T/Eq` paths unchanged.
- src/ir/lower.zig::resolveTypeWithBindings: handles
  `pack_index_type_expr` first — looks up the pack name in
  `pack_arg_types`, returns `arg_tys[index]` when in range.
  OOB and "no active pack binding" cases emit focused
  diagnostics at the node span.
- src/ir/type_bridge.zig::resolveAstType: handles the same
  node but falls back to `.s64` with a stderr note — the bare
  type_bridge has no access to lowering state. Pack-aware
  callers route through `resolveTypeWithBindings`.
- src/sema.zig: adds `pack_index_type_expr` to the no-op
  arms in `analyzeNode` and `findNodeAtOffset` so the sema
  pass doesn't reject the new variant.

Tests:

- examples/165-pack-type-position.sx (lock-in from 69dcee8)
  flips from parse error to "42 first". Exercises both a
  return-type position (-> $args[0]) AND a local-var
  annotation (second : $args[1] = args[1]); two
  heterogeneous call shapes confirm distinct monos pick
  distinct concrete types per pack index.
- examples/166-pack-type-position-three.sx — three-element
  pack with $args[2] (third element) as return type. Three
  call shapes: (s64,s64,string), (bool,f64,s64),
  (string,string,bool). Prints "third 99 false".

Out of scope (deferred):
- $args[$i] where $i is a comptime-bound expression (only
  literal int supported in this slice).
- $args[$i] in fn-pointer type LITERALS (works for named
  decls but nested fn type expressions need an audit).
- $args[$i] in struct field types.

206/206 example tests + `zig build test` green.
2026-05-27 17:23:47 +03:00
agra
69dcee88cd ffi M5.A.next.3a.A: $args[$i] in type positions — expected-failing test
Step 3 of the variadic heterogeneous type packs feature.
`$args[$i]` (with `$i` a literal integer for the first slice)
should resolve to the i-th element type of the active pack
binding in every type position: return types, param types,
local var annotations, fn-pointer type literals, struct fields.

Today the parser hits "expected '{'" at the `$args[<lit>]`
token because the `$<ident>` arm in `parseTypeExpr` only
recognises plain generic names (`$T`, `$T/Eq/Hashable`).
After `<ident>`, an opening `[` is unexpected.

`examples/165-pack-type-position.sx` exercises two type
positions per mono — a return type `-> $args[0]` AND a local
var annotation `second : $args[1] = args[1]` — so the parser
change must cover more than the trailing return arrow. Two
call shapes (`swap_take(42, "ignored")` and `swap_take("first",
99)`) confirm heterogeneous monos pick distinct concrete
types per position.

Cadence shape 2: the expected output is the WORKING output
("42 first"); pre-fix the diff vs the parser-error output
fails. Next commit lands the parser + resolver changes and the
test flips green.

204/204 + 1 expected-failing = 205 total. `zig build test`
green.
2026-05-27 17:20:37 +03:00
agra
95755be888 ffi issue-0047: file #run-on-stderr-vs-runtime-on-stdout split
Surfaced while adding the `--- build done ---` delimiter
(commit 2993072). `#run print()` output is buffered by the
interp and flushed via std.debug.print → stderr at
core.zig:187/190; JIT runtime `print()` writes via libc
write(1, ...) → stdout. Same `print` call from the user's
viewpoint, different streams in practice.

Not blocking step 3 — tests capture both streams via 2>&1 so
snapshots are unaffected. Issue file documents the fix path
(move the two `std.debug.print` flushes in core.zig to
stdout-writes) for a future session.
2026-05-27 17:11:31 +03:00
agra
2993072972 main: "--- build done ---" delimiter on stderr for top-level #run
Tests that exercise top-level #run produce two interleaved
output streams: the interp's #run prints (flushed via
std.debug.print → stderr at core.zig:187/190) and the JIT-
executed main's prints (libc write fd=1 → stdout). When the
test runner captures both via 2>&1 the boundary between them
is invisible — the snapshot reads as one block.

Now `sx run` emits "--- build done ---\n" on stderr right
before invoking the JIT, when `hasTopLevelRun(root)` is true.
Tests without top-level #run keep their current snapshots
unchanged; only the 7 affected tests pick up the delimiter
between the build-time and run-time sections.

Example: 05-run flips from
    hello 25
    hello 25
to
    hello 25
    --- build done ---
    hello 25

— the first "hello 25" is from `#run main()` running at
compile time, the second is from JIT main() running at
runtime. The delimiter makes that explicit.

204/204 example tests + `zig build test` green.
2026-05-27 17:08:14 +03:00
agra
d91a15f6c9 ffi checkpoint: issue-0046 fixed — nested comptime calls now safe
Logs the 13efc56 lock-in + 248d6e6 fix for issue-0046.
`createComptimeFunction` now saves/restores eight pieces of
outer lowering state so the wrapper fn (which the interp
executes in isolation) does not inherit caller state that
would corrupt its body lowering.

Pack-fn face was already fixed by step 2b; this commit closes
the plain `(\$x: s32)` comptime face.

Test count: 204/204.

Outstanding items reduced to: non-literal comptime args in
mixed-mode pack-fns (degrades to `?` mangle today).
2026-05-27 16:58:14 +03:00
agra
248d6e669c ffi issue-0046 fix: save/restore outer state in createComptimeFunction
`createComptimeFunction` wraps a comptime expression into a
fresh fn that the interp executes in isolation. The wrapper
must not inherit the enclosing call's lowering state — any
leaked slot, binding, or scope flag corrupts the wrapper's
own lowering.

Pre-fix, only `func` / `current_block` / `inst_counter` /
`scope` / `current_ctx_ref` were saved. Specifically NOT
saved:

- `inline_return_target` — set by `lowerComptimeCall` for an
  outer comptime body with `return X;`. The wrapper's body
  was lowering through this slot, routing the wrapper's
  `ret` into a basic block from a different function.
- `pack_arg_nodes`, `pack_param_count`, `pack_arg_types` —
  active during a pack-fn mono's body lowering. (Pack-fn
  face of 0046 was already fixed by step 2b moving pack-fn
  calls off the inline path; these saves close a latent
  cross-contamination if any future pack-mono body invokes
  the comptime interp.)
- `comptime_param_nodes` — active during an outer
  `lowerComptimeCall` to bind `$fmt`-style substitutions.
- `block_terminated`, `target_type`, `func_defer_base` — fn-
  local flags that the wrapper's lowering needs fresh.

All eight now save/restore in `createComptimeFunction`. The
wrapper runs in a clean state.

`examples/issue-0046.sx` flips from the
non-deterministic interp panic to "inside\n" + "n=42\n".

204/204 example tests + `zig build test` green. Issue file
marked FIXED with a pointer to the regression test.
2026-05-27 16:57:19 +03:00
agra
13efc565fa ffi issue-0046: nested comptime call + return — expected-failing test
Lock-in for issue-0046. The test file expects the WORKING
output ("inside" / "n=42") — pre-fix the interp panics
non-deterministically at `storeAtRawPtr` (null pointer store)
because `createComptimeFunction` does not save/restore the
outer `lowerComptimeCall`'s `inline_return_target` state; the
wrapper fn built for the nested `print` body inherits a slot
belonging to a different basic block.

Cadence rule shape 2: expected-failing test, the next commit
turns it green. Today the suite shows 1 failure (issue-0046);
post-fix it returns to all green.

The thread ID + hex addresses in the panic output are non-
deterministic so locking in the broken shape directly would
be flaky — comparing actual panic vs expected-working still
diffs as FAIL pre-fix, no need to snapshot the panic.

The pack-fn face of issue-0046 was fixed incidentally by step
2b (mono path bypasses the inline-return-slot setup that
leaked into nested comptime calls). Plain `($x: s32)` comptime
fns stay on the inline path and still need this fix.
2026-05-27 16:56:25 +03:00
agra
c7854bd537 ffi checkpoint: step-2 follow-ups all landed (generic $R / bare args / runtime idx / mixed)
Logs all four step-2 follow-up commits (c917f92, 2e0b97a,
d30d566, 159f898 + their lock-ins). Pack-fns are functionally
complete on the mono path.

Test count: 203/203. Step 6 (stdlib print/format refactor) is
now unblocked from the type-system side; step 5 (generic
Into(Block) impl) still needs step 3 (type-position
$args[$i]) before its trampoline body can be parametric.

iOS-sim chess regression-verified post-step-2b.

Outstanding (not blocking step 3): issue-0046, non-literal
comptime arg mangling.
2026-05-27 16:49:04 +03:00
agra
159f898ffe ffi M5.A.next.2b.fu1.B: mixed comptime+pack — mono with comptime values folded into mangle
Fixes follow-up #1 from step 2b. Pack-fns can now mix non-pack
comptime params with the trailing pack:

  tagged :: ($tag: s32, ..$args) -> s64 {
      return tag * 100 + args.len;
  }

`isPackFn` relaxed to "exactly one trailing pack + any number
of non-pack comptime params". The mono path takes over.

Plumbing in src/ir/lower.zig:

- `lowerPackFnCall` walks fd.params + call_node.args in lockstep:
  comptime non-pack args fold into the mangle (`__ct_<value>`
  segments); non-comptime non-pack args contribute to the
  runtime arg-type list; remaining call args populate the pack
  expansion.
- `appendComptimeValueMangle` mangles int / bool / float /
  string literals stably. Strings hash to keep the symbol short.
  Distinct comptime values get distinct monos.
- `monomorphizePackFn` takes `call_node` so it can read comptime
  call args. Skips comptime non-pack params when building the
  runtime IR signature. Binds each comptime non-pack param both
  as a `comptime_param_nodes` entry (for `#insert`) AND as a
  runtime local via alloca+store (for bare-name body access).

`examples/164-pack-mixed-comptime.sx` flips from "unresolved
'tag'" to `703` / `900`. Two calls of `tagged` with
different comptime tags get distinct monos
(`tagged__ct_7__pack_...` and `tagged__ct_9__pack`).

This is the load-bearing prerequisite for step 6 of the plan
(stdlib `print` / `format` refactor to `(\$fmt, ..\$args)`).

Out of scope:
- Non-literal comptime args. `appendComptimeValueMangle`
  degrades them to `?` (so two distinct non-literal expressions
  in the same call slot would collide). Acceptable since
  literal args are the only common case; non-literal would need
  comptime evaluation to determine the value.

203/203 example tests + `zig build test` green.
2026-05-27 16:47:52 +03:00
agra
fc8a8c3f2e ffi M5.A.next.2b.fu1.A: mixed comptime+pack — lock in unresolved-tag miss
Follow-up #1 from step 2b: pack-fns that mix a non-pack
comptime param with the trailing pack (e.g. `tagged($tag: s32,
..$args)`). Today's `isPackFn` requires the pack to be the
ONLY comptime param; mixed shapes fall through to the inline
`lowerComptimeCall` path. That path adds non-string comptime
params to `comptime_param_nodes` for #insert substitution but
does NOT bind them as runtime locals, so the body's bare
`tag` reference hits "unresolved 'tag'" at the call site.

Next commit:
- Relax `isPackFn` to "exactly one trailing pack + any number
  of non-pack comptime params" so the mono path takes over.
- Fold comptime VALUES into the mangled name (`tagged(7, ...)`
  and `tagged(9, ...)` get distinct monos so each body sees
  its own comptime constants).
- Bind comptime args as both `comptime_param_nodes` (for
  #insert substitution) AND runtime locals (for bare-name
  references). String literals stay as string locals;
  int/bool/float literals become typed locals of the
  appropriate primitive type.

This is the load-bearing prerequisite for step 6 (stdlib
`print`/`format` refactor to `(\$fmt, ..\$args)`) — without
mixed-mode mono support, stdlib stays on the inline path
forever.

203/203 example tests + `zig build test` green (the lock-in
captures the wrong-shape diagnostic as the snapshot to flip).
2026-05-27 16:43:04 +03:00
agra
d30d566397 ffi M5.A.next.2b.fu34.B: pack-mono materialises []Any slice for bare args
Fixes follow-ups #3 (bare `args` reference) and #4
(`args[<runtime_int>]`) from step 2b. The pack-mono now
materialises an `[]Any` slice value for the pack name at body
entry: each pack-param slot is loaded, boxed via `boxAny`, and
stored into a stack [N x Any] array; the slice {data_ptr, len}
binds to the pack name in scope.

Plumbing in src/ir/lower.zig:

- `materialisePackSlice(scope, pack_name, slot_refs, arg_types)`
  — new helper that emits the array alloca + box+store loop +
  slice alloca + bind. Empty-pack case (N == 0) emits {null, 0}
  directly.
- `monomorphizePackFn` captures the pack-param slot Refs as
  they bind, then calls `materialisePackSlice` after binding so
  the slice load can pull each param value.

After: `args` (bare) resolves as `[]Any` and forwards to
slice-typed helpers; `args[<runtime_int>]` lowers through the
standard slice-indexing path, element type `Any`. Per-position
type info is lost via Any boxing — that is the inherent cost
of treating a heterogeneous pack as a uniform value. Literal-
indexed access still routes through `packArgNodeAt` and keeps
the concrete per-position types.

`examples/162-pack-bare-args.sx` flips from "unresolved 'args'"
to `3` (forwarded to `log_count(items: []Any)` which returns
`items.len`).

`examples/163-pack-runtime-index.sx` flips from the LLVM
verifier crash to `4` (while-loop over `args.len`, indexing
each `args[i]` runtime).

202/202 example tests + `zig build test` green.
2026-05-27 16:41:28 +03:00
agra
dadf80b3f1 ffi M5.A.next.2b.fu34.A: bare args + runtime index — lock in unresolved/LLVM errors
Lock-ins for follow-ups #3 (bare `args` reference) and #4
(`args[<runtime_int>]`) from step 2b. Both share the same root
cause: the pack-mono does not materialise an `[]Any` slice
value for the pack name, so any body that needs `args` as a
value at runtime fails.

`examples/162-pack-bare-args.sx` — pack-fn body forwards `args`
to a `[]Any`-typed helper. Today: "unresolved 'args' (in
... fn forward__pack_s64_string_f64)".

`examples/163-pack-runtime-index.sx` — pack-fn body indexes
`args[i]` with a runtime `i`. Today: LLVM verifier crash —
"GEP base pointer is not a vector or a vector of pointers" —
because `args` resolves to a junk Ref via the scope-lookup
fall-through, and the slice-indexing path emits a GEP off
that.

Next commit materialises an `[]Any` slice on demand inside the
mono: each pack param is boxed into Any, stored in a stack
[N x Any] array, and the slice {data_ptr, len} is bound to the
pack name. `args` then resolves as a runtime value the same way
the pre-2b inline path used to. `args[i]` runtime indexing goes
through the standard slice index path; element type is `Any`
(lossy on per-position types — inherent to runtime indexing
into a heterogeneous pack).

202/202 example tests + `zig build test` green.
2026-05-27 16:38:01 +03:00
agra
2e0b97aaa5 ffi M5.A.next.2b.fu2.C: heterogeneous pack ret + OOB diagnostic
Two follow-on fixes for follow-up #2 (generic pack-fn return).

(1) `pack_arg_types` — a new type-only pack binding consulted by
`inferExprType` for `<pack_name>[<int_literal>]`. The earlier
`pack_arg_nodes`-via-synthesized-idents path lost the type
during return-type inference because the synthesized idents
("__pack_args_0" etc.) only resolve once the mono scope is set
up — but the inference runs BEFORE scope setup. Now
`monomorphizePackFn` installs `pack_arg_types[<pack>] =
arg_types` alongside the existing nodes/count maps, and
`inferExprType` consults it directly.

`foo(..$args) -> $R => args[2]` called as `foo(42, 3.2, "hello")`
now correctly returns "hello" (string) — the third element-
typed pick threads through inference to the mono ret_ty.

(2) `diagPackIndexOOB` — focused diagnostic for `args[<lit>]`
where the literal exceeds the pack arity. Pre-fix the
substitution returned null and the standard slice-indexing
fall-through emitted "unresolved args" — burying the real
cause. Now: "pack index 2 out of bounds: 'args' has 1
element" at the index span.

Tests:
- `examples/160-pack-hetero-ret.sx` — generic `$R` with non-
  zeroth heterogeneous pick (returns "hello").
- `examples/161-pack-index-oob.sx` — call passes 1 arg but
  body indexes args[2]; locks in the OOB diagnostic shape.

200/200 example tests + `zig build test` green.
2026-05-27 16:34:26 +03:00
agra
c917f92509 ffi M5.A.next.2b.fu2.B: generic pack-fn return — infer ret_ty from body
Fix for follow-up #2 from step 2b. When a pack-fn declares
`(..\$args) -> \$R` (return type a generic name), the mono now
infers ret_ty from the body's first explicit `return X;` or
falls back to the tail expression of an arrow-form body.

Plumbing in src/ir/lower.zig:

- `inferPackBodyReturnType(body)` walks the body via the
  existing `findReturnValueType` helper (return stmts) and
  falls through to `inferExprType` on the tail expression for
  arrow-form / tail-expr bodies.
- `monomorphizePackFn` now pre-installs `pack_arg_nodes` and
  `pack_param_count` BEFORE resolving the return type so the
  inference can substitute `args[<lit>]` to call-site arg
  AST nodes during type lookup.
- Generic-ret detection: `fd.return_type` AST node is a
  `type_expr` with `is_generic = true`. Concrete returns stay
  on the standard `resolveReturnType` path.

`examples/159-pack-generic-ret.sx` flips from `0 0` (silent-
zero coercion through opaque struct ret_ty) to `42 99`.

198/198 example tests + `zig build test` green.
2026-05-27 16:28:52 +03:00
agra
e44ba4b240 ffi M5.A.next.2b.fu2.A: generic \$R pack-fn — lock in silent-zero return
Follow-up #2 from step 2b: pack-fns with a generic return type
(`(..\$args) -> \$R`). Today's `monomorphizePackFn` calls
`resolveReturnType` which sees `\$R` as a generic name and
returns an opaque struct TypeId. The mono's ret_ty is wrong
and the value silently coerces to 0.

`examples/159-pack-generic-ret.sx` pins this: `first(42)` and
`first(99)` both return `0` instead of the call arg. The lock-in
captures the wrong output as the snapshot to flip.

Next commit infers the ret type from the body's tail expression
(arrow form) or the first explicit `return X;` (block form),
then builds the mono signature against that concrete type.

198/198 example tests + \`zig build test\` green.
2026-05-27 16:22:49 +03:00
agra
a39437261d ffi checkpoint: step 2b done — per-call-shape pack monomorphisation
Logs commit 7989618 (the 2b shift from inline expansion to real
shared mono fns) and updates the test count to 197/197.

Pack feature step 2 is fully done — typed indexing, control-flow,
and per-call-shape mono all land. Step 3 (type-reflection
intrinsics) is the next slice and unlocks the stdlib payoff
(generic Into(Block) for Closure(..$args) -> $R) by enabling
$args[$i] substitution in type positions.

Documented follow-ups: mixed $fmt+..$args shapes, generic $R
binding, bare args reference, runtime indexing, and issue-0046
fix all remain deferred — none block step 3.
2026-05-27 15:45:03 +03:00
agra
79896188eb ffi M5.A.next.2b: per-call-shape monomorphisation for pack-fns
Pack-fns (`isPackFn(fd) == true` — last param `is_variadic AND
is_comptime`, no other comptime params) now emit ONE
monomorphised function per unique call-site signature. Repeat
calls with the same arg-type tuple share the mono; distinct
shapes get distinct symbols. Pre-2b each call inlined a fresh
body copy into the caller's basic block; IR size grew linearly
in call sites.

Plumbing in `src/ir/lower.zig`:

- `isPackFn(fd)` — true when the only comptime param is a
  trailing pack. Mixed `($fmt, ..$args)` shapes stay on the
  inline `lowerComptimeCall` path (different substitution
  mechanism for the comptime non-pack param; deferred).
- `lowerPackFnCall(fd, call_node)`:
  - Builds a mangled name `<fn_name>__pack__<arg_types>` from
    call-site `inferExprType` results. Distinct shapes get
    distinct symbols.
  - Cache-checks `lowered_functions`; calls
    `monomorphizePackFn` on miss.
  - Lowers call args, then re-fetches the func pointer (the
    fetch BEFORE arg lowering would invalidate after any
    transitively-triggered module.functions.items realloc),
    prepends ctx if needed, coerces, emits direct call.
- `monomorphizePackFn(fd, mangled, arg_types)`:
  - Mirrors `monomorphizeFunction` for the standard fn build:
    save state, build param list (ctx + fixed prefix + N pack
    params with synthesised names `__pack_<name>_<i>`),
    `beginFunction`, entry block, bind params to scope.
  - Installs `pack_arg_nodes[<name>]` with synthesised AST
    identifier nodes pointing at the pack-param slots so the
    body's `args[<int_literal>]` substitutes through the
    existing 2a.B mechanism — substitution resolves to the
    mono's own param slot loads.
  - Installs `pack_param_count[<name>] = N` so the body's
    `args.len` resolves to a compile-time constant via a new
    intercept in `lowerFieldAccess` (and the parallel arm in
    `inferExprType`).
  - Lowers the body with `inline_return_target = null` so
    `return X;` emits a real `ret X` instead of the inline-slot
    routing — the mono is a real fn now.
- Routed at three call sites: each `if (hasComptimeParams(fd))
  { return self.lowerComptimeCall(...); }` now first checks
  `isPackFn(fd)` and routes to `lowerPackFnCall` when true.

Lifetime gotcha caught and fixed: `params.items` is stored by
reference in `Function.init` (no copy), so the local
`ArrayList(Function.Param)` must NOT be deinit'd in
`monomorphizePackFn` — matches the leak convention already used
by `monomorphizeFunction`.

`examples/158-pack-mono-dedup.sx` confirms the dedup
end-to-end: `count(), count(1), count(2), count(1,2,3),
count("x", true)` produces `0 1 1 3 2` at runtime AND emits
exactly 4 monos in IR (`count__pack`, `count__pack_s64`,
`count__pack_s64_s64_s64`, `count__pack_string_bool`) — the
two s64 calls share. `args.len` resolves to the comptime
constant N inside each mono.

`examples/156-pack-typed-index.sx` and
`examples/157-pack-if-return.sx` continue to pass unchanged.

Out of scope:
- Mixed `$fmt + ..$args` shapes (stays on inline path).
- Generic `$R` return types (concrete returns only).
- Bare `args` reference (passing the slice as a whole).
- `args[<runtime_int>]` (non-literal index).

197/197 example tests + `zig build test` green.
2026-05-27 15:44:05 +03:00
agra
39a804f25e ffi checkpoint: 2a control-flow fix + step 2/3 roadmap
Logs the 2a.C/2a.D pair (6b7a66b + e6d6903) — the if-return
regression in issue-0045's original fix, now resolved via the
SSA "return-done block" pattern.

Step 2 is functionally sufficient for the impl-matching payoff
(step 5 of the plan); per-call-shape monomorphisation deferred
until a real workload demands it.

Step 3 ("Type-reflection intrinsics") flagged as the natural
next slice: `$args[$i]` in type positions unlocks the block-
trampoline body in stdlib's generic Into(Block) impl, which is
the visible payoff of the whole pack feature.
2026-05-27 14:58:13 +03:00
agra
e6d6903708 ffi M5.A.next.2a.D: inline-return uses CFG terminator, not block_terminated
Fixes the regression locked in by 2a.C (commit 6b7a66b).
issue-0045's original fix set `block_terminated = true` after
each inline `return X;` to skip dead code in the inlined body.
But the flag leaked past structured control flow — an `if cond
{ return X; }` whose merge block continued to subsequent
statements would short-circuit the trailing code at the
`lowerBlockValue` loop's `if (self.block_terminated) return
null;` check.

Switched to the classical SSA "return-done block" shape:

- `InlineReturnInfo` carries a third field `done_bb: BlockId`
  — a fresh basic block allocated by `lowerComptimeCall` per
  comptime-call instance.
- `lowerReturn`'s inline path stores into the slot, drains
  defers, and emits `br done_bb`. The basic block's terminator
  is what carries the "no fall-through" signal; the
  `block_terminated` flag is no longer touched.
- `lowerComptimeCall` allocates the slot + done_bb, lowers the
  body, then switches to done_bb and loads the slot. Tail-
  expression bodies that fall through (rare when has_return is
  true) get a synthetic store + br so the CFG is well-formed.

For `if cond { return 42; }; return -1;`:
- cond=true: then's `return 42` stores 42, br done_bb. Merge
  block has only the false predecessor, doesn't run the
  trailing return. Load done_bb → 42.
- cond=false: condBr skips to merge. Merge runs `return -1;`
  → store -1, br done_bb. Load → -1.

`examples/157-pack-if-return.sx` flips from `8354116000` (the
uninitialised slot load on the false path) to `-1`. A
three-way `classify(..$args)` smoke confirms multi-path
inline-return works for any of the three branches.

Dead-code-after-return inside the inlined body still trips the
LLVM verifier (same shape as a regular `return X; print("dead");`
which also crashes today). Acceptable consistency — user code
shouldn't write unreachable code in either context.

196/196 example tests + `zig build test` green.
2026-05-27 14:55:25 +03:00