test: remove resolved/fixed issue writeups
Delete the issues/*.md whose writeup carries a RESOLVED or FIXED banner; only the open issues (0030, 0148) remain.
This commit is contained in:
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -1,149 +0,0 @@
|
||||
# issue-0042 — Const-decl type aliases (`MyInt :: i32;`) silently return `.i64` from `size_of` / `align_of`
|
||||
|
||||
> **RESOLVED.** Root cause: the bare-name type-arg resolver consulted only `findByName`, falling back to `.i64` (8 bytes) for const-decl aliases (`MyInt :: i32;`) recorded in the alias map — silently mis-sizing any non-8-byte alias.
|
||||
> Fix: the nominal-leaf resolver now consults the alias maps before falling back — `resolveNominalLeaf` → `selectNominalLeaf` (`src/ir/lower/decl.zig`) returns `.resolved` with the alias's real `TypeId` from `program_index.type_aliases_by_source` (own-author const_decl branch ~L1820 / unwired branch ~L1801); aliases are registered via `putTypeAlias` (`decl.zig:560`).
|
||||
> The old `resolveTypeArg` `.identifier` arm in `lower.zig` is gone — the source-keyed `selectNominalLeaf` superseded it.
|
||||
> Covering regression test: `examples/0116-types-type-alias-size-align.sx` (alias, chain, and struct-name alias through `size_of`/`align_of`).
|
||||
|
||||
**FIXED.** `MyInt :: i32; size_of(MyInt)` now returns `4`
|
||||
correctly. The `resolveTypeArg` `.identifier` branch consults
|
||||
`type_alias_map` before falling through. The fix landed
|
||||
alongside the broader alias-resolution work tracked in
|
||||
CHECKPOINT.md (Session 63's `type_bridge` alias-resolution
|
||||
extension); no specific commit isolates this issue.
|
||||
|
||||
Below preserved as a record of the original problem.
|
||||
|
||||
## Symptom
|
||||
|
||||
A type alias declared via `Foo :: SomeType;` is registered in the
|
||||
lowering's `type_alias_map` but is **never consulted** when the alias
|
||||
name is later used as a type argument to `size_of` / `align_of`. The
|
||||
fallback returns `.i64` (8 bytes) — which coincidentally produces a
|
||||
correct result for any alias whose underlying type is 8 bytes
|
||||
(`*T`, `f64`, function pointers, `i64`, `u64`), silently masking the
|
||||
bug for years.
|
||||
|
||||
Observed:
|
||||
```
|
||||
size_of(i32) = 4 ← direct, correct
|
||||
size_of(MyInt) = 8 ← via alias, WRONG (expected 4)
|
||||
```
|
||||
|
||||
Where `MyInt :: i32;`.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
MyInt :: i32;
|
||||
|
||||
main :: () -> i32 {
|
||||
print("direct: {}\n", size_of(i32)); // 4
|
||||
print("alias: {}\n", size_of(MyInt)); // 8 — should be 4
|
||||
0;
|
||||
}
|
||||
```
|
||||
|
||||
`./zig-out/bin/sx run` against unmodified master prints:
|
||||
|
||||
```
|
||||
direct: 4
|
||||
alias: 8
|
||||
```
|
||||
|
||||
## Why this surfaces now
|
||||
|
||||
issue-0041 work extends the const-decl alias path to register
|
||||
pointer, optional, array, slice, many-pointer, and function-type
|
||||
aliases (`Ptr :: *u8;`, `Maybe :: ?u8;`, `Arr :: [3]u8;`,
|
||||
`Cb :: (i32) -> i32;`). Every one of those aliases ends up in
|
||||
`type_alias_map`, then `size_of(<alias>)` falls through the same
|
||||
`.identifier` branch that ignores the map — returning `.i64` (8).
|
||||
For pointer and function-type aliases this is coincidentally right
|
||||
(8 bytes). For optional, array, etc. it produces silently-wrong
|
||||
sizes (`size_of(Maybe) = 8` instead of 2;
|
||||
`size_of(Arr) = 8` instead of 3).
|
||||
|
||||
The issue-0041 work cannot land without this being fixed — the
|
||||
test snapshots would pin in the wrong values and the new feature
|
||||
would ship subtly broken.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> The bug lives in `src/ir/lower.zig`, in `resolveTypeArg`
|
||||
> ([line ~7132](src/ir/lower.zig#L7132)). The `.identifier`
|
||||
> branch looks like:
|
||||
>
|
||||
> ```zig
|
||||
> .identifier => |id| {
|
||||
> if (self.type_bindings) |tb| {
|
||||
> if (tb.get(id.name)) |ty| return ty;
|
||||
> }
|
||||
> const name_id = self.module.types.internString(id.name);
|
||||
> return self.module.types.findByName(name_id) orelse .i64;
|
||||
> },
|
||||
> ```
|
||||
>
|
||||
> It checks `type_bindings` (generic-monomorphization) and
|
||||
> `findByName` (registered named types), but never consults
|
||||
> `self.type_alias_map` — which is where the const-decl alias
|
||||
> registration in `lower.zig:425` puts entries. The neighbouring
|
||||
> `.type_expr` branch (line ~7143) DOES check `type_alias_map`:
|
||||
>
|
||||
> ```zig
|
||||
> .type_expr => |te| {
|
||||
> if (self.type_alias_map.get(te.name)) |alias_ty| return alias_ty;
|
||||
> return type_bridge.resolveAstType(node, &self.module.types);
|
||||
> },
|
||||
> ```
|
||||
>
|
||||
> Why two branches: an `.identifier` AST node is what parsePrimary
|
||||
> emits for non-keyword names; `.type_expr` is what it emits for
|
||||
> built-in primitive names recognised by `Type.fromName` (`i32`,
|
||||
> `u8`, etc.) and for the `f32`/`f64`/`Type` keywords. User-defined
|
||||
> alias names like `MyInt` and `Ptr` flow through `.identifier`.
|
||||
>
|
||||
> **Likely fix:** mirror the `type_alias_map.get` lookup in the
|
||||
> `.identifier` branch — try alias map first (or before/after
|
||||
> findByName, whichever is the established precedence elsewhere).
|
||||
>
|
||||
> ```zig
|
||||
> .identifier => |id| {
|
||||
> if (self.type_bindings) |tb| {
|
||||
> if (tb.get(id.name)) |ty| return ty;
|
||||
> }
|
||||
> if (self.type_alias_map.get(id.name)) |alias_ty| return alias_ty;
|
||||
> const name_id = self.module.types.internString(id.name);
|
||||
> return self.module.types.findByName(name_id) orelse .i64;
|
||||
> },
|
||||
> ```
|
||||
>
|
||||
> **Verification:**
|
||||
> 1. Add the repro above as `examples/issue-0042.sx`.
|
||||
> 2. `bash tests/run_examples.sh --update` to capture expected
|
||||
> output (`alias: 4`, not `alias: 8`).
|
||||
> 3. Make sure existing snapshots that test type aliases (search
|
||||
> `examples/` for `::` patterns followed by `size_of`) don't
|
||||
> change in unexpected ways.
|
||||
>
|
||||
> **Possible adjacency:** the issue may extend to `align_of`
|
||||
> (likely same call path) and to type-alias chains
|
||||
> (`A :: i32; B :: A;` — does B resolve through A's alias entry?).
|
||||
> Worth pinning down with a test once the primary fix lands.
|
||||
|
||||
## Plan-level impact
|
||||
|
||||
Blocks issue-0041 (compound-type-as-expression). Once 0042 is
|
||||
fixed, 0041 work can resume from the testing phase (the parser and
|
||||
lowering edits for 0041 are already in place; only the alias
|
||||
lookup is broken).
|
||||
|
||||
## Suggested fix order
|
||||
|
||||
1. Land 0042's `.identifier` alias-map lookup.
|
||||
2. Resume 0041 from the test step — re-run `examples/issue-0041.sx`
|
||||
and verify `size_of(Maybe) = 2`, `size_of(Arr) = 3`, etc.
|
||||
3. Regenerate snapshots and proceed with the 0041 finishing
|
||||
steps (50-smoke, rename, etc.).
|
||||
@@ -1,157 +0,0 @@
|
||||
# issue-0043: lazy-lowered function bodies don't resolve runtime-class method dispatch
|
||||
|
||||
> **RESOLVED.** Root cause: chained runtime-class dispatch (`Cls.alloc().init...(x)`) collapsed the inner call's return type, so the outer `.method(...)` couldn't find the receiver's `#objc_class` declaration — and on the lazy-lower path (inside an `inline if OS == .ios` branch) the runtime-class map was unavailable.
|
||||
> Fix: the runtime-class instance/static dispatch now resolves each call's declared return type via `resolveRuntimeMethodReturnType` against the runtime_class_map (`src/ir/calls.zig`, runtime-class branches ~L244-269 / L385-398), and that map lives on the shared `ProgramIndex` (`src/ir/program_index.zig:668`) so it is equally visible to eager and lazy lowering (`lazyLowerFunction`, `src/ir/lower/decl.zig:2508`).
|
||||
> Covering regression: `examples/1306-ffi-objc-runtime-class-chained-dispatch.sx` (header cites issue 0043) exercises both `*ClassName` and `*Self` chained shapes and passes.
|
||||
|
||||
**FIXED.** The original repro
|
||||
(`sx build --target ios-sim issue-0043.sx` with a
|
||||
`UIWindow.alloc().initWithWindowScene(scene)` call inside an
|
||||
`inline if OS == .ios { ... }`-gated function called transitively
|
||||
from `caller :: (...) callconv(.c)`) compiles cleanly today; chess
|
||||
on iOS-sim runs end-to-end through the same dispatch shape. The
|
||||
fix lives in tree as part of broader runtime-class /
|
||||
lazyLowerFunction work — no specific commit isolates it.
|
||||
|
||||
Below preserved as a record of the original problem.
|
||||
|
||||
## Symptom
|
||||
|
||||
When a function `B` containing `recv.method(...)` calls against a
|
||||
`#objc_class` receiver is invoked transitively via `lazyLowerFunction`
|
||||
from another `inline if OS == ...` branch in function `A`, the
|
||||
method dispatch fails with:
|
||||
|
||||
```
|
||||
unresolved 'methodName' (in <file> fn B)
|
||||
```
|
||||
|
||||
Direct (eager) lowering of `B` with the same body works. Direct call
|
||||
from `main` works. Only the transitive lazy-lower path from inside an
|
||||
`inline if` branch fails.
|
||||
|
||||
Concretely: under Phase 3.0 / Phase 3.2 C4 work, the iOS-sim build of
|
||||
chess fails:
|
||||
|
||||
```
|
||||
library/modules/platform/uikit.sx:591:12: error: unresolved 'initWithWindowScene' (in ... fn uikit_scene_will_connect_ios)
|
||||
library/modules/platform/uikit.sx:599:11: error: unresolved 'init'
|
||||
library/modules/platform/uikit.sx:609:5: error: unresolved 'setView'
|
||||
library/modules/platform/uikit.sx:611:5: error: unresolved 'setRootViewController'
|
||||
library/modules/platform/uikit.sx:661:11: error: unresolved 'init'
|
||||
```
|
||||
|
||||
Stack trace (with `SX_TRACE_UNRESOLVED=1`) shows the chain:
|
||||
|
||||
```
|
||||
emitError (lower.zig:5640)
|
||||
↑ lowerCall .field_access fallback
|
||||
lowerVarDecl (lowering body of uikit_scene_will_connect_ios)
|
||||
lowerBlock
|
||||
lazyLowerFunction (lower.zig:5165) (← lazy entry point)
|
||||
lowerCall (lower.zig:5165) (calling uikit_scene_will_connect_ios)
|
||||
lowerInlineBranch (inside `inline if OS == .ios { uikit_scene_will_connect_ios(...) }`)
|
||||
lowerIfExpr
|
||||
lowerExpr
|
||||
```
|
||||
|
||||
So when the *outer* `lowerCall` decides to lazy-lower
|
||||
`uikit_scene_will_connect_ios`, the *inner* body's method-dispatch
|
||||
on `*UIWindow` etc. fails to find the runtime-class declaration —
|
||||
even though the declaration is at module scope at the top of
|
||||
uikit.sx and resolves fine for non-lazy lowering paths (`sx build`
|
||||
for macOS target compiles the same source cleanly).
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
#import "modules/compiler.sx";
|
||||
|
||||
UIWindow :: #objc_class("UIWindow") extern {
|
||||
alloc :: () -> *UIWindow;
|
||||
initWithWindowScene :: (self: *Self, scene: *void) -> *UIWindow;
|
||||
}
|
||||
|
||||
// Function B: uses runtime-class method dispatch.
|
||||
do_work :: (scene: *void) {
|
||||
win := UIWindow.alloc().initWithWindowScene(scene);
|
||||
_ = win;
|
||||
}
|
||||
|
||||
// Function A: calls B inside an `inline if`. The transitive call
|
||||
// triggers lazy lowering of B, which fails for ios-sim only.
|
||||
caller :: (self: *void, _cmd: *void, scene: *void, b: *void, c: *void) callconv(.c) {
|
||||
inline if OS == .ios {
|
||||
do_work(scene);
|
||||
}
|
||||
}
|
||||
|
||||
main :: () -> i32 { 0; }
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```sh
|
||||
sx build --target ios-sim issue-0043.sx
|
||||
```
|
||||
|
||||
Observed: `error: unresolved 'initWithWindowScene' (in issue-0043.sx fn do_work)`.
|
||||
|
||||
Eager dispatch shape (called directly from `main` without the
|
||||
intermediate `inline if`-gated function) compiles cleanly. The
|
||||
isolated probe must mirror the lazy-lower trigger to reproduce.
|
||||
|
||||
## Investigation prompt (for a fresh session)
|
||||
|
||||
Suspected area: `lazyLowerFunction` at
|
||||
[src/ir/lower.zig:1057](../src/ir/lower.zig#L1057) and the field-
|
||||
access method dispatch at
|
||||
[src/ir/lower.zig:5290](../src/ir/lower.zig#L5290) (around the
|
||||
`runtime_class_map.get(sname_for_runtime)` lookup).
|
||||
|
||||
Hypotheses:
|
||||
|
||||
1. `lazyLowerFunction` swaps some piece of lowering state on entry
|
||||
(e.g., `saved_source_file`, `current_ctx_ref`) but doesn't preserve
|
||||
access to `runtime_class_map`. Check whether the map is
|
||||
instance-state vs. shared.
|
||||
2. The receiver type for `win` (`*UIWindow`) isn't being resolved to
|
||||
its `getStructTypeName` correctly during lazy lowering — possibly
|
||||
`inferExprType` for the lazy-lowered context resolves to an
|
||||
anonymous type instead of `UIWindow`.
|
||||
3. The runtime-class declarations are added to `runtime_class_map`
|
||||
during a pre-scan pass that runs BEFORE the outer function `A`'s
|
||||
body is lowered, but lazy lowering of `B` from within `A` might
|
||||
be observing the map at a pre-scan state where uikit.sx's
|
||||
declarations haven't been seen yet (cross-module ordering).
|
||||
|
||||
What the fix likely needs to do:
|
||||
- Confirm `runtime_class_map` contains `"UIWindow"` etc. at the
|
||||
point of the lazy lowering call (add a debug print at the failing
|
||||
dispatch).
|
||||
- If the map IS populated, trace why the field-access dispatch falls
|
||||
through to line 5640 instead of taking the `runtime_class_map.get`
|
||||
branch at line 5306.
|
||||
- If the map is NOT populated, find the seeding path and fix the
|
||||
ordering — likely a missing pre-scan step before lazy lowering.
|
||||
|
||||
Verification: rebuild chess with `sx build --target ios-sim` against
|
||||
the in-progress Phase 3.2 C4 branch (revert the
|
||||
`git checkout -- library/modules/platform/uikit.sx` to restore the
|
||||
work-in-progress migration) and confirm the unresolved errors go
|
||||
away.
|
||||
|
||||
## Why this blocks Phase 3.2 C4
|
||||
|
||||
The migration of `library/modules/platform/uikit.sx` to declarative
|
||||
`#objc_class` dispatch (Phase 3.2 plan part C, clusters C4 + C5)
|
||||
necessarily places runtime-class method calls inside iOS-only helper
|
||||
functions that get lazily lowered when chess's iOS scene-lifecycle
|
||||
hooks fire them. Without this fix, the migration produces compile
|
||||
errors on iOS-sim that don't appear on macOS. C1+C2+C3 happened to
|
||||
land cleanly because the methods they migrate are either (a) called
|
||||
from eagerly-lowered top-level paths, or (b) niladic enough that the
|
||||
specific dispatch path doesn't fail.
|
||||
|
||||
The Phase 3.2 plan flagged C4+C5 as blocked pending this fix.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,161 +0,0 @@
|
||||
# 0048 — bare `$args` slice loses `.len` (reads 0) when passed across a call
|
||||
|
||||
> **RESOLVED.** Root cause: a callee (`walk`/`describe`) lazily lowered *inside* a
|
||||
> pack-fn mono inherited the active pack's `pack_param_count`, so the
|
||||
> `<pack_name>.len` intercept in `lowerFieldAccess` constant-folded `args.len`
|
||||
> to the outer pack's arity — every shape's cross-call read returned that one
|
||||
> baked constant (originally observed as 0). Fix: `FnBodyReentry.enter` in
|
||||
> `src/ir/lower.zig` (used by `lowerFunctionBodyInto` / `lazyLowerFunction` in
|
||||
> `src/ir/lower/decl.zig`) now nulls `pack_param_count` (and the sibling
|
||||
> `pack_arg_nodes` / `pack_arg_types`) for the nested body and restores them on
|
||||
> exit. Covered by regression test `examples/0522-packs-pack-bare-args-cross-call.sx`.
|
||||
|
||||
## Symptom
|
||||
|
||||
Bare `$args` evaluated inside a pack-fn body has the correct `.len`
|
||||
inline (e.g. `($args).len == 2` for a two-arg shape). But the
|
||||
moment the same slice is passed as an argument to another
|
||||
function, the callee's read of `.len` returns 0 — regardless of
|
||||
the pack's actual element count. The `data` pointer is presumably
|
||||
similarly broken (haven't probed yet, but every element-access
|
||||
would index off a zero-length view).
|
||||
|
||||
Observed:
|
||||
|
||||
```
|
||||
inline: len=4
|
||||
callee: len=0
|
||||
```
|
||||
|
||||
Expected: callee receives the same `{ptr, len}` slice that the
|
||||
caller materialised; `.len` matches the pack's element count.
|
||||
|
||||
This blocks the **step-5 generic `Into(Block)` impl** (FFI plan):
|
||||
its body `#insert build_block_convert($args, $R);` calls a
|
||||
`build_block_convert(args: []Type, ret: Type) -> string` builder
|
||||
fn. The builder walks `args` to emit the per-shape trampoline +
|
||||
Block literal source. With this bug, the builder receives an
|
||||
empty slice and emits the empty-pack source for every call shape,
|
||||
silently producing wrong block trampolines.
|
||||
|
||||
Baseline regression check (not affected): a hand-built `[]i64`
|
||||
slice round-trips correctly across the same kind of call:
|
||||
|
||||
```sx
|
||||
walk :: (xs: []i64) -> i64 { return xs.len; }
|
||||
main :: () {
|
||||
arr : [3]i64 = .{10, 20, 30};
|
||||
sl : []i64 = arr;
|
||||
print("call: {}\n", walk(sl)); // prints 3 — works
|
||||
}
|
||||
```
|
||||
|
||||
So the bug is specific to slices produced by the pack-bare-`$args`
|
||||
materialisation path (`buildPackSliceValue` /
|
||||
`materialisePackSlice` in `src/ir/lower.zig`).
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
walk :: (args: []Any) -> string {
|
||||
return concat("len=", int_to_string(args.len));
|
||||
}
|
||||
|
||||
probe :: (..$args) -> string {
|
||||
return walk($args);
|
||||
}
|
||||
|
||||
#run print("inline: len={}\n", ($args).len); // not legal — replace
|
||||
// with a body-local form
|
||||
#run print("callee: {}\n", probe()); // expected: len=0
|
||||
#run print("callee: {}\n", probe(1, "x")); // expected: len=2 — fails, reads 0
|
||||
#run print("callee: {}\n", probe(1, "x", true, 3.14));
|
||||
// expected: len=4 — fails, reads 0
|
||||
main :: () {}
|
||||
```
|
||||
|
||||
Cleaner repro that contrasts inline vs callee:
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
walk :: (args: []Any) -> i64 { return args.len; }
|
||||
|
||||
probe :: (..$args) -> string {
|
||||
inline_list := $args;
|
||||
callee_len := walk($args);
|
||||
return concat(concat("inline=", int_to_string(inline_list.len)),
|
||||
concat(" callee=", int_to_string(callee_len)));
|
||||
}
|
||||
|
||||
#run print("{}\n", probe(1, "x", true, 3.14));
|
||||
// observed: inline=4 callee=0
|
||||
// expected: inline=4 callee=4
|
||||
main :: () {}
|
||||
```
|
||||
|
||||
Replace `[]Any` with `[]Type` — same wrong result (callee reads 0).
|
||||
|
||||
Tested on commit `a394372` (master, 2026-05-27 head as of this
|
||||
file). `zig build && bash tests/run_examples.sh` is green; the bug
|
||||
is uncovered, not pre-existing red.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
A pack-fn's bare `$args` lowers (per `current/CHECKPOINT-FFI.md`
|
||||
entry **M5.A.next.4A.bare.1.B**) to:
|
||||
|
||||
> `buildPackSliceValue(arg_types)` — emits `alloca [N x Any]`, one
|
||||
> `const_type(arg_tys[i])` per slot, then a `{data_ptr, len}` slice
|
||||
> aggregate.
|
||||
|
||||
Suspected area:
|
||||
|
||||
- `src/ir/lower.zig` — `buildPackSliceValue` / `materialisePackSlice`.
|
||||
- Whether the slice aggregate it returns is the same shape sx uses
|
||||
for an ordinary slice — `{ ptr: *T, len: i64 }` in field order
|
||||
used by `.len` reads at consumer sites.
|
||||
- Whether the slice survives the function-call ABI: the callee
|
||||
reads the slice fields from its frame's slot for the argument;
|
||||
if `buildPackSliceValue` returns a temporary that's not what the
|
||||
call-site argument-marshal step picks up, the callee sees uninit.
|
||||
|
||||
What to check first:
|
||||
|
||||
1. In the pack-fn (`probe(..$args) -> string { walk($args); }`),
|
||||
dump the IR of `lower_pack_fn_call` for `walk` — confirm
|
||||
`walk`'s arg-0 is a slice value whose `len` field has been
|
||||
populated from `arg_tys.len` (or the runtime-built `len`).
|
||||
2. If the pack-slice goes through `lowerExpr`'s
|
||||
`comptime_pack_ref` arm, verify the resulting Ref points at
|
||||
the slice aggregate produced by `buildPackSliceValue` — not at
|
||||
the underlying `alloca [N x Any]` (which would be the data
|
||||
pointer, not the slice).
|
||||
3. Compare with the `[]i64` round-trip path that works — what's
|
||||
different about how the slice is bound at the call site?
|
||||
|
||||
Verification step after fix:
|
||||
|
||||
Run the cleaner repro above; both lines should print
|
||||
`inline=4 callee=4` (and 0/0 for the empty-pack case, 2/2 for
|
||||
two-arg, etc).
|
||||
|
||||
A regression test goes in
|
||||
`examples/NNN-pack-bare-args-cross-call.sx` once the fix lands.
|
||||
|
||||
## Why this matters
|
||||
|
||||
The whole point of bare `$args` (step 4A) is that builder fns can
|
||||
walk the pack as a runtime slice — step 5 calls
|
||||
`build_block_convert($args, $R)` and the builder emits the
|
||||
trampoline + Block literal source by iterating `args`. With the
|
||||
slice's `.len` silently reading 0, every monomorphisation would
|
||||
emit the empty-pack source, producing wrong trampoline
|
||||
signatures and silently corrupt block dispatch on Apple targets.
|
||||
|
||||
The bug is invisible inline (the same call shape works inside the
|
||||
pack-fn body), so without the cross-call regression it'd ship
|
||||
quietly and burn the next person trying to write a `#insert
|
||||
build_x($args, ...)` style builder.
|
||||
@@ -1,164 +0,0 @@
|
||||
# 0049 — new-form variadic `..name: []Type` defined in an imported module crashes LLVM emit
|
||||
|
||||
> **RESOLVED.** Root cause: the new-form variadic `..name: []T` had its already-sliced declared type double-wrapped to `[][]T` (helpers treated it like legacy `name: ..T` and added a slice level), so the callee's stored param shape mismatched the call-site's `[N x T]` marshalling and emitted null/undef Refs that crashed `LLVMBuildExtractValue` in `emitStrCmp`.
|
||||
> Fix: `resolveParamType` (src/ir/lower.zig:642) now returns `declared_ty` as-is when it is already a slice instead of re-wrapping, and the companion `packVariadicCallArgs` (src/ir/lower/pack.zig:298) unwraps the new-form `[]T` back to element `T` for per-element packing so both surface forms converge.
|
||||
> Covered by regression test `examples/0523-packs-new-form-variadic-cross-module.sx` (pinned, exit 0), which calls the new-form stdlib `path_join` across the import boundary.
|
||||
|
||||
## Symptom
|
||||
|
||||
A pack-fn declared with the **new** variadic syntax
|
||||
`..name: []Type` (the form the FFI plan migrates to, replacing
|
||||
the legacy `name: ..Type`) crashes LLVM IR emission with a
|
||||
null-operand `LLVMBuildExtractValue` inside `emitStrCmp` when:
|
||||
|
||||
1. The pack-fn lives in an **imported** module (e.g. `library/modules/std.sx`).
|
||||
2. The caller is in a **different** module than the definition.
|
||||
|
||||
The same function written with the legacy `name: ..Type` syntax
|
||||
compiles and runs cleanly. The same new-form definition placed
|
||||
**locally** in the caller's module (or shadowing the imported
|
||||
name) also compiles cleanly. The two together — new form + import
|
||||
boundary — are what trip the emit.
|
||||
|
||||
```
|
||||
Segmentation fault at address 0x0
|
||||
???:?:?: 0x... in __ZN4llvm5Value11setNameImplERKNS_5TwineE (.../libLLVM.dylib)
|
||||
???:?:?: 0x... in _LLVMBuildExtractValue (.../libLLVM.dylib)
|
||||
/Users/agra/projects/sx/src/ir/emit_llvm.zig:3570:48: in emitStrCmp (sx)
|
||||
const rhs_ptr = c.LLVMBuildExtractValue(b, rhs, 0, "str.rp");
|
||||
^
|
||||
/Users/agra/projects/sx/src/ir/emit_llvm.zig:1805:45: in emitInst (sx)
|
||||
.str_eq => |bin| self.emitStrCmp(bin, true),
|
||||
```
|
||||
|
||||
So an emitted `.str_eq` op has a `rhs` Ref that resolves to a null
|
||||
LLVM Value. The `.str_eq` is somewhere downstream of the
|
||||
new-form pack-fn's monomorphisation — most likely an
|
||||
`any_to_string` / format-side string comparison that the migrated
|
||||
call path threads back. The `rhs` was either never materialised in
|
||||
the imported-mono's IR, or was registered against a stale
|
||||
function/module slot that `emit_llvm` resolves to null.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Modify `library/modules/std.sx`:
|
||||
|
||||
```sx
|
||||
- path_join :: (parts: ..string) -> string {
|
||||
+ path_join :: (..parts: []string) -> string {
|
||||
```
|
||||
|
||||
Body unchanged. Then:
|
||||
|
||||
```sx
|
||||
// repro.sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () {
|
||||
p := path_join("a", "b");
|
||||
print("{}\n", p);
|
||||
}
|
||||
```
|
||||
|
||||
`zig build && ./zig-out/bin/sx run repro.sx` → segfault as above.
|
||||
|
||||
Negative controls (all compile and run fine):
|
||||
|
||||
```sx
|
||||
// Same body, OLD form — works:
|
||||
path_join :: (parts: ..string) -> string { ... }
|
||||
|
||||
// New form but defined LOCALLY in the caller:
|
||||
path_join :: (..parts: []string) -> string { ... }
|
||||
#import "modules/std.sx"; // imported anyway, just no path_join call
|
||||
main :: () { p := path_join("a", "b"); ... }
|
||||
|
||||
// New form with `$`-prefixed pack name — works EITHER locally or imported:
|
||||
path_join :: (..$parts: []string) -> string { ... }
|
||||
```
|
||||
|
||||
So the bug is specifically:
|
||||
- new-form variadic (`..name: []Type`)
|
||||
- WITHOUT the `$` prefix on `name`
|
||||
- defined in a module that gets imported (not in the caller's own file)
|
||||
|
||||
Suite state when the bug first surfaced: commit `0ede097` (master,
|
||||
2026-05-27, just after the issue-0048 fix landed and the suite
|
||||
was green at 213/213). The only delta on top is the
|
||||
`path_join :: (parts: ..string)` → `path_join :: (..parts: []string)`
|
||||
edit in `library/modules/std.sx`.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
The FFI plan migrates all stdlib variadic decls from the legacy
|
||||
form to the new `..name: []Type` form (`path_join`, `format`,
|
||||
`print`, plus the extern `open` decl, plus the example fixtures).
|
||||
Per the FFI cadence rule the migration is supposed to be a
|
||||
mechanical textual change with identical semantics. This bug
|
||||
blocks that.
|
||||
|
||||
The fault location (`emitStrCmp` line 3570 with null `rhs`) is the
|
||||
crash point, not the root cause. The root cause is one of:
|
||||
|
||||
1. **Cross-module pack-fn monomorphisation** — the new-form path
|
||||
in `monomorphizePackFn` registers the mono'd function in the
|
||||
current module, but if the mono'd body uses a Ref that
|
||||
resolves through a stale module/function context, the LLVM
|
||||
pass through `emit_llvm.functions[fid]` lookup hands back a
|
||||
null. Compare the new-form mono path to the legacy `name:
|
||||
..Type` mono path side-by-side — look for any place the latter
|
||||
threads the caller's module ID / FuncId but the former forgets
|
||||
to.
|
||||
2. **Synthesised slot names** — the new-form pack-fn body uses
|
||||
synthesised `__pack_<name>_<i>` idents for per-position arg
|
||||
substitution (per CHECKPOINT-FFI step-2b). If these are
|
||||
re-emitted in the caller's IR against the imported function's
|
||||
body without re-resolving against the caller's scope, they'd
|
||||
appear as undef in the final LLVM pass.
|
||||
3. **The `$` workaround as a hint** — the bug disappears when the
|
||||
pack name is `..$parts: []string`. Inside the parser /
|
||||
`isPackFn` discriminator, the `$` prefix routes the function
|
||||
through the heterogeneous-pack mono path; the new-form
|
||||
WITHOUT `$` likely routes through a near-but-not-identical
|
||||
path. Diff the two routes — what does the `$` version do that
|
||||
the no-`$` version skips when the call crosses an import?
|
||||
|
||||
Where to start:
|
||||
|
||||
- `src/ir/lower.zig` — `monomorphizePackFn` (around line 8460),
|
||||
`materialisePackSlice` (around line 8261), `buildPackSliceValue`
|
||||
(around line 8225). Trace which gets called for the new-form
|
||||
no-`$` path.
|
||||
- `src/ir/lower.zig:lowerPackFnCall` — call-site mono dispatch.
|
||||
Look for a `$`-prefix branch.
|
||||
- `src/parser.zig:parseParam` (variadic handling) — confirm what
|
||||
AST shape the two forms produce. The new form sets `is_variadic
|
||||
= true` AND `is_comptime = false` (no `$`); the new form WITH
|
||||
`$` sets both true. The mono path probably gates on `is_comptime`.
|
||||
|
||||
Verification step:
|
||||
|
||||
After the fix, run with the path_join edit re-applied:
|
||||
|
||||
```sh
|
||||
git diff library/modules/std.sx # confirm new form lands
|
||||
./zig-out/bin/sx run examples/121-ios-sim-bundle.sx
|
||||
bash tests/run_examples.sh
|
||||
```
|
||||
|
||||
Both should be green, no segfault.
|
||||
|
||||
A regression test goes in
|
||||
`examples/NNN-new-form-variadic-cross-module.sx` once the fix
|
||||
lands. The migration of `path_join`, `format`, `print`, and
|
||||
`open` then proceeds.
|
||||
|
||||
## Why this matters
|
||||
|
||||
The FFI plan calls for the legacy `name: ..Type` form to be
|
||||
dropped entirely (`current/CHECKPOINT-FFI.md` references the
|
||||
`'args: ..Any' is in the plan to change to '..args: []Any'`
|
||||
migration). Every stdlib consumer plus the chess game needs the
|
||||
new form. With this bug, stdlib can't be migrated — moving any
|
||||
stdlib variadic to the new form breaks every program that imports
|
||||
it. The migration is gated on this fix.
|
||||
@@ -1,126 +0,0 @@
|
||||
# 0050 — `monomorphizeFunction` leaks outer pack-fn state into the mono body
|
||||
|
||||
> **RESOLVED.** Root cause: `monomorphizeFunction` saved/nulled `type_bindings`/`scope`/builder state but left the outer pack-fn maps live, so a generic callee with an `args`-named param had its `args.len` constant-folded (via `lowerFieldAccess`'s `<pack>.len` intercept) to the first mono's arity and baked into the cached IR.
|
||||
> Fix: `monomorphizeFunction` in `src/ir/lower/generic.zig` now saves+nulls+defer-restores `pack_arg_nodes` / `pack_param_count` / `pack_arg_types` / `inline_return_target` (lines 51-64), mirroring the `lazyLowerFunction` isolation from issue-0048.
|
||||
> Covered by regression test `examples/0524-packs-generic-fn-pack-state-leak.sx` (probes print 0/1/2/4).
|
||||
|
||||
## Symptom
|
||||
|
||||
A generic function (one with `$T: Type` type params) called from
|
||||
inside a pack-fn's monomorphisation inherits the outer pack maps
|
||||
during its OWN body lowering. The familiar fall-out: every
|
||||
identifier in the generic body whose name happens to match the
|
||||
outer pack's name gets routed through `lowerFieldAccess`'s
|
||||
`<pack_name>.len` intercept (or `pack_arg_types` / `pack_arg_nodes`
|
||||
substitutions) and silently picks up the WRONG value.
|
||||
|
||||
Same root cause as issue-0048, just a different lowering path.
|
||||
0048 fixed `lazyLowerFunction`. The generic-monomorphisation path
|
||||
(`monomorphizeFunction` in `src/ir/lower.zig` around line 8718)
|
||||
has the same omission: it saves and nulls `type_bindings` /
|
||||
`scope` / builder state, but does NOT save/null `pack_arg_nodes`
|
||||
/ `pack_param_count` / `pack_arg_types` / `inline_return_target`.
|
||||
|
||||
When the body of the mono'd function references an identifier
|
||||
named after the outer pack (typical: `args`), the outer
|
||||
`pack_param_count["args"]` entry is still present and the
|
||||
`args.len` lowers to a baked-in constant — the pack arity of
|
||||
whichever shape triggered the FIRST mono. Subsequent shapes call
|
||||
the same cached mono and read the same wrong constant.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
build :: (args: []Type, $ret: Type) -> string {
|
||||
return concat("len=", int_to_string(args.len));
|
||||
}
|
||||
|
||||
probe :: (..$args) -> string {
|
||||
return build($args, void);
|
||||
}
|
||||
|
||||
#run print("0: {}\n", probe());
|
||||
#run print("1: {}\n", probe(true));
|
||||
#run print("2: {}\n", probe(42, "hi"));
|
||||
main :: () {}
|
||||
```
|
||||
|
||||
Observed:
|
||||
|
||||
```
|
||||
0: len=0
|
||||
1: len=0
|
||||
2: len=0
|
||||
```
|
||||
|
||||
Expected: `len=0`, `len=1`, `len=2`. The slice passing across
|
||||
the function-call boundary already works post-0048; what's wrong
|
||||
here is that the callee's `args.len` is being constant-folded at
|
||||
lower time before the runtime slice ever gets a chance to be read.
|
||||
|
||||
Negative control — same shape WITHOUT the `$ret: Type` type-param
|
||||
(so `build` is not generic, gets lowered via `lazyLowerFunction`
|
||||
+ the 0048-protected isolation):
|
||||
|
||||
```sx
|
||||
build :: (args: []Type) -> string { ... } // no $ret
|
||||
probe :: (..$args) -> string { return build($args); }
|
||||
#run print("0: {}\n", probe()); // → 0
|
||||
#run print("1: {}\n", probe(true)); // → 1
|
||||
#run print("2: {}\n", probe(42, "hi")); // → 2
|
||||
```
|
||||
|
||||
This works correctly, which confirms the bug is specifically on
|
||||
the generic-mono path. The IR for the failing case shows
|
||||
`int_to_string(i64 0)` baked into `@build__void` — the args.len
|
||||
fold landed at monomorphisation time, not at the call site.
|
||||
|
||||
Suite green at commit `952dc0e` (master, 2026-05-27); the bug
|
||||
surfaces the moment a builder fn (FFI plan's
|
||||
`build_block_convert(args: []Type, $ret: Type) -> string`) is
|
||||
written using the new bare-`$args` + `$ret: Type` shape that
|
||||
step 5 of the FFI plan calls for.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Apply the same isolation pattern that landed for
|
||||
`lazyLowerFunction` (commit `0ede097`, issue-0048):
|
||||
`monomorphizeFunction` in `src/ir/lower.zig` (~line 8718) needs
|
||||
a save+null+defer-restore block covering:
|
||||
|
||||
- `self.pack_arg_nodes`
|
||||
- `self.pack_param_count`
|
||||
- `self.pack_arg_types`
|
||||
- `self.inline_return_target`
|
||||
|
||||
Place the block alongside the existing
|
||||
`saved_func` / `saved_block` / `saved_scope` / `saved_bindings`
|
||||
saves, before the body lowering begins. Mirror the defer pattern
|
||||
from `lazyLowerFunction` so all early-return paths restore
|
||||
correctly.
|
||||
|
||||
Verification:
|
||||
|
||||
Run the reproduction above; the three probes must print 0 / 1 /
|
||||
2 respectively. Then run `bash tests/run_examples.sh` (must stay
|
||||
214/214) and `zig build test`.
|
||||
|
||||
A regression test goes in
|
||||
`examples/NNN-generic-fn-pack-state-leak.sx` once the fix lands.
|
||||
The shape is exactly the reproduction above.
|
||||
|
||||
## Why this matters
|
||||
|
||||
Step 5 of the FFI plan (`current/CHECKPOINT-FFI.md` — generic
|
||||
`Into(Block) for Closure(..$args) -> $R` impl in stdlib) calls a
|
||||
builder `build_block_convert(args: []Type, $ret: Type) -> string`
|
||||
from inside the impl body. The impl body itself is mono'd per
|
||||
call shape (where the closure's pack types and return type are
|
||||
substituted in), and the builder is called from inside that mono.
|
||||
With this leak, every call shape sees the same wrong source
|
||||
string from the builder — the trampoline emission silently
|
||||
produces zero-arity blocks regardless of the actual closure.
|
||||
|
||||
Without the fix, step 5 can't proceed.
|
||||
@@ -1,137 +0,0 @@
|
||||
**FIXED.** Two parts, both needed for Finder/`open` to work:
|
||||
|
||||
> **RESOLVED.** A bundled macOS `.app` launched via Finder/`open` starts with
|
||||
> CWD=`/`, so CWD-relative asset loads (`read_file_bytes("assets/...")`) missed
|
||||
> and the app crashed loading fonts/textures. Fixed in `library/modules/platform/sdl3.sx`:
|
||||
> `SdlPlatform.init` now calls `sdl_chdir_to_bundle()`, which `chdir`s to
|
||||
> `SDL_GetBasePath()` only when that path lives inside a `.app` (gated on `OS == .macos`),
|
||||
> mirroring uikit.sx's iOS precedent; the `sx run` dev flow and wasm are left untouched.
|
||||
> This is a platform-library fix (no `src/**` compiler change); no standalone regression
|
||||
> test exists since it requires a bundled GUI `.app` launched via Finder.
|
||||
|
||||
1. **cwd-relative assets** — `SdlPlatform.init` now chdir's to
|
||||
`SDL_GetBasePath()` when running from inside a `.app` (sx commit
|
||||
`b31fbae`, `library/modules/platform/sdl3.sx`), mirroring uikit.sx's iOS
|
||||
`chdir_to_bundle`. Gated to the `.app` case so the `sx run` dev flow keeps
|
||||
the project CWD.
|
||||
2. **Gatekeeper-rejected signature** — the bundle was signed with a
|
||||
*Development* cert, which `spctl` rejects, so a standalone `open` wouldn't
|
||||
launch it. Switched the macOS build to **ad-hoc** signing (the bundler's
|
||||
macOS/sim default — just leave the codesign identity empty; game commit
|
||||
`d80a350`, `build.sx`).
|
||||
|
||||
Verified: a plain `cd game && sx build main.sx` then `open
|
||||
sx-out/macos/SxChess.app` launches and renders (was: instant `stbtt` segfault
|
||||
on missing font, or no launch at all). It was NOT App Translocation — the
|
||||
bundle had no `com.apple.quarantine` xattr.
|
||||
|
||||
# Symptom
|
||||
|
||||
A bundled macOS `.app` built with `sx build` crashes on launch when started
|
||||
via Finder double-click or `open Foo.app`, but runs fine when launched from a
|
||||
shell whose CWD is the bundle directory.
|
||||
|
||||
Observed: `open sx-out/macos/SxChess.app` → process exits within ~1s (segfaults
|
||||
inside `stbtt_ScaleForPixelHeight` because the font buffer is null — the asset
|
||||
wasn't found).
|
||||
Expected: double-click / `open` launches the app and it finds its bundled
|
||||
assets, same as on iOS.
|
||||
|
||||
Root: assets are loaded with **CWD-relative** paths (e.g.
|
||||
`"assets/fonts/default.ttf"`), but Finder/`open` start a GUI app with `CWD=/`,
|
||||
so the relative path resolves against `/` and the file is missing.
|
||||
|
||||
# Reproduction
|
||||
|
||||
Any consumer that bundles an `assets/` dir and loads from it by relative path.
|
||||
Minimal shape (real case: `/Users/agra/projects/game`):
|
||||
|
||||
```sx
|
||||
// main.sx — loads assets by CWD-relative path
|
||||
g_pipeline.init_font("assets/fonts/default.ttf", 32.0, dpi); // -> read_file_bytes
|
||||
g_chess_game.pieces.load("assets/chess/pieces.png", gpu); // -> read_file_bytes
|
||||
```
|
||||
|
||||
```sh
|
||||
cd game && sx build main.sx # produces sx-out/macos/SxChess.app (assets copied in)
|
||||
|
||||
# Works (CWD = bundle dir, so "assets/..." resolves):
|
||||
cd sx-out/macos/SxChess.app && ./SxChess
|
||||
|
||||
# Fails (CWD = /, asset not found -> null buffer -> stbtt segfault):
|
||||
open sx-out/macos/SxChess.app
|
||||
# or double-click in Finder
|
||||
```
|
||||
|
||||
`add_asset_dir("assets", "assets")` in `build.sx` correctly copies the tree
|
||||
into the flat `.app` (binary at `SxChess.app/SxChess`, assets at
|
||||
`SxChess.app/assets/...`), so the files ARE present in the bundle — they're just
|
||||
not found because the lookup is CWD-relative and CWD isn't the bundle.
|
||||
|
||||
# Root cause
|
||||
|
||||
The macOS SDL startup never reorients CWD (or the asset root) to the bundle.
|
||||
`SdlPlatform.init` ([library/modules/platform/sdl3.sx:35](../library/modules/platform/sdl3.sx#L35))
|
||||
calls `SDL_Init(SDL_INIT_VIDEO)` and creates the window but does no `chdir`,
|
||||
so `read_file_bytes`
|
||||
([library/modules/ui/glyph_cache.sx:202](../library/modules/ui/glyph_cache.sx#L202),
|
||||
and the chess `extern read_file_bytes`) opens paths relative to whatever CWD
|
||||
the launcher set — `/` under Finder/`open`.
|
||||
|
||||
The other platforms already handle this:
|
||||
- **iOS**: [library/modules/platform/uikit.sx:346](../library/modules/platform/uikit.sx#L346)
|
||||
explicitly `chdir`s to the bundle's `resourcePath()` at startup, with the
|
||||
comment "iOS apps start with CWD=/. chdir to the bundle's resourcePath so the
|
||||
[assets resolve]".
|
||||
- **Android**: [library/modules/platform/android.sx:71](../library/modules/platform/android.sx#L71)
|
||||
routes `read_file_bytes` through `AAssetManager` so paths resolve against the
|
||||
APK assets regardless of CWD.
|
||||
|
||||
macOS has neither — so it only works by accident when launched from a shell
|
||||
sitting in the bundle dir.
|
||||
|
||||
# Investigation prompt
|
||||
|
||||
For a fresh session picking this up:
|
||||
|
||||
The fix mirrors the iOS precedent. In `SdlPlatform.init`
|
||||
([sdl3.sx:35](../library/modules/platform/sdl3.sx#L35)), before any asset is
|
||||
loaded, reorient to the bundle's resource directory **on macOS only** (leave
|
||||
wasm/emscripten and the dev `sx run` path alone — those legitimately want the
|
||||
project CWD).
|
||||
|
||||
Recommended approach — `SDL_GetBasePath()`:
|
||||
- SDL3 `SDL_GetBasePath()` returns the directory containing the executable
|
||||
(for a `.app`, that's `SxChess.app/` where the assets were copied). `chdir`
|
||||
to it at the top of `init` when `BuildOptions.is_macos` (gate so `sx run`
|
||||
during development isn't affected — or gate on "the base path differs from
|
||||
CWD and contains an `assets/` dir").
|
||||
- Add the `extern` decl for `SDL_GetBasePath` (returns `*u8`, SDL-owned) and
|
||||
call `chdir` (already used by uikit.sx — reuse the same `extern`).
|
||||
|
||||
Alternative (no SDL dependency): `_NSGetExecutablePath` + `dirname`, same as a
|
||||
plain macOS resolve. SDL_GetBasePath is simpler and already links SDL3.
|
||||
|
||||
Things to verify / watch:
|
||||
- Don't chdir for the `sx run <file>` (JIT) dev flow or for wasm — only the
|
||||
bundled AOT macOS app. The cleanest gate is the bundle context; if `init`
|
||||
can't see `BuildOptions`, gate on `SDL_GetBasePath()` returning a path that
|
||||
ends in `.app/` (bundled) vs the build dir (dev).
|
||||
- After chdir, the existing `"assets/..."` relative loads resolve unchanged —
|
||||
no call-site changes needed in consumers (chess, glyph_cache).
|
||||
- Confirm the iOS path (uikit, doesn't use SdlPlatform) is untouched.
|
||||
|
||||
# Verification
|
||||
|
||||
```sh
|
||||
cd game && sx build main.sx
|
||||
open sx-out/macos/SxChess.app # must launch and stay up (board + pieces render)
|
||||
# screenshot / pgrep -lf sx-out/macos/SxChess -> alive after a few seconds
|
||||
```
|
||||
|
||||
Before the fix: `open` → exits ~1s (stbtt segfault, null font buffer).
|
||||
After: `open` and Finder double-click both launch and render, matching the
|
||||
`cd bundle && ./SxChess` behavior.
|
||||
|
||||
Also re-run host + cross suites to confirm no platform-module regression:
|
||||
`zig build && zig build test && bash tests/run_examples.sh`.
|
||||
@@ -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.
|
||||
@@ -1,80 +0,0 @@
|
||||
**FIXED** via the `xx <pack>` bridge (the preferred fix below), not by changing
|
||||
|
||||
> **RESOLVED.** Spreading a comptime pack into a `[]Any`/`[]P` parameter via `xx args` previously
|
||||
> hit the pack-as-value diagnostic ("pack has no runtime value") before `xx` was considered, so no
|
||||
> slice was ever materialized. Fixed by intercepting `xx <pack>` with a slice target in the
|
||||
> `unary_op .xx` arm of `lowerExpr` (`src/ir/lower/expr.zig`) *before* lowering the operand, calling
|
||||
> `lowerPackToSlice` (`src/ir/lower/pack.zig:167`) to box/erase each element into a runtime `[N]elem` → `[]elem`.
|
||||
> Covered by regression test `examples/0537-packs-pack-xx-to-slice.sx`.
|
||||
the `..args` spread. `xx args` with a slice target now materializes the pack
|
||||
into a runtime `[]Any`/`[]P` — see [examples/204-pack-xx-to-slice.sx](../examples/204-pack-xx-to-slice.sx).
|
||||
`lowerXX`/the unary-op arm intercepts `xx <pack>` before the pack-as-value
|
||||
check and calls the new `lowerPackToSlice` ([src/ir/lower.zig](../src/ir/lower.zig)).
|
||||
The bare `..args` spread into a non-variadic `[]Any` param is still unsupported
|
||||
(use `xx args`); left as-is.
|
||||
|
||||
# Symptom
|
||||
|
||||
Spreading a comptime pack `..$args` into a `[]Any` parameter — `f(..args)` where
|
||||
`f` takes `items: []Any` — fails LLVM verification:
|
||||
|
||||
```
|
||||
LLVM verification failed: Incorrect number of arguments passed to called function!
|
||||
%call = call i64 @log_count(ptr %0, { ptr, i64 }, { ptr, i64 }, double ...)
|
||||
```
|
||||
|
||||
The spread passes the pack's N elements as N separate positional args instead of
|
||||
materialising a single `[]Any` slice for the one `items` parameter.
|
||||
|
||||
# Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
log_count :: (items: []Any) -> i64 { return items.len; }
|
||||
forward :: (..$args) -> i64 { return log_count(..args); }
|
||||
main :: () -> i32 { print("{}\n", forward(1, "hi", 2.5)); return 0; }
|
||||
```
|
||||
|
||||
Expected: `3` (the pack spreads into the `[]Any` slice, like calling
|
||||
`log_count(1, "hi", 2.5)` against a `[]Any` variadic would).
|
||||
|
||||
# Preferred fix — `xx args` (pack → slice materialization)
|
||||
|
||||
Rather than make the splat-y `..args` spread collapse into a single slice arg,
|
||||
the cleaner spelling is an **`xx` cast**, which already means "erase/convert to
|
||||
the expected type":
|
||||
|
||||
```sx
|
||||
forward :: (..$args) -> i64 { return log_count(xx args); } // target: []Any
|
||||
```
|
||||
|
||||
`xx args` (target-typed) should materialize the pack into the expected slice:
|
||||
- target `[]Any` → box each pack element to `Any`, build `[N]Any` → `[]Any`;
|
||||
- target `[]P` → `xx`-erase each element to the protocol `P`, build `[N]P`
|
||||
→ `[]P` (reuse the slice-of-protocol erasure landed in `packVariadicCallArgs`,
|
||||
issue 0052).
|
||||
|
||||
This reuses the existing `xx`/protocol machinery, reads naturally, and keeps
|
||||
`..xs` reserved for true spreads into pack/variadic callees.
|
||||
|
||||
**Currently `xx args` errors** ("pack 'args' has no runtime value") because the
|
||||
Step 2.7 pack-as-value check fires on the bare `args` operand before `xx` is
|
||||
considered. The fix: in `xx` (unary_op `.xx`) lowering, intercept a pack operand
|
||||
*before* the pack-as-value diagnostic and, when the target type is a slice,
|
||||
materialize as above.
|
||||
|
||||
# Workaround today
|
||||
|
||||
Declare the forwarder as the **slice** variadic instead of a pack — then it's
|
||||
already a runtime `[]Any` and forwards directly:
|
||||
|
||||
```sx
|
||||
forward :: (..args: []Any) -> i64 { return log_count(args); } // works -> 3
|
||||
```
|
||||
|
||||
This is what `examples/162-pack-bare-args.sx` demonstrates.
|
||||
|
||||
# Verification
|
||||
|
||||
After the fix, `log_count(xx args)` (and the original `..args` form, if also
|
||||
fixed) should print `3` and pass `sx ir` LLVM verification.
|
||||
@@ -1,95 +0,0 @@
|
||||
**FIXED** (`1f6e27d`, `examples/212`). Two root causes:
|
||||
|
||||
> **RESOLVED.** A generic-struct instance erased to a parameterized protocol
|
||||
> (`xx c` : `Combined__i64_i64` → `VL(i64)`) trapped at dispatch because the
|
||||
> erasure thunk's vtable slot was never bound to the monomorphized instance
|
||||
> method. Fixed by `instantiateGenericStruct` binding the template name to the
|
||||
> concrete instance + recording `struct_instance_bindings` (src/ir/lower/generic.zig:1746,1753),
|
||||
> and `createProtocolThunk` monomorphizing the generic-struct instance method
|
||||
> for those bindings (src/ir/lower/protocol.zig:createProtocolThunk, ~line 349).
|
||||
> Covered by `examples/0414-protocols-generic-struct-protocol-erase.sx`.
|
||||
1. `instantiateGenericStruct` now binds the template name to the concrete
|
||||
instance (`tb.put(tmpl.name, id)`), so an impl method `self: *Combined`
|
||||
resolves `self.field` to the instance, not the 0-field generic stub. (This
|
||||
was a general pre-existing bug — `self.x` failed on *any* generic-struct
|
||||
impl method.)
|
||||
2. `createProtocolThunk` monomorphizes the template method for a generic-struct
|
||||
instance (`Combined.get` → `Combined__i64_i64.get` with the instance
|
||||
bindings), so the erasure vtable dispatches instead of an `unreachable` thunk.
|
||||
|
||||
`xx c` (Combined → VL($R)) now dispatches correctly. The *full* canonical `map`
|
||||
additionally needs mapper closure-pack typing + `$R` inference at the call site
|
||||
(a separate piece) — tracked separately.
|
||||
|
||||
# Symptom
|
||||
|
||||
`xx c` where `c` is a generic-struct instance and the target is a parameterized
|
||||
protocol — via a generic `impl P($R) for Combined($R, ..$Ts)` — **compiles
|
||||
cleanly but traps at runtime** (exit 133) when a method is then called on the
|
||||
erased value. The protocol value is built with a wrong/empty vtable, so the
|
||||
dispatch jumps to a bad fn-ptr.
|
||||
|
||||
This is the last piece of the canonical `map` (`return xx c;`).
|
||||
|
||||
# Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
VL :: protocol(T: Type) { get :: () -> T; }
|
||||
IntCell :: struct { v: i64; }
|
||||
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
|
||||
Combined :: struct($R: Type, ..$Ts: []Type) { sources: (..VL(Ts)); value: $R; }
|
||||
impl VL($R) for Combined($R, ..$Ts) { get :: (self: *Combined) -> $R => self.value; }
|
||||
|
||||
make :: (..sources: VL) -> VL(i64) {
|
||||
c : Combined(i64, ..sources.T) = ---;
|
||||
c.value = 99;
|
||||
c.sources = (..sources);
|
||||
return xx c; // Combined__i64_i64 -> VL(i64)
|
||||
}
|
||||
main :: () -> i32 {
|
||||
r := make(IntCell.{ v = 1 });
|
||||
print("{}\n", r.get()); // expect 99; instead traps
|
||||
0;
|
||||
}
|
||||
```
|
||||
|
||||
`sx ir` produces clean, verifier-passing IR (no "no visible xx conversion"
|
||||
diagnostic — so an impl *was* matched), but the JIT traps on `r.get()`.
|
||||
|
||||
# Root cause (suspected)
|
||||
|
||||
`param_impl_map` is keyed by **concrete** `(protocol, target_args_mangled,
|
||||
source_mangled)`. The impl `impl VL($R) for Combined($R, ..$Ts)` is generic on
|
||||
both sides — its source mangles to a generic `Combined` (with `$R`/`$Ts`), not
|
||||
the concrete `Combined__i64_i64`. Erasing `Combined__i64_i64 → VL(i64)` looks up
|
||||
`(VL, i64, Combined__i64_i64)`, which doesn't key-match the generic impl; some
|
||||
looser path still produces a protocol value, but its vtable slot for `get`
|
||||
isn't bound to the monomorphized `Combined__i64_i64.get` (which returns
|
||||
`self.value` as `$R`=i64). Calling through it traps.
|
||||
|
||||
The fix needs generic-impl matching + per-instance monomorphization for protocol
|
||||
erasure: when erasing a concrete generic-struct instance to a parameterized
|
||||
protocol, find the generic impl whose source template matches the instance's
|
||||
template (binding `$R`/`$Ts` from the instance's recorded bindings —
|
||||
`struct_instance_bindings`), monomorphize the impl methods for those bindings,
|
||||
and fill the vtable with the resulting fn-ptrs. Compare:
|
||||
- `buildProtocolValue` / `buildProtocolErasure` ([src/ir/lower.zig](../src/ir/lower.zig))
|
||||
— the vtable construction + impl-method lookup.
|
||||
- `param_impl_map` keying (`Proto\x00<args>\x00<src_mangled>`) and how a generic
|
||||
source template is (or isn't) matched against a concrete instance.
|
||||
- `instantiateParamProtocol` (the dst side already works) and
|
||||
`instantiateGenericStruct`'s `struct_instance_bindings` (the source bindings).
|
||||
|
||||
# Verification
|
||||
|
||||
The reproduction should print `99`. Plain (non-generic) struct → parameterized
|
||||
protocol erasure already works (`examples/206`: `xx IntCell -> VL(i64)`); the gap
|
||||
is specifically a *generic-struct* source matched via a *generic* impl.
|
||||
|
||||
# Status
|
||||
|
||||
Everything else in the canonical `map` works: `Combined($R, ..sources.T)`
|
||||
instantiation (`examples/209`), `c.sources = (..sources)` materialization with
|
||||
per-element erasure (`examples/210`), and `mapper(..sources.value)` projection +
|
||||
spread (`examples/211`). This erasure is the final blocker.
|
||||
@@ -1,126 +0,0 @@
|
||||
# 0055 — binary arithmetic accepts mismatched operand types (`i64 + string`)
|
||||
|
||||
> **RESOLVED.** Scalar binary ops derived the result type from the LHS and never
|
||||
> checked the RHS, so `i64 + string` lowered as `add : i64` and reinterpreted the
|
||||
> string's bytes (garbage); ordering/bitwise had the same hole. Fixed in
|
||||
> `lowerBinaryOp` (src/ir/lower/expr.zig:2520), which now gates arithmetic /
|
||||
> ordering / bitwise-shift groups through `isArithOperand` / `isOrderingOperand` /
|
||||
> `isBitwiseOperand` (src/ir/lower.zig:1408-1437) — `.unresolved` passes through,
|
||||
> a concretely incompatible operand emits `cannot apply '<op>' …` and returns a
|
||||
> placeholder sentinel. Covered by `examples/1106-diagnostics-binop-operand-type-check.sx`.
|
||||
|
||||
**FIXED** (`examples/214-binop-operand-type-check.sx`). `lowerBinaryOp` in
|
||||
[src/ir/lower.zig](../src/ir/lower.zig) now checks operand-type
|
||||
compatibility for every scalar binary-op group before emitting, via three
|
||||
predicates that pass `.unresolved` through (so a type we couldn't infer is
|
||||
never falsely diagnosed) but reject a concretely incompatible operand:
|
||||
|
||||
- **arithmetic** `+ - * / %` → `isArithOperand` (numeric / vector /
|
||||
pointer). Without it `i64 + string` lowered as `add : i64` and
|
||||
reinterpreted the string's bytes — garbage.
|
||||
- **ordering** `< <= > >=` → `isOrderingOperand` (numeric / enum / pointer
|
||||
/ bool / vector). Without it `i64 < string` fed mismatched LLVM types to
|
||||
`icmp` and tripped the verifier.
|
||||
- **bitwise / shift** `& | ^ << >>` → `isBitwiseOperand` (integer / enum /
|
||||
bool / vector). Without it `i64 & string` reinterpreted the bytes.
|
||||
|
||||
On mismatch it emits `cannot apply '<op>' to operands of type '<lhs>' and
|
||||
'<rhs>'` and returns a placeholder sentinel instead of the corrupting op.
|
||||
The existing optional-unwrap and int×float promotion are applied before the
|
||||
check. Legitimate mixes — flags-enum bitwise (`.read | .write`,
|
||||
`perm & .read`), enum/pointer comparison, int literals — are unaffected
|
||||
(covered by `examples/50-smoke.sx`). Regression test: `examples/214`
|
||||
(rejects `+ * < & <<` against a `string` operand).
|
||||
|
||||
Still NOT covered (left deliberately): equality `==` / `!=`, whose path is
|
||||
heavily special-cased (string `str_eq`, `Any` unbox, `optional == null`).
|
||||
`i64 == string` still slips through. Folding a compatibility check into
|
||||
that path without regressing the special cases is a separate change — open
|
||||
a fresh issue if it bites.
|
||||
|
||||
## Symptom
|
||||
|
||||
Binary arithmetic operators (`+`, `-`, `*`, `/`, `%`) perform **no
|
||||
operand-type compatibility check**. `i64 + string` compiles cleanly and
|
||||
runs, reinterpreting the `string` operand's bytes (pointer/len) as an
|
||||
integer.
|
||||
|
||||
- **Observed:** `a + c` where `a: i64`, `c: string` compiles and prints a
|
||||
garbage number (e.g. `4346102832` — `40 + <string data pointer>`).
|
||||
- **Expected:** a type-error diagnostic, e.g.
|
||||
`cannot apply '+' to operands of type 'i64' and 'string'`.
|
||||
|
||||
Surfaced in `examples/213-canonical-map.sx`: a third source `v3: StrCell`
|
||||
(`VL(string)`) was added so the mapper became `(a, b, c) => a + b + c`
|
||||
with `a, b: i64` and `c: string`. The canonical `map` infers the closure
|
||||
params from the projected pack element types, so `a + b + c` is
|
||||
`i64 + i64 + string` — which should reject. Instead `r.get()` prints
|
||||
garbage (`4312977714`).
|
||||
|
||||
> Note: the working-tree copy of `examples/213` also contains a *separate*
|
||||
> typo — `Closure(..sources.Target)` instead of `..sources.T`. `.Target`
|
||||
> is an unknown projection name and produces "cannot infer type of lambda
|
||||
> parameter" errors that mask this bug. Restoring `.T` exposes the real
|
||||
> hole. The two are independent; this issue is about the missing arithmetic
|
||||
> operand-type check, reproducible standalone with no pack machinery.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Minimal, standalone (only `modules/std.sx`):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () -> i32 {
|
||||
a : i64 = 40;
|
||||
c : string = "it should error";
|
||||
r := a + c; // expected: type error (i64 + string)
|
||||
print("{}\n", r); // actual: prints garbage, e.g. 4346102832
|
||||
0;
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
$ ./zig-out/bin/sx run repro.sx
|
||||
4346102832 # should be a compile error, not a number
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> Binary arithmetic in the sx compiler does no operand-type compatibility
|
||||
> check. `lowerBinaryOp` in [src/ir/lower.zig](src/ir/lower.zig) (starts
|
||||
> ~L2545) computes the result type `ty` purely from the **LHS** operand
|
||||
> (`var ty = lhs_ty;` ~L2680), then at the final `switch (bop.op)` (~L2735)
|
||||
> emits `.add => self.builder.add(lhs, rhs, ty)` (and `.sub/.mul/.div/.mod`)
|
||||
> with that LHS-derived `ty` — never verifying `rhs_ty` is compatible. For
|
||||
> `i64 + string`, `ty = .i64` and the `string` rhs Ref is fed to an
|
||||
> `add : i64`, reinterpreting its bytes as an integer.
|
||||
>
|
||||
> The fix: before the arithmetic `switch` arms, for the arithmetic ops
|
||||
> (`add/sub/mul/div/mod`) check that `lhs_ty` and `rhs_ty` are
|
||||
> arithmetic-compatible (both numeric — int/float — modulo the existing
|
||||
> int×float promotion at ~L2682, or otherwise an exact match). When they
|
||||
> are not, emit a diagnostic via the existing
|
||||
> `if (self.diagnostics) |diags| diags.addFmt(.err, <span>, "cannot apply
|
||||
> '{s}' to operands of type '{...}' and '{...}'", .{...})` pattern (see the
|
||||
> diagnostics calls already in this file, e.g. ~L2175, ~L2193) and return a
|
||||
> non-corrupting sentinel rather than a silently-wrong `add`.
|
||||
>
|
||||
> Watch the legitimate non-numeric `+` paths so the check doesn't
|
||||
> false-positive: tuple ops (`lowerTupleOp`, handled earlier ~L2718),
|
||||
> string `==`/`!=` (handled ~L2710), optional auto-unwrap (~L2693), and the
|
||||
> int×float promotion (~L2682). Decide whether string concatenation via `+`
|
||||
> is intended to be supported at all — if not, `string + string` should
|
||||
> also reject (currently untested here).
|
||||
>
|
||||
> Span: confirm how to get the operator/expression span for the diagnostic
|
||||
> — `lowerBinaryOp` takes `bop: *const ast.BinaryOp`; check whether
|
||||
> `ast.BinaryOp` carries a span or whether the enclosing node's span must
|
||||
> be threaded in (the other `addFmt` sites use `node.span`).
|
||||
>
|
||||
> Verify: re-run the repro above — expect a type-error diagnostic and a
|
||||
> non-zero exit instead of a printed integer. Then restore `examples/213`'s
|
||||
> `.Target` → `.T` and confirm `(a, b, c) => a + b + c` with a `string`
|
||||
> third source now errors at the `+` rather than printing garbage. Run
|
||||
> `bash tests/run_examples.sh` to confirm no legitimate arithmetic
|
||||
> regressed.
|
||||
@@ -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`).
|
||||
@@ -1,108 +0,0 @@
|
||||
# 0057 — `xx`-to-Any variadic arg inside an imported-module function segfaults
|
||||
|
||||
## ✅ RESOLVED
|
||||
|
||||
Root cause: when lowering a variadic-pack call (`lowerPackCall` in
|
||||
`src/ir/lower.zig`), the pack args were lowered with whatever `self.target_type`
|
||||
happened to be set to from the surrounding context. For a bare arg this is
|
||||
harmless (`inferExprType` ignores `target_type`), but `xx <expr>`'s result type
|
||||
IS `target_type` — so `format("…", xx i)` inside a `-> string` function cast the
|
||||
int to `string`, monomorphized `__pack_string`, and ABI-coerced the 4-byte int
|
||||
as a 16-byte string fat pointer → memory corruption. (Inline it happened to work
|
||||
because `target_type` was null there; the imported-module path left it set.)
|
||||
|
||||
Fix: clear `self.target_type` (save/restore) around the pack-arg lowering loop —
|
||||
a pack arg is independently typed (comptime `..$args` auto-boxes to `Any`; a
|
||||
value pack takes its element/protocol type), never coerced to a leftover outer
|
||||
target. Regression: `examples/242-xx-any-pack-cross-module.sx` (+ companion
|
||||
`242-xx-any-pack-cross-module/fmt.sx`). Gates: zig build, zig build test, 279
|
||||
examples pass. The original symptom/repro/investigation notes are kept below.
|
||||
|
||||
## Symptom
|
||||
|
||||
A `format(...)` / `print(...)` call whose variadic arg is an **explicit `xx`
|
||||
cast to `Any`** segfaults at runtime (`__platform_memmove`, an Any-box/string
|
||||
copy corruption) **when the call is inside a function defined in an imported
|
||||
module**. The identical code works (a) inline in the main file, and (b) in an
|
||||
imported module if the arg is passed *without* `xx` (auto-boxed).
|
||||
|
||||
- **Observed:** `Segmentation fault at address 0x1...` in `__platform_memmove`,
|
||||
via `runJITFromObject` (target.zig:244). Crashes for any int width (i32, u64).
|
||||
- **Expected:** prints the formatted string, same as the auto-boxed / inline
|
||||
forms.
|
||||
|
||||
## Reproduction
|
||||
|
||||
`library/modules/zz_repro.sx`:
|
||||
|
||||
```sx
|
||||
#import "std.sx";
|
||||
|
||||
build :: (n: i32) -> string {
|
||||
result := "x:\n";
|
||||
i : i32 = 0;
|
||||
while i < n {
|
||||
line := format(" item {}\n", xx i); // <-- xx cast to Any is the trigger
|
||||
result = concat(result, line);
|
||||
i = i + 1;
|
||||
}
|
||||
result;
|
||||
}
|
||||
```
|
||||
|
||||
Driver (e.g. `.sx-tmp/repro.sx`):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
m :: #import "modules/zz_repro.sx";
|
||||
main :: () -> i32 { print("[{}]", m.build(2)); return 0; }
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run .sx-tmp/repro.sx` → segfault.
|
||||
|
||||
### Isolation (what is / isn't the trigger)
|
||||
|
||||
- **Auto-box works:** change `xx i` → `i` in the module → prints fine.
|
||||
- **Inline works:** put the same `build` body (with `xx i`) directly in the
|
||||
driver's `main` (no import) → prints fine.
|
||||
- **Width-independent:** `xx` on an `i32` or a `u64` both crash.
|
||||
- So the trigger is specifically: **explicit `xx <int>` → Any as a variadic
|
||||
`format`/`print` arg, in a function that lives in an imported module.**
|
||||
|
||||
The crash blocked ERR step E3.3 (`library/modules/trace.sx`), whose
|
||||
`trace.to_string()` formatted each frame with `format("... {}\n", i, xx frame)`
|
||||
where `frame : u64` — exactly this pattern. (Dropping the `xx` would dodge it,
|
||||
but per the project STOP rule that workaround is not taken; the trace formatter
|
||||
waits on this fix.)
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
The bug is in how an explicit `xx`-to-`Any` cast lowers for a variadic argument
|
||||
when the enclosing function is monomorphized/emitted as part of an **imported
|
||||
module** (vs the root module). Auto-boxing (the implicit `T → Any` coercion at
|
||||
the variadic call site) produces correct code; the explicit `xx` path does not,
|
||||
but only across the module boundary — strongly suggesting the `xx`→`Any`
|
||||
box (`box_any` / `boxAny` in `src/ir/lower.zig` + `src/ir/emit_llvm.zig`'s
|
||||
`.box_any` arm) emits a value/width that the variadic-pack marshalling
|
||||
(`any_to_string` / the `#insert build_format` arg materialization in
|
||||
`library/modules/std.sx`) then `memmove`s incorrectly — possibly a stale
|
||||
`source_type`, a pointer-vs-value confusion, or the imported-module emit losing
|
||||
the box's type so the fat-pointer/string copy reads garbage.
|
||||
|
||||
Suspected area:
|
||||
- `src/ir/lower.zig`: the `unary_op .xx` → `coerceToType(..., .any)` /
|
||||
`boxAny` path, and how variadic args are collected for `format`/`print`
|
||||
(`build_format` / the `..$args` pack). Compare the IR for the inline vs
|
||||
imported-module versions (`sx ir` on each) — the diff at the `xx i` arg site
|
||||
is the lead.
|
||||
- `src/ir/emit_llvm.zig`: `.box_any` (≈ line 3258) — `coerceToI64` /
|
||||
`anyTag(source_type)`. Check whether the imported-module path supplies a
|
||||
wrong `source_type` (e.g. `.unresolved` / `.void`) so the tag/width is off.
|
||||
|
||||
Verification: run the reproduction above; expect `[x:\n item 0\n item 1\n]`
|
||||
(no segfault). Then re-confirm the auto-box and inline forms still work, and
|
||||
that `xx` on a non-Any target (e.g. `xx ptr` to integer) is unaffected.
|
||||
|
||||
Once fixed, ERR E3.3 resumes: restore `library/modules/trace.sx` (the
|
||||
`trace.to_string()` / `print_current()` formatter) using the `xx frame` form,
|
||||
and complete the E3.3 step (example + snapshot + commit).
|
||||
@@ -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 <objs> -o out -lc -lobjc -framework
|
||||
Foundation -framework Metal -L/opt/homebrew/lib -lSDL3`. **No** `-S` /
|
||||
`-dead_strip` / `strip` / `-g` / explicit linker override.
|
||||
- NOT caused by: the framework/lib set (linking a small DWARF object with the
|
||||
exact same frameworks keeps `OSO=1`); ad-hoc **codesign** (signing a small
|
||||
binary keeps `OSO=1`; the binary is `linker-signed` by ld anyway); the
|
||||
**bundler** (the *pre-bundle* `sx-out/macos/SxChess` already has 0 OSO, and
|
||||
`bundle.sx` runs no `strip`); **opt level** (DWARF is present in the .o);
|
||||
`#import "modules/std.sx"` (a tiny std-importing program keeps `OSO=1`);
|
||||
missing **global `_main`** (chess *does* have `T _main`).
|
||||
- The remaining correlate: chess's `main.o` is **large** (~1.1 MB, many
|
||||
monomorphized functions merged from many imported modules). Every small
|
||||
repro tried keeps `OSO=1`; only the large multi-module build drops it. A
|
||||
minimal repro has NOT been isolated yet.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Not yet minimal (this is part of the fix). Reproduces reliably on the chess
|
||||
app at `~/projects/game`:
|
||||
|
||||
```sh
|
||||
cd ~/projects/game
|
||||
/path/to/sx build --target macos --emit-obj main.sx
|
||||
dsymutil -dump-debug-map sx-out/macos/SxChess # → `---` (empty); BUG
|
||||
llvm-dwarfdump --debug-line .sx-tmp/main.o | grep main.sx # DWARF IS present
|
||||
```
|
||||
|
||||
Small programs (single file, with or without `#import "modules/std.sx"`, with
|
||||
or without the chess framework set) all produce `OSO: 1` and step fine in lldb.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
The Mach-O **debug map** (`N_OSO`/`N_FUN` stabs) is synthesized by Apple's
|
||||
`ld` at link time from each object's DWARF + symbol table; sx does not emit it
|
||||
directly. ld is silently emitting **none** for the large chess `main.o`.
|
||||
|
||||
Suspected areas:
|
||||
1. **Object emission** — `src/ir/emit_llvm.zig` `emitObject` /
|
||||
`LLVMTargetMachineEmitToFile`. Diff the working small `main.o` vs the chess
|
||||
`main.o`: do both have the same Mach-O symbol-table shape (N_FUN/global
|
||||
anchors), `__DWARF` section set, and `__debug_aranges`/`__debug_line`
|
||||
layout? A large single compile unit, a DWARF feature ld dislikes
|
||||
(e.g. `.debug_names`, `DW_FORM` ld can't follow), or a section-size/symbol
|
||||
threshold could make ld skip the debug map for that object.
|
||||
2. **Minimize**: grow a single-file program (more functions / import more
|
||||
modules / pull in `platform/bundle.sx`) until `dsymutil -dump-debug-map`
|
||||
flips from 1 → 0. That pins the trigger (size? a specific module/construct?).
|
||||
3. Check whether ld prints a (suppressed) warning — run the link with
|
||||
`SX_DEBUG_LINK=1` and `-Wl,-debug_variant` / ld verbosity, or link the
|
||||
chess objects by hand with `ld -v`.
|
||||
|
||||
Likely fix is in how we emit the object's DWARF/symbol table (so ld accepts
|
||||
it), or emitting our own `.dSYM`-compatible companion. The cheap workaround for
|
||||
users until fixed: debug a small sx repro of the failing logic, or emit per-
|
||||
module objects. Verification: `dsymutil -dump-debug-map <chess-binary>` lists
|
||||
`main.o`, and `lldb` resolves `frame` → `main.sx:line`.
|
||||
|
||||
## Impact
|
||||
|
||||
Blocks source-level **debugging of real (large/bundled) sx apps** in lldb /
|
||||
VSCode. The trace-formatting feature (ERR E3) is unaffected — runtime + comptime
|
||||
return traces resolve in-process via the embedded `Frame` table (no debug map
|
||||
needed). ERR E3 stepping rungs 1–2 (small macOS + iOS-sim binaries) are still
|
||||
verified; this gap is specific to large builds' debug map.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -1,91 +0,0 @@
|
||||
# 0065 — block-expression body does not parse a destructure decl (`v, e := f();`)
|
||||
|
||||
> **RESOLVED.** Two fixes landed:
|
||||
> - The braced `defer { … }` body now parses via `parseBlock` (src/parser.zig,
|
||||
> the `kw_defer` arm) instead of `parseExpr`, mirroring `onfail`. Regression:
|
||||
> `examples/1050-errors-defer-block-body.sx` (commit `634cf9b`).
|
||||
> - The general *value-producing block in binding position* fell out of the
|
||||
> trailing-`;` block-value rework: value-position `{ … }` now routes through
|
||||
> the same statement parser as every other block, so a destructure decl (and
|
||||
> any statement form) parses, and the trailing expression is the block's
|
||||
> value. Regression: `examples/0042-basic-block-value-destructure.sx`.
|
||||
|
||||
## Symptom
|
||||
|
||||
A destructure declaration (`v, e := f();`) inside a **block used in
|
||||
expression position** fails to parse with `expected ';'`. Two surfaced
|
||||
forms:
|
||||
|
||||
- `defer { v, e := f(); ... }` — a `defer` body is parsed via `parseExpr`
|
||||
(so its `{ ... }` is a block-EXPRESSION), and the block-expression
|
||||
statement loop doesn't recognize the `name, name :=` destructure form.
|
||||
- `y := { v, e := f(); v };` — a value-producing block bound to a name.
|
||||
|
||||
Observed: `error: expected ';'` pointing at the statement *after* the
|
||||
destructure (the parser bails at the `:=` and resyncs). Expected: the
|
||||
destructure parses exactly as it does in a normal statement block (an
|
||||
`if` body, a plain `{ }` statement block, or an `onfail { }` body — all of
|
||||
which use `parseBlock` and handle it fine).
|
||||
|
||||
This is the same family as the pre-existing "value-producing block body
|
||||
in binding position doesn't parse" note in `current/CHECKPOINT-ERR.md`
|
||||
(E2.4b log). `onfail { }` is unaffected because it parses its body with
|
||||
`parseBlock` (src/parser.zig ~2063); `defer` is affected because it uses
|
||||
`parseExpr` (~2029).
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
E :: error { Bad }
|
||||
val :: () -> (i32, !E) { return 5; }
|
||||
|
||||
f :: () -> !E {
|
||||
defer {
|
||||
v, e := val(); // ← error: expected ';'
|
||||
print("v={}\n", v);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
main :: () -> i32 { return 0; }
|
||||
```
|
||||
|
||||
Also reproduces with no `defer`, as a plain value block:
|
||||
|
||||
```sx
|
||||
y := {
|
||||
v, e := val(); // ← error: expected ';'
|
||||
v
|
||||
};
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
The block-expression statement loop (the parser path reached from
|
||||
`parseExpr` when it hits `{` — see `src/parser.zig`, the block-as-value
|
||||
parsing around the `parsePrimary`/`parseBlockExpr` path, distinct from
|
||||
`parseBlock` at ~1931) parses each inner statement but does not run the
|
||||
destructure-decl detection that `parseStmt` does. Find where
|
||||
`parseStmt`/`parseBlock` recognizes the `ident (, ident)+ :=` lookahead
|
||||
and make the block-expression statement loop use the same statement
|
||||
parser (ideally route block-expression bodies through `parseStmt` so
|
||||
every statement form — destructure, var/const decl, etc. — is handled
|
||||
uniformly).
|
||||
|
||||
For `defer` specifically: the simplest aligned fix is to parse a
|
||||
braced `defer` body with `parseBlock` (like `onfail` does) while keeping
|
||||
the bare-expression form (`defer expr;`) on `parseExpr`. That removes the
|
||||
defer-body manifestation even if the general block-expression path is
|
||||
handled separately.
|
||||
|
||||
Verification: run the repro above — expect it to compile and run
|
||||
(`exit 0`), with the destructure-bound value usable under an `if !e { … }`
|
||||
guard (ERR E1.8). Add a regression example under `examples/` once fixed.
|
||||
|
||||
## Status
|
||||
|
||||
OPEN. Orthogonal to ERR E1.7/E1.8 — the spec'd cleanup-body absorbers are
|
||||
`catch` / `or <value>` (both parse fine in a `defer` body), so this does
|
||||
not block the error-handling work. Filed while implementing E1.7.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,113 +0,0 @@
|
||||
# 0072 — global field-access constant initializer silently zero-initializes
|
||||
|
||||
> **RESOLVED.** Root cause: `Lowering.globalInitValue`'s issue-0071 `.identifier`
|
||||
> arm closed the bare-identifier hole, but `.field_access` (and every other
|
||||
> non-literal expression shape) still fell through to `else => null`, so the
|
||||
> global was emitted with no payload and silently zero-initialized (`g=0`).
|
||||
> Fix: the `else` now emits a diagnostic — "global '<name>' must be initialized
|
||||
> by a compile-time constant" — instead of returning a null payload, so an
|
||||
> unsupported initializer shape can never silently zero. Two arms were added
|
||||
> alongside it: `.null_literal => .null_val` (a `*void = null` global was
|
||||
> previously a no-payload zero-init; this preserves that exact emission —
|
||||
> `LLVMConstNull`), and an explicit `.enum_literal => null` carve-out (the
|
||||
> stdlib's `OS : OperatingSystem = .unknown;` zero-init is load-bearing for
|
||||
> compile-time `inline if OS == .X`; documented, not folded into a silent
|
||||
> fallthrough). Field-access constant *evaluation* (materializing `K.x` → 9) was
|
||||
> intentionally not implemented: a typed struct const like `K` is not registered
|
||||
> in `module_const_map`, so it would require new plumbing whose `module_const_map`
|
||||
> writes are read at runtime — out of scope; the diagnostic is the chosen,
|
||||
> issue-sanctioned outcome. Regression
|
||||
> `examples/1118-diagnostics-global-non-const-initializer-rejected.sx` (exit 1).
|
||||
|
||||
## Symptom
|
||||
|
||||
A top-level global initialized from a field access on a module constant compiles
|
||||
but is zero-initialized.
|
||||
|
||||
Observed: `g=0`
|
||||
|
||||
Expected: `g` should be initialized to `9`, or the compiler should reject the
|
||||
initializer loudly if field-access global constants are not supported yet.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
Point :: struct {
|
||||
x: i32;
|
||||
y: i32;
|
||||
}
|
||||
|
||||
K : Point : Point.{ x = 9, y = 4 };
|
||||
g : i32 = K.x;
|
||||
|
||||
main :: () -> i32 {
|
||||
print("g={}\n", g);
|
||||
return g;
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
./zig-out/bin/sx run .sx-tmp/review-0071-field-access-const.sx
|
||||
```
|
||||
|
||||
The repro is standalone; the inline source above is sufficient to recreate the
|
||||
scratch file under `.sx-tmp/`.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Fix issue 0072: a top-level global initialized from a field access on a module
|
||||
constant must not silently become zero.
|
||||
|
||||
Context:
|
||||
- This surfaced during Codex re-review of commit `ad7200c`, the issue-0071 fix.
|
||||
- `ad7200c` correctly handles identifier initializers that name module constants
|
||||
(`K : A : 42; g : A = K;`) and diagnoses identifiers that are not usable
|
||||
constants.
|
||||
- A remaining non-identifier expression shape still falls through silently:
|
||||
`K : Point : Point.{ x = 9, y = 4 }; g : i32 = K.x;` emits a null global
|
||||
initializer payload and runs as `g=0`.
|
||||
|
||||
Suspected area:
|
||||
- `src/ir/lower.zig`, `Lowering.globalInitValue`.
|
||||
- The `.identifier` arm now diagnoses unusable identifier initializers, but the
|
||||
generic `else => null` still covers `.field_access` and other expression
|
||||
forms.
|
||||
- `constExprValue` currently handles literals, negative numeric literals, and
|
||||
array literals; it does not evaluate field access or struct-literal module
|
||||
constants. `constStructLiteral` can serialize direct struct literals when
|
||||
called with the destination type, but that machinery is not used for `K.x`.
|
||||
|
||||
Likely fix:
|
||||
- Add a loud diagnostic for unsupported top-level global initializer expression
|
||||
shapes, or implement field-access constant evaluation in the same step.
|
||||
- If implementing it, resolve the base identifier through
|
||||
`ProgramIndex.module_const_map`, materialize the base constant with its
|
||||
recorded type, then extract the named field using the IR struct layout.
|
||||
- Do not replace the current bug with a real-type sentinel such as `.void`; an
|
||||
unsupported initializer should either produce a concrete `ConstantValue` or a
|
||||
user-visible diagnostic.
|
||||
- Preserve the issue-0071 behavior:
|
||||
`K : A : 42; g : A = K;` must still emit `g=42`.
|
||||
- Preserve the issue-0070/0068 regressions:
|
||||
`examples/0133-types-forward-alias-global.sx` and
|
||||
`examples/1117-diagnostics-value-const-as-type-rejected.sx` must stay green.
|
||||
- Scope-check enum-literal globals separately. `OS : OperatingSystem = .unknown`
|
||||
currently relies on compile-time constant injection and pre-existing zero-init
|
||||
behavior; decide explicitly whether that stays carved out or gets its own
|
||||
separate issue.
|
||||
|
||||
Verification:
|
||||
- Repro above should either print `g=9` and exit `9`, or fail with a clear
|
||||
"global initializer must be a compile-time constant" diagnostic. It must not
|
||||
print `g=0`.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
zig build
|
||||
zig build test
|
||||
bash tests/run_examples.sh
|
||||
```
|
||||
@@ -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.
|
||||
@@ -1,84 +0,0 @@
|
||||
# 0074 — silent `getRefIRType(arg_ref) orelse .void` fallback in FFI call-arg lowering
|
||||
|
||||
> **✅ RESOLVED.** Root cause: four FFI call-arg lowering loops resolved an
|
||||
> argument's IR type via `getRefIRType(arg_ref) orelse .void` — a silent fallback
|
||||
> to the load-bearing real type `.void`, which downstream `toLLVMType` →
|
||||
> `abiCoerceParamType` → `coerceArg` treat as a legitimate (void-typed) extern
|
||||
> argument, corrupting the call ABI with no diagnostic. Fix: one shared resolver
|
||||
> `LLVMEmitter.argIRTypeOrFail` ([src/ir/emit_llvm.zig]) returns the dedicated
|
||||
> `.unresolved` sentinel on a failed lookup — never `.void`/`.i64` — so the failure
|
||||
> cannot masquerade as a real type and trips `toLLVMType`'s existing hard `@panic`
|
||||
> tripwire at the call site. All four sites
|
||||
> ([src/ir/emit_llvm.zig] JNI constructor; [src/backend/llvm/ops.zig] objc_msgSend,
|
||||
> JNI non-virtual, JNI `Call<Type>Method`) now route through the helper. Happy path
|
||||
> is byte-identical (every real arg already has a resolved type) — FFI examples stay
|
||||
> green with zero snapshot churn. Regression test (fail-before/pass-after):
|
||||
> `src/ir/emit_llvm.test.zig` — "argIRTypeOrFail surfaces .unresolved for an
|
||||
> unresolvable FFI arg ref (issue 0074)".
|
||||
|
||||
## Symptom
|
||||
**One-line:** Four FFI call-arg lowering sites silently default a failed
|
||||
argument-type lookup to `.void` — the forbidden silent-type-fallback anti-pattern
|
||||
(`.void` as a failed-type-lookup sentinel), which would produce a void-typed
|
||||
extern-call argument (wrong LLVM param type → silent ABI corruption) with no
|
||||
diagnostic if the lookup ever fails.
|
||||
|
||||
**Observed:** `self.getRefIRType(arg_ref) orelse .void` at:
|
||||
- `src/ir/emit_llvm.zig:2463`
|
||||
- `src/backend/llvm/ops.zig:517` (Obj-C `objc_msgSend` arg loop)
|
||||
- `src/backend/llvm/ops.zig:731` (JNI non-virtual call arg loop)
|
||||
- `src/backend/llvm/ops.zig:761` (JNI `Call<Type>Method` arg loop)
|
||||
|
||||
Each then does `toLLVMType(raw_ty)` → `abiCoerceParamType` → `coerceArg`, so a
|
||||
`.void` fallback silently mis-types the extern-call argument.
|
||||
|
||||
**Expected:** `getRefIRType` returning null for a real call argument is a
|
||||
"must-succeed lookup" failure (every arg is a valid param/instruction ref). Per
|
||||
`CLAUDE.md` REJECTED PATTERNS — *"`.void` is an UNACCEPTABLE sentinel for a failed
|
||||
type lookup"* — the lookup failure must surface as a diagnostic / hard tripwire, not
|
||||
a silently-corrupted argument type.
|
||||
|
||||
## Provenance / scope
|
||||
Pre-existing pattern (the `emit_llvm.zig` site is original; the three `ops.zig` sites
|
||||
were relocated **verbatim, behavior-preserving** by step A7.4c of the arch-refactor —
|
||||
the flow reviewer/observer correctly approved the relocations as equivalence-preserving).
|
||||
Surfaced by the **A9.3 final fallback-audit** of the arch-refactor stream. Not
|
||||
introduced by the refactor; filed per the IMPASSIBLE RULE (*"If you find an existing
|
||||
default-return in the compiler that swallows a lookup failure, treat it as a
|
||||
discovered bug — file an issue, do not just delete the default in place"*).
|
||||
|
||||
## Reproduction
|
||||
This is a **latent / static** finding: there is no known sx program that drives
|
||||
`getRefIRType` to `null` for a valid extern-call argument (well-formed IR always
|
||||
has a type for every arg ref), so it cannot currently be triggered at runtime — which
|
||||
is exactly why it is dangerous (a future IR change that breaks the invariant would
|
||||
corrupt FFI ABI silently). The code paths are exercised (and must stay green after
|
||||
the fix) by the existing FFI examples, e.g.:
|
||||
|
||||
```
|
||||
examples/13xx-ffi-objc-* # objc_msgSend arg lowering (ops.zig:517)
|
||||
examples/14xx-ffi-jni-* # JNI Call<Type>Method / non-virtual (ops.zig:731/761)
|
||||
```
|
||||
|
||||
(No new minimal repro `.sx` is meaningful for a latent defensive fallback; the fix is
|
||||
verified by (a) the FFI suite staying green and (b) a unit test that asserts the new
|
||||
loud-failure path, see below.)
|
||||
|
||||
## Investigation prompt (ready to paste into a fresh session)
|
||||
> In `/Users/agra/projects/sx`, four FFI call-arg lowering sites use the forbidden
|
||||
> silent type-fallback `self.getRefIRType(arg_ref) orelse .void`
|
||||
> (`src/ir/emit_llvm.zig:2463`; `src/backend/llvm/ops.zig:517`, `:731`, `:761`).
|
||||
> `getRefIRType` (`src/ir/emit_llvm.zig:2229`, returns `?TypeId`) yields `null` only
|
||||
> when a ref is neither a function param nor a block instruction result — a
|
||||
> must-not-happen case for a real call argument. Replace the silent `.void` default
|
||||
> with a loud failure that cannot be mistaken for a real type, per `CLAUDE.md`
|
||||
> REJECTED PATTERNS: emit a diagnostic via `self.diagnostics.addFmt(.err, span,
|
||||
> "...", .{...})` and/or a hard tripwire (`@panic`/`bailDetail`-style) naming the op
|
||||
> and the bad ref — do NOT substitute another real type. Prefer a single shared
|
||||
> helper (e.g. `argIRTypeOrFail(arg_ref, span)`) used by all four sites so the policy
|
||||
> lives in one place. Then: (1) `/Users/agra/.zvm/bin/zig build && /Users/agra/.zvm/bin/zig
|
||||
> build test && bash tests/run_examples.sh` must stay 361/0 with the FFI examples
|
||||
> green (the happy path is unchanged); (2) add a `*.test.zig` unit test that
|
||||
> constructs an FFI call op with a bogus arg ref and asserts the loud failure fires
|
||||
> (not a `.void` silent default). Expected new behavior: an unresolved FFI arg type
|
||||
> produces a clear compiler error / panic, never a void-typed extern argument.
|
||||
@@ -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`.
|
||||
@@ -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 <file>` today → LLVM verifier abort.
|
||||
**Expected after fix:** a clean compile-time diagnostic that `i2` is a reserved
|
||||
type name and cannot be an identifier (exit non-zero, readable error — NOT an
|
||||
LLVM abort, NOT a silent copy). The same program with a non-reserved name
|
||||
(`hasher := init(); update(@hasher, ".")`) must compile and print `total_len=1`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Pinned diagnostics test(s) asserting the error for representative reserved
|
||||
names used as identifiers: `i2`, `u8`, `i64`, `bool`, `string` (declaration
|
||||
forms: `:=`, typed local, and a parameter name). Capture the diagnostic text
|
||||
in `expected/`.
|
||||
2. A positive test: the same `*self` streaming pattern with NON-reserved names
|
||||
(`hasher`, `ctx`) compiles and accumulates state correctly via both
|
||||
`update(@h, ...)` and `h.update(...)` — proving the `.identifier` paths are
|
||||
correct and no lowering special-case is needed.
|
||||
3. `zig build && zig build test && bash tests/run_examples.sh` all green. If any
|
||||
existing example/test declares a variable with a reserved type name, it is now
|
||||
illegal — fix the test's variable name (do NOT weaken the diagnostic). Report
|
||||
how many such sites existed.
|
||||
|
||||
## Provenance
|
||||
|
||||
Discovered by the `distribution` flow (P1.2 pure-sx SHA-256), whose minimal repro
|
||||
happened to name a local `i2`. Real SHA-256 code with names like `hasher`/`ctx`
|
||||
is unaffected on the current compiler — so the P1.2 "blocker" was a
|
||||
naming artifact, and this issue is really a missing-diagnostic correctness bug.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -1,102 +0,0 @@
|
||||
# 0079 — stores to module-global array elements are silently dropped
|
||||
|
||||
> **RESOLVED.**
|
||||
> **Root cause:** `Lowering.lowerExprAsPtr` (`src/ir/lower.zig`) — the lvalue/
|
||||
> address path — handled only *local* identifiers (alloca pointers). A
|
||||
> module-global identifier fell through to the value fallback `lowerExpr`,
|
||||
> which emits `global_get` (loads the whole array *by value*). The LLVM
|
||||
> backend's `emitIndexGep` then sees an array *value*, allocas a throwaway
|
||||
> temp, copies the value in, and GEPs into the temp — so `g[i] = v` wrote a
|
||||
> discarded copy and a later `g[i]` read the global's untouched initializer.
|
||||
> Local arrays worked because they hit the alloca-pointer path; global
|
||||
> *scalar* stores worked via `global_set`.
|
||||
> **Fix:** teach `lowerExprAsPtr`'s identifier arm about globals — emit
|
||||
> `global_addr` (a pointer into the global's live storage) for a normal
|
||||
> global, or `global_get` for a pointer-typed global (mirroring the local
|
||||
> pointer case). The same array-base resolution in the `address_of(index_expr)`
|
||||
> path now routes through `lowerExprAsPtr` so `&g[i]` is also an lvalue into
|
||||
> the global. `index_gep` then GEPs directly into `@g` for const AND variable
|
||||
> index, across functions, in both `sx run` and `sx build`.
|
||||
> **Regression:** `examples/0136-types-global-array-element-store.sx`
|
||||
> (const-index, var-index, cross-function store on a scalar global array; a
|
||||
> struct-element global array for element-stride; a nested-array global for the
|
||||
> recursive indexed lvalue). FAILS on the pre-fix compiler (reads return the
|
||||
> initializer / zero), PASSES after.
|
||||
|
||||
## Symptom
|
||||
|
||||
A store to a module-global (file-scope) ARRAY element is silently lost: after
|
||||
`g[i] = v`, reading `g[i]` returns the array's INITIALIZER value, not `v`. No
|
||||
diagnostic. Reproduces with a constant index, a variable index, and a store from
|
||||
another function, in BOTH `sx run` (JIT) and `sx build` (AOT). Local-array stores
|
||||
and global-SCALAR stores work correctly — so the indexed load/store on a *global*
|
||||
array appears to read/write the initializer constant rather than the global's
|
||||
live storage. This is a SILENT data-corruption bug (the dangerous class per the
|
||||
project rules), not a crash.
|
||||
|
||||
Manager-reproduced output (JIT):
|
||||
```
|
||||
global[1] const-idx=20 (want 222)
|
||||
global[k] var-idx=30 (want 333)
|
||||
global[0] via fn=10 (want 111)
|
||||
```
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
g : [3]i64 = .[10, 20, 30];
|
||||
write_global :: (i: i64, v: i64) { g[i] = v; }
|
||||
|
||||
main :: () {
|
||||
loc : [3]i64 = .[10, 20, 30];
|
||||
loc[1] = 222;
|
||||
print("local[1]={}\n", loc[1]); // 222 (correct)
|
||||
g[1] = 222;
|
||||
print("global[1] const-idx={}\n", g[1]); // 20 (WRONG, want 222)
|
||||
k := 2;
|
||||
g[k] = 333;
|
||||
print("global[k] var-idx={}\n", g[k]); // 30 (WRONG, want 333)
|
||||
write_global(0, 111);
|
||||
print("global[0] via fn={}\n", g[0]); // 10 (WRONG, want 111)
|
||||
}
|
||||
```
|
||||
|
||||
`./zig-out/bin/sx run <file>` → prints the WRONG values above. Expected:
|
||||
`local[1]=222 / global[1]=222 / global[k]=333 / global[0]=111`.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
A store to a module-global array element (`g[i] = v`) is silently lost: a
|
||||
subsequent `g[i]` yields the initializer value, in BOTH the JIT (`sx run`) and
|
||||
compiled (`sx build`) paths. Global SCALAR stores and LOCAL array stores both
|
||||
work, so the defect is specific to INDEXED lvalue access on a GLOBAL array.
|
||||
Suspect the IR-gen / codegen for an indexed expression on a global symbol:
|
||||
the load of `global_array[idx]` is likely decaying / constant-folding to the
|
||||
array's initializer constant (or the address computation for the store targets a
|
||||
private copy of the initializer rather than the global's live storage). Look in
|
||||
the index-expression lowering and global-symbol address resolution (and how
|
||||
global array initializers are materialized) — `src/ir/lower.zig` /
|
||||
`src/ir/emit_llvm.zig` and the global-symbol/constant-initializer paths.
|
||||
|
||||
The fix must make `load(global_array[idx])` read the global's live storage and
|
||||
`store(global_array[idx], v)` write it — for constant AND variable indices, and
|
||||
when the store happens in a different function than the read. Do NOT special-case;
|
||||
fix the address resolution so a global array element is an lvalue into the
|
||||
global's storage like any other.
|
||||
|
||||
## Verification (once fixed)
|
||||
- The repro above prints `222 / 333 / 111` for the global cases (exit 0).
|
||||
- Add a pinned regression `examples/NNNN-*.sx` covering const-index, var-index,
|
||||
and cross-function store to a global array (+ a global array of a struct/larger
|
||||
element if practical).
|
||||
- `zig build && zig build test && bash tests/run_examples.sh` all green.
|
||||
|
||||
## Provenance
|
||||
|
||||
Discovered by the `distribution` flow (sx-foundation step F3.1, std.cli argv
|
||||
accessor) while exploring argv backing strategies. The shipped F3.1 code does NOT
|
||||
depend on this (it uses caller-provided buffers + zero-copy views over the C argv
|
||||
block), so F3.1 is correct independently — this is a separate latent
|
||||
silent-miscompile surfaced per the STOP-on-compiler-bug rule.
|
||||
@@ -1,121 +0,0 @@
|
||||
# 0080 - global array of struct literals silently zero-initializes
|
||||
|
||||
> **RESOLVED.**
|
||||
> **Root cause:** `Lowering.constExprValue` (`src/ir/lower.zig`) — the constant-
|
||||
> aggregate serializer for global initializers — handled primitive and nested-
|
||||
> array leaves but had **no `.struct_literal` arm**. A module-global `[N]Struct`
|
||||
> initialized with struct literals reached `constArrayLiteral` → `constExprValue`
|
||||
> per element; each struct-literal element returned `null`, collapsing the whole
|
||||
> array initializer to `null`. `globalInitValue` then emitted no payload, so the
|
||||
> LLVM backend zero-initialized the global (`@pairs = ... zeroinitializer`),
|
||||
> silently dropping every declared field — the same silent-zero class as
|
||||
> 0071/0072, one level inside an array literal. (A global *struct* literal and a
|
||||
> *struct-with-array* already worked, because `constStructLiteral` existed and was
|
||||
> reached directly; the gap was specifically struct literals *as array elements*.)
|
||||
> **Fix:** make `constExprValue` type-aware — thread the destination element/field
|
||||
> `TypeId` so a `.struct_literal` leaf routes through `constStructLiteral` and a
|
||||
> nested `.array_literal` through `constArrayLiteral` with the correct element
|
||||
> type. `constArrayLiteral` derives its element type from the array `TypeId`;
|
||||
> `constStructLiteral` passes each field's type. A global aggregate initializer
|
||||
> that still does not fully reduce to a compile-time constant is now **rejected
|
||||
> loudly** (`diagnoseNonConstGlobal`) instead of falling through to a zeroed
|
||||
> global. The downstream `emitConstAggregate` already recurses over nested
|
||||
> aggregates, so const/AOT (`sx build`) and JIT (`sx run`) both materialize the
|
||||
> declared values.
|
||||
> **Regression:** `examples/0137-types-global-aggregate-literal-init.sx` (global
|
||||
> `[N]Struct` literal, global struct literal, struct-with-array, nested array-of-
|
||||
> struct-with-array; values read back with no prior store, plus a store on top).
|
||||
> FAILS on the pre-fix compiler (array-of-struct fields read 0), PASSES after.
|
||||
|
||||
## Symptom
|
||||
|
||||
A module-global fixed array whose elements are struct literals is emitted as
|
||||
zero-initialized storage instead of preserving the literal fields.
|
||||
|
||||
Observed: reading `pairs[0].b` and `pairs[1].a` prints `0`.
|
||||
Expected: the global should contain the declared struct literal values
|
||||
(`2` and `3`), or the compiler should reject the initializer loudly if this
|
||||
constant shape is unsupported.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
Pair :: struct {
|
||||
a: i64;
|
||||
b: i64;
|
||||
}
|
||||
|
||||
pairs : [2]Pair = .[ .{ a = 1, b = 2 }, .{ a = 3, b = 4 } ];
|
||||
|
||||
main :: () -> i32 {
|
||||
print("pairs[0]={},{}\n", pairs[0].a, pairs[0].b);
|
||||
print("pairs[1]={},{}\n", pairs[1].a, pairs[1].b);
|
||||
if pairs[0].a == 1 and pairs[0].b == 2 and pairs[1].a == 3 and pairs[1].b == 4 {
|
||||
print("PASS\n");
|
||||
return 0;
|
||||
}
|
||||
print("FAIL: global array struct literal initializer zeroed\n");
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
On the current compiler this prints:
|
||||
|
||||
```text
|
||||
pairs[0]=0,0
|
||||
pairs[1]=0,0
|
||||
FAIL: global array struct literal initializer zeroed
|
||||
```
|
||||
|
||||
`sx ir <file>` shows the global as:
|
||||
|
||||
```llvm
|
||||
@pairs = internal global [2 x { i64, i64 }] zeroinitializer
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Fix issue 0080: a module-global array initialized with struct literal elements
|
||||
silently becomes `zeroinitializer`.
|
||||
|
||||
Suspected area:
|
||||
- `src/ir/lower.zig`, `Lowering.globalInitValue`.
|
||||
- `src/ir/lower.zig`, `Lowering.constArrayLiteral`.
|
||||
- `src/ir/lower.zig`, `Lowering.constExprValue`.
|
||||
- `src/ir/lower.zig`, `Lowering.constStructLiteral`.
|
||||
|
||||
Likely root cause: `globalInitValue` handles a top-level `.array_literal` by
|
||||
calling `constArrayLiteral`, and `constArrayLiteral` serializes each element via
|
||||
`constExprValue`. `constExprValue` handles primitive literals and nested arrays,
|
||||
but not `.struct_literal`, so an array whose element is a struct literal returns
|
||||
`null`. That null initializer payload is later emitted as zero-initialized
|
||||
storage, recreating the silent zero pattern from issues 0071/0072 one level
|
||||
inside an otherwise-supported array literal.
|
||||
|
||||
Likely fix:
|
||||
- Thread the expected element `TypeId` into `constArrayLiteral`, or otherwise
|
||||
make `constExprValue` type-aware for struct literals.
|
||||
- Serialize each struct element through `constStructLiteral` with the array's
|
||||
element type.
|
||||
- If any element shape is still unsupported, emit a diagnostic naming the global
|
||||
instead of returning `null` and allowing zero-initialization.
|
||||
|
||||
Verification:
|
||||
- Run the repro above and expect:
|
||||
|
||||
```text
|
||||
pairs[0]=1,2
|
||||
pairs[1]=3,4
|
||||
PASS
|
||||
```
|
||||
|
||||
- Add a pinned regression in the `01xx` types block.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
zig build
|
||||
zig build test
|
||||
bash tests/run_examples.sh
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 / `<pack>.len`, so `inline for 0..M` failed
|
||||
> outright. Fix: every one of those sites now routes through the single shared
|
||||
> `program_index.evalConstIntExpr` — `evalComptimeInt` delegates to it (the pack
|
||||
> `.len` leaf moved into the shared folder via a new `ctx.lookupPackLen`); the
|
||||
> Vector lane and value-param resolvers fold through it and emit a clean diagnostic
|
||||
> + `.unresolved` (never `else => 0`) on a non-const operand. Two enabling fixes
|
||||
> upstream of resolution: the unknown-type semantic checker no longer walks a
|
||||
> value-param position (`Vector(N, …)` / `Vec(N, …)`) as a type name (it was
|
||||
> reporting "unknown type 'N'"); and both the parameterized-type-arg parser and
|
||||
> the function-body-detection lookahead (`hasFnBodyAfterArrow`) accept a
|
||||
> const-EXPRESSION in a value position, so `Vector(M + 1, f32)` and `[M + 1]T`
|
||||
> parse as a return type too (the latter a pre-existing attempt-4 sibling miss).
|
||||
> Files: `src/ir/program_index.zig` (+`.test.zig`), `src/ir/lower.zig`,
|
||||
> `src/ir/type_bridge.zig`, `src/ir/semantic_diagnostics.zig`, `src/parser.zig`.
|
||||
> Regressions: `examples/1501-vectors-const-lane.sx` (named-const + const-expr
|
||||
> lane, direct + alias, 3- and 4-lane reads), `examples/1502-vectors-runtime-lane-
|
||||
> not-const.sx` (a runtime lane clean-halts, exit 1, no LLVM crash),
|
||||
> `examples/0207-generics-value-param-const.sx` (`Vec(N,f32)` / `Vec(M+1,f32)`
|
||||
> resolve to the same instantiation as `Vec(3,f32)`),
|
||||
> `examples/0610-comptime-inline-for-const-bound.sx` (`inline for 0..M` and
|
||||
> `0..(M+1)` unroll).
|
||||
>
|
||||
> **Value-param type functions + oversized guard (attempt 6).** Two remaining
|
||||
> siblings in the comptime-int path. (1) A type-RETURNING function with a value
|
||||
> param used as a TYPE annotation (`b : Make(N, i64)` where `Make :: ($K: u32,
|
||||
> $T: Type) -> Type { return [K]T; }`) was rejected "unknown type 'N'" because
|
||||
> the unknown-type checker walked the value-param position as a type name, AND the
|
||||
> parameterized-type-annotation path never routed to `instantiateTypeFunction`
|
||||
> (only the `.call` path did), nor did that binder resolve a non-struct/union
|
||||
> return shape. Fix: `isValueParamPosition` (semantic_diagnostics.zig) now also
|
||||
> skips a value param of a `fn_ast_map` type-returning function (mirroring the
|
||||
> binder's value/type classification); `resolveParameterizedWithBindings` routes
|
||||
> a type-returning-function name to `instantiateTypeFunction`; and that binder
|
||||
> resolves a general return-type expression (`return [K]T`) with bindings active.
|
||||
> `Make(N, i64)`, `Make(M + 1, i64)`, and `Make(3, i64)` now resolve to one
|
||||
> `[3]i64`. (2) Oversized dim/lane folds (`[5_000_000_000]`) panicked the
|
||||
> compiler — fixed under issue 0087 via the shared range-checked
|
||||
> `program_index.foldDimU32` gate. Files: `src/ir/semantic_diagnostics.zig`,
|
||||
> `src/ir/lower.zig`, `src/ir/program_index.zig`, `src/ir/type_bridge.zig`.
|
||||
> Regression: `examples/0208-generics-value-param-type-function.sx`.
|
||||
>
|
||||
> **Diagnostic-accuracy parity (attempt 7).** The fold + layout were correct, but
|
||||
> the two paths still DIVERGED on the error MESSAGE for an oversized dim. The
|
||||
> direct form (`a : [5_000_000_000]i64`) reported the accurate "array dimension
|
||||
> 5000000000 does not fit in u32" (from the stateful `resolveArrayLen`, which
|
||||
> branches on `foldDimU32`'s `.too_large` / `.below_min` / `.not_const` variants),
|
||||
> but the type-ALIAS form (`Big :: [5_000_000_000]i64`) reported a FALSE "an array
|
||||
> dimension is not a compile-time integer constant" — because the stateless
|
||||
> `resolveArrayLen` collapsed every non-`.ok` `DimU32` to `null`, so the
|
||||
> alias-registration site had only one generic message to emit. Fix: a single
|
||||
> wording source `program_index.reportDimError(diag, span, DimU32)` now owns the
|
||||
> dim-error text; the stateful path emits through it, and the alias-registration
|
||||
> site re-folds a top-level array dim via the new `type_bridge.foldArrayDim`
|
||||
> (same shared `foldDimU32`) and routes a `.too_large` / `.below_min` result to
|
||||
> `reportDimError` — so an oversized alias dim now reports the SAME precise
|
||||
> message as the direct form. A genuinely non-const alias dim (`[get()]`) still
|
||||
> gets the alias-specific "not a compile-time integer constant" message (1129).
|
||||
> Files: `src/ir/program_index.zig`, `src/ir/type_bridge.zig`, `src/ir/lower.zig`.
|
||||
> Regression: `examples/1131-diagnostics-array-dim-oversized-u32-alias.sx`
|
||||
> (oversized dim via alias → "does not fit in u32", matching direct example 1130;
|
||||
> 1129 still proves the non-const path keeps the generic message).
|
||||
>
|
||||
> **Integral-float counts + value-param range gate (attempt 8, Agra ruling).**
|
||||
> Two finishing items on the shared count path. (1) An *integral* compile-time
|
||||
> FLOAT used as a count (array dim, Vector lane, value-param, `inline for` bound)
|
||||
> was wrongly rejected — `N : f64 : 4.0`, `N :: 4.0`, and `[4.0]i64` all said
|
||||
> "must be a compile-time integer constant". The shared evaluator now folds an
|
||||
> integral float to its integer at the single leaf
|
||||
> (`program_index.floatToIntExact`, used by both the `.float_literal` arm of
|
||||
> `evalConstIntExpr` and `moduleConstInt`), so every consumer accepts `4.0` ≡ `4`
|
||||
> while a non-integral (`4.5`) or negative value is still rejected by the
|
||||
> downstream `foldDimU32` gate. (2) A generic value-param bind (`Box($K: u32)`)
|
||||
> never range-checked the folded arg against its declared type, so
|
||||
> `Box(5_000_000_000)` compiled and ran; the bind now routes a `u32` count
|
||||
> through the same `foldDimU32` gate (and any other declared integer type through
|
||||
> `program_index.intTypeRange`), so an out-of-range arg is a clean compile error
|
||||
> ("value 5000000000 does not fit in u32 parameter K"). Files:
|
||||
> `src/ir/program_index.zig` (+`.test.zig`), `src/ir/lower.zig`, `specs.md`.
|
||||
> Regressions: `examples/0145-types-integral-float-array-dim.sx`,
|
||||
> `examples/1504-vectors-integral-float-lane.sx`,
|
||||
> `examples/0611-comptime-integral-float-inline-for.sx`,
|
||||
> `examples/0209-generics-value-param-integral-float.sx`,
|
||||
> `examples/1132-diagnostics-array-dim-non-integral-float.sx`,
|
||||
> `examples/1133-diagnostics-array-dim-negative-float.sx`,
|
||||
> `examples/1134-diagnostics-value-param-u32-overflow.sx`.
|
||||
>
|
||||
> **Convergence — the last three count-surface cells (attempt 9).** Three
|
||||
> adjacent cells of the SAME shared count surface still diverged. (1) An ALIASED
|
||||
> integer constraint (`Count :: u32`; `$K: Count`) bypassed the value-param range
|
||||
> gate — only BUILTIN constraint names matched `intTypeRange`, so
|
||||
> `Box(5_000_000_000)` with `$K: Count` compiled and bound a truncated value. The
|
||||
> gate (`Lowering.resolveValueParamArg`, shared by BOTH binders — struct +
|
||||
> type-fn) now resolves the constraint to its underlying builtin
|
||||
> (`canonicalIntConstraintName`: `Count` → u32, `Small` → i8) before
|
||||
> range-checking, so an aliased integer constraint behaves exactly like the
|
||||
> builtin it names. (2) A named const with an EXPRESSION RHS (`M :: 2; N :: M + 1`)
|
||||
> did not fold as a count — `program_index.moduleConstInt` read only a LITERAL RHS
|
||||
> node. It now folds every const's RHS through the shared `evalConstIntExpr`
|
||||
> (cycle-guarded so `N :: N` / mutual cycles fold to null, not a stack overflow),
|
||||
> and scanDecls pass-0 pre-registers expression-RHS consts; so `N :: M + 1` == 3
|
||||
> at every count consumer (dim direct + alias, Vector lane, value-param struct +
|
||||
> type-fn, `inline for`). (3) The stateful `Lowering.resolveArrayLen` STILL
|
||||
> fabricated length 0 after a failed fold; it now returns null → the `.unresolved`
|
||||
> sentinel (no fabrication), and the binding's lowering bails on it cleanly — a
|
||||
> field access on an already-diagnosed `.unresolved` value stays silent
|
||||
> (`emitFieldError`), so a failed-fold dim emits ONE clean diagnostic and never
|
||||
> reaches the `sizeOf` panic. Files: `src/ir/program_index.zig` (+`.test.zig`),
|
||||
> `src/ir/lower.zig`. Regressions: `examples/0146-types-comptime-count-matrix.sx`
|
||||
> (the full positive matrix — every consumer × representative leaf form),
|
||||
> `examples/1135-diagnostics-value-param-alias-constraint-overflow.sx` (aliased
|
||||
> u32 + i8 overflow), `examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx`
|
||||
> (direct non-const dim halts cleanly, no fabrication / panic); the cascade
|
||||
> cleanup also tightened `examples/1502`/`1503` to one diagnostic each.
|
||||
>
|
||||
> **Final convergence — type-fn binder parity (attempt 10).** One last cell of
|
||||
> the count surface still diverged from the struct binder. A FAILED value-param
|
||||
> bind on a type-RETURNING FUNCTION (`MakeC :: ($K: Count, $T: Type) -> Type
|
||||
> { return [K]T; }`; `a : MakeC(5_000_000_000, i64)`) emitted its correct range
|
||||
> diagnostic, but `instantiateTypeFunction` then returned `null`, so
|
||||
> `resolveParameterizedWithBindings` fell through to the empty-struct *placeholder*
|
||||
> named `MakeC`. The binding `a` got that placeholder type, so a downstream
|
||||
> `a.len` cascaded a bogus second error `field 'len' not found on type 'MakeC'`.
|
||||
> The struct binder (`instantiateGenericStruct`) already returned `.unresolved`
|
||||
> here; the type-fn binder now matches it — the failed value-param bind poisons to
|
||||
> `.unresolved` instead of `null`, so the caller propagates the diagnosed poison
|
||||
> and the existing `emitFieldError` suppression yields ONE clean diagnostic. Covers
|
||||
> every type-fn value-param failure mode (overflow via aliased constraint,
|
||||
> non-const arg, unknown type arg). Files: `src/ir/lower.zig` (one line in
|
||||
> `instantiateTypeFunction`). Regression:
|
||||
> `examples/1137-diagnostics-value-param-type-fn-no-cascade.sx`.
|
||||
|
||||
## Symptom
|
||||
A fixed array whose dimension is a module-global integer constant (`N :: 16;
|
||||
a : [N]T`) miscompiles element access: reads/writes compute a wrong address.
|
||||
With `i64` elements `a[0]` returns GARBAGE (silent); with slice/pointer element
|
||||
types (`[N]string`) it Bus-errors. The identical program with a LITERAL dimension
|
||||
(`a : [16]T`) is correct. Silent-miscompile class (cf. 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.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 0084 — array/slice literal passed directly as a call argument miscompiles
|
||||
|
||||
> **RESOLVED.** Root cause: `lowerArrayLiteral` always produces an aggregate
|
||||
> ARRAY value; the array→slice conversion is the caller's job. The local-bound
|
||||
> var-decl path did it (emits `array_to_slice`), but the call-argument coercion
|
||||
> path (`coerceCallArgs` → `coerceToType` → `CoercionResolver.classify`) had no
|
||||
> array→slice arm, so `classify([N]T, []T)` returned `.none` and the raw array
|
||||
> value was passed where a slice was expected — the callee read its {ptr,len}
|
||||
> header off the wrong bytes (returned 0 / garbage, segfaulted for `[]i64`). Fix:
|
||||
> `classify` now returns a new `.array_to_slice` plan for `[N]T → []T` (same
|
||||
> element type), and `coerceToType` emits the existing `array_to_slice` op, which
|
||||
> materializes the array into addressable storage and builds the slice header —
|
||||
> identical to the local-bound path. Files: `src/ir/conversions.zig`,
|
||||
> `src/ir/lower.zig`. Regression: `examples/0141-types-slice-literal-direct-call-arg.sx`
|
||||
> (string + numeric `[]i64`, direct vs local-bound).
|
||||
|
||||
## Symptom
|
||||
A `.[...]` array/slice literal passed DIRECTLY as a call argument yields a slice
|
||||
whose element CONTENTS are not reliably readable in the callee (silent — reads
|
||||
garbage, wrong results). Binding the same literal to a typed local first and
|
||||
passing the local is correct.
|
||||
|
||||
## Reproduction
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
show :: (xs: []string) -> i64 { n:=0; i:=0; while i<xs.len { if xs[i]=="nope" {n+=1;} i+=1; } return n; }
|
||||
main :: () {
|
||||
print("direct={}\n", show(.["a","nope","b","nope"])); // prints 0 (WRONG)
|
||||
local : []string = .["a","nope","b","nope"]; print("local={}\n", show(local)); // prints 2 (correct)
|
||||
}
|
||||
```
|
||||
Want both `2`. Direct-literal-arg returns `0`.
|
||||
|
||||
## Investigation prompt
|
||||
Passing a `.[...]` literal directly as a call arg builds a slice/array temporary
|
||||
whose backing storage is not correctly materialized/kept alive for the callee —
|
||||
the slice header may point at a stack temp that is clobbered, or the elements are
|
||||
not stored before the call. Binding to a typed local first works (the local's
|
||||
storage backs the slice). Look at how a literal aggregate argument is lowered at a
|
||||
call site (materialize the literal into addressable storage whose lifetime spans
|
||||
the call, then pass a slice/pointer to it) vs the local-bound path. Fix so a
|
||||
directly-passed literal arg behaves identically to a local-bound one. Verify with
|
||||
the repro (both `2`) + a numeric `[]i64` case, gate green.
|
||||
@@ -1,55 +0,0 @@
|
||||
# 0085 — nested slice literal elements are stored as raw arrays
|
||||
|
||||
> **RESOLVED.** Root cause: `Lowering.lowerArrayLiteral` lowered each element with
|
||||
> the element type as `target_type` but appended the returned value directly. For
|
||||
> a nested `.[...]` element whose expected element type is a slice (`[]T`), the
|
||||
> inner literal still lowers to an aggregate ARRAY `[N]T` — so the outer aggregate
|
||||
> (typed array-of-`[]T`) held raw arrays where slice {ptr,len} headers were
|
||||
> expected; indexing the inner slice read a garbage pointer and segfaulted. Fix:
|
||||
> after lowering each element, when the element type is a slice and the lowered
|
||||
> value is a same-element array, coerce it via the existing `array_to_slice` op
|
||||
> (materialize backing storage + build the header) — identical to the whole-
|
||||
> literal coercion the var-decl / call-arg paths already run. The coercion
|
||||
> recurses with the nesting, so `[][]T` and deeper materialize at every level.
|
||||
> Files: `src/ir/lower.zig` (`lowerArrayLiteral`). Regression:
|
||||
> `examples/0142-types-nested-slice-literal-elements.sx` (`[][]i64` + `[][]string`,
|
||||
> local-bound AND direct-call-argument forms).
|
||||
|
||||
## Symptom
|
||||
Nested array/slice literals such as `.[.[1, 2], .[3, 4]]` miscompile when the
|
||||
expected element type is a slice (`[][]i64`). Observed: both the local-bound and
|
||||
direct-call forms segfault while indexing the inner slice. Expected: both forms
|
||||
materialize each inner `[N]T` literal as a `[]T` slice and print the same value.
|
||||
|
||||
## Reproduction
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
sum_nested :: (xss: [][]i64) -> i64 {
|
||||
return xss[0][1] + xss[1][0];
|
||||
}
|
||||
|
||||
main :: () {
|
||||
local : [][]i64 = .[.[1, 2], .[3, 4]];
|
||||
print("local={}\n", sum_nested(local));
|
||||
print("direct={}\n", sum_nested(.[.[1, 2], .[3, 4]]));
|
||||
}
|
||||
```
|
||||
Observed on `flow/sx-foundation/F0.4`: segfault at address `0x9` before either
|
||||
line prints. Expected output:
|
||||
```text
|
||||
local=5
|
||||
direct=5
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
Fix nested slice literal materialization. The likely area is
|
||||
`src/ir/lower.zig` in `Lowering.lowerArrayLiteral`: the outer literal can know
|
||||
its expected element type is `[]T`, and the loop sets `self.target_type =
|
||||
elem_ty` while lowering each inner literal, but it appends the returned value
|
||||
directly. For an inner `.[...]`, that returned value is still an array aggregate
|
||||
`[N]T`, not the target `[]T` slice. Add per-element coercion/materialization
|
||||
after lowering each element, using the element source type and expected
|
||||
`elem_ty` (the existing `array_to_slice` coercion should be reused). Verify the
|
||||
repro prints `local=5` and `direct=5`, then run `zig build && zig build test &&
|
||||
bash tests/run_examples.sh`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -1,131 +0,0 @@
|
||||
> **RESOLVED (F0.7)** — A typed module-level constant whose initializer does not
|
||||
> match its annotation is now rejected at the declaration with a clear
|
||||
> `type mismatch` diagnostic, killing both symptoms (the `print(N)` segfault and
|
||||
> the `[N]i64` → 4 fold).
|
||||
>
|
||||
> **Root cause.** `registerTypedModuleConst` (`src/ir/lower.zig`) stored the
|
||||
> annotation type on the const but never checked the initializer literal against
|
||||
> it, so `N : string : 4` registered as `{value = int 4, ty = string}`.
|
||||
> `emitModuleConst` then stamped the `int_literal` with the `string` type (a
|
||||
> bogus pointer → segfault at the use site), and `program_index.moduleConstInt`
|
||||
> folded the const into an integer COUNT by inspecting the `int_literal` node
|
||||
> alone, ignoring `ModuleConstInfo.ty` (so `[N]i64` folded to 4).
|
||||
>
|
||||
> Both LITERAL initializers (`N : string : 4`) and const-EXPRESSION initializers
|
||||
> (`M :: 2; N : string : M + 2`, `V : string : -M`) are rejected — the validation
|
||||
> is type-based, so a non-literal node kind can no longer escape it (attempt 2).
|
||||
>
|
||||
> **Mixed-numeric escape closed at the type-system root (attempt 3).** The
|
||||
> type-based validation reuses `inferExprType`, which inferred a non-comparison
|
||||
> binary op from its LHS alone — so `BAD : i64 : M + 0.5` (i64 + f64) inferred
|
||||
> `i64` and was accepted+truncated, while `0.5 + M` inferred `f64` and was
|
||||
> rejected: operand-order-dependent. The fix is in the binary-op arm of
|
||||
> `ExprTyper.inferType` (`src/ir/expr_typer.zig`): arithmetic / bitwise / shift
|
||||
> ops now infer the PROMOTED result of `(lhs, rhs)` via `Lowering.arithResultType`
|
||||
> — the same int×float → float rule `lowerBinaryOp` already applied for the
|
||||
> value (extracted from its inline block into a shared helper, so the two can't
|
||||
> diverge). `M + 0.5` now infers `f64` in either operand order, so the typed-const
|
||||
> validation rejects it against an `i64` annotation with no special-casing in the
|
||||
> validation logic itself. This was a pre-existing inference bug broader than
|
||||
> typed consts (it also mis-formatted `print("{}", M + 0.5)` as a truncated int);
|
||||
> the typed-LOCAL `y : i64 = 1.5` → 1 narrowing is a SEPARATE assignment-coercion
|
||||
> bug tracked as issue 0095.
|
||||
>
|
||||
> **Fix per file.**
|
||||
> - `src/ir/lower.zig` — `registerTypedModuleConst` validates the initializer
|
||||
> against the resolved annotation BY TYPE, covering literals AND
|
||||
> const-expressions (binary_op / unary_op) uniformly. `typedConstInitFits`
|
||||
> keeps the literal arms (int → int/float, float → float, bool → bool,
|
||||
> string → string, null → pointer/optional, `---` → any) and routes any
|
||||
> non-literal through `constExprInitFits`, which compares the initializer's
|
||||
> INFERRED type (`inferExprType`, the existing type-inference facility — no
|
||||
> second const evaluator) to the annotation with the same integer/float
|
||||
> compatibility. A mismatch emits `type mismatch: constant '<n>' is declared
|
||||
> '<ty>' but its initializer is <desc>` at the initializer span (a literal
|
||||
> names its kind; a const-expression is described by its inferred type, e.g.
|
||||
> "an integer expression"), and does NOT register the const — it evicts the
|
||||
> pass-0 placeholder so a count use can't still fold it. On a MATCH the const is
|
||||
> registered at its resolved annotation type (the same `put` the literal path
|
||||
> always did), so a const-expression folds and emits at its declared type.
|
||||
> - `src/ir/program_index.zig` — `moduleConstInt` / `moduleConstIntFramed` take
|
||||
> the `TypeTable` and gate the fold on `isCountableConstType(ci.ty)` (integer
|
||||
> of any width, or a float), so a non-numeric typed const can never be folded
|
||||
> into a count off its initializer node — whether that node is a literal or a
|
||||
> foldable integer expression. Callers in `lower.zig` and `type_bridge.zig`
|
||||
> updated.
|
||||
>
|
||||
> **Regression tests.**
|
||||
> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: eight
|
||||
> mismatch shapes — four literal (`int→string`, `string→i64`, `bool→i64`,
|
||||
> `float→i64`), two const-expression (`M + 2 → string`, `-M → string`), and two
|
||||
> mixed-numeric (`i64 : M + 0.5` and `i64 : 0.5 + M`, rejected in BOTH operand
|
||||
> orders) — each emit a `type mismatch` diagnostic, exit 1.
|
||||
> - `examples/0162-types-typed-module-const-roundtrip.sx` — positive: valid typed
|
||||
> consts (`i64` as count + printed, `f32` from int, `f32` float, `string`,
|
||||
> `*void` null, const-expression `i64 : M + 2` used as a count + printed,
|
||||
> `f32 : M + 2`, plus mixed-numeric `f64 : M + 0.5` and `f64 : 0.5 + M` folding
|
||||
> to 2.5 in both orders) compile, fold, and print correctly.
|
||||
> - `examples/0163-types-mixed-numeric-promotion.sx` — positive: pins the
|
||||
> inferExprType promotion DIRECTLY in a value context (`print("{}", n + 0.5)`
|
||||
> formats as the float `2.5`, both operand orders, across `+ - * /` and an f32
|
||||
> operand; a pure-int expression stays an integer).
|
||||
> - `src/ir/program_index.test.zig` — `moduleConstInt gates the fold on the
|
||||
> declared type, not the initializer node` (covers both a literal and a
|
||||
> binary_op value node declared with a non-numeric type).
|
||||
> - `src/ir/expr_typer.test.zig` — `arithResultType promotes int×float to the
|
||||
> float regardless of operand order` (the shared promotion helper).
|
||||
|
||||
# 0088 — Typed module const annotation mismatch is accepted
|
||||
|
||||
## Symptom
|
||||
|
||||
A module-level typed constant whose initializer does not match its annotation is
|
||||
accepted. Observed: `N : string : 4` compiles; printing `N` segfaults, and using
|
||||
`N` as an array dimension folds it as `4`. Expected: the const declaration emits
|
||||
a type-mismatch diagnostic and no downstream use treats it as a valid string or
|
||||
integer count.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
N : string : 4;
|
||||
|
||||
main :: () {
|
||||
print("N={}\n", N);
|
||||
}
|
||||
```
|
||||
|
||||
Related count-surface manifestation:
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
N : string : 4;
|
||||
|
||||
main :: () {
|
||||
a : [N]i64 = ---;
|
||||
print("{}\n", a.len);
|
||||
}
|
||||
```
|
||||
|
||||
Observed on `flow/sx-foundation/F0.4` attempt 10: the first repro segfaults in
|
||||
the generated program; the second prints `4`.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Fix issue 0088: typed module constants must validate/coerce their initializer
|
||||
against the explicit annotation before being registered or used. Suspected area:
|
||||
`src/ir/lower.zig`, especially `registerTypedModuleConst`, `lowerExpr`'s
|
||||
module-const identifier path, and any const-declaration lowering that stores
|
||||
`ProgramIndex.module_const_map` entries. `src/ir/program_index.zig`'s
|
||||
`moduleConstInt` currently folds by inspecting the initializer node and ignores
|
||||
`ModuleConstInfo.ty`; after the declaration is diagnosed or represented
|
||||
correctly, a non-integer typed const such as `N : string : 4` must not become a
|
||||
valid count. Likely fix: add a typed-const validation path that emits a clear
|
||||
diagnostic for incompatible initializer/annotation pairs, and ensure the
|
||||
module-const count lookup only accepts constants whose declared/inferred type is
|
||||
numeric and integral-compatible. Verify by running the two repros above: expect
|
||||
a non-zero compile with a type-mismatch diagnostic for `N : string : 4`, no
|
||||
runtime segfault, and no `[N]` length of `4`.
|
||||
@@ -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.
|
||||
@@ -1,132 +0,0 @@
|
||||
# 0090 — integer formatter can't render i64::MIN or unsigned all-ones
|
||||
|
||||
> STATUS: RESOLVED (F0.8). Both extremes now render correctly:
|
||||
> `i64.min` → `-9223372036854775808`, `u64.max` → `18446744073709551615`.
|
||||
>
|
||||
> **Root cause.**
|
||||
> - Symptom 1 (i64::MIN): `std.int_to_string` computed the magnitude as
|
||||
> `0 - n`, which overflows for `i64::MIN` (its magnitude is
|
||||
> unrepresentable as a positive i64) — the value stayed negative, the
|
||||
> `while v > 0` loop ran zero times, and only the `-` was emitted.
|
||||
> - Symptom 2 (unsigned all-ones): `any_to_string`'s `case int:` arm
|
||||
> formatted every integer as i64 (`int_to_string(xx val)`); there was no
|
||||
> way to tell a `u64` from an `i64`, so an all-ones u64 printed as `-1`.
|
||||
>
|
||||
> **Fix per file.**
|
||||
> - `library/modules/std.sx` — `int_to_string` now extracts digits straight
|
||||
> from `n` (taking `|n % 10|` per digit, `n` truncates toward zero) so it
|
||||
> never negates `i64::MIN`. Added `uint_to_string` (unsigned decimal via
|
||||
> long-division-by-10 over four 16-bit limbs) and `decompose_u16x4` (the
|
||||
> shared 16-bit-limb split, now reused by `int_to_hex_string` too).
|
||||
> `any_to_string`'s `case int:` routes through the new
|
||||
> `type_is_unsigned(type)` query to pick the unsigned vs signed formatter.
|
||||
> Declared `type_is_unsigned :: ($T: Type) -> bool #builtin;`.
|
||||
> - `src/ir/types.zig` — `TypeTable.isUnsignedInt` (canonical signedness
|
||||
> predicate; single source of truth).
|
||||
> - `src/ir/inst.zig` — `type_is_unsigned` BuiltinId.
|
||||
> - `src/ir/calls.zig` — register `type_is_unsigned` as a `.bool` reflection
|
||||
> builtin.
|
||||
> - `src/ir/lower.zig` — `tryLowerReflectionCall` arm: static fold +
|
||||
> dynamic `callBuiltin`.
|
||||
> - `src/ir/interp.zig` — interp arm (reads the boxed TypeId / `type_of`
|
||||
> aggregate shape).
|
||||
> - `src/ir/emit_llvm.zig` + `src/backend/llvm/reflection.zig` +
|
||||
> `src/backend/llvm/ops.zig` — lazy `[N x i1]` `__sx_type_is_unsigned`
|
||||
> table built from `isUnsignedInt`; runtime arm GEPs in at the TypeId.
|
||||
>
|
||||
> **Regression test.** `examples/0046-basic-int-formatter-extremes.sx`
|
||||
> pins both extremes plus a width spread (i8/i16/i32 + u8/u16/u32/u64,
|
||||
> mins/maxes, 0, ordinary values). Unit tests: `isUnsignedInt` in
|
||||
> `src/ir/types.test.zig`.
|
||||
>
|
||||
> **Follow-up (F0.8 attempt 2) — strict `$T: Type` on all 7 reflection
|
||||
> builtins.** The stress-review of the additive `type_is_unsigned` builtin
|
||||
> found it (and the whole reflection family) silently accepted a non-type
|
||||
> argument: `type_is_unsigned(6)` reinterpreted `6` as a TypeId index and
|
||||
> returned the signedness of `types[6]` (`u8` → true); `size_of(6)`/`(true)`
|
||||
> sized its `typeof` (8); `type_name(6)` returned `types[6]`'s name.
|
||||
> Per Agra's ruling, all 7 type-introspection builtins — `size_of`,
|
||||
> `align_of`, `field_count`, `type_name`, `type_eq`, `type_is_unsigned`,
|
||||
> `is_flags` — now STRICTLY require a type (compile-time): a value argument
|
||||
> is rejected with `"<builtin> expects a type, got '<type>'"`.
|
||||
> - `src/ir/lower.zig` — one shared guard, `reflectionTypeArgGuard` (run at
|
||||
> the top of `tryLowerReflectionCall`), classifies each arg via
|
||||
> `reflectionArgIsType`: a spelled / compile-time type or generic type
|
||||
> param (the `isStaticTypeArg` shapes), or a runtime `Type` value (static
|
||||
> type `.any` — `type_of(x)`, a `[]Type` element `list[i]`, a `Type`-typed
|
||||
> local / field / param) is ACCEPTED; anything else is rejected. The
|
||||
> existing runtime path for `type_name` / `type_is_unsigned` is preserved
|
||||
> (the formatter calls `type_is_unsigned(type_of(val))` at runtime). The 5
|
||||
> comptime-only builtins stay comptime-only (runtime reflection deferred).
|
||||
> - Negative regression: `examples/1144-diagnostics-reflection-builtin-needs-type.sx`
|
||||
> (reject cases across all 7, exit 1). Unit test: `reflectionArgIsType` in
|
||||
> `src/ir/lower.test.zig`.
|
||||
>
|
||||
> **Follow-up (F0.8 attempt 3) — reflection builtins on an `Any` consult the
|
||||
> Any's runtime TYPE-TAG, not its payload.** The attempt-2 guard correctly
|
||||
> accepts an `Any` argument (the formatter passes `val: Any`), but the dynamic
|
||||
> `type_name` / `type_is_unsigned` path still read the Any's payload as a
|
||||
> TypeId index unconditionally — correct only when the Any holds a *Type
|
||||
> value*. For an Any holding a *value* (`av : Any = 6`, runtime tag `i64`,
|
||||
> payload `6`) it reported `types[6]` (`u8`): `type_name(av)` → `"u8"`,
|
||||
> `type_is_unsigned(av)` → `true`. Per Agra's ruling ("Any is a type AND a
|
||||
> value, so it's expected to work"), both builtins now branch on the Any's
|
||||
> runtime tag: tag `== .any` → the box is a Type value, use the payload as the
|
||||
> TypeId; otherwise the tag IS the held value's type. So `type_name(av)` →
|
||||
> `"i64"`, `type_is_unsigned(av)` → `false`, while `type_name(type_of(x))`
|
||||
> still names the held type. The formatter is unchanged (it already passed
|
||||
> `type_of(val)`, a proper Type value).
|
||||
> - `src/ir/interp.zig` — shared `Value.reflectTypeId` (the tag-branching
|
||||
> resolver); the `type_name` / `type_is_unsigned` interp arms route through
|
||||
> it. `src/backend/llvm/ops.zig` — shared `Ops.reflectArgTypeId` emits
|
||||
> `extractvalue tag` / `icmp eq tag, .any` / `select` for the runtime path;
|
||||
> both reflection arms route through it. The two backends agree.
|
||||
> - Regression: `examples/0164-types-reflection-any-tag.sx` pins `type_name` /
|
||||
> `type_is_unsigned` / `print` on an Any holding a value vs. a Type value.
|
||||
> Unit test: `reflectTypeId` in `src/ir/interp.test.zig`.
|
||||
> - Out of scope (kept comptime-only / deferred): the 5 comptime-only builtins
|
||||
> (`size_of`/`align_of`/`field_count`/`is_flags`/`type_eq`). `type_eq` has no
|
||||
> dynamic emit path (it folds at lower time), so it is unaffected.
|
||||
|
||||
> STATUS (original): OPEN. Pre-existing + orthogonal; surfaced (not introduced) by NL.1.
|
||||
> Manager-verified independent of the numeric-limit accessors. Scheduled separately.
|
||||
|
||||
## Symptom
|
||||
|
||||
`print("{}", x)` mis-renders the integer extremes the i64-based formatter can't
|
||||
represent:
|
||||
- `i64::MIN` (`-9223372036854775808`) prints a bare `-` (the minus sign with NO
|
||||
digits).
|
||||
- An unsigned all-ones value (e.g. `u64.max` = 18446744073709551615) prints `-1`
|
||||
(the i64 bit-reinterpretation), not the unsigned decimal.
|
||||
|
||||
## Reproduction (no numeric-limit accessor needed — pre-existing)
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
main :: () {
|
||||
x := -9223372036854775807 - 1; // i64::MIN
|
||||
print("min={}\n", x); // prints "min=-" (should be -9223372036854775808)
|
||||
}
|
||||
```
|
||||
|
||||
`u64.max` (via the NL.1 accessor, or any all-ones u64) prints `-1` for the same
|
||||
root reason.
|
||||
|
||||
## Root cause (suspected)
|
||||
|
||||
The integer-to-string path is `i64`-based (`std.int_to_string` / the `{}` formatter
|
||||
takes `i64`): it negates the value to print the sign, but `-i64::MIN` overflows, and
|
||||
it has no unsigned-aware path so an all-ones u64 is read as `-1`. Needs a width/
|
||||
signedness-aware integer formatter (format by the value's actual integer TYPE:
|
||||
unsigned types print the unsigned decimal; signed `MIN` is handled without negating).
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Make the `{}` integer formatter type-aware: render an unsigned integer as its
|
||||
unsigned decimal (all 64 bits for u64), and handle signed `MIN` without the
|
||||
`-MIN` overflow (e.g. format the magnitude via unsigned arithmetic, or special-case
|
||||
MIN). Verify: `i64::MIN` prints `-9223372036854775808`; `u64.max` prints
|
||||
`18446744073709551615`; existing numeric output (incl. the NL.1 examples, which
|
||||
assert via bit-reinterpret) stays green. Likely area: the formatter / `int_to_string`
|
||||
in the std print path and/or the comptime `{}` lowering.
|
||||
@@ -1,79 +0,0 @@
|
||||
# 0091 — float `!=` lowers to ORDERED not-equal, so `nan != nan` is false in native code
|
||||
|
||||
> **RESOLVED** (F0.9). Root cause: `emitCmpNe` in `src/backend/llvm/ops.zig`
|
||||
> passed `c.LLVMRealONE` (ordered not-equal) as the float predicate. Fix:
|
||||
> `c.LLVMRealONE` → `c.LLVMRealUNE` (unordered not-equal). The integer predicate
|
||||
> `LLVMIntNE` and `emitCmpEq` (`OEQ`) are unchanged. For all non-NaN operands
|
||||
> `UNE` ≡ `ONE`, so only NaN-involving float `!=` changes (toward correct).
|
||||
> Regression test: `examples/0150-types-float-ne-unordered-nan.sx`. Spec note
|
||||
> added to `specs.md` (Operators → "Float comparison and NaN").
|
||||
|
||||
## Symptom
|
||||
|
||||
The LLVM backend lowers float `!=` to `LLVMRealONE` (ordered not-equal), which
|
||||
returns **false** when either operand is NaN. Consequences:
|
||||
|
||||
- Observed: `nan != nan` evaluates to **false** (via `sx run`).
|
||||
- Expected: **true** — `!=` must be the logical complement of `==`, and the
|
||||
canonical NaN-detection idiom `x != x` must be true for a NaN.
|
||||
|
||||
This makes `==` and `!=` non-complementary for NaN: `nan == nan` is false
|
||||
(correct, `OEQ`) AND `nan != nan` is also false (wrong, `ONE`). It silently
|
||||
breaks the standard NaN check used throughout numerical code
|
||||
(`if x != x { /* NaN */ }`): NaN is never detected at runtime.
|
||||
|
||||
## Reproduction (accessor-free)
|
||||
|
||||
NaN is produced as `0.0 / 0.0` — no numeric-limit accessor required:
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
main :: () {
|
||||
z := 0.0;
|
||||
n := z / z; // NaN
|
||||
print("ne={} eq={}\n", n != n, n == n); // observed: ne=false eq=false
|
||||
} // correct: ne=true eq=false
|
||||
```
|
||||
|
||||
`./zig-out/bin/sx run <repro>.sx` printed `ne=false eq=false` before the fix.
|
||||
After the fix it prints `ne=true eq=false`. Non-NaN comparisons are unchanged
|
||||
(`1.0 != 2.0` true, `1.0 != 1.0` false). The `#run`/comptime path (JIT-compiled
|
||||
through the same backend) and the native runtime path agree in both states.
|
||||
|
||||
## Root cause
|
||||
|
||||
`src/backend/llvm/ops.zig`, `emitCmpNe`:
|
||||
|
||||
```zig
|
||||
pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void {
|
||||
self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealONE);
|
||||
// ^^^^^^^^^^^^^^^ ordered
|
||||
}
|
||||
```
|
||||
|
||||
`LLVMRealONE` = ordered not-equal (false if either operand is NaN). The IEEE/C
|
||||
`!=` is `LLVMRealUNE` (unordered not-equal → true if either is NaN). For all
|
||||
NON-NaN operands `UNE` and `ONE` are identical, so the fix changes behavior only
|
||||
for the NaN case — bringing native codegen in line with `==` (`OEQ`) and with
|
||||
the interpreter's `evalCmp` (`.ne => lf != rf`, which is unordered in Zig).
|
||||
|
||||
`emitCmpNe` is the sole float-`!=` lowering site (dispatched from
|
||||
`src/ir/emit_llvm.zig` `cmp_ne` → `ops().emitCmpNe`). There is no second backend
|
||||
path (no `fcmp one` appears in any `.ir` snapshot; `src/codegen.zig` has no
|
||||
float-`!=` lowering).
|
||||
|
||||
## Fix
|
||||
|
||||
```zig
|
||||
pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void {
|
||||
self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealUNE);
|
||||
}
|
||||
```
|
||||
|
||||
## Regression test
|
||||
|
||||
`examples/0150-types-float-ne-unordered-nan.sx` asserts (runtime, exit 0):
|
||||
`nan != nan` true, `nan == nan` false, `nan != 1.0` true, `nan == 1.0` false,
|
||||
the finite cases (`1.0 != 2.0` true, `1.0 != 1.0` false, `2.0 != 2.0` false),
|
||||
and that the `#run` comptime `nan != nan` matches the runtime one. It fails on
|
||||
the pre-fix compiler (`nan != nan: false`) and passes after.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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 / `<pack>.len` are still resolved by the
|
||||
> int delegation, so only the float-limit case lands here.
|
||||
> - The audit also surfaced a missing `%` arm: the int evaluator folds `.mod` but
|
||||
> the float one did not, so `y : i64 = 5.5 % 2.0` (= 1.5) truncated silently to 1.
|
||||
> `evalConstFloatExpr` now handles `.mod` via `@rem` (matching `evalConstIntExpr`
|
||||
> and codegen's `frem`; `6.0 % 4.0` folds to 2 via the int delegation, `5.5 % 2.0`
|
||||
> = 1.5 is rejected). The two evaluators are now at full leaf/operator parity, so
|
||||
> no compile-time-const float shape escapes the rule at one site while folding at
|
||||
> another. (A comptime-fn returning float is a genuinely new form for BOTH and is
|
||||
> out of scope.)
|
||||
>
|
||||
> Regression tests: `examples/0168-types-integral-float-to-int.sx` (positive —
|
||||
> local/field/param/const fold, integral int-const-EXPRESSION (`M + 2.0`) AND
|
||||
> float-const-LEAF (`F + 1.5`, `F : f64 : 2.5`) fold at local/field/param/const,
|
||||
> `xx`/`cast` truncate incl. `xx (M + 0.5)` / `xx (F + 0.25)`),
|
||||
> `examples/1146-diagnostics-nonintegral-float-to-int.sx` (negative —
|
||||
> non-integral LITERAL, int-const-EXPRESSION (`M + 0.5`), AND float-const-LEAF
|
||||
> (`F + 0.25`) error at local/param/field), the integral-float const cases in
|
||||
> `examples/0162-types-typed-module-const-roundtrip.sx`, and the aligned const
|
||||
> diagnostic in `examples/1143-diagnostics-typed-module-const-mismatch.sx`
|
||||
> (G / BAD / BAD2 stay errors with the new wording). The array-dimension site is
|
||||
> pinned in the same two examples: 0168 adds `[F + 1.5]i64`, `[KF]i64`
|
||||
> (`KF : i64 : F + 1.5`), and a type-alias `ArrFE :: [F + 1.5]i64` all folding to
|
||||
> len 4; 1146 adds `[F + 0.25]i64` erroring; `examples/1132` now expects the
|
||||
> precise non-integral-float dim wording. Unit:
|
||||
> `program_index.test.zig` "evalConstFloatExpr folds comptime float expressions"
|
||||
> (covers the float-const leaf: `F` → 2.5, `F + 0.25` → 2.75, `F + 1.5` → 4.0;
|
||||
> attempt 5 adds the numeric-limit leaf `f64.max`/`f64.true_min`/`f32.epsilon`,
|
||||
> `f64.max - f64.max` → 0, `f64.true_min + 0.5` → 0.5, and the `%` arm `5.5 % 2.0`
|
||||
> → 1.5 / `% 0.0` → null) and "foldCountI64 / foldDimU32 fold an integral float
|
||||
> count, reject a non-integral one" (the count fold + the `non_integral_float` /
|
||||
> `below_min` distinction). Attempt 5 also extends 0168 (positive: `f64.max -
|
||||
> f64.max` → 0, `6.0 % 4.0` → 2, integer-limit `i8.max`/`[u8.max]` unregressed,
|
||||
> `xx` escapes for both new forms) and 1146 (negative: `f64.true_min + 0.5` and
|
||||
> `5.5 % 2.0` error at a binding site).
|
||||
>
|
||||
> **Completion (F0.11 attempt 6)** — attempt 5 reached evaluator parity for
|
||||
> leaves/operators, but a structural hole remained in the SHARED integer folder:
|
||||
> `evalConstIntExpr` accepts an integral float literal/const as an integer leaf
|
||||
> (`[4.0]` → 4) and then applies INTEGER arithmetic to the whole expression — so a
|
||||
> float DIVISION with integral-looking operands (`5.0 / 2.0`) folded as integer
|
||||
> truncating division (`divTrunc(5,2)` = 2) instead of float division (`2.5`). The
|
||||
> bug fired at ALL FIVE sites (`5.0 / 2.0` printed `2` at a typed local, field
|
||||
> default, param default, typed const, and array dimension), because the typed
|
||||
> sites evaluate through `evalConstFloatExpr` (which delegates the whole node to
|
||||
> the int folder) and the count sites through `foldCountI64` (which tries the int
|
||||
> folder first). Closed at the single root: `evalConstIntExpr`'s `.div` arm now
|
||||
> REFUSES to fold a division whose lhs/rhs is float-valued (a new
|
||||
> `isFloatValuedExpr` predicate, resolving a float-typed const leaf through each
|
||||
> ctx's `nameIsFloatTyped`) — so the division surfaces through `evalConstFloatExpr`
|
||||
> (float `/`) + the unified rule: an integral quotient (`6.0 / 2.0` → 3) folds, a
|
||||
> non-integral one (`5.0 / 2.0` = 2.5, mixed `5 / 2.0`, float-const `F / G`)
|
||||
> errors. Genuine integer `/` (`5 / 2` → 2) is unchanged; `*`/`+`/`-` need no guard
|
||||
> (they agree between int and float for the integral operands the int folder ever
|
||||
> sees). Regression: `examples/1147-diagnostics-float-division-narrowing.sx`
|
||||
> (negative — `5.0 / 2.0` errors at all five sites), the integral-`/` positives
|
||||
> added to `examples/0168` (`6.0 / 2.0` local/field, `12.0 / 4.0` const, `[6.0 /
|
||||
> 2.0]` dim, `xx (5.0 / 2.0)` → 2), and unit
|
||||
> `program_index.test.zig` "the int folder refuses a FLOAT division".
|
||||
>
|
||||
> **Completion (F0.11 attempt 7)** — one structural hole survived in the
|
||||
> field-access arm of the SHARED const evaluators: a backtick raw value-shadow
|
||||
> receiver (`` `f64 := FBox.{ epsilon = … } `` then `` `f64.epsilon ``) was
|
||||
> misclassified as the builtin numeric-limit accessor. The sibling
|
||||
> `isFloatValuedExpr` already guards this with an `is_raw` check, but
|
||||
> `evalConstFloatExpr` / `evalConstIntExpr` did NOT — so once the read flowed into
|
||||
> an integer binding, the float folder returned the BUILTIN `f64.epsilon`
|
||||
> (2.22e-16) and the rule wrongly errored ("narrow non-integral float
|
||||
> '0.0000…0002220446049250313'"), and the integer folder turned `` `i8.max `` as an
|
||||
> array dimension into the builtin `127` (a fabricated 127-element array) instead
|
||||
> of an ordinary runtime field read. Closed at the single root: both evaluators'
|
||||
> field-access arms now mirror `isFloatValuedExpr`'s `is_raw` guard — a raw
|
||||
> receiver yields `obj_name = null`, so it is never a numeric-limit/pack leaf and
|
||||
> falls through to the ordinary runtime field read. A raw value-shadow is a
|
||||
> mutable-local field (a subsequent `` `f64.epsilon = 4.0 `` is observable), so it
|
||||
> is genuinely runtime and must not be const-folded: it now behaves EXACTLY like a
|
||||
> plainly-named field read — `` `f64.epsilon `` narrowing into `i64` truncates to
|
||||
> its field value (`11.5` → `11`, identical to `b.epsilon`, NOT a non-integral
|
||||
> error on the builtin limit), and `` `i8.max `` as an array dimension is rejected
|
||||
> as a non-constant count (identical to `b.max`). The bare builtin path is
|
||||
> unchanged (`f64.epsilon`, `i8.max`, `[u8.max]` still fold). Regression:
|
||||
> `examples/0169-types-value-shadow-field-narrowing.sx` (positive — raw float-field
|
||||
> read narrows/truncates, mutation proves runtime, bare limit still folds),
|
||||
> `examples/1148-diagnostics-value-shadow-field-dim-not-const.sx` (negative — raw
|
||||
> int-field dim rejected as non-const), and unit `program_index.test.zig` "a
|
||||
> backtick raw-shadow receiver is a field read, not a numeric-limit fold (F0.11-7)".
|
||||
|
||||
## Symptom
|
||||
A typed LOCAL (and likely typed param/field) silently truncates a floating-point
|
||||
initializer to an integer annotation instead of rejecting or requiring an explicit cast.
|
||||
|
||||
Observed:
|
||||
- `y : i64 = 1.5;` → y == 1 (float literal truncated, no diagnostic)
|
||||
- `y : i64 = 2 + 0.5;` → y == 2 (float-valued expr truncated, no diagnostic)
|
||||
|
||||
Expected: a type-mismatch / narrowing diagnostic (consistent with typed MODULE CONSTS,
|
||||
which after F0.7 reject `N : i64 : 1.5` and `N : i64 : M + 0.5`). Today consts are strict
|
||||
but locals are lenient — an inconsistency.
|
||||
|
||||
## Reproduction
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
main :: () {
|
||||
y : i64 = 1.5;
|
||||
print("{}\n", y); // prints 1
|
||||
}
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
Decide + implement the language rule for implicit float→int narrowing in a TYPED binding
|
||||
(local / param / field) initializer. Module consts already reject it (F0.7,
|
||||
registerTypedModuleConst + typedConstInitFits/constExprInitFits). Make typed-local/param/field
|
||||
assignment-coercion consistent: either reject a non-integral float→int initializer with a
|
||||
diagnostic (matching the const path) or require an explicit `xx`/cast. Suspected area: the
|
||||
assignment / typed-binding coercion path (coerceToType ladder, specs.md §"coercion") in
|
||||
src/ir/lower.zig. Verify `y : i64 = 1.5` errors (or requires a cast); confirm integral-float
|
||||
folding rules (specs.md: `4.0`→4 ok, `4.5` rejected) stay consistent. Then gate.
|
||||
|
||||
## Disposition
|
||||
Discovered during F0.7 (issue 0088) attempt-2 review. Agra ruled F0.7 fixes the
|
||||
inferExprType ROOT for binary-op promotion; this typed-LOCAL narrowing is a SEPARATE
|
||||
assignment-coercion concern -> its own scheduled step.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,78 +0,0 @@
|
||||
# RESOLVED — 0098: enum literal in a non-enum target silently lowers to variant 0
|
||||
|
||||
> **RESOLVED** (2026-06-12). Root cause: `lowerEnumLiteral`
|
||||
> (src/ir/lower/expr.zig) resolved the variant against the RAW
|
||||
> destination type. For any non-enum destination —
|
||||
> an OPTIONAL `?E`, a builtin like `i64`, or no destination at all —
|
||||
> `resolveVariantValue` fell through its switch to the silent
|
||||
> `return 0` tail (the classic silent-fallback-default this repo's
|
||||
> CLAUDE.md forbids), and the `enum_init` was stamped with the WRONG
|
||||
> type (the optional itself / `.unresolved`). Fix: the literal now
|
||||
> unwraps optional destinations and resolves against the CHILD (the
|
||||
> coercion layer's `.optional_wrap` then wraps the well-typed `E`
|
||||
> into `?E`), and every other shape is DIAGNOSED instead of zeroed:
|
||||
> unknown variant of a real enum (with the variant list), non-enum
|
||||
> destination, and destination-less literal (cascade-guarded so a
|
||||
> destination whose type already failed to resolve doesn't double-
|
||||
> report; pre-fix this case PANICKED LLVM emission with an
|
||||
> unresolved type). Regression tests:
|
||||
> `examples/0183-types-enum-literal-optional-target.sx` (return +
|
||||
> assignment + reassignment into `?Enum`, non-zero variants, null
|
||||
> path) and `examples/1169/1170-diagnostics-enum-literal-*.sx`
|
||||
> (each refusal); all three FAIL on pre-fix master. Gates:
|
||||
> `zig build test` 426/426, `tests/run_examples.sh` 598/598.
|
||||
|
||||
## Symptom
|
||||
|
||||
An enum LITERAL whose destination is not literally the enum type silently
|
||||
lowers to variant 0 (or worse), with no diagnostic.
|
||||
|
||||
- **Observed**: `return .android_apk;` from a `-> ?Platform` function is
|
||||
seen by the caller as `.ios` (variant 0) or even `null`, depending on
|
||||
the optional's layout. `x : i64 = .foo;` compiles and `x == 0`.
|
||||
`x : Platform = .nonexistent;` compiles to variant 0. `v := .ios;`
|
||||
panics LLVM emission ("unresolved type reached LLVM emission").
|
||||
- **Expected**: the optional case works (resolve against the child, wrap);
|
||||
every unresolvable case is a compile error.
|
||||
|
||||
Hit in production: /Users/agra/projects/distribution
|
||||
`src/server/distd.sx` `ua_platform` (2026-06-12) — every User-Agent
|
||||
"detected" as iOS because each `return .<variant>;` into `?Platform`
|
||||
lowered to 0. The shipped workaround routed through a typed local
|
||||
(`p : Platform = .android_apk; return p;`).
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
Platform :: enum u8 { ios; android_apk; macos; linux; windows; }
|
||||
|
||||
classify :: (n: i64) -> ?Platform {
|
||||
if n == 1 { return .android_apk; } // BUG: caller observes variant 0 / null
|
||||
return null;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
p := classify(1);
|
||||
if p == null { return 1; }
|
||||
if p! == .android_apk { return 0; }
|
||||
return 2;
|
||||
}
|
||||
```
|
||||
|
||||
Observed at master d8076b9: exits 1 (null). Expected: exits 0.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Suspected area: `src/ir/lower/expr.zig` `lowerEnumLiteral` /
|
||||
`resolveVariantValue`. The literal resolves against
|
||||
`self.target_type` verbatim; an optional target isn't unwrapped, so
|
||||
`resolveVariantValue`'s `switch` misses and returns 0, and the
|
||||
`enum_init` carries the optional TypeId itself. Fix: unwrap optional
|
||||
layers to the child enum before resolving (then the existing
|
||||
`.optional_wrap` coercion handles `E` → `?E`), and emit diagnostics
|
||||
for unknown variants / non-enum destinations / no destination instead
|
||||
of the silent-zero tail. Verification: the repro exits 0; the
|
||||
diagnostics cases each error; `zig build test` and
|
||||
`tests/run_examples.sh` green; pin the repro as examples.
|
||||
@@ -1,100 +0,0 @@
|
||||
# 0099 — LSP analyzer panics on an identifier array dimension (`[MAX]u8`)
|
||||
|
||||
**RESOLVED.** The editor analyzer (`src/sema.zig`) read the array-dimension node's
|
||||
`.int_literal` union field unconditionally. When the dimension is a **named const**
|
||||
(`[MAX]u8`), the `.length` node is an `identifier`, so the access tripped Zig's
|
||||
checked-union panic and `sx lsp` aborted (`SIGABRT`) the moment the file was opened
|
||||
(didOpen → `analyzeDocument`). The main compiler (`sx run`) was never affected — it
|
||||
resolves `[MAX]u8` through the IR's `foldArrayDim` machinery.
|
||||
|
||||
## Symptom
|
||||
|
||||
- **Observed:** opening a buffer with a named-const dimension in an editor crashes the
|
||||
language server: `src/sema.zig` aborts with
|
||||
`access of union field 'int_literal' while field 'identifier' is active`.
|
||||
- **Expected:** the analyzer resolves `[MAX]u8` to length 4 (or, when it can't fold the
|
||||
dimension, records an explicit "unknown" length) and never crashes.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
MAX :: 4;
|
||||
Thing :: struct { buf: [MAX]u8; }
|
||||
```
|
||||
|
||||
`sx run` compiles this fine; `sx lsp` aborts on didOpen. (The crash is reached via
|
||||
`registerTopLevelDecl` → `fieldType` → `resolveTypeNode` while building the editor
|
||||
index — not a diagnostic pass.)
|
||||
|
||||
## Root cause
|
||||
|
||||
`Analyzer.resolveTypeNode` in `src/sema.zig`, the `array_type_expr` arm:
|
||||
|
||||
```zig
|
||||
const length: u32 = @intCast(ate.length.data.int_literal.value);
|
||||
```
|
||||
|
||||
`ate.length` is an arbitrary expression node. For a literal `[64]u8` it is
|
||||
`int_literal`; for `[MAX]u8` it is `identifier`. The code assumed `int_literal`
|
||||
unconditionally — a textbook unchecked-union-field access. `@intCast` of the
|
||||
non-existent payload never even runs; the union-field read panics first.
|
||||
|
||||
## Fix
|
||||
|
||||
- New helper `Analyzer.arrayDimLength(len_node)` switches on the dimension node's tag:
|
||||
`int_literal` → its value; `identifier` → the value recorded for that name in a new
|
||||
`const_int_values` registry (populated when a top-level `const_decl` has an
|
||||
`int_literal` value); anything else (unknown name, non-const expression) → `null`. A
|
||||
value outside `u32` range also yields `null`. No node shape is ever assumed without a
|
||||
tag check, so the path cannot panic.
|
||||
- `Type.ArrayTypeInfo.length` changed from `u32` to `?u32`. `null` is the explicit
|
||||
"editor couldn't fold this dimension" marker — never a fabricated concrete length
|
||||
(this is hover/metadata, not codegen). `displayName` renders an unknown length as
|
||||
`[_]T`.
|
||||
- `src/sema.zig` `substType` passes the (now optional) length through unchanged; the
|
||||
only other consumer of the editor `ArrayTypeInfo` is `displayName`, updated above.
|
||||
|
||||
The named-const value is captured at registration time, so resolution shares the
|
||||
analyzer's existing intra-pass forward-reference limitation: a const declared *after* the
|
||||
struct that uses it resolves to the safe "unknown" length rather than its value. This
|
||||
matches how the analyzer already treats a struct field that references a later-declared
|
||||
struct type, and never crashes.
|
||||
|
||||
## Sibling audit (`resolveTypeNode` / `fieldType` family)
|
||||
|
||||
Audited every arm for the same unchecked `.data.<field>` pattern. The array dimension
|
||||
was the **only** crashing site:
|
||||
|
||||
- `slice_type_expr`, `optional_type_expr`, `pointer_type_expr`,
|
||||
`many_pointer_type_expr`, `function_type_expr` — all recurse through
|
||||
`resolveTypeNode`, which dispatches on the node tag; no raw field access.
|
||||
- `parameterized_type_expr` returns `.void_type`; `type_expr` / `identifier` read
|
||||
`.name` / `.is_raw` only *after* `tn.data == .type_expr or tn.data == .identifier`.
|
||||
- `fieldType`, `typeExprName`, `typeExprIsRaw`, `resolveTypeAnnotation`,
|
||||
`resolveTypeRef` all tag-check (switch capture or explicit `==`) before any payload
|
||||
read; `resolveTypeAnnotation`'s `array_type_expr` case delegates to `resolveTypeNode`,
|
||||
so it is fixed transitively.
|
||||
|
||||
**Non-crashing display gap noted (not fixed here — out of scope for A):**
|
||||
`src/lsp/server.zig` (~line 2801) renders a struct's array field in hover/completion
|
||||
detail directly off the AST. It already guards `if (ate.length.data == .int_literal)`,
|
||||
so it does **not** crash, but it renders a named-const dimension as an empty `[]u8`
|
||||
instead of `[4]u8`. Tracked for the broad LSP `.data` sweep (next step B), not addressed
|
||||
in this crash fix.
|
||||
|
||||
## Regression
|
||||
|
||||
First `src/lsp/*.test.zig` — establishes the minimal LSP test harness
|
||||
(`src/lsp/document.test.zig`), wired into the `zig build test` graph via
|
||||
`src/root.zig`'s `lsp.document_tests` reference. Tests drive the real didOpen path
|
||||
(`DocumentStore.analyzeDocument`) and inspect the editor index:
|
||||
|
||||
- `[MAX]u8` (named const) → no panic; folded length is `4`, element `u8`.
|
||||
- `[64]u8` (int literal) → length `64` (happy-path guard).
|
||||
- `[N]u8` (undeclared name) → explicit unknown length (`null`), no panic, no fabricated
|
||||
value.
|
||||
|
||||
Verified fail-before / pass-after: with the pre-fix unconditional `.int_literal`
|
||||
dereference restored, the identifier test aborts with the exact original panic; with the
|
||||
tag-switching fix it passes. Full gate green: `zig build`, `zig build test` (incl. the
|
||||
new LSP test), `bash tests/run_examples.sh` (453 passed, 0 failed).
|
||||
@@ -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).
|
||||
@@ -1,76 +0,0 @@
|
||||
# 0101 — postfix `!` chained with `.field` miscompiles
|
||||
|
||||
**RESOLVED.** A postfix optional force-unwrap chained directly with a member
|
||||
access — `opt!.field` — read garbage instead of the field, while the
|
||||
bind-first form (`v := opt!; v.field`) was correct. Sibling chains shared the
|
||||
bug: `opt!.method()` failed to resolve the method at all (`error: unresolved
|
||||
'<method>'`), and `opt!.a.b` / `opt![i]` were affected the same way.
|
||||
|
||||
**Symptom** — observed vs expected:
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
S :: struct { id: string; n: i64; }
|
||||
mk :: () -> ?S { return S.{ id = "hello", n = 42 }; }
|
||||
main :: () {
|
||||
print("chained: {}\n", mk()!.id); // observed: garbage (e.g. 8362783136)
|
||||
v := mk()!; print("bind: {}\n", v.id); // observed: "hello" (correct)
|
||||
}
|
||||
```
|
||||
|
||||
Expected: `mk()!.id` prints `hello` (same as the bind-first form).
|
||||
|
||||
**Root cause** (`src/ir/expr_typer.zig`, `ExprTyper.inferType`): the AST-level
|
||||
type-inference switch had **no `.force_unwrap` arm**, so `mk()!` typed as
|
||||
`.unresolved` (the `else` fallback). `lowerForceUnwrap` lowers the unwrap to a
|
||||
correctly-typed value, so the *bind* form works — `v := mk()!` stores that
|
||||
typed Ref into a slot and `v.field` reads it back. But the *chained* form never
|
||||
materializes a slot: `lowerFieldAccess` re-derives the receiver type via
|
||||
`inferExprType(fa.object)` (= `inferExprType(mk()!)`), got `.unresolved`, and
|
||||
the struct-field lookup on `.unresolved` failed — `mk()!.id` was typed
|
||||
`.unresolved`/`i64` and its value emitted as `undef` (the print monomorphized
|
||||
`pack_i64` with `i64 undef`, surfacing as a stale stack address). The method
|
||||
chain failed for the same reason: receiver typing returned `.unresolved`, so
|
||||
method resolution found nothing.
|
||||
|
||||
**Fix** (`src/ir/expr_typer.zig:92`): add a `.force_unwrap` arm to
|
||||
`ExprTyper.inferType` that resolves the operand's optional child type (mirrors
|
||||
`lowerForceUnwrap`'s `resolveOptionalInner`):
|
||||
|
||||
```zig
|
||||
.force_unwrap => |fu| blk: {
|
||||
const opt_ty = self.l.inferExprType(fu.operand);
|
||||
if (!opt_ty.isBuiltin()) {
|
||||
const info = self.l.module.types.get(opt_ty);
|
||||
if (info == .optional) break :blk info.optional.child;
|
||||
}
|
||||
break :blk .unresolved;
|
||||
},
|
||||
```
|
||||
|
||||
This is the single root cause for every chained form — field, nested field,
|
||||
method call, and index all route their receiver type through `inferExprType`.
|
||||
One arm fixes all of them.
|
||||
|
||||
**Reproduction** (standalone, std-only):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
Inner :: struct { tag: string; k: i64; }
|
||||
S :: struct {
|
||||
id: string; n: i64; inner: Inner;
|
||||
greet :: (self: *S) -> string { return self.id; }
|
||||
}
|
||||
mk :: () -> ?S { return S.{ id = "hello", n = 42, inner = Inner.{ tag = "deep", k = 7 } }; }
|
||||
main :: () {
|
||||
print("{}\n", mk()!.id); // pre-fix: garbage; post-fix: hello
|
||||
print("{}\n", mk()!.n); // pre-fix: garbage; post-fix: 42
|
||||
print("{}\n", mk()!.greet()); // pre-fix: error unresolved 'greet'; post-fix: hello
|
||||
print("{}\n", mk()!.inner.tag); // post-fix: deep
|
||||
}
|
||||
```
|
||||
|
||||
**Regression**: `examples/0905-optionals-unwrap-field-chain.sx` exercises
|
||||
`opt!.field` (string + int field, chained vs bind-first), `opt!.method()`
|
||||
(pointer + value receiver), nested `opt!.a.b`, and `opt![i]`. Garbage / compile
|
||||
error on pre-fix code; all correct after.
|
||||
@@ -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).
|
||||
@@ -1,172 +0,0 @@
|
||||
# 0106 — namespaced-import internal names are silently bare-visible (over-permissive `isNameVisible`)
|
||||
|
||||
> **RESOLVED** (flow stdlib/B attempt-3 — root fix, no exemption). Two coupled
|
||||
> changes:
|
||||
>
|
||||
> 1. **Tightened bare visibility to the flat edge set.** `isNameVisible` /
|
||||
> `isCImportVisible` now route through the unified `isVisible` predicate over
|
||||
> `user_bare_flat` / `c_import_bare` (both join over `flat_import_graph`, not
|
||||
> `import_graph`). A namespaced-only import's internal name is no longer
|
||||
> bare-visible — face #1 now errors `'<name>' is not visible; #import the
|
||||
> module that declares it`.
|
||||
> 2. **Pin the defining-module context during pack/comptime monomorphization.**
|
||||
> The flat tightening alone broke `std.print` / `log.*`: a library metaprogram's
|
||||
> body (`#insert build_format(fmt)` comptime call + the `#insert "out(result);"`
|
||||
> inserted statement) was lowered under the CALL SITE's `current_source_file`,
|
||||
> so its bare names (`build_format`, `out`, `emit`) were policed against the
|
||||
> consumer's imports. **Root cause:** `monomorphizePackFn` (bare `print` /
|
||||
> `format`) and `lowerComptimeCall` (namespaced `std.print` / `log.*`, reached
|
||||
> via the field-access `hasComptimeParams` branch) lower the metaprogram body
|
||||
> without pinning the source context — unlike a normal function, which lowers
|
||||
> via `lowerFunctionBodyInto` pinning `func.source_file`. **Fix:** both paths
|
||||
> now save/set/restore `current_source_file` to the body's DEFINING module
|
||||
> before lowering the body (the call-site ARGS are lowered first, in the
|
||||
> caller's context, which is correct). The defining path is stamped onto each
|
||||
> function body node by `resolveImports` (`stampFnBodySource`, mirroring how a
|
||||
> declared function carries `Function.source_file`). So the metaprogram's bare
|
||||
> `build_format` / `out` / `emit` resolve in `std.sx` / `log.sx` naturally —
|
||||
> and a USER's `#insert <expr>` is still checked in the USER's context, so a
|
||||
> bare reach into a namespaced-only import there errors. **No `#insert`
|
||||
> exemption** (attempt-2's `in_insert_expansion` flag is deleted): the fix is
|
||||
> the absence of an exemption, not a narrower one.
|
||||
> 3. **Substituted caller `$`-args resolve in the CALLER's context** (attempt-5).
|
||||
> The point-2 defining-module pin covers the metaprogram body's OWN code only.
|
||||
> A caller-provided comptime `$`-arg (e.g. a caller-owned helper passed to an
|
||||
> imported metaprogram) is spliced into the body by `substituteComptimeNodes`;
|
||||
> those nodes are CALLER-authored and must resolve in the caller's visibility
|
||||
> context, not the callee's. **Fix:** the `$`-arg node is stamped with the
|
||||
> caller's `source_file` at the `cpn` build site (`lowerComptimeCall` /
|
||||
> `monomorphizePackFn`, `stampCallerSource`), and `lowerExpr` switches
|
||||
> `current_source_file` to a node's `source_file` when present — so the
|
||||
> substituted subtree resolves against the caller while the surrounding callee
|
||||
> code keeps the defining-module pin. Regression:
|
||||
> `examples/0738-modules-comptime-arg-caller-context.sx` (caller-owned helper
|
||||
> as a comptime-only `$`-arg through a namespaced import; fail-before
|
||||
> "'caller_name' is not visible" → pass-after "hello world").
|
||||
>
|
||||
> Root cause: `isNameVisible` walked `import_graph` (flat AND namespaced edges)
|
||||
> where a bare name should join only over `flat_import_graph`; and the pack /
|
||||
> comptime monomorphizers lowered the metaprogram body under the wrong source
|
||||
> context.
|
||||
> Regressions: `examples/0736-modules-namespaced-only-bare-not-visible.sx` (+
|
||||
> `0736-…/a.sx`) — face #1 pinned (exit 1 + the stderr);
|
||||
> `examples/0737-modules-insert-bare-not-visible.sx` (+ `0737-…/a.sx`) — a USER
|
||||
> `#insert secret()` into a namespaced-only import errors (fail-before exit 0 on
|
||||
> the attempt-2 exemption / pass-after exit 1). Face #2 restored WITHOUT an
|
||||
> exemption: `examples/0015 / 0700 / 0718 / 1030` pass again (`run_examples`
|
||||
> 471 → 474, incl. the attempt-5 caller-context regression `0738`). Fix in `src/ir/lower.zig` (`monomorphizePackFn` +
|
||||
> `lowerComptimeCall` source-context pin; exemption removed) + `src/imports.zig`
|
||||
> (`stampFnBodySource`) + `src/ir/resolver.zig` (`VisibilityMode` modes, landed in
|
||||
> attempt-1).
|
||||
|
||||
**Symptom.** A bare reference to a top-level name authored in a module that the
|
||||
consumer imports **only namespaced** (`ns :: #import "m.sx"`) is silently
|
||||
visible from the consumer. Observed: it compiles + runs. Expected: an error —
|
||||
the name is reachable only as `ns.name`. Root: `Lowering.isNameVisible` walks
|
||||
`program_index.import_graph`, which records BOTH flat (`#import`) and namespaced
|
||||
(`ns :: #import`) edges; bare-name visibility should join only over FLAT edges
|
||||
(`flat_import_graph`). This is the latent 0102-family visibility bug the Phase B
|
||||
caller-mode audit (unified-resolver R5) was told to surface.
|
||||
|
||||
This **directly gates flow `stdlib/B`**: that step requires migrating
|
||||
`isNameVisible`/`isCImportVisible` to the resolver's `user_bare_flat`/
|
||||
`c_import_bare` modes (which walk `flat_import_graph`) **byte-identically**.
|
||||
Switching the edge set drops `run_examples` from 471 → 467 (see face #2), so the
|
||||
byte-identical requirement cannot hold until this bug is fixed.
|
||||
|
||||
## Reproduction — face #1 (user-facing over-permissiveness)
|
||||
|
||||
```sx
|
||||
// m.sx
|
||||
secret :: () -> i64 { 7 }
|
||||
```
|
||||
|
||||
```sx
|
||||
// main.sx
|
||||
m :: #import "m.sx";
|
||||
main :: () -> i32 {
|
||||
x := secret(); // bare; `secret` is only namespaced-imported as `m.secret`
|
||||
0
|
||||
}
|
||||
```
|
||||
|
||||
- **Observed** (current master): compiles, runs, exit `0` — bare `secret`
|
||||
wrongly resolves to `m`'s author.
|
||||
- **Expected**: `error: 'secret' is not visible; #import the module that
|
||||
declares it` (it is reachable only as `m.secret`).
|
||||
|
||||
(With the Phase-B edge-set change applied — `isNameVisible` over
|
||||
`flat_import_graph` — this repro correctly errors, confirming the diagnosis.)
|
||||
|
||||
## Reproduction — face #2 (library comptime entanglement: why a naive fix breaks std)
|
||||
|
||||
```sx
|
||||
// main.sx
|
||||
std :: #import "modules/std.sx";
|
||||
main :: () -> i32 {
|
||||
std.print("hello\n"); // legit qualified call
|
||||
0
|
||||
}
|
||||
```
|
||||
|
||||
`print` in `std.sx` is a comptime metaprogram:
|
||||
|
||||
```sx
|
||||
print :: ($fmt: string, ..$args) {
|
||||
#insert build_format(fmt); // comptime call to a std-internal fn
|
||||
#insert "out(result);"; // inserts a bare call to a std-internal fn
|
||||
}
|
||||
```
|
||||
|
||||
The comptime call to `build_format` and the inserted `out(result)` are bare
|
||||
names **authored in std.sx**, but they are visibility-checked in the
|
||||
**consumer's** `current_source_file` context (comptime / `#insert` expansion
|
||||
happens at the call site). Today they pass only because `import_graph[consumer]`
|
||||
contains the namespaced `std` edge. Tightening bare visibility to
|
||||
`flat_import_graph` makes them error
|
||||
(`'build_format' / 'out' is not visible`). The same shape breaks `log` (`emit`).
|
||||
Affected examples when the edge set is switched to flat:
|
||||
`0015-basic-demo`, `0700-modules-import`, `0718-modules-cli-exit-json`,
|
||||
`1030-errors-log-and-comptime` (467/471).
|
||||
|
||||
## Root cause (suspected area)
|
||||
|
||||
- `Lowering.isNameVisible` / `isCImportVisible` — `src/ir/lower.zig` (~1768-1840
|
||||
after the Phase-B refactor; `visibleOverEdges` / `nameVisibleOverEdges`). The
|
||||
cross-module join uses `import_graph` (flat **and** namespaced edges) where it
|
||||
should use `flat_import_graph` for a bare name.
|
||||
- Comptime / `#insert` expansion context: the inserted/comptime-evaluated bare
|
||||
calls of a namespaced module's function are policed against the **consumer's**
|
||||
imports, not the **defining module's** own scope. The existing visibility
|
||||
check already exempts UFCS-alias rewrites and mangled local names as "compiler
|
||||
indirections" (`lower.zig` call site ~7284, identifier site ~3237); inserted /
|
||||
comptime-generated bare calls are the same kind of indirection and are not
|
||||
yet exempt — or, equivalently, the expansion should restore the defining
|
||||
module's `current_source_file`.
|
||||
|
||||
## Investigation prompt (paste into a fresh session)
|
||||
|
||||
> Fix issue 0106: `isNameVisible` over-permits bare references to a
|
||||
> namespaced-only import's internal names. Two coupled changes, in this order:
|
||||
>
|
||||
> 1. **Library-internal context.** Ensure a namespaced/comptime-expanded
|
||||
> function's body — including `#insert`ed statements and comptime calls like
|
||||
> `build_format` inside `std.print` — is visibility-checked in its **defining
|
||||
> module's** context, OR exempt compiler-generated / `#insert`ed bare calls
|
||||
> from the visibility check (mirror the UFCS-alias / mangled-name exemptions
|
||||
> at `src/ir/lower.zig` ~7284 and ~3237). Verify with the face-#2 repro and
|
||||
> examples `0015 / 0700 / 0718 / 1030`.
|
||||
> 2. **Tighten bare visibility to flat.** Change `isNameVisible` (and the
|
||||
> `c_import_bare` fall-through of `isCImportVisible`) to join over
|
||||
> `flat_import_graph` instead of `import_graph` — i.e. route them through the
|
||||
> resolver's `user_bare_flat` / `c_import_bare` modes (this is exactly Phase B
|
||||
> of the unified-resolver R5 plan; `src/ir/resolver.zig` already defines the
|
||||
> modes and `Lowering.visibleOverEdges` already takes a `.flat` / `.all`
|
||||
> selector). Verify the face-#1 repro now errors.
|
||||
>
|
||||
> Acceptance: the face-#1 repro errors ("not visible"); `bash tests/run_examples.sh`
|
||||
> is back to 471 `ok` with bare visibility on the flat edge set; add the face-#1
|
||||
> repro as a pinned regression (`issues/0106-…` with an `expected/` marker, or
|
||||
> promote to `examples/07xx-modules-…`). Suspected files: `src/ir/lower.zig`
|
||||
> (visibility + comptime/#insert expansion context), `src/ir/resolver.zig`
|
||||
> (`user_bare_flat` / `c_import_bare`).
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -1,139 +0,0 @@
|
||||
# RESOLVED — 0111: unannotated int-literal locals adopt the enclosing fn's return type
|
||||
|
||||
**Root cause:** `lowerFunctionBodyInto` sets `self.target_type = ret_ty` for the whole
|
||||
body (for the implicit trailing return), the `.int_literal` arm adopts any integer
|
||||
`target_type`, and the unannotated paths of `lowerVarDecl` / `lowerDestructureDecl`
|
||||
lowered their initializer without clearing it — so `x := 0` in a `-> i32`/`-> i8`
|
||||
function was typed i32/i8 and `big := 3000000000` silently wrapped.
|
||||
|
||||
**Fix:** both decl paths now save/clear/restore `target_type` around the initializer
|
||||
`lowerExpr` (`src/ir/lower/stmt.zig`); a declaration without annotation provides no
|
||||
target, so literals take their spec defaults (i64/f64). Trailing-expression and
|
||||
`return` coercion to the return type are untouched.
|
||||
|
||||
**Regression test:** `examples/0173-types-int-literal-default-i64.sx` (f.x/g.x/main.x,
|
||||
destructured a/b all print `i64`; `big` prints `3000000000`).
|
||||
|
||||
**Note (not fixed here):** the `.int_literal` arm still wraps a literal that does not
|
||||
fit an explicitly-annotated integer target (`x : i8 = 300`) with no diagnostic —
|
||||
filed separately as issue 0112.
|
||||
|
||||
---
|
||||
|
||||
# 0111 — unannotated int-literal locals adopt the enclosing fn's return type (silent narrowing + wraparound)
|
||||
|
||||
**Symptom.** A local declared `x := <int literal>` (no annotation) inside a
|
||||
function whose return type is an integer type T gets typed as **T**, not the
|
||||
spec'd default. `f :: () -> i32 { x := 0; ... }` gives `x: i32`;
|
||||
`g :: () -> i8 { x := 0; ... }` gives `x: i8`. Expected (specs.md §"Integer
|
||||
literals default to `i64`", lines 240 / 1428): `i64` in all of these — the
|
||||
declaration has no target type. Inside a `-> void` function the same decl
|
||||
correctly infers `i64`.
|
||||
|
||||
Consequences are silent and severe:
|
||||
|
||||
- All arithmetic through such locals wraps at the narrowed width:
|
||||
`x := 0; x += 3000000000;` inside `main :: () -> i32` prints `-1294967296`.
|
||||
- A large literal initializer truncates with **no diagnostic**:
|
||||
`big := 3000000000;` inside a `-> i32` fn binds `big` to i32 = `-1294967296`.
|
||||
- Blast radius: every `main :: () -> i32 { ... }` in the corpus types every
|
||||
unannotated int-literal local as i32 — long-running counters/accumulators
|
||||
in such functions are one overflow away from wrong results. Discovered
|
||||
because issue 0109's verification loop (`sum` over 1M iterations in a
|
||||
`-> i32` main) printed the 32-bit-wrapped sum after the segfault was fixed.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
f :: () -> i32 {
|
||||
x := 0;
|
||||
print("f.x: {}\n", type_name(type_of(x)));
|
||||
0
|
||||
}
|
||||
|
||||
g :: () -> i8 {
|
||||
x := 0;
|
||||
print("g.x: {}\n", type_name(type_of(x)));
|
||||
0
|
||||
}
|
||||
|
||||
big_host :: () -> i32 {
|
||||
big := 3000000000;
|
||||
print("big: {} = {}\n", type_name(type_of(big)), big);
|
||||
0
|
||||
}
|
||||
|
||||
main :: () {
|
||||
f();
|
||||
g();
|
||||
big_host();
|
||||
x := 0;
|
||||
print("main.x: {}\n", type_name(type_of(x)));
|
||||
}
|
||||
```
|
||||
|
||||
- **Observed** (current master): `f.x: i32`, `g.x: i8`,
|
||||
`big: i32 = -1294967296`, `main.x: i64`.
|
||||
- **Expected**: `i64` for all four; `big` prints `3000000000`.
|
||||
|
||||
Repro co-located: `issues/0111-int-literal-local-adopts-fn-return-type.sx`
|
||||
(standalone version of the above; unpinned until fixed).
|
||||
|
||||
## Root cause (traced)
|
||||
|
||||
Three-link chain, all confirmed by reading:
|
||||
|
||||
1. `src/ir/lower/decl.zig` ~2149 (`lowerFunctionBodyInto`): before lowering
|
||||
the body, `self.target_type = ret_ty` is set for the WHOLE body — intended
|
||||
for the implicit trailing-return coercion — and only restored after the
|
||||
body finishes.
|
||||
2. `src/ir/lower/expr.zig` ~1499 (`.int_literal` arm): an int literal adopts
|
||||
`self.target_type` whenever it `isIntEx` — with no fits-check, so a
|
||||
too-big literal wraps silently.
|
||||
3. `src/ir/lower/stmt.zig` ~335-348 (`lowerVarDecl`, unannotated path):
|
||||
lowers the initializer with whatever `target_type` is in scope and takes
|
||||
the decl's type from the lowered ref. The annotated path overwrites
|
||||
`target_type` with the annotation (which is why `y : i64 = 0` is immune);
|
||||
the unannotated path inherits the function-return context it should never
|
||||
see.
|
||||
|
||||
## Investigation prompt (paste into a fresh session)
|
||||
|
||||
> Fix issue 0111: unannotated int-literal locals adopt the enclosing
|
||||
> function's integer return type instead of the i64 default. Root cause chain
|
||||
> is in the issue (decl.zig ~2149 body-wide `target_type = ret_ty`;
|
||||
> expr.zig ~1499 `.int_literal` adopting it; stmt.zig ~335 unannotated
|
||||
> `lowerVarDecl` not clearing it). Fix shape: in `lowerVarDecl`'s
|
||||
> unannotated path, save `self.target_type`, set it to `null` around the
|
||||
> `lowerExpr(val)` of the initializer, restore after — a declaration without
|
||||
> annotation provides no target. Audit the sibling statement positions that
|
||||
> also shouldn't inherit the return-type context (e.g. expression statements,
|
||||
> `lowerMultiAssign` / `lowerDestructureDecl` unannotated paths) and apply the
|
||||
> same clear where applicable. Do NOT touch the trailing-expression /
|
||||
> `return` paths — `f :: () -> i32 { 0 }` must keep coercing the tail to the
|
||||
> return type.
|
||||
>
|
||||
> Separately consider (same fix or follow-up per scope): the `.int_literal`
|
||||
> arm wraps a literal that doesn't fit the adopted integer target with no
|
||||
> diagnostic — add a fits-check that errors like the float-narrowing rule
|
||||
> (`floatToIntExact` precedent) instead of silently truncating.
|
||||
>
|
||||
> Verify: run `issues/0111-int-literal-local-adopts-fn-return-type.sx` —
|
||||
> expect `i64` for f.x / g.x / big / main.x and `big = 3000000000`. Then
|
||||
> `zig build && zig build test && bash tests/run_examples.sh` against
|
||||
> EXISTING snapshots: any diff means an example was silently relying on
|
||||
> narrowed locals — review each (the change is user-visible only via
|
||||
> type_name/overflow behavior). Promote the repro per the resolution flow
|
||||
> (`examples/01xx-types-...` block) and mark this issue RESOLVED.
|
||||
>
|
||||
> Context: this bug BLOCKS the in-flight fix session for issues 0108/0109/
|
||||
> 0110 (for-loop codegen resource bugs). The 0109 emitter change (entry-block
|
||||
> alloca hoisting) is already applied in the working tree, builds, and fixes
|
||||
> both 0109 repros — but its regression example (1M-iteration `sum`
|
||||
> accumulation in `main :: () -> i32`) cannot produce the documented expected
|
||||
> output (`sum=499999500000`) until locals stop narrowing to i32, and
|
||||
> suite/.ir snapshot regen must not run on a compiler with this live
|
||||
> miscompile. After 0111 lands, resume: finalize 0109 (suite + .ir review +
|
||||
> regression example + commit), then 0110, then 0108 per their issue files.
|
||||
@@ -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-...`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -1,62 +0,0 @@
|
||||
# 0117 — indexing through a pointer-to-array panics at LLVM emission
|
||||
|
||||
> **RESOLVED** (2026-06-11). `ptrToArrayElem` on Lowering recognises the
|
||||
> `*[N]T` receiver; the index READ path GEPs the pointee array through the
|
||||
> pointer value and loads the element; the WRITE / compound-assign /
|
||||
> lvalue / addr-of-element paths and the expression typer resolve the
|
||||
> element type through the same helper (their GEP machinery already
|
||||
> handled a pointer base). Postfix deref (`p.*`) was always the deref
|
||||
> spelling — the issue's `(*p)[2]` note was a wrong-syntax red herring.
|
||||
> Regression test: examples/0176-types-pointer-to-array-index.sx
|
||||
> (read, write, compound, element pointer + deref).
|
||||
|
||||
**Symptom.** Indexing through a `*[N]T` pointer is neither lowered nor
|
||||
diagnosed: the index expression reaches the LLVM emitter with the
|
||||
`.unresolved` type sentinel and trips the panic tripwire.
|
||||
|
||||
- **Observed**: `panic: unresolved type reached LLVM emission`
|
||||
(src/backend/llvm/types.zig:175, via `emitIndexGet`).
|
||||
- **Expected**: either `p[2]` auto-derefs the pointer-to-array (load the
|
||||
pointer, GEP into the array — mirroring the field-access auto-deref on
|
||||
struct pointers), or a clean diagnostic if indexing through `*[N]T` is
|
||||
out of spec. Never an emit-time panic.
|
||||
|
||||
Pre-existing — reproduces on plain locals with no module-level features
|
||||
involved (found while pinning `@K` reads for PLAN-CONST-AGG step 1; the
|
||||
same panic fires for `@<var-decl global array>` and `@<array const>`).
|
||||
|
||||
Also note: the explicit-deref spelling `(*p)[2]` does not parse as a
|
||||
deref — it lowers as `unknown_expr` ("unresolved 'unknown_expr'"), so
|
||||
there is no working spelling for reading an element through a
|
||||
pointer-to-array.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () {
|
||||
k : [4]i64 = .[11, 22, 33, 44];
|
||||
p := @k; // *[4]i64
|
||||
print("{}\n", p[2]); // expected 33; panics at emission today
|
||||
}
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
`emitIndexGet` receives an `index_get` whose result type is `.unresolved`
|
||||
— the lowering of `index_expr` over a receiver of pointer-to-array type
|
||||
produces no deref. Suspected area: the index lowering in
|
||||
src/ir/lower/expr.zig (`lowerIndexExpr` or equivalent — find the
|
||||
`index_expr` arm) and its typing twin in src/ir/expr_typer.zig: both
|
||||
handle array / slice / many-pointer receivers but not
|
||||
`pointer → array`. The fix likely mirrors the struct-pointer auto-deref:
|
||||
when the receiver type is `.pointer` whose pointee is `.array`, load the
|
||||
pointer value and index the pointee array (element type = pointee
|
||||
element), in BOTH the typer and the lowering. Check the assignment-target
|
||||
path too (`p[1] = v` through a pointer-to-array).
|
||||
|
||||
Verification: the repro above prints `33`; add a pin under
|
||||
examples/01xx-types-… covering read AND write-through
|
||||
(`p[1] = 5; print k[1]` → 5 for a mutable local). Suite + zig build test
|
||||
stay green.
|
||||
@@ -1,86 +0,0 @@
|
||||
# 0118 — `cast(<compound type>) expr` dies with "unresolved 'unknown_expr'"
|
||||
|
||||
> **RESOLVED** (2026-06-11, same session, Agra-authorized in-session fix).
|
||||
> Two-part root cause: (1) `lowerCall` lowers args BEFORE the cast handler,
|
||||
> and compound type literals had NO expression-position lowering — they hit
|
||||
> `lowerExpr`'s catch-all `unknown_expr` error. Fixed by giving the six
|
||||
> compound type-expr shapes (`*T`, `[*]T`, `[]T`, `?T`, `[N]T`, fn types) a
|
||||
> first-class `const_type` lowering arm in `src/ir/lower/expr.zig`,
|
||||
> mirroring named types (`t : Type = *i64;` now works like `t : Type =
|
||||
> f64;`). (2) The cast handler's private static-type gate only accepted
|
||||
> bare names — replaced with the canonical `isStaticTypeArg`
|
||||
> (`src/ir/lower/call.zig`), so static compound casts route through
|
||||
> `coerceExplicit` while the runtime-`Type`-variable form (`cast(type) val`
|
||||
> in category arms) still falls through to runtime dispatch. Regression
|
||||
> test: examples/0182-types-cast-compound-types.sx (all compound cast
|
||||
> forms + the first-class Type value). Suite 579/579.
|
||||
|
||||
## Symptom
|
||||
|
||||
`cast(T) expr` with a COMPOUND static type argument (`*i64`, `[]u8`, `?i32`,
|
||||
`[*]i64`, `[4]i64`, …) fails to compile with a junk diagnostic pointing at the
|
||||
type argument. Observed:
|
||||
|
||||
```
|
||||
error: unresolved 'unknown_expr' (in probe.sx fn main)
|
||||
--> probe.sx:5:21
|
||||
|
|
||||
5 | q : *i64 = cast(*i64) p;
|
||||
| ^^^^
|
||||
```
|
||||
|
||||
Expected: the cast resolves the type argument statically and routes through
|
||||
`coerceExplicit` (for `cast(*i64) p` where `p : *i64`, a no-op), exactly as it
|
||||
does for bare names (`cast(i32) 3.14` works). The spec places no scalar-only
|
||||
restriction on `cast(Type)` (specs.md "cast(Type) expr — prefix operator that
|
||||
converts expr to Type").
|
||||
|
||||
Pre-existing on master (verified on a clean build of 679653f) — independent of
|
||||
the in-flight const-pointer work; plain `*i64` reproduces it.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () {
|
||||
x := 42;
|
||||
p : *i64 = @x;
|
||||
q : *i64 = cast(*i64) p; // error: unresolved 'unknown_expr'
|
||||
print("{}\n", q.*); // expected: 42
|
||||
}
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
The `cast` handler in `src/ir/lower/call.zig` (~line 391, the
|
||||
`Handle cast(TargetType, val)` block) gates static resolution with a PRIVATE
|
||||
`is_static_type` check that only accepts `type_expr` and `identifier` AST
|
||||
shapes. Every compound type-expression shape (`pointer_type_expr`,
|
||||
`slice_type_expr`, `many_pointer_type_expr`, `optional_type_expr`,
|
||||
`array_type_expr`, `function_type_expr`) falls through to the "runtime cast"
|
||||
builtin path, which cannot resolve it and surfaces the catch-all
|
||||
"unresolved 'unknown_expr'".
|
||||
|
||||
The codebase already has the canonical gate: `Lowering.isStaticTypeArg`
|
||||
(`src/ir/lower/generic.zig:206`), which `type_name` / `type_eq` use — it
|
||||
accepts the full compound-shape set (this is why `type_name(*i64)` folds
|
||||
fine while `cast(*i64)` dies). The fix likely: replace the private
|
||||
`is_static_type` block with `self.isStaticTypeArg(type_arg)` (keeping the
|
||||
scope-shadow semantics: an identifier bound to a runtime `Type` variable must
|
||||
still route to the runtime-dispatch path used by `case`-arm `cast(type)` —
|
||||
that path is load-bearing for `any_to_string`, see specs.md "runtime generic
|
||||
dispatch"). Then `resolveTypeArg` already handles the compound shapes.
|
||||
|
||||
Verification:
|
||||
1. Run the repro above — expect `42`, exit 0.
|
||||
2. Sanity: `cast([]u8)`, `cast(?i32)`, `cast([*]i64)` forms resolve.
|
||||
3. `bash tests/run_examples.sh` — the `case`-arm runtime-dispatch examples
|
||||
(any_to_string formatting suite) must stay green, proving the
|
||||
runtime-`Type`-variable path still falls through to the builtin.
|
||||
4. Pin the repro as a regression example per CLAUDE.md ("Resolving an open
|
||||
issue").
|
||||
|
||||
Context note: surfaced while probing PLAN-CONST-AGG step 5 (`*const T`), whose
|
||||
const-discard diagnostic suggests "use an explicit cast (`xx`/`cast`)" — `xx`
|
||||
works for pointer-shaped targets; `cast` currently does not.
|
||||
@@ -1,100 +0,0 @@
|
||||
# 0119 — UFCS dot-call on a GENERIC free function: "unresolved '<name>'"
|
||||
|
||||
> **RESOLVED** (2026-06-11, same session — Agra language ruling + the
|
||||
> opt-in implementation). Final model: free-function dot-calls are
|
||||
> OPT-IN. `name :: ufcs (params) { body }` (new declaration form) and
|
||||
> `name :: ufcs target;` (alias) both opt in; a PLAIN fn never
|
||||
> dot-dispatches (tailored diagnostic steers to direct / `|>` /
|
||||
> marking it ufcs). Generic ufcs fns dispatch via dot with the
|
||||
> receiver participating in `$T` binding; a protocol-typed receiver
|
||||
> dispatches its own methods first and falls through to ufcs fns
|
||||
> (`context.allocator.create(Session)` works). Bonus root-cause fix:
|
||||
> plan-side `inferGenericReturnType` now delegates to the SAME
|
||||
> `buildTypeBindings` the monomorphizer uses, so structured generic
|
||||
> params (`[]$T`) type direct calls correctly too (was a `T{}` stub
|
||||
> through print's Any boxing — pre-existing). The previously-implicit
|
||||
> unannotated dot-dispatch was REMOVED (inverted vs the model);
|
||||
> in-tree reliance was 6 example files (audited; migrated to marked
|
||||
> form), zero in m3te/game. specs.md §UFCS rewritten around the
|
||||
> opt-in matrix. Regression: examples/0053-basic-ufcs-opt-in.sx +
|
||||
> 1166-diagnostics-ufcs-not-opted-in.sx; mem helpers marked ufcs
|
||||
> (0838 pins dot + pipe + direct). Suite 585/585.
|
||||
|
||||
## Symptom
|
||||
|
||||
`obj.func(args)` where `func` is a generic free function (any `$T` in its
|
||||
signature — a `[]$T`/`*$T` param or a `$T: Type` value param) fails with
|
||||
`unresolved '<name>'`. The same call spelled directly —
|
||||
`func(obj, args)` — compiles and runs correctly. Concrete (non-generic)
|
||||
free functions rewrite through UFCS fine.
|
||||
|
||||
Observed (one probe, all three failures):
|
||||
|
||||
- `xs.sum_all()` (concrete fn, slice receiver) → **works**
|
||||
- `xs.first_of()` (generic `[]$T` fn, slice receiver) → `unresolved 'first_of'`
|
||||
- `p.pick(i32)` (generic `$T: Type` fn, struct receiver) → `unresolved 'pick'`
|
||||
- `a.create(Session)` (generic fn, protocol-value receiver) → `unresolved 'create'`
|
||||
|
||||
Expected: specs.md §UFCS promises the rewrite unconditionally ("When
|
||||
`object.func(args)` is encountered and `func` is not a field of
|
||||
`object`'s type, the compiler rewrites the call to `func(object,
|
||||
args)`"). A generic free function called via dot must monomorphize and
|
||||
dispatch exactly as the direct spelling does.
|
||||
|
||||
Note: issue-0040 (fixed) covered generic STRUCT METHODS via dot —
|
||||
that is the method path, not the free-function UFCS rewrite.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
first_of :: (xs: []$T) -> T { xs[0] }
|
||||
|
||||
main :: () {
|
||||
arr := .[1, 2, 3];
|
||||
xs : []i64 = arr;
|
||||
print("{}\n", first_of(xs)); // 1 — direct call works
|
||||
print("{}\n", xs.first_of()); // error: unresolved 'first_of'
|
||||
}
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
The UFCS rewrite lives in the call-lowering path (`src/ir/calls.zig` /
|
||||
`src/ir/lower/call.zig` — the field-access-callee handling that falls
|
||||
back to "func is not a field → try `func(object, args)`"). The fallback
|
||||
resolves the bare name against DECLARED functions (`resolveFuncByName` /
|
||||
the lowered-function registry). A generic free function is never
|
||||
declared (it is a TEMPLATE in `fn_ast_map`, `fd.type_params.len > 0`,
|
||||
monomorphized per call shape) — so the lookup misses and the call is
|
||||
reported unresolved instead of routing through the generic machinery.
|
||||
|
||||
The fix likely: in the UFCS fallback, when the bare name resolves to a
|
||||
`fn_ast_map` entry with `type_params.len > 0` (the same gate
|
||||
`declareFunction` uses), rewrite to the direct-call shape and route
|
||||
through the SAME generic-call path a direct `func(obj, args)` takes
|
||||
(`mangleGenericName` + binding inference from args + monomorphize). The
|
||||
direct spelling already works, so the machinery exists — the UFCS arm
|
||||
just never reaches it. Mind the resolution order: scope locals and
|
||||
protocol/struct methods must keep winning over a same-named free
|
||||
template (mirror the existing concrete-UFCS precedence), and visibility
|
||||
gating (import-graph) must apply to the template exactly as for
|
||||
concrete fns.
|
||||
|
||||
Verification:
|
||||
1. The repro above prints `1` twice, exit 0.
|
||||
2. Matrix probe: generic-on-struct (`p.pick(i32)`), generic-on-slice
|
||||
(`xs.first_of()`), generic-on-protocol-value
|
||||
(`a.create(Session)` with `create :: (a: Allocator, $T: Type) -> *T`)
|
||||
all dispatch; concrete UFCS unchanged.
|
||||
3. `bash tests/run_examples.sh` — 582/582 baseline must hold
|
||||
(UFCS-heavy suite: protocols, packs, List methods).
|
||||
4. Pin the repro as a regression example per CLAUDE.md.
|
||||
|
||||
Context: BLOCKS MEM Phase 2.2 — the plan's memory helpers are "free
|
||||
functions in mem.sx, UFCS-callable" with canonical call sites
|
||||
`context.allocator.create(Session)` / `slice.clone(context.allocator)`
|
||||
(plan Appendix A). The helpers themselves work via direct calls; the
|
||||
step is paused rather than shipping a direct-call-only API that the
|
||||
plan would immediately re-churn.
|
||||
@@ -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.
|
||||
@@ -1,112 +0,0 @@
|
||||
# 0121 — aliasing a comptime-pack fn (`..$args`): "unresolved '<alias>'"
|
||||
|
||||
> **RESOLVED** (2026-06-11, same session — Agra-directed). The symptom
|
||||
> was broader than filed: RENAMED fn aliases failed for EVERY fn kind
|
||||
> (plain `helper2 :: r.helper;` too) — the "plain fns verified working"
|
||||
> claim below was a same-name confound (same-name re-exports resolve
|
||||
> through the name-keyed global `fn_ast_map`, no alias mechanism
|
||||
> involved; `my_pack :: r.my_pack;` already worked for packs too).
|
||||
> Fix: fn aliases register at SCAN time — `scanDecls`' const-decl arm
|
||||
> follows ident-/`ns.X`-RHS alias chains via `aliasedFnDecl`
|
||||
> (nominal.zig; shares 0120's hop walk, now extracted as
|
||||
> `followAliasChain`) and, when the chain terminates at a fn decl,
|
||||
> registers the ALIAS name in `fn_ast_map` (absent-only — a real
|
||||
> same-name fn keeps its slot). Every dispatch path reads that map
|
||||
> (early pack/comptime/generic, plain lazy-lower, plan-side typing),
|
||||
> so the alias dispatches exactly like the target with no per-path
|
||||
> changes. Verified matrix: same-file pack alias (the repro), renamed
|
||||
> plain / generic / pack through a facade, and `my_print :: s.print;`
|
||||
> / `my_format :: s.format;` over std's real pack fns. Regression:
|
||||
> `examples/0546-packs-fn-alias.sx` (+ `-rich.sx` companion). Gates:
|
||||
> zig build test 0, suite 588/588.
|
||||
|
||||
## Symptom
|
||||
|
||||
A const alias of a function whose signature carries a comptime pack
|
||||
(`..$args`) is not callable — every call through the alias fails with
|
||||
`unresolved '<alias>'`. All three alias shapes fail identically:
|
||||
|
||||
- same-file bare: `sum_alias :: pack_sum;` → `unresolved 'sum_alias'`
|
||||
- bare RHS over a flat import: `my_print :: print;` (std's `print`) →
|
||||
`unresolved 'my_print'`
|
||||
- namespace RHS: `my_print :: s.print;` with `s :: #import
|
||||
"modules/std.sx";` → `unresolved 'my_print'`
|
||||
|
||||
Contrast (all verified working): plain concrete fns (`helper ::
|
||||
r.helper;`), runtime-generic fns (`first_of :: r.first_of;` with
|
||||
`(xs: []$T) -> T`), and — since 0120 — generic struct heads
|
||||
(`List :: list.List;`). Only the comptime-pack shape misses.
|
||||
|
||||
Expected: the alias dispatches exactly like the target —
|
||||
`my_print("x {}\n", 1)` behaves as `print("x {}\n", 1)`. (If fn
|
||||
aliasing of pack fns is NOT meant to be promised, the hypothesis is
|
||||
wrong and the decl or the call should get a clean tailored
|
||||
diagnostic instead — but the std.sx-as-pure-re-exports restructure
|
||||
wants `print :: fmt.print;` to work, so support is the desirable
|
||||
outcome. Confirm with Agra only if support turns out prohibitively
|
||||
deep.)
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
pack_sum :: (..$args) -> i64 {
|
||||
args[0] + args[1]
|
||||
}
|
||||
sum_alias :: pack_sum;
|
||||
|
||||
main :: () {
|
||||
print("{}\n", sum_alias(3, 4)); // error: unresolved 'sum_alias'
|
||||
}
|
||||
```
|
||||
|
||||
Direct `pack_sum(3, 4)` works; only the aliased spelling fails.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Comptime-pack calls (`..$args` — NOT slice-variadics `..xs: []T`)
|
||||
dispatch EARLY in call lowering, keyed on the callee NAME against the
|
||||
pack-fn template registry (the `fn_ast_map` entry whose FnDecl has a
|
||||
comptime pack param; the early-dispatch gate lives in the call path —
|
||||
`src/ir/lower/call.zig` / `src/ir/packs.zig`, grep for the pack-param
|
||||
detection on the callee). An alias name has no `fn_ast_map` entry, so
|
||||
the early pack dispatch misses; no later stage handles pack fns
|
||||
(they cannot lower as ordinary declared functions — each call site
|
||||
expands with its own bound pack), so the call falls through to the
|
||||
generic `unresolved '<name>'`.
|
||||
|
||||
The fix likely: where the early pack dispatch resolves the callee
|
||||
name, on a miss follow const-ALIAS decls to their target FnDecl and
|
||||
dispatch with the TARGET's fd under the alias's call node. Issue
|
||||
0120's fix added exactly this follow for generic STRUCT heads —
|
||||
`aliasedStructTemplate` in `src/ir/lower/nominal.zig`
|
||||
(`singleVisibleAuthor` + hop-by-hop `followToTemplate`, each hop
|
||||
resolved from the ALIAS AUTHOR's source, `namespaceAliasVerdictFrom`
|
||||
for `ns.X` RHS, depth-capped). Mirror that shape for fn targets — a
|
||||
`followToFnDecl` sibling reusing `singleVisibleAuthor` (consider
|
||||
extracting the shared walk) — and route the early pack dispatch
|
||||
through it. Mind: own-wins / single-flat collision semantics must
|
||||
match 0120's (≥2 flat alias authors → loud, no silent pick), and the
|
||||
ufcs-alias map (`name :: ufcs target;`) is a DIFFERENT mechanism
|
||||
(`ufcs_alias_map`) — don't conflate.
|
||||
|
||||
Verification:
|
||||
|
||||
1. The repro prints `7`, exit 0.
|
||||
2. Matrix: same-file bare alias, bare RHS over a flat import
|
||||
(`my_print :: print;`), namespace RHS (`my_print :: s.print;` /
|
||||
`my_format :: s.format;` — formats AND returns a value), and a
|
||||
consumer one flat hop from the aliasing facade.
|
||||
3. Plain-fn and generic-fn aliases unchanged (examples 0211 family).
|
||||
4. `bash tests/run_examples.sh` — 587/587 baseline must hold; pin the
|
||||
repro as a regression example per CLAUDE.md.
|
||||
|
||||
Context: BLOCKS the std.sx-as-pure-re-exports restructure — `print` /
|
||||
`format` are the prelude's most-used names and are exactly this shape
|
||||
(`($fmt: string, ..$args)`). Generic struct heads (`List`) were
|
||||
unblocked by 0120; this is the remaining known gap. Still unprobed
|
||||
for the restructure (next session, after this fix): protocol aliases
|
||||
(`Allocator`, parameterized `Into`), `#builtin` decl aliases
|
||||
(`size_of`, `out`, `string :: []u8`), `extern` decl aliases
|
||||
(`memcpy`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -1,127 +0,0 @@
|
||||
# RESOLVED — 0128: `[:0]u8` at FFI boundaries — conflicting symbol views, garbage string returns
|
||||
|
||||
> **RESOLVED** (2026-06-12). Investigation corrected the filing: the
|
||||
> "silent `u8` return" and the "`?[:0]u8` unresolved panic" were BOTH
|
||||
> artifacts of the reproducers binding the C symbol `getenv`, which
|
||||
> std/process.sx already declares as `-> *u8` — the FIRST registration
|
||||
> of a C symbol silently won and every call through the later
|
||||
> declaration was typed by the older signature (`*u8`), cascading into
|
||||
> the panic. `?[:0]u8` itself resolves correctly (it is `?string`).
|
||||
> The two GENUINE defects, both fixed:
|
||||
>
|
||||
> 1. **Conflicting same-symbol redeclaration was silent.**
|
||||
> `dedupeExternSymbol` (src/ir/lower/decl.zig) now runs at extern
|
||||
> registration: an EQUAL signature shares the first registration's
|
||||
> FuncId; a CONFLICTING one is diagnosed ("extern symbol '<s>' is
|
||||
> already bound with a different signature").
|
||||
> 2. **Extern `-> string` / `-> ?string` returns read garbage.** The
|
||||
> C side returns ONE `char *`; the LLVM signature declared the fat
|
||||
> `{ptr,i64}` (len = register garbage; bus error on use), and
|
||||
> `?string` (24 B struct) was mis-declared SRET — the hidden
|
||||
> out-pointer landed in the C callee's first argument register.
|
||||
> Now: such returns are classified by `cstrRetKind`
|
||||
> (src/ir/emit_llvm.zig), declared as plain `ptr` returns (never
|
||||
> sret), and the call site synthesizes the sx value via
|
||||
> `cstrReturnToSx`: `{ptr, strlen(ptr)}` with the strlen call
|
||||
> branch-guarded (NULL → `{null,0}`), wrapped in `{string, i1}`
|
||||
> with `has = ptr != null` for the optional.
|
||||
>
|
||||
> Regression tests: `examples/1221-ffi-cstring-returns.sx` (plain +
|
||||
> optional non-null via strerror/strsignal + optional NULL via
|
||||
> dlerror) and `examples/1172-diagnostics-extern-symbol-conflict.sx`
|
||||
> (the getenv conflict); both FAIL on pre-fix master. The extern
|
||||
> dedupe changes IR snapshots (duplicate libc decls collapse), so the
|
||||
> affected `.ir` files were regenerated. Gates: zig build test
|
||||
> 426/426, tests/run_examples.sh 602/602, distribution repo 21/21.
|
||||
> Boundary: comptime-interp (`#run`) extern calls are untouched, and
|
||||
> indirect (fn-pointer) extern calls don't synthesize — both can
|
||||
> follow if ever needed.
|
||||
|
||||
## Design contract (Agra, 2026-06-12)
|
||||
|
||||
`?[:0]u8` should lower to a `char *` at the FFI boundary — a single
|
||||
nullable thin pointer — NOT a fat pointer. This is the natural sx type
|
||||
for every libc/sqlite-style API that returns a nullable C string
|
||||
(`getenv`, `sqlite3_errmsg`, `sqlite3_column_text`, ...).
|
||||
|
||||
## Current state (verified at master 1bc60d3)
|
||||
|
||||
`[:0]u8` is an ALIAS for `string` (src/types.zig:145 — the `'['` arm
|
||||
returns `.string_type`), i.e. a fat ptr+len value at the sx level. At a
|
||||
`extern` PARAM position the C-ABI lowering already thins it: sx
|
||||
`string`/slices coerce to a single pointer and the length is dropped
|
||||
(src/backend/llvm/abi.zig, the `is_extern_c_api` knob) — so
|
||||
`popen :: (cmd: [:0]u8, ...)` works and matches the design contract.
|
||||
|
||||
The other two boundary positions are broken:
|
||||
|
||||
### Defect A — `-> [:0]u8` extern RETURN silently resolves to `u8`
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
libc :: #library "c";
|
||||
getenv_s :: (name: [:0]u8) -> [:0]u8 extern libc "getenv";
|
||||
|
||||
main :: () -> i32 {
|
||||
v := getenv_s("PATH");
|
||||
print("len={}\n", v.len); // error: field 'len' not found on type 'u8'
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
The declared return type `[:0]u8` resolves to plain `u8` — not the
|
||||
`string` alias, not an error. Whatever the return-position type-name
|
||||
path is, it disagrees with the table in types.zig and fails SILENTLY
|
||||
into a wrong type (the silent-fallback-default class this repo's
|
||||
CLAUDE.md forbids).
|
||||
|
||||
### Defect B — `?[:0]u8` does not resolve; panics LLVM emission
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
libc :: #library "c";
|
||||
getenv_opt :: (name: [:0]u8) -> ?[:0]u8 extern libc "getenv";
|
||||
|
||||
main :: () -> i32 {
|
||||
p := getenv_opt("PATH");
|
||||
if p == null { return 1; }
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Panics: "unresolved type reached LLVM emission"
|
||||
(src/backend/llvm/types.zig:175). Meanwhile plain `?string` resolves
|
||||
and works at the sx level — the `'?'` arm (types.zig:141) stores
|
||||
`child_name = "[:0]u8"`, and whatever resolves the child later doesn't
|
||||
go through the same table that maps the bracket spelling to
|
||||
`string_type`. Minimum bar even without the feature: a diagnostic, not
|
||||
a panic.
|
||||
|
||||
## Fix shape
|
||||
|
||||
1. Make the bracket spelling resolve identically everywhere — return
|
||||
position and optional child position must hit the same alias table
|
||||
that param position does (Defect A and the resolution half of B).
|
||||
2. Implement the boundary contract for returns: a extern
|
||||
`-> [:0]u8` / `-> ?[:0]u8` receives ONE pointer from C; the sx-side
|
||||
`string` is built by synthesizing the length (strlen) at the
|
||||
boundary, and for the optional a NULL pointer maps to `null`.
|
||||
If (2) is deferred, extern string/optional-string RETURNS must be
|
||||
rejected with a diagnostic naming the workaround (`?*u8`).
|
||||
|
||||
## Workaround in use
|
||||
|
||||
/Users/agra/projects/distribution/src/db/sqlite.sx declares every
|
||||
nullable C-string return as `?*u8` (null-pointer niche verified working
|
||||
in both JIT and AOT) and copies via a manual strlen helper; its header
|
||||
comment cites this issue's gap. std's own `getenv :: -> *u8` +
|
||||
cast-check is the same dodge. Both can migrate to `?[:0]u8` once the
|
||||
contract lands.
|
||||
|
||||
## Verification
|
||||
|
||||
Both reproducers behave: A prints a real PATH length; B answers null /
|
||||
non-null correctly for missing / present variables. `zig build test`,
|
||||
`tests/run_examples.sh` green; pin both as examples.
|
||||
@@ -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.
|
||||
@@ -1,116 +0,0 @@
|
||||
# RESOLVED — 0130: `#library` declared behind two aliased imports is dropped — no `-l` flag, no JIT dlopen
|
||||
|
||||
> **RESOLVED** (2026-06-12). Root cause as filed: `extractLibraries`
|
||||
> and `extractFrameworks` (src/main.zig) walked the merged root's
|
||||
> decls plus exactly ONE `namespace_decl` level, while aliased imports
|
||||
> nest namespaces arbitrarily deep — a `#library`/`#framework` two or
|
||||
> more aliases down never reached the AOT link args or the JIT dlopen
|
||||
> loop. Both walks are now recursive over `namespace_decl` children
|
||||
> (same `seen`-set dedup as before). Regression test:
|
||||
> `examples/1617-modules-library-nested-namespace.sx` (+ its module
|
||||
> dir) — `main → b :: #import → c :: #import` where c.sx declares
|
||||
> `#library "pcap"`; libpcap is NOT in the compiler process's loaded
|
||||
> images (unlike libz/libbz2/libsqlite3, which CoreServices/LLVM pull
|
||||
> in and which mask the bug under `sx run`), so the pre-fix JIT fails
|
||||
> symbol materialization and the pre-fix AOT link dies with undefined
|
||||
> `_pcap_lib_version`. Gates: zig build test 426/426,
|
||||
> tests/run_examples.sh 605/605, distribution repo `make test` 21/21
|
||||
> at its HEAD plus a successful `make build` of its P5.2 branch state
|
||||
> (dist.sx → ops → db → sqlite, the original failing chain).
|
||||
|
||||
## Symptom
|
||||
|
||||
A `#library` declaration in a module that is reached through TWO (or
|
||||
more) levels of aliased `#import` never makes it into the build's
|
||||
library list: `sx build` emits no `-l<name>` on the link line (link
|
||||
fails with `Undefined symbols` for every `extern` fn of that
|
||||
library), and `sx run` skips the dlopen of that library (the JIT then
|
||||
resolves the extern symbol only if some already-loaded image happens
|
||||
to export them).
|
||||
|
||||
- Observed: `main → b :: #import "b.sx" → c :: #import "c.sx"` where
|
||||
c.sx declares `zlib :: #library "z"` → link line ends `-lc` (no
|
||||
`-lz`), `ld: symbol(s) not found: _zlibVersion`.
|
||||
- Expected: every `#library` reachable through the import graph is
|
||||
linked (AOT) / dlopened (JIT), regardless of import depth or
|
||||
aliasing.
|
||||
- Control: aliasing c.sx DIRECTLY from main (one namespace level)
|
||||
produces `-lz` and links fine. A plain (unaliased) `#import` of c.sx
|
||||
from a module that main aliases also works — the merged decls sit at
|
||||
one namespace level.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Three files in one directory; build `a.sx`.
|
||||
|
||||
```sx
|
||||
// c.sx — declares the library + a extern fn
|
||||
#import "modules/std.sx";
|
||||
|
||||
zlib :: #library "z";
|
||||
zlibVersion :: () -> ?cstring extern zlib "zlibVersion";
|
||||
|
||||
zver :: () -> string {
|
||||
p := zlibVersion();
|
||||
if p == null { return ""; }
|
||||
return from_cstring(p!);
|
||||
}
|
||||
```
|
||||
|
||||
```sx
|
||||
// b.sx — first namespace level
|
||||
#import "modules/std.sx";
|
||||
c :: #import "c.sx";
|
||||
|
||||
ver_via_b :: () -> string { return c.zver(); }
|
||||
```
|
||||
|
||||
```sx
|
||||
// a.sx — main; c.sx's library now sits two namespace levels deep
|
||||
#import "modules/std.sx";
|
||||
b :: #import "b.sx";
|
||||
|
||||
main :: () -> i32 {
|
||||
print("zlib {}\n", b.ver_via_b());
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
$ sx build -o a a.sx
|
||||
Undefined symbols for architecture arm64:
|
||||
"_zlibVersion", referenced from: ...
|
||||
error: linking failed
|
||||
```
|
||||
|
||||
Replacing a.sx's import with `c :: #import "c.sx"` (and calling
|
||||
`c.zver()` directly) links and prints the zlib version — same code,
|
||||
one namespace level less.
|
||||
|
||||
Found in the distribution repo the first time a product chain nested
|
||||
the SQLite bindings two aliases deep: `dist.sx → ops :: #import
|
||||
"release/ops.sx" → db :: #import "../repo/db.sx" → #import
|
||||
"../db/sqlite.sx"` loses `-lsqlite3` even though the bindings compile
|
||||
fine (the extern wrappers ARE in main.o; only the link flag is gone).
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> In /Users/agra/projects/sx: `extractLibraries` (src/main.zig, ~line
|
||||
> 877) walks `root.data.root.decls` and exactly ONE level of
|
||||
> `namespace_decl` children. Aliased imports lower to nested
|
||||
> `namespace_decl` nodes, so a `#library` (or, same pattern,
|
||||
> `#framework` — see `extractFrameworks` right below it, ~line 904)
|
||||
> sitting two or more namespace levels deep is never collected. Both
|
||||
> consumers are affected: the AOT link args (`link(...)` in
|
||||
> src/target.zig receives this list) and the JIT dlopen loop
|
||||
> (src/main.zig ~line 274). Fix: make the walk recursive over
|
||||
> `namespace_decl` (a small explicit stack or recursive helper over
|
||||
> `ns.decls`), dedup as today via the `seen` set; apply the same to
|
||||
> `extractFrameworks`. Verify with the three-file libz repro from
|
||||
> issues/0130-library-decl-nested-namespace-dropped.md: `sx build`
|
||||
> must emit `-lz` and link, `sx run` must dlopen libz; add an
|
||||
> examples/ regression mirroring the repro (one library decl behind
|
||||
> two aliased imports) that fails on pre-fix master. Then re-run the
|
||||
> distribution repo's `make test` (which now links SQLite through
|
||||
> dist.sx's ops→db→sqlite chain) to confirm the original failure is
|
||||
> gone.
|
||||
@@ -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`.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,174 +0,0 @@
|
||||
# 0135 — `xx <pack>[i]` to a protocol target lowers the pack as a value ("pack has no runtime value")
|
||||
|
||||
> **RESOLVED (2026-06-13).** Root cause: `buildProtocolErasure`
|
||||
> (`src/ir/lower/coerce.zig`) treated `pack[i]` as an lvalue (any `index_expr`
|
||||
> returned true from `isLvalueExpr`) and tried to take its address via
|
||||
> `lowerExprAsPtr`, whose `.index_expr` arm lowers the bare pack as a value →
|
||||
> the pack-as-value error. Fix (preferred option 1): `isLvalueExpr` now reports
|
||||
> a comptime pack index as an **rvalue**, so erasure falls into its heap-copy
|
||||
> branch and copies the already-materialized element. It decides pack-ness with
|
||||
> the SAME predicate the value path uses — `packArgNodeAt` (the `pack_arg_nodes`
|
||||
> map) — not `isPackName` (`pack_param_count`), since the comptime-call path
|
||||
> installs only `pack_arg_nodes`; sharing one predicate keeps the value and
|
||||
> lvalue paths from diverging on what counts as a pack element. Regression tests:
|
||||
> [examples/0547-packs-xx-pack-index-to-protocol.sx](../examples/0547-packs-xx-pack-index-to-protocol.sx)
|
||||
> (single element) and
|
||||
> [examples/0548-packs-xx-pack-index-two-elements.sx](../examples/0548-packs-xx-pack-index-two-elements.sx)
|
||||
> (two distinct concrete types, each resolving to its own vtable). This also
|
||||
> unblocked [issue 0133](0133-union-member-struct-literal-assign-unresolved-panic.md),
|
||||
> now landed. The standalone repro `.sx` was removed (superseded by 0547).
|
||||
|
||||
## Symptom
|
||||
|
||||
One-line: erasing a single comptime-pack element to a protocol value —
|
||||
`xx sources[0]` where the target type is a protocol (`VL(i64)`) — spuriously
|
||||
errors with **"pack 'sources' has no runtime value — a pack is comptime-only
|
||||
and can't be used as a value here"**, pointing at the pack name.
|
||||
|
||||
- **Observed:** the pack-as-value diagnostic fires on `sources` in
|
||||
`x : VL(i64) = xx sources[0];`, even though `sources[0]` is a valid
|
||||
compile-time pack index.
|
||||
- **Expected:** `sources[0]` resolves to the call-site arg (the concrete
|
||||
`IntCell`), gets erased to the protocol `VL(i64)`, and the program prints
|
||||
`7`, exit 0.
|
||||
|
||||
This is **PRE-EXISTING** and reproduces on clean `master`, independent of any
|
||||
union / tuple / issue-0133 work. Issue 0053 added the `xx <whole-pack>` →
|
||||
`[]Any`/`[]P` slice bridge (`lowerPackToSlice`), but **single-element**
|
||||
`xx pack[i]` erasure to a protocol scalar was never handled.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Minimal, standalone (only `modules/std.sx`):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
|
||||
VL :: protocol(T: Type) { get :: () -> T; }
|
||||
IntCell :: struct { v: i64; }
|
||||
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
|
||||
|
||||
make :: (..sources: VL) -> i64 {
|
||||
x : VL(i64) = xx sources[0]; // protocol local <- xx pack[0]
|
||||
return x.get();
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
print("{}\n", make(IntCell.{ v = 7 })); // 7
|
||||
0
|
||||
}
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.sx`
|
||||
→ errors today; the fix should make it print `7`, exit 0.
|
||||
|
||||
### Root cause (traced)
|
||||
|
||||
The error chain (from a stack trace at the diagnostic site):
|
||||
|
||||
```
|
||||
lowerAssignment / lowerVarDecl sets target_type = VL(i64) for the RHS
|
||||
lowerExpr(xx sources[0]) unary_op .xx
|
||||
operand = lowerExpr(sources[0]) → packArgNodeAt resolves to the
|
||||
call-site arg IntCell.{v=7} (OK)
|
||||
lowerXX(operand, sources[0]) classifies .erase_protocol
|
||||
buildProtocolErasure(..., operand_node = sources[0], dst = VL(i64))
|
||||
isLvalueExpr(sources[0]) == true (it's an index_expr)
|
||||
concrete_ptr = lowerExprAsPtr(sources[0]) ← HERE
|
||||
lowerExprAsPtr .index_expr arm: lowerExpr(ie.object)
|
||||
lowerExpr(sources) → bare pack name → diagPackAsValue ✗
|
||||
```
|
||||
|
||||
The defect: **`lowerExprAsPtr`'s `.index_expr` arm does NOT perform the
|
||||
pack-arg-node substitution that `lowerIndexExpr` does.** `lowerIndexExpr`
|
||||
intercepts `<pack>[<comptime-int>]` via `packArgNodeAt` and lowers the
|
||||
call-site arg node directly; `lowerExprAsPtr` skips straight to
|
||||
`lowerExpr(ie.object)`, lowering the bare pack `sources` as a value — which is
|
||||
the (correct) pack-as-value error for a context where there is genuinely no
|
||||
pointer to take.
|
||||
|
||||
So the value path (`lowerIndexExpr`) handles a pack index but the
|
||||
address-of path (`lowerExprAsPtr`) does not — a two-resolver divergence on
|
||||
the pack-index case. `buildProtocolErasure` only reaches the address-of path
|
||||
because `isLvalueExpr(sources[0])` returns `true` (any index_expr looks like
|
||||
an lvalue), so it tries to alias the operand's storage instead of heap-copying
|
||||
the already-materialized rvalue.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> `xx <pack>[i]` erased to a protocol target spuriously errors with "pack
|
||||
> '<name>' has no runtime value". Repro:
|
||||
> `issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.sx`
|
||||
> (errors today; the fix should make it print `7`, exit 0).
|
||||
>
|
||||
> Root cause: `buildProtocolErasure` (`src/ir/lower/coerce.zig`, ~line 389)
|
||||
> sees `isLvalueExpr(operand_node) == true` for the index_expr `sources[0]`
|
||||
> and takes the alias-the-storage branch:
|
||||
> `concrete_ptr = self.lowerExprAsPtr(operand_node)`. But
|
||||
> `lowerExprAsPtr`'s `.index_expr` arm (`src/ir/lower/stmt.zig`, the
|
||||
> `.index_expr => |ie|` case, `self.lowerExpr(ie.object)` at stmt.zig:1005
|
||||
> on clean master) does
|
||||
> NOT do the pack-arg-node substitution that `lowerIndexExpr`
|
||||
> (`src/ir/lower/expr.zig:1343`, via `packArgNodeAt`) performs. It lowers the
|
||||
> bare pack `sources` as a value → `diagPackAsValue` (the pack-as-value
|
||||
> error at `src/ir/lower/expr.zig:1722`).
|
||||
>
|
||||
> A comptime pack index has no addressable storage of its own — `sources[0]`
|
||||
> is the call-site arg node, which only acquires storage when lowered as a
|
||||
> value. So the address-of path is the wrong path for a pack index.
|
||||
>
|
||||
> Suspected fix (pick the one that keeps the value/address paths from
|
||||
> diverging — the recurring two-resolver defect class in this codebase):
|
||||
> 1. **Preferred — teach `isLvalueExpr` that a comptime pack index is NOT
|
||||
> an lvalue.** Add a check: an `index_expr` whose object is a pack name
|
||||
> (`isPackName(ie.object...)` / a `packArgNodeAt(ie) != null` hit) is an
|
||||
> rvalue. Then `buildProtocolErasure` falls into its existing
|
||||
> `else { heap_copy = true; alloca + store(operand) }` branch and erases
|
||||
> the already-materialized `IntCell` value correctly. Smallest, and
|
||||
> matches the semantic truth (a pack element is a comptime rvalue).
|
||||
> 2. Alternatively, make `lowerExprAsPtr`'s `.index_expr` arm resolve a
|
||||
> pack index the way `lowerIndexExpr` does (`packArgNodeAt` → lower the
|
||||
> arg node as a value → `addr_of` an alloca holding it). More plumbing,
|
||||
> and it manufactures storage the caller could already manufacture.
|
||||
> Do NOT paper over with a silent default — per CLAUDE.md, resolve the real
|
||||
> path or emit a diagnostic.
|
||||
>
|
||||
> Verification: the repro prints `7`, exit 0; then `zig build &&
|
||||
> zig build test` green. Add positive coverage (a new
|
||||
> `examples/05xx-packs-xx-pack-index-to-protocol.sx`, packs category) and a
|
||||
> sibling that erases two distinct pack elements. When resolved, this also
|
||||
> UNBLOCKS issue 0133 (see below) — re-apply the 0133 unified-resolver fix
|
||||
> and confirm `examples/0540-packs-pack-type-arg-spread.sx` stays green.
|
||||
|
||||
## Relationship to issue 0133
|
||||
|
||||
Surfaced while fixing **issue 0133** (assigning a struct literal to a union
|
||||
member panics — the RHS never gets its target type). The clean 0133 fix
|
||||
(per its own investigation prompt) unifies the lvalue field resolver so the
|
||||
**target-type path** and the **lvalue-pointer path** share one matcher
|
||||
(`fieldLvalueResolve`), which then resolves *tuple element* LHS types too
|
||||
(not just structs). That makes `c.sources.0 = xx sources[0]` in
|
||||
`examples/0540-packs-pack-type-arg-spread.sx` set `target_type = VL(i64)`
|
||||
for the RHS — which routes `xx sources[0]` through `buildProtocolErasure`
|
||||
(it previously erased later, at the store, via `coerceToType` on the
|
||||
already-materialized value). That is exactly this bug.
|
||||
|
||||
So **issue 0133's correct (unified-resolver) fix is BLOCKED on this issue.**
|
||||
The ready-to-apply 0133 patch is recorded in
|
||||
`issues/0133-union-member-struct-literal-assign-unresolved-panic.md`; after
|
||||
0135 lands, re-apply it and confirm both the 0133 union repro (`code=9`) and
|
||||
`examples/0540` stay green.
|
||||
|
||||
## Notes
|
||||
|
||||
- Diagnostic site (symptom): `src/ir/lower/expr.zig:1723` (`isPackName` at
|
||||
1722 → `diagPackAsValue`, `.generic`) via `src/ir/lower/expr.zig:1386`
|
||||
(`lowerExpr(ie.object)` in `lowerIndexExpr`) — reached here through
|
||||
`lowerExprAsPtr`'s `.index_expr` arm, NOT `lowerIndexExpr`.
|
||||
- Root area (cause): `buildProtocolErasure` (`src/ir/lower/coerce.zig:389-390`)
|
||||
+ `lowerExprAsPtr` `.index_expr` arm (`src/ir/lower/stmt.zig:1005`) +
|
||||
`isLvalueExpr`.
|
||||
- Prior art (RESOLVED, not duplicates): 0052 (slice-of-protocol variadic
|
||||
erasure), 0053 (`xx <whole-pack>` → slice bridge), 0054 (generic-struct →
|
||||
param protocol erasure). None handle single-element `xx pack[i]` → protocol
|
||||
scalar.
|
||||
@@ -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).
|
||||
@@ -1,76 +0,0 @@
|
||||
# 0137 — `sx run` on a program with no `main` segfaults (JIT entry lookup unguarded)
|
||||
|
||||
> **RESOLVED.** A pre-JIT entry-point check in `main.zig` now emits a clean
|
||||
> `error: no 'main' function found …` diagnostic and exits non-zero before any
|
||||
> codegen/JIT, so a no-main program never reaches the garbage-pointer call. A
|
||||
> defensive `main_addr == 0` guard in `target.zig`'s `runJITFromObject` (ORC
|
||||
> reports lookup success but leaves the address degenerate) remains as a
|
||||
> backstop. Regression test: `examples/1188-diagnostics-run-no-main.sx`.
|
||||
|
||||
## Symptom
|
||||
|
||||
`sx run <file>` on a program that defines no `main` function **crashes**
|
||||
(SIGSEGV/abort, "Segmentation fault at address 0x60") instead of emitting a clean
|
||||
diagnostic like `error: no 'main' function found`.
|
||||
|
||||
- **Observed:** process crash, exit 134 (abort) / 139 (SIGSEGV); no diagnostic.
|
||||
- **Expected:** a normal compile-style error ("no `main` entry point") and a
|
||||
clean non-zero exit, the same way any other missing-entry condition reports.
|
||||
|
||||
Independent of inline assembly — surfaced while writing an ASM-stream probe that
|
||||
omitted `main`, but reproduces with an ordinary, asm-free program (see below).
|
||||
|
||||
## Reproduction
|
||||
|
||||
A file with only an (uncalled) function and no `main`:
|
||||
|
||||
```sx
|
||||
foo :: (n: u64) -> u64 { return n + 1; }
|
||||
```
|
||||
|
||||
```sh
|
||||
sx run that.sx
|
||||
# => "Segmentation fault at address 0x60", exit 134
|
||||
# expected: "error: no 'main' function found" (or similar), clean non-zero exit
|
||||
```
|
||||
|
||||
## Root cause (suspected)
|
||||
|
||||
`src/target.zig` JIT-run path, ~lines 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.
|
||||
@@ -1,130 +0,0 @@
|
||||
# 0138 — `@const` (address-of a `::` comptime constant) yields a wild pointer
|
||||
|
||||
**Status:** RESOLVED
|
||||
|
||||
> **Resolution.** Root cause was in `src/ir/lower/expr.zig`'s unary `.address_of`
|
||||
> lowering: a scalar `::` constant binds a folded *value* (`is_alloca == false`,
|
||||
> no storage), so it skipped both the alloca path and `resolveGlobalRef`, then
|
||||
> fell through to the generic `addr_of` arm which reinterpreted the value as a
|
||||
> pointer (`inttoptr (i64 <value> to ptr)`). Fixed by diagnosing in the
|
||||
> `address_of(identifier)` path — both the lexical case (a non-alloca,
|
||||
> non-ref-capture, non-pack-elem scope binding) and the module case (a name in
|
||||
> `module_const_map` that `resolveGlobalRef` did not back with storage) now emit
|
||||
> "cannot take the address of constant '<name>' — a scalar '::' constant has no
|
||||
> storage …" and return a placeholder Ref. Chose **diagnose** over materializing
|
||||
> read-only storage (confirmed with the user): consistent with the fold-only
|
||||
> scalar model; array/struct consts keep their real storage and stay addressable
|
||||
> (`@K`/`@LIT` via `global_addr`, unchanged). This also gives the ASM stream's
|
||||
> planned "output-to-`const` rejection" for free — asm `-> @const` lowers `@place`
|
||||
> through the same path, so it now reports the clean diagnostic instead of an LLVM
|
||||
> verifier failure. Regression: `examples/1177-diagnostics-addr-of-const-rejected.sx`
|
||||
> (module const, local const, and asm `-> @const` write-through). `zig build test`
|
||||
> green (659 corpus).
|
||||
|
||||
## Symptom
|
||||
|
||||
Taking the address of a `::`-bound comptime constant (`@x` where `x :: 40`)
|
||||
does **not** produce a real address. The address-of lowering falls through to
|
||||
the generic `addr_of` arm, which takes the *folded constant value* and
|
||||
reinterprets it as a pointer:
|
||||
|
||||
```llvm
|
||||
store ptr inttoptr (i64 40 to ptr), ptr %alloca, align 8
|
||||
```
|
||||
|
||||
- **Observed:** `@x` of a const lowers to `inttoptr (i64 <value> to ptr)` — a
|
||||
pointer whose numeric address IS the constant's value. Dereferencing it
|
||||
segfaults (`@x` of `x :: 40` → wild pointer `0x28`). Using it as a store
|
||||
destination (e.g. inline-asm `-> @x` write-through) emits invalid IR that
|
||||
only the LLVM verifier catches: `Store operand must be a pointer / store i64
|
||||
%asm, i64 40`.
|
||||
- **Expected:** either a clean compile diagnostic ("cannot take the address of
|
||||
comptime constant `x`") or materialization of read-only backing storage so
|
||||
the address is real. Never a silent reinterpret-value-as-pointer (a textbook
|
||||
silent-miscompile per CLAUDE.md).
|
||||
|
||||
This is **not** inline-asm-specific — it was discovered while implementing the
|
||||
ASM stream's planned "output-to-`const` rejection for `-> @place`", but the
|
||||
root cause is in the general address-of path. The same `-> @place`-to-const
|
||||
rejection falls out for free once `@const` is handled correctly (asm lowers
|
||||
`@place` through the same address-of path).
|
||||
|
||||
## Reproduction
|
||||
|
||||
Segfault on deref (no inline asm needed, no project deps):
|
||||
|
||||
```sx
|
||||
main :: () -> i64 {
|
||||
x :: 40; // comptime constant — no runtime storage
|
||||
p := @x; // lowers to `inttoptr (i64 40 to ptr)` — wild pointer
|
||||
return p.*; // segfault (deref of 0x28)
|
||||
}
|
||||
```
|
||||
|
||||
The IR for just `p := @x` (no deref) shows the defect directly:
|
||||
|
||||
```sx
|
||||
main :: () -> i64 {
|
||||
x :: 40;
|
||||
p := @x;
|
||||
return 7;
|
||||
}
|
||||
```
|
||||
→
|
||||
```llvm
|
||||
%alloca = alloca ptr, align 8
|
||||
store ptr inttoptr (i64 40 to ptr), ptr %alloca, align 8 ; <-- bug
|
||||
ret i32 7
|
||||
```
|
||||
|
||||
Inline-asm write-through to a const (the path that surfaced it) — invalid IR
|
||||
caught by the verifier instead of a sx diagnostic:
|
||||
|
||||
```sx
|
||||
FORTY :: 40;
|
||||
main :: () -> i64 {
|
||||
asm volatile { "mov %[c], #99", [c] "=r" -> @FORTY };
|
||||
return FORTY;
|
||||
}
|
||||
```
|
||||
→ `LLVM verification failed: Store operand must be a pointer.`
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> `@const` (address-of a `::`-bound comptime constant) miscompiles: instead of
|
||||
> a real address it reinterprets the constant's *value* as a pointer
|
||||
> (`inttoptr (i64 <value> to ptr)`), segfaulting on deref and producing invalid
|
||||
> stores for inline-asm `-> @place` write-through to a const.
|
||||
>
|
||||
> **Suspected area:** `src/ir/lower/expr.zig`, the unary `.address_of` lowering.
|
||||
> The clean `address_of(identifier)` path (~line 1994) only handles
|
||||
> `binding.is_alloca` locals and globals (`resolveGlobalRef`). A `::` const is
|
||||
> neither, so it falls through to the generic `.address_of` arm (~line 2057),
|
||||
> which does `addr_of(self.lowerExpr(uop.operand))` — and `lowerExpr` of a const
|
||||
> identifier folds to the constant value, so `addr_of` of an i64 constant emits
|
||||
> `inttoptr`.
|
||||
>
|
||||
> **Fix likely needs to:** detect, in the `address_of(identifier)` path, that
|
||||
> the resolved binding is a comptime constant with no storage. Then either (a)
|
||||
> emit a clear diagnostic via `self.diagnostics.addFmt(.err, span, "cannot take
|
||||
> the address of comptime constant `{s}`", .{name})` and return a dedicated
|
||||
> sentinel (NOT a folded value) — matches CLAUDE.md's no-silent-default rule; or
|
||||
> (b) materialize a read-only global/alloca for the const and return its real
|
||||
> address. Decide which against `specs.md` (does sx intend `::` consts to be
|
||||
> addressable at all?). Coordinate with PLAN-CONST-AGG's "const-write rejection"
|
||||
> — a write through `@const` (asm `-> @place`, or a future `p.* = …`) must also
|
||||
> be rejected; the read-only-storage option (b) still needs the write rejected.
|
||||
>
|
||||
> **Verification:** run the three repros above. Expect: repro 1 (`return p.*`)
|
||||
> either fails to compile with the diagnostic, or returns 40 (if `::` consts
|
||||
> become addressable); repro 3 (asm `-> @FORTY`) reports a clean sx diagnostic,
|
||||
> NOT an LLVM verifier failure. Add a pinned regression under `issues/expected/`
|
||||
> (or migrate to `examples/` once the behavior is decided).
|
||||
|
||||
## Notes
|
||||
|
||||
- Discovered mid-ASM-stream while starting the planned output-to-`const`
|
||||
rejection step. Read-write `+` place outputs (the prior ASM step) shipped
|
||||
green before this surfaced.
|
||||
- Not covered by any existing issue or by `current/PLAN-CONST-AGG.md` (which
|
||||
addresses const *writes* via assignment, not address-of).
|
||||
@@ -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.
|
||||
@@ -1,143 +0,0 @@
|
||||
# 0140 — a failing comptime type construction panics ("unresolved type reached LLVM emission") instead of diagnosing the bail
|
||||
|
||||
> **RESOLVED (2026-06-17).** Root cause exactly as the investigation prompt
|
||||
> hypothesized: `evalComptimeType` (`src/ir/lower/comptime.zig`) did
|
||||
> `interp.call(...) catch return null`, dropping the interpreter's
|
||||
> `last_bail_detail`; callers poisoned to `.unresolved` with no diagnostic, so the
|
||||
> sentinel reached LLVM emission and tripped the codegen panic (or hid behind a
|
||||
> downstream cascade). Fix: clear `Interpreter.last_bail_detail` before the call,
|
||||
> and on the `catch` emit a build-gating `.err` at the construction expression's
|
||||
> span — `"comptime type construction failed: {detail}"` (mirroring the `#run`
|
||||
> surfacing at `emit_llvm.zig:856`) — then return null (keeping the `.unresolved`
|
||||
> poison, now gated by a real message). The empty-variants repro now prints
|
||||
> `comptime type construction failed: comptime define(): enum has no variants`
|
||||
> and exits 1 (no panic); make_enum-style computed-slice failures surface their
|
||||
> root reason at the construction site instead of only the `.green` cascade.
|
||||
> Regression test:
|
||||
> [examples/1179-diagnostics-comptime-type-construction-bail.sx](../examples/1179-diagnostics-comptime-type-construction-bail.sx).
|
||||
|
||||
## Symptom
|
||||
|
||||
One-line: when a comptime type construction (`declare`/`define`) bails in the
|
||||
interpreter, the failure is swallowed — the decl is poisoned to `.unresolved`
|
||||
with **no diagnostic**, and that `.unresolved` reaches LLVM emission and panics
|
||||
instead of emitting a clean, build-gating error that names the bail reason.
|
||||
|
||||
- **Observed:** `thread … panic: unresolved type reached LLVM emission — a type
|
||||
resolution failure was not diagnosed/aborted`
|
||||
(`src/backend/llvm/types.zig:176`, the `.unresolved` arm of `toLLVMTypeInfo`),
|
||||
reached from `emitAlloca` (`src/backend/llvm/ops.zig:329`) for the local
|
||||
`e : Empty = ---`. Exit 134 (panic), not a diagnostic.
|
||||
- **Expected:** a build-time `.err` at the construction site carrying the
|
||||
interpreter's bail detail — `defineEnum` already produces the precise reason
|
||||
("comptime define(): enum has no variants") via `bailDetail`, which sets
|
||||
`Interpreter.last_bail_detail`. Exit 1, no panic, message visible to the user.
|
||||
|
||||
This is **PRE-EXISTING** and orthogonal to the METATYPE `type_info` work that
|
||||
surfaced it: the repro uses only the plain `define` path with an empty literal
|
||||
variant list (`type_info` is not involved). It reproduces for *any* comptime
|
||||
construction that bails — bad/empty `TypeInfo`, a `variants` value the decoder
|
||||
can't read (e.g. a pointer-backed `[]EnumVariant` slice built from a local
|
||||
variable / `List`, which is the next thing the make_enum step needs), etc.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Minimal, standalone (only `modules/std.sx` + `modules/std/meta.sx`):
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/meta.sx";
|
||||
|
||||
Empty :: define(declare("Empty"), .enum(.{ variants = .[] }));
|
||||
|
||||
main :: () -> i32 {
|
||||
e : Empty = ---;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run issues/0140-comptime-type-construction-bail-unresolved-panic.sx`
|
||||
→ panics today (exit 134); the fix should emit a diagnostic naming the bail
|
||||
reason and exit 1 (no panic).
|
||||
|
||||
### Bisection (what does / does not trigger the *panic*)
|
||||
|
||||
| Variant | Result |
|
||||
|---|---|
|
||||
| `Empty :: define(declare("Empty"), .enum(.{ variants = .[] }))` + a *local* `e : Empty` | **PANICS** (exit 134) |
|
||||
| same construction, but `Empty` is only *referenced as a type* (no value created) | poisons silently — often a confusing downstream cascade, no root reason |
|
||||
| a make_enum-style computed slice `define(declare(n), .enum(.{ variants = local_slice }))` then a `.variant` *literal* | exit 1 with `"cannot infer enum type for '.x'"` — the literal-inference error fires first and *incidentally* gates emission, so no panic, but the **real bail reason is still never shown** |
|
||||
|
||||
So the panic specifically needs the `.unresolved` type to survive to emission
|
||||
(here via a local `alloca`); when some *other* diagnostic happens to fire first,
|
||||
the build aborts before emission and the panic is dodged — but in **every** case
|
||||
the actual interp bail reason (`last_bail_detail`) is lost and the user sees
|
||||
either a panic or a misleading follow-on, never the root cause.
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
> A comptime type construction (`declare`/`define`, and reflection like
|
||||
> `type_info`) that bails in the interpreter is swallowed: the build either
|
||||
> panics at LLVM emission ("unresolved type reached LLVM emission") or shows a
|
||||
> misleading downstream cascade, instead of a clean diagnostic naming the bail
|
||||
> reason. Repro:
|
||||
> `issues/0140-comptime-type-construction-bail-unresolved-panic.sx` (panics,
|
||||
> exit 134 today; the fix should print a diagnostic with the bail reason and
|
||||
> exit 1 — no panic).
|
||||
>
|
||||
> Root area: `evalComptimeType` in `src/ir/lower/comptime.zig` (~line 457):
|
||||
>
|
||||
> ```zig
|
||||
> const result = interp.call(func_id, &.{}) catch return null;
|
||||
> return result.asTypeId();
|
||||
> ```
|
||||
>
|
||||
> The `catch return null` drops the interpreter's `last_bail_detail`
|
||||
> (`Interpreter.last_bail_detail`, `src/ir/interp.zig:218`, set by every
|
||||
> `bailDetail(...)` — e.g. `defineEnum`'s "enum has no variants"). The two
|
||||
> callers then poison to `.unresolved` with NO diagnostic:
|
||||
> - `src/ir/lower/decl.zig:777`: `const tid = self.evalComptimeType(cd.value) orelse TypeId.unresolved;`
|
||||
> then `putTypeAlias(..., .unresolved)`.
|
||||
> - `src/ir/lower/generic.zig:1762`: `orelse return .unresolved`.
|
||||
>
|
||||
> `.unresolved` is the correct *sentinel* (it trips the codegen tripwire so a
|
||||
> resolution failure can never silently ship), but here NOTHING converts it into
|
||||
> a user-facing diagnostic, so it either crashes at emit or rides along behind a
|
||||
> follow-on error.
|
||||
>
|
||||
> Suspected fix: in `evalComptimeType`, on the interp error path, emit a
|
||||
> diagnostic at the construction expression's span carrying `last_bail_detail`
|
||||
> (mirror how `src/ir/emit_llvm.zig:856` / `:933` already surface
|
||||
> `last_bail_detail` for `#run` / comptime-function evaluation —
|
||||
> `Interpreter.last_bail_detail orelse "<generic>"`). Reset
|
||||
> `last_bail_detail = null` before the call and read it after the `catch`. Return
|
||||
> `.unresolved` (keep the poison) *after* the diagnostic has been emitted, so the
|
||||
> build is gated with a real message and no `.unresolved` reaches emission
|
||||
> unannounced. Make sure BOTH callers (decl + generic) route through the
|
||||
> diagnostic — ideally emit inside `evalComptimeType` so neither caller can
|
||||
> forget. Do NOT swap `.unresolved` for a "reasonable-looking" default type
|
||||
> (per CLAUDE.md REJECTED PATTERNS); the sentinel + diagnostic is the right shape.
|
||||
>
|
||||
> Verification: the repro emits a diagnostic naming the bail reason and exits 1
|
||||
> (no panic); then `zig build && zig build test` green. Pin the repro as an
|
||||
> `11xx` diagnostics example (move to
|
||||
> `examples/11xx-diagnostics-comptime-type-construction-bail.sx`, seed the
|
||||
> `expected/*.exit` marker, capture with `-Dupdate-goldens`, review the diff).
|
||||
> Also add a positive note in `current/CHECKPOINT-METATYPE.md` that the
|
||||
> make_enum computed-slice step can proceed once this lands (the decoder's
|
||||
> "variants is not a slice/array" bail will then be a clean diagnostic instead
|
||||
> of a cascade/panic).
|
||||
|
||||
## Notes
|
||||
|
||||
- Tripwire site (symptom): `src/backend/llvm/types.zig:176` (`toLLVMTypeInfo`,
|
||||
`.unresolved` arm) via `emitAlloca` (`src/backend/llvm/ops.zig:329`).
|
||||
- Root area (cause): `evalComptimeType` `catch return null`
|
||||
(`src/ir/lower/comptime.zig:~457`) drops `last_bail_detail`; callers
|
||||
`decl.zig:777` / `generic.zig:1762` poison to `.unresolved` with no diagnostic.
|
||||
- The interpreter already computes the precise reason — `bailDetail` /
|
||||
`typeErrorDetail` set `Interpreter.last_bail_detail` (`interp.zig:218`); the
|
||||
`#run` path at `emit_llvm.zig:856` is the existing precedent for surfacing it.
|
||||
- Blocks: the make_enum computed-(non-literal)-variant-list step
|
||||
(`current/PLAN-METATYPE.md` Status), whose decode failures currently land in
|
||||
exactly this swallow.
|
||||
@@ -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.
|
||||
@@ -1,127 +0,0 @@
|
||||
# 0142 — comptime-minted all-void (fully payloadless) enum
|
||||
|
||||
> **RESOLVED (2026-06-18).** Two distinct issues were tangled in the original
|
||||
> report (the "binds to `Any`" symptom was a *syntax* misdiagnosis):
|
||||
>
|
||||
> 1. **Real bug:** `defineEnum` (and the new `register_type`) minted a fully
|
||||
> payloadless enum as an all-void `tagged_union`, whose IR size disagrees with
|
||||
> its LLVM size → `verifySizes` panic at codegen. **Fix:** mint a real
|
||||
> `.@"enum"` when every variant is payloadless (`src/ir/interp.zig`
|
||||
> `defineEnum`; `src/ir/compiler_lib.zig` `handleRegisterType` kind 2).
|
||||
> 2. **Missing syntax (the "Any" error):** `EnumType.variant` qualified
|
||||
> construction of a *payloadless* variant wasn't supported (it failed for
|
||||
> hand-written enums too — `field 'X' not found on type 'Any'`, because the
|
||||
> type name lowered to a `Type` value). **Fix:** `src/ir/lower/expr.zig`
|
||||
> `lowerFieldAccess` now recognises a bare `Enum.variant` payloadless literal
|
||||
> (mirroring the `alias.Enum.variant` namespace path), via the new
|
||||
> `isPayloadlessVariant`. Payload-carrying variants keep their call form
|
||||
> (`Shape.circle(2.0)`).
|
||||
>
|
||||
> Regression tests: `examples/0632-comptime-metatype-make-enum-payloadless.sx`
|
||||
> (make_enum all-void), `examples/0187-types-enum-qualified-variant.sx` (qualified
|
||||
> construction), `examples/0631`/`0633`/`0634` (compiler-API minted enums, bare +
|
||||
> namespaced import).
|
||||
|
||||
## Symptom (as originally — partly a syntax misdiagnosis; see banner)
|
||||
|
||||
A comptime type-fn that mints a **fully payloadless** enum (every variant
|
||||
tagless, `payload = void`) via `make_enum` / `declare` + `define` returns a type
|
||||
whose alias binds to `Any` instead of the minted enum — so any later use of the
|
||||
alias as a type fails with `field '<variant>' not found on type 'Any'`.
|
||||
|
||||
- **Observed:** `Suit :: make_suit()` (all-void variants) → `Suit.spades` errors
|
||||
`field 'spades' not found on type 'Any'`.
|
||||
- **Expected:** `Suit` is the minted enum; `Suit.spades` constructs the variant
|
||||
(exactly as it does when at least one variant carries a payload).
|
||||
|
||||
The type **is** minted correctly — reflecting it through the comptime compiler
|
||||
API shows `kind = 2` (enum) and the right variant count; only the type-fn's
|
||||
**return value / alias binding** is wrong. A *mixed* variant list (≥1 non-void
|
||||
payload) works end-to-end; only the all-void case fails. This is independent of
|
||||
the new `register_type` write API — it reproduces with the shipped metatype
|
||||
`make_enum`.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/meta.sx";
|
||||
|
||||
make_suit :: () -> Type {
|
||||
return make_enum("Suit", EnumVariant.[
|
||||
EnumVariant.{ name = "hearts", payload = void },
|
||||
EnumVariant.{ name = "spades", payload = void },
|
||||
]);
|
||||
}
|
||||
Suit :: make_suit();
|
||||
|
||||
main :: () {
|
||||
s := Suit.spades;
|
||||
if s == {
|
||||
case .hearts: { print("hearts\n"); }
|
||||
case .spades: { print("spades\n"); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `./zig-out/bin/sx run repro.sx` → `error: field 'spades' not found on type 'Any'`.
|
||||
|
||||
Contrast (works — one variant carries a payload):
|
||||
|
||||
```sx
|
||||
make_lvl :: () -> Type {
|
||||
return make_enum("Lvl", EnumVariant.[
|
||||
EnumVariant.{ name = "info", payload = void },
|
||||
EnumVariant.{ name = "fatal", payload = i64 }, // ← non-void makes it work
|
||||
]);
|
||||
}
|
||||
Lvl :: make_lvl();
|
||||
```
|
||||
|
||||
(This is why `examples/0620-comptime-metatype-make-enum.sx` passes — its variant
|
||||
list is mixed, not all-void.)
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
A comptime type-fn returning a *fully payloadless* enum (`define` →
|
||||
`tagged_union` with every field `ty == .void`) binds the result alias to `Any`
|
||||
(`TypeId` 13) instead of the minted type, even though the type is correctly
|
||||
registered in the table (findable by name; reflects as kind=2). A mixed list (≥1
|
||||
non-void payload) returns the correct `TypeId`. Find why the all-void case yields
|
||||
`Any`.
|
||||
|
||||
Suspected area:
|
||||
- `src/ir/lower/comptime.zig` `runComptimeTypeFunc` / `evalComptimeType` — the
|
||||
result path. `runComptimeTypeFunc` already special-cases a zero-FIELD
|
||||
`tagged_union` (declared-but-never-defined); check whether an all-void
|
||||
(non-zero-field) `tagged_union`/`enum` is being normalized, rejected, or
|
||||
coalesced to `.any` somewhere on the way back. Print the `TypeId` returned by
|
||||
`result.asTypeId()` for the all-void vs mixed case to localize.
|
||||
- `src/ir/interp.zig` `defineEnum` (≈2157) / `defineType` — what TypeId/`Value`
|
||||
it returns for an all-void variant set; whether an all-void `tagged_union`
|
||||
interns/dedupes to a builtin (note: a 2-variant all-void union has *no payload
|
||||
storage*, so its structural key may collide with something — or `replaceKeyedInfo`
|
||||
may leave the handle pointing at a coalesced slot).
|
||||
- Whether an all-void payloadless enum should mint as `.@"enum"` (payloadless)
|
||||
rather than an all-void `.tagged_union` in the first place — and whether the
|
||||
`.any` leak is downstream of that representation choice.
|
||||
|
||||
Likely fix: ensure the type-fn returns the real minted `TypeId` for an all-void
|
||||
payloadless enum (don't coalesce/normalize it to `.any`), or mint it as a proper
|
||||
`.@"enum"`. Whatever the cause, surface it — never silently substitute `.any`.
|
||||
|
||||
Verification: run the repro above → expect `spades` printed (exit 0). Also
|
||||
confirm `examples/0620` still passes and add an all-void variant case as a
|
||||
regression example.
|
||||
|
||||
## Context (why this was hit)
|
||||
|
||||
Surfaced while building the comptime compiler-API **write side** (Phase 3 of
|
||||
`current/PLAN-COMPILER-VM.md`): `register_type(handle, kind, members)` minting an
|
||||
**actual payloadless enum** (`kind = 2 → .@"enum"`). The new write API mints the
|
||||
type correctly (reflection confirms kind=2/count=2), but the *alias binding* of a
|
||||
fully-payloadless minted type hits this pre-existing metatype bug — so the
|
||||
"actual enum" example can't be verified end-to-end until this is fixed. The
|
||||
`register_type` work (struct, tagged_union with payloads, and the
|
||||
mutually-recursive A↔B graph) is otherwise working; it is **uncommitted**, paused
|
||||
pending this fix.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user