Commit Graph

379 Commits

Author SHA1 Message Date
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
agra
6b7a66ba4d ffi M5.A.next.2a.C: pack if-return — lock in slot-load uninit regression
Follow-up to issue-0045's fix (commit 9e78790). The fix routes
inline-comptime-body `return X;` into a result slot but sets
`block_terminated = true` after the inline return — and that
flag leaks past the enclosing `if`'s merge block.

Body shape:
  maybe :: (..$args) -> s64 {
      if args.len > 0 { return 42; }
      return -1;
  }

For `maybe()` (zero call-args), the false-condition path skips
the then-branch's `return 42;` and should fall through to
`return -1;`. Today's flow:

  - Then-branch's `return 42;` stores 42 to slot and sets
    block_terminated = true.
  - if lowering switches to merge_bb. block_terminated stays
    true (never reset across the if/merge boundary).
  - lowerBlockValue's loop sees block_terminated and returns
    null without processing the trailing `return -1;`.
  - lowerComptimeCall loads slot — slot was never written on
    the false-condition path → garbage (8354116000 on this
    machine; stable across runs).

`maybe(99)` works because the cond is true; the then-branch's
store wins.

Next commit reshapes the inline-return mechanism to use a
dedicated "return-done" basic block: each inline `return X;`
stores to slot and branches to ret_done; after the body
lowers, lowerComptimeCall switches to ret_done and loads. The
basic block CFG carries the control-flow termination — no
need for the leaking `block_terminated` flag.

196/196 example tests + `zig build test` green (the new test
captures the wrong value as the snapshot to flip).
2026-05-27 14:52:43 +03:00
agra
83c2c9d176 ffi issue-0046: file nested-comptime-call + return latent bug
Comptime fn body containing BOTH a nested comptime call
(`print(...)`) AND a `return X;` fails in one of two shapes
depending on the comptime-param flavour: a `storeAtRawPtr`
panic in the interp (plain `$x: s32` comptime) or "unresolved
'result'" at compile time (pack-fn `..$args`).

Same root: my issue-0045 fix's `inline_return_target` slot
setup interacts badly with the recursive comptime-call path
that invokes `#insert build_format(fmt)` → interpreter →
parse-and-lower of `result := ...` statements.

Pre-issue-0045-fix the pattern crashed at the LLVM verifier
("Terminator found in the middle of a basic block") so the
recursive path never ran. The fix exposed the deeper bug; it
didn't create it.

Not blocking the next pack-feature slices:
- Step 2a tests use arrow-form bodies with no nested print.
- Steps 2b/3 don't inherently require nested comptime calls —
  builders run inside `#insert` contexts, not inside public
  pack-fn bodies.
- Will bite when step 5 refactors stdlib's `print`/`format` to
  `..$args` or when user code writes a pack-fn with both
  `print` debug output and an early `return`.

Investigation prompt in the issue file points at
`createComptimeFunction`'s saved/restored state list (missing
`inline_return_target`, `pack_arg_nodes`,
`comptime_param_nodes`) as the most likely angle.
2026-05-27 14:09:20 +03:00
agra
4d9a7eda5a ffi checkpoint: step 2a typed pack indexing done
Logs the two step-2a commits (223ec3d lock-in, cd36784 fix):
pack-fn bodies' `args[<int_literal>]` now substitutes the i-th
call-site argument's lowered value directly, propagating the
concrete type through field access and typed coercion.

Out-of-scope follow-ups noted: non-literal comptime indices,
type-position `$args[$i]` (step 3), per-mono mangling, and the
pre-existing nested-comptime-call scope bug (a pack-fn body
calling `print(...)` AND containing `return X;` trips
"unresolved 'result'" — same shape as the issue-0045 family,
worth filing separately if a later slice needs it).
2026-05-27 13:58:48 +03:00
agra
cd367847a9 ffi M5.A.next.2a.B: pack typed indexing — args[$i] substitutes call arg
Pack-fn bodies that index the pack via `args[<int_literal>]`
now resolve to the i-th call-site argument's lowered value
directly, propagating the call arg's concrete type instead
of the boxed `Any` that the `[]Any` slice path returns.

New plumbing in `src/ir/lower.zig`:

- `pack_arg_nodes: ?std.StringHashMap([]const *const Node)` on
  Lowering. Maps a pack param name (e.g. "args") to the slice
  of call-site arg AST nodes.
