test: remove resolved/fixed issue writeups

Delete the issues/*.md whose writeup carries a RESOLVED or FIXED banner;
only the open issues (0030, 0148) remain.
This commit is contained in:
agra
2026-06-21 14:41:18 +03:00
parent 6b0ebdd92b
commit 6d1409bc1f
109 changed files with 0 additions and 12415 deletions

View File

@@ -1,35 +0,0 @@
# 0019 — `#import` is non-transitive (C-function scope across files)
> **RESOLVED.** The `.md`'s own repro is non-runnable (relative `../modules/std.sx`
> imports) and superseded; the scenario — A importing B does NOT transitively expose
> a third module's top-level names — is enforced by the non-transitive `#import`
> visibility check in `lowerCall` (src/ir/lower/call.zig:106-118, gated on
> `isNameVisible`, defined in src/ir/lower/decl.zig:2502), with the const-reference
> path mirrored in src/ir/lower/expr.zig. Covered by the passing regression test
> `examples/0706-modules-import-non-transitive.sx`.
> **Status: superseded — kept for reference.** Relocated from the old
> `examples/issue-0019/` fixture during the test-layout migration. The behavior
> it probed (A imports B and C; C must NOT see B's `extern` C functions just
> because A imported B) is now covered by the passing test
> `examples/0706-modules-import-non-transitive.sx`.
## What it probed
`main` imports both `c_wrapper.sx` (which declares C `extern` functions) and
`other.sx`. `other.sx` should *not* gain access to `c_wrapper`'s C functions
transitively — using one should produce the "not visible; #import the module that
declares it" diagnostic.
- `main_good.sx` — the valid arrangement.
- `main_bad.sx` — the arrangement that must be rejected.
- `c_wrapper.sx`, `other.sx` — the imported modules.
## Caveat (why it doesn't run as-is)
The fixture uses **relative** imports (`#import "../modules/std.sx"`), which only
resolve relative to a specific working directory and violate the project's
"always `package:`/module-path imports, never relative" rule. It is not runnable
from the repo root and is not wired into the suite. If revived, rewrite the
imports to the standard `modules/...` form and pin expected output; otherwise it
can be deleted (the scenario is already covered by `0706-modules-import-non-transitive`).

View File

@@ -1,149 +0,0 @@
# issue-0041 — Pointer types don't parse as expressions / type-argument positions
**FIXED.** `size_of(*u8)`, `align_of(*u8)`, and the alias form
`Ptr :: *u8;` all parse and lower correctly today. The fix is
in tree as part of broader parser/lowering work — no specific
commit isolates it, but the original repro now prints `8` and
returns 0 exit.
Below preserved as a record of the original problem.
## Symptom
A pointer type like `*u8` or `*void` does not parse in positions
where a type expression is expected as a *value*, e.g.:
- As an argument to a `$T: Type` builtin: `size_of(*u8)`,
`align_of(*u8)`.
- On the RHS of a type alias: `Ptr :: *u8;`.
In each case the parser emits `error: unexpected token in expression`
at the column of the `*`.
Pointer types DO parse correctly in dedicated type-annotation
positions: function parameters (`(p: *u8)`), struct fields
(`field: *u8;`), variable annotations (`p: *u8 = ...;`). So the bug
is a parsing inconsistency between "type-annotation context" and
"expression context where a type is expected".
This is pre-existing — it affects `size_of` (already shipping) and
was just made more visible by adding `align_of` in Phase 0.6 of the
MEM plan. Not a regression introduced by 0.6, but a real limitation
worth pinning down because:
- Phase 1+ of the MEM plan will need `size_of(*T)` / `align_of(*T)`
in user-facing allocator helpers if we want to stay terse — e.g.
serializing a pointer-typed field in `field_value_int` patterns.
- It's a discoverability cliff. New users WILL write `size_of(*u8)`,
see "unexpected token", and have to learn the workaround.
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i32 {
n := size_of(*u8); // error: unexpected token in expression
print("{}\n", n);
0;
}
```
Also fails on the alias form:
```sx
#import "modules/std.sx";
Ptr :: *u8; // error: unexpected token in expression
main :: () -> i32 { 0; }
```
Both `sx run` and `sx build` reject identically.
## Confirmed working workarounds
A pointer type DOES resolve when bound through a `*void`-style
variable type and then cast, or routed via a helper:
```sx
// Workaround A: anonymous struct holding the pointer field, then
// pull alignment from the wrapping struct (clumsy).
Wrap :: struct { p: *u8; }
n := align_of(Wrap); // 8 — correct for pointer alignment.
// Workaround B: explicit *void
n := size_of(*void); // ALSO fails — same parse error.
```
Workaround B is NOT functional — it has the same parse error. Only
the wrap-in-struct or type-alias-via-typedef trick is currently
viable for code that needs pointer size/alignment.
There is no clean way today to write `size_of(*u8)`. The whole
class of "ptr type as type-expression value" is unsupported.
## Investigation prompt
> Pointer types parse via a dedicated `parseTypeExpr` (or similar)
> path that the parser invokes in type-annotation positions (param
> lists, field declarations, variable annotations). The expression
> grammar used in argument positions (e.g. inside `size_of(...)`)
> dispatches through `parseExpr` instead, which treats `*` as
> "either prefix unary deref or infix multiplication" — neither
> matches the desired "type literal" interpretation.
>
> The fix likely belongs in the call-argument parser path: when
> the callee is a builtin that takes `$T: Type`, OR more broadly
> whenever the parser sees a `*` at the start of an expression
> followed by an identifier that resolves to a type, it should
> dispatch to `parseTypeExpr` instead of `parsePrefixUnary`.
>
> Implementation sketch:
> - Check `src/parser.zig` for the expression entry point that
> handles `*` prefix. Today it likely returns a `unary_op
> { op = deref, operand = … }` AST node.
> - Look at how lower.zig's `resolveTypeArg` consumes the AST node
> for `size_of(i32)` — what AST shape does it expect for a type
> literal? Probably an `identifier` whose name resolves to a type.
> - The fix should extend `resolveTypeArg` to also accept a
> `unary_op { op = deref, ... }` and treat it as "pointer to
> resolved type" — equivalent to `Ptr$T` in spec terms.
> - For the type-alias case (`Ptr :: *u8;`), the RHS of a `::`
> const decl is parsed as an expression. The parser needs to
> recognize that the LHS-determined shape (type-level alias)
> should bias the RHS parser toward `parseTypeExpr`. Or: extend
> the constant-fold path to interpret `unary_op { deref, T }` as
> a type literal when used as a type.
>
> Verification:
> 1. Add `examples/issue-0041.sx` with the repro above and
> `tests/expected/issue-0041.txt` capturing the expected output
> (`size_of(*u8) → 8`).
> 2. Confirm `bash tests/run_examples.sh` still passes everything
> else (151 tests currently).
> 3. Run `tools/verify-step.sh` to confirm chess on three platforms.
> 4. Also bake into `examples/50-smoke.sx` near the existing
> `align_of` lines — add `align_of(*u8)`, `size_of(*u8)`,
> `align_of(*void)` and regen.
>
> Hazard: any change to expression parsing affects a huge surface.
> Watch for these contexts to make sure they still work post-fix:
> - `a * b` (multiplication)
> - `*p` (prefix deref read)
> - `*p = …` (prefix deref write)
> - `func(a, *b)` (deref as argument)
> A surgical "is the next token a built-in type identifier" lookahead
> at the `*` site is probably less invasive than a wholesale
> type-expression-in-expression-position rewrite.
## Plan-level impact
None for Phase 0.6 — `align_of` shipped and works for every shape
that `size_of` works for (primitives, structs, type aliases through
non-pointer types). The 50-smoke test addition uses only
non-pointer types, so it's stable.
Phase 1+ should bake an `align_of(*u8)` test once the parser fix
lands, since the allocator API will want to round-trip pointer
alignments at some call sites.

View File

@@ -1,149 +0,0 @@
# issue-0042 — Const-decl type aliases (`MyInt :: i32;`) silently return `.i64` from `size_of` / `align_of`
> **RESOLVED.** Root cause: the bare-name type-arg resolver consulted only `findByName`, falling back to `.i64` (8 bytes) for const-decl aliases (`MyInt :: i32;`) recorded in the alias map — silently mis-sizing any non-8-byte alias.
> Fix: the nominal-leaf resolver now consults the alias maps before falling back — `resolveNominalLeaf` → `selectNominalLeaf` (`src/ir/lower/decl.zig`) returns `.resolved` with the alias's real `TypeId` from `program_index.type_aliases_by_source` (own-author const_decl branch ~L1820 / unwired branch ~L1801); aliases are registered via `putTypeAlias` (`decl.zig:560`).
> The old `resolveTypeArg` `.identifier` arm in `lower.zig` is gone — the source-keyed `selectNominalLeaf` superseded it.
> Covering regression test: `examples/0116-types-type-alias-size-align.sx` (alias, chain, and struct-name alias through `size_of`/`align_of`).
**FIXED.** `MyInt :: i32; size_of(MyInt)` now returns `4`
correctly. The `resolveTypeArg` `.identifier` branch consults
`type_alias_map` before falling through. The fix landed
alongside the broader alias-resolution work tracked in
CHECKPOINT.md (Session 63's `type_bridge` alias-resolution
extension); no specific commit isolates this issue.
Below preserved as a record of the original problem.
## Symptom
A type alias declared via `Foo :: SomeType;` is registered in the
lowering's `type_alias_map` but is **never consulted** when the alias
name is later used as a type argument to `size_of` / `align_of`. The
fallback returns `.i64` (8 bytes) — which coincidentally produces a
correct result for any alias whose underlying type is 8 bytes
(`*T`, `f64`, function pointers, `i64`, `u64`), silently masking the
bug for years.
Observed:
```
size_of(i32) = 4 ← direct, correct
size_of(MyInt) = 8 ← via alias, WRONG (expected 4)
```
Where `MyInt :: i32;`.
## Reproduction
```sx
#import "modules/std.sx";
MyInt :: i32;
main :: () -> i32 {
print("direct: {}\n", size_of(i32)); // 4
print("alias: {}\n", size_of(MyInt)); // 8 — should be 4
0;
}
```
`./zig-out/bin/sx run` against unmodified master prints:
```
direct: 4
alias: 8
```
## Why this surfaces now
issue-0041 work extends the const-decl alias path to register
pointer, optional, array, slice, many-pointer, and function-type
aliases (`Ptr :: *u8;`, `Maybe :: ?u8;`, `Arr :: [3]u8;`,
`Cb :: (i32) -> i32;`). Every one of those aliases ends up in
`type_alias_map`, then `size_of(<alias>)` falls through the same
`.identifier` branch that ignores the map — returning `.i64` (8).
For pointer and function-type aliases this is coincidentally right
(8 bytes). For optional, array, etc. it produces silently-wrong
sizes (`size_of(Maybe) = 8` instead of 2;
`size_of(Arr) = 8` instead of 3).
The issue-0041 work cannot land without this being fixed — the
test snapshots would pin in the wrong values and the new feature
would ship subtly broken.
## Investigation prompt
> The bug lives in `src/ir/lower.zig`, in `resolveTypeArg`
> ([line ~7132](src/ir/lower.zig#L7132)). The `.identifier`
> branch looks like:
>
> ```zig
> .identifier => |id| {
> if (self.type_bindings) |tb| {
> if (tb.get(id.name)) |ty| return ty;
> }
> const name_id = self.module.types.internString(id.name);
> return self.module.types.findByName(name_id) orelse .i64;
> },
> ```
>
> It checks `type_bindings` (generic-monomorphization) and
> `findByName` (registered named types), but never consults
> `self.type_alias_map` — which is where the const-decl alias
> registration in `lower.zig:425` puts entries. The neighbouring
> `.type_expr` branch (line ~7143) DOES check `type_alias_map`:
>
> ```zig
> .type_expr => |te| {
> if (self.type_alias_map.get(te.name)) |alias_ty| return alias_ty;
> return type_bridge.resolveAstType(node, &self.module.types);
> },
> ```
>
> Why two branches: an `.identifier` AST node is what parsePrimary
> emits for non-keyword names; `.type_expr` is what it emits for
> built-in primitive names recognised by `Type.fromName` (`i32`,
> `u8`, etc.) and for the `f32`/`f64`/`Type` keywords. User-defined
> alias names like `MyInt` and `Ptr` flow through `.identifier`.
>
> **Likely fix:** mirror the `type_alias_map.get` lookup in the
> `.identifier` branch — try alias map first (or before/after
> findByName, whichever is the established precedence elsewhere).
>
> ```zig
> .identifier => |id| {
> if (self.type_bindings) |tb| {
> if (tb.get(id.name)) |ty| return ty;
> }
> if (self.type_alias_map.get(id.name)) |alias_ty| return alias_ty;
> const name_id = self.module.types.internString(id.name);
> return self.module.types.findByName(name_id) orelse .i64;
> },
> ```
>
> **Verification:**
> 1. Add the repro above as `examples/issue-0042.sx`.
> 2. `bash tests/run_examples.sh --update` to capture expected
> output (`alias: 4`, not `alias: 8`).
> 3. Make sure existing snapshots that test type aliases (search
> `examples/` for `::` patterns followed by `size_of`) don't
> change in unexpected ways.
>
> **Possible adjacency:** the issue may extend to `align_of`
> (likely same call path) and to type-alias chains
> (`A :: i32; B :: A;` — does B resolve through A's alias entry?).
> Worth pinning down with a test once the primary fix lands.
## Plan-level impact
Blocks issue-0041 (compound-type-as-expression). Once 0042 is
fixed, 0041 work can resume from the testing phase (the parser and
lowering edits for 0041 are already in place; only the alias
lookup is broken).
## Suggested fix order
1. Land 0042's `.identifier` alias-map lookup.
2. Resume 0041 from the test step — re-run `examples/issue-0041.sx`
and verify `size_of(Maybe) = 2`, `size_of(Arr) = 3`, etc.
3. Regenerate snapshots and proceed with the 0041 finishing
steps (50-smoke, rename, etc.).

View File

@@ -1,157 +0,0 @@
# issue-0043: lazy-lowered function bodies don't resolve runtime-class method dispatch
> **RESOLVED.** Root cause: chained runtime-class dispatch (`Cls.alloc().init...(x)`) collapsed the inner call's return type, so the outer `.method(...)` couldn't find the receiver's `#objc_class` declaration — and on the lazy-lower path (inside an `inline if OS == .ios` branch) the runtime-class map was unavailable.
> Fix: the runtime-class instance/static dispatch now resolves each call's declared return type via `resolveRuntimeMethodReturnType` against the runtime_class_map (`src/ir/calls.zig`, runtime-class branches ~L244-269 / L385-398), and that map lives on the shared `ProgramIndex` (`src/ir/program_index.zig:668`) so it is equally visible to eager and lazy lowering (`lazyLowerFunction`, `src/ir/lower/decl.zig:2508`).
> Covering regression: `examples/1306-ffi-objc-runtime-class-chained-dispatch.sx` (header cites issue 0043) exercises both `*ClassName` and `*Self` chained shapes and passes.
**FIXED.** The original repro
(`sx build --target ios-sim issue-0043.sx` with a
`UIWindow.alloc().initWithWindowScene(scene)` call inside an
`inline if OS == .ios { ... }`-gated function called transitively
from `caller :: (...) callconv(.c)`) compiles cleanly today; chess
on iOS-sim runs end-to-end through the same dispatch shape. The
fix lives in tree as part of broader runtime-class /
lazyLowerFunction work — no specific commit isolates it.
Below preserved as a record of the original problem.
## Symptom
When a function `B` containing `recv.method(...)` calls against a
`#objc_class` receiver is invoked transitively via `lazyLowerFunction`
from another `inline if OS == ...` branch in function `A`, the
method dispatch fails with:
```
unresolved 'methodName' (in <file> fn B)
```
Direct (eager) lowering of `B` with the same body works. Direct call
from `main` works. Only the transitive lazy-lower path from inside an
`inline if` branch fails.
Concretely: under Phase 3.0 / Phase 3.2 C4 work, the iOS-sim build of
chess fails:
```
library/modules/platform/uikit.sx:591:12: error: unresolved 'initWithWindowScene' (in ... fn uikit_scene_will_connect_ios)
library/modules/platform/uikit.sx:599:11: error: unresolved 'init'
library/modules/platform/uikit.sx:609:5: error: unresolved 'setView'
library/modules/platform/uikit.sx:611:5: error: unresolved 'setRootViewController'
library/modules/platform/uikit.sx:661:11: error: unresolved 'init'
```
Stack trace (with `SX_TRACE_UNRESOLVED=1`) shows the chain:
```
emitError (lower.zig:5640)
↑ lowerCall .field_access fallback
lowerVarDecl (lowering body of uikit_scene_will_connect_ios)
lowerBlock
lazyLowerFunction (lower.zig:5165) (← lazy entry point)
lowerCall (lower.zig:5165) (calling uikit_scene_will_connect_ios)
lowerInlineBranch (inside `inline if OS == .ios { uikit_scene_will_connect_ios(...) }`)
lowerIfExpr
lowerExpr
```
So when the *outer* `lowerCall` decides to lazy-lower
`uikit_scene_will_connect_ios`, the *inner* body's method-dispatch
on `*UIWindow` etc. fails to find the runtime-class declaration —
even though the declaration is at module scope at the top of
uikit.sx and resolves fine for non-lazy lowering paths (`sx build`
for macOS target compiles the same source cleanly).
## Reproduction
```sx
#import "modules/std.sx";
#import "modules/compiler.sx";
UIWindow :: #objc_class("UIWindow") extern {
alloc :: () -> *UIWindow;
initWithWindowScene :: (self: *Self, scene: *void) -> *UIWindow;
}
// Function B: uses runtime-class method dispatch.
do_work :: (scene: *void) {
win := UIWindow.alloc().initWithWindowScene(scene);
_ = win;
}
// Function A: calls B inside an `inline if`. The transitive call
// triggers lazy lowering of B, which fails for ios-sim only.
caller :: (self: *void, _cmd: *void, scene: *void, b: *void, c: *void) callconv(.c) {
inline if OS == .ios {
do_work(scene);
}
}
main :: () -> i32 { 0; }
```
Build:
```sh
sx build --target ios-sim issue-0043.sx
```
Observed: `error: unresolved 'initWithWindowScene' (in issue-0043.sx fn do_work)`.
Eager dispatch shape (called directly from `main` without the
intermediate `inline if`-gated function) compiles cleanly. The
isolated probe must mirror the lazy-lower trigger to reproduce.
## Investigation prompt (for a fresh session)
Suspected area: `lazyLowerFunction` at
[src/ir/lower.zig:1057](../src/ir/lower.zig#L1057) and the field-
access method dispatch at
[src/ir/lower.zig:5290](../src/ir/lower.zig#L5290) (around the
`runtime_class_map.get(sname_for_runtime)` lookup).
Hypotheses:
1. `lazyLowerFunction` swaps some piece of lowering state on entry
(e.g., `saved_source_file`, `current_ctx_ref`) but doesn't preserve
access to `runtime_class_map`. Check whether the map is
instance-state vs. shared.
2. The receiver type for `win` (`*UIWindow`) isn't being resolved to
its `getStructTypeName` correctly during lazy lowering — possibly
`inferExprType` for the lazy-lowered context resolves to an
anonymous type instead of `UIWindow`.
3. The runtime-class declarations are added to `runtime_class_map`
during a pre-scan pass that runs BEFORE the outer function `A`'s
body is lowered, but lazy lowering of `B` from within `A` might
be observing the map at a pre-scan state where uikit.sx's
declarations haven't been seen yet (cross-module ordering).
What the fix likely needs to do:
- Confirm `runtime_class_map` contains `"UIWindow"` etc. at the
point of the lazy lowering call (add a debug print at the failing
dispatch).
- If the map IS populated, trace why the field-access dispatch falls
through to line 5640 instead of taking the `runtime_class_map.get`
branch at line 5306.
- If the map is NOT populated, find the seeding path and fix the
ordering — likely a missing pre-scan step before lazy lowering.
Verification: rebuild chess with `sx build --target ios-sim` against
the in-progress Phase 3.2 C4 branch (revert the
`git checkout -- library/modules/platform/uikit.sx` to restore the
work-in-progress migration) and confirm the unresolved errors go
away.
## Why this blocks Phase 3.2 C4
The migration of `library/modules/platform/uikit.sx` to declarative
`#objc_class` dispatch (Phase 3.2 plan part C, clusters C4 + C5)
necessarily places runtime-class method calls inside iOS-only helper
functions that get lazily lowered when chess's iOS scene-lifecycle
hooks fire them. Without this fix, the migration produces compile
errors on iOS-sim that don't appear on macOS. C1+C2+C3 happened to
land cleanly because the methods they migrate are either (a) called
from eagerly-lowered top-level paths, or (b) niladic enough that the
specific dispatch path doesn't fail.
The Phase 3.2 plan flagged C4+C5 as blocked pending this fix.

View File

@@ -1,192 +0,0 @@
**FIXED.** Root cause was NOT the parameter name; the original `this`
> **RESOLVED.** A runtime-class (`#objc_class`) UFCS call site had no entry in
> `resolveCallParamTypes`, so per-arg `target_type` leaked the enclosing method's
> return type (e.g. BOOL→i8), truncating `xx this` to its low byte at the receiver.
> Fixed by adding the `runtime_class_map.get(sname)` + `findRuntimeMethodInChain`
> path in `resolveCallParamTypes` (now `src/ir/lower/call.zig`, lines 2533-2556),
> which threads each runtime-class method's declared param types. Regression test:
> `examples/1321-ffi-objc-defined-class-method-self.sx`.
rename surfaced an unrelated `target_type` leak in
`resolveCallParamTypes`. See "Root cause + fix" below.
## Symptom
In an `#objc_class` method body, the first `*Self` parameter MUST be
named `self`. Renaming to anything else (e.g. `this`) compiles cleanly
but produces wrong code at runtime: reading the parameter back (e.g.
`xx this` to coerce to `*void`) yields a small struct-offset-shaped
value (saw 0x20 / 32 in our repro) instead of the Obj-C `id` that the
IMP trampoline received. Calling into the Obj-C runtime with that value
crashes with `EXC_BAD_ACCESS` / `SIGSEGV` at a near-null address.
Observed:
- `library/modules/platform/uikit.sx` SxAppDelegate methods renamed
`self``this`. Body called `center.addObserver_selector_name_object(xx this, ...)`.
- Chess on iOS-sim crashed at first launch in
`-[NSNotificationCenter addObserver:selector:name:object:]``object_getClass(observer)`,
with `observer = 32` (low-int, not a real pointer).
- Reverting `this``self` (via `sed`) fixed the crash. Same body
shape, same `xx self` → call works fine.
Expected: parameter name should not matter. The IMP trampoline binds
the receiver `id` to whatever the first parameter is named in the
method declaration — by position, not by hardcoded name.
## Reproduction
```sx
#import "modules/std.sx";
#import "modules/std/objc.sx";
// Extern declaration so we can dispatch.
NSObject :: #objc_class("NSObject") extern {
class :: () -> *void;
description :: (self: *Self) -> *void;
}
// sx-defined class whose method's *Self param is named `this`.
Foo :: #objc_class("SxFooSelfTest") {
#extends NSObject;
poke :: (this: *Self) -> *void {
// Should return the receiver back to the caller.
return xx this;
}
}
main :: () -> i32 {
inline if OS == .macos {
f := Foo.alloc().init();
result := f.poke();
// result should be the same pointer as `f`. Expect equal.
if xx result == xx f {
print("ok\n");
} else {
print("WRONG: this != self\n");
// result will be a struct-offset shaped value
// (e.g. 0x20) instead of the Obj-C id.
}
}
inline if OS != .macos { print("skipped (not macos)\n"); }
0;
}
```
Build and run on macOS; expected `ok`; observed `WRONG: this != self`
(or a crash if the value is dereferenced).
If you swap `this``self` everywhere in the body and parameter list,
the test prints `ok`.
## Investigation prompt
In `src/ir/lower.zig`, several IMP-trampoline / method-body emission
paths hardcode the string `"self"` when binding the receiver parameter
or looking it up in the scope. Grep hits at the time of filing:
```
$ grep -n '"self"' src/ir/lower.zig
3422: init_scope.put("self", .{ ... });
5133: const self_binding = if (self.scope) |s| s.lookup("self") else null;
10044: .name = "self",
11999/12056/12499/12662: params.append(... .internString("self") ...);
```
The methods that synthesize the IMP trampoline (M1.2 A.2) and the
ones that wire `*Self` → opaque runtime-class stub (M1.2 A.3) appear
to either:
(a) emit the trampoline assuming the slot name in the body is "self",
so a body declared with `this: *Self` reads from an uninitialized /
different slot when it accesses the parameter; OR
(b) resolve `*Self`-typed parameters by name rather than by position
+ type, so a non-`self` name routes through a slower / different
binding path that doesn't see the IMP-passed receiver.
Likely fix: have the parser / type-checker for `#objc_class` method
bodies identify the first `*Self`-typed parameter by **position and
type**, and bind the IMP-passed receiver into whatever local name the
user chose. Remove hardcoded `"self"` literals from the trampoline
emission and from the M1.2 A.3 `lowerFieldAccess` / `lowerAssignment`
helpers (the ivar→struct_gep path needs to know which local IS the
receiver, not assume it's named "self").
Verification step:
1. Apply the fix.
2. Save the repro above as `examples/issue-0044.sx`.
3. Run `./zig-out/bin/sx run examples/issue-0044.sx` — expect `ok`
(not `WRONG: ...` and not a crash).
4. Bonus: confirm `bash tests/run_examples.sh` still passes (no
regression in existing `ffi-objc-*` tests, which all happen to
use `self` and so wouldn't have caught this).
## Background
Encountered during the FFI M3 follow-up cleanup of
`library/modules/platform/uikit.sx`. Renamed the IMP-side first
parameter from `self` to `this` across all `#objc_class` methods to
free `self` for upcoming M4 `self.method()` UFCS work on
`UIKitPlatform` methods called from those class bodies. Crash
manifested at first `[notificationCenter addObserver:self ...]` in
`-application:didFinishLaunchingWithOptions:`. Workaround in-session
was `sed -i '' 's/this: \*Self/self: *Self/g; s/xx this/xx self/g'`
across uikit.sx — the rename was uniform so the substitution was safe.
Cost of the foot-gun: future contributors who follow CLAUDE.md's
"any first parameter name that makes the body clearer is fine" mental
model will silently mis-compile their `#objc_class` method bodies.
## Root cause + fix
The parameter name `this` vs `self` was a red herring. What actually
went wrong:
1. uikit.sx renamed the AppDelegate's IMP method first param to `this`,
so `xx this` appeared inside the body of a `-> BOOL` method.
2. The body called `center.addObserver_selector_name_object(xx this, ..., null)`
on a `*NSNotificationCenter` runtime-class receiver.
3. `lowerCall` sets a per-arg `self.target_type` from
`resolveCallParamTypes(c)`. For UFCS dispatch on a runtime-class
alias, that function had no path covering `runtime_class_map`
it tried `resolveFuncByName(qualified)` and `fn_ast_map.get(qualified)`,
both of which miss for `#objc_class(…) extern` methods.
4. With `param_types` empty, the per-arg `target_type` assignment was
skipped, so `self.target_type` retained its previous value: the
enclosing fn's return type, **BOOL → i8**.
5. `xx this` then lowered with target type `i8`: `ptrtoint ptr to i64`
`trunc i64 to i8`. The receiver pointer became its low byte (0xC0
/ 0x20 / etc., depending on heap address).
6. `addObserver:selector:name:object:` got that byte as the observer.
Apple's runtime calls `object_getClass(observer)` internally for
validation → near-null deref → SIGSEGV.
The same shape works fine in `sx run` because the `xx` cast wasn't
exercised in the body in the original tests, OR the encoding
happened to land somewhere benign (e.g. the AOT-with-iOS-sim path
plus UIKit's specific validation order).
**Fix:** add a `runtime_class_map.get(sname)`
`findRuntimeMethodInChain` path to `resolveCallParamTypes`. When the
UFCS receiver is a runtime-class alias, walk the `#extends` chain to
find the method, then resolve its declared param types (skipping the
implicit `*Self` for instance methods). With the fix, `param_types`
returns `[*void, *void, *void, *void]` for the addObserver: call,
each `xx ptr` gets target type `*void`, and the cast is a clean
`ptrtoint``inttoptr` round-trip (or no-op since both sides are
pointer-typed).
[src/ir/lower.zig:8617-8639](../src/ir/lower.zig#L8617).
The parameter-name hardcoding in `lower.zig` (lines 3422, 5133, 10044,
11999, 12056, 12499, 12662) is unrelated — those are all SYNTHESIZED
parameters in compiler-generated functions (init scopes, JNI stubs,
property IMPs, dealloc IMPs), not the user-facing `#objc_class`
method body. The user's first param can be named anything.
Regression test: `examples/issue-0044.sx`. Pre-fix, the
`captureSelf-from-BOOL` probe prints `WRONG` because `xx this` gets
truncated to its low byte and the round-trip comparison fails. With
the fix, all three probes print `ok`.

View File

@@ -1,137 +0,0 @@
**FIXED.** `lowerComptimeCall` now allocates a result slot when
> **RESOLVED.** A comptime/pack-fn with a non-void return type and a block body
> containing `return X;` previously emitted a `ret` in the middle of the caller's
> basic block (LLVM verifier: "Terminator found in the middle of a basic block").
> Fixed in `lowerComptimeCall` (src/ir/lower/comptime.zig:822-869): when the body
> has a `return`, it allocates a result slot + shared `ct.ret_done` block and sets
> `inline_return_target`, so `lowerReturn` (src/ir/lower/stmt.zig:442) stores into
> the slot and branches to `ret_done` instead of emitting an inline `ret`.
> Regression test: examples/0525-packs-pack-fn-comptime-return.sx (prints "42").
the body contains a `return` statement and reroutes
`lowerReturn` to store into it instead of emitting `ret` into the
caller's basic block. Regression test:
[examples/issue-0045.sx](../examples/issue-0045.sx).
# Symptom
Calling a fn declared with `..$args` (variadic heterogeneous type
pack, parser-accepted as of commit `a51fe26`) — even with zero
positional arguments — emits LLVM IR that fails verification:
```
LLVM verification failed: Terminator found in the middle of a basic block!
label %entry
```
No IR is printed by `sx ir`; the `sx run` JIT exits 1 immediately.
Expected: at minimum, the empty-pack call site should compile and
execute the fn body. Plan step 2 ("Runtime indexing + mono expansion")
specifies per-mono mangling and `..$args` expansion to N positional
IR params; until that lands, calling such a fn should at minimum
emit a clear "pack-fn calls not yet implemented" diagnostic rather
than corrupt IR.
# Reproduction
```sx
foo :: (..$args) -> i64 { return 42; }
main :: () -> i32 {
n : i64 = foo();
return 0;
}
```
```
$ ./zig-out/bin/sx run repro.sx
LLVM verification failed: Terminator found in the middle of a basic block!
label %entry
```
`foo()` with zero args, one arg (`foo(1)`), or multiple args
(`foo(1, "hello")`) all produce the same crash.
# Background
After M5.A.next.1b (commit `a51fe26`), `parseParams` accepts
`..$args` as a parameter declaration. The Param is recorded with
`is_variadic = true`, `is_comptime = true`, `type_expr = inferred_type`.
`parseFnDecl`'s `collectTypeParams` then registers `args` as a
type-param (because `is_comptime = true`), so `fd.type_params.len > 0`.
This routes the fn through the existing generic-fn path:
`lowerFnDecl` skips eager lowering, expecting calls to monomorphise
at first use. But the existing monomorphisation machinery binds a
single TypeId per `$T` name — it has no notion of a *pack* (a
variable-length list of TypeIds bound positionally). When the
call site tries to monomorphise with the call's args, the body's
`args` parameter gets resolved to a single (probably default `.i64`)
TypeId, but the call-site arg-packing path (`packVariadicCallArgs`)
treats it as a regular `..T` slice — the two views disagree and
the emitted IR is malformed.
The bug isn't in step 1's code itself; it's the gap between
"step 1 made the syntax parseable" and "step 2 hasn't made the
calls executable yet."
# Investigation prompt
For a fresh session picking this up:
Plan step 2 ("Runtime indexing + mono expansion") in
`~/.claude/plans/lets-see-options-for-merry-dijkstra.md` is the
intended fix:
1. Detect pack-fns at declaration: the fn has a trailing param
with `is_variadic && is_comptime` (no concrete type annotation
distinguishes it from a regular `args: ..T` variadic).
2. Per-call monomorphisation: bind `$args := [T1, ..., Tn]`
from the call site's concrete arg types. Each unique
`(arg-type-tuple, $ret)` combination gets its own mono.
3. Expand the pack into N positional IR params in the mono's
signature; mangling encodes the pack shape so distinct
monos get distinct symbols.
4. Body `args[$i]` at comptime-known `$i` lowers to the i-th
expanded param load (return type from `$args[$i]`).
Key files:
- `src/ir/lower.zig`:
- `lowerFnDecl` (around line 949 — generic skip) needs to keep
skipping pack-fns.
- `monomorphizeFunction` (line 7834) needs a pack-aware path
that binds `pack_bindings` (the field added in commit
`08feb60` for impl matching) instead of just `type_bindings`.
- `packVariadicCallArgs` (line 7275) should NOT run for pack
fns — args stay positional, not slice-packed.
- Index-expression lowering needs an `args[$i]` arm that reads
the i-th positional param.
- `src/ir/types.zig`: `FunctionInfo`/`ClosureInfo` have
`pack_start` already (added in commit `6582449`); the mono's
expanded signature should NOT carry `pack_start` (it's a
concrete shape).
Verification: the repro above compiles and prints "42" when run
as `./zig-out/bin/sx run repro.sx`. A new
`examples/156-pack-fn-mono.sx` (number depends on next free slot)
should be added per the FFI cadence rule (xfail-lock-in then
green).
Alternative interim option: if step 2 is too large to land in
one session, gate `parseFnDecl` to reject pack params with an
explicit "pack-fn body lowering not yet implemented; only impl
target types accept `..$args` today" diagnostic. Lets the
parser accept the syntax in impl headers (step 1's payoff) while
preventing the LLVM verifier crash. The diagnostic disappears
when step 2 lands.
# Verification
Once the fix is in:
```sh
./zig-out/bin/sx run examples/156-pack-fn-mono.sx
# Expected: prints "42"
```
Full suite + zig test must still pass.

View File

@@ -1,164 +0,0 @@
**FIXED.** `createComptimeFunction` now saves/restores the
> **RESOLVED.** A nested comptime call inside a comptime fn body that also had a `return X;` lowered the wrapper fn built by `createComptimeFunction` while it still inherited the outer caller's `inline_return_target` (and pack / comptime-param bindings), so the interp stored into a slot belonging to a different basic block — null-pointer store at `storeAtRawPtr`.
> Fixed in `src/ir/lower/comptime.zig:createComptimeFunctionWithPrelude` (which `createComptimeFunction` delegates to): it now snapshots-and-clears `inline_return_target`, `pack_arg_nodes`, `pack_param_count`, `pack_arg_types`, `comptime_param_nodes`, `block_terminated`, `target_type`, and `func_defer_base`, restoring them via `defer` so the wrapper runs in isolation. The same protection is generalized as `Lowering.FnBodyReentry` (`src/ir/lower.zig`).
> Face 2 (pack-fn `..$args`) was fixed incidentally when pack-fn calls moved off the inline-return path onto the mono path.
> Covered by regression test `examples/0607-comptime-nested-comptime-return.sx`.
outer `lowerComptimeCall`'s state — specifically
`inline_return_target`, `pack_arg_nodes`, `pack_param_count`,
`pack_arg_types`, `comptime_param_nodes`, `block_terminated`,
`target_type`, and `func_defer_base` — so the wrapper fn it
builds for the nested comptime expression runs in isolation.
Without the saves, the wrapper inherited an inline-return slot
belonging to a different basic block; the interp executed it
and tripped a null pointer store at `storeAtRawPtr`.
The pack-fn face of this bug (filed as face 2) was fixed
incidentally by step 2b's mono refactor — pack-fn calls
bypass the inline-return-slot setup entirely. Plain
`($x: i32)` comptime fns stay on the inline path; the
`createComptimeFunction` save/restore fix covers that path.
Regression test:
[examples/issue-0046.sx](../examples/issue-0046.sx).
# Symptom
A comptime fn body containing BOTH a nested comptime call
(e.g. `print(...)`) AND a `return X;` statement fails in one of
two shapes depending on the comptime-param flavour:
| Outer fn shape | Failure |
|---|---|
| `helper :: ($x: i32) -> i64 { print("inside\n"); return 42; }` (plain comptime) | Panic: `cast causes pointer to be null` at `src/ir/interp.zig:207 storeAtRawPtr`. |
| `dump :: (..$args) -> i64 { n := args[0]; print("got {}\n", n); return n; }` (pack-fn) | Compile error: `unresolved 'result'` at fake span `1:5` (inside the inserted code). |
Both vanish if you remove either the nested `print(...)` OR the
`return X;` statement:
- Arrow-form bodies (`=> expr`) work.
- Bodies with `return` but no nested comptime call work.
- Bodies with a nested comptime call but no `return` work.
Both faces share one root: my fix for issue-0045 (commit
`9e78790`) added an `inline_return_target` slot + alloca in
`lowerComptimeCall`, which is now active when the outer call's
body recursively invokes another comptime fn that itself runs
the `#insert build_format(fmt)` → interpreter → parse-and-lower
pipeline.
Pre-fix this pattern crashed too — at the LLVM verifier with
"Terminator found in the middle of a basic block" — because the
outer `return` emitted `ret X` into the caller's basic block
mid-flight. My fix routed `return` into the slot so the outer
body now fully completes, which means the recursive comptime
call runs to completion too. The interpreter / #insert scope
chain then has to be correct in this newly-reachable context,
and it isn't.
# Reproduction
```sx
#import "modules/std.sx";
// Face 1 — interp panic:
helper :: ($x: i32) -> i64 {
print("inside\n");
return 42;
}
// Face 2 — "unresolved 'result'":
dump :: (..$args) -> i64 {
n : i64 = args[0];
print("got {}\n", n);
return n;
}
main :: () -> i32 {
n := helper(7); // ← panic in interp
print("{}\n", dump(7)); // ← "unresolved 'result'"
return 0;
}
```
Each face reproduces independently — they don't need to coexist
in the same program.
# What's NOT happening
- Not a regression introduced by issue-0045's fix per se: the
same pattern hit a different fatal stage (LLVM verifier) before
the fix. The fix exposed it; it didn't create it.
- Not caused by step 2a (pack typed indexing, commit `cd36784`):
Face 1 reproduces with a plain `($x: i32)` comptime fn, no
pack involved.
- Not exercised by any test in the suite today. `format`/`print`
use arrow form or `#insert`-only bodies — no `return` in a
block. User code historically followed the same pattern.
# Why this didn't block step 2a
Step 2a only tests `args[$i]` in arrow-form pack bodies and
arithmetic chains. No nested comptime call in any test body.
Step 2b (per-mono mangling) and step 3 (type-reflection
intrinsics, `$args[$i]` in type positions) don't inherently
require nested comptime calls either — builder fns run inside
`#insert` contexts, not inside the public pack-fn body, so
they have a different lowering path.
The pattern WILL bite when:
- Step 5 of the pack plan refactors stdlib's `print`/`format` to
use `..$args` — print's body itself becomes the outer comptime
fn that nests comptime calls.
- User code writes a pack-fn that wants both `print` for debug
output AND `return X;` for early exit.
# Investigation prompt
For a fresh session picking this up:
The interaction is between (a) my issue-0045 `inline_return_target`
slot + alloca setup in `lowerComptimeCall` and (b) the recursive
comptime path that invokes `#insert build_format(fmt)`
`evalComptimeString``createComptimeFunction``interp.call`
on a wrapper fn, then parses the returned source string and
lowers each parsed statement into the current scope.
Three angles worth probing:
1. **Saved/restored state in `createComptimeFunction`** at
`src/ir/lower.zig:8851+`. It saves `builder.func`,
`builder.current_block`, `builder.inst_counter`, `self.scope`,
`current_ctx_ref`. It does NOT save/restore
`inline_return_target`, `pack_arg_nodes`, `comptime_param_nodes`.
The first two were added by my recent commits (`9e78790`,
`cd36784`). One of these leaking into the wrapper-fn lowering
is the most likely cause of Face 1.
2. **Ref numbering** — the alloca I added for `ret_slot` shifts
subsequent Ref values in the outer fn (main). The interp
shouldn't see those refs (it executes the wrapper fn's IR,
not main's), but check whether the wrapper fn carries a stale
Ref handle from the outer build context.
3. **Scope chain visible to parsed `#insert` statements**. For
Face 2 the inserted code declares `result := ""` then references
`result` in the next stmt. The lookup fails. Maybe the
`lowerBlockValue` exit defer fires the parent scope deinit
before the next stmt lowers — or `block_terminated` from the
inline-return slot setup is interfering with the inserted-stmt
loop in `lowerInsertExprValue`
(`src/ir/lower.zig:7065+`).
A reasonable starting place: add the missing save/restore for
`inline_return_target` (and `pack_arg_nodes`) in
`createComptimeFunction`, then re-run both repros. If Face 1
disappears, that confirms angle 1.
# Verification
```sh
./zig-out/bin/sx run /tmp/issue-0046-face1.sx # expect "n=42"
./zig-out/bin/sx run /tmp/issue-0046-face2.sx # expect "got 7\n7"
```
Full suite + zig test must still pass after the fix.

View File

@@ -1,118 +0,0 @@
**FIXED** in commit `0119c9c`. Both `#run` output and the
> **RESOLVED.** `#run` (compile-time) `print` output landed on stderr while runtime `print` went to stdout, so separated-stream captures split user output across two fds.
> Fixed by routing both compile-time `print` output and the phase delimiter to fd 1 via direct libc `write`: the `--- build done ---` marker now goes through `std.c.write(1, ...)` at `src/main.zig:333` (cited to issue-0047), and `#run` `out`/print output writes straight to fd 1 on the comptime VM path (see `src/ir/comptime_vm.zig:1083` and the "now-direct libc write" notes in `src/ir/emit_llvm.zig:2967`).
> Covered by the regression test `examples/0600-comptime-run.sx`, whose expected `.stdout` carries the `#run` output, the delimiter, and runtime output together with an empty `.stderr`.
`--- build done ---` delimiter now write to fd 1 (stdout) via
`std.c.write` from `core.flushInterpOutput` and main.zig's
delimiter site. Test runner uses `2>&1` so snapshots are
unaffected.
# Symptom
`#run print(...)` output lands on **stderr**; runtime `print(...)`
output lands on **stdout**. The test runner captures both via
`2>&1` so they appear interleaved in snapshots, but for a human
running `sx run foo.sx` and piping stdout somewhere — or in any
context that separates the streams — the compile-time output
silently goes to a different place than the runtime output.
The recently-landed `--- build done ---` delimiter (commit
`2993072`) makes the boundary visible in test logs but doesn't
fix the underlying split.
# Reproduction
```sx
#import "modules/std.sx";
configure :: () {
print("hello from #run\n");
}
#run configure();
main :: () -> i32 {
print("hello from runtime\n");
return 0;
}
```
```
$ ./zig-out/bin/sx run repro.sx > /tmp/stdout.txt 2> /tmp/stderr.txt
$ cat /tmp/stdout.txt
hello from runtime
$ cat /tmp/stderr.txt
hello from #run
```
Expected (most consistent): both on stdout — both are `print`
calls in user code; the user doesn't distinguish "build-time
print" from "runtime print" at the call site.
# Root cause
The interp accumulates `print` output into an internal buffer
(`Interpreter.output: []u8`) via the `out` builtin
(`src/ir/interp.zig:1678-1683`). After the interp returns, the
buffer is flushed to **stderr** via `std.debug.print` from
`src/core.zig:187` and `src/core.zig:190`.
The JIT-executed runtime `print` lowers through `BuiltinId.out`
in `emit_llvm.zig:2936-2954` which emits `write(1, ptr, len)`
directly to fd 1 (stdout).
So the split is at the flush call in `core.zig`, not at the
print mechanism itself.
# Investigation prompt
For a fresh session picking this up:
The fix is small in code but touches a few callers. The two
`std.debug.print("{s}", .{interp.output.items})` sites at
`src/core.zig:187` and `:190` should write to stdout instead.
Options for the stdout write:
1. **libc write(1, ...)** via `std.c.write(1, ptr, len)`. Direct,
no Zig std buffering, interleaves correctly with `--- build
done ---` (also direct to stderr).
2. **std.posix.write(std.posix.STDOUT_FILENO, ...)** — Zig'\''s
typed wrapper. Same effect.
3. **std.fs.File.stdout() + writeAll** — more idiomatic but
may bring buffering complexity.
Whichever route, the order of writes vs the `--- build done ---`
delimiter (which currently goes to stderr) matters:
- If both #run output AND delimiter both go to stdout: ordering
preserved within stdout's buffer.
- If delimiter stays on stderr and #run goes to stdout: the
delimiter might appear out-of-order in mixed-stream captures.
Cleanest: move BOTH to stdout. The compile-error path (in
`renderErrors`) stays on stderr — only successful #run output
moves.
Other call sites that might leak `#run` output to stderr:
- `core.zig:187``invokeByFuncId` error path.
- `core.zig:190``invokeByFuncId` success path.
- Grep for `interp.output` to find others.
# Verification
After the fix, the repro above should produce:
```
$ ./zig-out/bin/sx run repro.sx > /tmp/stdout.txt 2> /tmp/stderr.txt
$ cat /tmp/stdout.txt
hello from #run
--- build done ---
hello from runtime
$ cat /tmp/stderr.txt
(empty)
```
Test snapshots for the 7 tests with top-level `#run` will need
to be re-`--update`d, but the visible content is the same —
just on the right stream now.
Full suite + `zig build test` must still pass.

View File

@@ -1,161 +0,0 @@
# 0048 — bare `$args` slice loses `.len` (reads 0) when passed across a call
> **RESOLVED.** Root cause: a callee (`walk`/`describe`) lazily lowered *inside* a
> pack-fn mono inherited the active pack's `pack_param_count`, so the
> `<pack_name>.len` intercept in `lowerFieldAccess` constant-folded `args.len`
> to the outer pack's arity — every shape's cross-call read returned that one
> baked constant (originally observed as 0). Fix: `FnBodyReentry.enter` in
> `src/ir/lower.zig` (used by `lowerFunctionBodyInto` / `lazyLowerFunction` in
> `src/ir/lower/decl.zig`) now nulls `pack_param_count` (and the sibling
> `pack_arg_nodes` / `pack_arg_types`) for the nested body and restores them on
> exit. Covered by regression test `examples/0522-packs-pack-bare-args-cross-call.sx`.
## Symptom
Bare `$args` evaluated inside a pack-fn body has the correct `.len`
inline (e.g. `($args).len == 2` for a two-arg shape). But the
moment the same slice is passed as an argument to another
function, the callee's read of `.len` returns 0 — regardless of
the pack's actual element count. The `data` pointer is presumably
similarly broken (haven't probed yet, but every element-access
would index off a zero-length view).
Observed:
```
inline: len=4
callee: len=0
```
Expected: callee receives the same `{ptr, len}` slice that the
caller materialised; `.len` matches the pack's element count.
This blocks the **step-5 generic `Into(Block)` impl** (FFI plan):
its body `#insert build_block_convert($args, $R);` calls a
`build_block_convert(args: []Type, ret: Type) -> string` builder
fn. The builder walks `args` to emit the per-shape trampoline +
Block literal source. With this bug, the builder receives an
empty slice and emits the empty-pack source for every call shape,
silently producing wrong block trampolines.
Baseline regression check (not affected): a hand-built `[]i64`
slice round-trips correctly across the same kind of call:
```sx
walk :: (xs: []i64) -> i64 { return xs.len; }
main :: () {
arr : [3]i64 = .{10, 20, 30};
sl : []i64 = arr;
print("call: {}\n", walk(sl)); // prints 3 — works
}
```
So the bug is specific to slices produced by the pack-bare-`$args`
materialisation path (`buildPackSliceValue` /
`materialisePackSlice` in `src/ir/lower.zig`).
## Reproduction
```sx
#import "modules/std.sx";
walk :: (args: []Any) -> string {
return concat("len=", int_to_string(args.len));
}
probe :: (..$args) -> string {
return walk($args);
}
#run print("inline: len={}\n", ($args).len); // not legal — replace
// with a body-local form
#run print("callee: {}\n", probe()); // expected: len=0
#run print("callee: {}\n", probe(1, "x")); // expected: len=2 — fails, reads 0
#run print("callee: {}\n", probe(1, "x", true, 3.14));
// expected: len=4 — fails, reads 0
main :: () {}
```
Cleaner repro that contrasts inline vs callee:
```sx
#import "modules/std.sx";
walk :: (args: []Any) -> i64 { return args.len; }
probe :: (..$args) -> string {
inline_list := $args;
callee_len := walk($args);
return concat(concat("inline=", int_to_string(inline_list.len)),
concat(" callee=", int_to_string(callee_len)));
}
#run print("{}\n", probe(1, "x", true, 3.14));
// observed: inline=4 callee=0
// expected: inline=4 callee=4
main :: () {}
```
Replace `[]Any` with `[]Type` — same wrong result (callee reads 0).
Tested on commit `a394372` (master, 2026-05-27 head as of this
file). `zig build && bash tests/run_examples.sh` is green; the bug
is uncovered, not pre-existing red.
## Investigation prompt
A pack-fn's bare `$args` lowers (per `current/CHECKPOINT-FFI.md`
entry **M5.A.next.4A.bare.1.B**) to:
> `buildPackSliceValue(arg_types)` — emits `alloca [N x Any]`, one
> `const_type(arg_tys[i])` per slot, then a `{data_ptr, len}` slice
> aggregate.
Suspected area:
- `src/ir/lower.zig``buildPackSliceValue` / `materialisePackSlice`.
- Whether the slice aggregate it returns is the same shape sx uses
for an ordinary slice — `{ ptr: *T, len: i64 }` in field order
used by `.len` reads at consumer sites.
- Whether the slice survives the function-call ABI: the callee
reads the slice fields from its frame's slot for the argument;
if `buildPackSliceValue` returns a temporary that's not what the
call-site argument-marshal step picks up, the callee sees uninit.
What to check first:
1. In the pack-fn (`probe(..$args) -> string { walk($args); }`),
dump the IR of `lower_pack_fn_call` for `walk` — confirm
`walk`'s arg-0 is a slice value whose `len` field has been
populated from `arg_tys.len` (or the runtime-built `len`).
2. If the pack-slice goes through `lowerExpr`'s
`comptime_pack_ref` arm, verify the resulting Ref points at
the slice aggregate produced by `buildPackSliceValue` — not at
the underlying `alloca [N x Any]` (which would be the data
pointer, not the slice).
3. Compare with the `[]i64` round-trip path that works — what's
different about how the slice is bound at the call site?
Verification step after fix:
Run the cleaner repro above; both lines should print
`inline=4 callee=4` (and 0/0 for the empty-pack case, 2/2 for
two-arg, etc).
A regression test goes in
`examples/NNN-pack-bare-args-cross-call.sx` once the fix lands.
## Why this matters
The whole point of bare `$args` (step 4A) is that builder fns can
walk the pack as a runtime slice — step 5 calls
`build_block_convert($args, $R)` and the builder emits the
trampoline + Block literal source by iterating `args`. With the
slice's `.len` silently reading 0, every monomorphisation would
emit the empty-pack source, producing wrong trampoline
signatures and silently corrupt block dispatch on Apple targets.
The bug is invisible inline (the same call shape works inside the
pack-fn body), so without the cross-call regression it'd ship
quietly and burn the next person trying to write a `#insert
build_x($args, ...)` style builder.

View File

@@ -1,164 +0,0 @@
# 0049 — new-form variadic `..name: []Type` defined in an imported module crashes LLVM emit
> **RESOLVED.** Root cause: the new-form variadic `..name: []T` had its already-sliced declared type double-wrapped to `[][]T` (helpers treated it like legacy `name: ..T` and added a slice level), so the callee's stored param shape mismatched the call-site's `[N x T]` marshalling and emitted null/undef Refs that crashed `LLVMBuildExtractValue` in `emitStrCmp`.
> Fix: `resolveParamType` (src/ir/lower.zig:642) now returns `declared_ty` as-is when it is already a slice instead of re-wrapping, and the companion `packVariadicCallArgs` (src/ir/lower/pack.zig:298) unwraps the new-form `[]T` back to element `T` for per-element packing so both surface forms converge.
> Covered by regression test `examples/0523-packs-new-form-variadic-cross-module.sx` (pinned, exit 0), which calls the new-form stdlib `path_join` across the import boundary.
## Symptom
A pack-fn declared with the **new** variadic syntax
`..name: []Type` (the form the FFI plan migrates to, replacing
the legacy `name: ..Type`) crashes LLVM IR emission with a
null-operand `LLVMBuildExtractValue` inside `emitStrCmp` when:
1. The pack-fn lives in an **imported** module (e.g. `library/modules/std.sx`).
2. The caller is in a **different** module than the definition.
The same function written with the legacy `name: ..Type` syntax
compiles and runs cleanly. The same new-form definition placed
**locally** in the caller's module (or shadowing the imported
name) also compiles cleanly. The two together — new form + import
boundary — are what trip the emit.
```
Segmentation fault at address 0x0
???:?:?: 0x... in __ZN4llvm5Value11setNameImplERKNS_5TwineE (.../libLLVM.dylib)
???:?:?: 0x... in _LLVMBuildExtractValue (.../libLLVM.dylib)
/Users/agra/projects/sx/src/ir/emit_llvm.zig:3570:48: in emitStrCmp (sx)
const rhs_ptr = c.LLVMBuildExtractValue(b, rhs, 0, "str.rp");
^
/Users/agra/projects/sx/src/ir/emit_llvm.zig:1805:45: in emitInst (sx)
.str_eq => |bin| self.emitStrCmp(bin, true),
```
So an emitted `.str_eq` op has a `rhs` Ref that resolves to a null
LLVM Value. The `.str_eq` is somewhere downstream of the
new-form pack-fn's monomorphisation — most likely an
`any_to_string` / format-side string comparison that the migrated
call path threads back. The `rhs` was either never materialised in
the imported-mono's IR, or was registered against a stale
function/module slot that `emit_llvm` resolves to null.
## Reproduction
Modify `library/modules/std.sx`:
```sx
- path_join :: (parts: ..string) -> string {
+ path_join :: (..parts: []string) -> string {
```
Body unchanged. Then:
```sx
// repro.sx
#import "modules/std.sx";
main :: () {
p := path_join("a", "b");
print("{}\n", p);
}
```
`zig build && ./zig-out/bin/sx run repro.sx` → segfault as above.
Negative controls (all compile and run fine):
```sx
// Same body, OLD form — works:
path_join :: (parts: ..string) -> string { ... }
// New form but defined LOCALLY in the caller:
path_join :: (..parts: []string) -> string { ... }
#import "modules/std.sx"; // imported anyway, just no path_join call
main :: () { p := path_join("a", "b"); ... }
// New form with `$`-prefixed pack name — works EITHER locally or imported:
path_join :: (..$parts: []string) -> string { ... }
```
So the bug is specifically:
- new-form variadic (`..name: []Type`)
- WITHOUT the `$` prefix on `name`
- defined in a module that gets imported (not in the caller's own file)
Suite state when the bug first surfaced: commit `0ede097` (master,
2026-05-27, just after the issue-0048 fix landed and the suite
was green at 213/213). The only delta on top is the
`path_join :: (parts: ..string)``path_join :: (..parts: []string)`
edit in `library/modules/std.sx`.
## Investigation prompt
The FFI plan migrates all stdlib variadic decls from the legacy
form to the new `..name: []Type` form (`path_join`, `format`,
`print`, plus the extern `open` decl, plus the example fixtures).
Per the FFI cadence rule the migration is supposed to be a
mechanical textual change with identical semantics. This bug
blocks that.
The fault location (`emitStrCmp` line 3570 with null `rhs`) is the
crash point, not the root cause. The root cause is one of:
1. **Cross-module pack-fn monomorphisation** — the new-form path
in `monomorphizePackFn` registers the mono'd function in the
current module, but if the mono'd body uses a Ref that
resolves through a stale module/function context, the LLVM
pass through `emit_llvm.functions[fid]` lookup hands back a
null. Compare the new-form mono path to the legacy `name:
..Type` mono path side-by-side — look for any place the latter
threads the caller's module ID / FuncId but the former forgets
to.
2. **Synthesised slot names** — the new-form pack-fn body uses
synthesised `__pack_<name>_<i>` idents for per-position arg
substitution (per CHECKPOINT-FFI step-2b). If these are
re-emitted in the caller's IR against the imported function's
body without re-resolving against the caller's scope, they'd
appear as undef in the final LLVM pass.
3. **The `$` workaround as a hint** — the bug disappears when the
pack name is `..$parts: []string`. Inside the parser /
`isPackFn` discriminator, the `$` prefix routes the function
through the heterogeneous-pack mono path; the new-form
WITHOUT `$` likely routes through a near-but-not-identical
path. Diff the two routes — what does the `$` version do that
the no-`$` version skips when the call crosses an import?
Where to start:
- `src/ir/lower.zig``monomorphizePackFn` (around line 8460),
`materialisePackSlice` (around line 8261), `buildPackSliceValue`
(around line 8225). Trace which gets called for the new-form
no-`$` path.
- `src/ir/lower.zig:lowerPackFnCall` — call-site mono dispatch.
Look for a `$`-prefix branch.
- `src/parser.zig:parseParam` (variadic handling) — confirm what
AST shape the two forms produce. The new form sets `is_variadic
= true` AND `is_comptime = false` (no `$`); the new form WITH
`$` sets both true. The mono path probably gates on `is_comptime`.
Verification step:
After the fix, run with the path_join edit re-applied:
```sh
git diff library/modules/std.sx # confirm new form lands
./zig-out/bin/sx run examples/121-ios-sim-bundle.sx
bash tests/run_examples.sh
```
Both should be green, no segfault.
A regression test goes in
`examples/NNN-new-form-variadic-cross-module.sx` once the fix
lands. The migration of `path_join`, `format`, `print`, and
`open` then proceeds.
## Why this matters
The FFI plan calls for the legacy `name: ..Type` form to be
dropped entirely (`current/CHECKPOINT-FFI.md` references the
`'args: ..Any' is in the plan to change to '..args: []Any'`
migration). Every stdlib consumer plus the chess game needs the
new form. With this bug, stdlib can't be migrated — moving any
stdlib variadic to the new form breaks every program that imports
it. The migration is gated on this fix.

View File

@@ -1,126 +0,0 @@
# 0050 — `monomorphizeFunction` leaks outer pack-fn state into the mono body
> **RESOLVED.** Root cause: `monomorphizeFunction` saved/nulled `type_bindings`/`scope`/builder state but left the outer pack-fn maps live, so a generic callee with an `args`-named param had its `args.len` constant-folded (via `lowerFieldAccess`'s `<pack>.len` intercept) to the first mono's arity and baked into the cached IR.
> Fix: `monomorphizeFunction` in `src/ir/lower/generic.zig` now saves+nulls+defer-restores `pack_arg_nodes` / `pack_param_count` / `pack_arg_types` / `inline_return_target` (lines 51-64), mirroring the `lazyLowerFunction` isolation from issue-0048.
> Covered by regression test `examples/0524-packs-generic-fn-pack-state-leak.sx` (probes print 0/1/2/4).
## Symptom
A generic function (one with `$T: Type` type params) called from
inside a pack-fn's monomorphisation inherits the outer pack maps
during its OWN body lowering. The familiar fall-out: every
identifier in the generic body whose name happens to match the
outer pack's name gets routed through `lowerFieldAccess`'s
`<pack_name>.len` intercept (or `pack_arg_types` / `pack_arg_nodes`
substitutions) and silently picks up the WRONG value.
Same root cause as issue-0048, just a different lowering path.
0048 fixed `lazyLowerFunction`. The generic-monomorphisation path
(`monomorphizeFunction` in `src/ir/lower.zig` around line 8718)
has the same omission: it saves and nulls `type_bindings` /
`scope` / builder state, but does NOT save/null `pack_arg_nodes`
/ `pack_param_count` / `pack_arg_types` / `inline_return_target`.
When the body of the mono'd function references an identifier
named after the outer pack (typical: `args`), the outer
`pack_param_count["args"]` entry is still present and the
`args.len` lowers to a baked-in constant — the pack arity of
whichever shape triggered the FIRST mono. Subsequent shapes call
the same cached mono and read the same wrong constant.
## Reproduction
```sx
#import "modules/std.sx";
build :: (args: []Type, $ret: Type) -> string {
return concat("len=", int_to_string(args.len));
}
probe :: (..$args) -> string {
return build($args, void);
}
#run print("0: {}\n", probe());
#run print("1: {}\n", probe(true));
#run print("2: {}\n", probe(42, "hi"));
main :: () {}
```
Observed:
```
0: len=0
1: len=0
2: len=0
```
Expected: `len=0`, `len=1`, `len=2`. The slice passing across
the function-call boundary already works post-0048; what's wrong
here is that the callee's `args.len` is being constant-folded at
lower time before the runtime slice ever gets a chance to be read.
Negative control — same shape WITHOUT the `$ret: Type` type-param
(so `build` is not generic, gets lowered via `lazyLowerFunction`
+ the 0048-protected isolation):
```sx
build :: (args: []Type) -> string { ... } // no $ret
probe :: (..$args) -> string { return build($args); }
#run print("0: {}\n", probe()); // → 0
#run print("1: {}\n", probe(true)); // → 1
#run print("2: {}\n", probe(42, "hi")); // → 2
```
This works correctly, which confirms the bug is specifically on
the generic-mono path. The IR for the failing case shows
`int_to_string(i64 0)` baked into `@build__void` — the args.len
fold landed at monomorphisation time, not at the call site.
Suite green at commit `952dc0e` (master, 2026-05-27); the bug
surfaces the moment a builder fn (FFI plan's
`build_block_convert(args: []Type, $ret: Type) -> string`) is
written using the new bare-`$args` + `$ret: Type` shape that
step 5 of the FFI plan calls for.
## Investigation prompt
Apply the same isolation pattern that landed for
`lazyLowerFunction` (commit `0ede097`, issue-0048):
`monomorphizeFunction` in `src/ir/lower.zig` (~line 8718) needs
a save+null+defer-restore block covering:
- `self.pack_arg_nodes`
- `self.pack_param_count`
- `self.pack_arg_types`
- `self.inline_return_target`
Place the block alongside the existing
`saved_func` / `saved_block` / `saved_scope` / `saved_bindings`
saves, before the body lowering begins. Mirror the defer pattern
from `lazyLowerFunction` so all early-return paths restore
correctly.
Verification:
Run the reproduction above; the three probes must print 0 / 1 /
2 respectively. Then run `bash tests/run_examples.sh` (must stay
214/214) and `zig build test`.
A regression test goes in
`examples/NNN-generic-fn-pack-state-leak.sx` once the fix lands.
The shape is exactly the reproduction above.
## Why this matters
Step 5 of the FFI plan (`current/CHECKPOINT-FFI.md` — generic
`Into(Block) for Closure(..$args) -> $R` impl in stdlib) calls a
builder `build_block_convert(args: []Type, $ret: Type) -> string`
from inside the impl body. The impl body itself is mono'd per
call shape (where the closure's pack types and return type are
substituted in), and the builder is called from inside that mono.
With this leak, every call shape sees the same wrong source
string from the builder — the trampoline emission silently
produces zero-arity blocks regardless of the actual closure.
Without the fix, step 5 can't proceed.

View File

@@ -1,137 +0,0 @@
**FIXED.** Two parts, both needed for Finder/`open` to work:
> **RESOLVED.** A bundled macOS `.app` launched via Finder/`open` starts with
> CWD=`/`, so CWD-relative asset loads (`read_file_bytes("assets/...")`) missed
> and the app crashed loading fonts/textures. Fixed in `library/modules/platform/sdl3.sx`:
> `SdlPlatform.init` now calls `sdl_chdir_to_bundle()`, which `chdir`s to
> `SDL_GetBasePath()` only when that path lives inside a `.app` (gated on `OS == .macos`),
> mirroring uikit.sx's iOS precedent; the `sx run` dev flow and wasm are left untouched.
> This is a platform-library fix (no `src/**` compiler change); no standalone regression
> test exists since it requires a bundled GUI `.app` launched via Finder.
1. **cwd-relative assets**`SdlPlatform.init` now chdir's to
`SDL_GetBasePath()` when running from inside a `.app` (sx commit
`b31fbae`, `library/modules/platform/sdl3.sx`), mirroring uikit.sx's iOS
`chdir_to_bundle`. Gated to the `.app` case so the `sx run` dev flow keeps
the project CWD.
2. **Gatekeeper-rejected signature** — the bundle was signed with a
*Development* cert, which `spctl` rejects, so a standalone `open` wouldn't
launch it. Switched the macOS build to **ad-hoc** signing (the bundler's
macOS/sim default — just leave the codesign identity empty; game commit
`d80a350`, `build.sx`).
Verified: a plain `cd game && sx build main.sx` then `open
sx-out/macos/SxChess.app` launches and renders (was: instant `stbtt` segfault
on missing font, or no launch at all). It was NOT App Translocation — the
bundle had no `com.apple.quarantine` xattr.
# Symptom
A bundled macOS `.app` built with `sx build` crashes on launch when started
via Finder double-click or `open Foo.app`, but runs fine when launched from a
shell whose CWD is the bundle directory.
Observed: `open sx-out/macos/SxChess.app` → process exits within ~1s (segfaults
inside `stbtt_ScaleForPixelHeight` because the font buffer is null — the asset
wasn't found).
Expected: double-click / `open` launches the app and it finds its bundled
assets, same as on iOS.
Root: assets are loaded with **CWD-relative** paths (e.g.
`"assets/fonts/default.ttf"`), but Finder/`open` start a GUI app with `CWD=/`,
so the relative path resolves against `/` and the file is missing.
# Reproduction
Any consumer that bundles an `assets/` dir and loads from it by relative path.
Minimal shape (real case: `/Users/agra/projects/game`):
```sx
// main.sx — loads assets by CWD-relative path
g_pipeline.init_font("assets/fonts/default.ttf", 32.0, dpi); // -> read_file_bytes
g_chess_game.pieces.load("assets/chess/pieces.png", gpu); // -> read_file_bytes
```
```sh
cd game && sx build main.sx # produces sx-out/macos/SxChess.app (assets copied in)
# Works (CWD = bundle dir, so "assets/..." resolves):
cd sx-out/macos/SxChess.app && ./SxChess
# Fails (CWD = /, asset not found -> null buffer -> stbtt segfault):
open sx-out/macos/SxChess.app
# or double-click in Finder
```
`add_asset_dir("assets", "assets")` in `build.sx` correctly copies the tree
into the flat `.app` (binary at `SxChess.app/SxChess`, assets at
`SxChess.app/assets/...`), so the files ARE present in the bundle — they're just
not found because the lookup is CWD-relative and CWD isn't the bundle.
# Root cause
The macOS SDL startup never reorients CWD (or the asset root) to the bundle.
`SdlPlatform.init` ([library/modules/platform/sdl3.sx:35](../library/modules/platform/sdl3.sx#L35))
calls `SDL_Init(SDL_INIT_VIDEO)` and creates the window but does no `chdir`,
so `read_file_bytes`
([library/modules/ui/glyph_cache.sx:202](../library/modules/ui/glyph_cache.sx#L202),
and the chess `extern read_file_bytes`) opens paths relative to whatever CWD
the launcher set — `/` under Finder/`open`.
The other platforms already handle this:
- **iOS**: [library/modules/platform/uikit.sx:346](../library/modules/platform/uikit.sx#L346)
explicitly `chdir`s to the bundle's `resourcePath()` at startup, with the
comment "iOS apps start with CWD=/. chdir to the bundle's resourcePath so the
[assets resolve]".
- **Android**: [library/modules/platform/android.sx:71](../library/modules/platform/android.sx#L71)
routes `read_file_bytes` through `AAssetManager` so paths resolve against the
APK assets regardless of CWD.
macOS has neither — so it only works by accident when launched from a shell
sitting in the bundle dir.
# Investigation prompt
For a fresh session picking this up:
The fix mirrors the iOS precedent. In `SdlPlatform.init`
([sdl3.sx:35](../library/modules/platform/sdl3.sx#L35)), before any asset is
loaded, reorient to the bundle's resource directory **on macOS only** (leave
wasm/emscripten and the dev `sx run` path alone — those legitimately want the
project CWD).
Recommended approach — `SDL_GetBasePath()`:
- SDL3 `SDL_GetBasePath()` returns the directory containing the executable
(for a `.app`, that's `SxChess.app/` where the assets were copied). `chdir`
to it at the top of `init` when `BuildOptions.is_macos` (gate so `sx run`
during development isn't affected — or gate on "the base path differs from
CWD and contains an `assets/` dir").
- Add the `extern` decl for `SDL_GetBasePath` (returns `*u8`, SDL-owned) and
call `chdir` (already used by uikit.sx — reuse the same `extern`).
Alternative (no SDL dependency): `_NSGetExecutablePath` + `dirname`, same as a
plain macOS resolve. SDL_GetBasePath is simpler and already links SDL3.
Things to verify / watch:
- Don't chdir for the `sx run <file>` (JIT) dev flow or for wasm — only the
bundled AOT macOS app. The cleanest gate is the bundle context; if `init`
can't see `BuildOptions`, gate on `SDL_GetBasePath()` returning a path that
ends in `.app/` (bundled) vs the build dir (dev).
- After chdir, the existing `"assets/..."` relative loads resolve unchanged —
no call-site changes needed in consumers (chess, glyph_cache).
- Confirm the iOS path (uikit, doesn't use SdlPlatform) is untouched.
# Verification
```sh
cd game && sx build main.sx
open sx-out/macos/SxChess.app # must launch and stay up (board + pieces render)
# screenshot / pgrep -lf sx-out/macos/SxChess -> alive after a few seconds
```
Before the fix: `open` → exits ~1s (stbtt segfault, null font buffer).
After: `open` and Finder double-click both launch and render, matching the
`cd bundle && ./SxChess` behavior.
Also re-run host + cross suites to confirm no platform-module regression:
`zig build && zig build test && bash tests/run_examples.sh`.

View File

@@ -1,76 +0,0 @@
**FIXED.** `packVariadicCallArgs` ([src/ir/lower.zig](../src/ir/lower.zig))
> **RESOLVED.** A slice-of-protocol variadic `..xs: []P` stored each raw 8-byte
> concrete arg into a 16-byte protocol-sized array slot, yielding garbage
> `{ctx, vtable}` values and a Bus error on `xs[i].method()` dispatch.
> Fixed in `packVariadicCallArgs` (`src/ir/lower/pack.zig`): it now computes
> `elem_is_protocol` and, per arg, `buildProtocolErasure`-erases each concrete
> value into a real protocol value before storing.
> Regression test: `examples/0535-packs-slice-of-protocol-variadic.sx`.
now detects a protocol element type and `xx`-erases each arg into the
`[N]P` array via `buildProtocolErasure`, instead of storing the raw concrete
value. Regression: [examples/202-slice-of-protocol-variadic.sx](../examples/202-slice-of-protocol-variadic.sx).
# Symptom
A slice-of-protocol variadic `..xs: []P` (P a protocol) compiles but **crashes
at runtime** (Bus error) the moment an element is used:
```
Bus error at address 0x3fff
```
# Reproduction
```sx
#import "modules/std.sx";
Show :: protocol { show :: () -> string; }
A :: struct { x: i64; }
impl Show for A { show :: (self: *A) -> string => "A"; }
each :: (..xs: []Show) -> void {
i := 0;
while i < xs.len { print("{}\n", xs[i].show()); i = i + 1; }
}
main :: () -> i32 { each(A.{ x = 1 }, A.{ x = 2 }); 0; }
```
# Root cause
`packVariadicCallArgs` packs trailing args into a `[N x elem_ty]` stack array.
It special-cased `is_any = (elem_ty == .any)` (box each arg to `Any`), but for
any other non-builtin `elem_ty` it stored the **raw lowered arg** into the
array slot. When `elem_ty` is a protocol struct (16 bytes `{ctx, vtable}`), an
8-byte concrete `A` value was written into a protocol-sized slot — a
size/type mismatch producing a garbage `{ctx, vtable}`. Indexing it and
calling `.show()` then jumped through a bad vtable → Bus error.
# Fix
Mirror the `xx` cast: when the slice element type is a protocol, erase each arg
to the protocol value before storing.
```zig
const elem_is_protocol = blk: {
if (elem_ty.isBuiltin()) break :blk false;
const ei = self.module.types.get(elem_ty);
break :blk ei == .@"struct" and ei.@"struct".is_protocol;
};
// ... per arg, in the non-`is_any` path:
} else if (elem_is_protocol) {
var source_ty = self.inferExprType(arg_node);
if (source_ty == .unresolved) source_ty = self.builder.getRefType(val);
if (source_ty != elem_ty) val = self.buildProtocolErasure(val, arg_node, source_ty, elem_ty);
}
```
This makes `..xs: []P` the runtime, protocol-erased counterpart to the
comptime heterogeneous pack `..xs: P` (which stays comptime-only per Decision
1). See specs.md §"Variadic Heterogeneous Type Packs" for the full
form-comparison table.
# Verification
`examples/202-slice-of-protocol-variadic.sx` prints `[0]=A [1]=B [2]=A` and the
empty call is a no-op. `zig build test` + `bash tests/run_examples.sh` (237)
green.

View File

@@ -1,80 +0,0 @@
**FIXED** via the `xx <pack>` bridge (the preferred fix below), not by changing
> **RESOLVED.** Spreading a comptime pack into a `[]Any`/`[]P` parameter via `xx args` previously
> hit the pack-as-value diagnostic ("pack has no runtime value") before `xx` was considered, so no
> slice was ever materialized. Fixed by intercepting `xx <pack>` with a slice target in the
> `unary_op .xx` arm of `lowerExpr` (`src/ir/lower/expr.zig`) *before* lowering the operand, calling
> `lowerPackToSlice` (`src/ir/lower/pack.zig:167`) to box/erase each element into a runtime `[N]elem` → `[]elem`.
> Covered by regression test `examples/0537-packs-pack-xx-to-slice.sx`.
the `..args` spread. `xx args` with a slice target now materializes the pack
into a runtime `[]Any`/`[]P` — see [examples/204-pack-xx-to-slice.sx](../examples/204-pack-xx-to-slice.sx).
`lowerXX`/the unary-op arm intercepts `xx <pack>` before the pack-as-value
check and calls the new `lowerPackToSlice` ([src/ir/lower.zig](../src/ir/lower.zig)).
The bare `..args` spread into a non-variadic `[]Any` param is still unsupported
(use `xx args`); left as-is.
# Symptom
Spreading a comptime pack `..$args` into a `[]Any` parameter — `f(..args)` where
`f` takes `items: []Any` — fails LLVM verification:
```
LLVM verification failed: Incorrect number of arguments passed to called function!
%call = call i64 @log_count(ptr %0, { ptr, i64 }, { ptr, i64 }, double ...)
```
The spread passes the pack's N elements as N separate positional args instead of
materialising a single `[]Any` slice for the one `items` parameter.
# Reproduction
```sx
#import "modules/std.sx";
log_count :: (items: []Any) -> i64 { return items.len; }
forward :: (..$args) -> i64 { return log_count(..args); }
main :: () -> i32 { print("{}\n", forward(1, "hi", 2.5)); return 0; }
```
Expected: `3` (the pack spreads into the `[]Any` slice, like calling
`log_count(1, "hi", 2.5)` against a `[]Any` variadic would).
# Preferred fix — `xx args` (pack → slice materialization)
Rather than make the splat-y `..args` spread collapse into a single slice arg,
the cleaner spelling is an **`xx` cast**, which already means "erase/convert to
the expected type":
```sx
forward :: (..$args) -> i64 { return log_count(xx args); } // target: []Any
```
`xx args` (target-typed) should materialize the pack into the expected slice:
- target `[]Any` → box each pack element to `Any`, build `[N]Any``[]Any`;
- target `[]P``xx`-erase each element to the protocol `P`, build `[N]P`
`[]P` (reuse the slice-of-protocol erasure landed in `packVariadicCallArgs`,
issue 0052).
This reuses the existing `xx`/protocol machinery, reads naturally, and keeps
`..xs` reserved for true spreads into pack/variadic callees.
**Currently `xx args` errors** ("pack 'args' has no runtime value") because the
Step 2.7 pack-as-value check fires on the bare `args` operand before `xx` is
considered. The fix: in `xx` (unary_op `.xx`) lowering, intercept a pack operand
*before* the pack-as-value diagnostic and, when the target type is a slice,
materialize as above.
# Workaround today
Declare the forwarder as the **slice** variadic instead of a pack — then it's
already a runtime `[]Any` and forwards directly:
```sx
forward :: (..args: []Any) -> i64 { return log_count(args); } // works -> 3
```
This is what `examples/162-pack-bare-args.sx` demonstrates.
# Verification
After the fix, `log_count(xx args)` (and the original `..args` form, if also
fixed) should print `3` and pass `sx ir` LLVM verification.

View File

@@ -1,95 +0,0 @@
**FIXED** (`1f6e27d`, `examples/212`). Two root causes:
> **RESOLVED.** A generic-struct instance erased to a parameterized protocol
> (`xx c` : `Combined__i64_i64` → `VL(i64)`) trapped at dispatch because the
> erasure thunk's vtable slot was never bound to the monomorphized instance
> method. Fixed by `instantiateGenericStruct` binding the template name to the
> concrete instance + recording `struct_instance_bindings` (src/ir/lower/generic.zig:1746,1753),
> and `createProtocolThunk` monomorphizing the generic-struct instance method
> for those bindings (src/ir/lower/protocol.zig:createProtocolThunk, ~line 349).
> Covered by `examples/0414-protocols-generic-struct-protocol-erase.sx`.
1. `instantiateGenericStruct` now binds the template name to the concrete
instance (`tb.put(tmpl.name, id)`), so an impl method `self: *Combined`
resolves `self.field` to the instance, not the 0-field generic stub. (This
was a general pre-existing bug — `self.x` failed on *any* generic-struct
impl method.)
2. `createProtocolThunk` monomorphizes the template method for a generic-struct
instance (`Combined.get``Combined__i64_i64.get` with the instance
bindings), so the erasure vtable dispatches instead of an `unreachable` thunk.
`xx c` (Combined → VL($R)) now dispatches correctly. The *full* canonical `map`
additionally needs mapper closure-pack typing + `$R` inference at the call site
(a separate piece) — tracked separately.
# Symptom
`xx c` where `c` is a generic-struct instance and the target is a parameterized
protocol — via a generic `impl P($R) for Combined($R, ..$Ts)` — **compiles
cleanly but traps at runtime** (exit 133) when a method is then called on the
erased value. The protocol value is built with a wrong/empty vtable, so the
dispatch jumps to a bad fn-ptr.
This is the last piece of the canonical `map` (`return xx c;`).
# Reproduction
```sx
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
Combined :: struct($R: Type, ..$Ts: []Type) { sources: (..VL(Ts)); value: $R; }
impl VL($R) for Combined($R, ..$Ts) { get :: (self: *Combined) -> $R => self.value; }
make :: (..sources: VL) -> VL(i64) {
c : Combined(i64, ..sources.T) = ---;
c.value = 99;
c.sources = (..sources);
return xx c; // Combined__i64_i64 -> VL(i64)
}
main :: () -> i32 {
r := make(IntCell.{ v = 1 });
print("{}\n", r.get()); // expect 99; instead traps
0;
}
```
`sx ir` produces clean, verifier-passing IR (no "no visible xx conversion"
diagnostic — so an impl *was* matched), but the JIT traps on `r.get()`.
# Root cause (suspected)
`param_impl_map` is keyed by **concrete** `(protocol, target_args_mangled,
source_mangled)`. The impl `impl VL($R) for Combined($R, ..$Ts)` is generic on
both sides — its source mangles to a generic `Combined` (with `$R`/`$Ts`), not
the concrete `Combined__i64_i64`. Erasing `Combined__i64_i64 → VL(i64)` looks up
`(VL, i64, Combined__i64_i64)`, which doesn't key-match the generic impl; some
looser path still produces a protocol value, but its vtable slot for `get`
isn't bound to the monomorphized `Combined__i64_i64.get` (which returns
`self.value` as `$R`=i64). Calling through it traps.
The fix needs generic-impl matching + per-instance monomorphization for protocol
erasure: when erasing a concrete generic-struct instance to a parameterized
protocol, find the generic impl whose source template matches the instance's
template (binding `$R`/`$Ts` from the instance's recorded bindings —
`struct_instance_bindings`), monomorphize the impl methods for those bindings,
and fill the vtable with the resulting fn-ptrs. Compare:
- `buildProtocolValue` / `buildProtocolErasure` ([src/ir/lower.zig](../src/ir/lower.zig))
— the vtable construction + impl-method lookup.
- `param_impl_map` keying (`Proto\x00<args>\x00<src_mangled>`) and how a generic
source template is (or isn't) matched against a concrete instance.
- `instantiateParamProtocol` (the dst side already works) and
`instantiateGenericStruct`'s `struct_instance_bindings` (the source bindings).
# Verification
The reproduction should print `99`. Plain (non-generic) struct → parameterized
protocol erasure already works (`examples/206`: `xx IntCell -> VL(i64)`); the gap
is specifically a *generic-struct* source matched via a *generic* impl.
# Status
Everything else in the canonical `map` works: `Combined($R, ..sources.T)`
instantiation (`examples/209`), `c.sources = (..sources)` materialization with
per-element erasure (`examples/210`), and `mapper(..sources.value)` projection +
spread (`examples/211`). This erasure is the final blocker.

View File

@@ -1,126 +0,0 @@
# 0055 — binary arithmetic accepts mismatched operand types (`i64 + string`)
> **RESOLVED.** Scalar binary ops derived the result type from the LHS and never
> checked the RHS, so `i64 + string` lowered as `add : i64` and reinterpreted the
> string's bytes (garbage); ordering/bitwise had the same hole. Fixed in
> `lowerBinaryOp` (src/ir/lower/expr.zig:2520), which now gates arithmetic /
> ordering / bitwise-shift groups through `isArithOperand` / `isOrderingOperand` /
> `isBitwiseOperand` (src/ir/lower.zig:1408-1437) — `.unresolved` passes through,
> a concretely incompatible operand emits `cannot apply '<op>' …` and returns a
> placeholder sentinel. Covered by `examples/1106-diagnostics-binop-operand-type-check.sx`.
**FIXED** (`examples/214-binop-operand-type-check.sx`). `lowerBinaryOp` in
[src/ir/lower.zig](../src/ir/lower.zig) now checks operand-type
compatibility for every scalar binary-op group before emitting, via three
predicates that pass `.unresolved` through (so a type we couldn't infer is
never falsely diagnosed) but reject a concretely incompatible operand:
- **arithmetic** `+ - * / %``isArithOperand` (numeric / vector /
pointer). Without it `i64 + string` lowered as `add : i64` and
reinterpreted the string's bytes — garbage.
- **ordering** `< <= > >=``isOrderingOperand` (numeric / enum / pointer
/ bool / vector). Without it `i64 < string` fed mismatched LLVM types to
`icmp` and tripped the verifier.
- **bitwise / shift** `& | ^ << >>``isBitwiseOperand` (integer / enum /
bool / vector). Without it `i64 & string` reinterpreted the bytes.
On mismatch it emits `cannot apply '<op>' to operands of type '<lhs>' and
'<rhs>'` and returns a placeholder sentinel instead of the corrupting op.
The existing optional-unwrap and int×float promotion are applied before the
check. Legitimate mixes — flags-enum bitwise (`.read | .write`,
`perm & .read`), enum/pointer comparison, int literals — are unaffected
(covered by `examples/50-smoke.sx`). Regression test: `examples/214`
(rejects `+ * < & <<` against a `string` operand).
Still NOT covered (left deliberately): equality `==` / `!=`, whose path is
heavily special-cased (string `str_eq`, `Any` unbox, `optional == null`).
`i64 == string` still slips through. Folding a compatibility check into
that path without regressing the special cases is a separate change — open
a fresh issue if it bites.
## Symptom
Binary arithmetic operators (`+`, `-`, `*`, `/`, `%`) perform **no
operand-type compatibility check**. `i64 + string` compiles cleanly and
runs, reinterpreting the `string` operand's bytes (pointer/len) as an
integer.
- **Observed:** `a + c` where `a: i64`, `c: string` compiles and prints a
garbage number (e.g. `4346102832``40 + <string data pointer>`).
- **Expected:** a type-error diagnostic, e.g.
`cannot apply '+' to operands of type 'i64' and 'string'`.
Surfaced in `examples/213-canonical-map.sx`: a third source `v3: StrCell`
(`VL(string)`) was added so the mapper became `(a, b, c) => a + b + c`
with `a, b: i64` and `c: string`. The canonical `map` infers the closure
params from the projected pack element types, so `a + b + c` is
`i64 + i64 + string` — which should reject. Instead `r.get()` prints
garbage (`4312977714`).
> Note: the working-tree copy of `examples/213` also contains a *separate*
> typo — `Closure(..sources.Target)` instead of `..sources.T`. `.Target`
> is an unknown projection name and produces "cannot infer type of lambda
> parameter" errors that mask this bug. Restoring `.T` exposes the real
> hole. The two are independent; this issue is about the missing arithmetic
> operand-type check, reproducible standalone with no pack machinery.
## Reproduction
Minimal, standalone (only `modules/std.sx`):
```sx
#import "modules/std.sx";
main :: () -> i32 {
a : i64 = 40;
c : string = "it should error";
r := a + c; // expected: type error (i64 + string)
print("{}\n", r); // actual: prints garbage, e.g. 4346102832
0;
}
```
```
$ ./zig-out/bin/sx run repro.sx
4346102832 # should be a compile error, not a number
```
## Investigation prompt
> Binary arithmetic in the sx compiler does no operand-type compatibility
> check. `lowerBinaryOp` in [src/ir/lower.zig](src/ir/lower.zig) (starts
> ~L2545) computes the result type `ty` purely from the **LHS** operand
> (`var ty = lhs_ty;` ~L2680), then at the final `switch (bop.op)` (~L2735)
> emits `.add => self.builder.add(lhs, rhs, ty)` (and `.sub/.mul/.div/.mod`)
> with that LHS-derived `ty` — never verifying `rhs_ty` is compatible. For
> `i64 + string`, `ty = .i64` and the `string` rhs Ref is fed to an
> `add : i64`, reinterpreting its bytes as an integer.
>
> The fix: before the arithmetic `switch` arms, for the arithmetic ops
> (`add/sub/mul/div/mod`) check that `lhs_ty` and `rhs_ty` are
> arithmetic-compatible (both numeric — int/float — modulo the existing
> int×float promotion at ~L2682, or otherwise an exact match). When they
> are not, emit a diagnostic via the existing
> `if (self.diagnostics) |diags| diags.addFmt(.err, <span>, "cannot apply
> '{s}' to operands of type '{...}' and '{...}'", .{...})` pattern (see the
> diagnostics calls already in this file, e.g. ~L2175, ~L2193) and return a
> non-corrupting sentinel rather than a silently-wrong `add`.
>
> Watch the legitimate non-numeric `+` paths so the check doesn't
> false-positive: tuple ops (`lowerTupleOp`, handled earlier ~L2718),
> string `==`/`!=` (handled ~L2710), optional auto-unwrap (~L2693), and the
> int×float promotion (~L2682). Decide whether string concatenation via `+`
> is intended to be supported at all — if not, `string + string` should
> also reject (currently untested here).
>
> Span: confirm how to get the operator/expression span for the diagnostic
> — `lowerBinaryOp` takes `bop: *const ast.BinaryOp`; check whether
> `ast.BinaryOp` carries a span or whether the enclosing node's span must
> be threaded in (the other `addFmt` sites use `node.span`).
>
> Verify: re-run the repro above — expect a type-error diagnostic and a
> non-zero exit instead of a printed integer. Then restore `examples/213`'s
> `.Target` → `.T` and confirm `(a, b, c) => a + b + c` with a `string`
> third source now errors at the `+` rather than printing garbage. Run
> `bash tests/run_examples.sh` to confirm no legitimate arithmetic
> regressed.

View File

@@ -1,87 +0,0 @@
# 0056 — parameterised-protocol impl not deduped across a diamond import
> **RESOLVED.** An anonymous `impl_block` has no `declName`, so the flat decl
> list's name-keyed dedup let the same cached AST node (shared by both diamond
> paths) be appended twice — `registerParamImpl` in `src/ir/lower.zig` then saw
> two same-module entries and raised "duplicate impl 'Into' for source 'i64'".
> Fixed in `ResolvedModule.mergeFlat` (and the directory-import merge loop) in
> `src/imports.zig`, which now also dedup by node identity via a `seen_nodes:
> AutoHashMap(*Node, void)`. Regression test: `examples/0709-modules-issue-0056-diamond-param-impl.sx`.
**FIXED** (`examples/issue-0056-diamond-param-impl.sx`, helpers in
`examples/issue-0056/`). The flat decl list in
[src/imports.zig](../src/imports.zig) now dedups by **node identity** as well
as by name. `ResolvedModule.mergeFlat` and the directory-import merge loop each
carry a `seen_nodes: std.AutoHashMap(*Node, void)` alongside the existing
`seen_in_list` name set, and skip a decl whose pointer was already appended.
## Symptom
A module containing a parameterised-protocol impl (`impl Into(T) for S`) could
not be imported through more than one path. Under a diamond —
```
main ─┬─ mid_a ─┐
└─ mid_b ─┴─ common (holds `impl Into(Wrapped) for i64`)
```
— compilation failed with:
```
error: duplicate impl 'Into' for source 'i64' in .../common.sx
```
This bit the moment `modules/std/objc.sx` (imported by `main.sx`,
`platform/uikit.sx`, and `gpu/metal.sx` — a diamond) gained an
`impl Into(*NSString) for string`.
## Root cause
`mergeFlat`/`addOwnDecl` dedup the global flat decl list by
`decl.data.declName()`. Named decls (structs, fns, runtime classes) dedup
fine across diamonds. But `impl_block` is anonymous — `declName()` returns
`null` (see [src/ast.zig](../src/ast.zig) `Data.declName`) — so the dedup
guard was skipped and the **same cached** impl node (modules are cached in
`ModuleCache`, so both paths share one node pointer) was appended once per
path. `registerParamImpl` in [src/ir/lower.zig](../src/ir/lower.zig) then saw
two entries with the same `defining_module` and raised the same-file
"duplicate impl" diagnostic.
The pre-existing `impl Into(Block) for Closure(...)` in
`modules/std/objc_block.sx` never tripped this because that module is not
imported through any diamond.
## Reproduction
`examples/issue-0056/common.sx`:
```sx
Wrapped :: struct { v: i64; }
impl Into(Wrapped) for i64 {
convert :: (self: i64) -> Wrapped {
return .{ v = self };
}
}
```
`mid_a.sx` / `mid_b.sx` each `#import "common.sx";`. The diamond main:
```sx
#import "modules/std.sx";
#import "issue-0056/mid_a.sx";
#import "issue-0056/mid_b.sx";
main :: () -> i32 {
w : Wrapped = xx 7; // pre-fix: duplicate impl 'Into' for source 'i64'
print("{}\n", w.v); // post-fix: prints 7
0;
}
```
## Fix
Dedup the flat decl list by node identity in addition to name — a physical
AST node must be lowered once regardless of how many import paths reach it.
Named-decl first-wins behaviour is unchanged. Regression test:
`examples/issue-0056-diamond-param-impl.sx` (in `tests/run_examples.sh`).

View File

@@ -1,108 +0,0 @@
# 0057 — `xx`-to-Any variadic arg inside an imported-module function segfaults
## ✅ RESOLVED
Root cause: when lowering a variadic-pack call (`lowerPackCall` in
`src/ir/lower.zig`), the pack args were lowered with whatever `self.target_type`
happened to be set to from the surrounding context. For a bare arg this is
harmless (`inferExprType` ignores `target_type`), but `xx <expr>`'s result type
IS `target_type` — so `format("…", xx i)` inside a `-> string` function cast the
int to `string`, monomorphized `__pack_string`, and ABI-coerced the 4-byte int
as a 16-byte string fat pointer → memory corruption. (Inline it happened to work
because `target_type` was null there; the imported-module path left it set.)
Fix: clear `self.target_type` (save/restore) around the pack-arg lowering loop —
a pack arg is independently typed (comptime `..$args` auto-boxes to `Any`; a
value pack takes its element/protocol type), never coerced to a leftover outer
target. Regression: `examples/242-xx-any-pack-cross-module.sx` (+ companion
`242-xx-any-pack-cross-module/fmt.sx`). Gates: zig build, zig build test, 279
examples pass. The original symptom/repro/investigation notes are kept below.
## Symptom
A `format(...)` / `print(...)` call whose variadic arg is an **explicit `xx`
cast to `Any`** segfaults at runtime (`__platform_memmove`, an Any-box/string
copy corruption) **when the call is inside a function defined in an imported
module**. The identical code works (a) inline in the main file, and (b) in an
imported module if the arg is passed *without* `xx` (auto-boxed).
- **Observed:** `Segmentation fault at address 0x1...` in `__platform_memmove`,
via `runJITFromObject` (target.zig:244). Crashes for any int width (i32, u64).
- **Expected:** prints the formatted string, same as the auto-boxed / inline
forms.
## Reproduction
`library/modules/zz_repro.sx`:
```sx
#import "std.sx";
build :: (n: i32) -> string {
result := "x:\n";
i : i32 = 0;
while i < n {
line := format(" item {}\n", xx i); // <-- xx cast to Any is the trigger
result = concat(result, line);
i = i + 1;
}
result;
}
```
Driver (e.g. `.sx-tmp/repro.sx`):
```sx
#import "modules/std.sx";
m :: #import "modules/zz_repro.sx";
main :: () -> i32 { print("[{}]", m.build(2)); return 0; }
```
Run: `./zig-out/bin/sx run .sx-tmp/repro.sx` → segfault.
### Isolation (what is / isn't the trigger)
- **Auto-box works:** change `xx i``i` in the module → prints fine.
- **Inline works:** put the same `build` body (with `xx i`) directly in the
driver's `main` (no import) → prints fine.
- **Width-independent:** `xx` on an `i32` or a `u64` both crash.
- So the trigger is specifically: **explicit `xx <int>` → Any as a variadic
`format`/`print` arg, in a function that lives in an imported module.**
The crash blocked ERR step E3.3 (`library/modules/trace.sx`), whose
`trace.to_string()` formatted each frame with `format("... {}\n", i, xx frame)`
where `frame : u64` — exactly this pattern. (Dropping the `xx` would dodge it,
but per the project STOP rule that workaround is not taken; the trace formatter
waits on this fix.)
## Investigation prompt
The bug is in how an explicit `xx`-to-`Any` cast lowers for a variadic argument
when the enclosing function is monomorphized/emitted as part of an **imported
module** (vs the root module). Auto-boxing (the implicit `T → Any` coercion at
the variadic call site) produces correct code; the explicit `xx` path does not,
but only across the module boundary — strongly suggesting the `xx``Any`
box (`box_any` / `boxAny` in `src/ir/lower.zig` + `src/ir/emit_llvm.zig`'s
`.box_any` arm) emits a value/width that the variadic-pack marshalling
(`any_to_string` / the `#insert build_format` arg materialization in
`library/modules/std.sx`) then `memmove`s incorrectly — possibly a stale
`source_type`, a pointer-vs-value confusion, or the imported-module emit losing
the box's type so the fat-pointer/string copy reads garbage.
Suspected area:
- `src/ir/lower.zig`: the `unary_op .xx``coerceToType(..., .any)` /
`boxAny` path, and how variadic args are collected for `format`/`print`
(`build_format` / the `..$args` pack). Compare the IR for the inline vs
imported-module versions (`sx ir` on each) — the diff at the `xx i` arg site
is the lead.
- `src/ir/emit_llvm.zig`: `.box_any` (≈ line 3258) — `coerceToI64` /
`anyTag(source_type)`. Check whether the imported-module path supplies a
wrong `source_type` (e.g. `.unresolved` / `.void`) so the tag/width is off.
Verification: run the reproduction above; expect `[x:\n item 0\n item 1\n]`
(no segfault). Then re-confirm the auto-box and inline forms still work, and
that `xx` on a non-Any target (e.g. `xx ptr` to integer) is unaffected.
Once fixed, ERR E3.3 resumes: restore `library/modules/trace.sx` (the
`trace.to_string()` / `print_current()` formatter) using the `xx frame` form,
and complete the E3.3 step (example + snapshot + commit).

View File

@@ -1,107 +0,0 @@
# 0058 — large/bundled macOS build links with an empty DWARF debug map
## ✅ RESOLVED (2026-06-01)
Root cause: **an empty `DW_AT_comp_dir`**. A source path with no directory
component (`sx build main.sx` from the project dir) made `emit_llvm`'s
`diFileFor` emit a `DIFile` with an empty `directory:`, so the compile unit's
`comp_dir` was `""`. Apple's `ld` then silently drops the *entire* object's
debug map (no `N_OSO`) — the binary becomes undebuggable. Builds whose path had
any directory component (`.sx-tmp/x.sx`, `examples/x.sx`) were unaffected, which
is why small repros + the smoke passed and only the chess app (`sx build
main.sx`) hit it.
Fix: `diFileFor` falls back to `"."` (and `/` for a root-level file) when the
path has no directory component, so `comp_dir` is never empty. One-line change
in `src/ir/emit_llvm.zig`. Regression guard added to the DWARF unit test in
`src/ir/emit_llvm.test.zig` (asserts `DIFile(... directory: ".")` for a bare
filename). Verified: chess (`sx build --target macos --emit-obj main.sx`) now
links with `OSO: 1` and lldb resolves `frame at main.sx:82:8`.
---
## Symptom
One-line: `sx build --emit-obj` produces a **debuggable** binary for small sx
programs (lldb resolves `.sx:line`), but for the chess app (a large,
multi-module bundled macOS build) the linked binary has an **empty debug map**
(0 `N_OSO` entries) even though `main.o` carries valid DWARF — so lldb / a
VSCode debug session cannot resolve any sx source for the real app.
- **Observed:** `dsymutil -dump-debug-map sx-out/macos/SxChess``---` (no
objects). `nm -ap … | grep -c ' OSO '` → 0. lldb shows
`where = SxChess\`frame, … unresolved` (symbol present, no `at main.sx:line`).
A `dsymutil` `.dSYM` is therefore empty (UUID matches, but no line info).
- **Expected:** an `N_OSO` debug-map entry for `main.o` (like every small
build gets), so lldb resolves `func at main.sx:line` and steps sx source.
## What's confirmed / ruled out
- `main.o` **has** valid DWARF: `llvm-dwarfdump --debug-line .sx-tmp/main.o`
shows `main.sx`; `--debug-info` shows `DW_TAG_compile_unit DW_AT_name
"main.sx"`, DWARF32 v4. So DWARF emission (ERR E3.0 slices 12) is fine and
`--emit-obj` correctly forces `-O0`.
- The link command is clean — `cc <objs> -o out -lc -lobjc -framework
Foundation -framework Metal -L/opt/homebrew/lib -lSDL3`. **No** `-S` /
`-dead_strip` / `strip` / `-g` / explicit linker override.
- NOT caused by: the framework/lib set (linking a small DWARF object with the
exact same frameworks keeps `OSO=1`); ad-hoc **codesign** (signing a small
binary keeps `OSO=1`; the binary is `linker-signed` by ld anyway); the
**bundler** (the *pre-bundle* `sx-out/macos/SxChess` already has 0 OSO, and
`bundle.sx` runs no `strip`); **opt level** (DWARF is present in the .o);
`#import "modules/std.sx"` (a tiny std-importing program keeps `OSO=1`);
missing **global `_main`** (chess *does* have `T _main`).
- The remaining correlate: chess's `main.o` is **large** (~1.1 MB, many
monomorphized functions merged from many imported modules). Every small
repro tried keeps `OSO=1`; only the large multi-module build drops it. A
minimal repro has NOT been isolated yet.
## Reproduction
Not yet minimal (this is part of the fix). Reproduces reliably on the chess
app at `~/projects/game`:
```sh
cd ~/projects/game
/path/to/sx build --target macos --emit-obj main.sx
dsymutil -dump-debug-map sx-out/macos/SxChess # → `---` (empty); BUG
llvm-dwarfdump --debug-line .sx-tmp/main.o | grep main.sx # DWARF IS present
```
Small programs (single file, with or without `#import "modules/std.sx"`, with
or without the chess framework set) all produce `OSO: 1` and step fine in lldb.
## Investigation prompt
The Mach-O **debug map** (`N_OSO`/`N_FUN` stabs) is synthesized by Apple's
`ld` at link time from each object's DWARF + symbol table; sx does not emit it
directly. ld is silently emitting **none** for the large chess `main.o`.
Suspected areas:
1. **Object emission** — `src/ir/emit_llvm.zig` `emitObject` /
`LLVMTargetMachineEmitToFile`. Diff the working small `main.o` vs the chess
`main.o`: do both have the same Mach-O symbol-table shape (N_FUN/global
anchors), `__DWARF` section set, and `__debug_aranges`/`__debug_line`
layout? A large single compile unit, a DWARF feature ld dislikes
(e.g. `.debug_names`, `DW_FORM` ld can't follow), or a section-size/symbol
threshold could make ld skip the debug map for that object.
2. **Minimize**: grow a single-file program (more functions / import more
modules / pull in `platform/bundle.sx`) until `dsymutil -dump-debug-map`
flips from 1 → 0. That pins the trigger (size? a specific module/construct?).
3. Check whether ld prints a (suppressed) warning — run the link with
`SX_DEBUG_LINK=1` and `-Wl,-debug_variant` / ld verbosity, or link the
chess objects by hand with `ld -v`.
Likely fix is in how we emit the object's DWARF/symbol table (so ld accepts
it), or emitting our own `.dSYM`-compatible companion. The cheap workaround for
users until fixed: debug a small sx repro of the failing logic, or emit per-
module objects. Verification: `dsymutil -dump-debug-map <chess-binary>` lists
`main.o`, and `lldb` resolves `frame` → `main.sx:line`.
## Impact
Blocks source-level **debugging of real (large/bundled) sx apps** in lldb /
VSCode. The trace-formatting feature (ERR E3) is unaffected — runtime + comptime
return traces resolve in-process via the embedded `Frame` table (no debug map
needed). ERR E3 stepping rungs 12 (small macOS + iOS-sim binaries) are still
verified; this gap is specific to large builds' debug map.

View File

@@ -1,101 +0,0 @@
# 0059 — expression-bodied lambda with inferred return type reaches LLVM emission unresolved
> **✅ RESOLVED.** Root cause: `resolveReturnType` ([src/ir/lower.zig]) infers a
> no-annotation function's return type from its body, but the body references the
> function's own params — which weren't in `self.scope` yet (they're bound later,
> at body lowering). So `inferExprType` couldn't resolve `x` in `(x: i32) => x * 2`
> and returned `.unresolved`, which reached LLVM emission. It only slipped through
> when a same-named binding happened to linger in scope from earlier lowering.
> Fix: bind the function's plain annotated value params into a temporary scope
> during return-type inference (resolving types directly via
> `resolveTypeWithBindings`, not `resolveParamType`, whose variadic/pack
> bookkeeping must run exactly once at body lowering). Covers both the arrow (`=>`)
> and inferred-via-`return` forms. Regression test:
> `examples/0308-closures-arrow-inferred-return.sx`.
## Symptom
An expression-bodied lambda (`name :: (params) => expr;`) **without** an explicit
return-type annotation aborts the compiler with:
```
thread … panic: unresolved type reached LLVM emission — a type resolution failure was not diagnosed/aborted
src/ir/emit_llvm.zig:4594 toLLVMTypeInfo (.unresolved => @panic(...))
src/ir/emit_llvm.zig:4457 toLLVMType
src/ir/emit_llvm.zig:1658 declareFunction ← const raw_ret_ty = self.toLLVMType(func.ret);
src/ir/emit_llvm.zig:322 emit
```
- **Observed:** the lambda's `func.ret` is `.unresolved` when `declareFunction`
runs, so emission panics (SIGABRT, exit 134).
- **Expected:** the inferred return type (`i32` here) is resolved before
emission; the program prints `14` and exits 0.
## Reproduction
`issues/0059-expr-lambda-inferred-return-unresolved-type.sx` (standalone):
```sx
#import "modules/std.sx";
f :: (x: i32) => x * 2; // inferred return type
main :: () {
print("{}\n", f(7)); // want: 14
}
```
`./zig-out/bin/sx run issues/0059-expr-lambda-inferred-return-unresolved-type.sx`
→ panic above.
### Key contrasts (narrowing clues)
- **Explicit return type works:** `f :: (x: i32) -> i32 => x * 2;` → prints `14`,
exit 0.
- **The same lambda inside a large program works:** the old monolithic
`50-smoke.sx` contained `double :: (x: i32) => x * 2;` as a local const and ran
clean (exit 0). The panic only appears when the expr-bodied inferred-return
lambda is compiled in a *small* module (minimal file, or as the first/only such
function). This strongly suggests the return-type inference for `=>` lambdas is
triggered as a side effect of some other pass that the large file happens to run
and the minimal file does not — rather than being driven unconditionally for
every expr-bodied lambda.
- Both the **top-level** form (`f :: (x: i32) => x * 2;`) and the **local-const**
form (inside `main`) panic identically.
## Investigation prompt (paste into a fresh session)
> An expression-bodied lambda `f :: (x: i32) => x * 2;` with an inferred return
> type panics at `src/ir/emit_llvm.zig:4594` ("unresolved type reached LLVM
> emission") because `func.ret` is still `.unresolved` when `declareFunction`
> (emit_llvm.zig:1658) emits it. Adding an explicit `-> i32` fixes it, and the
> same lambda compiled inside a large module (the old 50-smoke.sx) resolves fine
> — so the inferred-return resolution for `=>` lambdas is running only
> conditionally.
>
> Suspected area: the return-type inference for expression-bodied (`=>`) lambdas
> in `src/ir/lower.zig` (lambda lowering / `inferExprType` of the lambda body) or
> the sema lambda path — the inferred return type is computed lazily / on-demand
> and the demand isn't created for a lambda that is only *called* (not, e.g.,
> involved in whatever the big file does). Compare the block-bodied lambda path
> (`(x) { ... }`) and the explicit-`->`-return path, both of which resolve.
>
> Fix likely needs to: force the expr-bodied lambda's return type to be inferred
> and resolved during lowering of the lambda literal itself (so `func.ret` is
> concrete before emission), independent of call sites; OR, if a body genuinely
> can't be inferred, emit a real diagnostic instead of leaving `.unresolved` to
> trip the emission guard.
>
> Verification: `./zig-out/bin/sx run issues/0059-expr-lambda-inferred-return-unresolved-type.sx`
> should print `14` and exit 0. Then confirm the existing lambda/inference tests
> still pass: `0203-generics-infer-return-type`, `0300-closures-lambda`,
> `0021-basic-expression-bodied-fn`, plus `bash tests/run_examples.sh`.
## Impact on current work
Blocks the `50-smoke.sx` split (test-layout migration, Phase 2): the
**functions** section exercises exactly this construct
(`double :: (x: i32) => x * 2;`), so it cannot be extracted into a standalone
example until this is fixed. Working around it (adding an explicit return type)
would stop testing inferred-return lambdas and hide the bug, so per the project's
impassable rule the split is paused here.

View File

@@ -1,113 +0,0 @@
# 0060 — closure-literal composition miscompiles (blocks ERR/E5.1)
> **✅ RESOLVED.** A closure's underlying function carries a hidden `env` arg
> that a bare `(T) -> U` slot doesn't pass, so a closure flowing into a bare
> function-type slot dropped the env (the first user arg landed in the env slot;
> the rest read garbage). Fixes (all in this commit):
> - **`src/parser.zig`** — `isLambda` now accepts `.bang` in the return-type
> lookahead, so failable closure literals (`-> !` / `-> (T, !)`) parse.
> - **`src/ir/lower.zig`** — `createClosureToBareFnAdapter`: a capture-free
> closure flowing into a bare `(T) -> U` slot is bridged by a generated adapter
> carrying the bare ABI (forwards a null env). `lowerLambda` returns the
> adapter `func_ref` for that case. Rejected (no silent miscompile): a
> **capturing** closure into a bare slot (env has nowhere to live), and a
> **failable** closure into a **non-failable** slot (the FFI-boundary rule).
> - **`src/ir/lower.zig`** — arrow-body failable closures (`-> (T, !) => expr`)
> now wrap the bare success value into `{value, 0}` via
> `lowerFailableSuccessReturn` (the implicit return previously coerced a bare
> value into the failable tuple and returned `0`).
>
> Regression tests: `examples/0309-closures-literal-as-bare-fn-param.sx`
> (non-failable, block + arrow, called inside the callee) and
> `examples/1039-errors-failable-closure-literal.sx` (failable closures, block +
> arrow, direct + `Closure(...)` param).
>
> **Remaining E5.1 follow-up (not 0060):** calling a **bare** failable
> function-type param (`cb: (i64) -> (i64, !E)`) resolves the call result as
> `unresolved` (the idiomatic `Closure(i64) -> (i64, !E)` form works); the
> non-failable→failable widening adapter is currently *rejected* rather than
> generated; and the program-wide SCC union per closure shape is unimplemented.
## Symptom
A `closure(...)` literal passed **directly as a function-type argument**, where
the callee invokes it, produces wrong values. Surfaced while implementing ERR
E5.1 (composition with closures), but it is **not** error-specific — plain
non-failable closures miscompile too.
`issues/0060-closure-literal-composition-miscompiles.sx`:
```sx
#import "modules/std.sx";
apply :: (f: (i64) -> i64) -> i64 { return f(5); }
main :: () {
print("block={}\n", apply(closure((x: i64) -> i64 { return x * 2; }))); // want 10
print("arrow={}\n", apply(closure((x: i64) -> i64 => x * 2))); // want 10
}
```
- **Expected:** `block=10`, `arrow=10`.
- **Actual:** `block=192`, `arrow=20` (exit 0 — silent miscompile, no diagnostic).
**Working contrast:** `examples/0302-closures-closures.sx`
`apply :: (f, x) -> i64 { return f(x); }` called as `apply(closure(... => ...), 10)`
works. There the piped value arrives as a *separate* argument; here the callee
calls the closure param with a *literal*, and the literal/closure-env marshalling
is wrong. Likely an env/arg-slot mixup when a closure literal is materialized as a
call argument and then invoked with a constant inside the callee.
## Failable-closure follow-ons (the actual E5.1 surface)
Failable closures (`closure((x) -> (T, !) { ... })`) are the point of E5.1.
Two further gaps sit on top of 0060:
1. **Parser — `isLambda` doesn't accept a `!` return type.** A closure/lambda
literal with `-> !` / `-> (T, !)` fails to parse ("expected ','") because the
return-type token-skipper in `isLambda` (`src/parser.zig`, the `arrow` branch
~line 3302) omits `.bang`. One-line fix:
```zig
// self.current.tag == .star or self.current.tag == .question)
// becomes:
self.current.tag == .star or self.current.tag == .question or
self.current.tag == .bang)
```
With that patch, failable closure literals parse and **block-body, directly-
called** ones work end-to-end (success / `catch` / `or` all correct).
2. **Arrow-body failable closures miscompile.** After the parser patch,
`n := closure((x: i64) -> (i64, !E) => x + 1); n(40) catch e 0` returns `0`
instead of `41` — the value slot reads as undef/0. Block-body equivalents
are correct, so it's an arrow-body (`=>`) failable-closure lowering bug
(the expression-body return isn't assembled into the `{value, error}` tuple
the same way the block-body path does). Compare `lowerLambda`
(`src/ir/lower.zig` ~7617) block vs arrow return handling against the named-
function failable return path (`lowerFailableSuccessReturn`).
## Investigation prompt (paste into a fresh session)
> Closure literals passed as a function-type argument miscompile when the callee
> calls them: `apply :: (f: (i64)->i64) -> i64 { return f(5); }` then
> `apply(closure((x: i64) -> i64 { return x*2; }))` prints 192 (want 10); the
> arrow form prints 20. The working pattern (examples/0302) passes the value as a
> separate arg. Suspect the closure-literal-as-call-argument lowering: the
> closure env / the inner call's constant argument is marshalled into the wrong
> slot. Look at how a `closure(...)` literal in argument position is lowered
> (closure construction + the `Closure` calling convention) in `src/ir/lower.zig`
> / `src/ir/emit_llvm.zig`, vs the working separate-arg path.
>
> Then unblock ERR/E5.1: (a) apply the one-line `isLambda` `.bang` patch above;
> (b) fix arrow-body failable closure lowering (returns 0). Verify with a new
> `examples/XXXX-errors-failable-closure-literal.sx`: a block-body and an
> arrow-body failable closure, called directly, consumed by `catch` / `or`; and a
> failable closure passed as a `(T)->(U,!)` parameter and `try`-called inside the
> callee.
## Impact
Blocks ERR/E5.1 (composition with closures/methods/generics): every E5.1
sub-feature — failable closures as parameters, the program-wide SCC union per
closure shape, the FFI rejection check, and the non-failable→failable widening
adapter — needs closure literals to compose correctly first. Per the project's
impassable rule, E5.1 is paused here rather than built on miscompiling closures.

View File

@@ -1,68 +0,0 @@
# 0061 — dead statements after `return` / `raise` emit into a closed block
> **✅ RESOLVED (2026-06-01).** Root cause: `lowerBlock` / `lowerBlockValue`
> ([src/ir/lower.zig](../src/ir/lower.zig)) broke their statement loop only on
> the `block_terminated` flag, which `lowerReturn` deliberately does NOT set (it
> would leak past an `if cond { return }` merge block — see the comment at
> `lowerReturn`). So a bare `return X;` / `raise` mid-block closed the current
> LLVM basic block while lowering kept emitting the trailing statements into it.
> Fix: after each `lowerStmt`, also stop the loop when
> `currentBlockHasTerminator()` is true (CFG-level termination of the *current*
> block — correctly false at an `if`/`inline if` merge block, so conditional
> returns still fall through). Regression test:
> [examples/0038-basic-dead-code-after-terminator.sx](../examples/0038-basic-dead-code-after-terminator.sx).
## Symptom
Any statement following a block-terminating statement (`return`, `raise`) at the
same block level is lowered into the basic block *after* its terminator, so the
LLVM verifier aborts:
```
LLVM verification failed: Terminator found in the middle of a basic block!
label %entry
```
Observed: a well-formed program with trailing dead code crashes the compiler.
Expected: the dead statements are dropped (unreachable); the program compiles
and runs.
This blocked ERR E5.1: the canonical failable-closure form from the plan,
`closure((x) -> (i32, !) { raise error.X; return x; })`, has a dead `return x;`
after the unconditional `raise` and tripped the verifier.
## Reproduction
Minimal (non-failable — the bug is general, not error-specific):
```sx
#import "modules/std.sx";
main :: () -> i32 { return 0; print("dead\n"); }
```
Failable facet (the form that blocked E5.1):
```sx
#import "modules/std.sx";
E :: error { Neg }
top :: (x: i64) -> (i64, !E) { raise error.Neg; return x; }
main :: () -> i32 { print("r={}\n", top(5) catch e 0); return 0; }
```
Both abort with "Terminator found in the middle of a basic block". A
*conditional* terminator (`if c { return 1; } return 2;`) was unaffected — its
merge block is fresh and has no terminator.
## Investigation prompt
The bug is in block-statement lowering in [src/ir/lower.zig](../src/ir/lower.zig):
`lowerBlock` (~line 1455) and `lowerBlockValue` (~line 1496) iterate `blk.stmts`
and only check the `block_terminated` flag. `lowerReturn` (~line 1767) emits a
`ret`/`br` terminator but intentionally does NOT set `block_terminated` (setting
it would leak past `if cond { return }` merge blocks and wrongly skip their
trailing statements — see the comment there). The fix is to stop the loop when
the *current* basic block has a terminator after lowering a statement, using the
existing `currentBlockHasTerminator()` helper (~line 11725), which is naturally
false at a merge block. Verify with both repros above (now compile + run) and
confirm `examples/0518-packs-pack-value-dispatch.sx` (inline-if + return +
trailing statements) still produces all its output.

View File

@@ -1,76 +0,0 @@
# 0062 — generic function with a value-carrying `!` return miscompiles
> **✅ RESOLVED (2026-06-01) — NOT A BUG (invalid repro syntax).** The repro used
> the non-generic form `(T: type, …)` / `(T: Type, …)` — a plain value param of
> type `Type`, NOT a generic type parameter. Per `specs.md` (the `$` sigil
> introduces a generic type parameter), a function generic type param must be
> `$T: Type`. With the correct form, generic value-carrying failable composition
> (ERR E5.1 sub-feature 8) works fully:
>
> ```sx
> wrap :: ($T: Type, f: Closure() -> (T, !E)) -> (T, !E) { return try f(); }
> wrap(i32, closure(() -> (i32, !E) { return 7; })) catch e -1 // 7
> r, err := wrap(i32, closure(() -> (i32, !E) { return 9; })) // r=9
> wrap(i32, closure(() -> (i32, !E) { raise error.Bad; })) catch e -1 // -1
> ```
>
> The only real (separate, orthogonal) defect found: a NON-`$` `T: Type` function
> param used as a type silently resolves to an empty `{}` (renders `T{}`) instead
> of erroring — tracked as **issue 0064**, deferred (not ERR-scoped).
## Symptom
A generic function whose return type is a value-carrying failable in the generic
type param — `wrap :: (T: type, …) -> (T, !E)` — does not substitute `T` in the
failable return tuple during monomorphization. Observed two ways:
- Consumed via `catch`: `LLVM verification failed: PHI node operands are not the
same type as the result!` — the success branch carries `{}` (an unsubstituted
/ empty value) while the handler branch carries the real success type.
- Consumed via destructure: the success value renders as `T{}` (the literal
generic type name) instead of the concrete value, and the error slot is wrong.
Expected: `T` is bound to the concrete monomorphization type (`i32`), the success
value flows through as `7`, and the error slot is `0` on success.
## Reproduction
```sx
#import "modules/std.sx";
E :: error { Bad }
wrap :: (T: type, f: Closure() -> (T, !E)) -> (T, !E) { return try f(); }
main :: () -> i32 {
// catch form → LLVM phi type mismatch:
r := wrap(i32, closure(() -> (i32, !E) { return 7; })) catch e -1;
print("{}\n", r); // want 7
return 0;
}
```
Destructure form (same root cause, different surfacing):
```sx
r, err := wrap(i32, closure(() -> (i32, !E) { return 7; }));
print("{} {}\n", r, xx err); // prints "T{} i64"; want "7 0"
```
## Investigation prompt
The bug is in monomorphizing a value-carrying failable return type in
[src/ir/lower.zig](../src/ir/lower.zig). `monomorphizeFunction` (~10259) /
`resolveReturnType2` (~8309) resolve the return type under `type_bindings`
(`$T` → concrete). For a plain `-> T` this works; for `-> (T, !E)` the value
slot `T` of the failable tuple appears NOT to be substituted — the success
value stays the unsubstituted generic type (rendering as `T{}` / an empty `{}`
in IR), so `lowerFailableSuccessReturn` / `extractSuccessValue` and the `try`
success path produce a value of the wrong type, which the `catch` merge phi then
rejects.
Likely fix: ensure the failable-tuple return type is re-resolved through
`type_bindings` during monomorphization (the tuple's value fields, not just a
top-level `$T`), and that `failableSuccessType` / the `try`/`catch` success
extraction use the substituted tuple. Verify with both repros above (catch →
prints 7; destructure → prints "7 0"). This is ERR E5.1 sub-feature 8 (generic
functions with `!` returns); the program-wide shape-union slice deliberately
excluded generic shapes pending this fix.

View File

@@ -1,63 +0,0 @@
# 0063 — free-function UFCS with a pointer first-param passes the struct by value
> **✅ RESOLVED (2026-06-01).** The free-function UFCS fallback in
> [src/ir/lower.zig](../src/ir/lower.zig) ("Try to resolve as bare function
> name") built `method_args` with the value receiver but never called
> `fixupMethodReceiver`, and never lazily lowered the target — so the receiver
> was passed by value (LLVM signature mismatch) and a UFCS-only function was
> declared but never emitted (link error). Fix: that path now (1) lazily lowers
> `fa.field` if it's a known fn not yet lowered, and (2) calls
> `fixupMethodReceiver` + `coerceCallArgs` exactly like the qualified-method
> path. The explicit `bump(@p)` form was always fine. Regression:
> [examples/0039-basic-free-fn-ufcs-pointer-receiver.sx](../examples/0039-basic-free-fn-ufcs-pointer-receiver.sx).
## Symptom
Calling a **free** function via UFCS where the function's first parameter is a
pointer (`p: *Parser`), on a local struct value, passes the struct BY VALUE
where the function expects a pointer:
```
LLVM verification failed: Call parameter type does not match function signature!
%load = load { i32, i32 }, ptr %alloca, align 4
%call = call i32 @bump(ptr @__sx_default_context, { i32, i32 } %load)
```
The UFCS auto-address-of (`p.bump()``bump(@p)`) does not kick in for free
functions; the receiver is loaded by value instead of having its address taken.
The same method defined **inside** the struct works fine — so this is specific
to free-function UFCS, not method calls in general. Not failable-specific (the
repro is a plain `-> i32`), so this is orthogonal to ERR.
Expected: `p.bump()` on a `*Parser`-first-param free function takes `@p`'s
address, matching the in-struct method behavior.
## Reproduction
```sx
#import "modules/std.sx";
Parser :: struct { pos: i32; }
bump :: (p: *Parser) -> i32 { p.pos += 1; return p.pos; } // FREE fn, pointer first param
main :: () -> i32 {
p := Parser.{ pos = 0 };
print("{}\n", p.bump()); // LLVM signature mismatch
return 0;
}
```
Control (works): move `bump` inside `Parser :: struct { … bump :: (p: *Parser) -> i32 { … } }`.
Also fails with an explicit `bump(@p)` — so the explicit address-of of a local
struct into a pointer param is the underlying miscompile, not just the UFCS sugar.
## Investigation prompt
Two related call paths in [src/ir/lower.zig](../src/ir/lower.zig): (1) UFCS
rewrite of `obj.fn(args)` for a free function whose first param is a pointer —
it must auto-take-address of the receiver (as the in-struct method path does);
(2) more fundamentally, lowering an explicit `@local_struct` argument into a
`*T` parameter loads the struct by value instead of passing its slot pointer.
Compare the in-struct method call lowering (which marshals the `self`/receiver
correctly) against the free-function call + the address-of-local lowering.
Verify with the repro (`p.bump()` and `bump(@p)` both compile + print 1, then 2
if called twice).

View File

@@ -1,86 +0,0 @@
# 0064 — non-`$` `T: Type` function param used as a type silently yields `{}`
> **✅ RESOLVED (2026-06-02).** Root cause as diagnosed: an identifier in a type
> position that resolved to nothing fell through to `type_bridge.resolveTypeName`'s
> empty-struct stub, silently interning a 0-field struct under the name. **Fix
> (option 2, surfaced as a diagnostic):** a new post-scan pass
> `checkUnknownTypeNames` ([src/ir/lower.zig], Pass 1f) walks every main-file
> function signature and non-generic struct field type and rejects any leaf name
> that is not a primitive, an in-scope generic param (`$T` / `type_params`), a
> declared type, or a real (non-stub) registered type. The load-bearing
> empty-struct stub is left intact (forward references + runtime-class opaque
> types still rely on it during the scan); the pass runs after scanning and before
> body lowering, so `core.zig`'s `hasErrors()` halts the build before any stub
> reaches codegen. A value param used as a type gets the tailored hint
> *"'T' is a value parameter, not a type; introduce a generic type parameter with
> `$T: Type`"*; a genuine unknown name gets *"unknown type 'X'"*. Imported concrete
> types are recognized via the type table (`findByName`), so cross-module
> references aren't false-flagged; inline compound spellings (`[:0]u8`), arbitrary-
> width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are all exempted.
> The pass also walks function **bodies** (`checkBodyTypes` + `collectBodyDeclNames`):
> local `var` / `const` type annotations — including inside `if` / loop / `match` /
> `push` / `defer` / `onfail` blocks and decl-value blocks — are checked with the
> enclosing function's generic params in scope, and body-local `T :: struct/enum/
> union` declarations are collected so they aren't false-flagged. This closes the
> silent body-level hole where `v: Coordnate = 5` (a non-existent type) compiled and
> ran with the value dropped. Nested function / closure bodies are their own scope
> and are not descended (safe under-coverage); explicit `cast(T)` already has its
> own `unresolved` diagnostic and is left to it.
> The walk descends into **nested closure / function bodies** too (`walkBodyTypes`
> + `checkScope`): each scope accumulates its generic params onto the parent's, so
> a closure body still sees the enclosing function's `$T`, and a type annotation in
> any nesting depth is checked. `harvestScopeDecls` collects type-decl names across
> the whole body (including nested scopes) so locals aren't false-flagged. Cast
> targets are handled too: `cast(T)` where `T` is a value-`Type` param (the
> otherwise-silent cast case) gets the tailored hint, while an unknown *literal*
> cast target is left to the existing value-resolution `unresolved` diagnostic (no
> double-report). The only remaining under-coverage is benign (annotations buried in
> AST positions the walker doesn't descend stay unchecked — never a false positive).
> Regression tests: `examples/1111` (tailored hint, signature), `1112` (typo'd field
> type), `1113` (body-level local annotation), `1114` (nested-closure annotation),
> `1115` (`cast` value param) — all exit 1. Suite: 350 passed, 0 failed.
## Symptom
A function parameter declared `T: Type` (or lowercase `T: type`) — i.e. WITHOUT
the `$` generic-type-parameter sigil — that is then referenced in a type position
(`-> T`, `Closure() -> T`, etc.) silently resolves `T` to a fabricated empty
struct `{}` instead of the caller's argument type. The function "runs" but
produces garbage (the value renders as `T{}`), with no diagnostic.
```sx
idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
main :: () -> i32 {
print("{}\n", idwrap(i32, closure(() -> i32 { return 7; }))); // prints "T{}", want 7
return 0;
}
```
The correct, working form is the generic `$T: Type` (the `$` introduces the
generic type parameter — see `specs.md` §"`$` generic type parameter
introduction"). With `$T`, the binding is established and the result is `7`.
So this is not a miscompile of a valid program — it's a **missing diagnostic**
for a misuse: a non-generic `Type`-typed value param can't be used as a type, and
should be rejected (or the `$` requirement explained), not silently turned into
an empty struct.
## Reproduction
See above. Compare `idwrap :: ($T: Type, …)` (works, prints 7) vs `idwrap :: (T:
Type, …)` (prints `T{}`).
## Investigation prompt
In [src/ir/lower.zig](../src/ir/lower.zig), `resolveTypeWithBindings` resolves a
`.type_expr` named `T` by checking `type_bindings` (works for `$T`, which
`buildTypeBindings` registers). For a non-`$` `T: Type` param there is no binding,
so resolution falls through to `type_bridge.resolveAstType`, which fabricates an
empty-struct stub for the unknown name `T` — the classic "silent empty struct"
the CLAUDE.md REJECTED-PATTERNS warn about. Fix options: (1) at scan/sema time,
reject referencing a non-`$` `Type`-typed param in a type position with a
diagnostic ("type parameter must be introduced with `$` — write `$T: Type`"); or
(2) make `resolveAstType` return `.unresolved` + a diagnostic for an unknown bare
type name in a generic-eligible position, instead of stubbing `{}`. Deferred —
orthogonal to ERR; the working `$T` idiom exists. Low priority but should not stay
silent.

View File

@@ -1,91 +0,0 @@
# 0065 — block-expression body does not parse a destructure decl (`v, e := f();`)
> **RESOLVED.** Two fixes landed:
> - The braced `defer { … }` body now parses via `parseBlock` (src/parser.zig,
> the `kw_defer` arm) instead of `parseExpr`, mirroring `onfail`. Regression:
> `examples/1050-errors-defer-block-body.sx` (commit `634cf9b`).
> - The general *value-producing block in binding position* fell out of the
> trailing-`;` block-value rework: value-position `{ … }` now routes through
> the same statement parser as every other block, so a destructure decl (and
> any statement form) parses, and the trailing expression is the block's
> value. Regression: `examples/0042-basic-block-value-destructure.sx`.
## Symptom
A destructure declaration (`v, e := f();`) inside a **block used in
expression position** fails to parse with `expected ';'`. Two surfaced
forms:
- `defer { v, e := f(); ... }` — a `defer` body is parsed via `parseExpr`
(so its `{ ... }` is a block-EXPRESSION), and the block-expression
statement loop doesn't recognize the `name, name :=` destructure form.
- `y := { v, e := f(); v };` — a value-producing block bound to a name.
Observed: `error: expected ';'` pointing at the statement *after* the
destructure (the parser bails at the `:=` and resyncs). Expected: the
destructure parses exactly as it does in a normal statement block (an
`if` body, a plain `{ }` statement block, or an `onfail { }` body — all of
which use `parseBlock` and handle it fine).
This is the same family as the pre-existing "value-producing block body
in binding position doesn't parse" note in `current/CHECKPOINT-ERR.md`
(E2.4b log). `onfail { }` is unaffected because it parses its body with
`parseBlock` (src/parser.zig ~2063); `defer` is affected because it uses
`parseExpr` (~2029).
## Reproduction
```sx
#import "modules/std.sx";
E :: error { Bad }
val :: () -> (i32, !E) { return 5; }
f :: () -> !E {
defer {
v, e := val(); // ← error: expected ';'
print("v={}\n", v);
}
return;
}
main :: () -> i32 { return 0; }
```
Also reproduces with no `defer`, as a plain value block:
```sx
y := {
v, e := val(); // ← error: expected ';'
v
};
```
## Investigation prompt
The block-expression statement loop (the parser path reached from
`parseExpr` when it hits `{` — see `src/parser.zig`, the block-as-value
parsing around the `parsePrimary`/`parseBlockExpr` path, distinct from
`parseBlock` at ~1931) parses each inner statement but does not run the
destructure-decl detection that `parseStmt` does. Find where
`parseStmt`/`parseBlock` recognizes the `ident (, ident)+ :=` lookahead
and make the block-expression statement loop use the same statement
parser (ideally route block-expression bodies through `parseStmt` so
every statement form — destructure, var/const decl, etc. — is handled
uniformly).
For `defer` specifically: the simplest aligned fix is to parse a
braced `defer` body with `parseBlock` (like `onfail` does) while keeping
the bare-expression form (`defer expr;`) on `parseExpr`. That removes the
defer-body manifestation even if the general block-expression path is
handled separately.
Verification: run the repro above — expect it to compile and run
(`exit 0`), with the destructure-bound value usable under an `if !e { … }`
guard (ERR E1.8). Add a regression example under `examples/` once fixed.
## Status
OPEN. Orthogonal to ERR E1.7/E1.8 — the spec'd cleanup-body absorbers are
`catch` / `or <value>` (both parse fine in a `defer` body), so this does
not block the error-handling work. Filed while implementing E1.7.

View File

@@ -1,66 +0,0 @@
# 0066 — match-as-value with a negated-literal arm builds a mismatched phi
> **RESOLVED.** `lowerMatch`'s value path (`has_value_merge`) now lowers each
> arm body with `target_type = result_type`, so literals and negated literals in
> the arms pick the merge's width instead of leaking a narrower one. The phi
> operands are uniform; `coerceToType` still runs afterward as a backstop.
> Regression: `examples/0043-basic-match-value-mixed-width.sx`.
## Symptom
A value-position `match` (the `if subject == { case ... }` sugar) returning a
small integer type, where one arm is a **negated integer literal** (`-1`) and
others are plain positive literals, fails LLVM verification:
```
LLVM verification failed: PHI node operands are not the same type as the result!
%bp = phi i64 [ 100, %match.arm.1 ], [ 10, %match.arm.2 ], [ -1, %match.arm.3 ]
```
The negated arm lowers its value at a different integer width (it emits an i32
that is then sign-extended) than the positive-literal arms (i64), so the merge
phi's operands disagree with its result type. Positive literals in every arm
work; `if/else` with `-1` works. So it is specific to a **negated literal in a
match arm value**.
This is orthogonal to the trailing-`;` block-value rule — the repro uses the
exempt `case …: expr;` arm form (unchanged by that migration) and reproduces
with ordinary semicolon-terminated arms. Filed while writing the block-value
regression example (0040); the example sidesteps it with positive arm values.
## Reproduction
```sx
classify :: (n: i32) -> i32 {
if n == {
case 0: 100;
case 1: 10;
else: -1; // ← negated literal arm → phi width mismatch
}
}
main :: () -> i32 { classify(1) }
```
Expected: compiles, `classify(1)` returns 10. Actual: LLVM verification failure.
Workaround: give the arm an explicitly-typed value (`else: { x : i32 = -1; x }`)
or avoid the negation.
## Investigation prompt
Look at the match-as-value lowering in `src/ir/lower.zig` (`lowerMatch`, the
`has_value_merge` path ~line 4500, and the arm `result_type` inference
~line 11698). The arm value is lowered via `lowerBlockValue(arm.body)` then
`coerceToType(v, v_ty, result_type)`. For a negated-literal arm the value's
type comes out narrower than `result_type` (i64 here), and the coercion path
that should widen it before the `br merge_bb, {v}` either runs against the wrong
target or is skipped — so the phi gets an i32 operand under an i64 result. Make
the arm value coerce to the merge's `result_type` consistently (mirror how the
positive-literal arms are handled), or set `target_type = result_type` while
lowering each arm body so the negate picks the right width. Verify with the
repro above (expect exit 10) and add a regression example.
## Status
OPEN. Pre-existing match-value codegen quirk, surfaced (not caused) by the
block-value work.

View File

@@ -1,92 +0,0 @@
# 0067 — tuple literal used as a type silently accepts non-type elements
> **RESOLVED** (2026-06-02).
> **Root cause:** `type_bridge.resolveTupleLiteralAsType` treated a tuple literal
> as a tuple TYPE and, for any element that wasn't type-shaped, emitted a
> `std.debug.print` and substituted `.i64` for that field — a silent fabricated
> type (the forbidden silent-fallback pattern). The stateful caller
> (`Lowering.resolveTypeArg`, used by `size_of`) delegated `.tuple_literal`
> straight to that path, so `size_of((i32, 1))` compiled and printed `16`.
> **Fix:**
> - `type_bridge.resolveTupleLiteralAsType` now returns `.unresolved` (no `.i64`,
> no debug print) when any element is not type-shaped — it refuses to fabricate
> a tuple. (type_bridge is stateless, so this is the binding-free backstop.)
> - New stateful `Lowering.resolveTupleLiteralTypeArg` validates each element via
> `type_bridge.isTypeShapedAstNode`, emits a user-facing diagnostic at the
> offending element's span, and returns `.unresolved`. It is wired into BOTH
> `resolveTypeArg` (size_of/align_of/…) and the `resolveTypeWithBindings`
> name-fallback; type_bridge builds the tuple only after validation passes.
> **Regression test:** `examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx`
> (exit 1 + diagnostic). Valid `(i32, i32)` still works
> (`examples/0115-types-compound-type-in-expression.sx`). Suite 351/0.
## Symptom
`size_of((i32, 1))` treats the tuple literal as a tuple TYPE even though `1` is
not a type. The compiler prints an internal `type_bridge` debug line, then
silently substitutes `.i64` for that slot and compiles successfully.
Observed:
```text
type_bridge: tuple literal element is not a type (tag=int_literal) — cannot use as tuple type
bad tuple type size = 16
```
Expected: a user-facing compiler diagnostic rejecting the non-type tuple element,
with no fabricated tuple type and no successful run.
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i32 {
print("bad tuple type size = {}\n", size_of((i32, 1)));
0
}
```
Run:
```sh
./zig-out/bin/sx run .sx-tmp/probe-tuple-literal-type-fallback.sx
```
The repro is standalone; the inline source above is sufficient to recreate the
scratch file under `.sx-tmp/`.
## Investigation prompt
Fix issue 0067: tuple literals reinterpreted as tuple types must reject non-type
elements instead of silently fabricating `.i64` fields.
Suspected area:
- `src/ir/type_bridge.zig`, `resolveTupleLiteralAsType`
- The current non-type branch does `std.debug.print(...)` and
`field_ids.append(alloc, .i64)`, which violates the compiler fallback rules.
- Related callers: `type_bridge.resolveAstType` for `.tuple_literal`, and
`Lowering.resolveTypeWithBindings` fallback paths that reach `type_bridge`.
Likely fix:
- Replace the `.i64` substitution with a real diagnostic path and an
unmistakable failure result (`.unresolved`, or a nullable/result return that
forces callers to handle the failure).
- Make the diagnostic user-facing via the lowering diagnostics plumbing, not
`std.debug.print`.
- Preserve the valid behavior pinned by `examples/0115-types-compound-type-in-expression.sx`,
where `(i32, i32)` in a type-demanding site resolves as a tuple type.
Verification:
- Add a focused diagnostics example in the `11xx` block for
`size_of((i32, 1))` expecting exit 1 and a clear diagnostic.
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```
Expected result: the new invalid tuple-type repro fails with a diagnostic, the
valid `0115` tuple-type example still passes, and the full suite remains green.

View File

@@ -1,99 +0,0 @@
# 0068 — top-level value const used as a type silently yields an empty struct
> **RESOLVED** (2026-06-02).
> **Root cause:** the A2.4 unknown-type pass (`semantic_diagnostics`) inherited the
> issue-0064 behavior of adding EVERY `const_decl` name to its declared-type-name
> set. A value const (`NotAType :: 123`) thus satisfied `reportIfUnknownType`, so
> `v: NotAType` was not flagged; lowering then hit `TypeResolver.resolveNamed`'s
> empty-struct-stub fallback and fabricated `NotAType{}`.
> **Fix:** `collectDeclaredTypeNames` and `harvestScopeDecls` now add a const name
> only when its value INTRODUCES a type — gated on a new `constValueIntroducesType`
> (type declarations: struct/enum/union/error; type-expression aliases: type_expr,
> pointer/many-pointer/slice/optional/array/function/closure/tuple, parameterized).
> `.identifier` / `.call` aliases are intentionally excluded: the scan registers
> the type-valued ones into `ProgramIndex.type_alias_map` / the `TypeTable` (both
> queried separately by the pass), so a value-RHS alias is correctly left out and
> flagged, while a type-RHS alias stays covered by the canonical facts.
> **Regression test:** `examples/1117-diagnostics-value-const-as-type-rejected.sx`
> (exit 1). Issue-0064 regressions 11111116 + the `0115` aliases stay green.
> Suite 352/0.
## Symptom
A top-level value constant name is accepted in a type position and silently
resolves to a fabricated empty struct.
Observed:
```text
value = NotAType{}
```
Expected: a user-facing diagnostic rejecting `NotAType` as a type, with no
fabricated empty-struct type.
## Reproduction
```sx
#import "modules/std.sx";
NotAType :: 123;
main :: () -> i32 {
v: NotAType = ---;
print("value = {}\n", v);
return 0;
}
```
Run:
```sh
./zig-out/bin/sx run .sx-tmp/probe-top-level-value-const-as-type.sx
```
The repro is standalone; the inline source above is sufficient to recreate the
scratch file under `.sx-tmp/`.
## Investigation prompt
Fix issue 0068: a value constant name must not satisfy the unknown-type
diagnostic pass or resolve as a fabricated type when used in a type position.
Suspected area:
- `src/ir/semantic_diagnostics.zig`, especially
`UnknownTypeChecker.collectDeclaredTypeNames` and `harvestScopeDecls`.
- The moved issue-0064 pass currently adds every `const_decl` name to the
`declared` set. That preserves old behavior, but it means a value const like
`NotAType :: 123;` suppresses `reportIfUnknownType`, then the later type
resolver's unknown-name fallback interns an empty struct named `NotAType`.
- Related fallback: `TypeResolver.resolveNamed` / `type_bridge.resolveAstType`
still create empty struct stubs for unknown names in paths that the diagnostic
pass is supposed to reject before lowering reaches codegen.
Likely fix:
- Change `collectDeclaredTypeNames` / `harvestScopeDecls` so only declarations
that actually introduce type-position names are added: struct / enum / union /
error declarations, type aliases, generic templates, protocols, extern
classes, and local type declarations.
- Do not add arbitrary value const names to the type-name set.
- Preserve valid type alias behavior such as `Alias :: u32;` and local
type-declaration behavior.
- Keep the pass querying canonical facts (`ProgramIndex`, `TypeResolver`, and
`TypeTable`) rather than reintroducing a parallel top-level truth table.
Verification:
- Add a focused diagnostics example in the `11xx` block for the repro above,
expecting exit 1 and a clear diagnostic.
- Keep issue-0064 regressions green (`1111` through `1115`) and keep existing
alias/type-declaration examples green.
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```
Expected result: `NotAType :: 123; v: NotAType` is rejected with a diagnostic,
valid aliases and type declarations still resolve, and the full suite passes.

View File

@@ -1,114 +0,0 @@
# 0069 — forward identifier type alias is falsely rejected by unknown-type pass
> **RESOLVED.** Root cause: `Lowering.scanDecls`' `.identifier` alias branch only
> registered `A :: B` into `ProgramIndex.type_alias_map` when `B` was already
> known (in `type_alias_map` or the `TypeTable`). A forward target declared later
> (`MyChain :: MyInt; MyInt :: i32;`) was never present during the single forward
> scan, so the alias name went unregistered and the A2.4 unknown-type pass — which
> treats `type_alias_map` keys as declared types — flagged its uses.
> Fix: added a fixpoint post-pass `resolveForwardIdentifierAliases` at the end of
> `scanDecls` that re-resolves identifier-RHS aliases until no progress, after every
> top-level name has been seen. A value const is never an `.identifier` node and an
> alias whose target is a value const still misses both lookups, so issue 0068's
> value-const rejection is preserved. Regression:
> `examples/0132-types-forward-type-alias.sx`.
## Symptom
A forward-referenced identifier type alias is rejected as an unknown type, even
though the same alias chain works when ordered after its target.
Observed: `MyChain` is diagnosed as an unknown type.
Expected: `MyChain :: MyInt; MyInt :: i32;` should resolve `MyChain` to `i32`
when used in a type annotation, matching the existing ordered-chain behavior
(`MyInt :: i32; MyChain :: MyInt;`).
## Reproduction
```sx
MyChain :: MyInt;
MyInt :: i32;
main :: () -> i32 {
v: MyChain = 7;
return v;
}
```
Run:
```sh
./zig-out/bin/sx run .sx-tmp/probe-0068-forward-alias.sx
```
Observed output:
```text
error: unknown type 'MyChain'
--> .sx-tmp/probe-0068-forward-alias.sx:7:8
|
7 | v: MyChain = 7;
| ^^^^^^^
```
The repro is standalone; the inline source above is sufficient to recreate the
scratch file under `.sx-tmp/`.
## Investigation prompt
Fix issue 0069: a forward-referenced identifier type alias must not be falsely
rejected by the A2.4 unknown-type diagnostic pass.
Context:
- This surfaced while re-reviewing `8770145`, the issue-0068 fix. That fix
correctly stopped arbitrary value consts (`NotAType :: 123`) from satisfying
the unknown-type check.
- Ordered identifier aliases still work: `MyInt :: i32; MyChain :: MyInt;`.
- `.call` type aliases still work: `Vec3 :: Vec(3, f32);` and
`Foo :: Complex(u32);`.
- The failing shape is specifically a forward identifier alias:
`MyChain :: MyInt; MyInt :: i32;`.
Suspected area:
- `src/ir/lower.zig`, `Lowering.scanDecls`, especially the `.identifier` alias
branch for `const_decl` values. It only inserts `cd.name` into
`ProgramIndex.type_alias_map` if the RHS is already in `type_alias_map` or
already registered in the `TypeTable`. A forward target is not present yet, so
the alias name is never recorded.
- `src/ir/semantic_diagnostics.zig`,
`UnknownTypeChecker.collectDeclaredTypeNames` / `reportIfUnknownType`. After
issue 0068, `.identifier` aliases are intentionally excluded from
`constValueIntroducesType` and are supposed to be covered by canonical facts
(`ProgramIndex.type_alias_map` / `TypeTable`). Because the forward alias never
reaches those facts, the checker flags the alias as unknown.
Likely fix:
- Do not reintroduce the issue-0068 bug by adding all `.identifier` const names
to the declared-type set.
- Instead, make identifier aliases converge through canonical alias facts even
when the RHS is declared later. A small two-pass alias registration/resolution
in `scanDecls`, or an explicit pending-alias graph that resolves after all
top-level declarations are scanned, would keep `ProgramIndex.type_alias_map`
authoritative without accepting value constants.
- Preserve value const rejection: `NotAType :: 123; v: NotAType` must continue
to emit `unknown type 'NotAType'`.
- Preserve ordered/chained aliases and type-returning call aliases:
`examples/0116-types-type-alias-size-align.sx`,
`examples/0201-generics-generic-struct.sx`, and
`examples/1117-diagnostics-value-const-as-type-rejected.sx`.
Verification:
- Add a focused regression for the repro above, likely in the `01xx` types block
because the desired behavior is successful alias resolution.
- Run the new regression and the existing alias/diagnostic guards:
```sh
zig build
zig build test
bash tests/run_examples.sh
```
Expected result: the forward alias program compiles/runs (returning `7`), the
issue-0068 value-const case still fails with a diagnostic, and the full suite
passes.

View File

@@ -1,113 +0,0 @@
# 0070 — forward alias in top-level global annotation reaches LLVM verifier
> **RESOLVED.** Root cause: issue 0069's `resolveForwardIdentifierAliases`
> fixpoint runs at the END of `Lowering.scanDecls`, but the same scan loop
> resolved top-level `var_decl` global annotations (and typed module-constant
> annotations) via `self.resolveType(ta)` BEFORE that fixpoint ran — so a forward
> alias (`A :: B; B :: i32; g : A = 7;`) was still absent from
> `type_alias_map`, `resolveType` fabricated an empty-struct stub, and the global
> got a type mismatching its initializer at LLVM verification (the typed-const
> path silently mistyped the constant instead).
> Fix: split `scanDecls` into two passes. Pass 1 registers function/type/alias
> facts; then `resolveForwardIdentifierAliases` converges the aliases; then pass 2
> registers top-level `var_decl` globals (`registerTopLevelGlobal`) and typed
> module constants (`registerTypedModuleConst`), so their annotations resolve
> against the converged alias map. Globals/typed-consts can't be named in a type
> position, so deferring them past type/alias registration is order-safe; the
> untyped module-const branch (no annotation to resolve) stays in pass 1.
> Regression: `examples/0133-types-forward-alias-global.sx`.
## Symptom
A forward identifier type alias used as a top-level global's type annotation
does not resolve before the global is registered, producing an LLVM verifier
failure instead of compiling as the alias target type.
Observed:
```text
LLVM verification failed: Global variable initializer type does not match global variable type!
ptr @g
```
Expected: `A :: B; B :: i32; g : A = 7;` should type `g` as `i32` and compile/run
the same way as the ordered alias form.
## Reproduction
```sx
A :: B;
B :: i32;
g : A = 7;
main :: () -> i32 {
return g;
}
```
Run:
```sh
./zig-out/bin/sx run .sx-tmp/probe-0069-forward-alias-global.sx
```
The repro is standalone; the inline source above is sufficient to recreate the
scratch file under `.sx-tmp/`.
## Investigation prompt
Fix issue 0070: a forward identifier type alias used in a top-level global
annotation must resolve before that global's type is registered.
Context:
- Issue 0069 (`49a383d`) added `Lowering.resolveForwardIdentifierAliases`, a
fixpoint post-pass at the end of `scanDecls`, to resolve top-level
identifier-RHS aliases like `A :: B; B :: i32;`.
- That works for aliases used later in function bodies because the A2.4
unknown-type pass and body lowering run after `scanDecls`.
- But top-level `var_decl` annotations are resolved inside the same `scanDecls`
loop before `resolveForwardIdentifierAliases(decls)` is called. So
`g : A = 7;` can be typed while `A` is still absent from
`ProgramIndex.type_alias_map`.
- Suspected area: `src/ir/lower.zig`, `Lowering.scanDecls`, especially the
ordering between `.const_decl` alias collection, the new
`resolveForwardIdentifierAliases`, and the `.var_decl` branch that calls
`self.resolveType(ta)`.
Likely fix:
- Split the scan ordering so all top-level type declarations and identifier
aliases converge before any top-level global annotation is resolved.
- One possible shape: first scan/register function/type/alias facts, run the
forward-alias fixpoint, then handle top-level `var_decl` global registration
and literal module constants that require resolved annotation types.
- Do not reintroduce issue 0068: `NotAType :: 123; v: NotAType` must still emit
`unknown type 'NotAType'`.
- Do not fabricate stubs while trying to resolve the forward alias. The alias
facts should still come from `ProgramIndex.type_alias_map` and real
`TypeTable.findByName` hits.
Verification:
- Add a focused regression, likely in the `01xx` types block:
```sx
A :: B;
B :: i32;
g : A = 7;
main :: () -> i32 { return g; }
```
- Keep `examples/0132-types-forward-type-alias.sx`,
`examples/0116-types-type-alias-size-align.sx`,
`examples/0201-generics-generic-struct.sx`, and
`examples/1117-diagnostics-value-const-as-type-rejected.sx` green.
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```
Expected result: the forward-alias global program exits 7, issue 0068 remains
rejected with a diagnostic, and the full suite passes.

View File

@@ -1,117 +0,0 @@
# 0071 — global initialized from module const silently zero-initializes
> **RESOLVED.** Root cause: `Lowering.registerTopLevelGlobal`'s init_val switch
> serialized only literal / array-literal / struct-literal initializers; an
> identifier initializer (`g : A = K;`) fell through to `else => null`, so the
> global was emitted with no payload and silently zero-initialized.
> Fix: extracted the initializer serialization into `Lowering.globalInitValue`
> and added an `.identifier` arm that materializes the global's static value from
> `ProgramIndex.module_const_map` (typed module consts are registered in the same
> pass-2 just before, via `registerTypedModuleConst`). An identifier that names no
> usable constant now emits a diagnostic instead of silently zeroing. Other
> initializer shapes (enum-literal shorthand, etc.) keep their established
> static-lowering behavior — this pass only closes the identifier/module-const
> hole. Regression: `examples/0134-types-global-init-from-module-const.sx`
> (`g=42` / exit 42).
## Symptom
A top-level global initialized from a module constant compiles but is
zero-initialized instead of receiving the constant's value.
Observed:
```text
g=0
```
Expected: `g` should be initialized to `42`, or the compiler should reject the
initializer loudly if identifier/module-const global initializers are not
supported.
## Reproduction
```sx
#import "modules/std.sx";
A :: B;
B :: i32;
K : A : 42;
g : A = K;
main :: () -> i32 {
print("g={}\n", g);
return g;
}
```
Run:
```sh
./zig-out/bin/sx run .sx-tmp/probe-0070-global-init-from-const.sx
```
The repro is standalone; the inline source above is sufficient to recreate the
scratch file under `.sx-tmp/`.
## Investigation prompt
Fix issue 0071: a top-level global initialized from a module constant must not
silently become zero.
Context:
- This surfaced during Codex re-review of `932cdfa`, the issue-0070 fix.
- `932cdfa` correctly defers top-level global and typed-module-const annotation
resolution until after the forward-alias fixpoint.
- The remaining bug is in the global initializer path, not the annotation path:
`K : A : 42; g : A = K;` resolves `A` correctly, registers `K` in
`ProgramIndex.module_const_map`, but `g` is emitted as zero.
Suspected area:
- `src/ir/lower.zig`, `Lowering.registerTopLevelGlobal`.
- Its `init_val` switch serializes literal / array / struct-literal initializers,
but an identifier initializer falls through to `else => null`, and the global
is emitted with no initializer payload. That silently becomes zero-initialized.
- Related facts: `ProgramIndex.module_const_map` already records typed module
constants via `registerTypedModuleConst`, now in pass 2 after alias convergence.
Likely fix:
- Add explicit handling for identifier initializers that name a module constant,
converting the recorded constant value into the global's `ConstantValue` with
the global's declared type.
- If some initializer shape cannot be represented as a global constant yet, emit
a diagnostic instead of returning `null` / zero-initializing.
- Do not regress issue 0070: `A :: B; B :: i32; g : A = 7;` and
`K : A : 35;` must still resolve through the converged alias map.
- Preserve literal, array literal, struct literal, and extern-global behavior.
Verification:
- Add a focused regression, likely in the `01xx` types block:
```sx
#import "modules/std.sx";
A :: B;
B :: i32;
K : A : 42;
g : A = K;
main :: () -> i32 { print("g={}\n", g); return g; }
```
- Keep these green:
- `examples/0133-types-forward-alias-global.sx`
- `examples/0132-types-forward-type-alias.sx`
- `examples/0116-types-type-alias-size-align.sx`
- `examples/0201-generics-generic-struct.sx`
- `examples/1117-diagnostics-value-const-as-type-rejected.sx`
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```
Expected result: the new regression prints `g=42` and exits `42`; unsupported
global initializer shapes no longer silently zero-initialize; the full suite
passes.

View File

@@ -1,113 +0,0 @@
# 0072 — global field-access constant initializer silently zero-initializes
> **RESOLVED.** Root cause: `Lowering.globalInitValue`'s issue-0071 `.identifier`
> arm closed the bare-identifier hole, but `.field_access` (and every other
> non-literal expression shape) still fell through to `else => null`, so the
> global was emitted with no payload and silently zero-initialized (`g=0`).
> Fix: the `else` now emits a diagnostic — "global '<name>' must be initialized
> by a compile-time constant" — instead of returning a null payload, so an
> unsupported initializer shape can never silently zero. Two arms were added
> alongside it: `.null_literal => .null_val` (a `*void = null` global was
> previously a no-payload zero-init; this preserves that exact emission —
> `LLVMConstNull`), and an explicit `.enum_literal => null` carve-out (the
> stdlib's `OS : OperatingSystem = .unknown;` zero-init is load-bearing for
> compile-time `inline if OS == .X`; documented, not folded into a silent
> fallthrough). Field-access constant *evaluation* (materializing `K.x` → 9) was
> intentionally not implemented: a typed struct const like `K` is not registered
> in `module_const_map`, so it would require new plumbing whose `module_const_map`
> writes are read at runtime — out of scope; the diagnostic is the chosen,
> issue-sanctioned outcome. Regression
> `examples/1118-diagnostics-global-non-const-initializer-rejected.sx` (exit 1).
## Symptom
A top-level global initialized from a field access on a module constant compiles
but is zero-initialized.
Observed: `g=0`
Expected: `g` should be initialized to `9`, or the compiler should reject the
initializer loudly if field-access global constants are not supported yet.
## Reproduction
```sx
#import "modules/std.sx";
Point :: struct {
x: i32;
y: i32;
}
K : Point : Point.{ x = 9, y = 4 };
g : i32 = K.x;
main :: () -> i32 {
print("g={}\n", g);
return g;
}
```
Run:
```sh
./zig-out/bin/sx run .sx-tmp/review-0071-field-access-const.sx
```
The repro is standalone; the inline source above is sufficient to recreate the
scratch file under `.sx-tmp/`.
## Investigation prompt
Fix issue 0072: a top-level global initialized from a field access on a module
constant must not silently become zero.
Context:
- This surfaced during Codex re-review of commit `ad7200c`, the issue-0071 fix.
- `ad7200c` correctly handles identifier initializers that name module constants
(`K : A : 42; g : A = K;`) and diagnoses identifiers that are not usable
constants.
- A remaining non-identifier expression shape still falls through silently:
`K : Point : Point.{ x = 9, y = 4 }; g : i32 = K.x;` emits a null global
initializer payload and runs as `g=0`.
Suspected area:
- `src/ir/lower.zig`, `Lowering.globalInitValue`.
- The `.identifier` arm now diagnoses unusable identifier initializers, but the
generic `else => null` still covers `.field_access` and other expression
forms.
- `constExprValue` currently handles literals, negative numeric literals, and
array literals; it does not evaluate field access or struct-literal module
constants. `constStructLiteral` can serialize direct struct literals when
called with the destination type, but that machinery is not used for `K.x`.
Likely fix:
- Add a loud diagnostic for unsupported top-level global initializer expression
shapes, or implement field-access constant evaluation in the same step.
- If implementing it, resolve the base identifier through
`ProgramIndex.module_const_map`, materialize the base constant with its
recorded type, then extract the named field using the IR struct layout.
- Do not replace the current bug with a real-type sentinel such as `.void`; an
unsupported initializer should either produce a concrete `ConstantValue` or a
user-visible diagnostic.
- Preserve the issue-0071 behavior:
`K : A : 42; g : A = K;` must still emit `g=42`.
- Preserve the issue-0070/0068 regressions:
`examples/0133-types-forward-alias-global.sx` and
`examples/1117-diagnostics-value-const-as-type-rejected.sx` must stay green.
- Scope-check enum-literal globals separately. `OS : OperatingSystem = .unknown`
currently relies on compile-time constant injection and pre-existing zero-init
behavior; decide explicitly whether that stays carved out or gets its own
separate issue.
Verification:
- Repro above should either print `g=9` and exit `9`, or fail with a clear
"global initializer must be a compile-time constant" diagnostic. It must not
print `g=0`.
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```

View File

@@ -1,102 +0,0 @@
# issue 0073 — closure literal inside a `defer` body segfaults the compiler
> **✅ RESOLVED (2026-06-02).** Root cause: `lowerLambda` never opened its own
> `defer` window. Every other function-lowering entry (`lowerFunction`,
> `monomorphizeFunction`, `monomorphizePackFn`) saves `func_defer_base`, sets it
> to `defer_stack.items.len`, and restores it — but `lowerLambda` didn't, so a
> lambda's `return` drained the *enclosing* function's defers. When the defer
> body itself declared the lambda, draining re-lowered the lambda, which `return`ed,
> which drained again → infinite recursion → stack-overflow SIGSEGV.
> Fix: `lowerLambda` now opens a fresh defer window (save `func_defer_base` +
> `defer_stack` length, set base to the current length, restore both on exit) —
> `src/ir/lower.zig`. Regression test: `examples/0310-closures-closure-literal-in-defer.sx`
> (a closure declared + called inside a `defer`; verifies `body` then
> `defer closure: 42` at scope exit). Suite 358/0.
## Symptom
One-line: declaring a **closure literal inside a `defer` body** crashes the
compiler with a segfault during lowering.
- **Observed:** `sx run` / `sx build` SIGSEGVs in `lowerLambda`
(`src/ir/lower.zig`) while lowering the enclosing function. With a *failable*
closure (`() -> !E { ... }`) the crash surfaces one frame out, in
`lowerCall``expandCallDefaults``scope.lookupFn``hash_map.get` (a
corrupted/garbage scope pointer), suggesting a stale/clobbered `self.scope`
(or builder/current-function state) while the deferred body is lowered.
- **Expected:** the program either lowers normally or produces a clean
diagnostic. A compiler segfault is never acceptable, regardless of whether the
shape is intended to be supported.
Isolation (all on `arch-refactor`, current `HEAD`):
| Probe | Shape | Result |
|-------|-------|--------|
| (a) | failable closure declared + `cb() catch e {}`**no `defer`** | OK (exit 0) |
| (b) | failable closure literal inside a `defer` body | **SIGSEGV** (lowerCall/expandCallDefaults) |
| (c) | **non-failable** `() { return; }` inside a `defer` body | **SIGSEGV** (lowerLambda) |
| (d) | failable closure literal inside a plain `{ … }` block (not `defer`) | OK (exit 0) |
So the trigger is **a closure literal lowered inside a `defer` body** — not
failability, not whether the closure is called. (a)/(d) prove closures and
failable closures lower fine outside a `defer`; (c) proves a bare non-failable
closure in a `defer` is enough to crash.
## Reproduction
`issues/0073-closure-literal-in-defer-segfault.sx` (minimal — non-failable,
uncalled closure in a `defer`):
```sx
#import "modules/std.sx";
work :: () {
defer { cb := () { return; }; }
}
main :: () -> i32 {
work();
return 0;
}
```
Run: `./zig-out/bin/sx run issues/0073-closure-literal-in-defer-segfault.sx`
→ "Segmentation fault" with a stack through `lowerLambda` (`src/ir/lower.zig`).
## Investigation prompt
A `defer` body is captured at its declaration site and lowered into the
function's exit/cleanup path (drained by `lowerReturn` / block-exit), not inline.
Lowering a **lambda literal** while emitting a deferred body appears to run with
invalid lowering state — most likely `self.scope` (note probe (b)'s crash is in
`scope.lookupFn` reading a hash map at a garbage address) and/or the
builder's current-function / block context is stale or not the one
`lowerLambda` expects when it allocates the closure's trampoline + env.
Suspected area: `src/ir/lower.zig``lowerLambda` (~`:8145`) and the `defer`
capture/replay path (`defer_stmt` handling + the cleanup-drain in `lowerReturn` /
block exit; grep `defer_stack` / `func_defer_base`). Check whether deferred
bodies are lowered:
1. with a scope pointer that has since been popped/freed (use-after-free →
garbage `fn_names`/`map` in `lookupFn`), or
2. in a builder state where `lowerLambda`'s assumptions (current function,
insert block) don't hold, or
3. re-entrantly / unbounded (the failable-variant trace looked like deep
recursion through `expandCallDefaults``lowerCall``lowerLambda`).
Likely fix shape: lower deferred bodies under a valid, live scope (re-establish
or retain the declaring scope when replaying the `defer` body), or defer the
lambda's trampoline emission to a context that has the right function/block.
Verification step: run the repro above — expect it to lower cleanly (or emit a
clean diagnostic) and NOT segfault. Then confirm a closure that is actually used
inside the defer (`defer { cb := () { ... }; cb(); }`) also works, and re-run
`bash tests/run_examples.sh` (357/0) to confirm no regression.
## Provenance
Found while writing an A5.2 (architecture stream) test-first scaffolding example
for the ERR E1.7 "cleanup-absorption stops at nested closures" behavior — the
closure-boundary probe (a closure literal inside a `defer`) crashed the compiler
instead of exercising the diagnostic. The crash is a pre-existing lowering bug,
unrelated to the A5 error-analysis extraction; surfaced by the probe.

View File

@@ -1,84 +0,0 @@
# 0074 — silent `getRefIRType(arg_ref) orelse .void` fallback in FFI call-arg lowering
> **✅ RESOLVED.** Root cause: four FFI call-arg lowering loops resolved an
> argument's IR type via `getRefIRType(arg_ref) orelse .void` — a silent fallback
> to the load-bearing real type `.void`, which downstream `toLLVMType` →
> `abiCoerceParamType` → `coerceArg` treat as a legitimate (void-typed) extern
> argument, corrupting the call ABI with no diagnostic. Fix: one shared resolver
> `LLVMEmitter.argIRTypeOrFail` ([src/ir/emit_llvm.zig]) returns the dedicated
> `.unresolved` sentinel on a failed lookup — never `.void`/`.i64` — so the failure
> cannot masquerade as a real type and trips `toLLVMType`'s existing hard `@panic`
> tripwire at the call site. All four sites
> ([src/ir/emit_llvm.zig] JNI constructor; [src/backend/llvm/ops.zig] objc_msgSend,
> JNI non-virtual, JNI `Call<Type>Method`) now route through the helper. Happy path
> is byte-identical (every real arg already has a resolved type) — FFI examples stay
> green with zero snapshot churn. Regression test (fail-before/pass-after):
> `src/ir/emit_llvm.test.zig` — "argIRTypeOrFail surfaces .unresolved for an
> unresolvable FFI arg ref (issue 0074)".
## Symptom
**One-line:** Four FFI call-arg lowering sites silently default a failed
argument-type lookup to `.void` — the forbidden silent-type-fallback anti-pattern
(`.void` as a failed-type-lookup sentinel), which would produce a void-typed
extern-call argument (wrong LLVM param type → silent ABI corruption) with no
diagnostic if the lookup ever fails.
**Observed:** `self.getRefIRType(arg_ref) orelse .void` at:
- `src/ir/emit_llvm.zig:2463`
- `src/backend/llvm/ops.zig:517` (Obj-C `objc_msgSend` arg loop)
- `src/backend/llvm/ops.zig:731` (JNI non-virtual call arg loop)
- `src/backend/llvm/ops.zig:761` (JNI `Call<Type>Method` arg loop)
Each then does `toLLVMType(raw_ty)``abiCoerceParamType``coerceArg`, so a
`.void` fallback silently mis-types the extern-call argument.
**Expected:** `getRefIRType` returning null for a real call argument is a
"must-succeed lookup" failure (every arg is a valid param/instruction ref). Per
`CLAUDE.md` REJECTED PATTERNS — *"`.void` is an UNACCEPTABLE sentinel for a failed
type lookup"* — the lookup failure must surface as a diagnostic / hard tripwire, not
a silently-corrupted argument type.
## Provenance / scope
Pre-existing pattern (the `emit_llvm.zig` site is original; the three `ops.zig` sites
were relocated **verbatim, behavior-preserving** by step A7.4c of the arch-refactor —
the flow reviewer/observer correctly approved the relocations as equivalence-preserving).
Surfaced by the **A9.3 final fallback-audit** of the arch-refactor stream. Not
introduced by the refactor; filed per the IMPASSIBLE RULE (*"If you find an existing
default-return in the compiler that swallows a lookup failure, treat it as a
discovered bug — file an issue, do not just delete the default in place"*).
## Reproduction
This is a **latent / static** finding: there is no known sx program that drives
`getRefIRType` to `null` for a valid extern-call argument (well-formed IR always
has a type for every arg ref), so it cannot currently be triggered at runtime — which
is exactly why it is dangerous (a future IR change that breaks the invariant would
corrupt FFI ABI silently). The code paths are exercised (and must stay green after
the fix) by the existing FFI examples, e.g.:
```
examples/13xx-ffi-objc-* # objc_msgSend arg lowering (ops.zig:517)
examples/14xx-ffi-jni-* # JNI Call<Type>Method / non-virtual (ops.zig:731/761)
```
(No new minimal repro `.sx` is meaningful for a latent defensive fallback; the fix is
verified by (a) the FFI suite staying green and (b) a unit test that asserts the new
loud-failure path, see below.)
## Investigation prompt (ready to paste into a fresh session)
> In `/Users/agra/projects/sx`, four FFI call-arg lowering sites use the forbidden
> silent type-fallback `self.getRefIRType(arg_ref) orelse .void`
> (`src/ir/emit_llvm.zig:2463`; `src/backend/llvm/ops.zig:517`, `:731`, `:761`).
> `getRefIRType` (`src/ir/emit_llvm.zig:2229`, returns `?TypeId`) yields `null` only
> when a ref is neither a function param nor a block instruction result — a
> must-not-happen case for a real call argument. Replace the silent `.void` default
> with a loud failure that cannot be mistaken for a real type, per `CLAUDE.md`
> REJECTED PATTERNS: emit a diagnostic via `self.diagnostics.addFmt(.err, span,
> "...", .{...})` and/or a hard tripwire (`@panic`/`bailDetail`-style) naming the op
> and the bad ref — do NOT substitute another real type. Prefer a single shared
> helper (e.g. `argIRTypeOrFail(arg_ref, span)`) used by all four sites so the policy
> lives in one place. Then: (1) `/Users/agra/.zvm/bin/zig build && /Users/agra/.zvm/bin/zig
> build test && bash tests/run_examples.sh` must stay 361/0 with the FFI examples
> green (the happy path is unchanged); (2) add a `*.test.zig` unit test that
> constructs an FFI call op with a bogus arg ref and asserts the loud failure fires
> (not a `.void` silent default). Expected new behavior: an unresolved FFI arg type
> produces a clear compiler error / panic, never a void-typed extern argument.

View File

@@ -1,97 +0,0 @@
> **RESOLVED** (2026-06-03)
> **Root cause:** the `type_name` / `type_eq` reflection builtins resolved their
> `Type` arg's IR type with `getRefIRType(...) orelse TypeId.i64`, then gated `== .any`
> — so a failed must-succeed lookup silently became "bare i64" (`.i64 != .any`),
> reading the wrong value with no diagnostic.
> **Fix:** added the sibling classifier `LLVMEmitter.reflectArgRepr`
> (`src/ir/emit_llvm.zig`) which routes the lookup through `argIRTypeOrFail` →
> `.unresolved` and returns `{ boxed, bare, unresolved }`. The three emit sites
> (`src/backend/llvm/ops.zig` `type_name` + `type_eq` ×2) now `switch` on it: `.boxed`
> extracts the `Any` value field, `.bare` uses the value directly, and `.unresolved`
> hits a hard `@panic` tripwire — never silently classified as bare. Happy path
> (real args always resolve) is byte-identical; suite stays 361/0.
> **Secondary (confirmed intentional):** `src/ir/lower.zig:2531/2532`
> (`null_literal` / `undef_literal` → `target_type orelse .void`) is a typeless-literal
> default, not a lookup-swallow — `emitConstNull`/`emitConstUndef` deliberately handle
> `.void` (null-ptr / undef-i64). Left in place with an invariant comment.
> **Regression test:** `src/ir/emit_llvm.test.zig` — "emit: reflectArgRepr surfaces
> .unresolved for an unresolvable reflection arg ref (issue 0075)" (fail-before with
> `orelse .i64` → `.bare`; pass-after → `.unresolved`).
# 0075 — silent `getRefIRType(...) orelse TypeId.i64` fallback in reflection builtins
## Symptom
**One-line:** The `type_name` and `type_eq` reflection builtins resolve their Type
argument's IR type via `getRefIRType(...) orelse TypeId.i64` — the forbidden
silent-type-lookup fallback (`.i64` is the exact issue-0042 sentinel the project
rules name) — so a failed must-succeed lookup silently decides "not boxed (`!= .any`)"
and mis-handles the value with no diagnostic.
**Observed (primary — must fix):** `self.e.getRefIRType(...) orelse TypeId.i64` at:
- `src/backend/llvm/ops.zig:1023` (`.type_name` builtin — `arg_ir_ty`, gates the
`== .any` boxed-extract vs bare-i64 decision)
- `src/backend/llvm/ops.zig:1049` (`.type_eq` builtin — first operand)
- `src/backend/llvm/ops.zig:1055` (`.type_eq` builtin — second operand)
`getRefIRType` (`src/ir/emit_llvm.zig:2229`, `?TypeId`) returns `null` only when a ref
is neither a function param nor a block instruction result — a must-not-happen case
for a real builtin argument. On `null` the code defaults to `.i64`, then tests
`arg_ir_ty == .any`; the `.i64` default silently means "treat as a bare TypeId index,
not a boxed `Any`", so a genuinely-boxed arg whose lookup failed would skip the
`ExtractValue` and use the wrong value — silent miscompile, no diagnostic.
**Expected:** per `CLAUDE.md` REJECTED PATTERNS, a failed must-succeed type lookup
surfaces a diagnostic / hard tripwire (e.g. the `.unresolved` sentinel introduced for
issue 0074), never a real-type default.
## Secondary (confirm — borderline)
- `src/ir/lower.zig:2527``.null_literal => constNull(self.target_type orelse .void)`
- `src/ir/lower.zig:2528``.undef_literal => constUndef(self.target_type orelse .void)`
`target_type` is a context hint that may be legitimately absent for a bare
`null`/`undef` with no expected type — this may be an INTENTIONAL default rather
than a lookup-swallow. The fix session should confirm: if a `null`/`undef` literal
reaching here without a `target_type` is actually a must-not-happen case, make it
loud; if a typeless null/undef is legitimate, leave it and add a one-line comment
stating the invariant.
## Audited — intentional language defaults (NO action; documented so they aren't re-flagged)
- `src/ir/lower.zig:4855``int_literal => constInt(lit.value, info.ty orelse .i64)`:
an untyped integer literal defaulting to `i64` is standard language semantics, not a
lookup failure.
- `src/ir/lower.zig:4856``float_literal => constFloat(..., info.ty orelse .f64)`:
untyped float literal defaults to `f64` — language semantics.
- `src/ir/type_bridge.zig:334``.tag_type = tag_type orelse .i64`: documented
("enum unions are always tagged (default i64)") — an intentional default tag type,
not a swallowed lookup.
## Provenance / scope
Pre-existing, NOT introduced by the arch-refactor. Discovered during the **issue-0074
fix** (the fix worker surfaced the reflection `.i64` fallbacks as a separate pattern
outside 0074's FFI-arg scope) and confirmed by a manager sweep
(`rg "orelse \.(i64|void|...)" src`). Filed per the IMPASSIBLE RULE (existing
default-returns that swallow a lookup failure → file, don't fix in place).
## Reproduction
Latent / static (same nature as 0074): well-formed IR always gives a builtin arg a
resolvable type, so the `.i64` default can't be driven at runtime today — which is why
it's dangerous (a future IR change would silently miscompile `type_name`/`type_eq`).
Exercised by the comptime/reflection examples; the fix must keep the suite at 361/0.
## Investigation prompt (ready to paste into a fresh session)
> In `/Users/agra/projects/sx`, the `.type_name` and `.type_eq` reflection builtins in
> `src/backend/llvm/ops.zig` (lines 1023, 1049, 1055) resolve a Type argument's IR type
> with the forbidden silent fallback `getRefIRType(...) orelse TypeId.i64`, used to gate
> a `== .any` boxed-vs-bare decision. Issue 0074 already added the shared resolver
> `LLVMEmitter.argIRTypeOrFail` (`src/ir/emit_llvm.zig`) returning the dedicated
> `.unresolved` sentinel on a failed lookup. Route these three sites through that helper
> (or a sibling) so a failed lookup yields `.unresolved` — never `.i64`; then `==.any`
> is false for `.unresolved` AND you must make the unresolved case loud (diagnostic via
> `self.diagnostics.addFmt(.err, span, ...)` or a hard tripwire), not silently "bare
> i64". Also resolve the borderline `lower.zig:2527/2528` `target_type orelse .void`
> (confirm intentional vs make-loud; comment the invariant either way). Leave the
> audited intentional defaults (`lower.zig:4855/4856`, `type_bridge.zig:334`) untouched.
> Verify: `/Users/agra/.zvm/bin/zig build && /Users/agra/.zvm/bin/zig build test &&
> bash tests/run_examples.sh` stays 361/0; add a `*.test.zig` regression test asserting
> the loud `.unresolved` path for a `type_name`/`type_eq` arg with an unresolvable ref
> (fail-before/pass-after). Expected new behavior: an unresolved reflection-builtin arg
> type surfaces loudly, never silently defaults to `.i64`.

View File

@@ -1,170 +0,0 @@
# 0076 — builtin/reserved type name wrongly accepted as an identifier
> **Status: RESOLVED.**
>
> **Root cause:** the language accepted a value binding (local/global `var` or a
> parameter) spelled as a reserved/builtin type name. The parser turns such a
> spelling into a `.type_expr` rather than an `.identifier` (`parser.zig`, via
> `Type.fromName`), so the address-of family in `src/ir/lower.zig` never saw a
> scoped local and fell through to value lowering — loading the whole aggregate
> and passing it by value to a `ptr` parameter (LLVM verifier abort, or a silent
> `*self`-mutation-losing copy).
>
> **Fix:** a declaration-site diagnostic in the existing semantic pass
> `src/ir/semantic_diagnostics.zig` (`UnknownTypeChecker`). `checkBindingName`
> rejects any binding name whose spelling collides with a reserved type name;
> `isReservedTypeName` defers to the parser's own classifier
> (`types.Type.fromName`) so the rejected set never drifts from the set that
> would parse as a type — the named builtins (`bool`, `string`, `void`, `f32`,
> `f64`, `usize`, `isize`, `Any`) and `[su]N` over sx's 164 range. Bare value
> names (`s`, `self`, `index`) are untouched. No lowering special-case is added;
> the `.identifier`-only address-of paths are correct once type-shaped names can
> never be bound. The rejected `bareVarName` approach was never landed.
>
> **Coverage is structural (attempt 4).** Earlier landings hand-walked a subset
> of binding-bearing nodes with a silent `else => {}`, so each review found a new
> leaking syntactic form (destructure names, `impl` method params/locals, `if` /
> `while` / `for` / match-arm / `catch` / `onfail` captures) that bypassed the
> check and hit the original LLVM verifier abort. `checkBindingNames` is now an
> **exhaustive `switch` over every `Node.Data` tag with NO `else` arm**: a future
> binding-bearing node type fails to compile until it is handled here, so
> coverage is enforced by the compiler rather than by a hand-maintained list. The
> check stays in the pre-lowering semantic pass (NOT moved to the `Scope.put`
> scope-registration choke point) because lowering is lazy — an UNCALLED
> function's bindings never reach `Scope.put`, yet they must still be rejected at
> their declaration (e.g. `examples/1119`'s never-called `takes_u8`).
>
> **Span precision (attempt 5).** Every binding form now carries its own
> name span in the AST (`VarDecl.name_span`, `DestructureDecl.name_spans`,
> `IfExpr`/`WhileExpr.binding_span`, `ForExpr.capture_span`/`index_span`,
> `MatchArm.capture_span`, `CatchExpr`/`OnFailStmt.binding_span`,
> `Protocol`/`RuntimeMethodDecl.param_name_spans`), populated by the parser at
> each binding site. `checkBindingNames` passes that span to the diagnostic, so
> the caret underlines the offending identifier itself instead of the enclosing
> statement / `if` / `match` / `protocol` / `#objc_class` block. No call site
> falls back to the parent `node.span`. Regular `fn`/lambda params already used
> `Param.name_span`.
>
> **Regression tests:**
> - `examples/0125-types-type-named-var-rejected.sx` — `:=` form (`i2`) rejected.
> - `examples/1119-diagnostics-reserved-type-name-as-identifier.sx` — parameter
> (`u8`), typed-local (`i64`, `bool`), and `:=` (`string`) forms rejected.
> - `examples/1121-diagnostics-reserved-name-control-flow.sx` — destructure name,
> `if` / `while` optional bindings, `for` capture + index names, match-arm
> capture.
> - `examples/1122-diagnostics-reserved-name-impl-method.sx` — `impl`-block method
> reserved param AND reserved local.
> - `examples/1123-diagnostics-reserved-name-catch-onfail.sx` — `catch` and
> `onfail` error-tag bindings.
> - `examples/1124-diagnostics-imported-reserved-destructure.sx` — destructure
> name reserved in an IMPORTED module (renders against that module's source).
> - `examples/1125-diagnostics-reserved-name-method-param.sx` — protocol
> default-body method param AND sx-defined `#objc_class` method param, each
> caret landing on the parameter token.
> - `examples/0135-types-self-streaming-nonreserved.sx` — positive: `*self`
> streaming with non-reserved names (`hasher`, `ctx`) accumulates correctly via
> both `update(@h, …)` and `h.update(…)`.
>
> Pre-existing example `examples/0904-...` declared locals `i1`/`i2` (incidental
> names); renamed to `filled`/`empty`.
>
> **Coverage extension (issue 0077).** The first landing scoped the binding
> check to main-file decls (matching the unknown-type check's trusted-imports
> convention); an imported module could still declare `i2 := …` and hit the
> original LLVM verifier abort. The reserved-name binding diagnostic now runs
> over EVERY compiled module — imported user modules (descending the
> `namespace_decl` an `mod :: #import` wraps) AND the stdlib `library/` — and
> the two `u1` locals in `library/modules/ui/renderer.sx` were renamed
> accordingly. The unknown-type check (issue 0064) stays main-file-only. See
> issue 0077 for the imported-module facet and its pinned regression test
> `examples/1120-diagnostics-imported-reserved-type-name.sx`.
## Symptom (how it first surfaced)
A local variable whose name is lexically a type — e.g. `i2` (the `sN`
arbitrary-width signed-int syntax: `Type.fromName("i2")``s(2)`), or `u8`,
`i64`, etc. — is accepted as a variable. Because such a name parses as a
`.type_expr` (not `.identifier`), the address-of family of lowering sites
(`@i2`, the autoref `i2.update(...)` receiver, a bare `f(i2)` at a `*T` param,
global function-pointer args) does NOT recognize it as a scoped local and falls
through to value lowering — loading the whole aggregate and passing it **by
value** to a `ptr` parameter:
```
LLVM verification failed: Call parameter type does not match function signature!
call void @update(ptr @__sx_default_context,
{ [8 x i64], [64 x i8], i64, i64 } %load, ...)
```
For some struct shapes it compiles but silently passes a **copy** (callee
`*self` mutations lost). A non-type-shaped name (`hasher`, `ctx`) never triggers
any of this — the `.identifier` paths already work correctly.
## Root cause
The language is **accepting reserved/builtin type names as identifiers** in the
first place. `sN`/`uN` (arbitrary-width ints) and the named builtins
(`bool`, `string`, `void`, `f32`, `f64`, `i8`/`i16`/`i32`/`i64`,
`u8`/`u16`/`u32`/`u64`, …) are reserved type names; declaring a variable with
such a name is meaningless and produces the mis-lowering above. Patching each
address-of site to tolerate the name (the rejected `bareVarName` approach) is
whack-a-mole — there is always another site, and it entrenches a name that
should never have been allowed.
## Proper fix (the required direction)
Emit a **diagnostic error** when an identifier is declared with a name that
collides with a **builtin/reserved type name** — including the arbitrary-width
`[su][0-9]+` (`sN`/`uN`) family AND the named builtins (`bool`, `string`,
`void`, `f32`, `f64`, the fixed-width int types, etc.). Scope ruling (Agra):
**all builtin/reserved type names** are rejected as identifiers. (User-defined
struct/type-name shadowing, if intentionally supported elsewhere, is out of
scope for this issue — this is specifically about builtin/reserved type names.)
Diagnostic at the declaration site, e.g.:
`error: 'u8' is a reserved type name and cannot be used as an identifier`
with the declaration's span.
Suspected area: name binding / declaration handling — where a `:=` / typed
local / parameter name is introduced. Reject the name there, before it ever
reaches lowering. Do NOT add lowering special-cases for type-shaped names; the
`.identifier`-only checks at the address-of sites are then correct as-is (no
type-shaped name can reach them).
## Reproduction
```sx
#import "modules/std.sx";
Sha256 :: struct { h:[8]u64; block:[64]u8; block_len:i64=0; total_len:u64=0; }
init :: () -> Sha256 { s:Sha256=---; s.block_len=0; s.total_len=0; s }
update :: (self:*Sha256, data:string) { self.total_len += data.len; }
main :: () -> i32 { i2 := init(); update(@i2, "."); print("total_len={}\n", i2.total_len); return 0; }
```
`./zig-out/bin/sx run <file>` today → LLVM verifier abort.
**Expected after fix:** a clean compile-time diagnostic that `i2` is a reserved
type name and cannot be an identifier (exit non-zero, readable error — NOT an
LLVM abort, NOT a silent copy). The same program with a non-reserved name
(`hasher := init(); update(@hasher, ".")`) must compile and print `total_len=1`.
## Verification
1. Pinned diagnostics test(s) asserting the error for representative reserved
names used as identifiers: `i2`, `u8`, `i64`, `bool`, `string` (declaration
forms: `:=`, typed local, and a parameter name). Capture the diagnostic text
in `expected/`.
2. A positive test: the same `*self` streaming pattern with NON-reserved names
(`hasher`, `ctx`) compiles and accumulates state correctly via both
`update(@h, ...)` and `h.update(...)` — proving the `.identifier` paths are
correct and no lowering special-case is needed.
3. `zig build && zig build test && bash tests/run_examples.sh` all green. If any
existing example/test declares a variable with a reserved type name, it is now
illegal — fix the test's variable name (do NOT weaken the diagnostic). Report
how many such sites existed.
## Provenance
Discovered by the `distribution` flow (P1.2 pure-sx SHA-256), whose minimal repro
happened to name a local `i2`. Real SHA-256 code with names like `hasher`/`ctx`
is unaffected on the current compiler — so the P1.2 "blocker" was a
naming artifact, and this issue is really a missing-diagnostic correctness bug.

View File

@@ -1,103 +0,0 @@
# 0077 — reserved type-name binding diagnostic skips imported modules
> **Status: RESOLVED.**
>
> **Root cause:** the reserved-name binding diagnostic (issue 0076) only ran
> over main-file decls (`UnknownTypeChecker.run`'s `main_file` filter). An
> imported module's `i2 := …` was never checked and reached lowering, where the
> address-of family loaded the whole aggregate and passed it by value to a
> `*Box` param — LLVM verifier abort.
>
> **Fix:** the binding check (`checkBindingNames` in
> `src/ir/semantic_diagnostics.zig`) now walks EVERY compiled module — no
> main-file filter — visiting every `var`/`:=`/typed-local binding name and
> function/lambda/struct-method parameter at any depth, and descending the
> `namespace_decl` that a `mod :: #import` wraps so imported-module decls are
> reached. The walk tracks each module's `source_file` (via the diagnostic
> list's `current_source_file`, saved/restored per node) so the diagnostic
> renders against the imported module's text. Rejection still defers to the
> parser's `name_class.Type.fromName` classifier (no drift). The unknown-type
> check (issue 0064) stays main-file-only. No lowering special-case; the
> `.identifier`-only address-of paths are unchanged.
>
> **Stdlib audit:** the only reserved-name bindings under `library/` were two
> `u1` locals in `library/modules/ui/renderer.sx` (lines 203, 382); the UV-coord
> locals were renamed `u_min`/`u_max`/`v_min`/`v_max`. No reserved parameter
> names or other reserved bindings exist in `library/` or `examples/`.
>
> **Regression test:** `examples/1120-diagnostics-imported-reserved-type-name.sx`
> (+ companion `1120-diagnostics-imported-reserved-type-name/mod.sx`) — an
> imported module declaring `i2 := …` now emits the clean diagnostic at the
> import's declaration site (exit 1), not an LLVM abort.
## Symptom
An imported module can still declare a parameter or `var` binding whose name is a
reserved/builtin type name. Observed: the imported-module repro below reaches
lowering and fails LLVM verification by passing a loaded struct value to a
`*Box` parameter. Expected: the same declaration-site diagnostic used for
main-file issue 0076 should reject the imported module's `i2` binding before
lowering.
## Reproduction
Create these two files under the repo root, then run
`./zig-out/bin/sx run .sx-tmp/issue0077_main.sx`.
`.sx-tmp/issue0077_mod.sx`:
```sx
#import "modules/std.sx";
Box :: struct { total: i64 = 0; count: i64 = 0; }
update :: (self: *Box, n: i64) {
self.total += n;
self.count += 1;
}
run_imported_reserved_name :: () -> i32 {
i2 := Box.{ total = 0, count = 0 };
update(@i2, 5);
i2.update(7);
print("imported i2 total={} count={}\n", i2.total, i2.count);
return 0;
}
```
`.sx-tmp/issue0077_main.sx`:
```sx
#import "modules/std.sx";
mod :: #import ".sx-tmp/issue0077_mod.sx";
main :: () -> i32 {
return mod.run_imported_reserved_name();
}
```
Current output on `flow/sx-foundation/F0.1`:
```text
LLVM verification failed: Call parameter type does not match function signature!
%load = load { i64, i64 }, ptr %alloca, align 8, !dbg !461
ptr call void @update(ptr %0, { i64, i64 } %load, i64 5), !dbg !462
```
## Investigation prompt
Investigate and fix issue 0077 in the sx compiler. The suspected area is
`src/ir/semantic_diagnostics.zig`, especially
`UnknownTypeChecker.run` and its `main_file` filter. Attempt 2 for issue 0076
added `checkBindingName`, but it only runs over main-file declarations; imported
modules are still trusted and can hit the original LLVM verifier failure. The
fix likely needs to apply reserved-type-name binding diagnostics to all user
source modules that are lowered, while preserving the existing trusted-stdlib
or library convention only where intentionally required. Also audit existing
reserved-name bindings in `library/` (for example `u1 := ...` in
`library/modules/ui/renderer.sx`) and rename any source that will become
newly illegal under the corrected rule.
Verification: run the two-file repro above and expect a clean diagnostic at the
imported module's `i2 := ...` declaration, not LLVM verification failure. Then
run `zig build`, `zig build test`, and `bash tests/run_examples.sh`.

View File

@@ -1,131 +0,0 @@
# 0078 — string `==` as an `and`/`or` operand emits an invalid PHI
> **RESOLVED.** Root cause was in the LLVM emitter, not the `and`/`or`
> lowering: `fixupPhiNodes` wired each short-circuit merge PHI's incoming
> edge to `block_map[ir_block]` — the LLVM block the IR block *started* as.
> But a single IR instruction can expand into its own sub-CFG during
> emission (string `==`'s `str.memcmp`/`str.merge` blocks; a value `match`'s
> arm blocks), leaving the builder in a later block. The terminator — and
> therefore the real predecessor edge — lands in that later block, so the
> recorded predecessor was stale (`%entry`/`%and.rhs.0` instead of
> `%str.merge`). Fix: in `src/ir/emit_llvm.zig`, record the builder's
> *actual* insertion block after emitting each IR block's instructions
> (`term_block_map`, captured via `LLVMGetInsertBlock`) and use that as the
> PHI predecessor in `fixupPhiNodes`. General — corrects the incoming block
> for ANY operand that emitted intermediate basic blocks, not just string
> `==`. Mirrors the issue-0066 "stale PHI incoming-block after an operand
> emits new blocks" shape. Regression: `examples/0045-basic-string-eq-short-circuit.sx`.
# Symptom
A string equality (`a == "x"`) used as an operand of a short-circuit
`and` / `or` emits LLVM IR that fails verification — the JIT (`sx run`)
and AOT paths both abort before running:
```
LLVM verification failed: PHI node entries do not match predecessors!
%bp = phi i1 [ false, %entry ], [ %str.eq10, %and.rhs.0 ]
label %entry
label %str.merge
Instruction does not dominate all uses!
%str.eq10 = phi i1 [ false, %and.rhs.0 ], [ %str.ceq9, %str.memcmp6 ]
%bp = phi i1 [ false, %entry ], [ %str.eq10, %and.rhs.0 ]
```
Integer/`error`-tag equality in the same position is fine — only the
string `==` operand miscompiles, because string `==` lowers to its own
multi-block memcmp with an internal PHI (`str.eq` ← {`str.memcmp`,
short-circuit false}). When that result is then consumed by the `and`/`or`
short-circuit merge, the predecessor set the outer PHI records does not
match the actual CFG: the string-compare's merge block becomes a
predecessor of the `and` merge, but the outer PHI still lists the original
`entry`/`and.rhs` edges. The inner `str.eq` PHI also ends up referenced
from a block it does not dominate.
# Reproduction
```sx
#import "modules/std.sx";
main :: () {
a := "k";
b := "v";
r := a == "k" and b == "v"; // string == as an `and` operand
print("{}\n", r);
}
```
```
$ ./zig-out/bin/sx run repro.sx
LLVM verification failed: PHI node entries do not match predecessors!
...
```
`a == "k" or b == "v"` reproduces it identically (`or.rhs` in place of
`and.rhs`). A single `a == "k"` (no `and`/`or`) compiles and runs fine, as
does `x == 1 and y == 2` (integer operands). So the trigger is specifically
a **string `==`/`!=` as an operand of a short-circuit `and`/`or`** — the
operand emits its own `str.memcmp`/`str.merge` sub-CFG, and the
short-circuit PHI then records a stale predecessor block.
A related `match.merge`-predecessor variant of the same PHI mismatch also
appears in a LARGER function that mixes several enum-payload accesses
(`v.str`/`v.int_`) and `match` expressions with multiple `and`/`or`
operations (it surfaced while writing
`examples/0714-modules-json-reader.sx`). It did NOT reduce to a small
standalone repro — each construct compiles fine in isolation, and a single
payload-access operand (`true and e.a == 1`) or a preceding `match`
expression followed by an `and` of locals both compile — which points at
cumulative basic-block bookkeeping in the `and`/`or` lowering rather than a
single local pattern. The string-`==` case above is the reliable minimal
reproduction; the broader fix should address PHI predecessor tracking for
any `and`/`or` operand that emits intermediate basic blocks.
# Expected
`r` should be `true` (both compares hold) and the program print `true`.
Generally: a `string ==`/`!=` result must be usable as an operand of
`and`/`or` exactly like any other `bool`.
# Workaround (until fixed)
Don't combine string equality with `and`/`or` in one expression; split
into separate statements / separate boolean locals:
```sx
ok_k := a == "k";
ok_v := b == "v";
r := ok_k and ok_v; // each string-eq materialized before the short-circuit
```
# Background / where to look
The string `==` lowering (search `str.eq` / `str.memcmp` / `str.merge`
block names in `src/ir/lower.zig`) produces a value via a PHI that joins
the memcmp-equal block and the early-out (length-mismatch / short-circuit)
block. The boolean `and`/`or` lowering builds its own `and.rhs` /
`and.merge` (resp. `or.*`) blocks and a merge PHI. When the LHS (or RHS)
of the `and`/`or` is itself a string compare, the outer short-circuit
lowering must take the string-compare's *actual current block* (its merge
block) as the incoming predecessor for the outer PHI — not the block that
was current before the string compare emitted its sub-CFG. The mismatch
above is the classic "PHI incoming-block is stale after the operand
emitted new basic blocks" bug: the fix is to re-read the builder's current
insertion block when wiring the `and`/`or` PHI incoming edges, rather than
caching it before lowering the operand. This mirrors the shape of the
match-arm PHI fix in issue 0066.
Discovered while writing the std.json reader regression example
(`examples/0714-modules-json-reader.sx`, flow step F2.2): an assertion
`key == "k" and val.str == "v"` triggered it. The reader library code
itself does not use this pattern; the example was rewritten to assert the
two string equalities separately.
# Verification (once fixed)
```sh
./zig-out/bin/sx run repro.sx # prints: true
```
Add a regression example (next free `examples/NNNN-*.sx` slot) that uses a
string `==` on both sides of an `and` and on both sides of an `or`, and
the full suite + `zig build test` must stay green.

View File

@@ -1,102 +0,0 @@
# 0079 — stores to module-global array elements are silently dropped
> **RESOLVED.**
> **Root cause:** `Lowering.lowerExprAsPtr` (`src/ir/lower.zig`) — the lvalue/
> address path — handled only *local* identifiers (alloca pointers). A
> module-global identifier fell through to the value fallback `lowerExpr`,
> which emits `global_get` (loads the whole array *by value*). The LLVM
> backend's `emitIndexGep` then sees an array *value*, allocas a throwaway
> temp, copies the value in, and GEPs into the temp — so `g[i] = v` wrote a
> discarded copy and a later `g[i]` read the global's untouched initializer.
> Local arrays worked because they hit the alloca-pointer path; global
> *scalar* stores worked via `global_set`.
> **Fix:** teach `lowerExprAsPtr`'s identifier arm about globals — emit
> `global_addr` (a pointer into the global's live storage) for a normal
> global, or `global_get` for a pointer-typed global (mirroring the local
> pointer case). The same array-base resolution in the `address_of(index_expr)`
> path now routes through `lowerExprAsPtr` so `&g[i]` is also an lvalue into
> the global. `index_gep` then GEPs directly into `@g` for const AND variable
> index, across functions, in both `sx run` and `sx build`.
> **Regression:** `examples/0136-types-global-array-element-store.sx`
> (const-index, var-index, cross-function store on a scalar global array; a
> struct-element global array for element-stride; a nested-array global for the
> recursive indexed lvalue). FAILS on the pre-fix compiler (reads return the
> initializer / zero), PASSES after.
## Symptom
A store to a module-global (file-scope) ARRAY element is silently lost: after
`g[i] = v`, reading `g[i]` returns the array's INITIALIZER value, not `v`. No
diagnostic. Reproduces with a constant index, a variable index, and a store from
another function, in BOTH `sx run` (JIT) and `sx build` (AOT). Local-array stores
and global-SCALAR stores work correctly — so the indexed load/store on a *global*
array appears to read/write the initializer constant rather than the global's
live storage. This is a SILENT data-corruption bug (the dangerous class per the
project rules), not a crash.
Manager-reproduced output (JIT):
```
global[1] const-idx=20 (want 222)
global[k] var-idx=30 (want 333)
global[0] via fn=10 (want 111)
```
## Reproduction
```sx
#import "modules/std.sx";
g : [3]i64 = .[10, 20, 30];
write_global :: (i: i64, v: i64) { g[i] = v; }
main :: () {
loc : [3]i64 = .[10, 20, 30];
loc[1] = 222;
print("local[1]={}\n", loc[1]); // 222 (correct)
g[1] = 222;
print("global[1] const-idx={}\n", g[1]); // 20 (WRONG, want 222)
k := 2;
g[k] = 333;
print("global[k] var-idx={}\n", g[k]); // 30 (WRONG, want 333)
write_global(0, 111);
print("global[0] via fn={}\n", g[0]); // 10 (WRONG, want 111)
}
```
`./zig-out/bin/sx run <file>` → prints the WRONG values above. Expected:
`local[1]=222 / global[1]=222 / global[k]=333 / global[0]=111`.
## Investigation prompt
A store to a module-global array element (`g[i] = v`) is silently lost: a
subsequent `g[i]` yields the initializer value, in BOTH the JIT (`sx run`) and
compiled (`sx build`) paths. Global SCALAR stores and LOCAL array stores both
work, so the defect is specific to INDEXED lvalue access on a GLOBAL array.
Suspect the IR-gen / codegen for an indexed expression on a global symbol:
the load of `global_array[idx]` is likely decaying / constant-folding to the
array's initializer constant (or the address computation for the store targets a
private copy of the initializer rather than the global's live storage). Look in
the index-expression lowering and global-symbol address resolution (and how
global array initializers are materialized) — `src/ir/lower.zig` /
`src/ir/emit_llvm.zig` and the global-symbol/constant-initializer paths.
The fix must make `load(global_array[idx])` read the global's live storage and
`store(global_array[idx], v)` write it — for constant AND variable indices, and
when the store happens in a different function than the read. Do NOT special-case;
fix the address resolution so a global array element is an lvalue into the
global's storage like any other.
## Verification (once fixed)
- The repro above prints `222 / 333 / 111` for the global cases (exit 0).
- Add a pinned regression `examples/NNNN-*.sx` covering const-index, var-index,
and cross-function store to a global array (+ a global array of a struct/larger
element if practical).
- `zig build && zig build test && bash tests/run_examples.sh` all green.
## Provenance
Discovered by the `distribution` flow (sx-foundation step F3.1, std.cli argv
accessor) while exploring argv backing strategies. The shipped F3.1 code does NOT
depend on this (it uses caller-provided buffers + zero-copy views over the C argv
block), so F3.1 is correct independently — this is a separate latent
silent-miscompile surfaced per the STOP-on-compiler-bug rule.

View File

@@ -1,121 +0,0 @@
# 0080 - global array of struct literals silently zero-initializes
> **RESOLVED.**
> **Root cause:** `Lowering.constExprValue` (`src/ir/lower.zig`) — the constant-
> aggregate serializer for global initializers — handled primitive and nested-
> array leaves but had **no `.struct_literal` arm**. A module-global `[N]Struct`
> initialized with struct literals reached `constArrayLiteral` → `constExprValue`
> per element; each struct-literal element returned `null`, collapsing the whole
> array initializer to `null`. `globalInitValue` then emitted no payload, so the
> LLVM backend zero-initialized the global (`@pairs = ... zeroinitializer`),
> silently dropping every declared field — the same silent-zero class as
> 0071/0072, one level inside an array literal. (A global *struct* literal and a
> *struct-with-array* already worked, because `constStructLiteral` existed and was
> reached directly; the gap was specifically struct literals *as array elements*.)
> **Fix:** make `constExprValue` type-aware — thread the destination element/field
> `TypeId` so a `.struct_literal` leaf routes through `constStructLiteral` and a
> nested `.array_literal` through `constArrayLiteral` with the correct element
> type. `constArrayLiteral` derives its element type from the array `TypeId`;
> `constStructLiteral` passes each field's type. A global aggregate initializer
> that still does not fully reduce to a compile-time constant is now **rejected
> loudly** (`diagnoseNonConstGlobal`) instead of falling through to a zeroed
> global. The downstream `emitConstAggregate` already recurses over nested
> aggregates, so const/AOT (`sx build`) and JIT (`sx run`) both materialize the
> declared values.
> **Regression:** `examples/0137-types-global-aggregate-literal-init.sx` (global
> `[N]Struct` literal, global struct literal, struct-with-array, nested array-of-
> struct-with-array; values read back with no prior store, plus a store on top).
> FAILS on the pre-fix compiler (array-of-struct fields read 0), PASSES after.
## Symptom
A module-global fixed array whose elements are struct literals is emitted as
zero-initialized storage instead of preserving the literal fields.
Observed: reading `pairs[0].b` and `pairs[1].a` prints `0`.
Expected: the global should contain the declared struct literal values
(`2` and `3`), or the compiler should reject the initializer loudly if this
constant shape is unsupported.
## Reproduction
```sx
#import "modules/std.sx";
Pair :: struct {
a: i64;
b: i64;
}
pairs : [2]Pair = .[ .{ a = 1, b = 2 }, .{ a = 3, b = 4 } ];
main :: () -> i32 {
print("pairs[0]={},{}\n", pairs[0].a, pairs[0].b);
print("pairs[1]={},{}\n", pairs[1].a, pairs[1].b);
if pairs[0].a == 1 and pairs[0].b == 2 and pairs[1].a == 3 and pairs[1].b == 4 {
print("PASS\n");
return 0;
}
print("FAIL: global array struct literal initializer zeroed\n");
return 1;
}
```
On the current compiler this prints:
```text
pairs[0]=0,0
pairs[1]=0,0
FAIL: global array struct literal initializer zeroed
```
`sx ir <file>` shows the global as:
```llvm
@pairs = internal global [2 x { i64, i64 }] zeroinitializer
```
## Investigation prompt
Fix issue 0080: a module-global array initialized with struct literal elements
silently becomes `zeroinitializer`.
Suspected area:
- `src/ir/lower.zig`, `Lowering.globalInitValue`.
- `src/ir/lower.zig`, `Lowering.constArrayLiteral`.
- `src/ir/lower.zig`, `Lowering.constExprValue`.
- `src/ir/lower.zig`, `Lowering.constStructLiteral`.
Likely root cause: `globalInitValue` handles a top-level `.array_literal` by
calling `constArrayLiteral`, and `constArrayLiteral` serializes each element via
`constExprValue`. `constExprValue` handles primitive literals and nested arrays,
but not `.struct_literal`, so an array whose element is a struct literal returns
`null`. That null initializer payload is later emitted as zero-initialized
storage, recreating the silent zero pattern from issues 0071/0072 one level
inside an otherwise-supported array literal.
Likely fix:
- Thread the expected element `TypeId` into `constArrayLiteral`, or otherwise
make `constExprValue` type-aware for struct literals.
- Serialize each struct element through `constStructLiteral` with the array's
element type.
- If any element shape is still unsupported, emit a diagnostic naming the global
instead of returning `null` and allowing zero-initialization.
Verification:
- Run the repro above and expect:
```text
pairs[0]=1,2
pairs[1]=3,4
PASS
```
- Add a pinned regression in the `01xx` types block.
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```

View File

@@ -1,116 +0,0 @@
# 0081 - global aggregate null literal rejected as non-constant
> **RESOLVED.**
> **Root cause:** `Lowering.constExprValue` (`src/ir/lower.zig`) — the
> constant-aggregate serializer for global initializers — had no
> `.null_literal` arm. A `null` in a pointer (or optional-pointer) field
> therefore returned no constant, which propagated up through
> `constStructLiteral` / `constArrayLiteral` and made the whole aggregate look
> non-constant, so `globalInitValue` rejected it with "must be initialized by a
> compile-time constant". A `null` is a compile-time constant (the zero
> pointer) and a top-level scalar pointer global (`p : *i64 = null;`) already
> serialized fine — only the nested-aggregate path was wrong.
> **Fix:** add `.null_literal => .null_val` to `constExprValue` so a null leaf
> serializes to a constant zero pointer. Made the LLVM constant emitters
> exhaustive while at it: `emitConstAggregate` and the top-level `init_val`
> switch in `src/ir/emit_llvm.zig` previously ended in a silent
> `else => LLVMConstNull(...)` catch-all (the precise silent-arm class CLAUDE.md
> mandates rooting out); they now handle every `ConstantValue` tag explicitly
> (`.null_val`/`.zeroinit` → all-zero constant, `.undef` → `LLVMGetUndef`,
> `.func_ref` resolved, nested `.vtable` is a hard `@panic` tripwire since
> vtables are top-level-only). The reject-loud path for genuinely non-constant
> fields (a runtime call, etc.) is preserved.
> **Regression:** `examples/0138-types-global-aggregate-null-pointer-field.sx`
> (array-of-struct with null pointer fields, global array of all-null pointers,
> nested struct-in-struct null pointer — asserts null reads + correct neighbors)
> and the negative `examples/1126-diagnostics-global-aggregate-non-const-field-rejected.sx`
> (a null pointer field beside a non-constant field still errors loudly).
> Verified fail-before (pre-fix rejects 0138) / pass-after.
## Symptom
A module-global aggregate initializer rejects a `null` literal in a pointer
field as "not a compile-time constant"; expected the null pointer to serialize
as a constant zero pointer the same way a top-level pointer global does.
## Reproduction
```sx
#import "modules/std.sx";
Box :: struct {
p: *i64;
marker: i64;
}
boxes : [2]Box = .[
.{ p = null, marker = 11 },
.{ p = null, marker = 22 },
];
main :: () -> i32 {
print("ptrs={} {} markers={} {}\n",
boxes[0].p == null,
boxes[1].p == null,
boxes[0].marker,
boxes[1].marker);
if boxes[0].p == null and boxes[1].p == null and boxes[0].marker == 11 and boxes[1].marker == 22 {
return 0;
}
return 1;
}
```
Observed:
```text
error: global 'boxes' must be initialized by a compile-time constant
```
Expected:
```text
ptrs=true true markers=11 22
```
## Investigation prompt
Fix issue 0081: module-global aggregate initializers reject `null` literals in
pointer fields even though `null` is a compile-time constant pointer value.
Suspected area:
- `src/ir/lower.zig`, `Lowering.constExprValue` — the switch has no
`.null_literal` arm, so `constStructLiteral` treats a pointer field initialized
with `null` as non-constant and `globalInitValue` reports the whole aggregate.
- `src/ir/emit_llvm.zig`, top-level `global.init_val` emission and
`LLVMEmitter.emitConstAggregate` — both currently rely on catch-all
`else => LLVMConstNull(...)` for several `ConstantValue` tags. If `.null_val`
is threaded through aggregate constants, add explicit `.null_val` handling
there (and explicit `.zeroinit` / `.undef` handling as appropriate) rather
than depending on the catch-all.
Likely fix:
- Add `.null_literal => .null_val` to `constExprValue` for constant aggregate
serialization.
- Ensure LLVM constant emission handles `.null_val` explicitly for both
top-level constants and nested aggregate leaves.
- Keep unsupported aggregate expressions loud: non-constant calls/field-accesses
should still diagnose instead of zero-initializing.
Verification:
- Run the repro above and expect:
```text
ptrs=true true markers=11 22
```
- Add a pinned regression in the `01xx` types block covering a global
array-of-struct with pointer-null fields (and, if straightforward, optional
null fields too).
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```

View File

@@ -1,110 +0,0 @@
# 0082 - global enum-literal initializer silently zero-initializes
> **RESOLVED.**
> **Root cause:** `Lowering.globalInitValue` (`src/ir/lower.zig`) carried an
> `.enum_literal => null` carve-out: any enum-literal global initializer returned
> a null payload, which the LLVM/interp emitters turn into a zero-initialized
> global — so `chosen : Color = .green` read back as the first tag (`.red`).
> `constExprValue` had no enum-literal arm either, so an enum tag inside a global
> array (`[2]Color = .[.green, .blue]`) or struct field made the whole aggregate
> look non-constant and the global was rejected outright.
> **Fix:** a new `Lowering.constEnumLiteral` serializes an enum literal to a
> `ConstantValue.int` holding the variant's tag value, resolved against the
> destination enum type and respecting explicit variant values (`enum { a; b ::
> 5; }`); the global's type drives the backing width at emit time. Wired into both
> `globalInitValue` (scalar global) and `constExprValue` (array element / struct
> field / nested aggregate). A non-enum destination or an unknown variant is
> diagnosed loudly — never silently zero-initialized. The compiler-injected
> `OS`/`ARCH` globals now serialize to their real `.unknown` tag (6 / 4) instead
> of relying on the null→zero fallback; runtime reads are unchanged because they
> resolve through `comptime_constants`. As part of the same exhaustiveness pass,
> the silent `func_ref => … orelse LLVMConstNull` fallbacks in the LLVM constant
> emitters (`src/ir/emit_llvm.zig`) were removed: aggregate func_ref leaves carry
> a `require_resolved` flag (transient null in Pass 0, loud diagnostic if still
> unresolved in the Pass-1.5 re-emit), a top-level func_ref global is resolved in
> `initVtableGlobals`, and the comptime (`#run`) path bails loudly instead of
> emitting a null function pointer.
> **Regression:** `examples/0139-types-global-enum-literal-init.sx` (scalar enum
> global, global array of enum, enum struct field, explicit-value `enum u16` for
> element-stride, struct-array with enum field) — FAILS on the pre-fix compiler
> (wrong tag / rejected as non-constant), PASSES after. Negative:
> `examples/1127-diagnostics-global-enum-literal-bad-variant.sx` (unknown variant
> rejected loudly, exit 1).
## Symptom
A module-global enum initialized with a non-zero enum literal silently reads back
as the zero tag. Observed: `chosen : Color = .green;` prints `.red` and the
program exits 1. Expected: it should print `.green` and exit 0, or the compiler
should reject unsupported enum-literal global initializers loudly instead of
zero-initializing.
## Reproduction
```sx
#import "modules/std.sx";
Color :: enum u8 { red; green; blue; }
chosen : Color = .green;
main :: () -> i32 {
print("chosen={}\n", chosen);
if chosen == .green {
print("PASS\n");
return 0;
}
print("FAIL\n");
return 1;
}
```
Observed:
```text
chosen=.red
FAIL
```
Expected:
```text
chosen=.green
PASS
```
## Investigation prompt
Fix issue 0082: module-global enum literal initializers silently become the zero
tag.
Suspected area:
- `src/ir/lower.zig`, `Lowering.globalInitValue`: the `.enum_literal => null`
carve-out preserves the stdlib's historical zero-init path for compiler-
injected `OS : OperatingSystem = .unknown`, but it also silently drops any
user-written non-zero enum literal such as `.green`.
- `src/ir/lower.zig`, `Lowering.constExprValue`: aggregate enum-literal fields
are currently not serialized either, so audit both top-level and aggregate
enum literals.
Likely fix:
- Resolve the destination enum type from `var_ty` / `expected_ty` and serialize
the enum tag as a `ConstantValue.int` with the variant index/value.
- If a particular enum literal shape cannot be serialized yet (payload variants,
unsupported explicit tag values, etc.), emit a diagnostic instead of returning
`null`.
- Preserve the compiler-injected `OperatingSystem` / `Architecture` behavior by
making those globals real constants, not by relying on null initializer
fallback.
Verification:
- Run the repro above and expect `chosen=.green` / `PASS` / exit 0.
- Add a pinned regression in the `01xx` types block for a non-zero enum global
and, if supported by the fix, an enum field inside a global aggregate.
- Run:
```sh
zig build
zig build test
bash tests/run_examples.sh
```

View File

@@ -1,242 +0,0 @@
# 0083 — fixed array with a named-constant dimension is miscompiled
> **RESOLVED.** Root cause: `TypeResolver.resolveCompound`'s array arm resolved
> the dimension with `if (length.data == .int_literal) ... else 0` — a named
> const (`N :: 16`) hit the silent `else 0`, so `[N]T` became a 0-length / 0-byte
> array and element access ran out of bounds (garbage for scalars, bus error for
> slice/pointer/struct elements). Fix: the array arm now delegates the dimension
> to `inner.resolveArrayLen` (symmetric with `inner.resolveInner` for the element
> type). The stateful `Lowering.resolveArrayLen` evaluates the dimension as a
> compile-time integer across the comptime-constant, generic-value, and
> module-global const tables, and emits a diagnostic (no fabricated length) when
> it isn't one.
>
> **Exhaustive follow-up (attempt 2).** The first fix covered every *stateful*
> resolution path (direct local decls, struct fields, function params/returns),
> but the *stateless* registration-time resolver (`type_bridge`, used for type
> aliases `Arr :: [N]T` and inline union/enum field types) still resolved the
> named dim with a silent `else 0` — so `Arr :: [N]i64; a : Arr` and
> `union { a: [N]i64 }` were still miscompiled. Fix: the module-global const
> table (`ProgramIndex.module_const_map`) is now threaded into `type_bridge`
> alongside the alias map, so `StatelessInner.resolveArrayLen` resolves a named
> module-const dim to the same length everywhere. The remaining unresolvable case
> (a computed/comptime dimension on the binding-free path) bails LOUDLY instead of
> fabricating a 0 length. Files: `src/ir/type_resolver.zig`, `src/ir/lower.zig`,
> `src/ir/type_bridge.zig`. Regression: `examples/0140-types-named-const-array-dim.sx`
> (direct + type-alias + nested `[N][M]T` + union-field dims, i64 / string /
> struct element types).
>
> **Root-cause close-out (attempt 3).** Attempt 2 threaded the const map into
> `type_bridge` but the map wasn't fully populated when an alias resolved its
> dimension: type aliases (`Arr :: [N]T`) resolve EAGERLY in scanDecls pass 1,
> while TYPED consts (`N : i64 : 16`) register only in pass 2 and a
> forward-declared untyped const (`Arr :: [N]T; N :: 16`) hadn't registered yet
> either — so the stateless resolver saw an empty table, printed a non-fatal
> warning, fabricated length 0, and CONTINUED to garbage / a segfault. Three
> coordinated fixes: (1) a scanDecls **pass 0** pre-registers every integer-valued
> module const into `module_const_map` BEFORE any alias resolves, so typed,
> untyped, and forward-referenced consts all resolve identically; (2) both the
> stateful and stateless dim resolvers now share one routine
> (`program_index.moduleConstInt`) so they cannot disagree again; (3) the length-0
> fabrications are GONE — `resolveArrayLen` returns `?u32`, `resolveCompound`
> yields the `.unresolved` sentinel on null (never a 0-byte array), the stateful
> path emits a diagnostic, and the registration path surfaces an unresolved alias
> as a clean compile error that aborts the build (the `type_bridge.zig:270`
> Vector-lane `else => 0` is fixed the same way). Files:
> `src/ir/program_index.zig`, `src/ir/lower.zig`, `src/ir/type_bridge.zig`,
> `src/ir/type_resolver.zig`. Regressions:
> `examples/0143-types-typed-const-array-dim.sx` (typed-const dim direct + via
> alias for i64/string/struct, forward-ref alias, nested) and
> `examples/1129-diagnostics-array-dim-not-const.sx` (an unresolvable computed dim
> halts with a clean diagnostic + non-zero exit, not a fabricated 0-length array).
>
> **Const-expression dimensions (attempt 4).** Attempts 13 resolved only a BARE
> named-const dim (`[M]`) or a literal (`[5]`); any constant-FOLDABLE *expression*
> dimension (`[M + 1]`, `[M * N]`, `[N - M]`, nested `[M + N - 1]`, parenthesised
> `[(M + 1) * 2]`) was wrongly rejected as "not a compile-time integer constant"
> even though every operand is compile-time-known. Such a dimension MUST be
> evaluated, not rejected. Fix: the shared dim resolver now routes the dimension
> through a single constant integer-expression evaluator
> (`program_index.evalConstIntExpr`) that folds integer `+ - * / %` and unary
> negate (parentheses carry no AST node) over literals and named/typed module
> consts, recursively. The leaf-name lookup is delegated (`ctx.lookupDimName`) so
> the stateful body-lowering path and the stateless registration path share the
> EXACT SAME folding logic and cannot diverge — an expression dim via a type alias
> resolves identically to the direct form. The no-fabrication discipline is
> unchanged: a genuinely non-comptime dimension (a runtime local, a non-comptime
> call, an unbound name) — or arithmetic that overflows / divides by zero — still
> yields null → `.unresolved` → the same clean compile-halting diagnostic, never a
> fabricated length. Files: `src/ir/program_index.zig` (+`.test.zig`),
> `src/ir/lower.zig`, `src/ir/type_bridge.zig`. Regression:
> `examples/0144-types-const-expr-array-dim.sx` (every expression form, direct vs
> alias, scalar / string / struct element types); `1129` re-pointed at a genuinely
> non-const dimension (`[get()]i64`, a runtime call) so it still proves the
> stateless clean-halt.
>
> **Unified comptime-int evaluator (attempt 5).** Attempts 14 fixed the array
> *dimension* paths but the SAME length-0 fabrication class survived on the
> siblings that resolve a comptime integer elsewhere: the three Vector lane
> resolvers (`resolveTypeCallWithBindings`, `resolveParameterizedWithBindings`,
> `resolveArrayLiteralType`) and the two generic value-param binders
> (`instantiateGenericStruct`, `instantiateTypeFunction`) each hand-rolled an
> `else => 0` switch, so `Vector(N, f32)` / `Vec(N, f32)` (N a module const)
> fabricated a 0-lane `<0 x float>` (LLVM "huge alignment" abort) or a 0 binding
> under a wrong mangled name; and the `inline for` bound folder (`evalComptimeInt`)
> only knew literals / comptime cursors / `<pack>.len`, so `inline for 0..M` failed
> outright. Fix: every one of those sites now routes through the single shared
> `program_index.evalConstIntExpr` — `evalComptimeInt` delegates to it (the pack
> `.len` leaf moved into the shared folder via a new `ctx.lookupPackLen`); the
> Vector lane and value-param resolvers fold through it and emit a clean diagnostic
> + `.unresolved` (never `else => 0`) on a non-const operand. Two enabling fixes
> upstream of resolution: the unknown-type semantic checker no longer walks a
> value-param position (`Vector(N, …)` / `Vec(N, …)`) as a type name (it was
> reporting "unknown type 'N'"); and both the parameterized-type-arg parser and
> the function-body-detection lookahead (`hasFnBodyAfterArrow`) accept a
> const-EXPRESSION in a value position, so `Vector(M + 1, f32)` and `[M + 1]T`
> parse as a return type too (the latter a pre-existing attempt-4 sibling miss).
> Files: `src/ir/program_index.zig` (+`.test.zig`), `src/ir/lower.zig`,
> `src/ir/type_bridge.zig`, `src/ir/semantic_diagnostics.zig`, `src/parser.zig`.
> Regressions: `examples/1501-vectors-const-lane.sx` (named-const + const-expr
> lane, direct + alias, 3- and 4-lane reads), `examples/1502-vectors-runtime-lane-
> not-const.sx` (a runtime lane clean-halts, exit 1, no LLVM crash),
> `examples/0207-generics-value-param-const.sx` (`Vec(N,f32)` / `Vec(M+1,f32)`
> resolve to the same instantiation as `Vec(3,f32)`),
> `examples/0610-comptime-inline-for-const-bound.sx` (`inline for 0..M` and
> `0..(M+1)` unroll).
>
> **Value-param type functions + oversized guard (attempt 6).** Two remaining
> siblings in the comptime-int path. (1) A type-RETURNING function with a value
> param used as a TYPE annotation (`b : Make(N, i64)` where `Make :: ($K: u32,
> $T: Type) -> Type { return [K]T; }`) was rejected "unknown type 'N'" because
> the unknown-type checker walked the value-param position as a type name, AND the
> parameterized-type-annotation path never routed to `instantiateTypeFunction`
> (only the `.call` path did), nor did that binder resolve a non-struct/union
> return shape. Fix: `isValueParamPosition` (semantic_diagnostics.zig) now also
> skips a value param of a `fn_ast_map` type-returning function (mirroring the
> binder's value/type classification); `resolveParameterizedWithBindings` routes
> a type-returning-function name to `instantiateTypeFunction`; and that binder
> resolves a general return-type expression (`return [K]T`) with bindings active.
> `Make(N, i64)`, `Make(M + 1, i64)`, and `Make(3, i64)` now resolve to one
> `[3]i64`. (2) Oversized dim/lane folds (`[5_000_000_000]`) panicked the
> compiler — fixed under issue 0087 via the shared range-checked
> `program_index.foldDimU32` gate. Files: `src/ir/semantic_diagnostics.zig`,
> `src/ir/lower.zig`, `src/ir/program_index.zig`, `src/ir/type_bridge.zig`.
> Regression: `examples/0208-generics-value-param-type-function.sx`.
>
> **Diagnostic-accuracy parity (attempt 7).** The fold + layout were correct, but
> the two paths still DIVERGED on the error MESSAGE for an oversized dim. The
> direct form (`a : [5_000_000_000]i64`) reported the accurate "array dimension
> 5000000000 does not fit in u32" (from the stateful `resolveArrayLen`, which
> branches on `foldDimU32`'s `.too_large` / `.below_min` / `.not_const` variants),
> but the type-ALIAS form (`Big :: [5_000_000_000]i64`) reported a FALSE "an array
> dimension is not a compile-time integer constant" — because the stateless
> `resolveArrayLen` collapsed every non-`.ok` `DimU32` to `null`, so the
> alias-registration site had only one generic message to emit. Fix: a single
> wording source `program_index.reportDimError(diag, span, DimU32)` now owns the
> dim-error text; the stateful path emits through it, and the alias-registration
> site re-folds a top-level array dim via the new `type_bridge.foldArrayDim`
> (same shared `foldDimU32`) and routes a `.too_large` / `.below_min` result to
> `reportDimError` — so an oversized alias dim now reports the SAME precise
> message as the direct form. A genuinely non-const alias dim (`[get()]`) still
> gets the alias-specific "not a compile-time integer constant" message (1129).
> Files: `src/ir/program_index.zig`, `src/ir/type_bridge.zig`, `src/ir/lower.zig`.
> Regression: `examples/1131-diagnostics-array-dim-oversized-u32-alias.sx`
> (oversized dim via alias → "does not fit in u32", matching direct example 1130;
> 1129 still proves the non-const path keeps the generic message).
>
> **Integral-float counts + value-param range gate (attempt 8, Agra ruling).**
> Two finishing items on the shared count path. (1) An *integral* compile-time
> FLOAT used as a count (array dim, Vector lane, value-param, `inline for` bound)
> was wrongly rejected — `N : f64 : 4.0`, `N :: 4.0`, and `[4.0]i64` all said
> "must be a compile-time integer constant". The shared evaluator now folds an
> integral float to its integer at the single leaf
> (`program_index.floatToIntExact`, used by both the `.float_literal` arm of
> `evalConstIntExpr` and `moduleConstInt`), so every consumer accepts `4.0` ≡ `4`
> while a non-integral (`4.5`) or negative value is still rejected by the
> downstream `foldDimU32` gate. (2) A generic value-param bind (`Box($K: u32)`)
> never range-checked the folded arg against its declared type, so
> `Box(5_000_000_000)` compiled and ran; the bind now routes a `u32` count
> through the same `foldDimU32` gate (and any other declared integer type through
> `program_index.intTypeRange`), so an out-of-range arg is a clean compile error
> ("value 5000000000 does not fit in u32 parameter K"). Files:
> `src/ir/program_index.zig` (+`.test.zig`), `src/ir/lower.zig`, `specs.md`.
> Regressions: `examples/0145-types-integral-float-array-dim.sx`,
> `examples/1504-vectors-integral-float-lane.sx`,
> `examples/0611-comptime-integral-float-inline-for.sx`,
> `examples/0209-generics-value-param-integral-float.sx`,
> `examples/1132-diagnostics-array-dim-non-integral-float.sx`,
> `examples/1133-diagnostics-array-dim-negative-float.sx`,
> `examples/1134-diagnostics-value-param-u32-overflow.sx`.
>
> **Convergence — the last three count-surface cells (attempt 9).** Three
> adjacent cells of the SAME shared count surface still diverged. (1) An ALIASED
> integer constraint (`Count :: u32`; `$K: Count`) bypassed the value-param range
> gate — only BUILTIN constraint names matched `intTypeRange`, so
> `Box(5_000_000_000)` with `$K: Count` compiled and bound a truncated value. The
> gate (`Lowering.resolveValueParamArg`, shared by BOTH binders — struct +
> type-fn) now resolves the constraint to its underlying builtin
> (`canonicalIntConstraintName`: `Count` → u32, `Small` → i8) before
> range-checking, so an aliased integer constraint behaves exactly like the
> builtin it names. (2) A named const with an EXPRESSION RHS (`M :: 2; N :: M + 1`)
> did not fold as a count — `program_index.moduleConstInt` read only a LITERAL RHS
> node. It now folds every const's RHS through the shared `evalConstIntExpr`
> (cycle-guarded so `N :: N` / mutual cycles fold to null, not a stack overflow),
> and scanDecls pass-0 pre-registers expression-RHS consts; so `N :: M + 1` == 3
> at every count consumer (dim direct + alias, Vector lane, value-param struct +
> type-fn, `inline for`). (3) The stateful `Lowering.resolveArrayLen` STILL
> fabricated length 0 after a failed fold; it now returns null → the `.unresolved`
> sentinel (no fabrication), and the binding's lowering bails on it cleanly — a
> field access on an already-diagnosed `.unresolved` value stays silent
> (`emitFieldError`), so a failed-fold dim emits ONE clean diagnostic and never
> reaches the `sizeOf` panic. Files: `src/ir/program_index.zig` (+`.test.zig`),
> `src/ir/lower.zig`. Regressions: `examples/0146-types-comptime-count-matrix.sx`
> (the full positive matrix — every consumer × representative leaf form),
> `examples/1135-diagnostics-value-param-alias-constraint-overflow.sx` (aliased
> u32 + i8 overflow), `examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx`
> (direct non-const dim halts cleanly, no fabrication / panic); the cascade
> cleanup also tightened `examples/1502`/`1503` to one diagnostic each.
>
> **Final convergence — type-fn binder parity (attempt 10).** One last cell of
> the count surface still diverged from the struct binder. A FAILED value-param
> bind on a type-RETURNING FUNCTION (`MakeC :: ($K: Count, $T: Type) -> Type
> { return [K]T; }`; `a : MakeC(5_000_000_000, i64)`) emitted its correct range
> diagnostic, but `instantiateTypeFunction` then returned `null`, so
> `resolveParameterizedWithBindings` fell through to the empty-struct *placeholder*
> named `MakeC`. The binding `a` got that placeholder type, so a downstream
> `a.len` cascaded a bogus second error `field 'len' not found on type 'MakeC'`.
> The struct binder (`instantiateGenericStruct`) already returned `.unresolved`
> here; the type-fn binder now matches it — the failed value-param bind poisons to
> `.unresolved` instead of `null`, so the caller propagates the diagnosed poison
> and the existing `emitFieldError` suppression yields ONE clean diagnostic. Covers
> every type-fn value-param failure mode (overflow via aliased constraint,
> non-const arg, unknown type arg). Files: `src/ir/lower.zig` (one line in
> `instantiateTypeFunction`). Regression:
> `examples/1137-diagnostics-value-param-type-fn-no-cascade.sx`.
## Symptom
A fixed array whose dimension is a module-global integer constant (`N :: 16;
a : [N]T`) miscompiles element access: reads/writes compute a wrong address.
With `i64` elements `a[0]` returns GARBAGE (silent); with slice/pointer element
types (`[N]string`) it Bus-errors. The identical program with a LITERAL dimension
(`a : [16]T`) is correct. Silent-miscompile class (cf. 00790082).
## Reproduction
```sx
#import "modules/std.sx";
N :: 16;
main :: () { a : [N]i64 = ---; a[0] = 7; print("a0={}\n", a[0]); }
```
`./zig-out/bin/sx run` prints `a0=8472789232` (garbage); want `a0=7`. Replacing
`[N]` with `[16]` prints `7`.
## Investigation prompt
A fixed-array TYPE whose dimension is a named const (`N :: 16; [N]T`) resolves to
a wrong element stride / array length in codegen — element address computation is
wrong (garbage for scalars, bad pointer for slice/pointer elements). Literal
dimensions are correct, so the defect is in resolving the array-type DIMENSION
from a constant expression (vs a literal) — the dim likely resolves to 0/unknown
or the element size is wrong. Look at array-type resolution where the length is a
const-expr (type lowering / sizeof / element-stride computation). Fix so a
named-const dimension yields the same layout as the literal. Verify with the
repro (expect 7) + a `[N]string`/`[N]struct` case (no bus error, correct reads),
and `zig build && zig build test && bash tests/run_examples.sh` green.

View File

@@ -1,43 +0,0 @@
# 0084 — array/slice literal passed directly as a call argument miscompiles
> **RESOLVED.** Root cause: `lowerArrayLiteral` always produces an aggregate
> ARRAY value; the array→slice conversion is the caller's job. The local-bound
> var-decl path did it (emits `array_to_slice`), but the call-argument coercion
> path (`coerceCallArgs` → `coerceToType` → `CoercionResolver.classify`) had no
> array→slice arm, so `classify([N]T, []T)` returned `.none` and the raw array
> value was passed where a slice was expected — the callee read its {ptr,len}
> header off the wrong bytes (returned 0 / garbage, segfaulted for `[]i64`). Fix:
> `classify` now returns a new `.array_to_slice` plan for `[N]T → []T` (same
> element type), and `coerceToType` emits the existing `array_to_slice` op, which
> materializes the array into addressable storage and builds the slice header —
> identical to the local-bound path. Files: `src/ir/conversions.zig`,
> `src/ir/lower.zig`. Regression: `examples/0141-types-slice-literal-direct-call-arg.sx`
> (string + numeric `[]i64`, direct vs local-bound).
## Symptom
A `.[...]` array/slice literal passed DIRECTLY as a call argument yields a slice
whose element CONTENTS are not reliably readable in the callee (silent — reads
garbage, wrong results). Binding the same literal to a typed local first and
passing the local is correct.
## Reproduction
```sx
#import "modules/std.sx";
show :: (xs: []string) -> i64 { n:=0; i:=0; while i<xs.len { if xs[i]=="nope" {n+=1;} i+=1; } return n; }
main :: () {
print("direct={}\n", show(.["a","nope","b","nope"])); // prints 0 (WRONG)
local : []string = .["a","nope","b","nope"]; print("local={}\n", show(local)); // prints 2 (correct)
}
```
Want both `2`. Direct-literal-arg returns `0`.
## Investigation prompt
Passing a `.[...]` literal directly as a call arg builds a slice/array temporary
whose backing storage is not correctly materialized/kept alive for the callee —
the slice header may point at a stack temp that is clobbered, or the elements are
not stored before the call. Binding to a typed local first works (the local's
storage backs the slice). Look at how a literal aggregate argument is lowered at a
call site (materialize the literal into addressable storage whose lifetime spans
the call, then pass a slice/pointer to it) vs the local-bound path. Fix so a
directly-passed literal arg behaves identically to a local-bound one. Verify with
the repro (both `2`) + a numeric `[]i64` case, gate green.

View File

@@ -1,55 +0,0 @@
# 0085 — nested slice literal elements are stored as raw arrays
> **RESOLVED.** Root cause: `Lowering.lowerArrayLiteral` lowered each element with
> the element type as `target_type` but appended the returned value directly. For
> a nested `.[...]` element whose expected element type is a slice (`[]T`), the
> inner literal still lowers to an aggregate ARRAY `[N]T` — so the outer aggregate
> (typed array-of-`[]T`) held raw arrays where slice {ptr,len} headers were
> expected; indexing the inner slice read a garbage pointer and segfaulted. Fix:
> after lowering each element, when the element type is a slice and the lowered
> value is a same-element array, coerce it via the existing `array_to_slice` op
> (materialize backing storage + build the header) — identical to the whole-
> literal coercion the var-decl / call-arg paths already run. The coercion
> recurses with the nesting, so `[][]T` and deeper materialize at every level.
> Files: `src/ir/lower.zig` (`lowerArrayLiteral`). Regression:
> `examples/0142-types-nested-slice-literal-elements.sx` (`[][]i64` + `[][]string`,
> local-bound AND direct-call-argument forms).
## Symptom
Nested array/slice literals such as `.[.[1, 2], .[3, 4]]` miscompile when the
expected element type is a slice (`[][]i64`). Observed: both the local-bound and
direct-call forms segfault while indexing the inner slice. Expected: both forms
materialize each inner `[N]T` literal as a `[]T` slice and print the same value.
## Reproduction
```sx
#import "modules/std.sx";
sum_nested :: (xss: [][]i64) -> i64 {
return xss[0][1] + xss[1][0];
}
main :: () {
local : [][]i64 = .[.[1, 2], .[3, 4]];
print("local={}\n", sum_nested(local));
print("direct={}\n", sum_nested(.[.[1, 2], .[3, 4]]));
}
```
Observed on `flow/sx-foundation/F0.4`: segfault at address `0x9` before either
line prints. Expected output:
```text
local=5
direct=5
```
## Investigation prompt
Fix nested slice literal materialization. The likely area is
`src/ir/lower.zig` in `Lowering.lowerArrayLiteral`: the outer literal can know
its expected element type is `[]T`, and the loop sets `self.target_type =
elem_ty` while lowering each inner literal, but it appends the returned value
directly. For an inner `.[...]`, that returned value is still an array aggregate
`[N]T`, not the target `[]T` slice. Add per-element coercion/materialization
after lowering each element, using the element source type and expected
`elem_ty` (the existing `array_to_slice` coercion should be reused). Verify the
repro prints `local=5` and `direct=5`, then run `zig build && zig build test &&
bash tests/run_examples.sh`.

View File

@@ -1,86 +0,0 @@
# 0086 — writing to a Vector lane (`v.x = …`) panics with "unresolved type reached LLVM emission"
> **RESOLVED** (F0.5).
>
> **Root cause.** The vector-lane STORE path had no vector branch. In
> `Lowering.lowerAssignment` (`src/ir/lower.zig`) a `.field_access` target on a
> `Vector` fell through to the struct-field lookup, where no field matched a
> lane name, so `field_ty` stayed `.unresolved`. The store then built a
> `ptrTo(.unresolved)` whose pointee reached LLVM in `emitStore`
> (`src/backend/llvm/ops.zig`) → `toLLVMTypeInfo` `.unresolved` tripwire panic.
> The READ path resolved the lane fine; the two paths had diverged (issue-0083
> two-resolver class).
>
> **Fix (per file).**
> - `src/ir/lower.zig` — extracted a shared `Lowering.vectorLaneIndex(field)`
> resolver (`.x/.y/.z/.w` + colour aliases `.r/.g/.b/.a` → lane 0..3, `null`
> otherwise). The READ path (`lowerFieldAccessOnType`) now delegates to it
> (dropping its silent `else 0` fallback), and a new vector branch in
> `lowerAssignment` uses the SAME resolver to `structGepTyped` a typed pointer
> to the lane and `storeOrCompound` with the vector element type (plain and
> compound assignment). A non-lane field now reports a field-not-found error on
> both paths instead of silently reading lane 0 / panicking.
> - `src/backend/llvm/ops.zig` — `emitStructGep` now addresses a vector
> `base_type` with a `[0, lane]` `GEP2`, yielding a pointer to the lane element
> for the scalar store.
>
> **Regression test.** `examples/1506-vectors-lane-store.sx` — `.[…]`-init and
> `= ---`-init writes, every lane of a 4-lane vector, colour aliases, and a
> compound lane assignment, reading each value back. Unit test
> `src/ir/lower.test.zig` pins the `vectorLaneIndex` contract.
## Symptom
Assigning to a component of a `Vector` local — `v.x = 1.0` (also `.y` / `.z` /
`.w`) — aborts the compiler with the internal panic:
```
thread … panic: unresolved type reached LLVM emission — a type resolution
failure was not diagnosed/aborted
src/backend/llvm/types.zig:175 toLLVMTypeInfo (.unresolved arm @panic)
src/backend/llvm/ops.zig:358 emitStore (.pointer => toLLVMType(p.pointee))
```
READING a lane (`x := v.x`) is fine; only the STORE side hits it. The init form
(`= ---` undefined vs `= .[…]` literal) does not matter — both panic once a lane
is written. A literal lane count (`Vector(3, f32)`) triggers it, so this is NOT
the lane-count resolution class (issue 0083); it is a distinct bug in the
vector-lane **store** path, where the store's pointee type resolves to the
`.unresolved` sentinel instead of the lane element type.
Discovered while fixing issue 0083 (attempt 5). It is pre-existing and orthogonal
— confirmed by reproducing on the pristine pre-0083-attempt-5 compiler — so it was
NOT introduced by the lane-count fix. The standard vector idiom (construct via a
`.[…]` literal / a constructor function returning `.[…]`, then read components or
use vector arithmetic, as in `examples/1500-vectors-vector-math.sx`) is
unaffected; only component ASSIGNMENT is broken.
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
v : Vector(3, f32) = .[0.0, 0.0, 0.0];
v.x = 1.0; // panic here
print("x={}\n", v.x);
}
```
`./zig-out/bin/sx run` panics. Removing the `v.x = 1.0` line (read-only) prints
`x=0.000000` and exits 0.
## Investigation prompt
A store to a `Vector` lane (`v.x = …`) lowers a pointer-to-lane whose pointee
type reaches LLVM as `.unresolved`, so `emitStore`
(`src/backend/llvm/ops.zig:358`, the `.pointer => toLLVMType(p.pointee)` arm)
hits the `.unresolved` tripwire panic in
`src/backend/llvm/types.zig:175`. The lane READ path computes the lane element
type correctly, so compare the lvalue/store lowering for a vector-component
assignment against the rvalue/load path — the component-write path is likely
building the lane pointer's pointee from a vector `.x`/`.y`/`.z`/`.w` field
resolution that returns `.unresolved` (or a vector field-access that resolves the
element type on load but not on store). Find where a `Vector` swizzle/component
assignment lowers its destination pointer (grep for vector component handling in
`lower.zig` assignment lowering and in the LLVM `emitStore` GEP path) and resolve
the lane element type there the same way the load path does. Verify with the
repro (expect `x=1.000000`) plus a `.[…]`-init write and a write to each of
`.x/.y/.z/.w` on a 4-lane vector, then `zig build && zig build test && bash
tests/run_examples.sh` green.

View File

@@ -1,64 +0,0 @@
# 0087 - oversized compile-time integer in type dimension/lane panics
> **RESOLVED.** Root cause: an array dimension / Vector lane folded to a valid
> `i64` (e.g. `5_000_000_000`) was then narrowed to `u32` with an unchecked
> `@intCast` at three sites (`Lowering.resolveArrayLen` lower.zig:11656,
> `Lowering.resolveVectorLane` lower.zig:11836, `StatelessInner.resolveArrayLen`
> type_bridge.zig:58), aborting the COMPILER with "integer does not fit in
> destination type" — the fold was correct, the narrowing was not. Fix: a single
> range-checked fold-to-u32 gate, `program_index.foldDimU32(node, ctx, min)`,
> folds via `evalConstIntExpr` then checks `[min, maxInt(u32)]` and returns a
> tagged `DimU32` (`.ok` / `.not_const` / `.below_min` / `.too_large`). Every
> dim/lane narrowing now routes through it — no call site does a bare `@intCast`,
> so an out-of-u32-range dim/lane surfaces a clean diagnostic ("array dimension N
> does not fit in u32" / "Vector lane count N does not fit in u32") and halts the
> build (exit 1) instead of panicking. Value-param args stay `i64` until used as
> a dim/lane, where the same gate checks them. Files: `src/ir/program_index.zig`
> (`DimU32` + `foldDimU32`), `src/ir/lower.zig`, `src/ir/type_bridge.zig`.
> Regressions: `examples/1130-diagnostics-array-dim-oversized-u32.sx` (oversized
> array dim → clean halt) and `examples/1503-vectors-oversized-lane-not-u32.sx`
> (oversized Vector lane → clean halt).
## Symptom
An oversized compile-time integer used where the compiler expects a `u32`
array dimension or Vector lane count panics inside the compiler instead of
emitting a source diagnostic.
Observed: `@intCast` panics with "integer does not fit in destination type" in
`Lowering.resolveArrayLen` / `Lowering.resolveVectorLane`.
Expected: compile halts with a normal diagnostic such as "array dimension does
not fit in u32" / "Vector lane count must fit in u32", and no compiler panic.
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
a : [5000000000]i64 = ---;
print("{}\n", a.len);
}
```
Vector lane sibling:
```sx
#import "modules/std.sx";
main :: () {
v : Vector(5000000000, f32) = .[];
print("{}\n", v);
}
```
## Investigation prompt
Fix oversized compile-time integer handling for fixed-array dimensions and
Vector lane counts. Suspected area: `src/ir/lower.zig`
`Lowering.resolveArrayLen` and `Lowering.resolveVectorLane`, plus the stateless
adapter in `src/ir/type_bridge.zig` `StatelessInner.resolveArrayLen`. These
functions fold to `i64` through `program_index.evalConstIntExpr`, then cast to
`u32` with `@intCast`; values greater than `std.math.maxInt(u32)` panic in the
compiler. The fix likely needs an explicit range check before every `u32` cast,
with a diagnostic on the stateful path and null / `.unresolved` propagation on
the stateless path. Verify both repros exit 1 with source diagnostics and no
panic, then run `zig build && zig build test && bash tests/run_examples.sh`.

View File

@@ -1,131 +0,0 @@
> **RESOLVED (F0.7)** — A typed module-level constant whose initializer does not
> match its annotation is now rejected at the declaration with a clear
> `type mismatch` diagnostic, killing both symptoms (the `print(N)` segfault and
> the `[N]i64` → 4 fold).
>
> **Root cause.** `registerTypedModuleConst` (`src/ir/lower.zig`) stored the
> annotation type on the const but never checked the initializer literal against
> it, so `N : string : 4` registered as `{value = int 4, ty = string}`.
> `emitModuleConst` then stamped the `int_literal` with the `string` type (a
> bogus pointer → segfault at the use site), and `program_index.moduleConstInt`
> folded the const into an integer COUNT by inspecting the `int_literal` node
> alone, ignoring `ModuleConstInfo.ty` (so `[N]i64` folded to 4).
>
> Both LITERAL initializers (`N : string : 4`) and const-EXPRESSION initializers
> (`M :: 2; N : string : M + 2`, `V : string : -M`) are rejected — the validation
> is type-based, so a non-literal node kind can no longer escape it (attempt 2).
>
> **Mixed-numeric escape closed at the type-system root (attempt 3).** The
> type-based validation reuses `inferExprType`, which inferred a non-comparison
> binary op from its LHS alone — so `BAD : i64 : M + 0.5` (i64 + f64) inferred
> `i64` and was accepted+truncated, while `0.5 + M` inferred `f64` and was
> rejected: operand-order-dependent. The fix is in the binary-op arm of
> `ExprTyper.inferType` (`src/ir/expr_typer.zig`): arithmetic / bitwise / shift
> ops now infer the PROMOTED result of `(lhs, rhs)` via `Lowering.arithResultType`
> — the same int×float → float rule `lowerBinaryOp` already applied for the
> value (extracted from its inline block into a shared helper, so the two can't
> diverge). `M + 0.5` now infers `f64` in either operand order, so the typed-const
> validation rejects it against an `i64` annotation with no special-casing in the
> validation logic itself. This was a pre-existing inference bug broader than
> typed consts (it also mis-formatted `print("{}", M + 0.5)` as a truncated int);
> the typed-LOCAL `y : i64 = 1.5` → 1 narrowing is a SEPARATE assignment-coercion
> bug tracked as issue 0095.
>
> **Fix per file.**
> - `src/ir/lower.zig` — `registerTypedModuleConst` validates the initializer
> against the resolved annotation BY TYPE, covering literals AND
> const-expressions (binary_op / unary_op) uniformly. `typedConstInitFits`
> keeps the literal arms (int → int/float, float → float, bool → bool,
> string → string, null → pointer/optional, `---` → any) and routes any
> non-literal through `constExprInitFits`, which compares the initializer's
> INFERRED type (`inferExprType`, the existing type-inference facility — no
> second const evaluator) to the annotation with the same integer/float
> compatibility. A mismatch emits `type mismatch: constant '<n>' is declared
> '<ty>' but its initializer is <desc>` at the initializer span (a literal
> names its kind; a const-expression is described by its inferred type, e.g.
> "an integer expression"), and does NOT register the const — it evicts the
> pass-0 placeholder so a count use can't still fold it. On a MATCH the const is
> registered at its resolved annotation type (the same `put` the literal path
> always did), so a const-expression folds and emits at its declared type.
> - `src/ir/program_index.zig` — `moduleConstInt` / `moduleConstIntFramed` take
> the `TypeTable` and gate the fold on `isCountableConstType(ci.ty)` (integer
> of any width, or a float), so a non-numeric typed const can never be folded
> into a count off its initializer node — whether that node is a literal or a
> foldable integer expression. Callers in `lower.zig` and `type_bridge.zig`
> updated.
>
> **Regression tests.**
> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: eight
> mismatch shapes — four literal (`int→string`, `string→i64`, `bool→i64`,
> `float→i64`), two const-expression (`M + 2 → string`, `-M → string`), and two
> mixed-numeric (`i64 : M + 0.5` and `i64 : 0.5 + M`, rejected in BOTH operand
> orders) — each emit a `type mismatch` diagnostic, exit 1.
> - `examples/0162-types-typed-module-const-roundtrip.sx` — positive: valid typed
> consts (`i64` as count + printed, `f32` from int, `f32` float, `string`,
> `*void` null, const-expression `i64 : M + 2` used as a count + printed,
> `f32 : M + 2`, plus mixed-numeric `f64 : M + 0.5` and `f64 : 0.5 + M` folding
> to 2.5 in both orders) compile, fold, and print correctly.
> - `examples/0163-types-mixed-numeric-promotion.sx` — positive: pins the
> inferExprType promotion DIRECTLY in a value context (`print("{}", n + 0.5)`
> formats as the float `2.5`, both operand orders, across `+ - * /` and an f32
> operand; a pure-int expression stays an integer).
> - `src/ir/program_index.test.zig` — `moduleConstInt gates the fold on the
> declared type, not the initializer node` (covers both a literal and a
> binary_op value node declared with a non-numeric type).
> - `src/ir/expr_typer.test.zig` — `arithResultType promotes int×float to the
> float regardless of operand order` (the shared promotion helper).
# 0088 — Typed module const annotation mismatch is accepted
## Symptom
A module-level typed constant whose initializer does not match its annotation is
accepted. Observed: `N : string : 4` compiles; printing `N` segfaults, and using
`N` as an array dimension folds it as `4`. Expected: the const declaration emits
a type-mismatch diagnostic and no downstream use treats it as a valid string or
integer count.
## Reproduction
```sx
#import "modules/std.sx";
N : string : 4;
main :: () {
print("N={}\n", N);
}
```
Related count-surface manifestation:
```sx
#import "modules/std.sx";
N : string : 4;
main :: () {
a : [N]i64 = ---;
print("{}\n", a.len);
}
```
Observed on `flow/sx-foundation/F0.4` attempt 10: the first repro segfaults in
the generated program; the second prints `4`.
## Investigation prompt
Fix issue 0088: typed module constants must validate/coerce their initializer
against the explicit annotation before being registered or used. Suspected area:
`src/ir/lower.zig`, especially `registerTypedModuleConst`, `lowerExpr`'s
module-const identifier path, and any const-declaration lowering that stores
`ProgramIndex.module_const_map` entries. `src/ir/program_index.zig`'s
`moduleConstInt` currently folds by inspecting the initializer node and ignores
`ModuleConstInfo.ty`; after the declaration is diagnosed or represented
correctly, a non-integer typed const such as `N : string : 4` must not become a
valid count. Likely fix: add a typed-const validation path that emits a clear
diagnostic for incompatible initializer/annotation pairs, and ensure the
module-const count lookup only accepts constants whose declared/inferred type is
numeric and integral-compatible. Verify by running the two repros above: expect
a non-zero compile with a type-mismatch diagnostic for `N : string : 4`, no
runtime segfault, and no `[N]` length of `4`.

View File

@@ -1,188 +0,0 @@
# 0089 — backtick raw-identifier escape + `#import c` extern-name exemption from the reserved-type-name rule
> **✅ RESOLVED** (foundation step F0.6). Two mechanisms, per Agra's design
> ruling; the final shape is the **universal raw identifier** (attempt 4):
> `` `name `` is THE LITERAL identifier `name`, usable in EVERY position — value,
> declaration, AND type — meaning only "treat this token as a plain identifier,
> never the reserved keyword/type." The backtick is never part of the name's text.
>
> 1. **Backtick raw identifier.** The lexer recognises a leading backtick
> (`` `i2 ``) and emits an `.identifier` token whose span excludes the backtick,
> carrying a `Token.is_raw` flag ([src/lexer.zig], [src/token.zig]). The flag
> threads through `ast.Identifier`, `ast.TypeExpr`, and EVERY binding / capture /
> declaration node ([src/ast.zig]): `VarDecl` / `ConstDecl` / `Param` / `FnDecl`
> plus `IfExpr` / `WhileExpr` optional bindings, `ForExpr` capture + index,
> `MatchArm` capture, `CatchExpr` / `OnFailStmt` tag bindings, `DestructureDecl`
> per-name, protocol-default / runtime-class method params, AND every
> type-introducing decl — `StructDecl` / `EnumDecl` / `UnionDecl` /
> `ErrorSetDecl` / `ProtocolDecl` / `RuntimeClassDecl` / `UfcsAlias` /
> `NamespaceDecl` / `ImportDecl` / `CImportDecl` / `LibraryDecl`.
>
> - **Value position.** The parser skips `Type.fromName` for a raw identifier
> in expression position ([src/parser.zig] `parsePrimary`), so `` `i2 `` is a
> value identifier; a later bare reference resolves to the binding.
> - **Type position.** `parseTypeExpr` sets the raw flag on the type ATOM and
> lets it flow through the SAME continuations as a bare name (attempt 5), so a
> raw reference parameterizes a reserved-spelled template (`` `i2(i64) ``) and
> composes under the pointer / optional / slice wrappers; `ParameterizedTypeExpr`
> carries `is_raw` and `resolveParameterizedWithBindings` skips the `Vector`
> intrinsic when raw. Resolution skips the builtin classifier
> (`TypeResolver.resolveNamed`'s `skip_builtin`, threaded from `te.is_raw` in
> [src/ir/lower.zig] and [src/ir/type_bridge.zig]) and looks up a
> `` `i2 ``-declared type (struct / enum / union / alias), else a NORMAL
> "unknown type 'i2'" error (`UnknownTypeChecker.reportIfUnknownType` skips the
> builtin-name exemption when raw). A bare `i2` in type position is still the
> builtin int. The SECOND (editor/LSP) classifier in [src/sema.zig]
> (`Type.fromTypeExpr` / `resolveTypeNode` / `resolveTypeNameStr`) honors
> `is_raw` too, so a backtick reserved-name annotation resolves to the user type
> in hover/completion, not the builtin (no two-resolver divergence). The raw bit
> is carried STRUCTURALLY through every COMPOUND shape's inner-name metadata —
> `PointerTypeInfo` / `OptionalTypeInfo` / `SliceTypeInfo` / `ManyPointerTypeInfo`
> / `ArrayTypeInfo` each store a REQUIRED `is_raw` ([src/types.zig], no default,
> so a future construction site cannot drop it) that every `resolveTypeNameStr`
> call passes as its `skip_builtin` — so `` *`i2 ``, `` ?`i2 ``, `` [N]`i2 ``,
> `` []`i2 ``, `` [*]`i2 `` field-access / unwrap / index / deref in the editor
> index all reach the user type instead of reclassifying the inner `i2` to the
> builtin (the divergence the DIRECT-only attempt left for compound forms).
> - **Declaration position.** A bare reserved-name declaration of EVERY kind
> still errors (issue 0076 preserved); the backtick form is exempt. The check
> and the exemption are made structurally symmetric:
> `checkBindingName` / `checkDeclName` ([src/ir/semantic_diagnostics.zig]) take
> `is_raw` as a REQUIRED argument and skip inside the check — no call site can
> validate a name without also honoring the exemption, which is what kept the
> two from desyncing across the earlier attempts. On the PARSER side the
> symmetry is enforced structurally for the bug-prone node: `ConstDecl`'s
> `name_span` + `is_raw` carry NO default (attempt 5), so the compiler rejects
> any construction site — including the two struct-body const forms (untyped
> `` `i2 :: 5 `` and typed `` `i2 : T : v ``) that previously dropped both —
> that omits them. `FnDecl` is built at every parser site through `parseFnDecl`,
> whose `name_is_raw` is a REQUIRED parameter (the equivalent guarantee); the
> type decls likewise route through parse-functions taking `name_is_raw`.
> - **Member-name positions are exempt** (Agra ruling, attempt 7). A struct
> **field** name, a union **tag** name, and a protocol **method-signature**
> name accept a bare reserved spelling: these sit in a member slot and are
> reached via `obj.name` / dispatched by string, so they are never
> type-classified and never mis-lower — the binding-name walk's `struct_decl`
> / `union_decl` / `enum_decl` / `protocol_decl` arms
> ([src/ir/semantic_diagnostics.zig]) check only the *type* name (and method
> *params*), not field / tag / variant / method-signature names. The backtick
> is optional there (`obj.i2` and `` obj.`i2 `` resolve to the same member).
> This bare member-name exemption covers only the **identifier-classified**
> reserved spellings — `i1`..`i64`, `u1`..`u64`, `bool`, `string`, `void`,
> `usize`, `isize`, `Any` — which all lex as ordinary identifiers. The two
> **keyword-classified** spellings, `f32` and `f64`, are lexer keywords
> ([src/token.zig]), and a member-name slot requires an identifier token
> ([src/parser.zig]); a bare `f32` / `f64` is therefore rejected at parse
> (`expected field name in struct`) even in a member position, and still needs
> the backtick there too — `` struct { `f32: i64; } `` / `` union { `f64: … } ``
> / `` protocol { `f32 :: () -> i64; } `` work as field / tag / method names.
> The exemption stops at member *definitions*: an `impl` method is a real
> function reached through the `impl_block` → `fn_decl` arm, so a
> reserved-spelled impl method needs the backtick (`` `i2 :: (self) ``), no
> more exempt than a free function (cf. `examples/1122`). Pinned by
> `examples/0158-types-reserved-name-member-exempt.sx`.
> 2. **`#import c` extern-name exemption.** `c_import.zig` synthesizes extern
> `extern` decls with `Param.is_raw = true` (and the synthesized `FnDecl`
> `is_raw = true`), so generated C names that collide with reserved type names
> (`i1`, `i2`) import unedited and a reserved-name extern fn is bare-callable.
>
> **Bare-callable extern / backtick fn.** `lowerCall` rewrites a `.type_expr`
> callee to an identifier when a function **of RAW provenance** of that name is in
> scope ([src/ir/lower.zig]) — scoped to the callee `FnDecl`'s `is_raw` flag, so it
> only ever fires for a backtick / `#import c` extern fn (the decl check guarantees
> no bare reserved-name fn exists). `i2(4)` resolves to the function (`TypeName(val)`
> is not a cast).
>
> **Regression tests.** `examples/0151-types-backtick-raw-identifier.sx` (every
> VALUE position), `examples/0152-types-backtick-control-flow.sx` (every
> control-flow / capture form), `examples/0153-types-backtick-const-fn-decl.sx`
> (backtick `::` const + fn decl, bare + backtick call),
> `examples/0154-types-backtick-raw-type-reference.sx` (raw in TYPE position —
> struct / enum / union / alias decl + reference; bare `i2` still the int),
> `examples/0155-types-backtick-typed-const-union-tag.sx` (typed const + union tag),
> `examples/0156-types-backtick-struct-const.sx` (struct-body const, untyped + typed),
> `examples/0157-types-backtick-parameterized-raw-type.sx` (raw parameterized type +
> pointer/field wrappers),
> `examples/0158-types-reserved-name-member-exempt.sx` (bare reserved-name struct
> fields / union tag / protocol method signature — read & written bare and via
> backtick; impl method definition takes the backtick),
> `examples/1054-errors-backtick-reserved-binding.sx` (`catch`/`onfail` tag
> bindings), `examples/1220-ffi-c-import-reserved-name-params.{sx,h,c}` (extern
> param + fn-name exemption, bare-callable extern fn); negatives
> `examples/1119`/`1121`/`1123` (bare reserved binding across forms),
> `examples/1140-diagnostics-reserved-name-const-fn-decl.sx` (bare const + fn decl),
> `examples/1141-diagnostics-reserved-name-type-decl.sx` (bare struct / enum / union
> / error / typed-const decl),
> `examples/1142-diagnostics-reserved-name-struct-const.sx` (bare struct-body const,
> caret on the name). Backtick lexer + `resolveNamed(skip_builtin)` unit tests in
> `src/lexer.zig` / `src/ir/type_resolver.test.zig`; the editor/LSP raw-type
> resolution (the second classifier) is pinned in `src/sema.test.zig` — the direct
> case plus raw provenance through every compound shape (`` *`i2 `` field access,
> `` ?`i2 `` unwrap, `` [N]`i2 `` index, parameterized `` `i2(i64) ``), each with a
> bare-spelling control that stays the builtin (fail-before verified).
>
> The original report is preserved below.
---
## Symptom
Importing non-sx source whose names collide with sx reserved type names is
rejected. `library/modules/stb_truetype.sx` is a `#import c { ... }` block over a
vendored C header (`vendors/stb_truetype/stb_truetype.h`); C identifiers `i1`,
`i2` (which collide with sx's signed-int type keywords `i1`..`sN`) produce:
```
error: 'i1' is a reserved type name and cannot be used as an identifier
error: 'i2' is a reserved type name and cannot be used as an identifier
```
The user cannot hand-edit these — they are generated from the vendored C header.
Separately, sx-authored code has NO way to deliberately use a reserved-name-spelled
identifier even when it wants to.
## Root cause
The parser classifies any reserved-type-name spelling (`i2`, `u8`, `f64`, …) as a
`.type_expr` via `name_class.Type.fromName`, never as an `.identifier`. The F0.1 /
issue-0076 fix added `UnknownTypeChecker.checkBindingName`
(`src/ir/semantic_diagnostics.zig`) to reject a value binding / param spelled as
a reserved type name (the `.type_expr`-vs-`.identifier` mismatch otherwise breaks
address-of / autoref lowering). F0.1 deliberately extended this check to imported
declarations — which is what now fires on the C-imported `i1`/`i2`.
## Desired behaviour (Agra ruling)
External / imported source does NOT need to conform to sx naming standards. Two
mechanisms:
1. **Auto-exempt imports.** `#import c` (and other extern) declarations are
treated as RAW identifiers: extern names are never type-classified and never
reserved-checked, so generated bindings "just work" with zero user edits.
2. **Backtick raw-identifier for sx code.** A leading backtick makes the following
identifier raw — an identifier that is NEVER type-classified, so it bypasses the
reserved-name rule:
```sx
`i2 := 2.5; // OK — identifier "i2", distinct from the i2 signed-int type
i2 := 2.5; // ERROR — bare i2 is still the reserved type name
```
Prefix form (single leading backtick on the identifier). The raw identifier's
TEXT is `i2` (the backtick is not part of the name). A bare `i2` used as a TYPE
remains the signed-int type.
## Reproduction
sx-side (minimal):
```sx
#import "modules/std.sx";
main :: () {
`i2 := 2.5; // must compile: identifier i2 = 2.5
print("{}\n", `i2); // 2.5
}
```
Import-side: a `#import c` block over a header declaring `int i1, i2;` (or
`stb_truetype.sx`) must NOT emit the reserved-type-name error.

View File

@@ -1,132 +0,0 @@
# 0090 — integer formatter can't render i64::MIN or unsigned all-ones
> STATUS: RESOLVED (F0.8). Both extremes now render correctly:
> `i64.min` → `-9223372036854775808`, `u64.max` → `18446744073709551615`.
>
> **Root cause.**
> - Symptom 1 (i64::MIN): `std.int_to_string` computed the magnitude as
> `0 - n`, which overflows for `i64::MIN` (its magnitude is
> unrepresentable as a positive i64) — the value stayed negative, the
> `while v > 0` loop ran zero times, and only the `-` was emitted.
> - Symptom 2 (unsigned all-ones): `any_to_string`'s `case int:` arm
> formatted every integer as i64 (`int_to_string(xx val)`); there was no
> way to tell a `u64` from an `i64`, so an all-ones u64 printed as `-1`.
>
> **Fix per file.**
> - `library/modules/std.sx` — `int_to_string` now extracts digits straight
> from `n` (taking `|n % 10|` per digit, `n` truncates toward zero) so it
> never negates `i64::MIN`. Added `uint_to_string` (unsigned decimal via
> long-division-by-10 over four 16-bit limbs) and `decompose_u16x4` (the
> shared 16-bit-limb split, now reused by `int_to_hex_string` too).
> `any_to_string`'s `case int:` routes through the new
> `type_is_unsigned(type)` query to pick the unsigned vs signed formatter.
> Declared `type_is_unsigned :: ($T: Type) -> bool #builtin;`.
> - `src/ir/types.zig` — `TypeTable.isUnsignedInt` (canonical signedness
> predicate; single source of truth).
> - `src/ir/inst.zig` — `type_is_unsigned` BuiltinId.
> - `src/ir/calls.zig` — register `type_is_unsigned` as a `.bool` reflection
> builtin.
> - `src/ir/lower.zig` — `tryLowerReflectionCall` arm: static fold +
> dynamic `callBuiltin`.
> - `src/ir/interp.zig` — interp arm (reads the boxed TypeId / `type_of`
> aggregate shape).
> - `src/ir/emit_llvm.zig` + `src/backend/llvm/reflection.zig` +
> `src/backend/llvm/ops.zig` — lazy `[N x i1]` `__sx_type_is_unsigned`
> table built from `isUnsignedInt`; runtime arm GEPs in at the TypeId.
>
> **Regression test.** `examples/0046-basic-int-formatter-extremes.sx`
> pins both extremes plus a width spread (i8/i16/i32 + u8/u16/u32/u64,
> mins/maxes, 0, ordinary values). Unit tests: `isUnsignedInt` in
> `src/ir/types.test.zig`.
>
> **Follow-up (F0.8 attempt 2) — strict `$T: Type` on all 7 reflection
> builtins.** The stress-review of the additive `type_is_unsigned` builtin
> found it (and the whole reflection family) silently accepted a non-type
> argument: `type_is_unsigned(6)` reinterpreted `6` as a TypeId index and
> returned the signedness of `types[6]` (`u8` → true); `size_of(6)`/`(true)`
> sized its `typeof` (8); `type_name(6)` returned `types[6]`'s name.
> Per Agra's ruling, all 7 type-introspection builtins — `size_of`,
> `align_of`, `field_count`, `type_name`, `type_eq`, `type_is_unsigned`,
> `is_flags` — now STRICTLY require a type (compile-time): a value argument
> is rejected with `"<builtin> expects a type, got '<type>'"`.
> - `src/ir/lower.zig` — one shared guard, `reflectionTypeArgGuard` (run at
> the top of `tryLowerReflectionCall`), classifies each arg via
> `reflectionArgIsType`: a spelled / compile-time type or generic type
> param (the `isStaticTypeArg` shapes), or a runtime `Type` value (static
> type `.any` — `type_of(x)`, a `[]Type` element `list[i]`, a `Type`-typed
> local / field / param) is ACCEPTED; anything else is rejected. The
> existing runtime path for `type_name` / `type_is_unsigned` is preserved
> (the formatter calls `type_is_unsigned(type_of(val))` at runtime). The 5
> comptime-only builtins stay comptime-only (runtime reflection deferred).
> - Negative regression: `examples/1144-diagnostics-reflection-builtin-needs-type.sx`
> (reject cases across all 7, exit 1). Unit test: `reflectionArgIsType` in
> `src/ir/lower.test.zig`.
>
> **Follow-up (F0.8 attempt 3) — reflection builtins on an `Any` consult the
> Any's runtime TYPE-TAG, not its payload.** The attempt-2 guard correctly
> accepts an `Any` argument (the formatter passes `val: Any`), but the dynamic
> `type_name` / `type_is_unsigned` path still read the Any's payload as a
> TypeId index unconditionally — correct only when the Any holds a *Type
> value*. For an Any holding a *value* (`av : Any = 6`, runtime tag `i64`,
> payload `6`) it reported `types[6]` (`u8`): `type_name(av)` → `"u8"`,
> `type_is_unsigned(av)` → `true`. Per Agra's ruling ("Any is a type AND a
> value, so it's expected to work"), both builtins now branch on the Any's
> runtime tag: tag `== .any` → the box is a Type value, use the payload as the
> TypeId; otherwise the tag IS the held value's type. So `type_name(av)` →
> `"i64"`, `type_is_unsigned(av)` → `false`, while `type_name(type_of(x))`
> still names the held type. The formatter is unchanged (it already passed
> `type_of(val)`, a proper Type value).
> - `src/ir/interp.zig` — shared `Value.reflectTypeId` (the tag-branching
> resolver); the `type_name` / `type_is_unsigned` interp arms route through
> it. `src/backend/llvm/ops.zig` — shared `Ops.reflectArgTypeId` emits
> `extractvalue tag` / `icmp eq tag, .any` / `select` for the runtime path;
> both reflection arms route through it. The two backends agree.
> - Regression: `examples/0164-types-reflection-any-tag.sx` pins `type_name` /
> `type_is_unsigned` / `print` on an Any holding a value vs. a Type value.
> Unit test: `reflectTypeId` in `src/ir/interp.test.zig`.
> - Out of scope (kept comptime-only / deferred): the 5 comptime-only builtins
> (`size_of`/`align_of`/`field_count`/`is_flags`/`type_eq`). `type_eq` has no
> dynamic emit path (it folds at lower time), so it is unaffected.
> STATUS (original): OPEN. Pre-existing + orthogonal; surfaced (not introduced) by NL.1.
> Manager-verified independent of the numeric-limit accessors. Scheduled separately.
## Symptom
`print("{}", x)` mis-renders the integer extremes the i64-based formatter can't
represent:
- `i64::MIN` (`-9223372036854775808`) prints a bare `-` (the minus sign with NO
digits).
- An unsigned all-ones value (e.g. `u64.max` = 18446744073709551615) prints `-1`
(the i64 bit-reinterpretation), not the unsigned decimal.
## Reproduction (no numeric-limit accessor needed — pre-existing)
```sx
#import "modules/std.sx";
main :: () {
x := -9223372036854775807 - 1; // i64::MIN
print("min={}\n", x); // prints "min=-" (should be -9223372036854775808)
}
```
`u64.max` (via the NL.1 accessor, or any all-ones u64) prints `-1` for the same
root reason.
## Root cause (suspected)
The integer-to-string path is `i64`-based (`std.int_to_string` / the `{}` formatter
takes `i64`): it negates the value to print the sign, but `-i64::MIN` overflows, and
it has no unsigned-aware path so an all-ones u64 is read as `-1`. Needs a width/
signedness-aware integer formatter (format by the value's actual integer TYPE:
unsigned types print the unsigned decimal; signed `MIN` is handled without negating).
## Investigation prompt
Make the `{}` integer formatter type-aware: render an unsigned integer as its
unsigned decimal (all 64 bits for u64), and handle signed `MIN` without the
`-MIN` overflow (e.g. format the magnitude via unsigned arithmetic, or special-case
MIN). Verify: `i64::MIN` prints `-9223372036854775808`; `u64.max` prints
`18446744073709551615`; existing numeric output (incl. the NL.1 examples, which
assert via bit-reinterpret) stays green. Likely area: the formatter / `int_to_string`
in the std print path and/or the comptime `{}` lowering.

View File

@@ -1,79 +0,0 @@
# 0091 — float `!=` lowers to ORDERED not-equal, so `nan != nan` is false in native code
> **RESOLVED** (F0.9). Root cause: `emitCmpNe` in `src/backend/llvm/ops.zig`
> passed `c.LLVMRealONE` (ordered not-equal) as the float predicate. Fix:
> `c.LLVMRealONE` → `c.LLVMRealUNE` (unordered not-equal). The integer predicate
> `LLVMIntNE` and `emitCmpEq` (`OEQ`) are unchanged. For all non-NaN operands
> `UNE` ≡ `ONE`, so only NaN-involving float `!=` changes (toward correct).
> Regression test: `examples/0150-types-float-ne-unordered-nan.sx`. Spec note
> added to `specs.md` (Operators → "Float comparison and NaN").
## Symptom
The LLVM backend lowers float `!=` to `LLVMRealONE` (ordered not-equal), which
returns **false** when either operand is NaN. Consequences:
- Observed: `nan != nan` evaluates to **false** (via `sx run`).
- Expected: **true**`!=` must be the logical complement of `==`, and the
canonical NaN-detection idiom `x != x` must be true for a NaN.
This makes `==` and `!=` non-complementary for NaN: `nan == nan` is false
(correct, `OEQ`) AND `nan != nan` is also false (wrong, `ONE`). It silently
breaks the standard NaN check used throughout numerical code
(`if x != x { /* NaN */ }`): NaN is never detected at runtime.
## Reproduction (accessor-free)
NaN is produced as `0.0 / 0.0` — no numeric-limit accessor required:
```sx
#import "modules/std.sx";
main :: () {
z := 0.0;
n := z / z; // NaN
print("ne={} eq={}\n", n != n, n == n); // observed: ne=false eq=false
} // correct: ne=true eq=false
```
`./zig-out/bin/sx run <repro>.sx` printed `ne=false eq=false` before the fix.
After the fix it prints `ne=true eq=false`. Non-NaN comparisons are unchanged
(`1.0 != 2.0` true, `1.0 != 1.0` false). The `#run`/comptime path (JIT-compiled
through the same backend) and the native runtime path agree in both states.
## Root cause
`src/backend/llvm/ops.zig`, `emitCmpNe`:
```zig
pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void {
self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealONE);
// ^^^^^^^^^^^^^^^ ordered
}
```
`LLVMRealONE` = ordered not-equal (false if either operand is NaN). The IEEE/C
`!=` is `LLVMRealUNE` (unordered not-equal → true if either is NaN). For all
NON-NaN operands `UNE` and `ONE` are identical, so the fix changes behavior only
for the NaN case — bringing native codegen in line with `==` (`OEQ`) and with
the interpreter's `evalCmp` (`.ne => lf != rf`, which is unordered in Zig).
`emitCmpNe` is the sole float-`!=` lowering site (dispatched from
`src/ir/emit_llvm.zig` `cmp_ne``ops().emitCmpNe`). There is no second backend
path (no `fcmp one` appears in any `.ir` snapshot; `src/codegen.zig` has no
float-`!=` lowering).
## Fix
```zig
pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void {
self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealUNE);
}
```
## Regression test
`examples/0150-types-float-ne-unordered-nan.sx` asserts (runtime, exit 0):
`nan != nan` true, `nan == nan` false, `nan != 1.0` true, `nan == 1.0` false,
the finite cases (`1.0 != 2.0` true, `1.0 != 1.0` false, `2.0 != 2.0` false),
and that the `#run` comptime `nan != nan` matches the runtime one. It fails on
the pre-fix compiler (`nan != nan: false`) and passes after.

View File

@@ -1,77 +0,0 @@
> **RESOLVED** (NL.2 attempt 3). Root cause: the numeric-limit accessor
> intercept treated ANY receiver whose text matched a builtin numeric type
> name as a TYPE receiver, without first checking whether that identifier
> resolved to an in-scope VALUE binding. An F0.6 backtick raw identifier
> (`` `f64 := … ``) binds a local under the stripped name `f64`; field access
> on it (`` `f64.epsilon ``) parses as an `.identifier` receiver, which the
> intercept silently folded to the type's numeric limit — a silent-wrong-value
> bug.
>
> Fix (value-binding precedence for `.identifier` receivers; `.type_expr`
> receivers are unambiguous types and never shadowed):
> - `src/ir/lower.zig` — `lowerNumericLimit`: after confirming the receiver is
> a builtin numeric type name and the field is a limit accessor, return null
> (defer to ordinary field lowering) when `fa.object` is an `.identifier`
> that `Scope.lookup` resolves to a value binding.
> - `src/ir/expr_typer.zig` — numeric-limit inference arm: mirror the same
> guard so inferred types match lowering (avoids the issue-0083 two-resolver
> desync).
>
> Bare `f64.epsilon` / `i32.max` (no shadowing binding) still fold — the parser
> classifies a bare builtin name as a `.type_expr` (parser.zig:2743), so the
> bare receiver is never value-shadowed even in a scope where `` `f64 `` is
> bound. Float-only-on-int and non-numeric-receiver errors are unchanged.
>
> Regression: `examples/0161-types-numeric-limit-value-shadow.sx` (raw
> `` `f64 ``/`` `i32 ``/`` `u8 `` value reads coexisting with bare folds) +
> unit test in `src/ir/expr_typer.test.zig`. NL.1 (`examples/0148`) / NL.2
> (`examples/0159`, `examples/0160`) unregressed.
# 0092 — numeric-limit intercept hijacks raw reserved-spelled value receivers
## Symptom
Field access on a raw reserved-spelled value binding is interpreted as a builtin
type numeric-limit access instead of an ordinary value field access. Observed:
the repro prints `0.000000 2147483647` (`f64.epsilon` / `i32.max`). Expected:
it prints `12 78` from the `Box` fields.
## Reproduction
```sx
#import "modules/std.sx";
Box :: struct { epsilon: i64; max: i64; }
main :: () -> i32 {
`f64 := Box.{ epsilon = 12, max = 34 };
`i32 := Box.{ epsilon = 56, max = 78 };
print("{} {}\n", `f64.epsilon, `i32.max);
return 0;
}
```
## Investigation prompt
Investigate issue 0092: raw reserved-spelled value receivers are being captured
by the numeric-limit accessor intercept. In `src/ir/lower.zig`, start at
`Lowering.lowerFieldAccess` and `Lowering.lowerNumericLimit` (currently around
`lower.zig:4826` / `lower.zig:4923`). The intercept treats any
`.identifier`/`.type_expr` receiver whose text is a builtin type name as a type
receiver, without first checking whether the identifier resolves to a value
binding in the current lexical scope. Mirror the fix in `src/ir/expr_typer.zig`
around the numeric-limit inference arm so inferred types match lowering.
Likely fix: for `.identifier` receivers, prefer an in-scope value binding
(`Scope.lookup`) over the builtin-type numeric-limit intercept; keep the
intercept for actual type receivers (`.type_expr`, and bare reserved integer
names with no value binding). If raw provenance is available in the AST, using it
to disambiguate is also acceptable, but the observable rule must be that
`` `f64.epsilon `` reads the value field when `` `f64 `` is a value binding,
while bare `f64.epsilon` / `i32.max` still fold as numeric limits.
Verification: pin a regression test from the repro above. It should print
`12 78`. Also verify the existing numeric-limit examples still pass:
`examples/0148-types-int-numeric-limits.sx`,
`examples/0159-types-float-numeric-limits.sx`, and the negative diagnostics in
`examples/0160-types-float-numeric-limits-errors.sx`.

View File

@@ -1,84 +0,0 @@
> **RESOLVED** (NL.2 attempt 4). Root cause: the issue-0092 fix guarded the
> numeric-limit intercept against value shadowing using ONLY lexical
> `Scope.lookup`. But the ordinary identifier field-access path resolves a
> value through THREE sources (`expr_typer.zig` `.identifier` arm): lexical
> `scope` → program `global_names` → module value constants
> `module_const_map`. A backtick raw identifier bound at MODULE scope
> (`` `f64 := Box.{…} ``, a global, or `` `f64 :: Box.{…} ``, a module const)
> is registered in `global_names` / `module_const_map`, NOT in `Scope`, so the
> scope-only guard missed it and the intercept still folded `` `f64.epsilon ``
> to the numeric limit — the same silent-wrong-value bug as 0092, one source
> deeper. The module-const variant has the same root cause and is covered by
> the same fix (no separate issue).
>
> Fix (close ALL THREE value-binding sources in one pass): a single shared
> helper `Lowering.identifierBindsValue(name)` returns true when `name`
> resolves through `scope.lookup` OR `program_index.global_names` OR
> `program_index.module_const_map`. Used in BOTH resolvers so they cannot
> desync (issue-0083 two-resolver class):
> - `src/ir/lower.zig` — `lowerNumericLimit`: defer to ordinary field lowering
> (return null) when an `.identifier` receiver `identifierBindsValue`.
> - `src/ir/expr_typer.zig` — numeric-limit inference arm: the `shadowed`
> check now calls the same helper.
>
> A bare `f64.epsilon` / `i32.max` (a `.type_expr` receiver, never an
> `.identifier`) still folds, even when a global or module-const raw value of
> the same spelling exists — the bare receiver is never value-shadowed.
> Float-only-on-int and non-numeric-receiver errors are unchanged.
>
> Regression: `examples/0161-types-numeric-limit-value-shadow.sx` now exercises
> all three binding kinds — a GLOBAL `` `f32 ``, a MODULE-CONST `` `i16 ``, and
> LOCAL `` `f64 ``/`` `i32 ``/`` `u8 `` — each reading its field while the bare
> spelling still folds. Unit test `src/ir/expr_typer.test.zig` pins the global
> + module-const sources. NL.1 (`examples/0148`) / NL.2 (`examples/0159`,
> `examples/0160`) unregressed.
# 0093 — numeric-limit intercept hijacks global raw reserved-spelled value receivers
## Symptom
Field access on a **global** raw reserved-spelled value binding is interpreted as
a builtin type numeric-limit access instead of an ordinary value field access.
Observed: the repro prints `0.000000 2147483647` (`f64.epsilon` /
`i32.max`). Expected: it prints `12 78` from the `Box` fields.
## Reproduction
```sx
#import "modules/std.sx";
Box :: struct { epsilon: i64; max: i64; }
`f64 := Box.{ epsilon = 12, max = 34 };
`i32 := Box.{ epsilon = 56, max = 78 };
main :: () -> i32 {
print("{} {}\n", `f64.epsilon, `i32.max);
return 0;
}
```
## Investigation prompt
Investigate issue 0093: the issue-0092 value-binding precedence fix covers
lexical locals but misses global raw value bindings. In `src/ir/lower.zig`, start
at `Lowering.lowerNumericLimit` and the new issue-0092 guard around
`Scope.lookup`. That guard returns `null` for a shadowing local, but global raw
bindings are registered in `ProgramIndex.global_names` (and module constants in
`ProgramIndex.module_const_map`), not in `Scope`, so an `.identifier` receiver
whose text is `f64` / `i32` still folds to a numeric limit before ordinary
global field lowering can read the value. Mirror the same rule in
`src/ir/expr_typer.zig` so inferred types match lowering.
Likely fix: for `.identifier` numeric-limit receivers, prefer any in-scope value
binding source over the builtin-type fold: lexical `Scope.lookup`, global values
(`program_index.global_names`), and module value constants where applicable.
Keep `.type_expr` receivers folding as type receivers, so bare `f64.epsilon` and
`i32.max` still fold even when a raw global value of the same spelling exists.
Verification: pin the repro above as a regression. It should print `12 78`.
Also verify the existing numeric-limit examples still pass:
`examples/0148-types-int-numeric-limits.sx`,
`examples/0159-types-float-numeric-limits.sx`,
`examples/0160-types-float-numeric-limits-errors.sx`, and
`examples/0161-types-numeric-limit-value-shadow.sx`.

View File

@@ -1,86 +0,0 @@
# 0094 — assigning to a missing struct field panics with "unresolved type reached LLVM emission"
> **RESOLVED** (F0.10). **Root cause:** the lvalue field lookup never diagnosed a
> missing field. In `Lowering.lowerAssignment`'s `.field_access` target path
> (`src/ir/lower.zig`), `field_ty` started as `.unresolved`; when no struct field
> matched, the code still built `ptrTo(field_ty)` / `structGepTyped` and stored —
> so a pointer-to-`.unresolved` reached LLVM emission and tripped the
> `src/backend/llvm/types.zig` tripwire. The nested lvalue-pointer path
> (`Lowering.lowerExprAsPtr`'s `.field_access` fallback) had the sibling defect:
> on a miss it returned `structGepTyped(obj_ptr, 0, .i64, obj_ty)` — a silent
> field-0/`.i64` default.
>
> **Fix (`src/ir/lower.zig`):** all three lvalue field-store sites — single
> assignment, address-of, and multi-target assignment — route field resolution
> through one shared helper, `fieldLvaluePtr(obj_ptr, obj_ty, field)`, which
> resolves struct fields, union/tagged-union direct fields, promoted
> anonymous-struct union members, tuple elements, and vector lanes (reusing
> `vectorLaneIndex`), and returns `null` (no field 0 / `.unresolved` /`.i64`
> default) when nothing matches. Each caller emits the read path's
> field-not-found diagnostic (`emitFieldError`) on a `null` result:
> 1. `lowerAssignment` `.field_access` target — delegates to `fieldLvaluePtr`;
> its own duplicated union / promoted / tuple / vector / struct walk is
> deleted (issue-0083 two-resolver divergence removed).
> 2. `lowerExprAsPtr` `.field_access` — delegates to `fieldLvaluePtr`, so the
> address-of path resolves promoted union members (`@v.x`) — not only direct
> union fields — and a genuine miss errors. The `.i64` sentinel is gone.
> 3. `lowerMultiAssign` `.field_access` target — replaced its struct-only loop
> (which defaulted `field_idx 0` / `field_ty .unresolved` on a miss, silently
> storing into field 0 — `p.q, y = 2, 3` printed `x=2 y=3`) with the shared
> `fieldLvaluePtr`; a missing field now errors, and a valid promoted-union /
> tuple member at a non-zero offset stores into its own slot, not field 0.
>
> `fieldLvaluePtr` types every GEP `*field_ty` (a pointer to the field), the
> convention the single-assign path always used: `emitStore` reads the
> store-target pointer's IR type and unwraps exactly one `.pointer` level to
> find the stored value's type. The earlier `lowerExprAsPtr` / `lowerMultiAssign`
> walks typed the GEP with the *bare* field value type, so a field whose own
> type is a pointer-to-aggregate (`*Pair`, a two-pointer struct) made `emitStore`
> unwrap to the aggregate and `coerceArg`'s closure auto-promotion store a
> 16-byte `{ptr,null}` struct over the 8-byte slot — clobbering the neighbouring
> field. Consolidating all three sites onto the one `*field_ty` resolver
> preserves single-assign and fixes that pre-existing multi-assign / address-of
> clobber.
>
> All sites reuse `emitFieldError` (the exact facility the read path
> `lowerFieldAccessOnType` uses), so the read and write paths reject identically.
> The `src/backend/llvm/types.zig` tripwire is untouched — the fix is to never
> produce `.unresolved` for a missing-field store.
>
> **Regression tests:** `examples/1145-diagnostics-missing-struct-field-assign.sx`
> (negative — single-assign, address-of, AND multi-assign missing-field all error,
> exit 1), `examples/0165-types-nested-struct-field-assign.sx` (positive — nested
> struct field write + address-of a matched field), `examples/0166-types-union-promoted-member-lvalue.sx`
> (positive — promoted union member written and address-of'd, including a non-zero
> offset member), `examples/0167-types-ptr-to-aggregate-field-store.sx` (positive —
> a `*Pair` field stored via all three lvalue sites leaves the neighbour intact),
> and three lowering unit tests in `src/ir/lower.test.zig` (single- and
> multi-assign missing-field field-not-found, plus the `*field_ty` GEP convention).
## Symptom
Assigning to a nonexistent struct field (`p.q = ...`) panics during LLVM emission instead of reporting a source diagnostic.
Observed: the compiler reaches the `.unresolved` LLVM tripwire in `src/backend/llvm/types.zig:175` via `emitStore`.
Expected: a normal compile error like `field 'q' not found on type 'Point'`, matching the read-field diagnostic path.
## Reproduction
```sx
Point :: struct { x: i64; }
main :: () {
p := Point.{ x = 1 };
p.q = 2;
}
```
Running `./zig-out/bin/sx run repro.sx` currently panics with:
```text
panic: unresolved type reached LLVM emission — a type resolution failure was not diagnosed/aborted
```
## Investigation prompt
Fix issue 0094 in the sx compiler: assigning to a missing struct field (`p.q = 2`) panics with `.unresolved` reaching LLVM emission instead of emitting a field-not-found diagnostic.
Suspected area: `src/ir/lower.zig`, especially `Lowering.lowerAssignment`'s `.field_access` target path around the struct-field lookup (`field_ty` starts as `.unresolved`, no matched field diagnoses, then `ptrTo(field_ty)` is stored) and the related `Lowering.lowerExprAsPtr` field-access fallback that returns `structGepTyped(obj_ptr, 0, .i64, obj_ty)` on lookup failure. The fix should make failed lvalue field lookup loud, reusing `emitFieldError(obj_ty, field, span)` or equivalent, and should not use `.i64`, `.void`, or any real type as a sentinel.
Verification: run the repro and expect exit 1 with a source diagnostic `field 'q' not found on type 'Point'`; no LLVM panic. Then run `zig build`, `zig build test`, and `bash tests/run_examples.sh`.

View File

@@ -1,230 +0,0 @@
# 0095 — typed local/decl silently truncates a float initializer to an integer annotation
> **RESOLVED (F0.11).** Agra ruled the UNIFIED rule (Option B): an implicit
> float→int in a typed binding behaves exactly like the array-dimension rule —
> an **integral** float FOLDS to its integer (`4.0` → 4, `-2.0` → -2), a
> **non-integral** float is a COMPILE ERROR (`1.5`, `4.5`), and an explicit
> `xx` / `cast(T)` ALWAYS truncates (the escape). Applied consistently across
> typed local / param-default / field-default, typed module CONST, and array
> dim — all reusing the single `program_index.floatToIntExact` /
> `evalConstIntExpr` facility (no second integral check).
>
> Fix (`src/ir/lower.zig`, `src/ir/module.zig`, `src/ir/program_index.zig`):
> - `Builder.constFloatInfo` reads a compile-time `const_float` back from its
> Ref (value + span).
> - `coerceToType` now means IMPLICIT coercion: its `.float_to_int` arm folds an
> integral const-float to `constInt`, else emits the narrowing diagnostic.
> `coerceExplicit` is the raw truncating path; `xx` (`lowerXX`) and
> `cast(T)` route through it so the escape still truncates.
> - Field-default lowering (struct-literal pad, named-field default,
> `buildDefaultValue`) now coerces the default to the field type at the IR
> level (was silently bit-coerced by `emitStructInit`).
> - Const path: `typedConstInitFits` accepts an integral float (literal or a
> `M + 2.0`-style expression that folds via `evalComptimeInt`); `emitModuleConst`
> / `constExprValue` / `globalInitValue` fold an integral float to its int and
> reject a non-integral one.
>
> **Completion (F0.11 attempt 2)** — the direct-`const_float` coerce arm only
> caught a float LITERAL; a non-integral const-folded float EXPRESSION
> (`local/field/param : i64 = M + 0.5`) still truncated silently. Closed by:
> - New `program_index.evalConstFloatExpr` — the f64 counterpart to
> `evalConstIntExpr`, delegating every integer subtree back to it (no parallel
> integer logic), adding only the float literal / negate / `+ - * /` arms.
> - `Lowering.foldComptimeFloatInit` routes the typed LOCAL, struct FIELD
> default, and call ARGUMENT (incl. an expanded param default) through
> `evalConstFloatExpr` + `floatToIntExact`: an integral comptime float folds,
> a non-integral one errors, a genuine runtime float / `xx` cast is left to the
> normal path. (Run pure `evalConstFloatExpr` FIRST so a `$pack[i]` arg isn't
> spuriously type-resolved out of binding.)
> - One `Lowering.diagNonIntegralNarrow` now emits the narrowing wording at all
> five sites (coerce arm, global init, const-expr value, the typed-binding
> sites, and the typed-const path), so the typed-CONST non-integral diagnostic
> reads `cannot implicitly narrow non-integral float …` instead of the stale
> `initializer is a float literal / floating-point expression`.
>
> **Completion (F0.11 attempt 3)** — attempt 2 resolved INT-const-expr leaves
> (`M + 0.5`, `M :: 2`), but a non-integral result via a FLOAT-const leaf
> (`F : f64 : 2.5; y : i64 = F + 0.25` = 2.75) still truncated silently:
> `evalConstFloatExpr` delegated only integer leaves to `evalConstIntExpr` and had
> no float-const leaf arm. Closed by completing the evaluator:
> - `program_index.moduleConstFloat` — the f64 twin of `moduleConstInt` (same
> `isCountableConstType` gate, same cyclic-definition frame), recovering a
> numeric module const's value through `evalConstFloatExpr`. A new
> `lookupFloatName` ctx method (on `Lowering` and `ModuleConstCtx`) surfaces a
> NON-INTEGRAL float const leaf; `evalConstFloatExpr` gained `.identifier` /
> `.type_expr` arms that call it. Integer / integral-float leaves keep resolving
> through the existing `evalConstIntExpr` delegation, so the unified rule now
> applies to ANY compile-time-constant float expression — literal, int-const
> leaf, float-const leaf, and combinations — at every binding site.
> - `typedConstInitFits` now judges integral-fold via `evalConstFloatExpr` +
> `floatToIntExact` (the SAME facility `foldComptimeFloatInit` uses) instead of
> the int-only `evalComptimeInt`, which folded leaf-by-leaf in `i64` and so
> rejected an integral SUM built from a non-integral float leaf
> (`K : i64 : F + 1.5` = 4.0). Integral float-const-leaf consts now FOLD;
> non-integral ones still error with the unified wording.
> - Out of scope (consistent with the int evaluator): a LOCAL `::` const leaf is
> resolved as a scope ref, not through the const tables, so neither
> `evalConstIntExpr` nor `evalConstFloatExpr` folds it — a local `M : i64 : 2`
> in `M + 0.5` and a local `F : f64 : 2.5` in `F + 0.25` both still truncate
> identically. Float now matches int exactly at that boundary.
>
> **Completion (F0.11 attempt 4)** — attempts 13 unified the four binding sites
> (local / field / param / const) for compile-time float exprs, but the ARRAY-
> DIMENSION / count path still diverged: it folded a DIRECT integral float literal
> (`[4.0]`, `[N]` with `N : f64 : 4.0`) yet rejected an INTEGRAL expression built
> from a non-integral float-const leaf (`[F + 1.5]` = 4.0, or `[K]` with
> `K : i64 : F + 1.5`) as "must be a compile-time integer constant" — because the
> dim fold used the int-only `evalConstIntExpr`, never the float-aware path. Closed
> by routing the count fold through the SAME facility the other four sites use:
> - New `program_index.foldCountI64` — the single int-or-integral-float count fold:
> `evalConstIntExpr` first, then (only on failure) `evalConstFloatExpr` +
> `floatToIntExact`. `foldDimU32` (array dim / Vector lane / u32 value-param) and
> the non-`u32` value-param gate both delegate to it, so no count site disagrees
> on which floats fold (the issue-0083 unify-or-diverge rule extended to floats).
> - A new `DimU32.non_integral_float` variant carries a non-integral float dim to a
> distinct, accurate diagnostic ("array dimension must be an integer, but '2.75'
> is a non-integral float") rather than the generic "must be a compile-time
> integer constant" — the cast-escape advice the binding sites give does not apply
> in a dimension position, so the dim wording omits it. `reportDimError`, the
> Vector-lane resolver, and the top-level array-alias diagnostic all handle the
> new variant, so the DIRECT (`a : [F + 0.25]i64`) and type-ALIAS
> (`Arr :: [F + 0.25]i64`) forms emit the identical message.
> - `type_bridge.StatelessInner.lookupFloatName` (routed through `moduleConstFloat`)
> is the float twin of its `lookupDimName`, so the registration-time alias path
> folds a float-const-leaf dimension to the SAME count as the stateful direct
> path. This relaxes the F0.4 `examples/1132` wording (a non-integral float const
> dim now reports the precise "non-integral float" message; it still errors).
>
> **Completion (F0.11 attempt 5)** — attempts 14 unified all five sites for
> literal / int-const-expr / float-const-leaf forms, but `evalConstFloatExpr` still
> LAGGED `evalConstIntExpr`: the int evaluator resolves a numeric-limit field-access
> leaf (`f64.true_min`, `f64.max`) via `type_resolver.integerLimitFor`, but the
> float evaluator had no parallel arm, so `y : i64 = f64.true_min + 0.5` (= 0.5)
> truncated silently to 0 (the direct `f64.true_min` already errored via the IR-level
> `constFloatInfo` path, but the *expression* form escaped). Closed by bringing the
> two evaluators to PARITY:
> - `evalConstFloatExpr` gains a `.field_access` arm that resolves a builtin FLOAT
> numeric-limit accessor through `type_resolver.TypeResolver.floatLimitFor` (the
> SAME facility `lowerNumericLimit` uses) — the float twin of the int evaluator's
> `integerLimitFor` arm. Integer limits / `<pack>.len` are still resolved by the
> int delegation, so only the float-limit case lands here.
> - The audit also surfaced a missing `%` arm: the int evaluator folds `.mod` but
> the float one did not, so `y : i64 = 5.5 % 2.0` (= 1.5) truncated silently to 1.
> `evalConstFloatExpr` now handles `.mod` via `@rem` (matching `evalConstIntExpr`
> and codegen's `frem`; `6.0 % 4.0` folds to 2 via the int delegation, `5.5 % 2.0`
> = 1.5 is rejected). The two evaluators are now at full leaf/operator parity, so
> no compile-time-const float shape escapes the rule at one site while folding at
> another. (A comptime-fn returning float is a genuinely new form for BOTH and is
> out of scope.)
>
> Regression tests: `examples/0168-types-integral-float-to-int.sx` (positive —
> local/field/param/const fold, integral int-const-EXPRESSION (`M + 2.0`) AND
> float-const-LEAF (`F + 1.5`, `F : f64 : 2.5`) fold at local/field/param/const,
> `xx`/`cast` truncate incl. `xx (M + 0.5)` / `xx (F + 0.25)`),
> `examples/1146-diagnostics-nonintegral-float-to-int.sx` (negative —
> non-integral LITERAL, int-const-EXPRESSION (`M + 0.5`), AND float-const-LEAF
> (`F + 0.25`) error at local/param/field), the integral-float const cases in
> `examples/0162-types-typed-module-const-roundtrip.sx`, and the aligned const
> diagnostic in `examples/1143-diagnostics-typed-module-const-mismatch.sx`
> (G / BAD / BAD2 stay errors with the new wording). The array-dimension site is
> pinned in the same two examples: 0168 adds `[F + 1.5]i64`, `[KF]i64`
> (`KF : i64 : F + 1.5`), and a type-alias `ArrFE :: [F + 1.5]i64` all folding to
> len 4; 1146 adds `[F + 0.25]i64` erroring; `examples/1132` now expects the
> precise non-integral-float dim wording. Unit:
> `program_index.test.zig` "evalConstFloatExpr folds comptime float expressions"
> (covers the float-const leaf: `F` → 2.5, `F + 0.25` → 2.75, `F + 1.5` → 4.0;
> attempt 5 adds the numeric-limit leaf `f64.max`/`f64.true_min`/`f32.epsilon`,
> `f64.max - f64.max` → 0, `f64.true_min + 0.5` → 0.5, and the `%` arm `5.5 % 2.0`
> → 1.5 / `% 0.0` → null) and "foldCountI64 / foldDimU32 fold an integral float
> count, reject a non-integral one" (the count fold + the `non_integral_float` /
> `below_min` distinction). Attempt 5 also extends 0168 (positive: `f64.max -
> f64.max` → 0, `6.0 % 4.0` → 2, integer-limit `i8.max`/`[u8.max]` unregressed,
> `xx` escapes for both new forms) and 1146 (negative: `f64.true_min + 0.5` and
> `5.5 % 2.0` error at a binding site).
>
> **Completion (F0.11 attempt 6)** — attempt 5 reached evaluator parity for
> leaves/operators, but a structural hole remained in the SHARED integer folder:
> `evalConstIntExpr` accepts an integral float literal/const as an integer leaf
> (`[4.0]` → 4) and then applies INTEGER arithmetic to the whole expression — so a
> float DIVISION with integral-looking operands (`5.0 / 2.0`) folded as integer
> truncating division (`divTrunc(5,2)` = 2) instead of float division (`2.5`). The
> bug fired at ALL FIVE sites (`5.0 / 2.0` printed `2` at a typed local, field
> default, param default, typed const, and array dimension), because the typed
> sites evaluate through `evalConstFloatExpr` (which delegates the whole node to
> the int folder) and the count sites through `foldCountI64` (which tries the int
> folder first). Closed at the single root: `evalConstIntExpr`'s `.div` arm now
> REFUSES to fold a division whose lhs/rhs is float-valued (a new
> `isFloatValuedExpr` predicate, resolving a float-typed const leaf through each
> ctx's `nameIsFloatTyped`) — so the division surfaces through `evalConstFloatExpr`
> (float `/`) + the unified rule: an integral quotient (`6.0 / 2.0` → 3) folds, a
> non-integral one (`5.0 / 2.0` = 2.5, mixed `5 / 2.0`, float-const `F / G`)
> errors. Genuine integer `/` (`5 / 2` → 2) is unchanged; `*`/`+`/`-` need no guard
> (they agree between int and float for the integral operands the int folder ever
> sees). Regression: `examples/1147-diagnostics-float-division-narrowing.sx`
> (negative — `5.0 / 2.0` errors at all five sites), the integral-`/` positives
> added to `examples/0168` (`6.0 / 2.0` local/field, `12.0 / 4.0` const, `[6.0 /
> 2.0]` dim, `xx (5.0 / 2.0)` → 2), and unit
> `program_index.test.zig` "the int folder refuses a FLOAT division".
>
> **Completion (F0.11 attempt 7)** — one structural hole survived in the
> field-access arm of the SHARED const evaluators: a backtick raw value-shadow
> receiver (`` `f64 := FBox.{ epsilon = … } `` then `` `f64.epsilon ``) was
> misclassified as the builtin numeric-limit accessor. The sibling
> `isFloatValuedExpr` already guards this with an `is_raw` check, but
> `evalConstFloatExpr` / `evalConstIntExpr` did NOT — so once the read flowed into
> an integer binding, the float folder returned the BUILTIN `f64.epsilon`
> (2.22e-16) and the rule wrongly errored ("narrow non-integral float
> '0.0000…0002220446049250313'"), and the integer folder turned `` `i8.max `` as an
> array dimension into the builtin `127` (a fabricated 127-element array) instead
> of an ordinary runtime field read. Closed at the single root: both evaluators'
> field-access arms now mirror `isFloatValuedExpr`'s `is_raw` guard — a raw
> receiver yields `obj_name = null`, so it is never a numeric-limit/pack leaf and
> falls through to the ordinary runtime field read. A raw value-shadow is a
> mutable-local field (a subsequent `` `f64.epsilon = 4.0 `` is observable), so it
> is genuinely runtime and must not be const-folded: it now behaves EXACTLY like a
> plainly-named field read — `` `f64.epsilon `` narrowing into `i64` truncates to
> its field value (`11.5` → `11`, identical to `b.epsilon`, NOT a non-integral
> error on the builtin limit), and `` `i8.max `` as an array dimension is rejected
> as a non-constant count (identical to `b.max`). The bare builtin path is
> unchanged (`f64.epsilon`, `i8.max`, `[u8.max]` still fold). Regression:
> `examples/0169-types-value-shadow-field-narrowing.sx` (positive — raw float-field
> read narrows/truncates, mutation proves runtime, bare limit still folds),
> `examples/1148-diagnostics-value-shadow-field-dim-not-const.sx` (negative — raw
> int-field dim rejected as non-const), and unit `program_index.test.zig` "a
> backtick raw-shadow receiver is a field read, not a numeric-limit fold (F0.11-7)".
## Symptom
A typed LOCAL (and likely typed param/field) silently truncates a floating-point
initializer to an integer annotation instead of rejecting or requiring an explicit cast.
Observed:
- `y : i64 = 1.5;` → y == 1 (float literal truncated, no diagnostic)
- `y : i64 = 2 + 0.5;` → y == 2 (float-valued expr truncated, no diagnostic)
Expected: a type-mismatch / narrowing diagnostic (consistent with typed MODULE CONSTS,
which after F0.7 reject `N : i64 : 1.5` and `N : i64 : M + 0.5`). Today consts are strict
but locals are lenient — an inconsistency.
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
y : i64 = 1.5;
print("{}\n", y); // prints 1
}
```
## Investigation prompt
Decide + implement the language rule for implicit float→int narrowing in a TYPED binding
(local / param / field) initializer. Module consts already reject it (F0.7,
registerTypedModuleConst + typedConstInitFits/constExprInitFits). Make typed-local/param/field
assignment-coercion consistent: either reject a non-integral float→int initializer with a
diagnostic (matching the const path) or require an explicit `xx`/cast. Suspected area: the
assignment / typed-binding coercion path (coerceToType ladder, specs.md §"coercion") in
src/ir/lower.zig. Verify `y : i64 = 1.5` errors (or requires a cast); confirm integral-float
folding rules (specs.md: `4.0`→4 ok, `4.5` rejected) stay consistent. Then gate.
## Disposition
Discovered during F0.7 (issue 0088) attempt-2 review. Agra ruled F0.7 fixes the
inferExprType ROOT for binary-op promotion; this typed-LOCAL narrowing is a SEPARATE
assignment-coercion concern -> its own scheduled step.

View File

@@ -1,79 +0,0 @@
# issue 0096 — `#run`/comptime print of an `Any` holding a `Type` silently stops
> **RESOLVED** (F0.12).
> **Root cause:** `any_to_string` runs `type := type_of(val)`; for an `.any`
> operand `type_of` lowers to `struct_get(val, 0)` (read the Any's tag field).
> At runtime a first-class `Type` value is the aggregate `{ tag=.any, value=tid }`,
> so the read succeeds. The comptime interpreter stores a `Type` as a bare
> `.type_tag(tid)` Value, and the comptime `struct_get` arm had no case for
> `.type_tag` — it fell through to `typeErrorDetail("…base has no fields…")` and
> raised `CannotEvalComptime`. That error was then swallowed silently:
> `runComptimeSideEffects` ran `interp.call(...) catch Value.void_val`, so the
> `#run` truncated mid-execution yet the build still exited 0.
> **Fix:** (1) `src/ir/interp.zig` — the comptime `struct_get` arm now handles a
> `.type_tag(tid)` base by mirroring the runtime Any-Type layout: field 0 →
> `.int(TypeId.any.index())` (the `.any` tag), field 1 → `.type_tag(tid)`. So
> `type_of` of an Any-held Type evaluates the same as runtime and execution
> continues. (2) `src/ir/emit_llvm.zig` — `runComptimeSideEffects` no longer
> swallows a side-effect bail into `void_val`; it prints a loud diagnostic and
> sets `comptime_failed` (→ `error.ComptimeError`, non-zero exit), matching the
> const-init path. A truncated `#run` can no longer ship a successful build.
> **Regression test:** `examples/0613-comptime-print-any-type.sx` (all five
> lines print, exit 0). Verified fail-before / pass-after.
## Symptom
During `#run`/comptime execution, `print("{}", at)` where `at : Any` holds a
`Type` value **silently halts** the comptime interpreter: the formatted value
and every following statement are omitted, yet **the build still succeeds
(exit 0)**. At runtime the same `Any`-held `Type` prints fine (`u64`). A
successful build with truncated `#run` execution is the dangerous part — a
silent stop, the exact class of failure the project's REJECTED-PATTERNS rule
forbids.
## Reproduction (only imports `modules/std.sx`)
```sx
#import "modules/std.sx";
ct_probe :: () {
print("before\n");
x : u64 = 1;
t : Type = type_of(x);
at : Any = t;
print("name={}\n", type_name(at));
print("unsigned={}\n", type_is_unsigned(at));
print("value={}\n", at);
print("after\n");
}
#run ct_probe();
main :: () {}
```
**Observed pre-fix** (comptime stops after `unsigned=true`, build still exit 0):
```text
before
name=u64
unsigned=true
--- build done ---
```
**Expected / post-fix** (same as runtime, execution continues):
```text
before
name=u64
unsigned=true
value=u64
after
--- build done ---
```
## Bisect (ground-truth)
Pre-existing: the minimal repro stops on `dist-foundation` too (and pre-F0.8),
so it is NOT introduced by F0.8's Any-tag fix and is orthogonal to issue 0090.
A standing comptime-interpreter limitation, scheduled and fixed as F0.12.

View File

@@ -1,120 +0,0 @@
# 0097 — value-failable returning an ENUM corrupts the error slot on the success path
**RESOLVED.** Root cause was **not** the field-offset/width miscalculation originally
hypothesized — `tuple_init` / `tuple_get` and the backend struct layout were correct. The real
cause was upstream in `lowerReturn` (`src/ir/lower.zig`): when lowering the returned expression of
a value-carrying failable `-> (T..., !)`, `target_type` was set to the **full failable tuple**
`(Color, !E)` instead of the success **value** type `Color`. A bare enum literal `.red` resolves
its variant tag against `target_type` (`lowerEnumLiteral``resolveVariantValue`); against a tuple
type there is no matching variant, so it returned the silent `0` default AND stamped the result
with the tuple type. `lowerFailableSuccessReturn` then saw `val_ty == ret_ty` and took the
**forwarding** branch, returning the half-built aggregate `{ value, undef }` as-is — the appended
`constInt(0, err_ty)` was never inserted, leaving the error slot `undef` (read back as garbage
nonzero) on the success path.
**Fix:** in `lowerReturn`, choose the `target_type` for the returned expression via
`failableReturnTarget(ret_ty, value_node)`: for a value-carrying failable a **bare** returned
value resolves against `failableSuccessType(ret_ty)` (the value type / value-tuple) so an enum
literal gets its real ordinal and the success-return path appends the `0` error slot; an
**explicit full failable tuple** literal (`return (v..., e)`, arity == full-tuple field count)
keeps the full-tuple target so its trailing error element resolves against the error set and is
forwarded as-is. The i32 case was already correct because integer literals don't resolve variants
against `target_type`.
Two follow-up defects from the first cut of this fix were corrected (attempt-2 review):
- **F1 — explicit full tuple return panicked.** Narrowing the target to the value type for *all*
value-failables broke `return (.blue, error.Nope)`: the trailing error element no longer
resolved against the error set, leaving an `.unresolved` tuple field that tripped the
"unresolved type reached LLVM emission" panic in `src/backend/llvm/types.zig`. The
arity-aware `failableReturnTarget` keeps the full-tuple target for the explicit form, so it
lowers and forwards as before.
- **F2 — comptime-param inline return still corrupted.** A `-> (Enum, !E)` body with a comptime
parameter is inlined (`lowerComptimeCall`), so its success `return .red` took the
inline-return path (`if (self.inline_return_target)`), which the first cut skipped — it stored
`{value, undef}` (error slot `undef`) into the inline slot. That path now applies the same
target narrowing and routes a value-carrying failable through `lowerFailableSuccessReturn`
(whose `emitTupleRet` stores `{value, 0}` into the inline slot + branches), so the success
error slot is `0` there too.
**Regression:** `examples/1055-errors-enum-value-failable-error-slot.sx` (bare-enum success slot)
and `examples/1056-errors-enum-value-failable-tuple-and-comptime.sx` (F1 explicit-tuple error +
bare-value success in one fn; F2 comptime-param enum value-failable read at runtime on the success
path — `cast`, bare `if`, `== error.X`, plus the error path). Both read the slot at runtime so an
`undef` is caught, not masked by the `if !e` proof. Fail on pre-fix code, pass after. Verified
`zig build`, `zig build test`, and `bash tests/run_examples.sh` (453 ok) all green.
Below preserved as a record of the original problem.
## Symptom
A value-failable function `-> (EnumType, !ErrSet)` writes a **garbage nonzero tag into the error
slot on the SUCCESS path**. Per specs.md the error channel must be `0` on success ("0 in the
error slot means no error"). Every **runtime read** of the slot on success (`cast(i64) err`, bare
`if err`, `err == error.X`, and therefore `error_tag_name(err)`) reports a false error. Only the
path-sensitive compile-time proof `if !err` reads correctly (it is tied to the SSA value, not a
runtime load of the slot), which is why it masks the bug.
- **Observed (enum value):** success path → error slot reads nonzero (garbage `undef`), not `0`.
- **Expected:** success path → error slot reads `0`; `if err` is false; `err == error.X` is false.
## Reproduction (only imports `modules/std.sx`)
```sx
#import "modules/std.sx";
Color :: enum { red; green; blue; }
E :: error { Nope }
pick :: (s: string) -> (Color, !E) {
if s == "red" { return .red; } // SUCCESS path
raise error.Nope;
}
main :: () -> i32 {
c, e := pick("red"); // SUCCESS -> error slot MUST be 0
print("error e (int) = {}\n", cast(i64) e); // EXPECT 0 ; BUG prints 1
if e { print("bare-if e: ERROR (WRONG)\n"); } else { print("bare-if e: ok\n"); }
if e == error.Nope { print("e == Nope (WRONG)\n"); } else { print("e != Nope (ok)\n"); }
if !e { print("guard !e: value c (int) = {}\n", cast(i64) c); } // c = 0 = .red (CORRECT)
return 0;
}
```
**Actual (buggy):**
```text
error e (int) = 1
bare-if e: ERROR (WRONG)
e == Nope (WRONG)
guard !e: value c (int) = 0
```
**Expected (now produced):**
```text
error e (int) = 0
bare-if e: ok
e != Nope (ok)
guard !e: value c (int) = 0
```
## Contrast — the IDENTICAL shape with an i32 value is CORRECT
```sx
pick :: (n: i32) -> (i32, !E) { if n > 0 { return n; } raise error.Nope; }
// v, e := pick(5); → error slot = 0 (correct); bare-if e: ok
```
The split is **enum-value-specific** because only an enum literal (`return .variant`) resolves its
tag against `target_type`. An integer literal does not, so the i32 path never got mis-stamped with
the failable-tuple type and never took the false forwarding branch.
## Root cause (confirmed at ground truth)
`return .red` in `pick` lowered the enum literal with `target_type = (Color, !E)` (the whole
failable tuple). The LLVM IR on the success path was:
```llvm
ret { i64, i32 } { i64 0, i32 undef } ; error slot UNDEF, not 0 (.blue gave i64 0 too — value lost)
```
vs. the i32 case which already produced `ret { i32, i32 } { i32 7, i32 0 }`. After narrowing the
return target to the value type, the enum success path produces `ret { i64, i32 } zeroinitializer`
(value 0 = `.red`, error slot 0), and `.blue` correctly carries ordinal 2.

View File

@@ -1,78 +0,0 @@
# RESOLVED — 0098: enum literal in a non-enum target silently lowers to variant 0
> **RESOLVED** (2026-06-12). Root cause: `lowerEnumLiteral`
> (src/ir/lower/expr.zig) resolved the variant against the RAW
> destination type. For any non-enum destination —
> an OPTIONAL `?E`, a builtin like `i64`, or no destination at all —
> `resolveVariantValue` fell through its switch to the silent
> `return 0` tail (the classic silent-fallback-default this repo's
> CLAUDE.md forbids), and the `enum_init` was stamped with the WRONG
> type (the optional itself / `.unresolved`). Fix: the literal now
> unwraps optional destinations and resolves against the CHILD (the
> coercion layer's `.optional_wrap` then wraps the well-typed `E`
> into `?E`), and every other shape is DIAGNOSED instead of zeroed:
> unknown variant of a real enum (with the variant list), non-enum
> destination, and destination-less literal (cascade-guarded so a
> destination whose type already failed to resolve doesn't double-
> report; pre-fix this case PANICKED LLVM emission with an
> unresolved type). Regression tests:
> `examples/0183-types-enum-literal-optional-target.sx` (return +
> assignment + reassignment into `?Enum`, non-zero variants, null
> path) and `examples/1169/1170-diagnostics-enum-literal-*.sx`
> (each refusal); all three FAIL on pre-fix master. Gates:
> `zig build test` 426/426, `tests/run_examples.sh` 598/598.
## Symptom
An enum LITERAL whose destination is not literally the enum type silently
lowers to variant 0 (or worse), with no diagnostic.
- **Observed**: `return .android_apk;` from a `-> ?Platform` function is
seen by the caller as `.ios` (variant 0) or even `null`, depending on
the optional's layout. `x : i64 = .foo;` compiles and `x == 0`.
`x : Platform = .nonexistent;` compiles to variant 0. `v := .ios;`
panics LLVM emission ("unresolved type reached LLVM emission").
- **Expected**: the optional case works (resolve against the child, wrap);
every unresolvable case is a compile error.
Hit in production: /Users/agra/projects/distribution
`src/server/distd.sx` `ua_platform` (2026-06-12) — every User-Agent
"detected" as iOS because each `return .<variant>;` into `?Platform`
lowered to 0. The shipped workaround routed through a typed local
(`p : Platform = .android_apk; return p;`).
## Reproduction
```sx
#import "modules/std.sx";
Platform :: enum u8 { ios; android_apk; macos; linux; windows; }
classify :: (n: i64) -> ?Platform {
if n == 1 { return .android_apk; } // BUG: caller observes variant 0 / null
return null;
}
main :: () -> i32 {
p := classify(1);
if p == null { return 1; }
if p! == .android_apk { return 0; }
return 2;
}
```
Observed at master d8076b9: exits 1 (null). Expected: exits 0.
## Investigation prompt
Suspected area: `src/ir/lower/expr.zig` `lowerEnumLiteral` /
`resolveVariantValue`. The literal resolves against
`self.target_type` verbatim; an optional target isn't unwrapped, so
`resolveVariantValue`'s `switch` misses and returns 0, and the
`enum_init` carries the optional TypeId itself. Fix: unwrap optional
layers to the child enum before resolving (then the existing
`.optional_wrap` coercion handles `E``?E`), and emit diagnostics
for unknown variants / non-enum destinations / no destination instead
of the silent-zero tail. Verification: the repro exits 0; the
diagnostics cases each error; `zig build test` and
`tests/run_examples.sh` green; pin the repro as examples.

View File

@@ -1,100 +0,0 @@
# 0099 — LSP analyzer panics on an identifier array dimension (`[MAX]u8`)
**RESOLVED.** The editor analyzer (`src/sema.zig`) read the array-dimension node's
`.int_literal` union field unconditionally. When the dimension is a **named const**
(`[MAX]u8`), the `.length` node is an `identifier`, so the access tripped Zig's
checked-union panic and `sx lsp` aborted (`SIGABRT`) the moment the file was opened
(didOpen → `analyzeDocument`). The main compiler (`sx run`) was never affected — it
resolves `[MAX]u8` through the IR's `foldArrayDim` machinery.
## Symptom
- **Observed:** opening a buffer with a named-const dimension in an editor crashes the
language server: `src/sema.zig` aborts with
`access of union field 'int_literal' while field 'identifier' is active`.
- **Expected:** the analyzer resolves `[MAX]u8` to length 4 (or, when it can't fold the
dimension, records an explicit "unknown" length) and never crashes.
## Reproduction
```sx
MAX :: 4;
Thing :: struct { buf: [MAX]u8; }
```
`sx run` compiles this fine; `sx lsp` aborts on didOpen. (The crash is reached via
`registerTopLevelDecl``fieldType``resolveTypeNode` while building the editor
index — not a diagnostic pass.)
## Root cause
`Analyzer.resolveTypeNode` in `src/sema.zig`, the `array_type_expr` arm:
```zig
const length: u32 = @intCast(ate.length.data.int_literal.value);
```
`ate.length` is an arbitrary expression node. For a literal `[64]u8` it is
`int_literal`; for `[MAX]u8` it is `identifier`. The code assumed `int_literal`
unconditionally — a textbook unchecked-union-field access. `@intCast` of the
non-existent payload never even runs; the union-field read panics first.
## Fix
- New helper `Analyzer.arrayDimLength(len_node)` switches on the dimension node's tag:
`int_literal` → its value; `identifier` → the value recorded for that name in a new
`const_int_values` registry (populated when a top-level `const_decl` has an
`int_literal` value); anything else (unknown name, non-const expression) → `null`. A
value outside `u32` range also yields `null`. No node shape is ever assumed without a
tag check, so the path cannot panic.
- `Type.ArrayTypeInfo.length` changed from `u32` to `?u32`. `null` is the explicit
"editor couldn't fold this dimension" marker — never a fabricated concrete length
(this is hover/metadata, not codegen). `displayName` renders an unknown length as
`[_]T`.
- `src/sema.zig` `substType` passes the (now optional) length through unchanged; the
only other consumer of the editor `ArrayTypeInfo` is `displayName`, updated above.
The named-const value is captured at registration time, so resolution shares the
analyzer's existing intra-pass forward-reference limitation: a const declared *after* the
struct that uses it resolves to the safe "unknown" length rather than its value. This
matches how the analyzer already treats a struct field that references a later-declared
struct type, and never crashes.
## Sibling audit (`resolveTypeNode` / `fieldType` family)
Audited every arm for the same unchecked `.data.<field>` pattern. The array dimension
was the **only** crashing site:
- `slice_type_expr`, `optional_type_expr`, `pointer_type_expr`,
`many_pointer_type_expr`, `function_type_expr` — all recurse through
`resolveTypeNode`, which dispatches on the node tag; no raw field access.
- `parameterized_type_expr` returns `.void_type`; `type_expr` / `identifier` read
`.name` / `.is_raw` only *after* `tn.data == .type_expr or tn.data == .identifier`.
- `fieldType`, `typeExprName`, `typeExprIsRaw`, `resolveTypeAnnotation`,
`resolveTypeRef` all tag-check (switch capture or explicit `==`) before any payload
read; `resolveTypeAnnotation`'s `array_type_expr` case delegates to `resolveTypeNode`,
so it is fixed transitively.
**Non-crashing display gap noted (not fixed here — out of scope for A):**
`src/lsp/server.zig` (~line 2801) renders a struct's array field in hover/completion
detail directly off the AST. It already guards `if (ate.length.data == .int_literal)`,
so it does **not** crash, but it renders a named-const dimension as an empty `[]u8`
instead of `[4]u8`. Tracked for the broad LSP `.data` sweep (next step B), not addressed
in this crash fix.
## Regression
First `src/lsp/*.test.zig` — establishes the minimal LSP test harness
(`src/lsp/document.test.zig`), wired into the `zig build test` graph via
`src/root.zig`'s `lsp.document_tests` reference. Tests drive the real didOpen path
(`DocumentStore.analyzeDocument`) and inspect the editor index:
- `[MAX]u8` (named const) → no panic; folded length is `4`, element `u8`.
- `[64]u8` (int literal) → length `64` (happy-path guard).
- `[N]u8` (undeclared name) → explicit unknown length (`null`), no panic, no fabricated
value.
Verified fail-before / pass-after: with the pre-fix unconditional `.int_literal`
dereference restored, the identifier test aborts with the exact original panic; with the
tag-switching fix it passes. Full gate green: `zig build`, `zig build test` (incl. the
new LSP test), `bash tests/run_examples.sh` (453 passed, 0 failed).

View File

@@ -1,178 +0,0 @@
# 0100 — cross-module same-name function lowering collision
**RESOLVED.** Two modules each exporting a top-level function with the same
short name (`std.cli.parse`, 3 params; `std.json.parse`, 2 params) collided
in IR lowering's bare-name function table. `fn_ast_map` (short name → AST)
was **last-wins**, while `module.functions` / `resolveFuncByName` are
**first-wins**, so importing both modules and calling one bound the AST of
one function against the FuncId of the other and tripped
`lazyLowerFunction`'s param-count assert (`src/ir/lower.zig:1606`,
`func.params.len == fd.params.len + ctx_slots`) — `panic: reached
unreachable code`. Qualified imports (`j :: #import`) did not help: lowering
keyed everything by short name, so `j.parse` and a bare `parse` resolved to
the same colliding entry.
**Fix** (`src/ir/lower.zig`, `src/ast.zig`, `src/imports.zig`):
1. **Module-qualified identity.** A namespaced import's OWN plain functions
are now registered under their qualified name (`ns.fn`) in `fn_ast_map`,
giving `cli.parse` / `json.parse` independent identities. The qualified
resolution paths in `CallResolver.plan` and `lowerCall` already prefer
`ns.fn` — they just had nothing to find. `NamespaceDecl` carries the
module's `own_decls` (populated in `imports.addNamespace`) so the
registration covers authored decls, not transitive flat imports. Generic
/ comptime / pack / extern functions are excluded — they dispatch by
monomorphization off the bare template name, not the plain
`resolveFuncByName` path, so a qualified alias would strand their
per-call type bindings. The qualified function is declared + lowered on
demand by `lazyLowerFunction`'s null-FuncId path (no eager `declareFunction`,
which would resolve types before the forward-alias fixpoint).
2. **First-wins bare registration.** `scanDecls` no longer lets a later
namespace recursion clobber an existing bare `fn_ast_map` entry, aligning
it with `mergeFlat` / `resolveFuncByName`. A bare `parse` with one module
flat-imported now consistently resolves to the first (unqualified-scope)
function instead of splitting AST/FuncId across modules.
Regression: `examples/0719-modules-cli-and-json.sx` imports BOTH `std.cli`
and `std.json` under distinct namespaces and calls both `cli.parse`
(dispatch) and `json.parse` (document read), asserting correct results.
Panics on pre-fix code; passes after.
## F1 follow-up — qualified alias must lower in its own source context
The identity fix above registered `ns.fn` in `fn_ast_map` WITHOUT an eager
`declareFunction`, so the qualified alias is lowered through
`lazyLowerFunction`'s **null-FuncId** `lowerFunction` path — which had no
`Function.source_file` to restore (the non-null path does
`setCurrentSourceFile(func.source_file)`). The alias therefore lowered in the
**caller's** visibility context, and a qualified function calling a helper
from **its own module's flat import** was rejected:
```
m :: #import "m.sx"; // m.sx: `#import "helper.sx"; foo :: () { helper() }`
main :: () -> i32 { print("{}\n", m.foo()); 0 } // → 'helper' is not visible
```
**Fix** (`src/ir/program_index.zig`, `src/ir/lower.zig`):
- New `ProgramIndex.qualified_fn_source` (qualified name → declaring source
file), populated in `registerQualifiedFn` from the decl's own source.
- `lazyLowerFunction`'s null-FuncId branch restores that source via
`setCurrentSourceFile` before calling `lowerFunction`, so `ns.fn`'s body
lowers in its own module's context and its own-import callees resolve.
- `lowerFunction` now records `Function.source_file = current_source_file`
on the freshly-begun function (matching `declareFunction`), so the lowered
alias carries its own module for diagnostics/emit.
Regression: `examples/0720-modules-qualified-own-import.sx``calc.compute`
(a qualified alias) calls `triple` / `base` from calc.sx's own flat import.
Reports `'triple' is not visible` on the attempt-1 code; passes after. 0719's
cross-module dual-`parse` assertion stays green.
## F2 follow-up — null-FuncId path must restore the FULL caller lowering state
The F1 fix patched the **source file** in `lazyLowerFunction`'s null-FuncId
branch, but that branch still restored only a SUBSET of the caller state the
non-null branch restores — it omitted `self.block_terminated`. A qualified
alias whose body terminates (e.g. a constant-folded `if true { return … }`)
leaves `block_terminated = true` after `lowerFunction`; the null branch then
returned without resetting it, so the flag leaked into the **caller's** body
lowering and the caller's own trailing statements / `return` were treated as
dead-after-terminator:
```
m :: #import "m.sx"; // m.sx: `#import "helper.sx"; foo :: () -> i64 { if true { return helper(); } return 0; }`
main :: () -> i32 {
x := m.foo();
print("after\n"); // dropped
return 0; // → error: body produces no value
}
```
**Fix** (`src/ir/lower.zig`): the three exit paths of `lazyLowerFunction` (the
null-FuncId branch, the already-promoted early return, and the bottom of the
non-null branch) duplicated the restore, and the null branch's copy drifted.
They are now collapsed into a **single `defer`** registered right after the
state is saved, so every exit path restores the identical full set and the
class can't diverge again. The fields the defer now restores on all paths:
- `current_source_file` (via `setCurrentSourceFile`, which also resyncs
`diagnostics.current_source_file`) — F1
- `scope`
- `func_defer_base`
- `block_terminated`**F2** (was missing on the null path)
- `force_block_value`
- `builder.func`
- `builder.current_block`
- `builder.inst_counter`
(The `current_runtime_class`, `jni_env_stack_base`, and pack-mono /
`inline_return_target` fields already had their own `defer`s and apply on all
paths; they are unchanged.)
Regression: `examples/0721-modules-qualified-terminating-callee.sx``m.foo`
(a qualified alias) folds `if true { return helper(); }` and is followed by
caller statements + the caller's own `return 0`. Reports `body produces no
value` on the attempt-2 code; prints `terminating-callee: ok` / `after` and
exits 0 after. 0719 and 0720 stay green.
## Symptom
- **Observed:** a program that imports two modules each exporting a
same-named top-level function AND calls one crashes IR lowering:
`panic: reached unreachable code` at `src/ir/lower.zig:1606`
(`lazyLowerFunction`) via `lowerCall`.
- **Expected:** each `pkg.fn(...)` resolves to its own module's function;
the program compiles and runs.
## Reproduction
```sx
#import "modules/std.sx";
cli :: #import "modules/std/cli.sx";
json :: #import "modules/std/json.sx";
main :: () -> i32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 8192);
defer arena.deinit();
cmds : []Command = .[ Command.{ group = "ci", command = "publish", flags = .[] } ];
argv : []string = .["ci", "publish"];
d : Diag = .{};
p, e := cli.parse(argv, cmds, @d); // 3-param cli.parse
if e { return 64; }
v, je := json.parse("[1,2,3]", xx arena); // 2-param json.parse
if je { return 65; }
return 0;
}
```
Pre-fix: `panic: reached unreachable code` at `src/ir/lower.zig:1606`.
Post-fix: compiles and runs (exit 0).
## Root cause
`fn_ast_map`, `module.functions` (matched by interned name), and
`lowered_functions` were all keyed by a function's SHORT name. Two functions
sharing a short name across modules occupied the same key; the `put`-order
mismatch (AST last-wins vs FuncId first-wins) drove `lazyLowerFunction` to
lower one signature against the other's body. The qualified-call resolution
machinery already existed but was never fed module-qualified entries.
## Fix verification
- `zig build` → 0
- `zig build test` → 0 (incl. LSP corpus sweep, 473 examples; 397/397 tests)
- `bash tests/run_examples.sh` → 456 passed, 0 failed
- `examples/0719-modules-cli-and-json.sx`: panics pre-fix, passes post-fix.
- `examples/0720-modules-qualified-own-import.sx`: `'… is not visible'` on
the attempt-1 code, passes after the F1 fix.
- `examples/0721-modules-qualified-terminating-callee.sx`: `body produces no
value` on the attempt-2 code, passes after the F2 fix.
Regression tests: `examples/0719-modules-cli-and-json.sx` (collision),
`examples/0720-modules-qualified-own-import.sx` (F1 own-import visibility),
`examples/0721-modules-qualified-terminating-callee.sx` (F2 terminating
qualified callee — caller state transparency).

View File

@@ -1,76 +0,0 @@
# 0101 — postfix `!` chained with `.field` miscompiles
**RESOLVED.** A postfix optional force-unwrap chained directly with a member
access — `opt!.field` — read garbage instead of the field, while the
bind-first form (`v := opt!; v.field`) was correct. Sibling chains shared the
bug: `opt!.method()` failed to resolve the method at all (`error: unresolved
'<method>'`), and `opt!.a.b` / `opt![i]` were affected the same way.
**Symptom** — observed vs expected:
```sx
#import "modules/std.sx";
S :: struct { id: string; n: i64; }
mk :: () -> ?S { return S.{ id = "hello", n = 42 }; }
main :: () {
print("chained: {}\n", mk()!.id); // observed: garbage (e.g. 8362783136)
v := mk()!; print("bind: {}\n", v.id); // observed: "hello" (correct)
}
```
Expected: `mk()!.id` prints `hello` (same as the bind-first form).
**Root cause** (`src/ir/expr_typer.zig`, `ExprTyper.inferType`): the AST-level
type-inference switch had **no `.force_unwrap` arm**, so `mk()!` typed as
`.unresolved` (the `else` fallback). `lowerForceUnwrap` lowers the unwrap to a
correctly-typed value, so the *bind* form works — `v := mk()!` stores that
typed Ref into a slot and `v.field` reads it back. But the *chained* form never
materializes a slot: `lowerFieldAccess` re-derives the receiver type via
`inferExprType(fa.object)` (= `inferExprType(mk()!)`), got `.unresolved`, and
the struct-field lookup on `.unresolved` failed — `mk()!.id` was typed
`.unresolved`/`i64` and its value emitted as `undef` (the print monomorphized
`pack_i64` with `i64 undef`, surfacing as a stale stack address). The method
chain failed for the same reason: receiver typing returned `.unresolved`, so
method resolution found nothing.
**Fix** (`src/ir/expr_typer.zig:92`): add a `.force_unwrap` arm to
`ExprTyper.inferType` that resolves the operand's optional child type (mirrors
`lowerForceUnwrap`'s `resolveOptionalInner`):
```zig
.force_unwrap => |fu| blk: {
const opt_ty = self.l.inferExprType(fu.operand);
if (!opt_ty.isBuiltin()) {
const info = self.l.module.types.get(opt_ty);
if (info == .optional) break :blk info.optional.child;
}
break :blk .unresolved;
},
```
This is the single root cause for every chained form — field, nested field,
method call, and index all route their receiver type through `inferExprType`.
One arm fixes all of them.
**Reproduction** (standalone, std-only):
```sx
#import "modules/std.sx";
Inner :: struct { tag: string; k: i64; }
S :: struct {
id: string; n: i64; inner: Inner;
greet :: (self: *S) -> string { return self.id; }
}
mk :: () -> ?S { return S.{ id = "hello", n = 42, inner = Inner.{ tag = "deep", k = 7 } }; }
main :: () {
print("{}\n", mk()!.id); // pre-fix: garbage; post-fix: hello
print("{}\n", mk()!.n); // pre-fix: garbage; post-fix: 42
print("{}\n", mk()!.greet()); // pre-fix: error unresolved 'greet'; post-fix: hello
print("{}\n", mk()!.inner.tag); // post-fix: deep
}
```
**Regression**: `examples/0905-optionals-unwrap-field-chain.sx` exercises
`opt!.field` (string + int field, chained vs bind-first), `opt!.method()`
(pointer + value receiver), nested `opt!.a.b`, and `opt![i]`. Garbage / compile
error on pre-fix code; all correct after.

View File

@@ -1,102 +0,0 @@
# 0102 — flat-import same-name function collision (per-source binding)
**RESOLVED.** Two **flat** imports (bare `#import "a.sx"` / a flat directory
import, NOT a namespaced `ns :: #import`) that each author a top-level free
function with the **same short name** collided in IR lowering. The flat/
directory merge keeps exactly **one** author per name in the merged decl list
(first-wins), and every bare-name consumer site — call dispatch, default-arg
expansion, function-value capture, free-function UFCS, comptime `#run` — read
that one **name-keyed** winner. So when module `b.sx` authored its own `greet`
but `a.sx` was imported first, `b.sx`'s own bare `greet()` silently bound
`a.sx`'s author. Unlike issue 0100 (which crashed on a param-count assert when
the AST/FuncId split across modules), this miscompiled **silently**: the wrong
same-name author ran, with no diagnostic.
The defect had two faces, both rooted in name-keyed identity across a flat
collision:
1. **Lowering** keyed function bodies by short name (`fn_ast_map` /
`resolveFuncByName` are first-wins), so a shadowed author never got its own
FuncId or body — there was nothing to bind even if a consumer wanted the
per-source author.
2. **Resolution** at every bare-name consumer site re-looked-up the winner by
name, so even once shadow authors had distinct FuncIds, the consumer sites
kept binding the first-wins winner.
## Fix — four sub-steps (`src/imports.zig`, `src/ir/lower.zig`)
- **0102a — retain dup authors + identity indexes.** The flat/directory merge
keeps first-wins in the merged scope (unchanged), but now *also* retains
every dropped same-name author in `program_index.module_fns`
(`path → name → *FnDecl`) plus a `flat_import_graph` (`file → flat-import
edges`). Resolution is untouched at this step — the indexes just make the
shadowed authors addressable.
- **0102b — identity-addressable function lowering.** `fn_decl_fids`
(`*const ast.FnDecl → FuncId`) lets a body be declared + lowered against a
**specific** `*FnDecl` (`lowerFunctionBodyInto` / `bareAuthorFuncId`) instead
of a name. A shadow author gets a fresh same-name FuncId in its own module's
visibility context; the winner keeps the name-keyed slot. `scanDecls` keys
`fn_decl_fids` by the stable `module_fns` `*FnDecl`.
- **0102c — THE resolver + call path + param typing.**
`resolveBareCallee(name, caller_file) -> .func(ResolvedAuthor) | .ambiguous |
.none` (`src/ir/lower.zig`). It returns `.none` whenever the outcome would
equal first-wins (single author, or own-author == winner), so every
single-author / local / parameter / std / qualified / extern / generic /
builtin name resolves byte-for-byte as before. Only a genuine flat collision
reroutes: own-author wins; else the caller's flat-reachable authors — `≥2`
distinct → `.ambiguous` (loud "qualify the call" diagnostic), exactly one
differing from the winner → bind it. Routed the **primary call path** and the
call's **parameter target typing** (so a `*T`-param shadow gets implicit
address-of, not a value bit-cast to a pointer → segfault).
- **0102d — the four remaining bare-name sites.** Routed the SAME resolver
through every other site that resolved a bare callee/function-name by
first-wins, each gated exactly as the call path (plain top-level identifier,
no scope-mangle / UFCS alias / local shadow; act on `.func` / `.ambiguous`,
fall through on `.none`):
1. **Default-argument expansion** (`expandCallDefaults`): omitted trailing
args fill from the RESOLVED author's defaults, not the winner's.
2. **Function-value conversion** (`closure(fn)` and the bare-fn-as-value
`func_ref` / fn-ptr / closure-coercion path): captures the resolved
author's FuncId. The winner's body is lazily lowered ONLY on the `.none`
fallback — a rerouted value never uses the winner, so taking a shadow as a
value must not pre-lower (and possibly mis-diagnose) the winner's body.
3. **Free-function UFCS** (`recv.fn()``fn(recv, …)`): dispatches the
resolved author for the receiver's source.
4. **Comptime `#run`** of a bare call: `lowerMainAndComptime` now sets
`current_source_file` per decl, so a `NAME :: #run f()` in an imported
module resolves `f` from THAT module's flat imports (own-author wins)
rather than the main file's perspective (where two flat authors made it
spuriously `.ambiguous` and failed the build).
## Regression tests
`examples/0722``0735` (each a focused multi-file flat-collision scene that
fails on pre-fix code and passes after):
- `0722-modules-flat-same-name-own` — own-author wins on the call path.
- `0723-modules-flat-vs-namespaced` — a flat author + a namespaced same-name
author don't collide.
- `0724-modules-flat-same-name-ambiguous``≥2` flat authors, bare call →
loud diagnostic.
- `0725-modules-flat-dir-same-name` — flat **directory** import collision.
- `0726-modules-flat-same-name-variadic` — per-source variadic packing.
- `0728-modules-flat-same-name-paramtype` — per-source parameter target typing
(value vs pointer param).
- `0729-modules-flat-same-name-extern` — same-name `extern` authors are NOT
rerouted (non-plain authors keep first-wins).
- `0730-modules-flat-same-name-default-arg` — per-source default-arg expansion.
- `0731-modules-flat-same-name-closure` — per-source `closure(fn)` + bare
fn-value capture.
- `0732-modules-flat-same-name-ufcs` — per-source free-function UFCS dispatch.
- `0733-modules-flat-same-name-comptime-run` — per-source comptime `#run`
callee.
- `0734-modules-flat-same-name-ufcs-ambiguous``≥2` flat authors, UFCS call
→ loud diagnostic (pre-fix: silently bound the winner).
- `0735-modules-flat-same-name-fn-value-winner` — the first-wins winner's body
is independently broken and never used; a shadow taken as a function value
binds the shadow and runs while the winner is NOT lowered (pre-fix: the
fn-value site eagerly lowered the winner before the resolver rerouted,
surfacing the winner's error for a function the value never touches).

View File

@@ -1,172 +0,0 @@
# 0106 — namespaced-import internal names are silently bare-visible (over-permissive `isNameVisible`)
> **RESOLVED** (flow stdlib/B attempt-3 — root fix, no exemption). Two coupled
> changes:
>
> 1. **Tightened bare visibility to the flat edge set.** `isNameVisible` /
> `isCImportVisible` now route through the unified `isVisible` predicate over
> `user_bare_flat` / `c_import_bare` (both join over `flat_import_graph`, not
> `import_graph`). A namespaced-only import's internal name is no longer
> bare-visible — face #1 now errors `'<name>' is not visible; #import the
> module that declares it`.
> 2. **Pin the defining-module context during pack/comptime monomorphization.**
> The flat tightening alone broke `std.print` / `log.*`: a library metaprogram's
> body (`#insert build_format(fmt)` comptime call + the `#insert "out(result);"`
> inserted statement) was lowered under the CALL SITE's `current_source_file`,
> so its bare names (`build_format`, `out`, `emit`) were policed against the
> consumer's imports. **Root cause:** `monomorphizePackFn` (bare `print` /
> `format`) and `lowerComptimeCall` (namespaced `std.print` / `log.*`, reached
> via the field-access `hasComptimeParams` branch) lower the metaprogram body
> without pinning the source context — unlike a normal function, which lowers
> via `lowerFunctionBodyInto` pinning `func.source_file`. **Fix:** both paths
> now save/set/restore `current_source_file` to the body's DEFINING module
> before lowering the body (the call-site ARGS are lowered first, in the
> caller's context, which is correct). The defining path is stamped onto each
> function body node by `resolveImports` (`stampFnBodySource`, mirroring how a
> declared function carries `Function.source_file`). So the metaprogram's bare
> `build_format` / `out` / `emit` resolve in `std.sx` / `log.sx` naturally —
> and a USER's `#insert <expr>` is still checked in the USER's context, so a
> bare reach into a namespaced-only import there errors. **No `#insert`
> exemption** (attempt-2's `in_insert_expansion` flag is deleted): the fix is
> the absence of an exemption, not a narrower one.
> 3. **Substituted caller `$`-args resolve in the CALLER's context** (attempt-5).
> The point-2 defining-module pin covers the metaprogram body's OWN code only.
> A caller-provided comptime `$`-arg (e.g. a caller-owned helper passed to an
> imported metaprogram) is spliced into the body by `substituteComptimeNodes`;
> those nodes are CALLER-authored and must resolve in the caller's visibility
> context, not the callee's. **Fix:** the `$`-arg node is stamped with the
> caller's `source_file` at the `cpn` build site (`lowerComptimeCall` /
> `monomorphizePackFn`, `stampCallerSource`), and `lowerExpr` switches
> `current_source_file` to a node's `source_file` when present — so the
> substituted subtree resolves against the caller while the surrounding callee
> code keeps the defining-module pin. Regression:
> `examples/0738-modules-comptime-arg-caller-context.sx` (caller-owned helper
> as a comptime-only `$`-arg through a namespaced import; fail-before
> "'caller_name' is not visible" → pass-after "hello world").
>
> Root cause: `isNameVisible` walked `import_graph` (flat AND namespaced edges)
> where a bare name should join only over `flat_import_graph`; and the pack /
> comptime monomorphizers lowered the metaprogram body under the wrong source
> context.
> Regressions: `examples/0736-modules-namespaced-only-bare-not-visible.sx` (+
> `0736-…/a.sx`) — face #1 pinned (exit 1 + the stderr);
> `examples/0737-modules-insert-bare-not-visible.sx` (+ `0737-…/a.sx`) — a USER
> `#insert secret()` into a namespaced-only import errors (fail-before exit 0 on
> the attempt-2 exemption / pass-after exit 1). Face #2 restored WITHOUT an
> exemption: `examples/0015 / 0700 / 0718 / 1030` pass again (`run_examples`
> 471 → 474, incl. the attempt-5 caller-context regression `0738`). Fix in `src/ir/lower.zig` (`monomorphizePackFn` +
> `lowerComptimeCall` source-context pin; exemption removed) + `src/imports.zig`
> (`stampFnBodySource`) + `src/ir/resolver.zig` (`VisibilityMode` modes, landed in
> attempt-1).
**Symptom.** A bare reference to a top-level name authored in a module that the
consumer imports **only namespaced** (`ns :: #import "m.sx"`) is silently
visible from the consumer. Observed: it compiles + runs. Expected: an error —
the name is reachable only as `ns.name`. Root: `Lowering.isNameVisible` walks
`program_index.import_graph`, which records BOTH flat (`#import`) and namespaced
(`ns :: #import`) edges; bare-name visibility should join only over FLAT edges
(`flat_import_graph`). This is the latent 0102-family visibility bug the Phase B
caller-mode audit (unified-resolver R5) was told to surface.
This **directly gates flow `stdlib/B`**: that step requires migrating
`isNameVisible`/`isCImportVisible` to the resolver's `user_bare_flat`/
`c_import_bare` modes (which walk `flat_import_graph`) **byte-identically**.
Switching the edge set drops `run_examples` from 471 → 467 (see face #2), so the
byte-identical requirement cannot hold until this bug is fixed.
## Reproduction — face #1 (user-facing over-permissiveness)
```sx
// m.sx
secret :: () -> i64 { 7 }
```
```sx
// main.sx
m :: #import "m.sx";
main :: () -> i32 {
x := secret(); // bare; `secret` is only namespaced-imported as `m.secret`
0
}
```
- **Observed** (current master): compiles, runs, exit `0` — bare `secret`
wrongly resolves to `m`'s author.
- **Expected**: `error: 'secret' is not visible; #import the module that
declares it` (it is reachable only as `m.secret`).
(With the Phase-B edge-set change applied — `isNameVisible` over
`flat_import_graph` — this repro correctly errors, confirming the diagnosis.)
## Reproduction — face #2 (library comptime entanglement: why a naive fix breaks std)
```sx
// main.sx
std :: #import "modules/std.sx";
main :: () -> i32 {
std.print("hello\n"); // legit qualified call
0
}
```
`print` in `std.sx` is a comptime metaprogram:
```sx
print :: ($fmt: string, ..$args) {
#insert build_format(fmt); // comptime call to a std-internal fn
#insert "out(result);"; // inserts a bare call to a std-internal fn
}
```
The comptime call to `build_format` and the inserted `out(result)` are bare
names **authored in std.sx**, but they are visibility-checked in the
**consumer's** `current_source_file` context (comptime / `#insert` expansion
happens at the call site). Today they pass only because `import_graph[consumer]`
contains the namespaced `std` edge. Tightening bare visibility to
`flat_import_graph` makes them error
(`'build_format' / 'out' is not visible`). The same shape breaks `log` (`emit`).
Affected examples when the edge set is switched to flat:
`0015-basic-demo`, `0700-modules-import`, `0718-modules-cli-exit-json`,
`1030-errors-log-and-comptime` (467/471).
## Root cause (suspected area)
- `Lowering.isNameVisible` / `isCImportVisible` — `src/ir/lower.zig` (~1768-1840
after the Phase-B refactor; `visibleOverEdges` / `nameVisibleOverEdges`). The
cross-module join uses `import_graph` (flat **and** namespaced edges) where it
should use `flat_import_graph` for a bare name.
- Comptime / `#insert` expansion context: the inserted/comptime-evaluated bare
calls of a namespaced module's function are policed against the **consumer's**
imports, not the **defining module's** own scope. The existing visibility
check already exempts UFCS-alias rewrites and mangled local names as "compiler
indirections" (`lower.zig` call site ~7284, identifier site ~3237); inserted /
comptime-generated bare calls are the same kind of indirection and are not
yet exempt — or, equivalently, the expansion should restore the defining
module's `current_source_file`.
## Investigation prompt (paste into a fresh session)
> Fix issue 0106: `isNameVisible` over-permits bare references to a
> namespaced-only import's internal names. Two coupled changes, in this order:
>
> 1. **Library-internal context.** Ensure a namespaced/comptime-expanded
> function's body — including `#insert`ed statements and comptime calls like
> `build_format` inside `std.print` — is visibility-checked in its **defining
> module's** context, OR exempt compiler-generated / `#insert`ed bare calls
> from the visibility check (mirror the UFCS-alias / mangled-name exemptions
> at `src/ir/lower.zig` ~7284 and ~3237). Verify with the face-#2 repro and
> examples `0015 / 0700 / 0718 / 1030`.
> 2. **Tighten bare visibility to flat.** Change `isNameVisible` (and the
> `c_import_bare` fall-through of `isCImportVisible`) to join over
> `flat_import_graph` instead of `import_graph` — i.e. route them through the
> resolver's `user_bare_flat` / `c_import_bare` modes (this is exactly Phase B
> of the unified-resolver R5 plan; `src/ir/resolver.zig` already defines the
> modes and `Lowering.visibleOverEdges` already takes a `.flat` / `.all`
> selector). Verify the face-#1 repro now errors.
>
> Acceptance: the face-#1 repro errors ("not visible"); `bash tests/run_examples.sh`
> is back to 471 `ok` with bare visibility on the flat edge set; add the face-#1
> repro as a pinned regression (`issues/0106-…` with an `expected/` marker, or
> promote to `examples/07xx-modules-…`). Suspected files: `src/ir/lower.zig`
> (visibility + comptime/#insert expansion context), `src/ir/resolver.zig`
> (`user_bare_flat` / `c_import_bare`).

View File

@@ -1,20 +0,0 @@
# RESOLVED — 0107: forward alias binds to flat-import same-name type during initial scanDecls
**Root cause:** `selectNominalLeaf` checked flat-import authors (`flatTypeAuthorCount`) before
checking whether the querying module's own `const_decl` for the same name was still pending.
When both a flat import AND a namespaced import exported `B :: u8`, and the own module had
`A :: B; B :: u64;` (B declared after A), the flat import's `u8` was returned as `.resolved`
before the own `B :: u64` was processed — binding A to `u8` and silently truncating values.
**Symptom:** `x : A = 300` printed `44` (u8 truncation of 300) when both imports had `B :: u8`.
The namespaced import alone was not sufficient; both flat AND namespaced were required.
**Fix:** Added `ownConstDeclIsPendingAlias(from, name)` check between the own-alias check and
the flat-import walk in `selectNominalLeaf`. If the querying module has an own `const_decl` for
`name` that is not yet in `type_aliases_by_source`, return `.pending` — deferring to the
forward-alias fixpoint instead of accepting a flat import's version.
**Regression test:** `examples/0830-modules-flat-ns-same-name-forward-alias.sx` (exit 0,
prints 300 — the u64 value, not the u8 truncation).
**Fix in:** `src/ir/lower.zig``selectNominalLeaf` + new `ownConstDeclIsPendingAlias`.

View File

@@ -1,107 +0,0 @@
# RESOLVED — 0108: `defer` silently skipped on `break` / `continue` loop exits
**Root cause:** `lowerBreak`/`lowerContinue` emitted a bare `br`; the enclosing
block's `emitBlockDefers` then saw the terminator and discarded the pending
entries on the assumption they were already emitted (true only for
return/raise).
**Fix:** `Lowering.loop_defer_base` records the defer-stack height at each
loop's body start (`lowerWhile` / `lowerFor` / `lowerRuntimeRangeFor`,
saved/restored like `break_target`); `lowerBreak`/`lowerContinue` drain
non-`onfail` entries down to it in LIFO order via the new, non-truncating
`emitLoopExitDefers` (`src/ir/lower/stmt.zig`) before branching — truncation
stays with the lexical block exits, since the same entries still belong to the
fall-through path. `break`/`continue` outside a loop now diagnose
(`` `break` outside a loop ``) instead of silently no-op'ing.
**Regression test:** `examples/0049-basic-defer-break-continue.sx` (`for`
break + continue, `while` break + continue, nested-block LIFO drain; the
breaking iteration's cleanups were missing pre-fix).
---
# 0108 — `defer` silently skipped on `break` / `continue` loop exits
**Symptom.** A `defer` registered inside a loop body does not run when the
iteration exits via `break` or `continue`. Observed: the cleanup for the
breaking/continuing iteration never executes. Expected (specs.md §6 Defer:
"`defer expr;` schedules `expr` to execute when the enclosing scope block
exits"): `break`/`continue` exit the loop-body scope, so all pending defers of
that iteration must fire before the jump. The normal fall-through end of an
iteration DOES run them — only the `break`/`continue` paths skip.
Resource impact: `for ... { f := open(...); defer close(f); if cond { break; } }`
leaks the handle on the break path. Same for `continue` (leaks once per
continued iteration). Affects `for` (collection, range) and `while` equally —
all share `lowerBreak`/`lowerContinue`.
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i32 {
for 0..3: (i) {
defer print("cleanup {}\n", i);
if i == 1 { break; }
print("body {}\n", i);
}
print("after break loop\n");
for 0..3: (i) {
defer print("c2 {}\n", i);
if i == 1 { continue; }
print("b2 {}\n", i);
}
print("done\n");
0
}
```
- **Observed** (current master):
`body 0 / cleanup 0 / after break loop / b2 0 / c2 0 / b2 2 / c2 2 / done`
— `cleanup 1` and `c2 1` are missing.
- **Expected**:
`body 0 / cleanup 0 / cleanup 1 / after break loop / b2 0 / c2 0 / c2 1 / b2 2 / c2 2 / done`
Repro co-located: `issues/0108-defer-skipped-on-break-continue.sx` (unpinned —
pin as the regression once fixed, with the expected output above).
## Root cause (suspected area)
`src/ir/lower/control_flow.zig` — `lowerBreak` / `lowerContinue` (~864-876)
emit a bare `self.builder.br(target)` without draining the defer stack.
Contrast `lowerReturn` (`src/ir/lower/stmt.zig` ~501), which calls
`self.emitBlockDefers(self.func_defer_base)` before `ret`. After the bare
`br`, the enclosing `lowerBlock`'s scope-exit `emitBlockDefers` sees
`currentBlockHasTerminator()` and **discards** the entries under the
assumption "cleanups were already emitted" (`stmt.zig` ~1016) — true for
return/raise, false for break/continue. So the cleanups are dropped, not
deferred-elsewhere.
## Investigation prompt (paste into a fresh session)
> Fix issue 0108: `defer` is skipped on `break`/`continue` exits.
>
> 1. Record the loop's defer base: in `lowerFor` / `lowerRuntimeRangeFor` /
> `lowerWhile` (`src/ir/lower/control_flow.zig`), alongside the existing
> save/restore of `break_target`/`continue_target`, save
> `self.defer_stack.items.len` into a new `Lowering` field (e.g.
> `loop_defer_base: usize`), restoring the old value after the body.
> 2. In `lowerBreak`/`lowerContinue`, before the `br`, emit pending non-onfail
> cleanups from `defer_stack.items.len` down to `loop_defer_base` in LIFO
> order **without truncating the stack** (mirror `emitErrorCleanup`'s
> non-truncating walk in `src/ir/lower/stmt.zig`, success-exit filtering
> like `emitBlockDefers`). Truncation must stay with the lexical
> `lowerBlock` scope exits — the same defer entries still belong to the
> fall-through lowering path after the `if { break; }` arm.
> 3. `inline for` (`lowerInlineRangeFor`) bodies lower through `lowerBlock`
> per unrolled iteration; check a `break` inside one targets the enclosing
> runtime loop with the same drain (and that `break` with no enclosing loop
> gets a diagnostic rather than the current silent no-op `Ref.none`).
>
> Verify: run the repro in `issues/0108-defer-skipped-on-break-continue.sx`,
> expect `cleanup 1` after `body 0`/`cleanup 0`, and `c2 1` between `c2 0` and
> `b2 2`. Add a `while`-loop break/continue + defer case. Then promote to
> `examples/00xx-basic-defer-break-continue.sx` per the resolution flow, and
> run `zig build && zig build test && bash tests/run_examples.sh` (all ok).

View File

@@ -1,136 +0,0 @@
# RESOLVED — 0109: allocas inside loop bodies accumulate stack per iteration
**Root cause:** `emitAlloca` (and ~18 sibling `LLVMBuildAlloca` temp sites in the
LLVM backend) built allocas at the builder's current position. An alloca inside a
loop body re-executes per iteration and LLVM reclaims allocas only at `ret`, so
the frame grew with the trip count — body locals, nested-loop index slots, and
spill temps (`ig.tmp` etc.) all segfaulted long loops on stack exhaustion.
**Fix:** new `LLVMEmitter.buildEntryAlloca` (src/ir/emit_llvm.zig) builds every
per-instruction alloca in the function's entry block (after existing entry
allocas, builder position restored); all `LLVMBuildAlloca` sites reachable
during instruction emission in src/backend/llvm/ops.zig, src/backend/llvm/abi.zig
and src/ir/emit_llvm.zig route through it. Initialization stores stay at the
use site, so per-iteration re-init semantics are unchanged; entry-block slots
are also mem2reg-promotable. ~35 `.ir` snapshots churned (pure alloca position
moves — verified type-multiset-identical per file).
**Regression test:** `examples/0047-basic-loop-local-stack-reuse.sx` (1M-iteration
body-local loop prints `sum=499999500000`; 3M-iteration nested loop prints
`n=3000000`; both segfaulted pre-fix).
---
# 0109 — allocas inside loop bodies accumulate stack per iteration → segfault on long loops
**Symptom.** Any `alloca` that lands inside a loop's body block executes anew
on every iteration, and LLVM stack allocas are only reclaimed at function
return — so the frame grows monotonically with the trip count. Observed: a
1M-iteration loop with a body-local array segfaults (stack overflow, fault
address at the guard page); so does a 3M-iteration nested loop with **no user
locals at all** (the inner loop's hidden index slot is itself a body-block
alloca of the outer loop). Expected: loop-local storage is reused across
iterations; stack usage is static per frame regardless of trip count.
This hits three shapes, all confirmed:
1. user locals declared in a loop body (`buf : [128]i64 = ---;`),
2. nested loops (inner `for`'s `idx_slot` alloca sits in the outer body),
3. compiler temporaries spilled in the body (e.g. `index_get`'s `ig.tmp`
see issue 0110 for the for-over-array case specifically).
## Reproduction
Repro A — body local (`issues/0109-loop-body-alloca-stack-growth.sx`):
```sx
#import "modules/std.sx";
main :: () -> i32 {
sum := 0;
for 0..1000000: (i) {
buf : [128]i64 = ---;
buf[0] = i;
sum += buf[0];
}
print("sum={}\n", sum);
0
}
```
- **Observed**: `Segmentation fault at address 0x16e70ffd0` (guard page).
With `0..1000` instead it prints `sum=499500` and exits 0 — the program is
correct, only the stack accumulation kills it.
- **Expected**: prints `sum=499999500000`, exit 0, at any trip count.
Repro B — pure nested loops, zero user locals:
```sx
#import "modules/std.sx";
main :: () -> i32 {
n := 0;
for 0..3000000: (i) {
for 0..1: (j) { n += 1; }
}
print("n={}\n", n);
0
}
```
- **Observed**: segfault. **Expected**: `n=3000000`, exit 0.
The emitted IR shows the cause directly (`sx ir`, body of repro A):
```llvm
for.body.1:
%alloca2 = alloca [128 x i64], align 8 ; fresh 1KB every iteration
...
%ig.tmp = alloca [128 x i64], align 8 ; plus a 1KB spill temp
```
## Root cause (suspected area)
`Builder.alloca` (`src/ir/module.zig` ~474) emits the `.alloca` instruction
into the current block, and the LLVM emitter (`src/backend/llvm/ops.zig`
`emitAlloca` ~327) builds `LLVMBuildAlloca` at the current insertion point —
so loop-body allocas are *executed* per iteration. LLVM only treats
entry-block allocas as static frame slots (and mem2reg/SROA only promote
those); a non-entry alloca re-executes and grows the stack each time, until
`ret`.
The standard fix (what clang does): emit **all** static allocas into the
function's entry block. Least-invasive locus is the emitter — in
`emitAlloca`, save the current insertion point, position the builder at the
entry block's first non-alloca instruction (or end of entry if empty), build
the alloca there, restore the position, `mapRef` as before. The IR shape and
the interpreter are untouched. All sx allocas are statically sized (TypeId),
so every one is hoistable.
## Investigation prompt (paste into a fresh session)
> Fix issue 0109: loop-body allocas grow the stack per iteration and long
> loops segfault. In `src/backend/llvm/ops.zig` `emitAlloca` (~327), hoist the
> alloca to the current function's entry block: get the function via the
> current insert block's parent, position the builder before the entry
> block's first non-alloca instruction (`LLVMGetEntryBasicBlock` +
> `LLVMGetFirstInstruction` walk past `LLVMAlloca` opcodes — same positioning
> pattern as `injectCtorIntoMain` in `src/ir/emit_llvm.zig` ~466), build the
> alloca + `mapRef`, then restore the previous insertion point
> (`LLVMGetInsertBlock` before / `LLVMPositionBuilderAtEnd` after). Audit the
> other in-place `LLVMBuildAlloca` temporaries in `src/ir/emit_llvm.zig`
> (`ba.tmp`, `abi.tmp`, `ig.tmp`, etc. — grep `BuildAlloca`) and route the
> ones reachable inside loops through the same hoist helper.
>
> Semantics note: per-iteration re-zeroing must not regress — initialization
> stores (e.g. `store undef` / `= .{...}` inits) stay where the decl was, in
> the body block; only the `alloca` itself moves to entry.
>
> Verify: both repros in `issues/0109-loop-body-alloca-stack-growth.md` (A is
> `issues/0109-loop-body-alloca-stack-growth.sx`) now print
> `sum=499999500000` / `n=3000000` and exit 0; `sx ir` on repro A shows no
> `alloca` inside `for.body.*`. Then `zig build && zig build test && bash
> tests/run_examples.sh` — any `.ir` snapshot churn from alloca placement must
> be reviewed (`git diff examples/expected/`) before `--update`. Promote a
> trip-count-bounded variant (e.g. 200k iterations, small buf) to
> `examples/00xx-basic-loop-local-stack-reuse.sx` as the pinned regression.

View File

@@ -1,112 +0,0 @@
# RESOLVED — 0110: `for arr: (x)` over an array copies the ENTIRE array per element
**Root cause:** `lowerFor`'s by-value element fetch emitted `index_get` on the
array *value*; the emitter realizes that as spill-whole-array-to-temp + GEP one
element, per iteration — O(N²) bytes copied (and pre-0109, per-iteration stack
growth that segfaulted a `[4096]i64` loop).
**Fix:** in `src/ir/lower/control_flow.zig` `lowerFor`, when the iterable is an
array with addressable storage (`getExprAlloca` hit, and the iterable was not
deref'd from a pointer — a deref'd identifier's alloca holds the pointer, not
the array), the by-value fetch is `index_gep` on the storage + a single element
`load`. Storage-less arrays and the deref'd-pointer case keep the `index_get`
fallback. Capture semantics unchanged: the loaded element is still a copy
(mutating it does not write back), matching the by-ref branch's base resolution.
**Regression test:** `examples/0048-basic-for-array-large.sx` (4096-element sum
prints `sum=8386560`, segfaulted pre-fix; copy-guard confirms by-value capture
mutation leaves the array untouched).
---
# 0110 — `for arr: (x)` over an array copies the ENTIRE array per element
**Symptom.** The collection-form `for` with by-value capture over an array
lowers the element fetch as `index_get` on the array *value*, which the LLVM
emitter realizes as: load the whole array as an SSA value, spill it to a
fresh `ig.tmp` alloca, GEP one element. Per iteration. Observed: a `for` over
a `[4096]i64` array segfaults (4096 iterations × 32KB spill = ~134MB of stack
— see issue 0109 for why body allocas never unwind); a `[256]i64` version
completes but copies 256 × 2KB = 512KB to read 2KB of data. Expected: O(1)
stack, O(N) total work — GEP into the array's existing storage and load the
single element.
Even after 0109's entry-block hoist (which collapses the spill to one reused
slot and fixes the crash), the full-array copy per element remains — O(N²)
bytes copied per loop. The by-ref capture path (`for arr: (*x)`) already does
this right: it GEPs into the array's alloca (`getExprAlloca`) without
copying. By-value should reuse the same base and just add a load.
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i32 {
arr : [4096]i64 = ---;
i := 0;
while i < 4096 { arr[i] = i; i += 1; }
sum := 0;
for arr: (x) { sum += x; }
print("sum={}\n", sum);
0
}
```
- **Observed**: `Segmentation fault` (stack overflow). With `256` instead of
`4096` it prints `sum=32640` — and `sx ir` shows the per-iteration spill:
```llvm
for.body.4:
%ig.tmp = alloca [256 x i64], align 8 ; fresh full-array temp
store [256 x i64] %load6, ptr %ig.tmp, align 8 ; copy ALL 256 elements
%ig.ptr = getelementptr [256 x i64], ptr %ig.tmp, i64 0, i64 %load8
%ig.val = load i64, ptr %ig.ptr, align 8 ; ...to read ONE
```
- **Expected**: `sum=8386560`, exit 0; the body GEPs `arr`'s own storage:
`getelementptr` on the original alloca + a single `i64` load, no array-sized
temp, no copy.
Repro co-located: `issues/0110-for-array-by-value-full-array-spill.sx`
(unpinned until fixed; depends on 0109 only for the crash, not for the copy).
## Root cause (suspected area)
`src/ir/lower/control_flow.zig` `lowerFor` (~336-343): the by-ref branch
builds `index_gep` on the array's storage (`getExprAlloca(fe.iterable) orelse
iterable`), but the by-value branch emits
`.index_get = .{ .lhs = iterable, .rhs = idx_val }` where `iterable` is the
loaded array **value**. The emitter's `index_get` on an aggregate SSA value
(`src/ir/emit_llvm.zig`, `ig.tmp` site ~2100s) has no address to index, so it
spills the whole value first.
Fix shape: in `lowerFor`, when the iterable is an array with storage
(`getExprAlloca` hit — same condition the by-ref branch uses), lower the
by-value element as `index_gep` on that storage followed by a `load` of the
element type, instead of `index_get` on the value. Capture semantics are
unchanged: the load still produces a per-iteration copy of the *element*
(mutating `x` must not write back; mutating the array inside the body is
already visible to subsequent iterations through the same storage in the
by-ref form — match existing behavior with a test). Keep the `index_get`
fallback for storage-less iterables (e.g. an array returned by a call).
## Investigation prompt (paste into a fresh session)
> Fix issue 0110: collection-form `for arr: (x)` (by-value capture) over an
> array spills the entire array per iteration. In
> `src/ir/lower/control_flow.zig` `lowerFor` (~336-343), make the by-value
> element fetch reuse the by-ref branch's base resolution: if the iterable is
> an `.array` and `getExprAlloca(fe.iterable)` (or the deref'd pointer base)
> yields storage, emit `index_gep` on that storage + `builder.load` of the
> element type; otherwise keep the existing `index_get` path. Slices/List
> views are unaffected (their `index_get` is already pointer-backed).
>
> Verify: `./zig-out/bin/sx run issues/0110-for-array-by-value-full-array-spill.sx`
> prints `sum=8386560` exit 0 (after 0109 lands; before it, verify the
> 256-element variant's `sx ir` shows no `ig.tmp` array spill in
> `for.body.*`). Add a semantics guard test: mutate `x` in the body and
> confirm the array is unchanged after the loop (by-value capture stays a
> copy). Then `zig build && zig build test && bash tests/run_examples.sh`,
> review any `.ir` snapshot diffs, and promote the repro to
> `examples/00xx-basic-for-array-large.sx`.

View File

@@ -1,139 +0,0 @@
# RESOLVED — 0111: unannotated int-literal locals adopt the enclosing fn's return type
**Root cause:** `lowerFunctionBodyInto` sets `self.target_type = ret_ty` for the whole
body (for the implicit trailing return), the `.int_literal` arm adopts any integer
`target_type`, and the unannotated paths of `lowerVarDecl` / `lowerDestructureDecl`
lowered their initializer without clearing it — so `x := 0` in a `-> i32`/`-> i8`
function was typed i32/i8 and `big := 3000000000` silently wrapped.
**Fix:** both decl paths now save/clear/restore `target_type` around the initializer
`lowerExpr` (`src/ir/lower/stmt.zig`); a declaration without annotation provides no
target, so literals take their spec defaults (i64/f64). Trailing-expression and
`return` coercion to the return type are untouched.
**Regression test:** `examples/0173-types-int-literal-default-i64.sx` (f.x/g.x/main.x,
destructured a/b all print `i64`; `big` prints `3000000000`).
**Note (not fixed here):** the `.int_literal` arm still wraps a literal that does not
fit an explicitly-annotated integer target (`x : i8 = 300`) with no diagnostic —
filed separately as issue 0112.
---
# 0111 — unannotated int-literal locals adopt the enclosing fn's return type (silent narrowing + wraparound)
**Symptom.** A local declared `x := <int literal>` (no annotation) inside a
function whose return type is an integer type T gets typed as **T**, not the
spec'd default. `f :: () -> i32 { x := 0; ... }` gives `x: i32`;
`g :: () -> i8 { x := 0; ... }` gives `x: i8`. Expected (specs.md §"Integer
literals default to `i64`", lines 240 / 1428): `i64` in all of these — the
declaration has no target type. Inside a `-> void` function the same decl
correctly infers `i64`.
Consequences are silent and severe:
- All arithmetic through such locals wraps at the narrowed width:
`x := 0; x += 3000000000;` inside `main :: () -> i32` prints `-1294967296`.
- A large literal initializer truncates with **no diagnostic**:
`big := 3000000000;` inside a `-> i32` fn binds `big` to i32 = `-1294967296`.
- Blast radius: every `main :: () -> i32 { ... }` in the corpus types every
unannotated int-literal local as i32 — long-running counters/accumulators
in such functions are one overflow away from wrong results. Discovered
because issue 0109's verification loop (`sum` over 1M iterations in a
`-> i32` main) printed the 32-bit-wrapped sum after the segfault was fixed.
## Reproduction
```sx
#import "modules/std.sx";
f :: () -> i32 {
x := 0;
print("f.x: {}\n", type_name(type_of(x)));
0
}
g :: () -> i8 {
x := 0;
print("g.x: {}\n", type_name(type_of(x)));
0
}
big_host :: () -> i32 {
big := 3000000000;
print("big: {} = {}\n", type_name(type_of(big)), big);
0
}
main :: () {
f();
g();
big_host();
x := 0;
print("main.x: {}\n", type_name(type_of(x)));
}
```
- **Observed** (current master): `f.x: i32`, `g.x: i8`,
`big: i32 = -1294967296`, `main.x: i64`.
- **Expected**: `i64` for all four; `big` prints `3000000000`.
Repro co-located: `issues/0111-int-literal-local-adopts-fn-return-type.sx`
(standalone version of the above; unpinned until fixed).
## Root cause (traced)
Three-link chain, all confirmed by reading:
1. `src/ir/lower/decl.zig` ~2149 (`lowerFunctionBodyInto`): before lowering
the body, `self.target_type = ret_ty` is set for the WHOLE body — intended
for the implicit trailing-return coercion — and only restored after the
body finishes.
2. `src/ir/lower/expr.zig` ~1499 (`.int_literal` arm): an int literal adopts
`self.target_type` whenever it `isIntEx` — with no fits-check, so a
too-big literal wraps silently.
3. `src/ir/lower/stmt.zig` ~335-348 (`lowerVarDecl`, unannotated path):
lowers the initializer with whatever `target_type` is in scope and takes
the decl's type from the lowered ref. The annotated path overwrites
`target_type` with the annotation (which is why `y : i64 = 0` is immune);
the unannotated path inherits the function-return context it should never
see.
## Investigation prompt (paste into a fresh session)
> Fix issue 0111: unannotated int-literal locals adopt the enclosing
> function's integer return type instead of the i64 default. Root cause chain
> is in the issue (decl.zig ~2149 body-wide `target_type = ret_ty`;
> expr.zig ~1499 `.int_literal` adopting it; stmt.zig ~335 unannotated
> `lowerVarDecl` not clearing it). Fix shape: in `lowerVarDecl`'s
> unannotated path, save `self.target_type`, set it to `null` around the
> `lowerExpr(val)` of the initializer, restore after — a declaration without
> annotation provides no target. Audit the sibling statement positions that
> also shouldn't inherit the return-type context (e.g. expression statements,
> `lowerMultiAssign` / `lowerDestructureDecl` unannotated paths) and apply the
> same clear where applicable. Do NOT touch the trailing-expression /
> `return` paths — `f :: () -> i32 { 0 }` must keep coercing the tail to the
> return type.
>
> Separately consider (same fix or follow-up per scope): the `.int_literal`
> arm wraps a literal that doesn't fit the adopted integer target with no
> diagnostic — add a fits-check that errors like the float-narrowing rule
> (`floatToIntExact` precedent) instead of silently truncating.
>
> Verify: run `issues/0111-int-literal-local-adopts-fn-return-type.sx` —
> expect `i64` for f.x / g.x / big / main.x and `big = 3000000000`. Then
> `zig build && zig build test && bash tests/run_examples.sh` against
> EXISTING snapshots: any diff means an example was silently relying on
> narrowed locals — review each (the change is user-visible only via
> type_name/overflow behavior). Promote the repro per the resolution flow
> (`examples/01xx-types-...` block) and mark this issue RESOLVED.
>
> Context: this bug BLOCKS the in-flight fix session for issues 0108/0109/
> 0110 (for-loop codegen resource bugs). The 0109 emitter change (entry-block
> alloca hoisting) is already applied in the working tree, builds, and fixes
> both 0109 repros — but its regression example (1M-iteration `sum`
> accumulation in `main :: () -> i32`) cannot produce the documented expected
> output (`sum=499999500000`) until locals stop narrowing to i32, and
> suite/.ir snapshot regen must not run on a compiler with this live
> miscompile. After 0111 lands, resume: finalize 0109 (suite + .ir review +
> regression example + commit), then 0110, then 0108 per their issue files.

View File

@@ -1,98 +0,0 @@
# RESOLVED — 0112: out-of-range int literal silently wraps into a narrower annotated target
**Root cause:** the `.int_literal` arm adopted an integer `target_type` with no
fits-check, truncating at emission width; `globalInitValue` serialized literal
global initializers raw the same way.
**Fix:** `Lowering.checkIntLiteralFits` (src/ir/lower.zig) range-checks a
literal against its integer target (`intLiteralRange`: builtins + custom
widths; width-64 types skip — every representable literal is legal there) and
diagnoses `integer literal N does not fit in T (range lo..hi) — use an
explicit `xx` / `cast` to truncate`. Wired into the `.int_literal` arm,
`lowerStructConstant`, and `globalInitValue`. A negated literal now folds to
one constant (`-128` checks as -128, not as an out-of-range +128
intermediate), and an explicit `xx` operand skips the check
(`suppress_int_fit_check`) — truncation stays available on request;
`cast(T)` was already exempt (its value arg lowers without the target).
Coverage via the shared arm: decls, assignments, call args, struct-literal
fields, struct constants, globals.
**Behavior change:** `examples/0300-closures-lambda.sx` passed `133` to an
`i3` param and pinned the wrapped `-3`; updated to a fitting value.
**Regression tests:** `examples/1156-diagnostics-int-literal-out-of-range.sx`
(both faces diagnosed in one run) and
`examples/0174-types-int-literal-boundaries.sx` (extreme in-range values,
width-64 types, `xx`/`cast` escapes, call args).
**Found during the fix:** negated-literal GLOBAL initializers (`g : i64 = -1;`)
are rejected as non-constant — pre-existing gap, filed as issue 0113.
---
# 0112 — out-of-range int literal silently wraps into a narrower annotated target
**Symptom.** An integer literal that does not fit its explicitly-annotated
integer target truncates with no diagnostic: `x : i8 = 300;` binds 44,
`y : u8 = 256;` binds 0. Expected: a compile-time error (the value is known
exactly at compile time; this is the integer analogue of the float→int
narrowing rule, which errors on non-exact `y : i64 = 1.5`).
Split from issue 0111 (whose fix removed the *implicit* narrowing — an
unannotated `x := 0` no longer adopts the fn return type — but the explicit
annotation path keeps wrapping).
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
x : i8 = 300;
print("x: {}\n", x);
y : u8 = 256;
print("y: {}\n", y);
}
```
- **Observed** (current master): prints `x: 44` / `y: 0`, exit 0, no
diagnostic.
- **Expected**: compile error per literal, e.g.
`integer literal 300 does not fit in i8 (range -128..127)`, and the analog
for `256` / `u8 (range 0..255)`.
Repro co-located: `issues/0112-int-literal-out-of-range-silent-wrap.sx`
(unpinned until fixed).
## Root cause (suspected area)
`src/ir/lower/expr.zig` `.int_literal` arm (~1499): when `target_type` is an
integer type, it emits `constInt(lit.value, tt)` with no fits-check — the
value truncates at LLVM emission width. The annotated-decl path
(`lowerVarDecl` with `type_annotation`, `src/ir/lower/stmt.zig` ~255) sets
`target_type` to the annotation before lowering the initializer, so every
annotated narrow decl funnels through this arm. Assignments to narrow
lvalues (`b = 300` where `b: i8`) reach the same arm via `lowerAssignment`'s
LHS-derived target and likely need the same check.
## Investigation prompt (paste into a fresh session)
> Fix issue 0112: an int literal that does not fit its integer target type
> silently wraps. In the `.int_literal` arm of `lowerExpr`
> (`src/ir/lower/expr.zig` ~1499), before adopting an integer `target_type`,
> range-check `lit.value` against the target's signedness/width (the type
> table knows both; mirror the bounds logic used by
> `TypeResolver.integerLimitFor`). On overflow emit a diagnostic via
> `self.diagnostics.addFmt(.err, node.span, ...)` naming the literal, the
> type, and its range — do NOT silently fall back to i64 (REJECTED PATTERNS:
> no silent fallback defaults); still return a `constInt` of the target type
> so lowering continues to surface further errors. Audit sibling literal
> sinks that bypass this arm (comptime folds, `lowerStructConstant`, global
> initializers) for the same check.
>
> Verify: `issues/0112-int-literal-out-of-range-silent-wrap.sx` errors with
> two diagnostics (i8/300, u8/256); boundary values still compile
> (`x : i8 = -128` / `127`, `y : u8 = 0` / `255`, `m : u64` large literals).
> `zig build && zig build test && bash tests/run_examples.sh` — any example
> that relied on silent wrapping must be reviewed individually. Promote the
> repro per the resolution flow (likely `examples/11xx-diagnostics-...`).

View File

@@ -1,77 +0,0 @@
# RESOLVED — 0113: negative-literal global initializer rejected as "not a compile-time constant"
**Root cause:** `globalInitValue` (src/ir/lower/decl.zig) had no `.unary_op`
arm, so a negated literal fell into the catch-all "must be initialized by a
compile-time constant" — even though `constExprValue` already folds
`negate(int/float literal)` for the module-const identifier route.
**Fix:** a `.unary_op` arm routes the initializer through `constExprValue`;
the folded value follows the direct-literal rules — `checkIntLiteralFits` on
ints (`g : i8 = -300;` gets the range diagnostic, not "non-constant"), and a
negated float at an integer global narrows only when integral
(`g : i64 = -4.0;` → -4; `-4.5` errors). Binary-op initializers
(`g : i32 = 2 + 3;`) remain unsupported and keep the specific
"must be initialized by a compile-time constant" diagnostic — const-expr
folding for those is a separate feature if ever wanted.
**Regression test:** `examples/0175-types-negative-literal-global.sx`
(prints `-1 -4 -128`; failed "non-constant" pre-fix).
---
# 0113 — negative-literal global initializer rejected as "not a compile-time constant"
**Symptom.** A top-level global initialized with a negated literal fails to
compile: `g : i64 = -1;` errors
`global 'g' must be initialized by a compile-time constant`. Expected: a
negated literal is a compile-time constant; the global serializes to -1.
Positive literals work (`g : i64 = 1;`). Locals are unaffected
(`x : i64 = -1;` inside a function is fine — lowerExpr folds the negate).
## Reproduction
```sx
#import "modules/std.sx";
g : i64 = -1;
main :: () {
print("{}\n", g);
}
```
- **Observed**: `error: global 'g' must be initialized by a compile-time
constant` at the initializer.
- **Expected**: compiles; prints `-1`.
Repro co-located: `issues/0113-negative-literal-global-initializer-rejected.sx`.
## Root cause (suspected area)
`src/ir/lower/decl.zig` `globalInitValue` (~973): the initializer switch has
arms for `.int_literal` / `.float_literal` / `.bool_literal` / etc., but a
negated literal is a `.unary_op` node, which falls into the catch-all
`else => "must be initialized by a compile-time constant"`. The identifier
arm already routes module-const values through `constExprValue` (~1013) —
the direct `.unary_op` / `.binary_op` initializer shapes never get that
chance.
## Investigation prompt (paste into a fresh session)
> Fix issue 0113: `g : i64 = -1;` (and const-expression initializers like
> `g : i64 = 2 + 3;`) are rejected as non-constant globals. In
> `src/ir/lower/decl.zig` `globalInitValue`, route `.unary_op` and
> `.binary_op` initializers through the same const-expression evaluation the
> `.identifier` arm uses (`constExprValue`, or the
> `program_index.evalConstFloatExpr`-family used by `typedConstInitFits`
> ~878) before falling into the catch-all diagnostic. Apply the int-literal
> fits-check (`checkIntLiteralFits`) to the folded value against the
> global's type — `g : i8 = -300;` must produce the range diagnostic, not a
> wrap and not "non-constant". Negative bounds in `typedConstInitFits`
> already admit unary_op shapes; keep both checks consistent.
>
> Verify: the repro prints -1; `g2 : i8 = -300;` errors with the range
> message; `g3 : i32 = 2 + 3;` initializes to 5 (or, if expression globals
> are deliberately unsupported, keeps a SPECIFIC diagnostic saying so).
> `zig build && zig build test && bash tests/run_examples.sh`. Promote the
> repro per the resolution flow.

View File

@@ -1,75 +0,0 @@
# 0114 — namespace aliases leak transitively and collide first-wins, silently
> **RESOLVED** (2026-06-11). Root cause: `lowerCall`'s namespace branch
> consulted the global `fn_ast_map["alias.fn"]` (registered first-wins by
> `registerQualifiedFn`) with no per-importer gate, and fell back to the
> global LAST-wins bare map for comptime/generic members. Fix: the branch
> now routes plain-identifier alias roots through the carry-aware
> `namespaceAliasVerdict` — visible targets dispatch the member fd pinned
> to the TARGET module (`namespaceFnMember` + fd-keyed `bareAuthorFuncId`),
> ambiguous carries diagnose loudly, and an alias that exists only beyond
> one flat hop errors "namespace 'X' is not visible". Extern/builtin/
> #compiler members keep the literal-symbol path. Regression tests:
> `examples/0832-modules-namespace-alias-two-hop-not-visible.sx`,
> `examples/0833-modules-namespace-alias-carried-collision-ambiguous.sx`,
> `examples/0834-modules-namespace-alias-own-target-pin.sx`.
**Symptom.** A namespace alias (`t :: #import "target.sx";`) declared in module
B is usable from ANY module whose import closure reaches B — at any depth, flat
or not — and when two modules register the same qualified name (`t.helper`),
the first registration silently wins (`registerQualifiedFn`:
`if contains return`). Expected (the approved carry design, session 72f): an
alias is visible one level deep — in the declaring module and in its DIRECT
flat importers — with own-wins / ambiguity-diagnostic collision semantics,
mirroring ordinary declarations.
This is the alias-side sibling of issue 0106 (bare-name over-permissiveness),
plus a REJECTED-PATTERNS silent first-wins.
## Reproduction
```sx
// target.sx
helper :: () -> i64 { 7 }
```
```sx
// facade.sx
t :: #import "target.sx";
```
```sx
// facade2.sx
#import "facade.sx";
```
```sx
// main.sx
#import "modules/std.sx";
#import "facade2.sx"; // TWO flat hops from the alias declaration
main :: () { print("{}\n", t.helper()); }
```
- **Observed**: prints `7` — the alias rides two flat hops.
- **Expected**: `'t' is not visible` (one-level carry; `facade2.sx` would need
to re-alias or flat-import `facade.sx`'s surface deliberately).
Collision face: two modules each declaring `t :: #import` of DIFFERENT
targets, both flat-imported by main — first-lowered wins silently.
## Suspected area / fix shape
Qualified members are registered GLOBALLY (`fn_ast_map["t.helper"]`,
`registerQualifiedFn` in src/ir/lower/decl.zig ~1912) with no per-importer
gate; `lowerCall`'s namespace path (src/ir/lower/call.zig ~687) and the
comptime field-access path consult only that global map. Meanwhile
`nominal.zig`'s `qualifiedStructTemplate`/`qualifiedMemberMissing` use STRICT
per-file `namespace_edges` — so carried aliases are inconsistently
over-visible for plain/comptime fns and under-visible for generic structs
(and `alias.Type.method` heads — see PLAN-STDLIB).
Fix shape: one carry-aware resolver on Lowering —
`resolveNamespaceAlias(alias) → {own | carried(target) | ambiguous | none}`
walking own `namespace_edges[from]` first, then the DIRECT
`flat_import_graph[from]` targets' edges (one level; ≥2 distinct targets →
diagnostic). Route every `ns.member` consumer through it: the global
qualified-name paths gain the gate, the strict nominal/type paths gain the
carry. See `current/PLAN-STDLIB.md` for the full design and the std.sx
restructure that depends on it.

View File

@@ -1,122 +0,0 @@
# 0115 — same-name consts of different shapes collide across modules (panic / silent clobber)
> **RESOLVED** (2026-06-11). Root cause: the globals registry
> (`global_names`) is last-wins across modules and every read/write/addr/
> call site consulted it with no source-awareness; `var_decl` was not even
> a selectable raw author. Fix: `var_decl` joins `RawDeclRef`;
> `selectGlobalAuthor` (the globals analogue of F2's `selectModuleConst`)
> selects the author own-wins / one-flat-visible / ambiguous-loudly and
> serves the AUTHOR's per-source global; all bare-identifier global sites
> (read, addr-of, assignment, fn-ptr call, type inference) route through
> it. `selectModuleConst` gained `.own_opaque` so an own const author with
> no materialized value (unsupported shape, e.g. an array `::` const)
> blocks borrowing another module's same-named const instead of panicking.
> The fn-as-VALUE arm admits raw-facts-only authors (an own fn dropped from
> the global decl list by a flat-merge collision — the 0601 `test` case).
> Regression tests: examples/0835, 0836, 0837. The co-blockers also
> landed: dead-global elimination at emit (unreferenced plain-data globals
> are not emitted) and 1055/1056 no longer pin global error ordinals —
> the full std namespace tail is enabled on top.
**Symptom.** When two modules in one program declare a same-named module
const with DIFFERENT shapes (scalar `K : i64 : 4` vs array
`K : [4]i64 : .[...]`), resolution conflates them instead of selecting
per-author:
- **Observed (minimal repro below)**: compiler PANIC — `unresolved type
reached LLVM emission` (`src/backend/llvm/types.zig:175`, the
`.unresolved` sentinel tripwire).
- **Observed (full std-tail topology)**: SILENT WRONG VALUES — a module's
own scalar `K` reads as the other module's array global (prints the
array's address or the whole array). Seen corpus-wide when
`hash :: #import "modules/std/hash.sx"` (hash.sx declares the SHA-256
`K : [64]i64` table) is added to the std.sx namespace tail: examples
0786/0787/0788/0789/0791/0793/0794 (same-name-const family), 0162, 0168
all read hash's `K` instead of their own.
- **Expected**: own-wins / per-author const selection (the documented F2
semantics — readme "Own-wins holds at every one of those sites") applies
regardless of the consts' shapes; no cross-shape leakage, no panic.
This blocks the PLAN-STDLIB "full tail" follow-up: fs/process/socket/
json/cli/hash/test cannot join the std.sx namespace tail until same-name
consts are robust across every module pulled into every program.
## Reproduction (panic variant — minimal, standalone)
```sx
// h.sx
K : [4]i64 : .[11, 22, 33, 44];
use_k :: () -> i64 { K[2] }
```
```sx
// main.sx
#import "modules/std.sx";
h :: #import "h.sx";
K : i64 : 4;
main :: () { print("K={} h.use_k={}\n", K, h.use_k()); }
```
Run `main.sx` → panic `unresolved type reached LLVM emission`.
Expected: prints `K=4 h.use_k=33`.
## Reproduction (silent-clobber variant — full topology)
Add the full tail to `library/modules/std.sx` after the existing
`mem/xml/log` lines:
```sx
fs :: #import "modules/std/fs.sx";
process :: #import "modules/std/process.sx";
socket :: #import "modules/std/socket.sx";
json :: #import "modules/std/json.sx";
cli :: #import "modules/std/cli.sx";
hash :: #import "modules/std/hash.sx";
test :: #import "modules/std/test.sx";
```
Run `bash tests/run_examples.sh` → ~50 failures. The const-family
failures (0786 prints `a=4318334368 b=4318334368`, 0162 prints the whole
64-entry array for `K=...`) are this bug. (A flat-importing main + a
namespaced array-K module WITHOUT the tail topology resolves correctly —
the silent variant needs the tail's shape, where hash.sx itself
flat-imports std.sx. The panic variant above is the minimal entry point.)
## Co-blockers observed in the same experiment (note, possibly separate issues)
1. **Eager emission bloat**: with the tail in place, every program emits
hash's 64-entry `@K` table plus `@OS/@ARCH/@POINTER_SIZE` globals even
when unused (visible in every pinned `.ir` snapshot). Tail modules'
globals should emit lazily (only when referenced).
2. **0601-comptime-meta** prints nothing (comptime meta machinery breaks
with the tail in place — root cause unknown, possibly same-name
generic/comptime fn last-wins: `isPlainFreeFn` excludes generic /
comptime authors from own-wins rerouting).
3. **1055/1056 errors-enum-value**: user-visible error ints shift when
tail modules' error sets join the global error-tag registry (numbering
coupling, arguably inherent; snapshot fragility at minimum).
## Investigation prompt
Same-name module consts are selected own-wins via `selectModuleConst`
(F2, src/ir/lower/expr.zig ~1641) over `module_const_map` — but ARRAY
consts lower as GLOBALS (`@K = internal global [4 x i64]`), registered in
a different, still last-wins registry (find it: grep the lowering for
where a top-level array const becomes a module global — likely
`lowerGlobalDecl` / the global-var map in src/ir/lower/decl.zig). The
panic happens because the scalar `K`'s type resolution reads the OTHER
author's array shape (or vice versa) and poisons to `.unresolved`.
The fix likely needs:
1. Source-aware selection for the globals registry, mirroring
`selectModuleConst` (own-wins, ≥2 flat-visible authors → loud
ambiguity) — including the MIXED scalar/array case where the two
authors live in different registries today.
2. The 0786-family examples already pin scalar/scalar own-wins; add a
scalar-vs-array pin (the minimal repro above) once fixed.
Verification: run the panic repro (expect `K=4 h.use_k=33`), then add the
full tail to std.sx and run `bash tests/run_examples.sh` — the const
family (0786/0787/0788/0789/0791/0793/0794, 0162, 0168) must pass; the
remaining tail failures decompose into co-blockers 1-3 above (file
separately if they persist).

View File

@@ -1,44 +0,0 @@
# 0116 — writes through module consts are not rejected (struct-const write bus-errors at runtime)
> **RESOLVED** (2026-06-11, PLAN-CONST-AGG step 2). The assignment
> lowering rejects any target chain ROOTED at a constant — a
> const-flagged global (array consts, #run consts) or a module value
> const (struct consts incl.) — with `cannot assign through constant
> 'X'`. A deref along the chain (`p.*`) breaks the root (pointer writes
> are permanently unchecked — the documented pointer contract, specs.md
> §Pointer Types); a local
> shadowing the const name stays writable. Regression test:
> examples/1162-diagnostics-const-write-rejected.sx (struct field — the
> crash repro, array element, compound, bare scalar).
**Symptom.** Assigning through a module-level constant compiles silently.
For a struct-literal const the store lands in read-only memory and the
program crashes at runtime.
- **Observed**: compiles; `Bus error` at runtime on the store.
- **Expected**: compile-time diagnostic — `cannot assign through constant
'WHITE'`.
## Reproduction
```sx
#import "modules/std.sx";
Color :: struct { r, g, b: i64; }
WHITE :: Color.{ r = 255, g = 255, b = 255 };
main :: () {
WHITE.r = 0; // compiles; bus error at runtime
print("{}\n", WHITE.r);
}
```
(Copies are fine and stay fine: `w := WHITE; w.r = 0;` mutates the copy.)
## Fix
Scheduled as **step 2 of `current/PLAN-CONST-AGG.md`** (aggregate consts):
one rejection rule for assignment/compound-assignment targets whose ROOT
identifier resolves to a module const (module_const_map author) or a
const-flagged global (the array consts that plan introduces). Diagnose at
the assignment site; pin the repro above as a diagnostics example.

View File

@@ -1,62 +0,0 @@
# 0117 — indexing through a pointer-to-array panics at LLVM emission
> **RESOLVED** (2026-06-11). `ptrToArrayElem` on Lowering recognises the
> `*[N]T` receiver; the index READ path GEPs the pointee array through the
> pointer value and loads the element; the WRITE / compound-assign /
> lvalue / addr-of-element paths and the expression typer resolve the
> element type through the same helper (their GEP machinery already
> handled a pointer base). Postfix deref (`p.*`) was always the deref
> spelling — the issue's `(*p)[2]` note was a wrong-syntax red herring.
> Regression test: examples/0176-types-pointer-to-array-index.sx
> (read, write, compound, element pointer + deref).
**Symptom.** Indexing through a `*[N]T` pointer is neither lowered nor
diagnosed: the index expression reaches the LLVM emitter with the
`.unresolved` type sentinel and trips the panic tripwire.
- **Observed**: `panic: unresolved type reached LLVM emission`
(src/backend/llvm/types.zig:175, via `emitIndexGet`).
- **Expected**: either `p[2]` auto-derefs the pointer-to-array (load the
pointer, GEP into the array — mirroring the field-access auto-deref on
struct pointers), or a clean diagnostic if indexing through `*[N]T` is
out of spec. Never an emit-time panic.
Pre-existing — reproduces on plain locals with no module-level features
involved (found while pinning `@K` reads for PLAN-CONST-AGG step 1; the
same panic fires for `@<var-decl global array>` and `@<array const>`).
Also note: the explicit-deref spelling `(*p)[2]` does not parse as a
deref — it lowers as `unknown_expr` ("unresolved 'unknown_expr'"), so
there is no working spelling for reading an element through a
pointer-to-array.
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
k : [4]i64 = .[11, 22, 33, 44];
p := @k; // *[4]i64
print("{}\n", p[2]); // expected 33; panics at emission today
}
```
## Investigation prompt
`emitIndexGet` receives an `index_get` whose result type is `.unresolved`
— the lowering of `index_expr` over a receiver of pointer-to-array type
produces no deref. Suspected area: the index lowering in
src/ir/lower/expr.zig (`lowerIndexExpr` or equivalent — find the
`index_expr` arm) and its typing twin in src/ir/expr_typer.zig: both
handle array / slice / many-pointer receivers but not
`pointer → array`. The fix likely mirrors the struct-pointer auto-deref:
when the receiver type is `.pointer` whose pointee is `.array`, load the
pointer value and index the pointee array (element type = pointee
element), in BOTH the typer and the lowering. Check the assignment-target
path too (`p[1] = v` through a pointer-to-array).
Verification: the repro above prints `33`; add a pin under
examples/01xx-types-… covering read AND write-through
(`p[1] = 5; print k[1]` → 5 for a mutable local). Suite + zig build test
stay green.

View File

@@ -1,86 +0,0 @@
# 0118 — `cast(<compound type>) expr` dies with "unresolved 'unknown_expr'"
> **RESOLVED** (2026-06-11, same session, Agra-authorized in-session fix).
> Two-part root cause: (1) `lowerCall` lowers args BEFORE the cast handler,
> and compound type literals had NO expression-position lowering — they hit
> `lowerExpr`'s catch-all `unknown_expr` error. Fixed by giving the six
> compound type-expr shapes (`*T`, `[*]T`, `[]T`, `?T`, `[N]T`, fn types) a
> first-class `const_type` lowering arm in `src/ir/lower/expr.zig`,
> mirroring named types (`t : Type = *i64;` now works like `t : Type =
> f64;`). (2) The cast handler's private static-type gate only accepted
> bare names — replaced with the canonical `isStaticTypeArg`
> (`src/ir/lower/call.zig`), so static compound casts route through
> `coerceExplicit` while the runtime-`Type`-variable form (`cast(type) val`
> in category arms) still falls through to runtime dispatch. Regression
> test: examples/0182-types-cast-compound-types.sx (all compound cast
> forms + the first-class Type value). Suite 579/579.
## Symptom
`cast(T) expr` with a COMPOUND static type argument (`*i64`, `[]u8`, `?i32`,
`[*]i64`, `[4]i64`, …) fails to compile with a junk diagnostic pointing at the
type argument. Observed:
```
error: unresolved 'unknown_expr' (in probe.sx fn main)
--> probe.sx:5:21
|
5 | q : *i64 = cast(*i64) p;
| ^^^^
```
Expected: the cast resolves the type argument statically and routes through
`coerceExplicit` (for `cast(*i64) p` where `p : *i64`, a no-op), exactly as it
does for bare names (`cast(i32) 3.14` works). The spec places no scalar-only
restriction on `cast(Type)` (specs.md "cast(Type) expr — prefix operator that
converts expr to Type").
Pre-existing on master (verified on a clean build of 679653f) — independent of
the in-flight const-pointer work; plain `*i64` reproduces it.
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
x := 42;
p : *i64 = @x;
q : *i64 = cast(*i64) p; // error: unresolved 'unknown_expr'
print("{}\n", q.*); // expected: 42
}
```
## Investigation prompt
The `cast` handler in `src/ir/lower/call.zig` (~line 391, the
`Handle cast(TargetType, val)` block) gates static resolution with a PRIVATE
`is_static_type` check that only accepts `type_expr` and `identifier` AST
shapes. Every compound type-expression shape (`pointer_type_expr`,
`slice_type_expr`, `many_pointer_type_expr`, `optional_type_expr`,
`array_type_expr`, `function_type_expr`) falls through to the "runtime cast"
builtin path, which cannot resolve it and surfaces the catch-all
"unresolved 'unknown_expr'".
The codebase already has the canonical gate: `Lowering.isStaticTypeArg`
(`src/ir/lower/generic.zig:206`), which `type_name` / `type_eq` use — it
accepts the full compound-shape set (this is why `type_name(*i64)` folds
fine while `cast(*i64)` dies). The fix likely: replace the private
`is_static_type` block with `self.isStaticTypeArg(type_arg)` (keeping the
scope-shadow semantics: an identifier bound to a runtime `Type` variable must
still route to the runtime-dispatch path used by `case`-arm `cast(type)`
that path is load-bearing for `any_to_string`, see specs.md "runtime generic
dispatch"). Then `resolveTypeArg` already handles the compound shapes.
Verification:
1. Run the repro above — expect `42`, exit 0.
2. Sanity: `cast([]u8)`, `cast(?i32)`, `cast([*]i64)` forms resolve.
3. `bash tests/run_examples.sh` — the `case`-arm runtime-dispatch examples
(any_to_string formatting suite) must stay green, proving the
runtime-`Type`-variable path still falls through to the builtin.
4. Pin the repro as a regression example per CLAUDE.md ("Resolving an open
issue").
Context note: surfaced while probing PLAN-CONST-AGG step 5 (`*const T`), whose
const-discard diagnostic suggests "use an explicit cast (`xx`/`cast`)" — `xx`
works for pointer-shaped targets; `cast` currently does not.

View File

@@ -1,100 +0,0 @@
# 0119 — UFCS dot-call on a GENERIC free function: "unresolved '<name>'"
> **RESOLVED** (2026-06-11, same session — Agra language ruling + the
> opt-in implementation). Final model: free-function dot-calls are
> OPT-IN. `name :: ufcs (params) { body }` (new declaration form) and
> `name :: ufcs target;` (alias) both opt in; a PLAIN fn never
> dot-dispatches (tailored diagnostic steers to direct / `|>` /
> marking it ufcs). Generic ufcs fns dispatch via dot with the
> receiver participating in `$T` binding; a protocol-typed receiver
> dispatches its own methods first and falls through to ufcs fns
> (`context.allocator.create(Session)` works). Bonus root-cause fix:
> plan-side `inferGenericReturnType` now delegates to the SAME
> `buildTypeBindings` the monomorphizer uses, so structured generic
> params (`[]$T`) type direct calls correctly too (was a `T{}` stub
> through print's Any boxing — pre-existing). The previously-implicit
> unannotated dot-dispatch was REMOVED (inverted vs the model);
> in-tree reliance was 6 example files (audited; migrated to marked
> form), zero in m3te/game. specs.md §UFCS rewritten around the
> opt-in matrix. Regression: examples/0053-basic-ufcs-opt-in.sx +
> 1166-diagnostics-ufcs-not-opted-in.sx; mem helpers marked ufcs
> (0838 pins dot + pipe + direct). Suite 585/585.
## Symptom
`obj.func(args)` where `func` is a generic free function (any `$T` in its
signature — a `[]$T`/`*$T` param or a `$T: Type` value param) fails with
`unresolved '<name>'`. The same call spelled directly —
`func(obj, args)` — compiles and runs correctly. Concrete (non-generic)
free functions rewrite through UFCS fine.
Observed (one probe, all three failures):
- `xs.sum_all()` (concrete fn, slice receiver) → **works**
- `xs.first_of()` (generic `[]$T` fn, slice receiver) → `unresolved 'first_of'`
- `p.pick(i32)` (generic `$T: Type` fn, struct receiver) → `unresolved 'pick'`
- `a.create(Session)` (generic fn, protocol-value receiver) → `unresolved 'create'`
Expected: specs.md §UFCS promises the rewrite unconditionally ("When
`object.func(args)` is encountered and `func` is not a field of
`object`'s type, the compiler rewrites the call to `func(object,
args)`"). A generic free function called via dot must monomorphize and
dispatch exactly as the direct spelling does.
Note: issue-0040 (fixed) covered generic STRUCT METHODS via dot —
that is the method path, not the free-function UFCS rewrite.
## Reproduction
```sx
#import "modules/std.sx";
first_of :: (xs: []$T) -> T { xs[0] }
main :: () {
arr := .[1, 2, 3];
xs : []i64 = arr;
print("{}\n", first_of(xs)); // 1 — direct call works
print("{}\n", xs.first_of()); // error: unresolved 'first_of'
}
```
## Investigation prompt
The UFCS rewrite lives in the call-lowering path (`src/ir/calls.zig` /
`src/ir/lower/call.zig` — the field-access-callee handling that falls
back to "func is not a field → try `func(object, args)`"). The fallback
resolves the bare name against DECLARED functions (`resolveFuncByName` /
the lowered-function registry). A generic free function is never
declared (it is a TEMPLATE in `fn_ast_map`, `fd.type_params.len > 0`,
monomorphized per call shape) — so the lookup misses and the call is
reported unresolved instead of routing through the generic machinery.
The fix likely: in the UFCS fallback, when the bare name resolves to a
`fn_ast_map` entry with `type_params.len > 0` (the same gate
`declareFunction` uses), rewrite to the direct-call shape and route
through the SAME generic-call path a direct `func(obj, args)` takes
(`mangleGenericName` + binding inference from args + monomorphize). The
direct spelling already works, so the machinery exists — the UFCS arm
just never reaches it. Mind the resolution order: scope locals and
protocol/struct methods must keep winning over a same-named free
template (mirror the existing concrete-UFCS precedence), and visibility
gating (import-graph) must apply to the template exactly as for
concrete fns.
Verification:
1. The repro above prints `1` twice, exit 0.
2. Matrix probe: generic-on-struct (`p.pick(i32)`), generic-on-slice
(`xs.first_of()`), generic-on-protocol-value
(`a.create(Session)` with `create :: (a: Allocator, $T: Type) -> *T`)
all dispatch; concrete UFCS unchanged.
3. `bash tests/run_examples.sh` — 582/582 baseline must hold
(UFCS-heavy suite: protocols, packs, List methods).
4. Pin the repro as a regression example per CLAUDE.md.
Context: BLOCKS MEM Phase 2.2 — the plan's memory helpers are "free
functions in mem.sx, UFCS-callable" with canonical call sites
`context.allocator.create(Session)` / `slice.clone(context.allocator)`
(plan Appendix A). The helpers themselves work via direct calls; the
step is paused rather than shipping a direct-call-only API that the
plan would immediately re-churn.

View File

@@ -1,161 +0,0 @@
# 0120 — aliasing a GENERIC struct head: silent `.unresolved`, backend panic
> **RESOLVED** (2026-06-11, same session — Agra-directed fix). Root
> cause: a const alias of a generic struct head was registered nowhere
> (`type_alias_map` holds TypeIds, `struct_template_map` only direct
> struct decls), and the head selector's miss fell through as
> `.not_generic`; the `.call`-node type resolver then returned
> `.unresolved` SILENTLY (its parameterized sibling diagnosed; it
> didn't). Fix, option 1 (support): `selectGenericStructHead` now
> follows const-alias decls (`aliasedStructTemplate` in
> `src/ir/lower/nominal.zig`) — own-wins / single-flat author, each hop
> resolved from the ALIAS AUTHOR's source (`namespaceAliasVerdictFrom`
> for `ns.X` RHS), depth-capped against cycles, checked BEFORE the
> template map so a facade's same-name re-export beats an invisible
> global template. Plus the missing diagnostic: an unknown `.call` type
> head now errors "unknown type 'X'" instead of silently poisoning
> (`resolveTypeCallWithBindings`). Alias-vs-alias flat collisions stay
> loud (not-visible diagnostic). Still unsupported, by scope:
> `ns.AliasName(..)` qualified heads (namespace member that is itself
> an alias). Regression test:
> `examples/0211-generics-struct-alias-head.sx` (+ `-rich.sx` /
> `-facade.sx` companions; pins same-file alias, method, chain,
> annotation, and the cross-module facade re-export). Gates: zig build
> test 426/426 (incl. fixing the PRE-EXISTING stale
> `calls.test.zig` UFCS plan test that predated 0119's opt-in model),
> suite 587/587.
## Symptom
`Alias :: Box;` where `Box` is a generic struct (`struct ($T: Type)`)
lowers without any diagnostic, and instantiating through the alias
(`Alias(i64).{ ... }`) reaches LLVM emission with an `.unresolved`
type — the backend tripwire panics:
```
panic: unresolved type reached LLVM emission — a type resolution
failure was not diagnosed/aborted
src/backend/llvm/types.zig:175 toLLVMTypeInfo
src/backend/llvm/ops.zig:1204 emitStructInit
```
Observed (one probe family, three manifestations of the same root):
- field access through the aliased instantiation → **backend panic**
(no front-end diagnostic at all);
- method call through the aliased instantiation (`b.get()`) →
misleading `unresolved 'get'` (the receiver's type never resolved);
- cross-module re-export (`facade.sx`: `Box :: r.Box;`, consumer
flat-imports facade) → consumer gets `type 'Box' is not visible;
#import the module that declares it` even though the alias is the
facade's OWN declaration.
Expected: one of the two, decided explicitly —
1. **Support it** (desirable): a const decl whose RHS names a generic
struct head (bare `Box` or qualified `r.Box`) binds the alias to the
SAME template; instantiation, methods, and one-level flat-import
carry behave exactly as the non-generic struct alias already does.
2. **Reject it loudly**: a decl-site diagnostic ("cannot alias a
generic struct head" or similar) at `Alias :: Box;`.
Silently lowering and panicking in the backend is neither — it is the
REJECTED-PATTERNS "silent unresolved" shape.
For contrast, both of these alias re-exports already WORK across one
flat-import hop (own-decl visibility): `helper :: r.helper;` (plain
fn) and `Thing :: r.Thing;` (non-generic struct, including its static
`init`). Only the generic head breaks. A fix must not regress these.
## Reproduction
Backend panic (primary):
```sx
#import "modules/std.sx";
Box :: struct ($T: Type) {
item: T;
}
BoxAlias :: Box;
main :: () {
b := BoxAlias(i64).{ item = 3 };
print("{}\n", b.item);
}
```
Method-call variant (front-end `unresolved 'get'`, same root):
```sx
#import "modules/std.sx";
Box :: struct ($T: Type) {
item: T;
get :: (b: *Box(T)) -> T { b.item }
}
BoxAlias :: Box;
main :: () {
b := BoxAlias(i64).{ item = 3 };
print("{}\n", b.get());
}
```
Cross-module variant (`rich.sx` declares `Box`; `facade.sx` has
`r :: #import "rich.sx"; Box :: r.Box;`; a consumer flat-importing
facade.sx gets `type 'Box' is not visible` at `Box(i64).{ ... }`).
## Investigation prompt
Generic structs live as TEMPLATES in
`src/ir/program_index.zig``struct_template_map`
(`StringHashMap(StructTemplate)`, registered by `registerStructDecl`;
a parallel `struct_template_by_decl` exists but isn't read for
selection yet). Instantiation resolves the head name against that map
in `src/ir/lower/nominal.zig` (see the qualified-head comments around
nominal.zig:357382) and monomorphizes via
`lower_generic.instantiateGenericStruct` (re-exported at
`src/ir/lower.zig:1820`).
`BoxAlias :: Box;` is a const decl whose RHS identifier names a
template, not a value or a concrete Type — const-decl lowering neither
registers `BoxAlias` as a template alias nor rejects the decl. The
instantiation head lookup for `BoxAlias` then misses, and the
`Name(args).{ ... }` path continues with an `.unresolved` struct type
instead of diagnosing the miss — that silent continuation is the bug
underneath all three manifestations, and fixing it is step one
regardless of the language decision: a struct_init whose head fails to
resolve must produce a hard diagnostic, never reach emission.
Then the language decision (confirm with Agra if option 2 is ever
preferred; the motivating use case wants option 1): when a const
decl's RHS resolves to a generic struct head — bare identifier or
`ns.X` through a namespace alias — register the alias name in the
template registry bound to the same `StructTemplate`, scoped to the
declaring module with ordinary own-decl visibility so one-level
flat-import carry works (mirror whatever makes `Thing :: r.Thing;`
re-export correctly today). Mind collision semantics (own-wins /
ambiguity) and that the alias must also work as a plain type head in
annotations (`x: BoxAlias(i64)`), nested generics
(`List(BoxAlias(i64))` if applicable), and method/UFCS dispatch on
instantiations through the alias.
Motivating context: the std.sx-as-pure-re-exports restructure wants
`List :: list.List;` in `modules/std.sx` (with `list :: #import
"modules/std/list.sx";`) so `List` stays bare-visible to std.sx's flat
importers. Plain fns and plain structs already re-export this way;
generic heads are the missing piece.
Verification:
1. Primary repro: prints `3`, exit 0 (option 1) — or a clean decl-site
diagnostic, no panic (option 2).
2. Matrix: method-call variant runs (`b.get()` → 3); cross-module
variant runs through the facade; `helper :: r.helper;` and
`Thing :: r.Thing;` re-exports unchanged; two facades carrying the
same alias name still diagnose ambiguity.
3. `bash tests/run_examples.sh` — full suite ok, zero failures.
4. Pin the repro as a regression example per CLAUDE.md.

View File

@@ -1,112 +0,0 @@
# 0121 — aliasing a comptime-pack fn (`..$args`): "unresolved '<alias>'"
> **RESOLVED** (2026-06-11, same session — Agra-directed). The symptom
> was broader than filed: RENAMED fn aliases failed for EVERY fn kind
> (plain `helper2 :: r.helper;` too) — the "plain fns verified working"
> claim below was a same-name confound (same-name re-exports resolve
> through the name-keyed global `fn_ast_map`, no alias mechanism
> involved; `my_pack :: r.my_pack;` already worked for packs too).
> Fix: fn aliases register at SCAN time — `scanDecls`' const-decl arm
> follows ident-/`ns.X`-RHS alias chains via `aliasedFnDecl`
> (nominal.zig; shares 0120's hop walk, now extracted as
> `followAliasChain`) and, when the chain terminates at a fn decl,
> registers the ALIAS name in `fn_ast_map` (absent-only — a real
> same-name fn keeps its slot). Every dispatch path reads that map
> (early pack/comptime/generic, plain lazy-lower, plan-side typing),
> so the alias dispatches exactly like the target with no per-path
> changes. Verified matrix: same-file pack alias (the repro), renamed
> plain / generic / pack through a facade, and `my_print :: s.print;`
> / `my_format :: s.format;` over std's real pack fns. Regression:
> `examples/0546-packs-fn-alias.sx` (+ `-rich.sx` companion). Gates:
> zig build test 0, suite 588/588.
## Symptom
A const alias of a function whose signature carries a comptime pack
(`..$args`) is not callable — every call through the alias fails with
`unresolved '<alias>'`. All three alias shapes fail identically:
- same-file bare: `sum_alias :: pack_sum;``unresolved 'sum_alias'`
- bare RHS over a flat import: `my_print :: print;` (std's `print`) →
`unresolved 'my_print'`
- namespace RHS: `my_print :: s.print;` with `s :: #import
"modules/std.sx";` → `unresolved 'my_print'`
Contrast (all verified working): plain concrete fns (`helper ::
r.helper;`), runtime-generic fns (`first_of :: r.first_of;` with
`(xs: []$T) -> T`), and — since 0120 — generic struct heads
(`List :: list.List;`). Only the comptime-pack shape misses.
Expected: the alias dispatches exactly like the target —
`my_print("x {}\n", 1)` behaves as `print("x {}\n", 1)`. (If fn
aliasing of pack fns is NOT meant to be promised, the hypothesis is
wrong and the decl or the call should get a clean tailored
diagnostic instead — but the std.sx-as-pure-re-exports restructure
wants `print :: fmt.print;` to work, so support is the desirable
outcome. Confirm with Agra only if support turns out prohibitively
deep.)
## Reproduction
```sx
#import "modules/std.sx";
pack_sum :: (..$args) -> i64 {
args[0] + args[1]
}
sum_alias :: pack_sum;
main :: () {
print("{}\n", sum_alias(3, 4)); // error: unresolved 'sum_alias'
}
```
Direct `pack_sum(3, 4)` works; only the aliased spelling fails.
## Investigation prompt
Comptime-pack calls (`..$args` — NOT slice-variadics `..xs: []T`)
dispatch EARLY in call lowering, keyed on the callee NAME against the
pack-fn template registry (the `fn_ast_map` entry whose FnDecl has a
comptime pack param; the early-dispatch gate lives in the call path —
`src/ir/lower/call.zig` / `src/ir/packs.zig`, grep for the pack-param
detection on the callee). An alias name has no `fn_ast_map` entry, so
the early pack dispatch misses; no later stage handles pack fns
(they cannot lower as ordinary declared functions — each call site
expands with its own bound pack), so the call falls through to the
generic `unresolved '<name>'`.
The fix likely: where the early pack dispatch resolves the callee
name, on a miss follow const-ALIAS decls to their target FnDecl and
dispatch with the TARGET's fd under the alias's call node. Issue
0120's fix added exactly this follow for generic STRUCT heads —
`aliasedStructTemplate` in `src/ir/lower/nominal.zig`
(`singleVisibleAuthor` + hop-by-hop `followToTemplate`, each hop
resolved from the ALIAS AUTHOR's source, `namespaceAliasVerdictFrom`
for `ns.X` RHS, depth-capped). Mirror that shape for fn targets — a
`followToFnDecl` sibling reusing `singleVisibleAuthor` (consider
extracting the shared walk) — and route the early pack dispatch
through it. Mind: own-wins / single-flat collision semantics must
match 0120's (≥2 flat alias authors → loud, no silent pick), and the
ufcs-alias map (`name :: ufcs target;`) is a DIFFERENT mechanism
(`ufcs_alias_map`) — don't conflate.
Verification:
1. The repro prints `7`, exit 0.
2. Matrix: same-file bare alias, bare RHS over a flat import
(`my_print :: print;`), namespace RHS (`my_print :: s.print;` /
`my_format :: s.format;` — formats AND returns a value), and a
consumer one flat hop from the aliasing facade.
3. Plain-fn and generic-fn aliases unchanged (examples 0211 family).
4. `bash tests/run_examples.sh` — 587/587 baseline must hold; pin the
repro as a regression example per CLAUDE.md.
Context: BLOCKS the std.sx-as-pure-re-exports restructure — `print` /
`format` are the prelude's most-used names and are exactly this shape
(`($fmt: string, ..$args)`). Generic struct heads (`List`) were
unblocked by 0120; this is the remaining known gap. Still unprobed
for the restructure (next session, after this fix): protocol aliases
(`Allocator`, parameterized `Into`), `#builtin` decl aliases
(`size_of`, `out`, `string :: []u8`), `extern` decl aliases
(`memcpy`).

View File

@@ -1,64 +0,0 @@
# 0122 — whole-program passes resolve/diagnose under a stale ambient source
> **RESOLVED** (2026-06-11, same session — found and fixed during the
> std.sx-as-pure-re-exports restructure, Agra-directed). Three
> whole-program passes ran under whatever `current_source_file` the
> previous pipeline phase happened to leave behind, instead of pinning
> the context per declaration:
>
> 1. `ErrorAnalysis.convergeClosureShapeSets` (error_analysis.zig) —
> resolves closure-literal param/return annotations; a stale context
> made example-declared nominal types (`Point`, `Color`) fail the E4
> visibility gate with `type 'X' is not visible` attributed to
> nonsense std.sx spans. Fixed: pin `setCurrentSourceFile` per
> `fn_ast_map` entry from `body.source_file` (already stamped by
> resolveImports).
> 2. `ErrorFlow.checkErrorFlow` (error_flow.zig) — the flow walk
> resolves types via `inferExprType` AND emits its reject
> diagnostics; both used the ambient file. Fixed: pin per decl.
> 3. The `UnknownTypeChecker` unknown-type loop
> (semantic_diagnostics.zig) — emitted with the ambient file
> (`checkBindingNames` beside it already saved/restored per node).
> Fixed: pin `diagnostics.current_source_file` per decl.
>
> Latent on master for all three — the ambient just happened to be the
> main file with the old single-file std.sx; the restructured std.sx
> (namespace part-file imports) reordered the pipeline's last-touched
> module and exposed them. Pinned coverage: examples 0129 / 1047 /
> 1049 / 1052 / 1053 / 1056 (closure shapes with nominal types,
> error-flow reject attribution) fail without the fixes once std.sx is
> the re-export facade. Gates: zig build test 426/426, suite 588/588.
## Symptom
With a std.sx whose first declarations are namespace imports, programs
using closures with user-struct parameter types failed
`type 'Point' is not visible; #import the module that declares it`
attributed to meaningless std.sx spans, and error-flow / unknown-type
diagnostics for main-file code rendered against std.sx's line table
(e.g. expected `examples/foo.sx:22:21`, got `std.sx:16:25`).
## Reproduction
Against the pre-fix compiler with the re-export std.sx:
```sx
#import "modules/std.sx";
Point :: struct { x, y: i32; }
main :: () {
f := closure((p: Point) -> Point => Point.{ x = p.x + 1, y = p.y });
r := f(Point.{ x = 1, y = 2 });
out("done\n");
}
```
## Investigation prompt
(Resolved — kept for the record.) The root pattern: any pass that runs
after module scanning and either resolves source-gated names or emits
diagnostics MUST pin the visibility/rendering context per declaration
(`setCurrentSourceFile(decl.source_file)` — syncs the lowering context
and the diagnostics renderer), never inherit the ambient. Fn bodies
carry `body.source_file` (stamped by resolveImports) for fn-keyed
walks. When auditing for siblings, check every `lowerRoot` phase that
walks `fn_ast_map` or the program decl list.

View File

@@ -1,104 +0,0 @@
# 0123 — wrong arg counts to fixed-arity fns reach LLVM emission
> **RESOLVED** (2026-06-12). Root cause: no dispatch path in `lowerCall`
> ever compared the supplied arg count against the callee's declared
> params (`coerceCallArgs` iterates `@min(args.len, params.len)`, so a
> mismatch sailed through to the LLVM verifier). Fix: a shared
> `checkCallArity` (src/ir/lower/call.zig) computes min (params without
> trailing defaults) / max (`params.len`, unbounded past a variadic)
> from the AST decl and rejects with a source-located diagnostic at the
> five plain dispatch sites — bare selected-author + lazy, namespace
> alias-gate + qualified, struct-method, ufcs. Pack / comptime / generic
> / `#compiler` / `#builtin` callees are exempt (own dispatch). The
> method/ufcs sites also gained the `appendDefaultArgs` fill the
> generic-instance leg already had — trailing defaults on dot-calls
> previously emitted under-arity calls (same verifier failure). Flushed
> out en route: `lowerStmt`'s `.fn_decl => |fd| ... (&fd)` registered a
> STACK address in `fn_ast_map`, so every local fn's map entry aliased
> the most recently lowered one — pointer capture (`|*fd|`) fixes it.
> Regressions: `examples/1167-diagnostics-call-arity-mismatch.sx`
> (too many / too few, bare + stdlib + method + ufcs) and
> `examples/0054-basic-dot-call-default-args.sx` (dot-call defaults,
> variadic, `#caller_location`). Gates: zig build test 426/426, suite
> 590/590 (fix in isolation), distribution repo 14/14.
## Symptom
Calling a fixed-arity function with the wrong number of arguments is
not rejected by the frontend — the mismatched argument list flows all
the way to LLVM emission, which fails verification instead of a
source-located diagnostic.
- **Observed**: `LLVM verification failed: Incorrect number of
arguments passed to called function!` plus the raw IR call line —
no file/line/snippet, no callee name in user terms.
- **Expected**: a compile error at the call site naming the callee
and its declared arity (matching the style of existing
diagnostics, e.g. the "unresolved ..." errors with source
snippets).
Both directions are broken, on every plain dispatch path probed:
- too MANY args, bare call: `concat("a", "b", "c")` (std's `concat`
takes 2 strings) → LLVM verifier failure.
- too FEW args, bare call: `add2(1)` with `add2 :: (a: i64, b: i64)`
→ same.
- methods / ufcs dot-calls: same shape, receiver included. Worse:
a trailing-default param on a plain struct method or a ufcs fn is
never filled on the dot-call path (`p.scaled()` with
`scaled :: (self: Point, k: i64 = 2)` emits a 1-arg call to a
2-param fn — bare calls fill defaults via `expandCallDefaults`,
the method/ufcs sites never run `appendDefaultArgs`).
Legitimate flexible shapes must keep working: slice variadics
(`..xs: []T` — no upper bound), comptime/protocol packs (`..$args` /
`..xs: P` — own dispatch), default-valued params (incl.
`loc: Source_Location = #caller_location`), generic `$T` fns
(explicit vs inferred type args make the count flexible), `extern`
C variadics, `#compiler` / `#builtin` bodies.
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i32 {
s := concat("a", "b", "c"); // concat takes (a: string, b: string)
out(s);
return 0;
}
```
Observed at master b3b78e2: compiles past resolution, dies at LLVM
verification with the verifier message above.
## Investigation prompt
Call argument binding never compares the supplied arg count against
the callee's declared parameter list. Suspected area: the plain
direct-dispatch sites in `src/ir/lower/call.zig` (`lowerCall`) — the
bare-identifier selected-author and lazy-lower legs, the
namespace-qualified legs, the qualified struct-method leg, and the
ufcs leg. All of them run `packVariadicCallArgs` /
`coerceCallArgs` and emit `builder.call` without an arity check;
`coerceCallArgs` iterates `@min(args.len, params.len)` so a mismatch
sails through to the emitter.
The fix likely needs a shared `checkCallArity(fd, name, supplied,
has_receiver, span)` helper consulted at each plain dispatch site,
computing min (params without trailing defaults) / max
(`fd.params.len`, unbounded when a variadic param exists) from the
AST decl and emitting via `self.diagnostics.addFmt(.err, span, ...)`
on violation. Pack (`isPackFn`), comptime (`hasComptimeParams`),
generic (`type_params.len > 0`), `#compiler`, and `#builtin` callees
bind args through their own dispatch and must be exempt. The
method/ufcs sites also need `appendDefaultArgs` (the generic
instance-method leg already runs it) so trailing defaults fill
before the count is meaningful.
Verification: the repro errors with a source-located diagnostic
naming `concat` and its 2-arg signature; too-few errors likewise;
variadic / pack / default / ufcs / generic calls keep compiling
(`print`, `format`, `List.append`, `#caller_location` defaults).
`zig build && zig build test`, `bash tests/run_examples.sh` all
green; pin the repro as a diagnostics example per CLAUDE.md.

View File

@@ -1,108 +0,0 @@
# RESOLVED — 0124: 64K+ stack arrays emit whole-aggregate load/store ops that segfault LLVM
> **RESOLVED** (2026-06-12). Root cause: two lowering sites materialized
> a local array as a first-class LLVM value, which the legalizer
> scalarizes into one SelectionDAG node per element. Fix: (1)
> `lowerVarDecl` (src/ir/lower/stmt.zig) emits NO store for an
> array-typed `---` initializer — the slot stays uninitialized instead
> of receiving a whole-array undef store (tuple zero-init carve-out
> kept; non-array `---` keeps the undef store); (2) `lowerIndexExpr`
> (src/ir/lower/expr.zig) reads elements of an array with addressable
> storage via `index_gep` on the storage + a single-element load — the
> general-expression sibling of 0110's `lowerFor` fix — without
> value-lowering the object (a dead whole-array load would still reach
> the DAG). Storage-less arrays (rvalues, by-value params) keep the
> `index_get` fallback. Residual sibling shapes filed as issue 0125
> (`any_to_string`'s per-array-type arms pass the array by value — any
> 64K+ array type + any `{}` print still crashes).
> Regression test: `examples/0055-basic-large-stack-array.sx`
> ([65536]u8 write/read loops + [131072]i64 first/last — `sx build`
> segfaulted pre-fix). 22 `.ir` snapshots re-pinned (removed undef
> stores / `ig.tmp` spills → in-place gep+load; reviewed
> instruction-shape-only). Gates: zig build test 426/426, suite
> 592/592, distribution repo 14/14.
## Symptom
Declaring a large (~64KB+) stack array in a function reachable from
`main` crashes the compiler during native emission — a segfault inside
libLLVM, not a diagnostic.
- **Observed**: `Segmentation fault at address 0x16b...` (a stack
address) under `sx build`, inside
`DAGCombiner::visitMERGE_VALUES``SelectionDAG::ReplaceAllUsesWith`
(via `LLVMTargetMachineEmitToFile`, src/ir/emit_llvm.zig:2894).
- **Expected**: the program compiles; the array lives in the frame and
is accessed in place.
The crash threshold is DAG-shape dependent, not a clean size boundary
(`[65535]u8` and `[65537]u8` compile, `[65536]u8`, `[66000]u8`,
`[131072]u8` crash), because the real problem is the SelectionDAG
node count: lowering materializes the array as a FIRST-CLASS LLVM
value, and the legalizer scalarizes each whole-aggregate op into one
node per element. Two emission shapes produce such ops:
1. `buf : [N]u8 = ---;` stores a whole-array undef constant
(`store [N x i8] undef, ptr %alloca`) — a store of nothing, for an
explicitly-uninitialized local.
2. `buf[i]` reads on a local array lower as `index_get` on the array
VALUE: load the entire array as an SSA value, spill it to an
`ig.tmp` alloca, GEP one element (the general-expression sibling of
resolved issue 0110, which fixed only `lowerFor`'s element fetch).
Besides the crash, this copies N bytes to read 1.
Each shape crashes llc in isolation on the dumped IR; with both
replaced by in-place access the module compiles.
## Reproduction
```sx
#import "modules/std.sx";
f :: (fd: i32) {
buf : [65536]u8 = ---;
if buf[0] > 0 { out("x\n"); }
}
main :: () -> i32 {
f(1);
return 0;
}
```
Observed at master 7f2b8b5: `sx build` segfaults in libLLVM with the
stack trace above. `sx ir` shows the two whole-aggregate ops:
```llvm
%alloca1 = alloca [65536 x i8], align 1
store [65536 x i8] undef, ptr %alloca1, align 1
%load = load [65536 x i8], ptr %alloca1, align 1
%ig.tmp = alloca [65536 x i8], align 1
store [65536 x i8] %load, ptr %ig.tmp, align 1
%ig.ptr = getelementptr [65536 x i8], ptr %ig.tmp, i64 0, i64 0
```
## Investigation prompt
Two lowering sites produce the whole-aggregate ops; fix both:
1. `src/ir/lower/stmt.zig` `lowerVarDecl` (annotated branch): a
`.undef_literal` initializer falls through to
`lowerExpr(val)``constUndef(array type)``store`. `---` means
explicitly uninitialized — emit NO store at all (keep the existing
tuple zero-init carve-out above it).
2. `src/ir/lower/expr.zig` `lowerIndexExpr`: when the indexed object
is an array with addressable storage (`getExprAlloca` hit, same
guard as 0110's `lowerFor` fix), emit `index_gep` on the storage +
a single-element `load` instead of `index_get` on the loaded array
value. Storage-less arrays (rvalues) keep the `index_get` fallback.
The object must NOT be lowered as a value on the storage path or
the dead whole-array `load` still reaches the DAG.
Verification: the repro builds and runs (prints nothing or `x`
depending on stack garbage — gate on exit 0 of the build, not the
read); `[65535]`/`[65537]`/`[131072]` variants all build. Pin a
regression example that builds AND deterministically runs (write
before read). `zig build && zig build test`,
`bash tests/run_examples.sh` green; expect `.ir` snapshot churn from
removed undef stores and the new gep+load shape — re-pin and review.

View File

@@ -1,101 +0,0 @@
# 0125 — any_to_string's array arms materialize every interned array type by value
> **RESOLVED (2026-06-19).** Root cause as described below: the type-match
> dispatcher (`lowerRuntimeDispatchCall`, src/ir/lower/call.zig) unboxed each
> interned array tag to the concrete array type — a whole-array load — and fed it
> to `array_to_string` by value, which LLVM scalarized to one DAG node per element.
> **Fix (route 1):** `any_to_string`'s `case array:` arm now calls `slice_to_string`
> (library/modules/std/fmt.sx); the dispatcher detects an ARRAY tag bound to a SLICE
> param and builds a `{ptr,len}` slice VIEW of the payload pointer (`unbox_any →
> [*]elem` is an int-to-ptr with NO load, paired with the array length) instead of
> loading the array. Output is byte-identical (`[a, b, c]`). The repro compiles fast
> and prints correctly; pinned as `examples/0056-basic-large-array-format-no-blowup.sx`.
## Symptom
A program that (a) interns any large (~64KB+) array type and (b) uses
`{}` formatting anywhere — `print("{}\n", 5)` of a plain int is enough —
crashes `sx build` inside libLLVM (`DAGCombiner::visitMERGE_VALUES`
`SelectionDAG::ReplaceAllUsesWith`), and makes `sx run` (-O0) take ~18s
to compile a trivial file. The two triggers are independent: the array
need never be printed, sliced, or passed anywhere near the format call.
- **Observed**: segfault under `sx build`; multi-second compiles under
`sx run`.
- **Expected**: formatting an int is unaffected by an unrelated large
array type; printing the array itself formats in place.
Root cause shape: `any_to_string`'s comptime type-switch
(library/modules/std/fmt.sx, `case array:` arm) expands one arm per
interned array type, and each arm is
`array_to_string(cast(type) val)`:
1. the `cast(type) val` unbox loads the WHOLE array from the Any
payload pointer (`coerceFromI64`, src/ir/emit_llvm.zig ~2240,
`ua.load`),
2. the call passes the array BY VALUE to the `array_to_string` mono,
3. the mono spills its by-value param to an alloca and (since the
param is an SSA value, not addressable storage) reads elements via
`index_get` on the value — copy-whole-array per element.
LLVM's legalizer scalarizes each whole-aggregate op into one
SelectionDAG node per element; at ~64K elements the DAG combiner
recurses to death (the sibling of issue 0124, which fixed the
local-variable shapes: `---` undef store and index reads on
addressable storage).
## Reproduction
```sx
#import "modules/std.sx";
f :: () {
buf : [65536]u8 = ---;
buf[0] = 1;
out(string.{ ptr = @buf[0], len = 1 });
}
main :: () -> i32 {
f();
print("{}\n", 5);
return 0;
}
```
Observed (with 0124's fix in place): `sx build` segfaults in libLLVM;
`sx ir` shows the giant arm inside `@any_to_string`:
```llvm
%ua.load = load [65536 x i8], ptr %ua.ptr, align 1
%call = call { ptr, i64 } @array_to_string__AR_65536_u8(ptr %0, [65536 x i8] %ua.load)
```
## Investigation prompt
The fix needs the array formatting chain to never materialize the
array as a first-class value. The Any payload for an array IS a
pointer to its storage (that is what `coerceFromI64` intToPtr+loads),
so the arm has everything it needs to format in place. Plausible
routes, most contained first:
1. Lower the `case array:` arm to a slice view: box the payload
pointer + the array's element count as a `[]elem` and call
`slice_to_string` (slices unbox as a 16-byte {ptr,len} — no giant
ops). Needs the element type at arm-expansion time — the comptime
type-switch already has the concrete array TypeId in hand; an
`element_type(T)`-style comptime accessor may need to be added for
the sx-level spelling, or the arm can be synthesized in the
compiler where both pieces are known.
2. Teach `array_to_string :: (a: $T)` monos (and the unbox `cast`) an
indirect ABI for array-typed params — bigger blast radius: touches
call emission, param spills, and many `.ir` snapshots.
Suspected files: src/ir/lower/comptime.zig / lower/call.zig (the
type-switch arm expansion and `cast(type)` lowering),
src/ir/emit_llvm.zig `coerceFromI64`,
library/modules/std/fmt.sx (`any_to_string`, `array_to_string`).
Verification: the repro builds and runs printing `5`; printing the
array itself (`print("{}\n", buf)` on a small array) still renders
element lists (pinned by 0101/0904 et al.); `zig build test` and
`bash tests/run_examples.sh` green; the repro pinned as an example.

View File

@@ -1,86 +0,0 @@
# RESOLVED — 0126: array arg at a `[]$T` param leaves T unbound → LLVM emission panic
> **RESOLVED** (2026-06-12). Root cause: `extractTypeParam`'s
> `.slice_type_expr` arm (src/ir/lower/generic.zig) only extracted from
> `.slice`-typed args, so an array arg left `T` unbound and
> `monomorphizeFunction` stamped `.unresolved` through the body — no
> diagnostic before the emitter's sentinel panic. Fix: (1) the arm also
> extracts from `.array` args via the array's element type, mirroring
> the array→slice promotion concrete slice params perform (the existing
> coercion then handles the lowered arg); (2) `lowerGenericCall`
> (src/ir/lower/call.zig) diagnoses any still-uninferrable TYPE param
> at the call site ("cannot infer generic type parameter ...") instead
> of monomorphizing unbound — covers the deliberate string-at-`[]$T`
> gap, which used to hit the same panic. Comptime value params and
> `..$Ts` packs stay exempt. Regression tests:
> `examples/0212-generics-array-arg-slice-param.sx` (scalar/u8/struct
> elements + slice spelling; panicked or mis-typed pre-fix) and
> `examples/1168-diagnostics-generic-param-uninferrable.sx` (string
> arg; panicked pre-fix). Gates: zig build test 426/426, suite 594/594,
> distribution repo 14/14.
## Symptom
Calling a generic function whose param is a slice of the type param —
`first :: (xs: []$T) -> T` — with an ARRAY argument panics the compiler
during emission instead of compiling (or diagnosing).
- **Observed**: `panic: unresolved type reached LLVM emission — a type
resolution failure was not diagnosed/aborted`
(src/backend/llvm/types.zig:175, via `emitIndexGet` in the
monomorphized body).
- **Expected**: the array coerces to a slice at the `[]T` param — the
same promotion a CONCRETE `[]i64` param (and a `[]i64`-annotated
local) already performs — so `T` binds from the array's element type
and the call compiles.
Passing an actual slice works (`s : []i64 = a; first(s)` prints the
element); only the direct array spelling breaks, and only for generic
slice params.
## Reproduction
```sx
#import "modules/std.sx";
first :: (xs: []$T) -> T {
return xs[0];
}
main :: () -> i32 {
a : [3]i64 = ---;
a[0] = 7; a[1] = 8; a[2] = 9;
v := first(a);
print("{}\n", v);
return 0;
}
```
Observed at master 837b5d3: the panic above. With `s : []i64 = a;
first(s)` it prints `7`.
## Investigation prompt
Root cause: `extractTypeParam` (src/ir/lower/generic.zig, the
`.slice_type_expr` arm) only extracts when the ARG type is itself a
`.slice` — an `.array` arg returns null, so `buildTypeBindings` leaves
`T` unbound, `monomorphizeFunction` stamps the body with `T →
.unresolved`, and nothing diagnoses before the emitter's sentinel
tripwire fires (the declaration-time gate from ca5bd52 doesn't apply:
this `T` IS bindable — the call-site inference is what failed).
Fix: mirror the existing array→slice param coercion in the binding
extractor — in the `.slice_type_expr` arm, when the arg type is an
`.array`, recurse on the array's ELEMENT type exactly as the `.slice`
case does. Verify the lowered call then coerces the array arg to the
mono's now-concrete `[]T` param (the same `array_to_slice` the
concrete path uses) — if not, the generic dispatch arg path needs the
same promotion.
Out of scope, known gap (CHECKPOINT-MEM): `string` deliberately does
not bind `[]$T` — that case diagnoses "unknown type 'T'" and its
story is deferred to the mem-stream phases.
Verification: the repro prints `7`; the slice spelling still works;
`zig build && zig build test`, `bash tests/run_examples.sh` green;
pin the repro as a generics example.

View File

@@ -1,80 +0,0 @@
# RESOLVED — 0127: namespaced generic call's result mis-types as the unbound `T` stub
> **RESOLVED** (2026-06-12). Root cause: the call-PLAN producer's
> namespace-fn arms (src/ir/calls.zig, the `fn_ast_map`-backed qualified
> and bare-name fallbacks) returned the DECLARED return type — `T`,
> resolving to the unbound stub — without checking `type_params`, while
> the bare-identifier path routes generics through
> `inferGenericReturnType`. Lowering dispatched the right mono (the
> value was correct); only the planned result type was wrong, so
> pack-fn callers (print's Any boxing) mis-tagged it — and a non-i64
> binding (f64) failed LLVM verification outright, the pack being
> monomorphized for the stub while the call returned `double`. Fix:
> both arms now classify a `type_params.len > 0` callee as
> `.generic_fn` and infer the return type through the call's bindings,
> mirroring the flat path. Regression test:
> `examples/0213-generics-namespaced-call-result.sx` (i64 + f64
> bindings via print, concrete type flowing into arithmetic; pre-fix:
> `T{}` boxing / LLVM verification failure — both demonstrated).
> Gates: zig build test 426/426, suite 595/595, distribution repo
> 14/14.
## Symptom
A NAMESPACED call to a generic free function returns a value typed as the
unbound `T` empty-struct stub instead of the bound concrete type.
- **Observed**: `print("{}\n", m.pick(3, 9))` prints `T{}` — the Any boxing
tags the result with the unbound `T` stub type.
- **Expected**: prints `9`.
The same call through a FLAT import (`pick(3, 9)`) prints `9` — only the
namespaced spelling (`field_access` callee whose object is a namespace
alias) mis-types the result.
## Reproduction
m.sx:
```sx
#import "modules/std.sx";
pick :: (a: $T, b: T) -> T {
if a > b then a else b
}
```
main.sx:
```sx
#import "modules/std.sx";
m :: #import "m.sx";
main :: () {
print("{}\n", m.pick(3, 9));
}
```
Observed at master 309f48e: prints `T{}`. The flat-import spelling of the
same call prints `9`.
## Investigation prompt
Suspected area: the namespaced/qualified dispatch leg in
`src/ir/lower/call.zig` `lowerCall` (the qualified `fn_ast_map` paths)
and/or the generic return-type inference not being consulted for the
namespaced callee shape. The plain-identifier path routes the call's
result typing through `buildTypeBindings` (the one binding builder —
see a47ea14's note); the namespaced path appears to stamp the declared
return type (`T`) without substituting the call's inferred bindings, so
the result carries the unbound-`T` stub struct into Any boxing.
Fix: make the namespaced generic call derive its result type from the
call's inferred `$T → concrete` bindings exactly like the flat path.
Check whether the value itself is computed correctly (the mono body) and
only the recorded result TYPE is wrong, or whether dispatch skipped
monomorphization entirely.
Verification: the repro prints `9`; the flat spelling still prints `9`;
`zig build && zig build test`, `bash tests/run_examples.sh` green; pin
the repro as a generics example (next free 02xx number).

View File

@@ -1,127 +0,0 @@
# RESOLVED — 0128: `[:0]u8` at FFI boundaries — conflicting symbol views, garbage string returns
> **RESOLVED** (2026-06-12). Investigation corrected the filing: the
> "silent `u8` return" and the "`?[:0]u8` unresolved panic" were BOTH
> artifacts of the reproducers binding the C symbol `getenv`, which
> std/process.sx already declares as `-> *u8` — the FIRST registration
> of a C symbol silently won and every call through the later
> declaration was typed by the older signature (`*u8`), cascading into
> the panic. `?[:0]u8` itself resolves correctly (it is `?string`).
> The two GENUINE defects, both fixed:
>
> 1. **Conflicting same-symbol redeclaration was silent.**
> `dedupeExternSymbol` (src/ir/lower/decl.zig) now runs at extern
> registration: an EQUAL signature shares the first registration's
> FuncId; a CONFLICTING one is diagnosed ("extern symbol '<s>' is
> already bound with a different signature").
> 2. **Extern `-> string` / `-> ?string` returns read garbage.** The
> C side returns ONE `char *`; the LLVM signature declared the fat
> `{ptr,i64}` (len = register garbage; bus error on use), and
> `?string` (24 B struct) was mis-declared SRET — the hidden
> out-pointer landed in the C callee's first argument register.
> Now: such returns are classified by `cstrRetKind`
> (src/ir/emit_llvm.zig), declared as plain `ptr` returns (never
> sret), and the call site synthesizes the sx value via
> `cstrReturnToSx`: `{ptr, strlen(ptr)}` with the strlen call
> branch-guarded (NULL → `{null,0}`), wrapped in `{string, i1}`
> with `has = ptr != null` for the optional.
>
> Regression tests: `examples/1221-ffi-cstring-returns.sx` (plain +
> optional non-null via strerror/strsignal + optional NULL via
> dlerror) and `examples/1172-diagnostics-extern-symbol-conflict.sx`
> (the getenv conflict); both FAIL on pre-fix master. The extern
> dedupe changes IR snapshots (duplicate libc decls collapse), so the
> affected `.ir` files were regenerated. Gates: zig build test
> 426/426, tests/run_examples.sh 602/602, distribution repo 21/21.
> Boundary: comptime-interp (`#run`) extern calls are untouched, and
> indirect (fn-pointer) extern calls don't synthesize — both can
> follow if ever needed.
## Design contract (Agra, 2026-06-12)
`?[:0]u8` should lower to a `char *` at the FFI boundary — a single
nullable thin pointer — NOT a fat pointer. This is the natural sx type
for every libc/sqlite-style API that returns a nullable C string
(`getenv`, `sqlite3_errmsg`, `sqlite3_column_text`, ...).
## Current state (verified at master 1bc60d3)
`[:0]u8` is an ALIAS for `string` (src/types.zig:145 — the `'['` arm
returns `.string_type`), i.e. a fat ptr+len value at the sx level. At a
`extern` PARAM position the C-ABI lowering already thins it: sx
`string`/slices coerce to a single pointer and the length is dropped
(src/backend/llvm/abi.zig, the `is_extern_c_api` knob) — so
`popen :: (cmd: [:0]u8, ...)` works and matches the design contract.
The other two boundary positions are broken:
### Defect A — `-> [:0]u8` extern RETURN silently resolves to `u8`
```sx
#import "modules/std.sx";
libc :: #library "c";
getenv_s :: (name: [:0]u8) -> [:0]u8 extern libc "getenv";
main :: () -> i32 {
v := getenv_s("PATH");
print("len={}\n", v.len); // error: field 'len' not found on type 'u8'
return 0;
}
```
The declared return type `[:0]u8` resolves to plain `u8` — not the
`string` alias, not an error. Whatever the return-position type-name
path is, it disagrees with the table in types.zig and fails SILENTLY
into a wrong type (the silent-fallback-default class this repo's
CLAUDE.md forbids).
### Defect B — `?[:0]u8` does not resolve; panics LLVM emission
```sx
#import "modules/std.sx";
libc :: #library "c";
getenv_opt :: (name: [:0]u8) -> ?[:0]u8 extern libc "getenv";
main :: () -> i32 {
p := getenv_opt("PATH");
if p == null { return 1; }
return 0;
}
```
Panics: "unresolved type reached LLVM emission"
(src/backend/llvm/types.zig:175). Meanwhile plain `?string` resolves
and works at the sx level — the `'?'` arm (types.zig:141) stores
`child_name = "[:0]u8"`, and whatever resolves the child later doesn't
go through the same table that maps the bracket spelling to
`string_type`. Minimum bar even without the feature: a diagnostic, not
a panic.
## Fix shape
1. Make the bracket spelling resolve identically everywhere — return
position and optional child position must hit the same alias table
that param position does (Defect A and the resolution half of B).
2. Implement the boundary contract for returns: a extern
`-> [:0]u8` / `-> ?[:0]u8` receives ONE pointer from C; the sx-side
`string` is built by synthesizing the length (strlen) at the
boundary, and for the optional a NULL pointer maps to `null`.
If (2) is deferred, extern string/optional-string RETURNS must be
rejected with a diagnostic naming the workaround (`?*u8`).
## Workaround in use
/Users/agra/projects/distribution/src/db/sqlite.sx declares every
nullable C-string return as `?*u8` (null-pointer niche verified working
in both JIT and AOT) and copies via a manual strlen helper; its header
comment cites this issue's gap. std's own `getenv :: -> *u8` +
cast-check is the same dodge. Both can migrate to `?[:0]u8` once the
contract lands.
## Verification
Both reproducers behave: A prints a real PATH length; B answers null /
non-null correctly for missing / present variables. `zig build test`,
`tests/run_examples.sh` green; pin both as examples.

View File

@@ -1,68 +0,0 @@
# RESOLVED — 0129: `if !e` held on a SET error binding (logical not lowered bitwise)
> **RESOLVED** (2026-06-12). Root cause: the `.not` arm of unary
> lowering (src/ir/lower/expr.zig) emitted `bool_not` — LLVM's bitwise
> `Not` — for EVERY operand type. On a real `i1` bool that is logical
> not; on an error binding (an error-set value, a u32 tag at the LLVM
> level) a bitwise not of a nonzero tag is still nonzero, so the branch
> condition stayed truthy: `if e` and `if !e` BOTH held on a set error,
> and the `!e` branch read the uninitialized success value. Plain
> integers had the same hole (`!7` was `~7` — truthy). Fix: `!` is now
> truthiness-aware — bool keeps `bool_not`; integers and error-set
> values lower as the complement `operand == 0` (`cmp_eq` against a
> typed zero); any other operand type is DIAGNOSED ("'!' needs a bool,
> integer, or error operand") instead of silently bit-flipped.
> Regression tests: `examples/1057-errors-negated-error-binding.sx`
> (set error: `!e` must not hold; success: `!e` holds with a real
> value; `!7`/`!0` integer truthiness) and
> `examples/1171-diagnostics-logical-not-bad-operand.sx` (`!"text"`
> diagnosed); both FAIL on pre-fix master. Gates: zig build test
> 426/426, tests/run_examples.sh 600/600.
## Symptom
`if e { ... }` on an error binding from a value-carrying failable
correctly tests "error is set", but `if !e { ... }` evaluates TRUE even
when the error IS set — both branches run, and the success value read
in the `!e` branch is uninitialized garbage.
Hit in production: /Users/agra/projects/distribution
`tests/sqlite_api.sx` (2026-06-12) — a `close()` behind `if !ne`
segfaulted on a garbage handle. The interim workaround routed negated
error logic through a plain bool.
## Reproduction
```sx
#import "modules/std.sx";
E :: error { Boom }
f :: (fail: bool) -> (i64, !E) {
if fail { raise error.Boom; }
return 42;
}
main :: () -> i32 {
v, e := f(true);
if e { print("error set\n"); }
if !e { print("BUG: !e true on a set error (v={})\n", v); return 1; }
return 0;
}
```
Observed at master ba37d0b: prints both lines, v is garbage, exits 1.
Expected: prints only "error set", exits 0.
## Investigation prompt
Suspected area: the unary `.not` lowering in src/ir/lower/expr.zig —
it emits `bool_not` (src/backend/llvm/ops.zig `emitBoolNot`
`LLVMBuildNot`, a bitwise xor-with-all-ones) regardless of operand
type. Error bindings are error-set values backed by a u32 tag
(src/backend/llvm/types.zig lowers `.error_set` to i32), so `~tag` of
a set error is nonzero and the condition holds. Fix: make `!`
truthiness-aware (complement-of-zero for integer-backed operands), or
diagnose non-bool operands; silent wrong evaluation is the worst of
both. Verify with the repro plus an integer-truthiness case, and run
zig build test + tests/run_examples.sh.

View File

@@ -1,116 +0,0 @@
# RESOLVED — 0130: `#library` declared behind two aliased imports is dropped — no `-l` flag, no JIT dlopen
> **RESOLVED** (2026-06-12). Root cause as filed: `extractLibraries`
> and `extractFrameworks` (src/main.zig) walked the merged root's
> decls plus exactly ONE `namespace_decl` level, while aliased imports
> nest namespaces arbitrarily deep — a `#library`/`#framework` two or
> more aliases down never reached the AOT link args or the JIT dlopen
> loop. Both walks are now recursive over `namespace_decl` children
> (same `seen`-set dedup as before). Regression test:
> `examples/1617-modules-library-nested-namespace.sx` (+ its module
> dir) — `main → b :: #import → c :: #import` where c.sx declares
> `#library "pcap"`; libpcap is NOT in the compiler process's loaded
> images (unlike libz/libbz2/libsqlite3, which CoreServices/LLVM pull
> in and which mask the bug under `sx run`), so the pre-fix JIT fails
> symbol materialization and the pre-fix AOT link dies with undefined
> `_pcap_lib_version`. Gates: zig build test 426/426,
> tests/run_examples.sh 605/605, distribution repo `make test` 21/21
> at its HEAD plus a successful `make build` of its P5.2 branch state
> (dist.sx → ops → db → sqlite, the original failing chain).
## Symptom
A `#library` declaration in a module that is reached through TWO (or
more) levels of aliased `#import` never makes it into the build's
library list: `sx build` emits no `-l<name>` on the link line (link
fails with `Undefined symbols` for every `extern` fn of that
library), and `sx run` skips the dlopen of that library (the JIT then
resolves the extern symbol only if some already-loaded image happens
to export them).
- Observed: `main → b :: #import "b.sx" → c :: #import "c.sx"` where
c.sx declares `zlib :: #library "z"` → link line ends `-lc` (no
`-lz`), `ld: symbol(s) not found: _zlibVersion`.
- Expected: every `#library` reachable through the import graph is
linked (AOT) / dlopened (JIT), regardless of import depth or
aliasing.
- Control: aliasing c.sx DIRECTLY from main (one namespace level)
produces `-lz` and links fine. A plain (unaliased) `#import` of c.sx
from a module that main aliases also works — the merged decls sit at
one namespace level.
## Reproduction
Three files in one directory; build `a.sx`.
```sx
// c.sx — declares the library + a extern fn
#import "modules/std.sx";
zlib :: #library "z";
zlibVersion :: () -> ?cstring extern zlib "zlibVersion";
zver :: () -> string {
p := zlibVersion();
if p == null { return ""; }
return from_cstring(p!);
}
```
```sx
// b.sx — first namespace level
#import "modules/std.sx";
c :: #import "c.sx";
ver_via_b :: () -> string { return c.zver(); }
```
```sx
// a.sx — main; c.sx's library now sits two namespace levels deep
#import "modules/std.sx";
b :: #import "b.sx";
main :: () -> i32 {
print("zlib {}\n", b.ver_via_b());
return 0;
}
```
```
$ sx build -o a a.sx
Undefined symbols for architecture arm64:
"_zlibVersion", referenced from: ...
error: linking failed
```
Replacing a.sx's import with `c :: #import "c.sx"` (and calling
`c.zver()` directly) links and prints the zlib version — same code,
one namespace level less.
Found in the distribution repo the first time a product chain nested
the SQLite bindings two aliases deep: `dist.sx → ops :: #import
"release/ops.sx" → db :: #import "../repo/db.sx" → #import
"../db/sqlite.sx"` loses `-lsqlite3` even though the bindings compile
fine (the extern wrappers ARE in main.o; only the link flag is gone).
## Investigation prompt
> In /Users/agra/projects/sx: `extractLibraries` (src/main.zig, ~line
> 877) walks `root.data.root.decls` and exactly ONE level of
> `namespace_decl` children. Aliased imports lower to nested
> `namespace_decl` nodes, so a `#library` (or, same pattern,
> `#framework` — see `extractFrameworks` right below it, ~line 904)
> sitting two or more namespace levels deep is never collected. Both
> consumers are affected: the AOT link args (`link(...)` in
> src/target.zig receives this list) and the JIT dlopen loop
> (src/main.zig ~line 274). Fix: make the walk recursive over
> `namespace_decl` (a small explicit stack or recursive helper over
> `ns.decls`), dedup as today via the `seen` set; apply the same to
> `extractFrameworks`. Verify with the three-file libz repro from
> issues/0130-library-decl-nested-namespace-dropped.md: `sx build`
> must emit `-lz` and link, `sx run` must dlopen libz; add an
> examples/ regression mirroring the repro (one library decl behind
> two aliased imports) that fails on pre-fix master. Then re-run the
> distribution repo's `make test` (which now links SQLite through
> dist.sx's ops→db→sqlite chain) to confirm the original failure is
> gone.

View File

@@ -1,71 +0,0 @@
# 0131 — protocol method call with extra arguments compiles and silently drops them
> **RESOLVED.** Protocol-dispatch lowering matched the call args against the
> method's parameter list without an arity check, so extra trailing args were
> silently truncated (and missing args left the thunk reading garbage). Fixed in
> `src/ir/lower/protocol.zig:emitProtocolDispatch` (lines 531-538), which now
> exact-arity-checks and emits `'{name}' expects N argument(s), but M were given`
> at the call span. Regression pinned by `examples/1634-protocol-call-arity`.
## Symptom
Calling a protocol method with MORE arguments than the protocol
declares is accepted by the compiler; the extra arguments are silently
dropped at the call. Observed: `Allocator.dealloc_bytes` is declared
`(ptr: *void)`, yet `a.dealloc_bytes(p, 12345)` compiles and runs
under both `sx run` and `sx build`. Expected: a compile diagnostic
("expected 1 argument, got 2"), exactly as for plain function calls.
Found via std.http (PLAN-HTTPZ S7a): three `dealloc_bytes(ptr, size)`
calls — written against an imagined two-arg signature — compiled
clean and survived the full example sweep. Corrected in `81fa50c`;
this issue is about the missing diagnostic.
## Reproduction
```sx
#import "modules/std.sx";
main :: () -> i32 {
gpa := GPA.init();
a : Allocator = xx gpa;
p := a.alloc_bytes(64);
a.dealloc_bytes(p, 12345); // protocol declares (ptr) — must be rejected
print("compiled and ran\n");
return 0;
}
```
Run `sx run repro.sx`: prints "compiled and ran", exit 0. Expected: a
compile error naming the arity mismatch.
## Investigation prompt
The sx compiler accepts protocol method calls with extra trailing
arguments and silently drops them (repro above — `Allocator.
dealloc_bytes(ptr)` called with `(ptr, extra)` compiles and runs).
Plain function calls DO arity-check, so the gap is specific to the
protocol-dispatch call path. Suspected area: the protocol call
planning/lowering in `src/ir/lower/call.zig` (and/or the protocol
method resolution in `src/ir/protocols.zig`) — wherever a protocol
method's parameter list is matched against call-site args, the
arg-count check that plain calls get is likely skipped, and lowering
then truncates args to the method's param count. The fix should emit
the same diagnostic plain calls produce (expected N args, got M) for
both too many AND verify too few is also caught. Verify with the
repro (expect a compile error) plus a negative-test example or a
`call.test.zig` case pinning the diagnostic; then `zig build &&
zig build test && bash tests/run_examples.sh` all green (existing
examples must not regress — if any example relied on dropped extra
args, that example is itself a latent bug to fix in the same
session).
## Resolution
RESOLVED (merged master `d7808f6`, 2026-06-12, Agra-directed in-session
fix). `emitProtocolDispatch` arity-checks exactly (extra AND missing
args) with the standard "expects N arguments" diagnostic at the call
span. Regression pinned by examples/1634-protocol-call-arity; sweep
622/622 + unit tests green — nothing in the tree relied on the
leniency. The three std.http call sites that exposed the gap were
corrected separately in `81fa50c`.

View File

@@ -1,231 +0,0 @@
# 0132 — protocol method return/param type resolves to the WRONG same-name type (visibility-unaware registration)
> **RESOLVED (2026-06-13).** Root cause: `registerProtocolDecl`
> (`src/ir/protocols.zig`) resolved each method's param/return type NAME
> through the flat, visibility-UNAWARE `type_bridge.resolveAstType`, so a
> name colliding across modules (the user's `Event` enum vs the stdlib
> `event.Event` struct) bound to the wrong author. Fix: resolve both
> through `self.l.resolveTypeInSource(pd.source_file, …)` — the
> visibility-aware stateful resolver pinned to the protocol's OWN
> declaring module — keeping the `Self → *void` short-circuit. This
> brings the non-parameterized path to parity with the parameterized
> path (`instantiateParamProtocol`) and concrete-fn signatures, which
> already pin to the defining module. The broader enum/union/inline/
> error-set registration class was already fixed in `f13f4ab`; this
> commit closes the protocol-return case it left open. Regression test:
> `examples/0417-protocols-protocol-return-name-collision.sx` (prints
> `escape!`, exit 0). The error-set reference path remains dormant
> pending error-set per-decl nominal identity (issue 0134).
> **ROOT CAUSE CORRECTED (2026-06-13).** The original write-up (kept in
> "Original hypothesis" below) guessed this was about an inferred
> protocol-return enum TypeId "not carrying payload struct field types".
> That is **not** the cause. A ground-truth trace (instrumented build)
> shows the real bug: **`registerProtocolDecl` resolves method
> parameter/return type NAMES through a flat, visibility-UNAWARE lookup**
> (`type_bridge.resolveAstType` → `resolveNamed` → global `findByName`),
> so when the named type has a same-name shadow (another module also
> declares that name), it picks the WRONG one. In the repro the user's
> `Event` enum collides with the stdlib `library/modules/std/event.sx`
> `Event :: struct` (pulled in by `#import "modules/std.sx"`, std.sx:101,
> namespaced as `event`). The protocol grabs the stdlib struct; the
> annotation path grabs the user enum. Hence inferred fails, annotated
> works.
## Symptom
One-line: a protocol (dynamic-dispatch) method whose declared parameter
or return type NAME also exists in another module resolves to the wrong
type, because protocol signature registration is not visibility-aware.
- **Observed (repro):** `error: enum literal '.escape' has no destination
type to resolve against` on `if e.key == .escape { ... }`, where `e` is
the payload bound by `case .key_up: (e)` on a value whose type was
inferred from a protocol method returning `Event`.
- **Why that error:** the protocol method's cached `ret_type` is the
stdlib `Event` **struct** (empty of the user's variants), not the
user's `Event` **tagged_union**. So `ev := g_plat.one_event()` types
`ev` as a plain struct; the `case .key_up:(e)` match finds no
tagged-union variant, binds `e` to `.unresolved`; `e.key` on an
`.unresolved` object silently returns a placeholder (the cascade guard
in `lower.zig:emitFieldError` suppresses the field error on
`.unresolved`); so `.escape` then has an `.unresolved` destination and
emits the reported diagnostic.
- **Expected:** bare `Event` inside the protocol resolves to the user's
own `Event` (the visibility-correct author), exactly as an explicit
`ev : Event = …` annotation already does. The repro then prints
`escape!`, exit 0.
Surfaced building the downstream `m3te` app at `main.sx:222` —
`for g_plat.poll_events() (*ev) { … case .key_up: (e) { if e.key == .escape … } }`,
where `g_plat : Platform` is a `modules/platform/api.sx` protocol and
`poll_events :: () -> []Event` returns `ui.Event`. m3te imports `std`
(which carries the namespaced `event.Event` struct) AND has its own
`ui.Event`, so the protocol's flat lookup picks the wrong `Event` — the
same collision as the minimal repro.
## Reproduction
Minimal, standalone (only depends on `modules/std.sx`). The trigger is
the type NAME `Event` colliding with `std/event.sx`'s `Event` struct:
```sx
#import "modules/std.sx";
Keycode :: enum { unknown; escape; enter; }
KeyData :: struct { key: Keycode; }
Event :: enum { none; key_up: KeyData; } // <-- name collides with std/event.sx `Event :: struct`
Plat :: protocol { one_event :: () -> Event; }
Impl :: struct { dummy: i64; }
impl Plat for Impl {
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
}
main :: () {
impl : Impl = .{ dummy = 0 };
g_plat : Plat = xx @impl;
ev := g_plat.one_event(); // type INFERRED from protocol return
if ev == {
case .key_up: (e) {
if e.key == .escape { print("escape!\n"); } // <-- errors here
}
}
}
```
Run: `./zig-out/bin/sx run issues/0132-protocol-return-enum-case-payload-field-unresolved.sx`
Actual:
```
error: enum literal '.escape' has no destination type to resolve against
--> ...:NN:NN
|
| if e.key == .escape { print("escape!\n"); }
| ^^^^^^^
```
### Decisive bisection (verified)
| Variant | Result | Why |
|---|---|---|
| Repro as above (name `Event`, inferred) | **FAILS** | protocol flat-resolves `Event` → stdlib `event.Event` struct (104) |
| Rename the user type `Event` → `Evt` everywhere | **OK** (`escape!`) | no same-name shadow → flat lookup gets the only `Evt` |
| Keep `Event` but annotate `ev : Event = g_plat.one_event()` | **OK** | annotation uses the visibility-aware `resolveNominalLeaf` → user enum (152) |
| Concrete fn (non-protocol) returns `Event`, same body | **OK** | concrete fn signatures already resolve via `self.resolveType` (visibility-aware) |
| Protocol returns a plain struct / a plain enum named `Event` | varies | same root cause: flat lookup picks the colliding author |
Ground-truth TypeIds (from an instrumented build): the protocol method's
`ret_type` = **104** (`tag=struct name=Event`, the stdlib placeholder);
the annotation resolves `Event` = **152** (`tag=tagged_union name=Event`,
the user type with the `key_up → KeyData` payload). Two distinct authors
of the name `Event`; the flat path picks 104, the visibility-aware path
picks 152.
## Fix
Make protocol method signature registration visibility-aware, mirroring
what concrete functions and `registerStructDecl` already do.
In `src/ir/protocols.zig` `registerProtocolDecl` (~lines 289316), pin
the visibility context to the protocol's declaring module and resolve
through the source-aware helpers instead of the flat resolver:
- param types: `self.l.resolveParamTypeInSource(pd.source_file, p)`
(keep the `Self → *void` special-case)
- return type: `self.l.resolveTypeInSource(pd.source_file, rt)`
(keep the `Self → *void` special-case)
Both helpers already exist (`src/ir/lower.zig:670` / `:684`) and are the
exact tool for "resolve a type in its DEFINING module's visibility
context". `ProtocolDecl.source_file` is already stamped by
`resolveImports` for this purpose (`src/ast.zig:817`). The
**parameterized**-protocol path (`instantiateParamProtocol`,
`src/ir/lower/protocol.zig:119`) ALREADY does exactly this (pins
`current_source_file = pd.source_file` and resolves via
`resolveTypeWithBindings`); this change brings the NON-parameterized path
to parity.
No silent default is introduced: the visibility-aware path emits real
diagnostics for genuinely not-visible / ambiguous names and poisons with
`.unresolved` (per CLAUDE.md "Silent fallback defaults" rules).
## Broader latent risk (same class — track separately)
The same visibility-unaware flat resolution at REGISTRATION time also
affects **enum payloads** and **union field types** (CONFIRMED failing),
because `registerEnumDecl` / `registerUnionDecl` build their bodies via
the stateless `type_bridge.buildEnumInfo` / `buildUnionInfo`, which
flat-resolve type names. Repro shape (confirmed):
```sx
#import "modules/std.sx";
Event :: struct { code: i64; } // collides with std/event.sx Event
Wrap :: enum { none; got: Event; } // payload type Event → flat-resolves to the WRONG Event
main :: () {
w : Wrap = .{}; w = .got(.{ code = 7 });
if w == { case .got: (e) { print("{}\n", e.code); } } // error: field 'code' not found on type 'Event'
}
```
(Structs are already SAFE — `registerStructDecl` resolves fields via the
visibility-aware `self.resolveType`, `src/ir/lower/nominal.zig:637`.)
Suggested broader fix: inject a resolver into `buildEnumInfo` /
`buildUnionInfo` (an `anytype` adapter with a `resolve(node) → TypeId`
method) so the stateless inline callers keep the flat resolver while the
stateful `registerEnumDecl` / `registerUnionDecl` pass a
`self.resolveType`-backed (visibility-aware) one — single source of truth
for the body shape, two resolution strategies. Also switch the struct-
constant annotation resolve (`src/ir/lower/nominal.zig:706`) to
`self.resolveType`. See the session notes for the full design.
## Verification
`./zig-out/bin/sx run issues/0132-…sx` prints `escape!` exit 0; then
`zig build && zig build test` and `bash tests/run_examples.sh` all green.
When resolved, promote the repro to
`examples/04xx-protocols-protocol-return-name-collision.sx` per the
"Resolving an open issue" procedure.
## Notes
- Diagnostic site (where the symptom surfaces, NOT the root cause):
`src/ir/lower/expr.zig:920` (`lowerEnumLiteral`, `target == .unresolved`
branch).
- Root cause site: `src/ir/protocols.zig:299,309`
(`registerProtocolDecl`, flat `type_bridge.resolveAstType` for
param/return types).
- The minimal repro previously used a legacy `Impl_methods :: { … }`
block; that compiles but crashes at runtime independently. The repro
here uses the canonical `impl Plat for Impl { … }` so that, post-fix,
it actually runs and prints `escape!`.
- Workaround in downstream code (annotate the binding, or rename the
type to avoid the std collision) is NOT applied in m3te per the
IMPASSABLE RULES — the fix belongs in the compiler.
---
## Original hypothesis (SUPERSEDED — kept for provenance)
The first write-up framed this as: "when an enum value's type is inferred
from a protocol method's declared return, a `case`-payload binding loses
its struct-field types", and pointed the fix at the call-result TypeId in
`src/ir/calls.zig` / `src/ir/conversions.zig` "not carrying the variant
payload struct's field types". The instrumented trace disproved this: the
inferred and annotated `Event` are two DIFFERENT registered types (a
same-name shadow), and the divergence is purely that protocol signature
registration uses a flat, visibility-unaware lookup. The payload-field
machinery is fine once the correct `Event` reaches the binding.
## Follow-up (2026-06-13)
The broader-latent union case this fix enabled — a colliding-name union
member now resolving to the correct type — was further blocked at codegen by
a separate bug: assigning a struct literal to a union member lowered as
`.unresolved` ([issue 0133](0133-union-member-struct-literal-assign-unresolved-panic.md),
now RESOLVED, which in turn required
[issue 0135](0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md)).
With both fixed, the union-member-via-struct-literal path is demonstrable
end-to-end (`examples/0184-types-union-member-struct-literal-assign.sx`).

View File

@@ -1,301 +0,0 @@
# 0133 — assigning a struct LITERAL to a union member panics ("unresolved type reached LLVM emission")
> **RESOLVED (2026-06-13).** Root cause: `lowerAssignment`'s `.field_access`
> target-type path used `getStructFields`, which returns nothing for a
> `union`, so a union-member LHS never set `target_type` and the RHS struct
> literal lowered as `.unresolved` → LLVM-emission tripwire. Fix: a single
> pure field-matching resolver `fieldLvalueResolve` (in `src/ir/lower/stmt.zig`)
> that both `fieldLvaluePtr` (builds GEPs) and the target-type path
> (`res.valueType()`) consume — covering union direct + promoted members,
> tuple/vector lanes, and structs, so the lvalue-pointer path and the
> target-type path can't diverge. Landing this required first fixing the
> latent [issue 0135](0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md)
> (the unified resolver newly types tuple-element LHSs, which routed
> `examples/0540`'s `c.sources.0 = xx sources[0]` through pack-index protocol
> erasure). Regression test:
> [examples/0184-types-union-member-struct-literal-assign.sx](../examples/0184-types-union-member-struct-literal-assign.sx).
> The § Confirmed fix block below records the exact patch that landed.
## Symptom
One-line: `u.b = .{ ... }` where `b` is a NAMED-struct member of a plain
`union` compiles to an `.unresolved`-typed `struct_init` and trips the
LLVM-emission tripwire. The RHS struct literal never receives its target
type (the union member's type), so it lowers as `.unresolved`.
- **Observed:** `thread … panic: unresolved type reached LLVM emission —
a type resolution failure was not diagnosed/aborted`
(`src/backend/llvm/types.zig:176`), reached from
`emitStructInit` (`src/backend/llvm/ops.zig:1211`) because the
`struct_init` instruction's `ty` is `.unresolved`.
- **Expected:** the literal types itself as the union member's struct type
(here `S`) and stores into the member — exactly as it already does when
the left-hand side is a STRUCT field.
This is PRE-EXISTING (reproduces on `master` / before any issue-0132
work) and ORTHOGONAL to type-name resolution: it reproduces with a
unique, non-colliding type name. Surfaced while testing issue 0132's
broader-latent fix (making enum/union payload registration
visibility-aware) — that fix makes a *colliding*-name union member
resolve to the correct type, at which point this separate codegen bug is
what blocks the end-to-end union case.
## Reproduction
Minimal, standalone (only `modules/std.sx`):
```sx
#import "modules/std.sx";
S :: struct { code: i64; }
U :: union { a: i64; b: S; }
main :: () {
u : U = ---;
u.b = .{ code = 9 }; // <-- panics: struct literal has no target type
print("code={}\n", u.b.code);
}
```
Run: `./zig-out/bin/sx run issues/0133-union-member-struct-literal-assign-unresolved-panic.sx`
→ panics today; the fix should make it print `code=9`, exit 0.
### Bisection (what does / does not trigger it)
| Variant | Result |
|---|---|
| `u.b = .{ code = 9 }` (union member ← struct LITERAL) | **PANICS** |
| `o.b = .{ code = 9 }` where `o : Outer = struct { a; b: S }` (STRUCT member ← literal) | **OK** |
| `s : S = .{ code = 9 }; u.b = s` (union member ← pre-made value) | **OK** |
| `u : U = ---` then only read (no literal assign) | **OK** |
So the trigger is exactly the conjunction **(LHS is a union member) AND
(RHS is a struct literal)**. A struct-field LHS propagates the target
type to the literal; a pre-made value needs no target type. Only the
union-member-lvalue + literal-RHS combination drops it.
## Investigation prompt
> Assigning a struct literal to a NAMED-struct member of a plain `union`
> panics with "unresolved type reached LLVM emission". Repro:
> `issues/0133-union-member-struct-literal-assign-unresolved-panic.sx`
> (expect a panic today; the fix should make it print `code=9`, exit 0).
>
> The `struct_init` instruction for the RHS literal `.{ code = 9 }` has
> `ty == .unresolved` — the literal was lowered without a target type, so
> it could not resolve to the union member's struct type `S`. The panic
> is the codegen tripwire in `src/backend/llvm/types.zig:176`
> (`toLLVMTypeInfo`), reached from `emitStructInit`
> (`src/backend/llvm/ops.zig:1211`).
>
> Root area: assignment lowering in `src/ir/lower.zig` —
> `lowerAssignment`'s `.field_access` target path. Issue 0094 already
> routes the lvalue POINTER through the shared `fieldLvaluePtr` (which
> correctly resolves union/tagged-union direct members — that's why a
> pre-made value stores fine). The gap is the RHS TARGET TYPE: for a
> STRUCT-field LHS the code sets `self.target_type` to the field's type
> before lowering the RHS (so a struct literal types itself), but for a
> UNION-member LHS that target-type propagation is missing, so the
> literal lowers under a null/unresolved target → `struct_init.ty ==
> .unresolved`.
>
> Suspected fix: before lowering the RHS expression in
> `lowerAssignment`'s field-access path, compute the LHS member's type
> for union / tagged-union members too (reuse the same member-type lookup
> `fieldLvaluePtr` already performs — ideally have it RETURN the resolved
> field type, or factor a `fieldLvalueType` helper, so the lvalue-pointer
> path and the target-type path cannot diverge — the two-resolver defect
> class this codebase keeps burning on) and set `self.target_type` to it
> for the RHS lowering. Do NOT paper over with an `.unresolved`→default;
> per CLAUDE.md, resolve the real member type or emit a diagnostic.
>
> Verification: the repro prints `code=9` exit 0; then `zig build &&
> zig build test` green. Add positive coverage (a union member written
> via struct literal, then read back) — extend
> `examples/0166-types-union-promoted-member-lvalue.sx` or add a new
> `examples/01xx-types-union-member-struct-literal-assign.sx`. When
> resolved, also note in issue 0132 that the broader-latent union case is
> now demonstrable end-to-end.
## Confirmed fix (landed)
Root cause confirmed exactly as the investigation prompt hypothesized: the
target-type path in `lowerAssignment`'s `.field_access` case used
`getStructFields`, which returns `&.{}` for a `union` (only `.@"struct"` is
handled). So a union-member LHS never set `self.target_type`, and the RHS
struct literal lowered with no target → `struct_init.ty == .unresolved`
LLVM-emission tripwire.
The fix unifies the resolver (per this issue's prompt — "factor a
`fieldLvalueType` helper … so the lvalue-pointer path and the target-type
path cannot diverge"): a pure `fieldLvalueResolve(obj_ty, field)
-> ?FieldResolution` matcher that both `fieldLvaluePtr` (builds GEPs) and the
target-type path (`res.valueType()`) consume. With it, the 0133 union repro
prints `code=9`, exit 0.
**Why it was blocked on 0135:** the unified matcher also resolves *tuple
element* LHS types (not just structs, as the old `getStructFields` path did).
That makes `c.sources.0 = xx sources[0]` in
`examples/0540-packs-pack-type-arg-spread.sx` set `target_type = VL(i64)`,
routing `xx sources[0]` through `buildProtocolErasure`
`lowerExprAsPtr(sources[0])` → the pre-existing pack-index-address-of bug
(issue 0135). Narrowing the fix to dodge tuples would reintroduce the
two-resolver divergence this issue explicitly set out to remove — i.e. a
workaround — so 0135 was fixed first, then this landed unchanged.
The patch that landed (`src/ir/lower.zig` + `src/ir/lower/stmt.zig`):
```diff
diff --git a/src/ir/lower.zig b/src/ir/lower.zig
--- a/src/ir/lower.zig
+++ b/src/ir/lower.zig
@@ pub const Lowering = struct {
pub const lowerAssignment = lower_stmt.lowerAssignment;
+ pub const fieldLvalueResolve = lower_stmt.fieldLvalueResolve;
pub const fieldLvaluePtr = lower_stmt.fieldLvaluePtr;
```
In `src/ir/lower/stmt.zig`, replace the struct-only target-type loop in
`lowerAssignment`'s `.field_access` branch:
```diff
- if (!obj_ty.isBuiltin()) {
- const field_name_id = self.module.types.internString(fa.field);
- const struct_fields = self.getStructFields(obj_ty);
- for (struct_fields) |f| {
- if (f.name == field_name_id) {
- self.target_type = f.ty;
- break;
- }
- }
- }
+ // Resolve the LHS member's type via the SAME resolver the lvalue-
+ // pointer path uses (fieldLvalueResolve), so the RHS target type
+ // and the store slot can't diverge. Covers union/tagged-union
+ // direct + promoted members, tuple/vector lanes, and structs —
+ // not just structs (a plain getStructFields loop returned nothing
+ // for a union member, leaving a struct-literal RHS untyped →
+ // struct_init.ty == .unresolved → LLVM-emission panic; issue 0133).
+ if (self.fieldLvalueResolve(obj_ty, fa.field)) |res| {
+ self.target_type = res.valueType();
+ }
```
Then refactor `fieldLvaluePtr` into a pure `FieldResolution` matcher
(`fieldLvalueResolve`) + a thin GEP-builder. Full hunk:
```zig
const FieldResolution = union(enum) {
union_direct: struct { index: u32, ty: TypeId },
union_promoted: struct { variant_index: u32, variant_ty: TypeId, member_index: u32, ty: TypeId },
indexed: struct { index: u32, ty: TypeId },
fn valueType(self: FieldResolution) TypeId {
return switch (self) {
.union_direct => |u| u.ty,
.union_promoted => |u| u.ty,
.indexed => |s| s.ty,
};
}
};
pub fn fieldLvalueResolve(self: *Lowering, obj_ty: TypeId, field: []const u8) ?FieldResolution {
if (obj_ty.isBuiltin()) return null;
const field_name_id = self.module.types.internString(field);
const type_info = self.module.types.get(obj_ty);
const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) {
.@"union" => |u| u.fields,
.tagged_union => |u| u.fields,
else => null,
};
if (union_fields) |fields| {
for (fields, 0..) |f, i| {
if (f.name == field_name_id) {
return .{ .union_direct = .{ .index = @intCast(i), .ty = f.ty } };
}
if (!f.ty.isBuiltin()) {
const fi = self.module.types.get(f.ty);
if (fi == .@"struct") {
for (fi.@"struct".fields, 0..) |sf, si| {
if (sf.name == field_name_id) {
return .{ .union_promoted = .{ .variant_index = @intCast(i), .variant_ty = f.ty, .member_index = @intCast(si), .ty = sf.ty } };
}
}
}
}
}
return null;
}
if (type_info == .tuple) {
const tup = type_info.tuple;
var elem_idx: ?usize = null;
if (std.fmt.parseInt(usize, field, 10)) |n| {
if (n < tup.fields.len) elem_idx = n;
} else |_| {
if (tup.names) |names| {
for (names, 0..) |nm, i| {
if (nm == field_name_id and i < tup.fields.len) {
elem_idx = i;
break;
}
}
}
}
if (elem_idx) |idx| {
return .{ .indexed = .{ .index = @intCast(idx), .ty = tup.fields[idx] } };
}
return null;
}
if (type_info == .vector) {
const vidx = Lowering.vectorLaneIndex(field) orelse return null;
return .{ .indexed = .{ .index = vidx, .ty = type_info.vector.element } };
}
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields, 0..) |f, i| {
if (f.name == field_name_id) {
return .{ .indexed = .{ .index = @intCast(i), .ty = f.ty } };
}
}
return null;
}
pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
const res = self.fieldLvalueResolve(obj_ty, field) orelse return null;
switch (res) {
.union_direct => |u| {
const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.index, .base_type = obj_ty } }, self.module.types.ptrTo(u.ty));
return .{ .ptr = ptr, .ty = u.ty };
},
.union_promoted => |u| {
const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.variant_index, .base_type = obj_ty } }, self.module.types.ptrTo(u.variant_ty));
const ptr = self.builder.structGepTyped(ug, u.member_index, self.module.types.ptrTo(u.ty), u.variant_ty);
return .{ .ptr = ptr, .ty = u.ty };
},
.indexed => |s| {
const ptr = self.builder.structGepTyped(obj_ptr, s.index, self.module.types.ptrTo(s.ty), obj_ty);
return .{ .ptr = ptr, .ty = s.ty };
},
}
}
```
(`fieldLvalueResolve` is also registered on `Lowering` in `lower.zig` — the
first diff hunk above.) Landed after 0135; the repro moved to
`examples/0184-types-union-member-struct-literal-assign.sx` and
`examples/0540` stays green.
## Notes
- Tripwire site (symptom): `src/backend/llvm/types.zig:176`
(`toLLVMTypeInfo`, `.unresolved` arm) via `emitStructInit`
(`src/backend/llvm/ops.zig:1211`).
- Root area (cause): `Lowering.lowerAssignment` `.field_access` target
path in `src/ir/lower.zig` — RHS target-type not set for union/
tagged-union members.
- Related but distinct: issue 0094 (RESOLVED) fixed the lvalue-POINTER
field resolution (missing-field panic + `.i64`/field-0 defaults). This
issue is the RHS-literal TARGET-TYPE path, which 0094 did not touch.

View File

@@ -1,176 +0,0 @@
# 0134 — a same-name `error` set collapses into a namespaced import's set (error sets lack per-decl nominal identity)
> **RESOLVED.** Error-set declarations now get the same per-decl nominal
> identity (E6a) as struct/enum/union. `registerErrorSetDecl` builds the
> `.error_set` `TypeInfo` (via a new `buildErrorSetInfo` helper factored out of
> `resolveInlineErrorSet`) and interns it through `internNamedTypeDecl` with a
> `shadowNominalId`; a `reserveShadowErrorSetSlot` reserves a distinct slot in
> `scanDecls`, and `namedRefTid`'s `.error_set_decl` arm consults the per-decl
> `type_decl_tids` before falling back to `findByName` — so a local set no
> longer collapses onto a same-name imported one. The inline/anonymous
> `findByName` short-circuit is preserved. Regression test:
> `examples/1059-errors-same-name-error-set-own-wins.sx`.
## Symptom
One-line: a top-level `error { ... }` whose NAME matches an error set
reachable through a (namespaced) import **collapses into the imported
set** at registration — losing its own tags — because error-set
declarations are NOT given per-decl nominal identity the way
struct / enum / union are (E6a). So a local set's tags become
"unknown".
- **Observed:** `error: error tag 'error.Boom' is not in error set
'EventErr'` on `raise error.Boom` (and on `r == error.Boom`), where
`EventErr :: error { Boom }` is declared locally but
`#import "modules/std.sx"` also carries `event.EventErr`
(tags `Init` / `Register` / `Wait`). The membership check sees the
IMPORTED set, which has no `Boom`.
- **Expected:** the local `EventErr { Boom }` is its OWN type; `Boom` is
a member; the program prints `own EventErr.Boom`, exit 0 — exactly as
a uniquely-named local error set already does.
This is the **declaration-side** twin of issue 0132's class. The
**reference-side** is already visibility-aware: `error_type_expr`
(`!EventErr`) resolves its name through `Lowering.resolveName` →
`resolveNominalLeaf` (own-author-wins). But that fix is **dormant** for
error sets: because the local declaration never gets its own TypeId
(it collapses into the import's), there is only ONE `EventErr` in the
type table for the reference to find. Fixing THIS issue is what makes
the reference-side resolution observable.
## Reproduction
Minimal, standalone (only `modules/std.sx`). The trigger is the name
`EventErr` colliding with `std/event.sx`'s `EventErr` error set:
```sx
#import "modules/std.sx";
EventErr :: error { Boom } // collides with std/event.sx `EventErr { Init, Register, Wait }`
fail :: () -> !EventErr {
raise error.Boom; // Boom IS a member of the local set
}
main :: () -> i32 {
r := fail();
if r == error.Boom {
print("own EventErr.Boom\n");
return 0;
}
print("wrong set\n");
return 1;
}
```
Run: `./zig-out/bin/sx run issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx`
Actual (today):
```
error: error tag 'error.Boom' is not in error set 'EventErr'
--> ...:NN:NN
|
| fail :: () -> !EventErr { raise error.Boom; }
| ^^^^^^^^^^
```
(and again on `r == error.Boom`). The fix should make it print
`own EventErr.Boom`, exit 0.
### Decisive bisection (verified)
| Variant | Result |
|---|---|
| Local `EventErr` (name collides with `std/event.sx`) | **FAILS** — membership checked against the imported set |
| Rename the local set `MyErr :: error { Boom }` (no collision) | **OK** — prints `own EventErr.Boom`-equivalent |
So the trigger is purely the same-name collision; the local set's body
(`{ Boom }`) is correct — it's simply never registered under its own
identity.
## Root cause
Error sets are excluded from the per-decl nominal identity system (E6a)
that struct / enum / union use:
- `Lowering.registerErrorSetDecl` (`src/ir/lower/nominal.zig`) registers
via the FLAT `type_bridge.resolveAstType(node, …)` →
`resolveInlineErrorSet` (`src/ir/type_bridge.zig`), whose first line is
`if (table.findByName(name_id)) |existing| return existing;` — so the
SECOND author of a name (here the local `EventErr`, registered after
the imported one) just gets the first author's TypeId. No distinct
nominal slot, no own tags.
- Contrast `registerEnumDecl` / `registerStructDecl` / `registerUnionDecl`,
which intern through `internNamedTypeDecl(decl_key, name_id, info,
nominal_id)` with `nominal_id = shadowNominalId(name_id)` — each author
gets a distinct TypeId.
- The E6a shadow-reservation scan only enumerates struct / enum / union:
`ShadowTypeDecl` (`src/ir/lower/nominal.zig`) is
`union(enum) { @"struct", @"enum", @"union" }`, `topLevelTypeDecl`
maps only those, and there is `reserveShadow{Struct,Enum,Union}Slot`
but no error-set equivalent. So a same-name error-set shadow is never
reserved up-front.
- The plumbing is half-there: `nominalIdOf` / `stampNominalId` already
handle the `.error_set` arm — registration just never sets a nominal id.
## Investigation prompt
> A top-level `error { ... }` whose name collides with a same-name error
> set from a namespaced import collapses into the imported set, so its
> own tags are lost ("error tag 'X' is not in error set 'Name'"). Repro:
> `issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.sx`
> (expect it to FAIL today; the fix should make it print
> `own EventErr.Boom`, exit 0).
>
> Root cause: error sets are excluded from the per-decl nominal identity
> system (E6a). `Lowering.registerErrorSetDecl`
> (`src/ir/lower/nominal.zig`) registers through the flat
> `type_bridge.resolveAstType` → `resolveInlineErrorSet`
> (`src/ir/type_bridge.zig`), which short-circuits on
> `findByName(name)` and returns the first same-name author's TypeId —
> instead of interning under a per-decl nominal id like
> `registerEnumDecl` does via `internNamedTypeDecl` +
> `shadowNominalId`.
>
> Fix direction (mirror E6a for error sets):
> 1. Add an `@"error_set"` variant to `ShadowTypeDecl`, an arm in
> `topLevelTypeDecl`, and a `reserveShadowErrorSetSlot` (mirroring
> `reserveShadowEnumSlot` — reserve a `.error_set` placeholder under
> the computed `shadowNominalId`).
> 2. Rewrite `registerErrorSetDecl` to build the `.error_set` `TypeInfo`
> (intern the tag ids — factor the body out of `resolveInlineErrorSet`
> if helpful, like `buildEnumInfo`) and intern it via
> `internNamedTypeDecl(decl_key, name_id, info, nominal_id)` with
> `nominal_id` from the reserved slot / `shadowNominalId`, instead of
> the flat `resolveAstType`.
> 3. The reference side is ALREADY visibility-aware (issue 0132's broader
> fix): `resolveErrorType` (`src/ir/type_bridge.zig`) resolves a named
> set through `inner.resolveName`, which for `*Lowering` is
> `resolveNominalLeaf` (own-wins). Once the declaration has its own
> TypeId, the named reference `!EventErr` will resolve to it
> automatically — no further reference-side change needed.
>
> Per CLAUDE.md "Silent fallback defaults": don't paper over with a
> findByName default — give error-set declarations real per-decl
> identity so the wrong-author resolution stops at the source.
>
> Verification: the repro prints `own EventErr.Boom` exit 0; then
> `zig build && zig build test` green. When resolved, promote the repro
> to `examples/10xx-errors-same-name-error-set-own-wins.sx` (the example
> was drafted during the 0132 broader-latent work and removed because it
> could not pass until this lands).
## Notes
- Membership-check diagnostic site (where the symptom surfaces, not the
root cause): `src/ir/lower/expr.zig` ("error tag '...' is not in error
set '...'").
- Root-cause sites: `src/ir/lower/nominal.zig` `registerErrorSetDecl`
(flat registration, no nominal id) + the `ShadowTypeDecl` /
`topLevelTypeDecl` / `reserveShadow*Slot` set (error sets excluded);
`src/ir/type_bridge.zig` `resolveInlineErrorSet` (the `findByName`
short-circuit).
- Related: issue 0132 (same class, reference + payload/field side, fixed
for struct/enum/union). This issue is the error-set declaration side;
the 0132 reference-side `error_type_expr` fix stays in place and
activates once this lands.

View File

@@ -1,174 +0,0 @@
# 0135 — `xx <pack>[i]` to a protocol target lowers the pack as a value ("pack has no runtime value")
> **RESOLVED (2026-06-13).** Root cause: `buildProtocolErasure`
> (`src/ir/lower/coerce.zig`) treated `pack[i]` as an lvalue (any `index_expr`
> returned true from `isLvalueExpr`) and tried to take its address via
> `lowerExprAsPtr`, whose `.index_expr` arm lowers the bare pack as a value →
> the pack-as-value error. Fix (preferred option 1): `isLvalueExpr` now reports
> a comptime pack index as an **rvalue**, so erasure falls into its heap-copy
> branch and copies the already-materialized element. It decides pack-ness with
> the SAME predicate the value path uses — `packArgNodeAt` (the `pack_arg_nodes`
> map) — not `isPackName` (`pack_param_count`), since the comptime-call path
> installs only `pack_arg_nodes`; sharing one predicate keeps the value and
> lvalue paths from diverging on what counts as a pack element. Regression tests:
> [examples/0547-packs-xx-pack-index-to-protocol.sx](../examples/0547-packs-xx-pack-index-to-protocol.sx)
> (single element) and
> [examples/0548-packs-xx-pack-index-two-elements.sx](../examples/0548-packs-xx-pack-index-two-elements.sx)
> (two distinct concrete types, each resolving to its own vtable). This also
> unblocked [issue 0133](0133-union-member-struct-literal-assign-unresolved-panic.md),
> now landed. The standalone repro `.sx` was removed (superseded by 0547).
## Symptom
One-line: erasing a single comptime-pack element to a protocol value —
`xx sources[0]` where the target type is a protocol (`VL(i64)`) — spuriously
errors with **"pack 'sources' has no runtime value — a pack is comptime-only
and can't be used as a value here"**, pointing at the pack name.
- **Observed:** the pack-as-value diagnostic fires on `sources` in
`x : VL(i64) = xx sources[0];`, even though `sources[0]` is a valid
compile-time pack index.
- **Expected:** `sources[0]` resolves to the call-site arg (the concrete
`IntCell`), gets erased to the protocol `VL(i64)`, and the program prints
`7`, exit 0.
This is **PRE-EXISTING** and reproduces on clean `master`, independent of any
union / tuple / issue-0133 work. Issue 0053 added the `xx <whole-pack>`
`[]Any`/`[]P` slice bridge (`lowerPackToSlice`), but **single-element**
`xx pack[i]` erasure to a protocol scalar was never handled.
## Reproduction
Minimal, standalone (only `modules/std.sx`):
```sx
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
make :: (..sources: VL) -> i64 {
x : VL(i64) = xx sources[0]; // protocol local <- xx pack[0]
return x.get();
}
main :: () -> i32 {
print("{}\n", make(IntCell.{ v = 7 })); // 7
0
}
```
Run: `./zig-out/bin/sx run issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.sx`
→ errors today; the fix should make it print `7`, exit 0.
### Root cause (traced)
The error chain (from a stack trace at the diagnostic site):
```
lowerAssignment / lowerVarDecl sets target_type = VL(i64) for the RHS
lowerExpr(xx sources[0]) unary_op .xx
operand = lowerExpr(sources[0]) → packArgNodeAt resolves to the
call-site arg IntCell.{v=7} (OK)
lowerXX(operand, sources[0]) classifies .erase_protocol
buildProtocolErasure(..., operand_node = sources[0], dst = VL(i64))
isLvalueExpr(sources[0]) == true (it's an index_expr)
concrete_ptr = lowerExprAsPtr(sources[0]) ← HERE
lowerExprAsPtr .index_expr arm: lowerExpr(ie.object)
lowerExpr(sources) → bare pack name → diagPackAsValue ✗
```
The defect: **`lowerExprAsPtr`'s `.index_expr` arm does NOT perform the
pack-arg-node substitution that `lowerIndexExpr` does.** `lowerIndexExpr`
intercepts `<pack>[<comptime-int>]` via `packArgNodeAt` and lowers the
call-site arg node directly; `lowerExprAsPtr` skips straight to
`lowerExpr(ie.object)`, lowering the bare pack `sources` as a value — which is
the (correct) pack-as-value error for a context where there is genuinely no
pointer to take.
So the value path (`lowerIndexExpr`) handles a pack index but the
address-of path (`lowerExprAsPtr`) does not — a two-resolver divergence on
the pack-index case. `buildProtocolErasure` only reaches the address-of path
because `isLvalueExpr(sources[0])` returns `true` (any index_expr looks like
an lvalue), so it tries to alias the operand's storage instead of heap-copying
the already-materialized rvalue.
## Investigation prompt
> `xx <pack>[i]` erased to a protocol target spuriously errors with "pack
> '<name>' has no runtime value". Repro:
> `issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.sx`
> (errors today; the fix should make it print `7`, exit 0).
>
> Root cause: `buildProtocolErasure` (`src/ir/lower/coerce.zig`, ~line 389)
> sees `isLvalueExpr(operand_node) == true` for the index_expr `sources[0]`
> and takes the alias-the-storage branch:
> `concrete_ptr = self.lowerExprAsPtr(operand_node)`. But
> `lowerExprAsPtr`'s `.index_expr` arm (`src/ir/lower/stmt.zig`, the
> `.index_expr => |ie|` case, `self.lowerExpr(ie.object)` at stmt.zig:1005
> on clean master) does
> NOT do the pack-arg-node substitution that `lowerIndexExpr`
> (`src/ir/lower/expr.zig:1343`, via `packArgNodeAt`) performs. It lowers the
> bare pack `sources` as a value → `diagPackAsValue` (the pack-as-value
> error at `src/ir/lower/expr.zig:1722`).
>
> A comptime pack index has no addressable storage of its own — `sources[0]`
> is the call-site arg node, which only acquires storage when lowered as a
> value. So the address-of path is the wrong path for a pack index.
>
> Suspected fix (pick the one that keeps the value/address paths from
> diverging — the recurring two-resolver defect class in this codebase):
> 1. **Preferred — teach `isLvalueExpr` that a comptime pack index is NOT
> an lvalue.** Add a check: an `index_expr` whose object is a pack name
> (`isPackName(ie.object...)` / a `packArgNodeAt(ie) != null` hit) is an
> rvalue. Then `buildProtocolErasure` falls into its existing
> `else { heap_copy = true; alloca + store(operand) }` branch and erases
> the already-materialized `IntCell` value correctly. Smallest, and
> matches the semantic truth (a pack element is a comptime rvalue).
> 2. Alternatively, make `lowerExprAsPtr`'s `.index_expr` arm resolve a
> pack index the way `lowerIndexExpr` does (`packArgNodeAt` → lower the
> arg node as a value → `addr_of` an alloca holding it). More plumbing,
> and it manufactures storage the caller could already manufacture.
> Do NOT paper over with a silent default — per CLAUDE.md, resolve the real
> path or emit a diagnostic.
>
> Verification: the repro prints `7`, exit 0; then `zig build &&
> zig build test` green. Add positive coverage (a new
> `examples/05xx-packs-xx-pack-index-to-protocol.sx`, packs category) and a
> sibling that erases two distinct pack elements. When resolved, this also
> UNBLOCKS issue 0133 (see below) — re-apply the 0133 unified-resolver fix
> and confirm `examples/0540-packs-pack-type-arg-spread.sx` stays green.
## Relationship to issue 0133
Surfaced while fixing **issue 0133** (assigning a struct literal to a union
member panics — the RHS never gets its target type). The clean 0133 fix
(per its own investigation prompt) unifies the lvalue field resolver so the
**target-type path** and the **lvalue-pointer path** share one matcher
(`fieldLvalueResolve`), which then resolves *tuple element* LHS types too
(not just structs). That makes `c.sources.0 = xx sources[0]` in
`examples/0540-packs-pack-type-arg-spread.sx` set `target_type = VL(i64)`
for the RHS — which routes `xx sources[0]` through `buildProtocolErasure`
(it previously erased later, at the store, via `coerceToType` on the
already-materialized value). That is exactly this bug.
So **issue 0133's correct (unified-resolver) fix is BLOCKED on this issue.**
The ready-to-apply 0133 patch is recorded in
`issues/0133-union-member-struct-literal-assign-unresolved-panic.md`; after
0135 lands, re-apply it and confirm both the 0133 union repro (`code=9`) and
`examples/0540` stay green.
## Notes
- Diagnostic site (symptom): `src/ir/lower/expr.zig:1723` (`isPackName` at
1722 → `diagPackAsValue`, `.generic`) via `src/ir/lower/expr.zig:1386`
(`lowerExpr(ie.object)` in `lowerIndexExpr`) — reached here through
`lowerExprAsPtr`'s `.index_expr` arm, NOT `lowerIndexExpr`.
- Root area (cause): `buildProtocolErasure` (`src/ir/lower/coerce.zig:389-390`)
+ `lowerExprAsPtr` `.index_expr` arm (`src/ir/lower/stmt.zig:1005`) +
`isLvalueExpr`.
- Prior art (RESOLVED, not duplicates): 0052 (slice-of-protocol variadic
erasure), 0053 (`xx <whole-pack>` → slice bridge), 0054 (generic-struct →
param protocol erasure). None handle single-element `xx pack[i]` → protocol
scalar.

View File

@@ -1,139 +0,0 @@
# 0136 — direct write to a tagged-union member updates the payload but not the tag
> **RESOLVED (2026-06-13).** Root cause: the read path distinguishes a tagged
> union (`enum_payload`/`enum_tag`, field 1/field 0) but the write path treated
> it like a plain union (`fieldLvalueResolve` → `.union_direct` → `union_gep`),
> storing the payload (field 1) with no tag store — so the discriminant went
> stale. Fix (chosen option 1 — reject; the spec only blesses construction /
> read / match for tagged unions, and no corpus relied on member writes): a new
> `diagTaggedUnionVariantWrite` guard (`src/ir/lower/stmt.zig`, reusing the
> shared `fieldLvalueResolve` matcher, registered in `src/ir/lower.zig`) rejects
> a direct whole-variant member assignment at both store sites (`lowerAssignment`
> and `lowerMultiAssign`) with a diagnostic pointing to `s = .variant(...)`.
> Plain `union` writes and nested sub-field writes (`s.rect.w = ...`) are
> unaffected (they don't resolve to `.union_direct` on a tagged union).
> Regression tests: `examples/0185-types-tagged-union-member-assign-rejected.sx`
> (rejected), `examples/0186-types-tagged-union-nested-field-write.sx` (nested
> write + construction still work). Spec/readme updated (enum section).
## Symptom
One-line: `s.rect = .{ ... }` on a tagged union (`enum`-with-payload) stores
into the payload area but leaves the discriminant (tag) untouched, so a later
`match`/`==` reads the STALE tag while the payload holds the new variant — a
silent tag/payload desync with no diagnostic.
- **Observed:** after `s : Shape = .circle(1.0); s.rect = .{ w=4, h=2 };`, a
`match` on `s` takes the `.circle` arm (tag never updated) even though the
payload now holds the rect. The wrong-variant payload read (`s.rect`) returns
the written bytes, masking the inconsistency.
- **Expected:** either a compile error directing the user to construction
(`s = .rect(...)`), or the member write sets the tag too so `s` becomes the
`.rect` variant and `match` sees `.rect`.
Same-variant write is fine (`s.circle = 9.0` while the tag is already
`circle`): the payload updates and the tag already matched. Only a write whose
variant differs from the current tag desyncs — and the compiler can't know the
runtime tag at the write site, so the danger is inherent to the operation.
## Reproduction
`issues/0136-tagged-union-member-write-does-not-set-tag.sx` (standalone, only
`modules/std.sx`):
```sx
#import "modules/std.sx";
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
}
main :: () {
s : Shape = .circle(1.0); // tag = circle, payload = 1.0
s.rect = .{ w = 4.0, h = 2.0 }; // writes rect payload; tag stays circle
if s == {
case .circle: print("tag=circle (STALE — wrote rect)\n");
case .rect: print("tag=rect (correct)\n");
}
r := s.rect;
print("rect.w={} rect.h={}\n", r.w, r.h);
}
```
Run: `./zig-out/bin/sx run issues/0136-tagged-union-member-write-does-not-set-tag.sx`
→ today prints `tag=circle (STALE — wrote rect)` then `rect.w=4 rect.h=2`.
## Root cause (traced)
A tagged union is laid out `{ tag (field 0), payload (field 1) }`
(`src/ir/types.zig` sizeOf: `tag_sz + max_field`). The READ and WRITE paths
treat it asymmetrically:
- **Read** distinguishes a tagged union: `lowerFieldAccess`
(`src/ir/lower/expr.zig`) emits `enum_payload``emitEnumPayload`
(`src/backend/llvm/ops.zig:1392`) GEPs **field 1** (payload); `.tag` /
`==` use `enum_tag` (field 0).
- **Write** treats a tagged union like a plain union: `fieldLvalueResolve`
(`src/ir/lower/stmt.zig`) maps a tagged-union member to `.union_direct`
`fieldLvaluePtr` emits `union_gep``emitUnionGep`
(`src/backend/llvm/ops.zig:1430`) GEPs **field 1** (payload) and stores there.
So the payload OFFSET is correct (both use field 1 — not an out-of-bounds /
clobber bug), but the write never emits the tag (field 0) store that
construction does. Construction `s = .rect(...)` lowers via `enum_init`
(`src/ir/inst.zig:170`), which writes BOTH tag and payload; the member-write
path emits only the payload store.
## Investigation prompt
> A direct write to a tagged-union member (`s.rect = .{...}`) updates the
> payload but not the discriminant, silently desyncing tag and payload. Repro:
> `issues/0136-tagged-union-member-write-does-not-set-tag.sx` (today prints the
> STALE tag; the fix should make it either a compile error or set the tag).
>
> Root: `fieldLvalueResolve` (`src/ir/lower/stmt.zig`) maps a tagged-union
> member to `.union_direct`, so `fieldLvaluePtr` emits a bare `union_gep`
> (payload pointer, field 1) and `lowerAssignment` stores only the payload. The
> tag (field 0) is never written. Construction (`enum_init`) writes both.
>
> Two reasonable fixes (pick one — both beat the silent desync):
> 1. **Reject** direct assignment to a tagged-union variant member with a
> diagnostic ("set a tagged-union variant via `s = .rect(...)`; direct
> member assignment can't set the discriminant"). Simplest and safe — the
> construction syntax already covers the intent, and the compiler can't
> know the runtime tag to validate a same-variant write anyway. Detect in
> `lowerAssignment`'s `.field_access` arm when the object type is a
> `tagged_union` and the field is a variant.
> 2. **Set the tag too**: make `s.rect = payload` equivalent to
> `s = .rect(payload)` — emit a tag store (the variant discriminant, which
> `fieldLvalueResolve` already knows as the variant index) alongside the
> payload store. More ergonomic but more plumbing (the assignment path
> must emit two stores, and compound ops like `s.rect.w += 1` need
> thought). Mirror how `enum_init` sets the tag.
> Do NOT leave the silent payload-only write. Note the read/write asymmetry
> (read uses `enum_payload`, write uses `union_gep`) is the structural root;
> whichever fix, keep plain `union` (no tag) working — only `tagged_union`
> needs the new behavior.
>
> Verification: the repro errors (option 1) or `match` sees `.rect` (option 2);
> then `zig build && zig build test` green. Add coverage under
> `examples/01xx-types-...` (a tagged-union member write: rejected, or
> tag-setting round-trips through `match`).
## Notes
- PRE-EXISTING and orthogonal to issues 0133 / 0135. Surfaced while reviewing
the 0133 fix (which unified the lvalue field resolver): the read path
special-cases `tagged_union` but the write path does not. The 0133 fix itself
is about PLAIN `union` (no tag) and did not introduce or change this — it
preserved the existing `union_direct → union_gep` routing for tagged-union
members.
- Related read-side gap (probably same fix family, not verified here): reading
the WRONG variant (`s.rect` while tag is `circle`) also returns raw payload
bytes with no tag check (`emitEnumPayload` doesn't check the discriminant).
A variant-safe accessor / checked read is a separate consideration.
- Sites: write — `fieldLvalueResolve`/`fieldLvaluePtr` (`src/ir/lower/stmt.zig`),
`emitUnionGep` (`src/backend/llvm/ops.zig:1430`); read — `emitEnumPayload`
(`src/backend/llvm/ops.zig:1392`); construction — `enum_init`
(`src/ir/inst.zig:170`); layout — `src/ir/types.zig` (tagged_union sizeOf).

View File

@@ -1,76 +0,0 @@
# 0137 — `sx run` on a program with no `main` segfaults (JIT entry lookup unguarded)
> **RESOLVED.** A pre-JIT entry-point check in `main.zig` now emits a clean
> `error: no 'main' function found …` diagnostic and exits non-zero before any
> codegen/JIT, so a no-main program never reaches the garbage-pointer call. A
> defensive `main_addr == 0` guard in `target.zig`'s `runJITFromObject` (ORC
> reports lookup success but leaves the address degenerate) remains as a
> backstop. Regression test: `examples/1188-diagnostics-run-no-main.sx`.
## Symptom
`sx run <file>` on a program that defines no `main` function **crashes**
(SIGSEGV/abort, "Segmentation fault at address 0x60") instead of emitting a clean
diagnostic like `error: no 'main' function found`.
- **Observed:** process crash, exit 134 (abort) / 139 (SIGSEGV); no diagnostic.
- **Expected:** a normal compile-style error ("no `main` entry point") and a
clean non-zero exit, the same way any other missing-entry condition reports.
Independent of inline assembly — surfaced while writing an ASM-stream probe that
omitted `main`, but reproduces with an ordinary, asm-free program (see below).
## Reproduction
A file with only an (uncalled) function and no `main`:
```sx
foo :: (n: u64) -> u64 { return n + 1; }
```
```sh
sx run that.sx
# => "Segmentation fault at address 0x60", exit 134
# expected: "error: no 'main' function found" (or similar), clean non-zero exit
```
## Root cause (suspected)
`src/target.zig` JIT-run path, ~lines 256273. After the ORC lookup:
```zig
var main_addr: c.LLVMOrcExecutorAddress = 0;
err = c.LLVMOrcLLJITLookup(jit, &main_addr, "main");
if (err != null) { /* prints "JIT lookup error" and returns error.CompileError */ }
// no guard for main_addr == 0 here:
const main_fn: *const fn () callconv(.c) i32 = @ptrFromInt(main_addr);
const result = main_fn(); // <- calls a null/garbage pointer when no main
```
When the module has no `main` symbol, the lookup leaves `main_addr` at `0` (or
ORC returns a degenerate success), so `@ptrFromInt(main_addr)` + `main_fn()`
calls into null → the crash. There is no `main_addr == 0` check.
## Investigation prompt (paste into a fresh session)
> `sx run` on a program with no `main` segfaults instead of diagnosing. The JIT
> run path in `src/target.zig` (~lines 256273) looks up `"main"` via
> `LLVMOrcLLJITLookup`, then unconditionally casts `main_addr` to a function
> pointer and calls it. When the program defines no `main`, `main_addr` is `0`
> (or the lookup degenerately "succeeds"), so the call dereferences null and
> crashes.
>
> Fix: after the lookup's `err` check, add `if (main_addr == 0) { … }` that emits
> a clean user-facing error ("no `main` function found" / "program has no entry
> point") and returns `error.CompileError` (matching the existing
> `JIT lookup error` style), BEFORE the `@ptrFromInt` + call. Consider whether a
> pre-JIT check (the module/program already knows whether a `main` decl exists —
> e.g. emit_llvm.zig:631 already null-checks `LLVMGetNamedFunction(.., "main")`)
> is the better choke point so the diagnostic carries a source span rather than a
> bare message. Either is acceptable; the hard requirement is *no crash*.
>
> Verification: `printf 'foo :: (n: u64) -> u64 { return n + 1; }\n' > /tmp/x.sx
> && sx run /tmp/x.sx` — expect a clean error message + non-zero exit, NOT a
> segfault. Add a pinned repro under `issues/` (or an `examples/11xx-diagnostics-*`
> once the message is settled) asserting the diagnostic on stderr + the exit code.

View File

@@ -1,130 +0,0 @@
# 0138 — `@const` (address-of a `::` comptime constant) yields a wild pointer
**Status:** RESOLVED
> **Resolution.** Root cause was in `src/ir/lower/expr.zig`'s unary `.address_of`
> lowering: a scalar `::` constant binds a folded *value* (`is_alloca == false`,
> no storage), so it skipped both the alloca path and `resolveGlobalRef`, then
> fell through to the generic `addr_of` arm which reinterpreted the value as a
> pointer (`inttoptr (i64 <value> to ptr)`). Fixed by diagnosing in the
> `address_of(identifier)` path — both the lexical case (a non-alloca,
> non-ref-capture, non-pack-elem scope binding) and the module case (a name in
> `module_const_map` that `resolveGlobalRef` did not back with storage) now emit
> "cannot take the address of constant '<name>' — a scalar '::' constant has no
> storage …" and return a placeholder Ref. Chose **diagnose** over materializing
> read-only storage (confirmed with the user): consistent with the fold-only
> scalar model; array/struct consts keep their real storage and stay addressable
> (`@K`/`@LIT` via `global_addr`, unchanged). This also gives the ASM stream's
> planned "output-to-`const` rejection" for free — asm `-> @const` lowers `@place`
> through the same path, so it now reports the clean diagnostic instead of an LLVM
> verifier failure. Regression: `examples/1177-diagnostics-addr-of-const-rejected.sx`
> (module const, local const, and asm `-> @const` write-through). `zig build test`
> green (659 corpus).
## Symptom
Taking the address of a `::`-bound comptime constant (`@x` where `x :: 40`)
does **not** produce a real address. The address-of lowering falls through to
the generic `addr_of` arm, which takes the *folded constant value* and
reinterprets it as a pointer:
```llvm
store ptr inttoptr (i64 40 to ptr), ptr %alloca, align 8
```
- **Observed:** `@x` of a const lowers to `inttoptr (i64 <value> to ptr)` — a
pointer whose numeric address IS the constant's value. Dereferencing it
segfaults (`@x` of `x :: 40` → wild pointer `0x28`). Using it as a store
destination (e.g. inline-asm `-> @x` write-through) emits invalid IR that
only the LLVM verifier catches: `Store operand must be a pointer / store i64
%asm, i64 40`.
- **Expected:** either a clean compile diagnostic ("cannot take the address of
comptime constant `x`") or materialization of read-only backing storage so
the address is real. Never a silent reinterpret-value-as-pointer (a textbook
silent-miscompile per CLAUDE.md).
This is **not** inline-asm-specific — it was discovered while implementing the
ASM stream's planned "output-to-`const` rejection for `-> @place`", but the
root cause is in the general address-of path. The same `-> @place`-to-const
rejection falls out for free once `@const` is handled correctly (asm lowers
`@place` through the same address-of path).
## Reproduction
Segfault on deref (no inline asm needed, no project deps):
```sx
main :: () -> i64 {
x :: 40; // comptime constant — no runtime storage
p := @x; // lowers to `inttoptr (i64 40 to ptr)` — wild pointer
return p.*; // segfault (deref of 0x28)
}
```
The IR for just `p := @x` (no deref) shows the defect directly:
```sx
main :: () -> i64 {
x :: 40;
p := @x;
return 7;
}
```
```llvm
%alloca = alloca ptr, align 8
store ptr inttoptr (i64 40 to ptr), ptr %alloca, align 8 ; <-- bug
ret i32 7
```
Inline-asm write-through to a const (the path that surfaced it) — invalid IR
caught by the verifier instead of a sx diagnostic:
```sx
FORTY :: 40;
main :: () -> i64 {
asm volatile { "mov %[c], #99", [c] "=r" -> @FORTY };
return FORTY;
}
```
`LLVM verification failed: Store operand must be a pointer.`
## Investigation prompt
> `@const` (address-of a `::`-bound comptime constant) miscompiles: instead of
> a real address it reinterprets the constant's *value* as a pointer
> (`inttoptr (i64 <value> to ptr)`), segfaulting on deref and producing invalid
> stores for inline-asm `-> @place` write-through to a const.
>
> **Suspected area:** `src/ir/lower/expr.zig`, the unary `.address_of` lowering.
> The clean `address_of(identifier)` path (~line 1994) only handles
> `binding.is_alloca` locals and globals (`resolveGlobalRef`). A `::` const is
> neither, so it falls through to the generic `.address_of` arm (~line 2057),
> which does `addr_of(self.lowerExpr(uop.operand))` — and `lowerExpr` of a const
> identifier folds to the constant value, so `addr_of` of an i64 constant emits
> `inttoptr`.
>
> **Fix likely needs to:** detect, in the `address_of(identifier)` path, that
> the resolved binding is a comptime constant with no storage. Then either (a)
> emit a clear diagnostic via `self.diagnostics.addFmt(.err, span, "cannot take
> the address of comptime constant `{s}`", .{name})` and return a dedicated
> sentinel (NOT a folded value) — matches CLAUDE.md's no-silent-default rule; or
> (b) materialize a read-only global/alloca for the const and return its real
> address. Decide which against `specs.md` (does sx intend `::` consts to be
> addressable at all?). Coordinate with PLAN-CONST-AGG's "const-write rejection"
> — a write through `@const` (asm `-> @place`, or a future `p.* = …`) must also
> be rejected; the read-only-storage option (b) still needs the write rejected.
>
> **Verification:** run the three repros above. Expect: repro 1 (`return p.*`)
> either fails to compile with the diagnostic, or returns 40 (if `::` consts
> become addressable); repro 3 (asm `-> @FORTY`) reports a clean sx diagnostic,
> NOT an LLVM verifier failure. Add a pinned regression under `issues/expected/`
> (or migrate to `examples/` once the behavior is decided).
## Notes
- Discovered mid-ASM-stream while starting the planned output-to-`const`
rejection step. Read-write `+` place outputs (the prior ASM step) shipped
green before this surfaced.
- Not covered by any existing issue or by `current/PLAN-CONST-AGG.md` (which
addresses const *writes* via assignment, not address-of).

View File

@@ -1,71 +0,0 @@
# 0139 — by-value self-referential type segfaults (`typeSizeBytes` infinite recursion)
> **RESOLVED.** Root cause: `typeSizeBytes` (and the layout path) recursed into
> each by-value aggregate field with no cycle guard, so a by-value self/mutual
> reference looped to a stack overflow. Fix: a new `checkInfiniteSize` pass
> (`src/ir/lower/decl.zig`, Pass 1g — after type registration, before body
> lowering) walks the by-VALUE containment graph; on a back-edge it emits a loud
> diagnostic (`type 'X' is infinitely sized (it contains itself by value); use a
> pointer ('*X') to break the cycle`) and poisons the offending field to
> `.unresolved`, breaking the recursion before any sizing runs. A pointer / slice
> / optional payload breaks the cycle, so `*Self` recursion stays valid. Covers
> both source decls and comptime-constructed (`declare`/`define`) types.
> Regression test: `examples/1178-diagnostics-infinite-size-self-reference.sx`.
**Symptom** — a type whose field/variant payload is ITSELF *by value* (not behind
a pointer) crashes the compiler with a stack-overflow segfault instead of a loud
"infinite size" diagnostic. Observed: `Segmentation fault` inside
`src/ir/types.zig:typeSizeBytes` (unbounded self-recursion through the field
loop). Expected: a clean compile error naming the offending type and suggesting
`*Self`.
**Pre-existing / scope** — NOT specific to the comptime `declare`/`define`
metaprogramming; a hand-written SOURCE enum reproduces it identically. So the fix
belongs in the general type-system size/layout path, and the comptime
construction path (METATYPE F5) inherits the protection for free once it lands.
**Reproduction** (source enum — no metaprogramming needed):
```sx
#import "modules/std.sx";
Bad :: enum {
node: Bad; // by-VALUE self-reference → infinite size
leaf;
}
main :: () -> i32 {
x : Bad = .leaf;
return 0;
}
```
Same crash via the metaprogramming path (`#import "modules/std/meta.sx"`):
```sx
make_bad :: () -> Type {
h := declare("Bad");
return define(h, .enum(.{ variants = .[
EnumVariant.{ name = "node", payload = Bad }, // by-value self-ref
EnumVariant.{ name = "leaf", payload = void } ] }));
}
Bad :: make_bad();
```
**Investigation prompt** — A by-value self-referential (or mutually-recursive)
aggregate has infinite size and must be rejected loudly, not recursed into
forever. The crash is in `src/ir/types.zig:typeSizeBytes` (~line 736), which
recurses into each struct/tagged-union field's type with no cycle guard; a field
typed as its own (or an enclosing) nominal type recurses unboundedly →
stack overflow. Fix: detect the cycle — walk the nominal-type dependency with a
visited set (or a recursion-depth / on-stack-nominal guard), and when a nominal
type reaches itself by value (no pointer indirection on the path), emit a
diagnostic via `self.diagnostics.addFmt(.err, span, "type '{s}' is infinitely
sized (it contains itself by value); use a pointer (`*{s}`) to break the cycle",
…)` and return a sentinel size (e.g. 0 with the error already raised, or poison)
rather than recursing. A pointer field (`*Bad`) breaks the cycle and must stay
allowed (pointers have a fixed size and don't recurse into the pointee).
Verification: run the repro above — expect the loud diagnostic + non-zero exit,
NOT a segfault. Add an `examples/11xx-diagnostics-*` (or `issues/`) pin once the
message is settled. This also closes METATYPE PLAN F5's "by-VALUE self-reference
rejected" item for the comptime path.

View File

@@ -1,143 +0,0 @@
# 0140 — a failing comptime type construction panics ("unresolved type reached LLVM emission") instead of diagnosing the bail
> **RESOLVED (2026-06-17).** Root cause exactly as the investigation prompt
> hypothesized: `evalComptimeType` (`src/ir/lower/comptime.zig`) did
> `interp.call(...) catch return null`, dropping the interpreter's
> `last_bail_detail`; callers poisoned to `.unresolved` with no diagnostic, so the
> sentinel reached LLVM emission and tripped the codegen panic (or hid behind a
> downstream cascade). Fix: clear `Interpreter.last_bail_detail` before the call,
> and on the `catch` emit a build-gating `.err` at the construction expression's
> span — `"comptime type construction failed: {detail}"` (mirroring the `#run`
> surfacing at `emit_llvm.zig:856`) — then return null (keeping the `.unresolved`
> poison, now gated by a real message). The empty-variants repro now prints
> `comptime type construction failed: comptime define(): enum has no variants`
> and exits 1 (no panic); make_enum-style computed-slice failures surface their
> root reason at the construction site instead of only the `.green` cascade.
> Regression test:
> [examples/1179-diagnostics-comptime-type-construction-bail.sx](../examples/1179-diagnostics-comptime-type-construction-bail.sx).
## Symptom
One-line: when a comptime type construction (`declare`/`define`) bails in the
interpreter, the failure is swallowed — the decl is poisoned to `.unresolved`
with **no diagnostic**, and that `.unresolved` reaches LLVM emission and panics
instead of emitting a clean, build-gating error that names the bail reason.
- **Observed:** `thread … panic: unresolved type reached LLVM emission — a type
resolution failure was not diagnosed/aborted`
(`src/backend/llvm/types.zig:176`, the `.unresolved` arm of `toLLVMTypeInfo`),
reached from `emitAlloca` (`src/backend/llvm/ops.zig:329`) for the local
`e : Empty = ---`. Exit 134 (panic), not a diagnostic.
- **Expected:** a build-time `.err` at the construction site carrying the
interpreter's bail detail — `defineEnum` already produces the precise reason
("comptime define(): enum has no variants") via `bailDetail`, which sets
`Interpreter.last_bail_detail`. Exit 1, no panic, message visible to the user.
This is **PRE-EXISTING** and orthogonal to the METATYPE `type_info` work that
surfaced it: the repro uses only the plain `define` path with an empty literal
variant list (`type_info` is not involved). It reproduces for *any* comptime
construction that bails — bad/empty `TypeInfo`, a `variants` value the decoder
can't read (e.g. a pointer-backed `[]EnumVariant` slice built from a local
variable / `List`, which is the next thing the make_enum step needs), etc.
## Reproduction
Minimal, standalone (only `modules/std.sx` + `modules/std/meta.sx`):
```sx
#import "modules/std.sx";
#import "modules/std/meta.sx";
Empty :: define(declare("Empty"), .enum(.{ variants = .[] }));
main :: () -> i32 {
e : Empty = ---;
return 0;
}
```
Run: `./zig-out/bin/sx run issues/0140-comptime-type-construction-bail-unresolved-panic.sx`
→ panics today (exit 134); the fix should emit a diagnostic naming the bail
reason and exit 1 (no panic).
### Bisection (what does / does not trigger the *panic*)
| Variant | Result |
|---|---|
| `Empty :: define(declare("Empty"), .enum(.{ variants = .[] }))` + a *local* `e : Empty` | **PANICS** (exit 134) |
| same construction, but `Empty` is only *referenced as a type* (no value created) | poisons silently — often a confusing downstream cascade, no root reason |
| a make_enum-style computed slice `define(declare(n), .enum(.{ variants = local_slice }))` then a `.variant` *literal* | exit 1 with `"cannot infer enum type for '.x'"` — the literal-inference error fires first and *incidentally* gates emission, so no panic, but the **real bail reason is still never shown** |
So the panic specifically needs the `.unresolved` type to survive to emission
(here via a local `alloca`); when some *other* diagnostic happens to fire first,
the build aborts before emission and the panic is dodged — but in **every** case
the actual interp bail reason (`last_bail_detail`) is lost and the user sees
either a panic or a misleading follow-on, never the root cause.
## Investigation prompt
> A comptime type construction (`declare`/`define`, and reflection like
> `type_info`) that bails in the interpreter is swallowed: the build either
> panics at LLVM emission ("unresolved type reached LLVM emission") or shows a
> misleading downstream cascade, instead of a clean diagnostic naming the bail
> reason. Repro:
> `issues/0140-comptime-type-construction-bail-unresolved-panic.sx` (panics,
> exit 134 today; the fix should print a diagnostic with the bail reason and
> exit 1 — no panic).
>
> Root area: `evalComptimeType` in `src/ir/lower/comptime.zig` (~line 457):
>
> ```zig
> const result = interp.call(func_id, &.{}) catch return null;
> return result.asTypeId();
> ```
>
> The `catch return null` drops the interpreter's `last_bail_detail`
> (`Interpreter.last_bail_detail`, `src/ir/interp.zig:218`, set by every
> `bailDetail(...)` — e.g. `defineEnum`'s "enum has no variants"). The two
> callers then poison to `.unresolved` with NO diagnostic:
> - `src/ir/lower/decl.zig:777`: `const tid = self.evalComptimeType(cd.value) orelse TypeId.unresolved;`
> then `putTypeAlias(..., .unresolved)`.
> - `src/ir/lower/generic.zig:1762`: `orelse return .unresolved`.
>
> `.unresolved` is the correct *sentinel* (it trips the codegen tripwire so a
> resolution failure can never silently ship), but here NOTHING converts it into
> a user-facing diagnostic, so it either crashes at emit or rides along behind a
> follow-on error.
>
> Suspected fix: in `evalComptimeType`, on the interp error path, emit a
> diagnostic at the construction expression's span carrying `last_bail_detail`
> (mirror how `src/ir/emit_llvm.zig:856` / `:933` already surface
> `last_bail_detail` for `#run` / comptime-function evaluation —
> `Interpreter.last_bail_detail orelse "<generic>"`). Reset
> `last_bail_detail = null` before the call and read it after the `catch`. Return
> `.unresolved` (keep the poison) *after* the diagnostic has been emitted, so the
> build is gated with a real message and no `.unresolved` reaches emission
> unannounced. Make sure BOTH callers (decl + generic) route through the
> diagnostic — ideally emit inside `evalComptimeType` so neither caller can
> forget. Do NOT swap `.unresolved` for a "reasonable-looking" default type
> (per CLAUDE.md REJECTED PATTERNS); the sentinel + diagnostic is the right shape.
>
> Verification: the repro emits a diagnostic naming the bail reason and exits 1
> (no panic); then `zig build && zig build test` green. Pin the repro as an
> `11xx` diagnostics example (move to
> `examples/11xx-diagnostics-comptime-type-construction-bail.sx`, seed the
> `expected/*.exit` marker, capture with `-Dupdate-goldens`, review the diff).
> Also add a positive note in `current/CHECKPOINT-METATYPE.md` that the
> make_enum computed-slice step can proceed once this lands (the decoder's
> "variants is not a slice/array" bail will then be a clean diagnostic instead
> of a cascade/panic).
## Notes
- Tripwire site (symptom): `src/backend/llvm/types.zig:176` (`toLLVMTypeInfo`,
`.unresolved` arm) via `emitAlloca` (`src/backend/llvm/ops.zig:329`).
- Root area (cause): `evalComptimeType` `catch return null`
(`src/ir/lower/comptime.zig:~457`) drops `last_bail_detail`; callers
`decl.zig:777` / `generic.zig:1762` poison to `.unresolved` with no diagnostic.
- The interpreter already computes the precise reason — `bailDetail` /
`typeErrorDetail` set `Interpreter.last_bail_detail` (`interp.zig:218`); the
`#run` path at `emit_llvm.zig:856` is the existing precedent for surfacing it.
- Blocks: the make_enum computed-(non-literal)-variant-list step
(`current/PLAN-METATYPE.md` Status), whose decode failures currently land in
exactly this swallow.

View File

@@ -1,354 +0,0 @@
# 0141 — `List(T).append` at comptime (in a type-construction `::`) bails
> **Status: RESOLVED (2026-06-19).**
>
> **Root cause (final, after the legacy interp was deleted and the comptime VM
> became the sole evaluator):** the original repro passes `vs.items` — a bare
> many-pointer `[*]EnumVariant` — where a slice `[]EnumVariant` is expected. A
> `[*]T` carries NO length, so there was no valid implicit coercion to `[]T`; the
> classifier fell through to `.none` and passed the bare 8-byte data pointer
> UNCHANGED where a 16-byte `{ptr,len}` fat pointer was expected. The callee then
> read the slice header off the wrong bytes. At RUNTIME this failed LLVM
> verification (8-byte ptr into a `{ptr,len}` param slot); at COMPTIME the VM read
> `.ptr`/`.len` from adjacent bytes and dereferenced a garbage data pointer (a
> comptime `Addr` is a REAL host pointer), faulting at address `0x646572` ("red")
> inside `comptime_vm.decodeMemberSlice`. This was a silent-wrong-coercion (a
> CLAUDE.md REJECTED PATTERN), not a List/allocator/slot_ptr problem — the
> List growth, the comptime allocator, and `define`/`decodeMemberSlice` are all
> correct (the prior multi-layer analyses below predate the VM rewrite and are
> SUPERSEDED).
>
> **Fix:**
> 1. `src/ir/conversions.zig` — `CoercionResolver.classify` now classifies
> `[*]T → []T` as the new `.many_to_slice_reject` plan (added to `CoercionPlan`).
> 2. `src/ir/lower/coerce.zig` — `coerceMode`'s new `.many_to_slice_reject` arm
> emits a build-gating diagnostic: *"a many-pointer '[*]T' does not coerce to a
> slice '[]T' implicitly (it carries no length) — slice it with a length:
> ptr[0..len]"*.
> 3. `src/ir/lower/comptime.zig` — `runComptimeTypeFunc` now skips the VM eval when
> `diagnostics.hasErrors()` is already set. A type-fn whose body failed coercion
> holds malformed IR; running the VM on it would deref garbage (the VM's
> bail-not-crash guards catch malformed *Refs*, not malformed comptime *data*).
> The user's real diagnostic is already on the list, so the build aborts cleanly
> instead of segfaulting.
>
> **Correct spelling** for the List-grown form is `vs.items[0..vs.len]` (a
> subslice that supplies the length), exactly like the array form's `dirs[0..2]`
> in `examples/0621`.
>
> **Regression tests:**
> - `examples/0640-comptime-list-grown-variant-define.sx` — the List-grown enum
> construction with the correct `vs.items[0..vs.len]` spelling → prints
> `green=7`, exit 0 (the feature this issue tracked, now working on the VM).
> - `examples/1183-diagnostics-many-pointer-to-slice-rejected.sx` — the bare
> `[*]T → []T` mis-coercion now produces the clean diagnostic (exit 1), no crash.
>
> ---
>
> *Original (now-stale) writeup follows — kept for history.*
>
> **Status: OPEN — deferred enhancement, NOT a blocker.** Building a comptime
> variant/field list with an array-literal local already works
> (`examples/0620`/`0624`); only the `List`-grown form fails. Filed to record the
> two-layer root cause for a dedicated session. Surfaces a CLEAN diagnostic, not a
> crash.
>
> **UPDATE (2026-06-18): the flat-memory VM now HANDLES this pattern via the
> compiler-API.** A type-fn that builds its members in a `List` (`.append` then
> `register_type(handle, kind, vs.items[0..vs.len])`) runs end-to-end on the VM
> (`-Dcomptime-flat`) — both 0141 blockers are gone there: lowering-time allocation
> works (the CAllocator thunks are now force-created in `runComptimeTypeFunc` and
> the VM's `materializeDefaultContext` lays their func-refs into a real context),
> and pointer field access has no slot_ptr chain (flat memory; `aggType` derefs a
> pointer `base_type`, `subslice` handles `[*]T`). What still fails is the ORIGINAL
> repro below, which uses the metatype `define`/`make_enum` (`#builtin` →
> `call_builtin` → VM falls back to legacy → legacy's slot_ptr + null-allocator
> bugs). Resolving the repro on the VM needs the metatype re-expressed over the
> compiler-API (so the type-fn hits no `call_builtin`); shipping it on BOTH gates
> needs the VM-default flip + legacy deletion. See CHECKPOINT-COMPILER-API
> (2026-06-18 "step 6").
## Symptom
One-line: a `List(T)` created and `.append`-ed at compile time inside a
type-construction `::` const bails — `comptime type construction failed:
comptime struct_get: base has no fields (not an aggregate/string/int)` — even
though the identical `List` code runs fine at RUNTIME and via `#run`.
- **Observed:** the `::` const evaluates to `.unresolved` after the interp bails
on the first `vs.append(...)`; the user sees the construction-failed diagnostic
plus a follow-on "cannot infer enum type for '.green'".
- **Expected:** the `List`-built variant list mints the enum exactly as the
array-literal form does (`examples/0620`): `Color` constructs, `.green(7)`
matches, prints `green=7`, exit 0.
## Reproduction
`issues/0141-comptime-list-growth-in-type-construction.sx` (standalone; only
`modules/std.sx` + `modules/std/meta.sx`). Run:
`./zig-out/bin/sx run issues/0141-comptime-list-growth-in-type-construction.sx`
→ bails today; the fix should print `green=7`, exit 0.
### Bisection (key signal: WHEN the comptime eval runs)
| Form | Path / eval time | Result |
|---|---|---|
| `List(i64)` append, read at RUNTIME (in `main`) | codegen | **works** |
| `v :: #run build()` where `build` grows a `List(i64)` | EMIT-time interp | **works** (`.sx-tmp/probe_list4.sx`) |
| `T :: makeListType()` where the body grows a `List` | `scanDecls`-time interp (`evalComptimeType`) | **BAILS** |
| same metatype `::` but with an array-LITERAL local instead of `List` | `scanDecls`-time interp | **works** (`examples/0620`/`0624`) |
The discriminator is eval time: `#run` evaluates at EMIT time (after the whole
program is lowered), whereas a metatype `::` const evaluates during `scanDecls`
(early, mid-lowering). Two things are not yet ready at `scanDecls` time.
It is NOT metatype/EnumVariant-specific — a plain `List(i64)` grown in a
`-> Type` body bails identically (`.sx-tmp/probe_li64.sx`).
## Root cause — REFINED (2026-06-17): wrong IR at scanDecls, not just "not ready"
Deeper investigation overturns the "two independent layers" framing below. The
real root cause is a single one: **a generic stdlib method's body is lowered to
WRONG IR when that lowering is triggered at `scanDecls` time** (during the
metatype `::` eval), because the generic struct instantiation context is
incomplete then.
Proof (instrumented `interp.zig`'s `.struct_get` / `.struct_gep` arms, ran the
same `List(i64)` append both ways):
| Eval time | `list.len` / `list.cap` (where `list: *List(T)`) lowers to | Result |
|---|---|---|
| `#run` (EMIT time, world complete) | `struct_gep` (pointer field access) — 38 hits | works |
| metatype `::` (scanDecls time) | `struct_get` (VALUE field access) — fails on the 1st | bails |
So `List.append` is fully lowered in BOTH cases (no unlowered/extern call fires),
but at `scanDecls` time `list.len` lowers as `struct_get` on the pointer VALUE
instead of `struct_gep` THROUGH the pointer — `struct_get` on a `*List` receiver
sees a `slot_ptr` whose load is another `slot_ptr` and bails. The two "layers"
below are both symptoms of this same incomplete-context lowering (the null
allocator is the same story for the CAllocator thunks).
**Consequence for the fix:** an interp-side "lazy-lower the missing function"
hook does NOT help — the function is already lowered, just to wrong IR before the
interp ever runs. The fix must ensure the bodies the metatype eval needs are
lowered with a COMPLETE type context. Two viable directions:
1. **Make field-access lowering robust**`list.len` on a `*List(T)` receiver
must emit `struct_gep` whenever the receiver is a pointer-to-struct, even if
the pointee's generic instantiation isn't finalized yet (resolve the field
index against the in-progress instantiation). Localized to the
field-access / generic-struct-instantiation path; risk is mis-lowering other
in-progress generics.
2. **Defer the comptime type-construction eval** to a dedicated pass AFTER the
stdlib/generic machinery the constructors call is lowered, but before general
body lowering of code that USES the constructed types (their forward slots
are already pre-registered, so `*Name` / annotations resolve in the interim).
This is the true "lazy/deferred" shape — the eval runs in a complete world,
exactly like `#run`. Bigger (pipeline ordering) but matches why `#run` works.
Decision pending (see the conversation) — direction 2 is the principled match to
the `#run`-works/metatype-fails asymmetry.
## Implementation plan — Direction 2 (defer eval to a complete-world pass)
Chosen direction: move the comptime type-construction eval out of `scanDecls`
(Pass 1) into a new pass that runs once the world is complete enough that the
constructor bodies lower correctly.
**Pass map (`decl.zig:lowerRoot`).** Today the eval is at Pass 1 (`scanDecls`,
`decl.zig:777`). The CAllocator thunks are created at Pass 1c
(`emitDefaultContextGlobal`) — AFTER scanDecls — which is why the comptime
allocator is null. `checkInfiniteSize` (Pass 1g) and body lowering (Pass 2)
consume the constructed layouts, so the eval must finish before Pass 1g. Target
slot for the new pass: **between Pass 1c and Pass 1g** (call it Pass 1c
`lowerDeferredComptimeTypes`).
**STEP 0 — DE-RISK FIRST (DONE 2026-06-17 — Direction 2 RULED OUT as scoped).**
Wired the minimal deferral (collect the consts in scanDecls + `preregisterForwardTypes`
eagerly; eval them in a new Pass 1c right after `emitDefaultContextGlobal`).
Result: the List repro STILL bailed with `struct_get` — deferring past the thunks
did NOT change `list.len` to `struct_gep`. So the wrong-IR cause is **not**
pass-position relative to `emitDefaultContextGlobal`; it's the field-access /
generic-struct-instantiation lowering itself, which only produces `struct_gep` at
**body-lowering (Pass 2) / emit** time, not at any pre-body-lowering pass. Worse,
the deferral DESTABILIZED a working case (`examples/0620`
"define(): handle is not a declare()'d enum slot", a forward-slot/alias ordering
regression). Experiment reverted.
**Revised conclusion.** There is no single pass slot where (a) the constructor body
lowers correctly AND (b) the constructed layout is ready before code that uses it:
the body only lowers right at Pass 2/emit, but the layout is consumed *during*
Pass 2. So a simple "defer the eval" (Direction 2) can't work; it would need a
genuine two-phase scheme. The tractable path is **Direction 1** — fix the
field-access lowering so `recv.field` on a `*Struct` receiver emits `struct_gep`
regardless of when it's lowered. Next step: find why `list.len` (`list: *List(T)`)
lowers as `struct_get` (value) instead of `struct_gep` (pointer) at scanDecls —
i.e. what about the generic `List(T)` instantiation is incomplete then that flips
the field-access decision (start at `lower/expr.zig:lowerFieldAccess` /
`lowerFieldAccessOnType` and the `*T`-receiver path).
## Root cause — RE-REFINED (2026-06-18): the IR is CORRECT; it's a legacy slot_ptr chain + null comptime allocator
Direction 1's premise is **wrong** — the IR is NOT mis-lowered. Instrumented
`lowerFieldAccess` (the `*T`-receiver auto-deref) on the repro: `list.len` lowers
with `obj_ty = *List__EnumVariant` (kind=**pointer**), so the auto-deref fires
(`obj = load(list, List); struct_get(obj, len_idx)`) — **byte-identical** to the
runtime/`#run` shape. There is no `struct_get`-vs-`struct_gep` divergence in the
READ path (field reads ALWAYS lower to load+`struct_get` via `expr.zig:~915`;
`struct_gep` is only the WRITE/lvalue path — the "38 hits" above were writes).
The bail is purely in the **legacy interp's slot_ptr semantics**. Instrumented the
`.struct_get` arm: for `list.len` the base is a `slot_ptr`, and the arm's own
`slot_ptr` auto-deref (`loadSlot`) yields **another `slot_ptr`** (a `*List` param
→ slot_ptr → slot_ptr chain), which no `switch(base)` case resolves → "base has no
fields". So `load(*List)` produced a slot_ptr the interp can't flatten to the
aggregate at this call shape.
There is a SECOND, independent blocker that survives even if the slot_ptr chain is
fixed: `List.append` grows on the first call (cap 0→4) → `context.allocator.alloc_bytes`
`call_indirect` on a **null `alloc_fn`** at lowering time (the comptime allocator
is null at type-construction time — confirmed in BOTH evaluators; see the
CHECKPOINT-COMPILER-API 2026-06-18 entry). So the repro needs BOTH the slot_ptr
chain AND the comptime allocator fixed.
**Strategic implication (2026-06-18).** Both blockers are LEGACY-interp issues
(slot_ptr chains; null comptime allocator dispatch — "raw fn-pointers from extern
calls aren't dispatchable in interp"). The flat-memory comptime VM (`comptime_vm.zig`)
has neither failure mode by construction: it uses flat byte memory (no slot_ptr
chains) and models `malloc`/`call_indirect` natively. So the principled fix is NOT
to patch the legacy interp (code slated for deletion), but to let the VM evaluate
these type-fns: (a) re-express the metatype `define`/`make_enum` over the
compiler-API so the type-fn body hits NO `call_builtin(define)` (which forces a
legacy fallback today), and (b) make `materializeDefaultContext` lay the REAL
allocator func-refs at lowering time (the global exists by Pass 1c, so the
`layoutConst` path should populate them — verify it handles the inline-protocol
`Allocator` field, which may need a `.protocol`/inline-struct arm). Then a
List-building type-fn runs entirely on the VM. The remaining tension is dual-path
validation: while the legacy still fails, a corpus example can't pass gate-OFF — so
this lands cleanly only alongside the VM-default flip + legacy deletion (the
end-state). Until then it stays a deferred enhancement, not a blocker.
**Plumbing (only after STEP 0 is green):**
1. `scanDecls` (`decl.zig:777` site): instead of `evalComptimeType` now,
(a) pre-register a forward nominal slot named `cd.name` + bind the alias
`cd.name → slot` (so `c : Color`, Pass 1f's UnknownTypeChecker, etc. resolve in
the interim), and (b) push `{ name, value, source_file }` to a new
`deferred_comptime_types` list on `Lowering`. Don't eval.
2. New `lowerDeferredComptimeTypes` pass, called from `lowerRoot` after
`emitDefaultContextGlobal` and before `checkInfiniteSize`: for each entry, set
`current_source_file`, `const tid = evalComptimeType(value)`, `putTypeAlias`.
The interp's `declare("Color")` finds the pre-registered slot (findByName) and
`define` fills it in place (`updatePreservingKey`), so `tid` == the forward slot
— alias stays valid.
3. Self-ref (`List :: make_list()` + `*List`): the forward slot for `List` is
registered in step 1, so `*List` resolves while `make_list`'s body lowers during
the deferred eval. Verify `examples/0618` still passes.
**Risks / watch:**
- **Name mismatch.** The minted type's name comes from `declare("X")` inside the
ctor, not the LHS `cd.name` (`decl.zig` comment "no rename"). For the
non-generic `::` path the two normally coincide, but if `declare`'s string ≠
`cd.name` the pre-registered forward slot is orphaned (empty tagged_union →
could trip the declare-never-defined / infinite-size checks). Handle: either
require the names to match here, or reconcile the orphan after eval.
- **Generic type-fns** (`RecvResult($T)`) go through `instantiateTypeFunction`, a
DIFFERENT site (lazy, at use) — leave those as-is; only the non-generic `::`
site (`decl.zig:774`) defers.
- Re-run the FULL suite: every existing metatype example (06140624, 11781182)
must stay green — the deferral changes *when* they evaluate.
## Root cause — TWO independent layers (SUPERSEDED by the refinement above)
### Layer 1 — null comptime allocator (has a known fix)
`src/ir/interp.zig:defaultContextValue` builds the comptime `context.allocator`
by looking up the CAllocator→Allocator protocol thunks BY NAME in the module's
functions:
```zig
const alloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_alloc_bytes");
// ... scan self.module.functions for that name ...
```
At `scanDecls` time those thunks aren't lowered yet, so `alloc_fn` / `dealloc_fn`
stay `.null_val` and ANY comptime allocation (List growth, direct
`context.allocator.alloc`) fails. Confirmed with a debug print: metatype path →
`alloc_fn=null_val`; `#run` path → `alloc_fn=func_ref`.
**Fix (verified for this layer):** force the thunks to exist before the interp
runs, in `src/ir/lower/comptime.zig:runComptimeTypeFunc`, guarded exactly like
`emitDefaultContextGlobal` (skip when Allocator/CAllocator aren't registered):
```zig
const tbl = &self.module.types;
if (tbl.findByName(tbl.internString("Allocator")) != null and
tbl.findByName(tbl.internString("CAllocator")) != null)
{
_ = self.getOrCreateThunks("Allocator", "CAllocator");
}
```
`createProtocolThunk` saves/restores builder state (`saved_func`/`saved_block`/
`saved_counter`), so calling it mid-lowering is safe (same as
`emitDefaultContextGlobal`). After this, `alloc_fn=func_ref` — but layer 2 still
bails.
### Layer 2 — `struct_get` through a `*T` slot_ptr chain (the deep part)
With the allocator fixed, `vs.append(…)` still bails. `List.append` takes
`self: *List`; the `vs.append(…)` UFCS desugars to `append(@vs, …)`, so inside
`append` the receiver `self` is a `*List`. At comptime it lands as a frame slot
whose CONTENTS are a `slot_ptr` to the actual `List` value, so `self.field` does
`struct_get` on `base=slot_ptr field_index=1` and falls through to the bail.
`src/ir/interp.zig`'s `.struct_get` arm auto-derefs a `slot_ptr` base with a
SINGLE `loadSlot` (+ `resolveFieldLoad` for field-pointer aggregates). A
chain-resolve loop (`while (loaded == .slot_ptr) loaded = loadSlot(...)`) did NOT
fix it: the final loaded value is a field-pointer aggregate that
`resolveFieldLoad` turns back into a `slot_ptr`. List's comptime in-memory
representation mixes field-pointers and slot_ptrs that the `struct_get` /
`resolveFieldLoad` path doesn't fully resolve for a `*T` receiver.
This is the substantive work: comptime pointer/struct/slot resolution for `*T`
struct receivers — its own focused interp session.
## Investigation prompt
> A `List(T)` grown at comptime inside a type-construction `::` bails
> ("struct_get: base has no fields"), though the same code works at runtime and
> via `#run`. Repro: `issues/0141-comptime-list-growth-in-type-construction.sx`
> (expect a bail today; the fix should print `green=7`, exit 0).
>
> It's two layers (see this file's Root cause). START with layer 1 (the known
> fix: force `getOrCreateThunks("Allocator","CAllocator")` in
> `comptime.zig:runComptimeTypeFunc` before the interp runs, guarded like
> `emitDefaultContextGlobal`). Verify with a debug print that `defaultContextValue`
> then sees `alloc_fn=func_ref`.
>
> THEN layer 2 (the real work): make the interp's `.struct_get` (and
> `index_get`/store paths) resolve a `*T` struct receiver whose slot holds a
> `slot_ptr` to the value. Reproduce in isolation with a plain non-generic
> `Box :: struct { x: i64; }` and a `bump :: (b: *Box) { b.x += 1; }` called at
> comptime, so you debug the pointer-receiver `struct_get` without List's
> generics. Trace what `frame.getRef(fa.base)` / `loadSlot` / `resolveFieldLoad`
> return for `self.field` and make the deref fully resolve to the backing
> aggregate (mirror `resolveSlotChain`, but for the field-pointer + slot_ptr mix
> that a `*T` receiver produces). Don't add a silent fallback — bail loudly if a
> shape still isn't handled (per CLAUDE.md REJECTED PATTERNS).
>
> Verification: the repro prints `green=7`, exit 0; then `zig build && zig build
> test` green. Move the repro to `examples/06xx-comptime-metatype-make-enum-list.sx`
> (resolving-an-issue workflow) and add a focused `*T`-comptime-receiver example
> too. Update `current/CHECKPOINT-METATYPE.md` (the last deferred enhancement).
## Notes
- Bail site (symptom): `src/ir/interp.zig` `.struct_get` arm, `else =>`
"struct_get: base has no fields".
- Layer-1 site: `src/ir/interp.zig:defaultContextValue` (thunk-by-name lookup);
fix in `src/ir/lower/comptime.zig:runComptimeTypeFunc`.
- Layer-2 site: `src/ir/interp.zig` `.struct_get` auto-deref (single `loadSlot` +
`resolveFieldLoad`); `*T` receiver slot_ptr chain unresolved.
- Both layers reproduce with a plain `List(i64)` — not metatype-specific. The
metatype `::` path just happens to be the first `scanDecls`-time comptime eval
that needs heap allocation.
- Workaround (no fix needed for callers): build the variant/field list with an
array-literal local — `examples/0620` / `0624` already do this.

View File

@@ -1,127 +0,0 @@
# 0142 — comptime-minted all-void (fully payloadless) enum
> **RESOLVED (2026-06-18).** Two distinct issues were tangled in the original
> report (the "binds to `Any`" symptom was a *syntax* misdiagnosis):
>
> 1. **Real bug:** `defineEnum` (and the new `register_type`) minted a fully
> payloadless enum as an all-void `tagged_union`, whose IR size disagrees with
> its LLVM size → `verifySizes` panic at codegen. **Fix:** mint a real
> `.@"enum"` when every variant is payloadless (`src/ir/interp.zig`
> `defineEnum`; `src/ir/compiler_lib.zig` `handleRegisterType` kind 2).
> 2. **Missing syntax (the "Any" error):** `EnumType.variant` qualified
> construction of a *payloadless* variant wasn't supported (it failed for
> hand-written enums too — `field 'X' not found on type 'Any'`, because the
> type name lowered to a `Type` value). **Fix:** `src/ir/lower/expr.zig`
> `lowerFieldAccess` now recognises a bare `Enum.variant` payloadless literal
> (mirroring the `alias.Enum.variant` namespace path), via the new
> `isPayloadlessVariant`. Payload-carrying variants keep their call form
> (`Shape.circle(2.0)`).
>
> Regression tests: `examples/0632-comptime-metatype-make-enum-payloadless.sx`
> (make_enum all-void), `examples/0187-types-enum-qualified-variant.sx` (qualified
> construction), `examples/0631`/`0633`/`0634` (compiler-API minted enums, bare +
> namespaced import).
## Symptom (as originally — partly a syntax misdiagnosis; see banner)
A comptime type-fn that mints a **fully payloadless** enum (every variant
tagless, `payload = void`) via `make_enum` / `declare` + `define` returns a type
whose alias binds to `Any` instead of the minted enum — so any later use of the
alias as a type fails with `field '<variant>' not found on type 'Any'`.
- **Observed:** `Suit :: make_suit()` (all-void variants) → `Suit.spades` errors
`field 'spades' not found on type 'Any'`.
- **Expected:** `Suit` is the minted enum; `Suit.spades` constructs the variant
(exactly as it does when at least one variant carries a payload).
The type **is** minted correctly — reflecting it through the comptime compiler
API shows `kind = 2` (enum) and the right variant count; only the type-fn's
**return value / alias binding** is wrong. A *mixed* variant list (≥1 non-void
payload) works end-to-end; only the all-void case fails. This is independent of
the new `register_type` write API — it reproduces with the shipped metatype
`make_enum`.
## Reproduction
```sx
#import "modules/std.sx";
#import "modules/std/meta.sx";
make_suit :: () -> Type {
return make_enum("Suit", EnumVariant.[
EnumVariant.{ name = "hearts", payload = void },
EnumVariant.{ name = "spades", payload = void },
]);
}
Suit :: make_suit();
main :: () {
s := Suit.spades;
if s == {
case .hearts: { print("hearts\n"); }
case .spades: { print("spades\n"); }
}
}
```
Run: `./zig-out/bin/sx run repro.sx``error: field 'spades' not found on type 'Any'`.
Contrast (works — one variant carries a payload):
```sx
make_lvl :: () -> Type {
return make_enum("Lvl", EnumVariant.[
EnumVariant.{ name = "info", payload = void },
EnumVariant.{ name = "fatal", payload = i64 }, // ← non-void makes it work
]);
}
Lvl :: make_lvl();
```
(This is why `examples/0620-comptime-metatype-make-enum.sx` passes — its variant
list is mixed, not all-void.)
## Investigation prompt
A comptime type-fn returning a *fully payloadless* enum (`define`
`tagged_union` with every field `ty == .void`) binds the result alias to `Any`
(`TypeId` 13) instead of the minted type, even though the type is correctly
registered in the table (findable by name; reflects as kind=2). A mixed list (≥1
non-void payload) returns the correct `TypeId`. Find why the all-void case yields
`Any`.
Suspected area:
- `src/ir/lower/comptime.zig` `runComptimeTypeFunc` / `evalComptimeType` — the
result path. `runComptimeTypeFunc` already special-cases a zero-FIELD
`tagged_union` (declared-but-never-defined); check whether an all-void
(non-zero-field) `tagged_union`/`enum` is being normalized, rejected, or
coalesced to `.any` somewhere on the way back. Print the `TypeId` returned by
`result.asTypeId()` for the all-void vs mixed case to localize.
- `src/ir/interp.zig` `defineEnum` (≈2157) / `defineType` — what TypeId/`Value`
it returns for an all-void variant set; whether an all-void `tagged_union`
interns/dedupes to a builtin (note: a 2-variant all-void union has *no payload
storage*, so its structural key may collide with something — or `replaceKeyedInfo`
may leave the handle pointing at a coalesced slot).
- Whether an all-void payloadless enum should mint as `.@"enum"` (payloadless)
rather than an all-void `.tagged_union` in the first place — and whether the
`.any` leak is downstream of that representation choice.
Likely fix: ensure the type-fn returns the real minted `TypeId` for an all-void
payloadless enum (don't coalesce/normalize it to `.any`), or mint it as a proper
`.@"enum"`. Whatever the cause, surface it — never silently substitute `.any`.
Verification: run the repro above → expect `spades` printed (exit 0). Also
confirm `examples/0620` still passes and add an all-void variant case as a
regression example.
## Context (why this was hit)
Surfaced while building the comptime compiler-API **write side** (Phase 3 of
`current/PLAN-COMPILER-VM.md`): `register_type(handle, kind, members)` minting an
**actual payloadless enum** (`kind = 2 → .@"enum"`). The new write API mints the
type correctly (reflection confirms kind=2/count=2), but the *alias binding* of a
fully-payloadless minted type hits this pre-existing metatype bug — so the
"actual enum" example can't be verified end-to-end until this is fixed. The
`register_type` work (struct, tagged_union with payloads, and the
mutually-recursive A↔B graph) is otherwise working; it is **uncommitted**, paused
pending this fix.

Some files were not shown because too many files have changed in this diff Show More