diff --git a/issues/0019-import-non-transitive-c-scope.md b/issues/0019-import-non-transitive-c-scope.md deleted file mode 100644 index edfbe966..00000000 --- a/issues/0019-import-non-transitive-c-scope.md +++ /dev/null @@ -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`). diff --git a/issues/0041-pointer-type-not-parsed-as-expression.md b/issues/0041-pointer-type-not-parsed-as-expression.md deleted file mode 100644 index 26e09c97..00000000 --- a/issues/0041-pointer-type-not-parsed-as-expression.md +++ /dev/null @@ -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. diff --git a/issues/0042-const-decl-type-aliases-not-resolved-as-identifier.md b/issues/0042-const-decl-type-aliases-not-resolved-as-identifier.md deleted file mode 100644 index 12fe3acf..00000000 --- a/issues/0042-const-decl-type-aliases-not-resolved-as-identifier.md +++ /dev/null @@ -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()` 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.). diff --git a/issues/0043-lazy-lower-loses-runtime-class-method-dispatch.md b/issues/0043-lazy-lower-loses-runtime-class-method-dispatch.md deleted file mode 100644 index 5c72f1dc..00000000 --- a/issues/0043-lazy-lower-loses-runtime-class-method-dispatch.md +++ /dev/null @@ -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 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. diff --git a/issues/0044-sx-defined-objc-class-method-self-param-must-be-named-self.md b/issues/0044-sx-defined-objc-class-method-self-param-must-be-named-self.md deleted file mode 100644 index 8fee5091..00000000 --- a/issues/0044-sx-defined-objc-class-method-self-param-must-be-named-self.md +++ /dev/null @@ -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`. diff --git a/issues/0045-pack-fn-call-llvm-verifier-failure.md b/issues/0045-pack-fn-call-llvm-verifier-failure.md deleted file mode 100644 index d750c6db..00000000 --- a/issues/0045-pack-fn-call-llvm-verifier-failure.md +++ /dev/null @@ -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. diff --git a/issues/0046-comptime-fn-nested-print-with-return.md b/issues/0046-comptime-fn-nested-print-with-return.md deleted file mode 100644 index 1305294a..00000000 --- a/issues/0046-comptime-fn-nested-print-with-return.md +++ /dev/null @@ -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. diff --git a/issues/0047-run-output-on-stderr-runtime-on-stdout.md b/issues/0047-run-output-on-stderr-runtime-on-stdout.md deleted file mode 100644 index 5e60fe66..00000000 --- a/issues/0047-run-output-on-stderr-runtime-on-stdout.md +++ /dev/null @@ -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. diff --git a/issues/0048-bare-pack-args-slice-loses-len-across-call.md b/issues/0048-bare-pack-args-slice-loses-len-across-call.md deleted file mode 100644 index 6c2eb64e..00000000 --- a/issues/0048-bare-pack-args-slice-loses-len-across-call.md +++ /dev/null @@ -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 -> `.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. diff --git a/issues/0049-new-form-variadic-cross-module-llvm-emit-crash.md b/issues/0049-new-form-variadic-cross-module-llvm-emit-crash.md deleted file mode 100644 index fe33fc12..00000000 --- a/issues/0049-new-form-variadic-cross-module-llvm-emit-crash.md +++ /dev/null @@ -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__` 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. diff --git a/issues/0050-monomorphizeFunction-pack-state-leak.md b/issues/0050-monomorphizeFunction-pack-state-leak.md deleted file mode 100644 index 19e7d6c5..00000000 --- a/issues/0050-monomorphizeFunction-pack-state-leak.md +++ /dev/null @@ -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 `.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 -`.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. diff --git a/issues/0051-macos-bundle-assets-cwd-relative.md b/issues/0051-macos-bundle-assets-cwd-relative.md deleted file mode 100644 index c0487420..00000000 --- a/issues/0051-macos-bundle-assets-cwd-relative.md +++ /dev/null @@ -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 ` (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`. diff --git a/issues/0052-slice-of-protocol-variadic-not-erased.md b/issues/0052-slice-of-protocol-variadic-not-erased.md deleted file mode 100644 index 62f86f5a..00000000 --- a/issues/0052-slice-of-protocol-variadic-not-erased.md +++ /dev/null @@ -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. diff --git a/issues/0053-comptime-pack-spread-into-any-slice.md b/issues/0053-comptime-pack-spread-into-any-slice.md deleted file mode 100644 index a1c119ab..00000000 --- a/issues/0053-comptime-pack-spread-into-any-slice.md +++ /dev/null @@ -1,80 +0,0 @@ -**FIXED** via the `xx ` 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 ` 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 ` 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. diff --git a/issues/0054-generic-struct-to-param-protocol-erasure.md b/issues/0054-generic-struct-to-param-protocol-erasure.md deleted file mode 100644 index 1d8603a8..00000000 --- a/issues/0054-generic-struct-to-param-protocol-erasure.md +++ /dev/null @@ -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\x00`) 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. diff --git a/issues/0055-binary-arith-no-operand-type-check.md b/issues/0055-binary-arith-no-operand-type-check.md deleted file mode 100644 index 0fe24160..00000000 --- a/issues/0055-binary-arith-no-operand-type-check.md +++ /dev/null @@ -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 '' …` 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 '' to operands of type '' and -''` 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 + `). -- **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, , "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. diff --git a/issues/0056-param-impl-not-deduped-across-diamond-import.md b/issues/0056-param-impl-not-deduped-across-diamond-import.md deleted file mode 100644 index 5115aef9..00000000 --- a/issues/0056-param-impl-not-deduped-across-diamond-import.md +++ /dev/null @@ -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`). diff --git a/issues/0057-xx-any-arg-in-imported-module-fn-segfaults.md b/issues/0057-xx-any-arg-in-imported-module-fn-segfaults.md deleted file mode 100644 index 4a5284bb..00000000 --- a/issues/0057-xx-any-arg-in-imported-module-fn-segfaults.md +++ /dev/null @@ -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 `'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 ` → 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). diff --git a/issues/0058-empty-debug-map-large-build.md b/issues/0058-empty-debug-map-large-build.md deleted file mode 100644 index ecc4e673..00000000 --- a/issues/0058-empty-debug-map-large-build.md +++ /dev/null @@ -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 1–2) is fine and - `--emit-obj` correctly forces `-O0`. -- The link command is clean — `cc -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 ` 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 1–2 (small macOS + iOS-sim binaries) are still -verified; this gap is specific to large builds' debug map. diff --git a/issues/0059-expr-lambda-inferred-return-unresolved-type.md b/issues/0059-expr-lambda-inferred-return-unresolved-type.md deleted file mode 100644 index 9c6cba0f..00000000 --- a/issues/0059-expr-lambda-inferred-return-unresolved-type.md +++ /dev/null @@ -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. diff --git a/issues/0060-closure-literal-composition-miscompiles.md b/issues/0060-closure-literal-composition-miscompiles.md deleted file mode 100644 index 69316b45..00000000 --- a/issues/0060-closure-literal-composition-miscompiles.md +++ /dev/null @@ -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. diff --git a/issues/0061-dead-code-after-terminator-stmt.md b/issues/0061-dead-code-after-terminator-stmt.md deleted file mode 100644 index 518d5e61..00000000 --- a/issues/0061-dead-code-after-terminator-stmt.md +++ /dev/null @@ -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. diff --git a/issues/0062-generic-failable-return-not-monomorphized.md b/issues/0062-generic-failable-return-not-monomorphized.md deleted file mode 100644 index b0d1cde5..00000000 --- a/issues/0062-generic-failable-return-not-monomorphized.md +++ /dev/null @@ -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. diff --git a/issues/0063-free-fn-ufcs-pointer-param-passes-by-value.md b/issues/0063-free-fn-ufcs-pointer-param-passes-by-value.md deleted file mode 100644 index 3721b3e3..00000000 --- a/issues/0063-free-fn-ufcs-pointer-param-passes-by-value.md +++ /dev/null @@ -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). diff --git a/issues/0064-nondollar-type-param-silent-empty-struct.md b/issues/0064-nondollar-type-param-silent-empty-struct.md deleted file mode 100644 index c80a1ae7..00000000 --- a/issues/0064-nondollar-type-param-silent-empty-struct.md +++ /dev/null @@ -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. diff --git a/issues/0065-block-expr-destructure-decl-parse.md b/issues/0065-block-expr-destructure-decl-parse.md deleted file mode 100644 index dcdc09c5..00000000 --- a/issues/0065-block-expr-destructure-decl-parse.md +++ /dev/null @@ -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 ` (both parse fine in a `defer` body), so this does -not block the error-handling work. Filed while implementing E1.7. diff --git a/issues/0066-match-arm-negative-literal-phi-width.md b/issues/0066-match-arm-negative-literal-phi-width.md deleted file mode 100644 index a7a8028a..00000000 --- a/issues/0066-match-arm-negative-literal-phi-width.md +++ /dev/null @@ -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. diff --git a/issues/0067-tuple-literal-type-nontype-fallback.md b/issues/0067-tuple-literal-type-nontype-fallback.md deleted file mode 100644 index 69afa89f..00000000 --- a/issues/0067-tuple-literal-type-nontype-fallback.md +++ /dev/null @@ -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. diff --git a/issues/0068-value-const-used-as-type-suppresses-unknown-type.md b/issues/0068-value-const-used-as-type-suppresses-unknown-type.md deleted file mode 100644 index 661ad3df..00000000 --- a/issues/0068-value-const-used-as-type-suppresses-unknown-type.md +++ /dev/null @@ -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 1111–1116 + 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. diff --git a/issues/0069-forward-identifier-type-alias-falsely-unknown.md b/issues/0069-forward-identifier-type-alias-falsely-unknown.md deleted file mode 100644 index 7a9374b7..00000000 --- a/issues/0069-forward-identifier-type-alias-falsely-unknown.md +++ /dev/null @@ -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. diff --git a/issues/0070-forward-alias-global-annotation-before-fixpoint.md b/issues/0070-forward-alias-global-annotation-before-fixpoint.md deleted file mode 100644 index ed821e05..00000000 --- a/issues/0070-forward-alias-global-annotation-before-fixpoint.md +++ /dev/null @@ -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. diff --git a/issues/0071-global-initializer-module-const-silent-zero.md b/issues/0071-global-initializer-module-const-silent-zero.md deleted file mode 100644 index f442c4e2..00000000 --- a/issues/0071-global-initializer-module-const-silent-zero.md +++ /dev/null @@ -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. diff --git a/issues/0072-global-field-access-const-initializer-silent-zero.md b/issues/0072-global-field-access-const-initializer-silent-zero.md deleted file mode 100644 index 86aaa0da..00000000 --- a/issues/0072-global-field-access-const-initializer-silent-zero.md +++ /dev/null @@ -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 '' 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 -``` diff --git a/issues/0073-closure-literal-in-defer-segfault.md b/issues/0073-closure-literal-in-defer-segfault.md deleted file mode 100644 index 7e992a7c..00000000 --- a/issues/0073-closure-literal-in-defer-segfault.md +++ /dev/null @@ -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. diff --git a/issues/0074-ffi-arg-type-void-fallback.md b/issues/0074-ffi-arg-type-void-fallback.md deleted file mode 100644 index 354205ce..00000000 --- a/issues/0074-ffi-arg-type-void-fallback.md +++ /dev/null @@ -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 `CallMethod`) 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 `CallMethod` 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 CallMethod / 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. diff --git a/issues/0075-reflection-builtin-i64-type-fallback.md b/issues/0075-reflection-builtin-i64-type-fallback.md deleted file mode 100644 index 638b1043..00000000 --- a/issues/0075-reflection-builtin-i64-type-fallback.md +++ /dev/null @@ -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`. diff --git a/issues/0076-stack-struct-addrof-passed-by-value.md b/issues/0076-stack-struct-addrof-passed-by-value.md deleted file mode 100644 index f8277454..00000000 --- a/issues/0076-stack-struct-addrof-passed-by-value.md +++ /dev/null @@ -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 1–64 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 ` 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. diff --git a/issues/0077-imported-reserved-type-name-binding.md b/issues/0077-imported-reserved-type-name-binding.md deleted file mode 100644 index 98b8d539..00000000 --- a/issues/0077-imported-reserved-type-name-binding.md +++ /dev/null @@ -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`. diff --git a/issues/0078-string-eq-operand-of-short-circuit-and-invalid-phi.md b/issues/0078-string-eq-operand-of-short-circuit-and-invalid-phi.md deleted file mode 100644 index 352a5958..00000000 --- a/issues/0078-string-eq-operand-of-short-circuit-and-invalid-phi.md +++ /dev/null @@ -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. diff --git a/issues/0079-global-array-element-store-dropped.md b/issues/0079-global-array-element-store-dropped.md deleted file mode 100644 index 261b2d0e..00000000 --- a/issues/0079-global-array-element-store-dropped.md +++ /dev/null @@ -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 ` → 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. diff --git a/issues/0080-global-array-struct-literal-initializer-zero.md b/issues/0080-global-array-struct-literal-initializer-zero.md deleted file mode 100644 index 6ed73daf..00000000 --- a/issues/0080-global-array-struct-literal-initializer-zero.md +++ /dev/null @@ -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 ` 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 -``` diff --git a/issues/0081-global-aggregate-null-literal-rejected.md b/issues/0081-global-aggregate-null-literal-rejected.md deleted file mode 100644 index d6642930..00000000 --- a/issues/0081-global-aggregate-null-literal-rejected.md +++ /dev/null @@ -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 -``` diff --git a/issues/0082-global-enum-literal-initializer-zeroes.md b/issues/0082-global-enum-literal-initializer-zeroes.md deleted file mode 100644 index cff92946..00000000 --- a/issues/0082-global-enum-literal-initializer-zeroes.md +++ /dev/null @@ -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 -``` diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md deleted file mode 100644 index e85e6a96..00000000 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ /dev/null @@ -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 1–3 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 1–4 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 / `.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. 0079–0082). - -## 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. diff --git a/issues/0084-slice-literal-direct-call-arg-miscompiled.md b/issues/0084-slice-literal-direct-call-arg-miscompiled.md deleted file mode 100644 index f4ff34b9..00000000 --- a/issues/0084-slice-literal-direct-call-arg-miscompiled.md +++ /dev/null @@ -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 **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`. diff --git a/issues/0086-vector-lane-store-unresolved-panic.md b/issues/0086-vector-lane-store-unresolved-panic.md deleted file mode 100644 index 687c8060..00000000 --- a/issues/0086-vector-lane-store-unresolved-panic.md +++ /dev/null @@ -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. diff --git a/issues/0087-oversized-comptime-int-dimension-panics.md b/issues/0087-oversized-comptime-int-dimension-panics.md deleted file mode 100644 index ebffedce..00000000 --- a/issues/0087-oversized-comptime-int-dimension-panics.md +++ /dev/null @@ -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`. diff --git a/issues/0088-typed-module-const-annotation-mismatch.md b/issues/0088-typed-module-const-annotation-mismatch.md deleted file mode 100644 index 0f23a06e..00000000 --- a/issues/0088-typed-module-const-annotation-mismatch.md +++ /dev/null @@ -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 '' is declared -> '' but its initializer is ` 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`. diff --git a/issues/0089-backtick-raw-identifier.md b/issues/0089-backtick-raw-identifier.md deleted file mode 100644 index 89172747..00000000 --- a/issues/0089-backtick-raw-identifier.md +++ /dev/null @@ -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. diff --git a/issues/0090-int-formatter-extremes.md b/issues/0090-int-formatter-extremes.md deleted file mode 100644 index 78f787d2..00000000 --- a/issues/0090-int-formatter-extremes.md +++ /dev/null @@ -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 `" expects a type, got ''"`. -> - `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. diff --git a/issues/0091-float-ne-ordered-nan.md b/issues/0091-float-ne-ordered-nan.md deleted file mode 100644 index fcfab789..00000000 --- a/issues/0091-float-ne-ordered-nan.md +++ /dev/null @@ -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 .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. diff --git a/issues/0092-raw-reserved-value-numeric-limit-shadow.md b/issues/0092-raw-reserved-value-numeric-limit-shadow.md deleted file mode 100644 index fe4b5529..00000000 --- a/issues/0092-raw-reserved-value-numeric-limit-shadow.md +++ /dev/null @@ -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`. diff --git a/issues/0093-global-raw-value-numeric-limit-shadow.md b/issues/0093-global-raw-value-numeric-limit-shadow.md deleted file mode 100644 index 9c6da176..00000000 --- a/issues/0093-global-raw-value-numeric-limit-shadow.md +++ /dev/null @@ -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`. diff --git a/issues/0094-missing-struct-field-assignment-unresolved-panic.md b/issues/0094-missing-struct-field-assignment-unresolved-panic.md deleted file mode 100644 index ecebb8e6..00000000 --- a/issues/0094-missing-struct-field-assignment-unresolved-panic.md +++ /dev/null @@ -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`. diff --git a/issues/0095-typed-local-float-int-narrowing.md b/issues/0095-typed-local-float-int-narrowing.md deleted file mode 100644 index 0068092a..00000000 --- a/issues/0095-typed-local-float-int-narrowing.md +++ /dev/null @@ -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 1–3 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 1–4 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 / `.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. diff --git a/issues/0096-comptime-print-any-type-stops.md b/issues/0096-comptime-print-any-type-stops.md deleted file mode 100644 index cc85ad1a..00000000 --- a/issues/0096-comptime-print-any-type-stops.md +++ /dev/null @@ -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. diff --git a/issues/0097-enum-value-failable-error-slot-corruption.md b/issues/0097-enum-value-failable-error-slot-corruption.md deleted file mode 100644 index e59cdb6f..00000000 --- a/issues/0097-enum-value-failable-error-slot-corruption.md +++ /dev/null @@ -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. diff --git a/issues/0098-enum-literal-non-enum-target-silent-zero.md b/issues/0098-enum-literal-non-enum-target-silent-zero.md deleted file mode 100644 index c09311ec..00000000 --- a/issues/0098-enum-literal-non-enum-target-silent-zero.md +++ /dev/null @@ -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 .;` 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. diff --git a/issues/0099-lsp-array-dim-identifier-panic.md b/issues/0099-lsp-array-dim-identifier-panic.md deleted file mode 100644 index 85bc8bf8..00000000 --- a/issues/0099-lsp-array-dim-identifier-panic.md +++ /dev/null @@ -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.` 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). diff --git a/issues/0100-cross-module-same-name-fn-lowering-collision.md b/issues/0100-cross-module-same-name-fn-lowering-collision.md deleted file mode 100644 index 2cab8165..00000000 --- a/issues/0100-cross-module-same-name-fn-lowering-collision.md +++ /dev/null @@ -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). diff --git a/issues/0101-postfix-bang-field-miscompile.md b/issues/0101-postfix-bang-field-miscompile.md deleted file mode 100644 index c65a5d25..00000000 --- a/issues/0101-postfix-bang-field-miscompile.md +++ /dev/null @@ -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 -''`), 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. diff --git a/issues/0102-flat-import-same-signature-collision.md b/issues/0102-flat-import-same-signature-collision.md deleted file mode 100644 index a92f0cd2..00000000 --- a/issues/0102-flat-import-same-signature-collision.md +++ /dev/null @@ -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). diff --git a/issues/0106-namespaced-import-bare-visibility-over-permissive.md b/issues/0106-namespaced-import-bare-visibility-over-permissive.md deleted file mode 100644 index 9d24caae..00000000 --- a/issues/0106-namespaced-import-bare-visibility-over-permissive.md +++ /dev/null @@ -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 `'' 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 ` 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`). diff --git a/issues/0107-forward-alias-initial-scan-global-leak.md b/issues/0107-forward-alias-initial-scan-global-leak.md deleted file mode 100644 index 01532fa6..00000000 --- a/issues/0107-forward-alias-initial-scan-global-leak.md +++ /dev/null @@ -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`. diff --git a/issues/0108-defer-skipped-on-break-continue.md b/issues/0108-defer-skipped-on-break-continue.md deleted file mode 100644 index 068a3224..00000000 --- a/issues/0108-defer-skipped-on-break-continue.md +++ /dev/null @@ -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). diff --git a/issues/0109-loop-body-alloca-stack-growth.md b/issues/0109-loop-body-alloca-stack-growth.md deleted file mode 100644 index 5f554763..00000000 --- a/issues/0109-loop-body-alloca-stack-growth.md +++ /dev/null @@ -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. diff --git a/issues/0110-for-array-by-value-full-array-spill.md b/issues/0110-for-array-by-value-full-array-spill.md deleted file mode 100644 index f1991c9b..00000000 --- a/issues/0110-for-array-by-value-full-array-spill.md +++ /dev/null @@ -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`. diff --git a/issues/0111-int-literal-local-adopts-fn-return-type.md b/issues/0111-int-literal-local-adopts-fn-return-type.md deleted file mode 100644 index b067bfbe..00000000 --- a/issues/0111-int-literal-local-adopts-fn-return-type.md +++ /dev/null @@ -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 := ` (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. diff --git a/issues/0112-int-literal-out-of-range-silent-wrap.md b/issues/0112-int-literal-out-of-range-silent-wrap.md deleted file mode 100644 index 8ef17f4d..00000000 --- a/issues/0112-int-literal-out-of-range-silent-wrap.md +++ /dev/null @@ -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-...`). diff --git a/issues/0113-negative-literal-global-initializer-rejected.md b/issues/0113-negative-literal-global-initializer-rejected.md deleted file mode 100644 index 01eb5ff9..00000000 --- a/issues/0113-negative-literal-global-initializer-rejected.md +++ /dev/null @@ -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. diff --git a/issues/0114-namespace-alias-transitive-first-wins.md b/issues/0114-namespace-alias-transitive-first-wins.md deleted file mode 100644 index 5d756810..00000000 --- a/issues/0114-namespace-alias-transitive-first-wins.md +++ /dev/null @@ -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. diff --git a/issues/0115-same-name-const-scalar-array-collision.md b/issues/0115-same-name-const-scalar-array-collision.md deleted file mode 100644 index e05e8759..00000000 --- a/issues/0115-same-name-const-scalar-array-collision.md +++ /dev/null @@ -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). diff --git a/issues/0116-const-write-not-rejected.md b/issues/0116-const-write-not-rejected.md deleted file mode 100644 index 9dbef171..00000000 --- a/issues/0116-const-write-not-rejected.md +++ /dev/null @@ -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. diff --git a/issues/0117-pointer-to-array-index-unresolved-panic.md b/issues/0117-pointer-to-array-index-unresolved-panic.md deleted file mode 100644 index 32711bb7..00000000 --- a/issues/0117-pointer-to-array-index-unresolved-panic.md +++ /dev/null @@ -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 `@` and `@`). - -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. diff --git a/issues/0118-cast-compound-type-arg-unresolved.md b/issues/0118-cast-compound-type-arg-unresolved.md deleted file mode 100644 index e86ae991..00000000 --- a/issues/0118-cast-compound-type-arg-unresolved.md +++ /dev/null @@ -1,86 +0,0 @@ -# 0118 — `cast() 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. diff --git a/issues/0119-ufcs-generic-free-function-unresolved.md b/issues/0119-ufcs-generic-free-function-unresolved.md deleted file mode 100644 index 386a624a..00000000 --- a/issues/0119-ufcs-generic-free-function-unresolved.md +++ /dev/null @@ -1,100 +0,0 @@ -# 0119 — UFCS dot-call on a GENERIC free function: "unresolved ''" - -> **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 ''`. 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. diff --git a/issues/0120-generic-struct-alias-head-unresolved-panic.md b/issues/0120-generic-struct-alias-head-unresolved-panic.md deleted file mode 100644 index 785d2b90..00000000 --- a/issues/0120-generic-struct-alias-head-unresolved-panic.md +++ /dev/null @@ -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:357–382) 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. diff --git a/issues/0121-pack-fn-alias-unresolved.md b/issues/0121-pack-fn-alias-unresolved.md deleted file mode 100644 index 1afb43e5..00000000 --- a/issues/0121-pack-fn-alias-unresolved.md +++ /dev/null @@ -1,112 +0,0 @@ -# 0121 — aliasing a comptime-pack fn (`..$args`): "unresolved ''" - -> **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 ''`. 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 ''`. - -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`). diff --git a/issues/0122-whole-program-passes-ambient-source-context.md b/issues/0122-whole-program-passes-ambient-source-context.md deleted file mode 100644 index f1d0d11a..00000000 --- a/issues/0122-whole-program-passes-ambient-source-context.md +++ /dev/null @@ -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. diff --git a/issues/0123-call-arity-unchecked.md b/issues/0123-call-arity-unchecked.md deleted file mode 100644 index 736ae3cc..00000000 --- a/issues/0123-call-arity-unchecked.md +++ /dev/null @@ -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. diff --git a/issues/0124-large-stack-array-aggregate-ops-crash-llvm.md b/issues/0124-large-stack-array-aggregate-ops-crash-llvm.md deleted file mode 100644 index fdb6203d..00000000 --- a/issues/0124-large-stack-array-aggregate-ops-crash-llvm.md +++ /dev/null @@ -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. diff --git a/issues/0125-any-to-string-array-arms-by-value.md b/issues/0125-any-to-string-array-arms-by-value.md deleted file mode 100644 index 763762f2..00000000 --- a/issues/0125-any-to-string-array-arms-by-value.md +++ /dev/null @@ -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. diff --git a/issues/0126-array-arg-slice-generic-param-unbound.md b/issues/0126-array-arg-slice-generic-param-unbound.md deleted file mode 100644 index 1c8c3b50..00000000 --- a/issues/0126-array-arg-slice-generic-param-unbound.md +++ /dev/null @@ -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. diff --git a/issues/0127-namespaced-generic-call-result-unbound-stub.md b/issues/0127-namespaced-generic-call-result-unbound-stub.md deleted file mode 100644 index d1814f3c..00000000 --- a/issues/0127-namespaced-generic-call-result-unbound-stub.md +++ /dev/null @@ -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). diff --git a/issues/0128-cstring-ffi-boundary-returns-and-optional.md b/issues/0128-cstring-ffi-boundary-returns-and-optional.md deleted file mode 100644 index 5b17b0f1..00000000 --- a/issues/0128-cstring-ffi-boundary-returns-and-optional.md +++ /dev/null @@ -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 '' 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. diff --git a/issues/0129-negated-error-binding-bitwise-not.md b/issues/0129-negated-error-binding-bitwise-not.md deleted file mode 100644 index 238af20b..00000000 --- a/issues/0129-negated-error-binding-bitwise-not.md +++ /dev/null @@ -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. diff --git a/issues/0130-library-decl-nested-namespace-dropped.md b/issues/0130-library-decl-nested-namespace-dropped.md deleted file mode 100644 index a6752a92..00000000 --- a/issues/0130-library-decl-nested-namespace-dropped.md +++ /dev/null @@ -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` 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. diff --git a/issues/0131-protocol-call-extra-args-silently-dropped.md b/issues/0131-protocol-call-extra-args-silently-dropped.md deleted file mode 100644 index bb89c369..00000000 --- a/issues/0131-protocol-call-extra-args-silently-dropped.md +++ /dev/null @@ -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`. diff --git a/issues/0132-protocol-return-enum-case-payload-field-unresolved.md b/issues/0132-protocol-return-enum-case-payload-field-unresolved.md deleted file mode 100644 index 410761c4..00000000 --- a/issues/0132-protocol-return-enum-case-payload-field-unresolved.md +++ /dev/null @@ -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 289–316), 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`). diff --git a/issues/0133-union-member-struct-literal-assign-unresolved-panic.md b/issues/0133-union-member-struct-literal-assign-unresolved-panic.md deleted file mode 100644 index 74c8416d..00000000 --- a/issues/0133-union-member-struct-literal-assign-unresolved-panic.md +++ /dev/null @@ -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. diff --git a/issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.md b/issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.md deleted file mode 100644 index b43e30f6..00000000 --- a/issues/0134-error-set-no-per-decl-nominal-identity-same-name-collapse.md +++ /dev/null @@ -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. diff --git a/issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md b/issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md deleted file mode 100644 index 0441aee4..00000000 --- a/issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md +++ /dev/null @@ -1,174 +0,0 @@ -# 0135 — `xx [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 ` → -`[]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 `[]` 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 [i]` erased to a protocol target spuriously errors with "pack -> '' 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 ` → slice bridge), 0054 (generic-struct → - param protocol erasure). None handle single-element `xx pack[i]` → protocol - scalar. diff --git a/issues/0136-tagged-union-member-write-does-not-set-tag.md b/issues/0136-tagged-union-member-write-does-not-set-tag.md deleted file mode 100644 index 2e2ec437..00000000 --- a/issues/0136-tagged-union-member-write-does-not-set-tag.md +++ /dev/null @@ -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). diff --git a/issues/0137-jit-run-no-main-segfault.md b/issues/0137-jit-run-no-main-segfault.md deleted file mode 100644 index 60054472..00000000 --- a/issues/0137-jit-run-no-main-segfault.md +++ /dev/null @@ -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 ` 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 256–273. 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 256–273) 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. diff --git a/issues/0138-address-of-comptime-const-yields-wild-pointer.md b/issues/0138-address-of-comptime-const-yields-wild-pointer.md deleted file mode 100644 index d42aa374..00000000 --- a/issues/0138-address-of-comptime-const-yields-wild-pointer.md +++ /dev/null @@ -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 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 '' — 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 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 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). diff --git a/issues/0139-byvalue-self-reference-segfault.md b/issues/0139-byvalue-self-reference-segfault.md deleted file mode 100644 index 080d0a72..00000000 --- a/issues/0139-byvalue-self-reference-segfault.md +++ /dev/null @@ -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. diff --git a/issues/0140-comptime-type-construction-bail-unresolved-panic.md b/issues/0140-comptime-type-construction-bail-unresolved-panic.md deleted file mode 100644 index 806afd25..00000000 --- a/issues/0140-comptime-type-construction-bail-unresolved-panic.md +++ /dev/null @@ -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 ""`). 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. diff --git a/issues/0141-comptime-list-growth-in-type-construction.md b/issues/0141-comptime-list-growth-in-type-construction.md deleted file mode 100644 index a0c99a8b..00000000 --- a/issues/0141-comptime-list-growth-in-type-construction.md +++ /dev/null @@ -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 (0614–0624, 1178–1182) - 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. diff --git a/issues/0142-comptime-minted-all-void-enum-binds-any.md b/issues/0142-comptime-minted-all-void-enum-binds-any.md deleted file mode 100644 index f6572e7f..00000000 --- a/issues/0142-comptime-minted-all-void-enum-binds-any.md +++ /dev/null @@ -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 '' 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. diff --git a/issues/0143-pack-as-type-slice-stride-mismatch.md b/issues/0143-pack-as-type-slice-stride-mismatch.md deleted file mode 100644 index dcf680ad..00000000 --- a/issues/0143-pack-as-type-slice-stride-mismatch.md +++ /dev/null @@ -1,95 +0,0 @@ -> **RESOLVED (2026-06-18).** Root cause: `buildPackSliceValue` (`src/ir/lower/pack.zig`) -> built the `$` `[]Type` slice as `[]Any` (16-byte elements) — a stale mapping -> from before the dedicated `Type` builtin (`.type_value`, 8 bytes) replaced `Type → .any`. -> It stored 8-byte `const_type` words into 16-byte slots, so a `[]Type` reader (8-byte -> stride) read `[t0, pad, t1, …]`. Fix: build the slice/array as `.type_value` (8 bytes). -> Regression test: `examples/0525-packs-pack-as-type-slice-arg.sx`. The stopgap -> `type_name` `.unresolved` guard added in 379ed05 was removed (root cause fixed). -> 700/0 both gates. - -# 0143 — A variadic pack passed as `[]Type` across a call is mis-strided (Any-sized backing, Type-sized view) - -**Symptom** — When a variadic `..$args` pack is forwarded as a `[]Type` *argument* -to another function, reading `args[i]` yields the **wrong** element: the backing -array is laid out as `[N x Any]` (16-byte `{i64,i64}` slots) but the slice's -element type is `Type`/`type_value` (8 bytes), so an 8-byte-strided index read -lands on `elem0`, then the *padding* of `elem0` (reads as `.unresolved`), then -`elem1`, … — i.e. half-stride reads. - -Observed (on the flat-memory comptime VM, which is byte-accurate) vs expected -(legacy interp, whose tagged-`Value` model is stride-agnostic and reads the right -logical element): - -``` -VM: [i64 string ] -legacy: [i64 string bool ] -``` - -The legacy interpreter masks the bug (it indexes a logical sequence of `Value`s, -not bytes). The VM exposes it. This is what makes `examples/0114-types-build-block-convert` -read `arg1: ` instead of `arg1: string` once the VM handles `type_name` -(it currently bails on the `.unresolved` read — see the guard added in `comptime_vm.zig` -`type_name`, commit 379ed05 — so under the fallback it falls back; under -`SX_COMPTIME_FLAT_STRICT` it is a hard error). - -## Reproduction - -```sx -#import "modules/std.sx"; - -inner :: (args: []Type) -> string { - s := ""; i : i64 = 0; - while i < args.len { s = concat(s, type_name(args[i])); s = concat(s, " "); i = i + 1; } - return s; -} -outer :: (..$args) -> string { return inner($args); } // <-- pack passed as []Type across a call - -R :: #run outer(42, "hi", true); -main :: () { print("[{}]\n", R); } -``` - -Expected: `[i64 string bool ]`. Actual (VM): `[i64 string ]`. - -Note the *direct* use (no cross-call) is fine — `walk :: (..$args) { list := $args; … type_name(list[i]) … }` -reads correctly. Only forwarding the pack as a `[]Type` **argument** mis-strides. - -## Root cause (hypothesis) - -The pack `$args` is materialised as a `[N x Any]` array (each arg boxed as a -16-byte `Any` — confirmed in the LLVM IR: `array_to_string__AR_3_Any` / -`[3 x { i64, i64 }]`), but when forwarded to a `[]Type` parameter the slice is -typed with element `type_value` (8 bytes). The slice `{ptr,len}` points at the -`[N x Any]` data, so `index_get` with an 8-byte element stride reads garbage. - -The fix belongs in **lowering**, not the VM: a pack consumed as `[]Type` must -either (a) materialise a real `[N x Type]` (8-byte) array by extracting each -arg's type tag from its Any box, or (b) keep the slice element type as `Any` and -have `type_name`/reflection read the Any's tag (the VM's `type_name` already -handles an `.any` arg). Option (b) is likely smaller. Whichever: the slice's -element type and its backing array's element size MUST agree. - -Suspected area: the pack-expansion / pack-as-slice lowering (search `src/ir/lower/` -for the `..$args` → slice materialisation and the `[]Type` coercion of a value -pack; `pack.zig`). - -## Investigation prompt (paste into a fresh session) - -> A variadic `..$args` pack forwarded as a `[]Type` argument across a call is -> mis-strided: the backing array is `[N x Any]` (16B slots) but the slice element -> type is `type_value` (8B), so `args[i]` reads at the wrong offset. The legacy -> interp masks it (Value-indexed); the byte-accurate comptime VM exposes it -> (`examples/0114`, and the minimal repro in `issues/0143-…md`). Fix it in -> lowering: make the pack→`[]Type` materialisation agree on element size — either -> build a real `[N x Type]` array (extract each Any's type tag) or type the slice -> `[]Any` and let reflection read the Any tag (`type_name` already handles `.any`). -> Verify: run the repro on the VM (`SX_COMPTIME_FLAT=1 sx run`) and expect -> `[i64 string bool ]`; then `examples/0114` must produce `arg1: string` (not -> ``) and run HANDLED under `SX_COMPTIME_FLAT_STRICT=1` matching legacy. -> Drop the `tid == .unresolved` guard in `comptime_vm.zig` `type_name` once the -> root cause is fixed (it was a stopgap so the VM declines rather than emits -> garbage). - -## Status - -OPEN. Blocks `examples/0114` from running HANDLED on the comptime VM (Phase 4 -legacy-interp retirement). Not a blocker for the other comptime-op ports. diff --git a/issues/0144-unrecognized-type-param-builtin-silent-zero.md b/issues/0144-unrecognized-type-param-builtin-silent-zero.md deleted file mode 100644 index ef453c02..00000000 --- a/issues/0144-unrecognized-type-param-builtin-silent-zero.md +++ /dev/null @@ -1,87 +0,0 @@ -# 0144 — unrecognized `$T`-param `#builtin` silently returns 0 - -> **RESOLVED.** The generic monomorphization path (`monomorphizeFunction`'s -> `builtin_expr` body branch in `src/ir/lower/generic.zig`) no longer falls -> through to `ensureTerminator`'s silent `constInt(0)` when `resolveBuiltin` -> returns null for an unknown name. It now emits a loud -> `error: unknown #builtin ''` diagnostic — removing exactly the -> silent-fallback-default the CLAUDE.md REJECTED PATTERNS forbid. Regression -> test: `examples/1189-diagnostics-unknown-builtin.sx`. - -## Symptom - -A **bodiless `#builtin`** whose signature takes a `$T: Type` parameter, when the -compiler does **not** recognize its name, **silently evaluates to `0`** (process -exit 0) instead of emitting a loud "unknown builtin" diagnostic. - -- **Observed:** `mystery(i64, 42)` prints `0`, exit 0. -- **Expected:** a loud compile error naming the unrecognized builtin (e.g. - `error: unknown #builtin 'mystery'`) + non-zero exit. - -Contrast: a bodiless `#builtin` WITHOUT a type parameter (e.g. -`noret :: (x: i64) #builtin;`) is lowered as an ordinary external call and fails -**loudly** at link time (`JIT session error: Symbols not found: [ _noret ]`). So -the silent path is specific to the `$T: Type`-parametrized (reflection-builtin- -shaped) form, which is routed through the reflection/type-arg lowering and falls -through to a zero/`i64` default rather than being rejected. - -This is the exact class CLAUDE.md "REJECTED PATTERNS" forbids: a lookup that -fails (here: "is this a recognized builtin?") returns a plausible-looking default -(`0`) instead of surfacing the failure. It was discovered during the atomics -stream — before `atomic_load`/`atomic_store` recognition existed, `Atomic($T)`'s -methods calling those `#builtin`s ran to `0` with exit 0 instead of erroring. - -## Reproduction - -Standalone (only needs `modules/std.sx`): - -```sx -#import "modules/std.sx"; - -// Bodiless #builtin the compiler does not recognize, with a `$T: Type` param. -mystery :: ($T: Type, x: T) -> T #builtin; - -main :: () { - print("mystery(42) = {}\n", mystery(i64, 42)); // prints 0, exit 0 -} -``` - -Also reproduces through a generic-struct method (the original atomics shape): - -```sx -#import "modules/std.sx"; -mystery :: ($T: Type, x: T) -> T #builtin; -Box :: struct ($T: Type) { - v: T; - get :: (self: *Box(T)) -> T { return mystery(T, self.v); } -} -main :: () { - b := Box(i64).{ v = 42 }; - print("{}\n", b.get()); // prints 0, exit 0 -} -``` - -## Investigation prompt (paste into a fresh session) - -> Fix issue 0144: an unrecognized bodiless `#builtin` with a `$T: Type` parameter -> silently lowers to `0` (exit 0) instead of erroring. Repro: -> `mystery :: ($T: Type, x: T) -> T #builtin;` then `print("{}\n", mystery(i64, 42))` -> prints `0`. Expected: a loud "unknown #builtin" diagnostic + non-zero exit (the -> non-type-param `#builtin` form already fails loudly at link time, so only the -> type-param/reflection-shaped path is silent). -> -> Suspected area: `src/ir/lower/call.zig` — the reflection-builtin recognition -> (`tryLowerReflectionCall`) declines unknown names, and the call then falls -> through a path that resolves the `$T` arg / folds to a default `0` / `.i64` -> rather than rejecting. Trace where a bodiless `#builtin` call whose name matches -> NO recognizer (reflection / atomic intrinsics / etc.) ends up producing a -> `const_int 0` (or an `else => .i64`-style default in `resolveTypeArg` or the -> builtin-call lowering). The fix: when a call resolves to a bodiless `#builtin` -> decl that no recognizer claimed, emit -> `self.diagnostics.addFmt(.err, callee.span, "unknown #builtin '{s}'", .{name})` -> and return a distinct sentinel — never a silent `0`. Grep for the `#builtin` -> body marker handling + how bodiless-decl calls are lowered. Verification: run the -> repro, expect the new diagnostic + non-zero exit; confirm `zig build test` stays -> green (no legitimate `#builtin` is newly rejected — every shipped builtin IS -> recognized, so only genuinely-unknown names should newly error). Add a `11xx` -> diagnostic example locking the new error. diff --git a/issues/0145-method-on-array-index-receiver-copies-element.md b/issues/0145-method-on-array-index-receiver-copies-element.md deleted file mode 100644 index f304a637..00000000 --- a/issues/0145-method-on-array-index-receiver-copies-element.md +++ /dev/null @@ -1,78 +0,0 @@ -# 0145 — method with `*self` called directly on an array-index expression operates on a COPY - -> **RESOLVED.** `fixupMethodReceiver` (src/ir/lower/expr.zig) now takes the -> real address of `.index_expr` and `.deref_expr` receivers via -> `lowerExprAsPtr` (normalizing to `*T`), mirroring the explicit-argument path -> in `call.zig` — so `arr[i].method()` mutates the live slot instead of a -> throwaway copy. A comptime-pack index (`xs[i]` where `xs` is a pack) is -> explicitly excluded: a pack has no runtime storage to address, so it keeps -> flowing through the general alloca+store-of-value path. Regression test: -> `examples/0188-types-method-array-index-receiver.sx`. - -## Summary - -Calling a method whose receiver is `*self` (mutating) directly on a -fixed-array element expression — `arr[i].method(...)` — mutates a temporary -COPY of the element, not the live array slot. The mutation is silently lost. -Binding the element to a pointer first (`p := @arr[i]; p.method(...)`) works -correctly. - -## Repro - -``` -S :: struct { - flag: bool; - set :: (self: *S) { self.flag = true; } -} - -A :: struct { items: [4]S; } - -main :: () -> i32 { - a : A = .{}; - a.items[1].set(); // BUG: mutates a copy - print("direct = {}\n", a.items[1].flag); // prints false - - p := @a.items[1]; - p.set(); // OK: mutates the live slot - print("ptr = {}\n", a.items[1].flag); // prints true - 0 -} -``` - -Observed: -``` -direct = false -ptr = true -``` - -Expected: both print `true` — `a.items[1].set()` takes `*self` and should bind -the receiver to the address of `a.items[1]`, exactly as the explicit-pointer -form does. - -The same surfaced with a non-trivial method (`Slider.handle_event(self: *Slider, -...)`): the direct call returned `true` (so the method body ran) yet left the -element's `pressed`/`value` fields unchanged, while `@arr[i]` bound to a local -and called on that pointer mutated the element as expected. - -## Impact - -Any struct that holds a fixed array of widgets/records and dispatches `*self` -methods per element (a layers panel with one `Slider` per row, an entity table, -etc.) silently no-ops the mutation. It is easy to miss because the method's -return value is correct — only the in-place writes vanish. - -## Workaround - -Bind the element to a pointer before the call: - -``` -p := @arr[i]; -p.method(...); -``` - -Field stores through the index (`arr[i].field = v`) and value assignment -(`arr[i] = v`) appear unaffected; only the implicit `&arr[i]` receiver of a -`*self` method call is materialized as a copy. - -Found while implementing `ui/layers_panel.sx` in the photo editor (one opacity -`Slider` per layer row). diff --git a/issues/0146-int-to-float-cast-dropped-in-comparison.md b/issues/0146-int-to-float-cast-dropped-in-comparison.md deleted file mode 100644 index 679f2369..00000000 --- a/issues/0146-int-to-float-cast-dropped-in-comparison.md +++ /dev/null @@ -1,74 +0,0 @@ -# 0146 — `xx i < t` drops the int→float cast, emits a mixed-type compare - -> **RESOLVED.** `lowerBinaryOp` (src/ir/lower/expr.zig) now promotes the -> comparison operands to the common type before emitting the compare: for the -> ordering/equality arms, when the promoted type `ty` (from `arithResultType`) -> is a float, each operand whose IR type differs is coerced via `coerceToType` -> (SIToFP / FPExt). LLVM then receives same-typed operands and a well-formed -> `fcmp`, instead of a mixed-type compare the verifier rejects. Regression -> test: `examples/0189-types-int-float-compare-promote.sx`. - -## Summary - -A comparison whose left operand is an *inline* int→float cast against a float -right operand (`if xx i < t { ... }`, with `i : i32` and `t : f32`) does NOT -coerce the integer to float. The backend emits the compare with mismatched -operand types and LLVM module verification fails the whole build: - -``` -LLVM verification failed: Both operands to ICmp instruction are not of the same type! - %icmp = icmp slt i32 %load4, float %load5 -error: default build pipeline failed: ComptimeVmBail: comptime emit_object: object emission failed -``` - -The same shape with `==` and `>` produces the matching `icmp eq`/`icmp sgt` -variants — it is the cast in the comparison operand that is lost, not a single -operator. - -## Minimal repro - -```sx -#import "modules/std.sx"; -ceil_half :: (v: f32) -> i32 { - t := v - 0.5; - i : i32 = xx t; - if xx i < t { // <-- icmp slt i32, float — cast dropped - i += 1; - } - i -} -main :: () -> i32 { - print("{}\n", ceil_half(2.3)); - 0 -} -``` - -`bash sx build` on this fails at object emission with the verifier error above. - -## Expected - -`xx i` is an i32→f32 cast; the comparison should be a float compare -(`fcmp`), i.e. the cast must materialize before the compare. The literal value -is computed correctly at comptime — only the emitted compare is malformed. - -## Workaround (documented, in use) - -Bind the cast to a typed float local first, then compare the local: - -```sx -i : i32 = xx t; -fi : f32 = xx i; // materialize the cast into a typed local -if fi < t { i += 1; } // float compare, both operands f32 — OK -``` - -This compiles and runs correctly. Applied in `doc/selection.sx` -(`ceil_half` / `floor_half_excl`). - -## Related - -A sibling symptom in the same function family: `sy := xx y + 0.5` (with -`y : i32`, `0.5` a comptime float) infers `sy` as **f64/double**, so a later -`sy >= lo` against an `f32 lo` emits `fcmp oge double, float`. Annotating the -local explicitly (`sy : f32 = xx y + 0.5`) pins it to f32 and the compare -matches. Likely the same missing-coercion root cause around mixed -int/comptime-float expressions feeding a float local or a compare. diff --git a/issues/0147-size-of-qualified-aliased-type-unresolved.md b/issues/0147-size-of-qualified-aliased-type-unresolved.md deleted file mode 100644 index cae3a732..00000000 --- a/issues/0147-size-of-qualified-aliased-type-unresolved.md +++ /dev/null @@ -1,68 +0,0 @@ -# 0147 — `size_of(alias.Type)` on a module-aliased type fails: "expects a type, got 'unresolved'" - -> **RESOLVED.** In a reflection-builtin argument slot, a module-alias-qualified -> type (`sel.Selection`) parses as a `.field_access` expression (not the dotted -> `.type_expr` a declaration annotation produces), and neither `isStaticTypeArg` -> nor `resolveTypeArg` (src/ir/lower/generic.zig) had a `.field_access` arm — so -> the reflection guard rejected it as a non-type. Both now handle the qualified -> form: `isStaticTypeArg` recognizes `alias.Type` when `alias` is a namespace -> alias whose target authors a type named `Type` (pure decl scan), and -> `resolveTypeArg` resolves it via `namespaceAliasTarget` + `resolveNominalLeaf` -> in the target module's context (the same mechanism `lowerFieldAccess` uses for -> `alias.Type` in value position). Regression test: -> `examples/0192-types-size-of-qualified-alias.sx`. - -## Summary -`size_of(T)` does not resolve a type referenced through a module ALIAS -(`alias :: #import "..."`), even though that same alias resolves fine everywhere -else (declarations, casts, struct-literal construction). The compiler reports the -qualified type as `unresolved` only inside `size_of`. - -## Repro -```sx -sel :: #import "doc/selection.sx"; // selection.sx exports `Selection` - -box :: () -> *sel.Selection { - // both of these fail: - p : *sel.Selection = xx context.allocator.alloc_bytes(size_of(sel.Selection)); - memset(xx p, 0, size_of(sel.Selection)); - p -} -``` - -Error: -``` -error: size_of expects a type, got 'unresolved' - | - | ... alloc_bytes(size_of(sel.Selection)); - | ^^^^^^^^^^^^^ -``` - -Note the SAME `sel.Selection` resolves correctly in the variable declaration -`p : *sel.Selection` and in calls like `sel.selection_create(...)` — only the -`size_of(...)` argument position treats the qualified name as unresolved. - -## Expected -`size_of(sel.Selection)` resolves the aliased type and yields its size, exactly -as `size_of(Selection)` does for an unqualified/flat-imported type. - -## Workaround (clean) -Introduce an unqualified local type alias and feed THAT to `size_of`: -```sx -sel :: #import "doc/selection.sx"; -Selection :: sel.Selection; // unqualified alias - -box :: () -> *Selection { - p : *Selection = xx context.allocator.alloc_bytes(size_of(Selection)); - memset(xx p, 0, size_of(Selection)); - p -} -``` -`size_of(Selection)` (the unqualified alias) resolves fine. Used in -photo `tests/toolbar.sx`'s `box_sel` (the selection model is imported qualified -as `sel` there to avoid a `Point` collision with `modules/ui/types.sx`). - -## Impact -Minor. Only bites when a type must be reached through a module alias AND its size -is needed (heap-boxing a zeroed value of that type). The unqualified-alias -workaround is a one-liner and reads clearly. diff --git a/issues/0150-void-struct-field-unsized-llvm-trap.md b/issues/0150-void-struct-field-unsized-llvm-trap.md deleted file mode 100644 index bff566ef..00000000 --- a/issues/0150-void-struct-field-unsized-llvm-trap.md +++ /dev/null @@ -1,83 +0,0 @@ -# 0150 — a `void` struct field crashes the compiler (unsized-type SIGTRAP in LLVM) - -> **RESOLVED.** Two coordinated changes let a `void` (zero-sized) field be a -> legitimate construct (so `Future(void)` works): (1) `TypeLowering.fieldLLVMType` -> (src/backend/llvm/types.zig) lowers a `void` struct/tuple/`?T` field to a SIZED -> zero-byte `[0 x i8]` instead of LLVM's unsized `void` (which trapped -> `getTypeSizeInBits`), keeping element count/indices identical; (2) `emitStructInit` -> (src/backend/llvm/ops.zig) skips inserting a value for a `void` field — the i64 -> placeholder would type-mismatch the `[0 x i8]` slot and corrupt the aggregate -> constant (the original runtime bus-error). Regression test: -> `examples/0190-types-void-struct-field-zero-sized.sx` (covers a plain struct, a -> generic `Box(void)`, and a tuple void element). - -## Status -RESOLVED (was: OPEN) — surfaced by Stream B1 (fibers) B1.2: `Future(void)` (needed by -`timeout(io, ms) -> Future(void)`) instantiates a struct with a `result: void` -field, which hits this bug. Independent of the fibers work (a plain -`struct { v: void; }` reproduces it standalone). - -## Symptom -Declaring or instantiating any struct that has a field of type `void` aborts the -compiler with `SIGTRAP` (exit 133/134) — no sx diagnostic. The trap is LLVM's -`llvm_unreachable("Cannot getTypeInfo() on a type that is unsized!")`: - -``` -libLLVM`llvm::DataLayout::getTypeSizeInBits + 912 brk #0x1 (EXC_BREAKPOINT) -``` - -Reached via `declareFunction` → `toLLVMType(func.ret)` when a function returns -such a struct, or directly when laying out the struct. - -Observed: SIGTRAP, no output, no diagnostic. -Expected: either zero-size the `void` field (a `void`/zero-sized field is a -legitimate construct — cf. Zig) OR emit a clean type diagnostic -("a struct field may not have type `void`") — never a raw backend crash. - -## Reproduction -```sx -#import "modules/std.sx"; - -Holder :: struct { v: void; ok: bool; } - -main :: () -> i32 { - h : Holder = .{ ok = true }; - if h.ok { print("ok\n"); } - return 0; -} -``` -`./zig-out/bin/sx run repro.sx` → SIGTRAP (exit 133), no output. - -Also reproduces through a generic: `Box :: struct($T: Type) { v: T; }` then -`Box(void)` — i.e. any monomorphization that binds a struct field to `void`. - -## Suspected area -- `src/backend/llvm/types.zig` `toLLVMTypeInfo` (struct field loop ~line 111): - a `void` field's LLVM type is the unsized `void` type, then `getTypeSizeInBits` - on the enclosing struct traps. -- The type layout / size code (`src/ir/types.zig` `typeSizeBytes` and the LLVM - struct builder) should treat a `void` field as zero-sized (skip it in the LLVM - struct, size 0, align 1) — the same way a zero-field struct is handled. - -## Investigation prompt (paste into a fresh session) -> A `void` struct field crashes the sx compiler with an unsized-type SIGTRAP in -> LLVM `getTypeSizeInBits` (no diagnostic). Repro: `issues/0150-...` (run it → -> exit 133). Decide the semantics: a `void` field should be ZERO-SIZED (preferred -> — it is a legitimate construct, e.g. `Future(void).result`), laid out as -> nothing (size 0, align 1) and OMITTED from the LLVM struct body; OR, if -> zero-sized fields are out of scope, a clean front-end diagnostic ("a struct -> field may not have type `void`, found in field `` of ``") before -> emission — NEVER a backend trap. Likely sites: `src/backend/llvm/types.zig` -> `toLLVMTypeInfo` (skip `void` fields when building the LLVM struct element -> list) + `src/ir/types.zig` size/align (`typeSizeBytes`/align: a `void` field -> contributes 0). If choosing the diagnostic route, add it where struct fields -> are validated at type-resolution time. Verify: the repro prints `ok` (zero-size -> route) or emits the diagnostic + clean exit 1 (diagnostic route); then move the -> repro into `examples/` as a regression test. - -## Why this matters for B1 (fibers) -`Future($R)` with `$R = void` is the natural shape for `timeout(io, ms) -> -Future(void)` (B1.2 spec) and for any future-of-no-value. B1.2 deferred -`timeout` pending this fix rather than route around it with a substitute -non-void shape (which would hide the bug). Once 0150 lands, re-add `timeout` -with `Future(void)` (see the saved WIP at `.sx-tmp/b12-wip/io.sx`). diff --git a/issues/0151-ufcs-closure-return-pack-generic-unresolved.md b/issues/0151-ufcs-closure-return-pack-generic-unresolved.md deleted file mode 100644 index a7e31c64..00000000 --- a/issues/0151-ufcs-closure-return-pack-generic-unresolved.md +++ /dev/null @@ -1,167 +0,0 @@ -# 0151 — generic type-var not inferred through a pointer / via UFCS (LLVM SIGTRAP / "cannot infer") - -## ✅ RESOLVED (2026-06-21) - -**Root cause** — the generic-inference engine had no path to bind a `$T` -from a generic-struct argument head. Three gaps, all in -`src/ir/lower/generic.zig` + the UFCS dispatch: - -1. `extractTypeParam` / `matchTypeParam` / `matchTypeParamStatic` lacked a - `.parameterized_type_expr` arm — so `Box($T)` (and, recursively, the - pointee of `*Box($T)`) never matched a type-param. Added an arm that - recovers the arg instance's recorded per-param bindings - (`struct_instance_bindings` + the template's ordered `type_params` via - `struct_instance_author`) and recurses positionally. -2. The `pointer_type_expr` arm bailed when the arg wasn't itself a pointer. - A UFCS receiver (`b.m()`) / a value passed to a `*T` param is auto- - address-of'd, so the arg type is the *value* `Box($T)`. Added a fall- - through that matches the pointee against the non-pointer arg. -3. `ExprTyper.inferType` had no `.lambda` arm (returned `.unresolved`), so - the UFCS binder — which types args from the raw AST *before* they're - lowered — couldn't read a lambda's declared return type to bind a - `Closure(..) -> $R`. Added an arm that builds the closure type from the - lambda's annotations. -4. A pack UFCS target (`worker: Closure(..) -> $R, ..$args`) was dispatched - through the non-pack generic path, which can't expand the pack. Routed - it through the SAME `lowerPackFnCall` the direct call uses, with the - receiver spliced in as `args[0]` (a synthetic call — `lowerPackFnCall` - reads only `call_node.args`, never the callee). - -**Fix verified** — the repro prints `value=42` (both spellings). Regression -tests: `examples/0214-generics-ufcs-closure-return-pack.sx` (direct + UFCS -closure-return pack) and `examples/0215-generics-infer-through-pointer.sx` -(by-value / pointer / multi-param / nested / UFCS-auto-ref struct-head -inference). Full suite green (726/0). - -**Downstream (NOT this bug):** with `await`/`cancel` now callable, the -B1.2 async examples surface a SEPARATE codegen bug — `Atomic(bool)` emits a -sub-byte (i1) atomic load/store that fails LLVM verification (filed as a new -issue). The `Future.canceled: Atomic(bool)` field hits it, so `1805`/`1806` -stay blocked on that, not on 0151. - ---- - - -## WIDENED (adversarial review of B1.2, 2026-06-21) -The UFCS-closure-return-pack case below is one symptom of a BROADER generic-inference -gap: **sx cannot infer a generic `$T` from a POINTER-wrapped argument.** Minimal repro, -no UFCS / no pack / no closure involved: -```sx -Box :: struct ($T: Type) { v: T; } -unbox :: (b: *Box($T)) -> $T { return b.v; } -// unbox(@b) → error: cannot infer generic type parameter 'T' for 'unbox' -``` -This blocks `await`/`cancel` in `library/modules/std/io.sx` (both take `*Future($R)`) — -they are **uncallable in EVERY form** (explicit `await(@f)` → "cannot infer 'R'"; UFCS -`f.await()` → SIGTRAP). So B1.2's async layer can CREATE a Future (`async(...)` works) but -cannot AWAIT it. Fix scope is the generic-inference engine (infer `$T` from `*T`-wrapped -params, and from closure-return-via-pack, and through UFCS dot-dispatch) — not the Io lib. -The two symptoms below + the `*Box($T)` repro above are the acceptance cases. - -## Symptom - -A generic free fn whose generic param `$R` is inferred from a worker -**closure's return type** — `worker: Closure(..$args) -> $R` plus a -trailing `..$args` pack — and which RETURNS a type built from `$R` -(`-> Wrap($R)`), monomorphizes correctly when called **directly** -(`mymk(recv, worker, ..)`) but leaves `$R` **unresolved** when called via -**UFCS dot syntax** (`recv.mymk(worker, ..)`). The unresolved `Wrap($R)` -reaches LLVM emission and trips the tripwire: - -``` -thread … panic: unresolved type reached LLVM emission — a type -resolution failure was not diagnosed/aborted - src/backend/llvm/types.zig:180 .unresolved => @panic(...) - … toLLVMTypeInfo (struct field) → toLLVMType - … emit_llvm.zig:1262 declareFunction const raw_ret_ty = self.toLLVMType(func.ret) -``` - -- `mymk(bx, worker, 40, 2)` (direct) → **works** (prints 42) -- `bx.mymk(worker, 40, 2)` (UFCS) → **SIGTRAP** (unresolved `$R`) - -This is distinct from RESOLVED issue 0119 (UFCS generic free-fn where -`$T` is inferred from the **receiver / a slice param**). Here the -receiver does NOT carry `$R`; `$R` comes only from the **closure -return type**, and the variadic `..$args` pack is also present. The -UFCS dispatch path infers `$R` differently from (and incorrectly vs) -the direct-call path: direct resolves `$R = i64`, UFCS leaves it -`.unresolved`. - -## Reproduction - -Standalone — depends on no project symbols beyond `modules/std.sx`: - -```sx -#import "modules/std.sx"; - -Box :: struct { n: i64; } -Wrap :: struct ($R: Type) { value: R; } - -mymk :: ufcs (b: Box, worker: Closure(..$args) -> $R, ..$args) -> Wrap($R) { - f : Wrap($R) = ---; - f.value = worker(..args); - return f; -} - -main :: () -> i32 { - bx : Box = .{ n = 1 }; - // Direct call — works (prints 42): - // g := mymk(bx, (a: i64, b: i64) -> i64 => a + b, 40, 2); - // UFCS dot-call — SIGTRAPs with "unresolved type reached LLVM emission": - g := bx.mymk((a: i64, b: i64) -> i64 => a + b, 40, 2); - print("value={}\n", g.value); - return 0; -} -``` - -Expected: the UFCS spelling resolves `$R = i64` identically to the -direct spelling and prints `value=42`. - -## Impact - -Blocks Stream B1 step B1.2's verified async idiom: the user-facing -call site is `context.io.async((a,b) -> R => ..., x, y)` — a UFCS -dot-call whose `$R` is inferred from the worker closure's return type -through the `..$args` pack. The async/await LIBRARY layer (Io protocol, -`Future($R)`, blocking impl, `async`/`async_void`/`await`/`cancel`) -and the compiler plumbing (Context.io field, both `__sx_default_context` -materializers, push-inherit) are all in place and correct — the -`async` body itself works when invoked with the receiver passed -explicitly (`async(context.io, worker, ..)`). Only the UFCS dot form -hits this inference gap. - -## Investigation prompt - -The UFCS-vs-direct generic inference divergence lives in the UFCS -rewrite + generic-binding path. issue 0119's fix routed -`inferGenericReturnType` through the same `buildTypeBindings` the -monomorphizer uses, but that fix established bindings from the -**receiver / structured params** (`[]$T`); it does NOT cover `$R` -inferred from a **closure argument's return type** combined with a -trailing **variadic pack** on the UFCS path. - -Suspect area: -- `src/ir/calls.zig` — UFCS dot-call resolution / receiver binding. -- `src/ir/lower/call.zig` — generic argument inference - (`buildTypeBindings` / the closure-arg → `$R` binding; the - variadic-pack `..$args` binding). -- Compare the binding map produced for the DIRECT call - `mymk(bx, worker, 40, 2)` (resolves `$R = i64`) against the one for - the UFCS call `bx.mymk(worker, 40, 2)` — the UFCS path must be - dropping/mis-indexing the closure-arg's return-type contribution to - `$R` (likely because the receiver is spliced in as arg 0 and the - closure/pack arg indices shift, so the closure-return → `$R` - inference rule no longer fires). - -The fix likely needs: when rewriting `recv.f(args)` → `f(recv, args)`, -run the SAME closure-return-type → generic-param inference (and the -same variadic-pack binding) the direct path runs, against the -spliced-receiver argument list — so `$R` binds from the closure's -return type regardless of dot vs direct spelling. - -Verification: run the repro above (`issues/0151-...sx`); expect -`value=42` (was SIGTRAP). Then restore example -`examples/1805-concurrency-io-blocking-async.sx` to the UFCS -`context.io.async(...)` form (see Stream B1 / CHECKPOINT-FIBERS) and -confirm it prints `double: 42` / `sum: 42` / `clock ok`. -``` diff --git a/issues/0152-atomic-bool-sub-byte-atomic-llvm-reject.md b/issues/0152-atomic-bool-sub-byte-atomic-llvm-reject.md deleted file mode 100644 index c9ddff60..00000000 --- a/issues/0152-atomic-bool-sub-byte-atomic-llvm-reject.md +++ /dev/null @@ -1,108 +0,0 @@ -# 0152 — `Atomic(bool)` emits a sub-byte (i1) atomic load/store that LLVM rejects - -## ✅ RESOLVED (2026-06-21) - -**Root cause** — the atomic load/store emitters in `src/backend/llvm/ops.zig` -used `toLLVMType(ty)` directly as the atomic access type. For a `bool` -element that is `i1`, which LLVM rejects for atomics (size must be a byte -multiple). - -**Fix** — `emitAtomicLoad`/`emitAtomicStore` now promote a sub-byte element -to its byte storage type (`i8`) for the atomic access, and `trunc`/`zext` -the value at the boundary (a new `atomicByteType` helper returns `i8` for -`.bool`, null otherwise). rmw/cmpxchg were left unchanged on purpose: a -`bool` rmw/CAS is rejected at the sx level (`atomic.sx` — "requires an -integer type"), so a sub-byte element can never reach those emitters (a -comment records this). `bool` is the only sub-byte scalar in sx. - -**Verified** — the repro prints `yes`; regression test -`examples/1705-atomics-bool-byte-promoted.sx` (init / store / reset round- -trip on `Atomic(bool)`). Full suite green (729/0). - -**Downstream (NOT this bug):** with `Atomic(bool)` fixed, the B1.2 async -examples surfaced ANOTHER, separate bug — a generic value-failable -`($R, !E)` fn reached through a re-export alias loses its `!` error channel -at the call site (typed as a plain tuple), so `await(...) or { … }` builds a -malformed PHI. `io.sx`'s `await`/`IoErr` are re-exported via `std.sx`, so the -async surface is now blocked on THAT (filed as a new issue), not on 0152. - ---- - - -## Symptom - -`Atomic(bool)` lowers `bool` to LLVM `i1` and emits the atomic load/store -at that type. LLVM requires atomic memory accesses to be byte-sized, so -codegen fails verification: - -``` -LLVM verification failed: atomic memory access' size must be byte-sized - i1 store atomic i1 %load5, ptr %gep release, align 1 - i1 %atomic_load = load atomic i1, ptr %gep11 acquire, align 1 -``` - -- `Atomic(i64)`, `Atomic(i32)`, … → fine (byte-sized). -- `Atomic(bool)` → LLVM verifier error (i1 is 1 *bit*, not byte-sized). - -## Reproduction - -Standalone — depends on no project symbols beyond `modules/std.sx` + -`modules/std/atomic.sx`: - -```sx -#import "modules/std.sx"; -#import "modules/std/atomic.sx"; - -main :: () -> i32 { - a := Atomic(bool).init(false); - a.store(true, .release); - if a.load(.acquire) { print("yes\n"); } else { print("no\n"); } - return 0; -} -``` - -Expected: prints `yes`. Actual: LLVM verification failure (i1 atomic). - -## Impact - -Blocks the B1.2 async surface. `library/modules/std/io.sx`'s -`Future($R)` carries a `canceled: Atomic(bool)` cancellation flag (atomic -so a future scheduler thread can flip it). `async`/`cancel`/`await` all -touch it (`Atomic(bool).init`, `.store(true, .release)`, -`.load(.acquire)`), so the async examples (`1805`/`1806`) cannot build. -This is independent of issue 0151 (generic inference) — that is now fixed, -which is what newly exposed this codegen path. - -## Investigation prompt - -The atomic load/store emitters in `src/backend/llvm/ops.zig` -(`emitAtomicLoad` ~line 387, `emitAtomicStore`, `emitAtomicRmw`, -`emitAtomicCmpxchg`) use `toLLVMType(instruction.ty)` directly as the -atomic access type. For a `bool` element that is `i1`, which LLVM rejects -for atomics (must be a byte-multiple). - -The fix should promote a sub-byte atomic to its byte-sized storage type: -load/store as `i8` (the ABI storage type for `bool`) and `trunc`/`zext` -between `i1` and `i8` at the value boundary — mirroring how a non-atomic -`bool` field is already stored as a byte. Apply consistently across -load / store / rmw / cmpxchg so an `Atomic(bool)` round-trips. Confirm the -alignment (`LLVMSetAlignment`) uses the promoted byte size. - -Possible alternative: have `Atomic($T)` (in `library/modules/std/atomic.sx`) -constrain / widen a `bool` element to a byte-sized integer in the type -itself — but the codegen-level promotion is more robust (any i1-typed -atomic, however it arises, becomes legal). - -Verification: run the repro above; expect `yes`. Then restore -`examples/1805-concurrency-io-blocking-async.sx` (+ add `1806` cancel) per -Stream B1 / CHECKPOINT-FIBERS and confirm the async surface builds and runs -(`sum: 42` / `double: 42`, cancel → `.canceled`). - -### Possibly-related secondary symptom (verify after the i1 fix) - -The same async probe also tripped an `or`-merge PHI type mismatch -(`%bp = phi i1 [ true, … ], [ 0, … ]`) when `f.await() or { }` was -lowered. A minimal `(i64, !E)` + `or { -1 }` does NOT reproduce it, so this -may be entangled with the malformed `Atomic(bool)` field in the `Future` -struct rather than a second bug. Re-check once the i1 atomic is fixed; if it -persists, file separately. diff --git a/issues/0153-reexport-generic-value-failable-loses-error-channel.md b/issues/0153-reexport-generic-value-failable-loses-error-channel.md deleted file mode 100644 index 7714d97a..00000000 --- a/issues/0153-reexport-generic-value-failable-loses-error-channel.md +++ /dev/null @@ -1,139 +0,0 @@ -# 0153 — a re-exported generic value-failable `($R, !E)` loses its `!` error channel - -## ✅ RESOLVED (2026-06-21) - -**Root cause** — `GenericResolver.inferGenericReturnType` -(`src/ir/generics.zig`) resolved the generic call's return-type AST -(`($R, !E)`) in the CALL-SITE module context. For a re-exported fn the error -set name (`LE` / `IoErr`, re-exported as `LE :: lib.LE`) resolved through the -call-site alias to a TypeId that is NOT tagged `.error_set`, so the planned -result was a tuple whose last field wasn't an error set — `errorChannelOf` -(`lower/error.zig:148`) saw a plain tuple and the failable channel was lost. -`monomorphizeFunction` already pins the source to the fn's defining module -before resolving the return type; `inferGenericReturnType` did not, so the -planned call-result type and the instance's real signature disagreed. - -**Fix** — pin the source to the function's defining module -(`fd.body.source_file`) around the return-type resolution in -`inferGenericReturnType`, mirroring `monomorphizeFunction`. The binding-build -stays in the call-site context (its args are typed there). Now the `!E` -resolves to the same `.error_set` TypeId the instance's signature uses. - -**Verified** — the repro prints `r=42`; regression test -`examples/1058-errors-reexport-value-failable-channel.sx` (+ companion -`lib.sx`). This also unblocked the B1.2 async surface end-to-end: -`examples/1805-concurrency-io-blocking-async.sx` (`sum: 42` / `double: 42` / -`clock ok`) + `examples/1806-concurrency-io-cancel.sx` (cancel → `await` -raises `.Canceled`). Full suite green. - ---- - - -## Symptom - -A generic function returning a value-failable `($R, !E)` keeps its error -channel when called from the module that declares it, but **loses it when -the function is reached through a re-export alias** (`get :: lib.get;`). At -the consumer the call result is typed as a plain **tuple** (last field is a -*non*-`.error_set` type), so: - -- `try f()` → `error: `try` requires a failable expression; operand has type 'tuple'` -- `f() or { default }` → LLVM verification failure — the `or` merge PHI is - typed `i1` (the lost-channel discriminant) but carries the `i64` default: - ``` - PHI node operands are not the same type as the result! - %bp = phi i1 [ true, %entry ], [ -1, %or.rhs.0 ] - ``` - -It requires **both** conditions — drop either and it works: - -- non-generic re-exported value-failable + `or` → **works** -- generic value-failable imported **directly** (no re-export) + `or` → **works** -- generic value-failable, **direct call** (no UFCS) through a re-export → **fails** too - (so it is NOT UFCS-specific) - -## Reproduction - -Co-located minimal repro (two files, no project deps beyond `modules/std.sx`): -`issues/0153-...sx` (consumer) + `issues/0153-.../lib.sx` (impl). Run the -consumer; expect `r=42`, get the PHI verification failure. - -```sx -// lib.sx -#import "modules/std.sx"; -LE :: error { Bad } -Box :: struct ($R: Type) { v: R; } -get :: ufcs (b: *Box($R)) -> ($R, !LE) { return b.v; } -``` -```sx -// main -#import "modules/std.sx"; -lib :: #import ".../lib.sx"; -Box :: lib.Box; // re-export the generic struct, -get :: lib.get; // the generic value-failable fn, -LE :: lib.LE; // AND its error set (the std.sx facade pattern) -main :: () -> i32 { - b : Box(i64) = .{ v = 42 }; - r := b.get() or { -1 }; // ← PHI i1/i64 mismatch - print("r={}\n", r); - return 0; -} -``` - -Real-world one-liner (same bug, via the stdlib facade — `await`/`IoErr` are -re-exported from `std/io.sx` through `std.sx`): - -```sx -#import "modules/std.sx"; -#import "modules/std/atomic.sx"; -main :: () -> i32 { - f : Future(i64) = ---; - f.value = 42; f.state = .ready; f.canceled = Atomic(bool).init(false); - r := f.await() or { -1 }; // ← same PHI mismatch - print("r={}\n", r); - return 0; -} -``` - -## Impact - -Blocks the B1.2 async surface (the LAST blocker after 0151 + 0152). `await` -returns `($R, !IoErr)` and is re-exported via `std.sx` -(`await :: io_mod.await; IoErr :: io_mod.IoErr;`), so every -`context.io.async(...).await() or { … }` / `try …await()` hits this. The -async runtime itself is correct (Futures build, `$R` infers, the value is -right) — only the call-site failable typing is wrong. - -## Investigation prompt - -A value-failable `(T, !E)` is represented as a **tuple whose LAST field is -an `.error_set` TypeId** — that is exactly what `Lowering.errorChannelOf` -(`src/ir/lower/error.zig:148`) keys on. The bug is that the call-result type -inferred for a re-exported generic fn is a tuple whose last field is NOT an -`.error_set`, so `errorChannelOf` returns null (→ "plain tuple"). - -Suspect: the generic return-type resolution -(`inferGenericReturnType` / `buildTypeBindings` in `src/ir/generics.zig` + -`monomorphizeFunction` in `src/ir/lower/generic.zig`) resolves the fn's -return-type AST `($R, !LE)` in a module context where the error-set name -reached through the re-export alias (`LE :: lib.LE`) resolves to a TypeId -that is NOT tagged `.error_set` (a duplicate/plain interning of the aliased -error type, or the alias is followed to a non-error-set placeholder). The -"generic + re-export" co-requirement points at the monomorphized return-type -path specifically — a non-generic re-export keeps the channel (its return -type isn't re-resolved per-instance), and a direct generic import keeps it -(the error set resolves in its own module). - -Steps: -1. At the consumer call site, dump the inferred call-result TypeId for - `b.get()` and inspect its last tuple field's `TypeInfo` — confirm it is - NOT `.error_set` (vs the direct-import case, where it IS). -2. Trace where the aliased error-set name (`LE` / `IoErr`) is resolved during - the instance's return-type construction; ensure it resolves to the SAME - `.error_set` TypeId the declaring module interned (follow the re-export - alias to the original error set, don't re-intern a plain type). - -Verification: run the co-located repro; expect `r=42`. Then restore the B1.2 -async examples (`examples/1805-concurrency-io-blocking-async.sx` + -`1806-...-io-cancel.sx`) per CHECKPOINT-FIBERS and confirm -`sum: 42` / `double: 42` / cancel raises `.Canceled`.