- `lowerComptimeCall` populates the map when the variadic
  param is heterogeneous (`is_variadic AND is_comptime`, i.e.
  the `..$args` form). Plain `args: ..Any` keeps the existing
  `[]Any` slice path so stdlib's `format`/`print` continue
  unchanged. The map is saved/restored across nested calls
  mirroring `comptime_param_nodes`.
- `packArgNodeAt(ie)` returns the call-arg node when an
  index_expr matches `<pack_name>[<comptime_int_literal>]`
  with the index in range; null otherwise (fall through to
  standard slice indexing for runtime indices or non-pack
  bases).
- `lowerIndexExpr` checks `packArgNodeAt` first; on a hit it
  lowers the call arg node directly. `inferExprType`'s
  `index_expr` arm does the parallel check so AST-level type
  inference (e.g., for field-access type checking) sees the
  concrete call-arg type.

`examples/156-pack-typed-index.sx` flips from
"field 'x' not found on type 'Any'" to `7` — `args[0].x` now
resolves through the concrete `Point` type instead of Any.

Out of scope (deferred): non-literal comptime indices
(`args[$i]` where `$i` is an arbitrary comptime expression);
`$args[$i]` in type positions (step 3); per-mono mangling
(monomorphisation stays inline-only).

195/195 example tests + `zig build test` green.
2026-05-27 13:55:19 +03:00
agra
223ec3d0b3 ffi M5.A.next.2a.A: pack typed indexing — lock in Any-untyped miss
Step 2 of the variadic heterogeneous type packs feature: typed
runtime indexing (`args[$i]` at comptime-known `$i`). Today's
pack-fn body lowers `args[i]` through the `[]Any` slice path —
the static type returned is `Any`, so any downstream field
access / typed-coercion / further indexing fails the moment it
needs more than primitive auto-unboxing.

`examples/156-pack-typed-index.sx` pins the simplest visible
failure: `args[0].x` on a struct-typed call arg trips
"field 'x' not found on type 'Any'" at the field-access site
because AST-level type inference for `args[0]` returns Any.

Next commit teaches `lowerIndexExpr` (and `inferExprType` for
the same shape) to detect an index_expr whose base is a
pack-name binding from the enclosing comptime call AND whose
index is a comptime int literal — substitutes the i-th
call-site arg's lowered value directly, propagating the call
arg's concrete type through field access, typed assignments,
and further indexing. The `[]Any` slice path stays as the
runtime-indexed fallback for `args[i]` where `i` is not a
comptime constant.

195/195 example tests + `zig build test` green.
2026-05-27 13:49:44 +03:00
agra
618aa3724d ffi checkpoint: issue-0045 fix + step 1 wrap-up
Logs the issue-0045 fix (inline-return slot for comptime-call
bodies, commits 3d32ab0 + 9e78790) — the LLVM verifier crash
that surfaced when probing step 2 of the pack feature. Test
count now 194/194 with the new regression test.
2026-05-27 13:22:01 +03:00
agra
9e78790ebf ffi issue-0045 fix: inline-return slot for comptime-call bodies
`lowerComptimeCall` now scans the body for `return` statements
via `fnBodyHasReturn`. When found, it allocates a stack slot
typed to the fn's return type and installs it as
`self.inline_return_target` before lowering the body.

`lowerReturn` checks `inline_return_target` first:
- If set, it stores the coerced return value into the slot,
  drains pending defers, sets `block_terminated = true`, and
  returns without emitting a `ret` into the caller's basic
  block.
- Otherwise it emits the standard `ret` as before.

After the body lowers, the inliner either returns the
tail-expression value (existing fast path — bodies with no
`return` skip the slot entirely) or loads the slot when
`block_terminated` is set.

Why the bug was invisible until now: `format`/`print` and
every other stdlib comptime fn use arrow form (`=> expr`) or
`#insert`-only bodies — no `return` statement, no path through
`lowerReturn`. Step 1.b of the pack feature made `..$args`
parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first
comptime-fn body to take the `return`-with-trailing-statements
path, surfacing the LLVM verifier crash.

`examples/issue-0045.sx` flips from the lock-in failure to
`42`. 194/194 example tests + `zig build test` green.
2026-05-27 13:21:23 +03:00
agra
3d32ab0fc6 ffi issue-0045: pack-fn block-body call — lock in LLVM verifier crash
Filed `issues/0045-pack-fn-call-llvm-verifier-failure.md`.
Surfaced by probing step 2 territory of the variadic
heterogeneous type packs feature: any `..$args` fn whose body
is a block containing `return X;` (or any comptime fn with a
non-void return, comptime params, and explicit `return` in a
block body) trips LLVM's "Terminator found in the middle of a
basic block" verifier.

`lowerComptimeCall` inlines the body's statements directly into
the caller's LLVM function. `lowerReturn` then emits a `ret`
into the caller's basic block — but the caller still has
trailing instructions, hence the verifier failure.

`examples/issue-0045.sx` reproduces the crash with the minimum
pack-fn shape (`foo :: (..$args) -> s64 { return 42; }`). Same
shape with a plain comptime param (`($x: s32) -> s64 { return
42; }`) reproduces identically, so the bug is broader than
packs. Arrow-form bodies (`=> 42`) work today because they have
no `return` statement.

Next commit teaches `lowerComptimeCall` to allocate a result
slot when the body contains a `return`, and reroutes
`lowerReturn` to store into that slot + flag the block as
terminated so the inliner picks up the value.
2026-05-27 13:19:49 +03:00
agra
35ef32ffdb ffi M5.A.next: checkpoint — pack feature step 1 done (1c.A → 1d.B)
Logs the four commits that closed out step 1 of the variadic
heterogeneous type packs feature:

- 1c.A: parser-rejection lock-in for `..$args` inside `Closure(...)`.
- 1c.B: parser + AST + types.zig `pack_start` representation.
- 1d.A: impl-matching concrete-only miss lock-in.
- 1d.B: pack-aware impl matching with $args + $R binding through
  `param_impl_pack_map` and `pack_bindings`.

Next step is plan step 2 — runtime `args[$i]` indexing + per-mono
mangling — opening the door to body-side pack reflection that
step 5 needs to retire the hand-rolled `Into(Block)` impls.

Also catches up the 1b entry which the prior session left
uncommitted in the working tree.
2026-05-27 12:58:57 +03:00
agra
08feb6040b ffi M5.A.next.1d.B: pack impl matching — bind $args + $R per call
Pack-shaped impls (`impl P(...) for Closure(..$args) -> $R`) now
match concrete closure sources at xx resolution time. Concrete
impls keep their priority — pack matching only fires on a
concrete-key miss in `param_impl_map`.

New plumbing in src/ir/lower.zig:

- `PackParamImplEntry` carries the pack-shaped source TypeId plus
  the pack-var and ret-var names extracted from the impl AST's
  `target_type_expr`. `registerParamImpl` detects pack-shaped
  sources via `pack_start != null` on the resolved closure type
  and additionally registers in a new `param_impl_pack_map`
  keyed by `"Proto\x00<arg_mangled>"` (no source suffix).

- `tryUserConversion` re-shapes the concrete lookup so the pack
  path runs on miss. `tryPackImplMatch` walks the pack entries,
  verifies the source's fixed prefix matches the impl's prefix,
  binds the pack-var to the source's tail param TypeIds, binds
  the ret-var (when the impl's return is generic) to the source
  return, and monomorphises the convert method. Mangled name
  stays keyed on the concrete source so distinct call shapes
  monomorphise separately.

- `pack_bindings: ?StringHashMap([]const TypeId)` is saved/
  restored around monomorphisation, mirroring `type_bindings`.

- `resolveClosureTypeWithBindings` handles the closure_type_expr
  node during type resolution: when the closure carries a
  `pack_name` AND `pack_bindings` has a binding for it, the
  bound TypeIds are appended after the fixed prefix and the
  result is a concrete (non-pack) closure type — so the impl
  body's `self: Closure(..$args) -> $R` substitutes to the
  concrete source closure during monomorphisation. Without an
  active binding, the pack shape is preserved.

`examples/155-pack-impl-match.sx` flips from the
"no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to
"pack impl match ok": one user-declared
`impl Into(Block) for Closure(..$args) -> $R` covers a
`Closure(s32, bool) -> bool` source that stdlib has no
hand-rolled impl for. Constructed Block isn't invoked
(invoke=null) — the test exercises only the matching +
monomorphisation, not the trampoline (step 5 of the plan).

Existing concrete-impl paths unchanged: 95-objc-block-noop,
96-objc-block-multi-arg, and stdlib's hand-rolled
`Into(Block) for Closure(bool) -> void` continue to pass through
the concrete map first. Same-file duplicate pack impls
diagnose at registration; cross-module visibility and
multi-pack-impl specificity stay TODOs (matching the deferred
Phase 5 work on the concrete path).

193/193 example tests + `zig build test` green.
2026-05-27 12:57:45 +03:00