ExprTyper.inferType had no `.force_unwrap` arm, so `mk()!` typed as
`.unresolved`. The bind-first form (`v := mk()!; v.field`) worked because
lowerForceUnwrap produces a correctly typed value stored in a slot, but the
chained `mk()!.field` re-derives the receiver type via inferExprType and got
`.unresolved` — the struct-field lookup failed, the field read emitted as
`undef` (garbage), and `mk()!.method()` failed to resolve the method.
Add a `.force_unwrap` arm resolving the operand's optional child type. One
arm fixes every chained form — field, nested `opt!.a.b`, `opt!.method()`
(pointer + value receiver), and `opt![i]` all route receiver typing through
inferExprType.
Regression: examples/0905-optionals-unwrap-field-chain.sx — garbage / compile
error pre-fix, all correct after.
lazyLowerFunction's three exit paths (non-null branch, already-promoted
early return, null-FuncId `ns.fn` qualified-alias branch) each duplicated
the caller-state restore, and the null branch's copy had drifted: it
restored every saved field EXCEPT `block_terminated`. A qualified alias
whose body terminates (e.g. a constant-folded `if true { return ... }`)
leaves `block_terminated = true` after lowerFunction; the null path
returned without resetting it, so the flag leaked into the CALLER's body
lowering and the caller's own trailing statements / `return` were rejected
as dead-after-terminator ("function ... body produces no value").
Fix: collapse the three restores into a single `defer` registered right
after the state is saved, so every exit path restores the identical full
set and the class cannot diverge again. Fields restored on all paths:
current_source_file (F1), scope, func_defer_base, block_terminated (F2),
force_block_value, builder.func/current_block/inst_counter. The
foreign-class / jni-env / pack-mono / inline-return fields already had
their own defers and are unchanged.
Regression: examples/0721-modules-qualified-terminating-callee.sx — a
qualified alias `m.foo` folds `if true { return helper(); }` (helper from
m.sx's own import) and is followed by caller statements + the caller's own
`return 0`. Reports "body produces no value" pre-fix; prints
"terminating-callee: ok" / "after" and exits 0 after. 0719 (collision) and
0720 (F1 own-import visibility) stay green. issues/0100 RESOLVED banner
extended with the F2 follow-up.
The 0100 identity fix registers a namespaced import's own functions under a
module-qualified name (ns.fn) in fn_ast_map WITHOUT an eager declareFunction,
so the alias is lowered through lazyLowerFunction's null-FuncId lowerFunction
path. That path had no Function.source_file to restore (the non-null path does
setCurrentSourceFile(func.source_file)), so the alias lowered in the CALLER's
visibility context. A qualified function that called a helper from its OWN
module's flat import was then rejected "not visible".
Fix:
- ProgramIndex.qualified_fn_source maps each ns.fn alias to its declaring
source file, populated in registerQualifiedFn (current_source_file is
pinned to the decl's source by registerNamespaceQualifiedFns).
- lazyLowerFunction's null-FuncId branch restores that source before
lowerFunction, so ns.fn's body lowers in its own module's context and its
intra-module / own-import callees resolve.
- lowerFunction 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. issues/0100 RESOLVED banner
extended with the F1 follow-up.
Two modules each exporting a top-level function with the same short name
(std.cli.parse 3-param, std.json.parse 2-param) collided in IR lowering's
bare-name function table. fn_ast_map (name -> AST) was last-wins while
module.functions / resolveFuncByName are first-wins, so importing both and
calling one bound one function's AST against the other's FuncId and tripped
lazyLowerFunction's param-count assert (lower.zig:1606) — reached
unreachable code.
Fix:
- Register a namespaced import's OWN plain functions under their qualified
name (ns.fn) in fn_ast_map, giving cli.parse / json.parse independent
identities. The qualified resolution paths in CallResolver.plan /
lowerCall already prefer ns.fn. NamespaceDecl now carries own_decls
(populated in imports.addNamespace). Generic/comptime/pack/foreign
functions are excluded (they dispatch by monomorphization off the bare
template name); no eager declareFunction (it would resolve types before
the forward-alias fixpoint).
- Make scanDecls' bare fn_ast_map registration first-wins so a later
namespace recursion cannot clobber an earlier (flat) entry, aligning it
with mergeFlat / resolveFuncByName.
Regression: examples/0719-modules-cli-and-json.sx imports both std.cli and
std.json under distinct namespaces and calls both parses; panics pre-fix,
passes after. issues/0100 marked RESOLVED.
Adds `src/lsp/corpus_sweep.test.zig`: a permanent test that drives the
editor analyzer (`DocumentStore.analyzeDocument` — the exact path the
server's `textDocument/didOpen` uses) over EVERY `.sx` file in the
example + issue corpora, in process. The contract: analysis must
complete without panic/abort for any file. A panic aborts the test
binary — the loud CI signal that some new AST node shape crashes the
analyzer (the bug class issue 0099 fixed at sema.zig:397).
- Corpus dirs are injected as absolute paths at configure time
(build.zig `corpus_paths` options module) so the sweep is
CWD-independent; the FILE LIST is still read from disk at test time,
so new examples are covered automatically with no test edit.
- Imports resolve against the shipped `library/` (root_path + stdlib
path set), so the analyzer runs over real, fully-resolved code —
maximum crash surface, mirroring an editor session opened on the repo.
- Wired into `zig build test` via the `src/root.zig` lsp barrel, same
mechanism document.test.zig uses (refAllDecls reaches one struct deep,
so the file is referenced directly).
- `SX_LSP_SWEEP_VERBOSE` prints each file before analysis; on a crash the
last printed line names the offending file.
Coverage: 470 examples + 1 issue repro analyze with zero crashes.
Regression-guard proven: temporarily reverting A's sema.zig:397 fix
(`@intCast(ate.length.data.int_literal.value)`) makes the sweep abort
with `access of union field 'int_literal' while field 'identifier' is
active`; restoring it turns the sweep green.
`Analyzer.resolveTypeNode` read the array `.length` node's `.int_literal`
union field unconditionally. For a named-const dimension (`MAX :: 4;
[MAX]u8`) that node is an `identifier`, so the access tripped Zig's
checked-union panic and `sx lsp` aborted on didOpen. The main compiler
was unaffected (it folds the dim through the IR).
- New `arrayDimLength` helper switches on the dimension node tag:
int_literal → value; identifier → a recorded module-const int value;
anything else / out-of-u32-range → unknown. Never assumes a node shape.
- `Type.ArrayTypeInfo.length` is now `?u32`; null is an explicit "editor
couldn't fold this dimension" marker (rendered `[_]T`), never a
fabricated concrete length.
- New `const_int_values` registry records integer-literal consts at
registration time for the identifier path.
Regression: first `src/lsp/*.test.zig` (the minimal LSP harness), wired
into the test graph via `src/root.zig`. Drives `analyzeDocument` over
`[MAX]u8` (folds to 4, no panic), `[64]u8` (happy-path guard), and
`[N]u8` (explicit unknown). Fail-before/pass-after verified.
Sibling audit of the resolveTypeNode/fieldType family: the array dim was
the only unchecked union-field access; all other arms recurse or
tag-check first. Noted a non-crashing display gap in server.zig hover
rendering for step B.
Attempt-1 narrowed lowerReturn's target to failableSuccessType(ret_ty) for
every value-carrying failable. That fixed the bare-enum success slot but
introduced two defects (attempt-2 review):
F1 — explicit full failable tuple `return (.v, error.X)` panicked. With the
target narrowed to the value type, the trailing error element no longer
resolved against the error set, leaving an `.unresolved` tuple field that
tripped "unresolved type reached LLVM emission" in backend/llvm/types.zig.
F2 — a `-> (Enum, !E)` body with a comptime parameter is inlined
(lowerComptimeCall), so its success `return .red` took the inline-return path,
which the first cut skipped: it stored `{value, undef}` (error slot undef) into
the inline slot, so the success error slot read garbage at runtime.
Fix: choose the return-expr target via failableReturnTarget(ret_ty, value_node)
— a BARE value resolves against failableSuccessType (real enum ordinal), while
an EXPLICIT full failable tuple literal (arity == full-tuple field count) keeps
the full-tuple target and is forwarded as-is. This applies on the inline path
too, and the inline value-failable return now routes through
lowerFailableSuccessReturn (whose emitTupleRet stores `{value, 0}` into the
inline slot + branches), so the success error slot is 0 there as well.
Regression: examples/1056-errors-enum-value-failable-tuple-and-comptime.sx —
F1 explicit-tuple error return + bare-value success in one fn (no panic, slot 0
on success, tag 1 on error); F2 comptime-param enum value-failable read at
runtime on the success path (cast, bare if, == error.X) + error path. Reads the
slot at runtime so an undef is caught, not masked by the `if !e` proof.
examples/1055 + the original 0097 repro still pass. Gate: zig build 0,
zig build test 0, run_examples.sh 453 ok / 0 failed / 0 timed out.
A `-> (Enum, !E)` `return .variant` lowered the enum literal with
`target_type` set to the full failable tuple `(Enum, !E)` instead of the
success value type. The bare literal resolves its tag against `target_type`;
against a tuple it matched no variant (silent tag 0) and was stamped with the
tuple type, so `lowerFailableSuccessReturn` saw `val_ty == ret_ty` and took the
forwarding branch — returning the half-built `{value, undef}` aggregate and
never appending the `0` error slot. Every runtime read of the slot on the
success path (`cast(s64) e`, bare `if e`, `e == error.X`) saw garbage nonzero;
only the compile-time `if !e` proof masked it. The s32 case was already correct
because integer literals don't resolve variants against `target_type`.
Fix: in lowerReturn, narrow `target_type` to `failableSuccessType(ret_ty)` for
a value-carrying failable before lowering the returned expression. The enum
literal then resolves to its real ordinal and is typed as the value type, so
the success path correctly appends `0`. Forwarding (`return call()` / explicit
`(v, e)`) is unaffected — those still yield a value typed equal to the tuple.
Regression: examples/1055-errors-enum-value-failable-error-slot.sx reads the
error slot at runtime on the success path (cast, bare if, == error.X), checks a
non-zero ordinal (.blue=2, also corrupted to 0 pre-fix), and asserts the error
path still carries the right tag + error_tag_name. Fails pre-fix, passes after.
Scratch file path violated the project rule 'scratch files go in
.sx-tmp/, never /tmp'. Route the temp .bin through the repo-local,
gitignored .sx-tmp/ dir, creating it at runtime via create_dir_all
(mirroring 0713) so the test is self-contained on a clean checkout.
Digest assertions and output are unchanged.
`any_to_string` runs `type := type_of(val)`; for an `.any` operand
`type_of` lowers to `struct_get(val, 0)` to read the Any's tag. At
runtime a first-class Type value is the aggregate `{ tag=.any, value=tid }`
so the read succeeds, but the comptime interpreter stores a Type as a bare
`.type_tag(tid)` and the comptime `struct_get` arm had no case for it — it
raised `CannotEvalComptime`, which `runComptimeSideEffects` swallowed into
`void_val`, truncating the `#run` while still building with exit 0.
- interp.zig: comptime `struct_get` handles a `.type_tag(tid)` base by
mirroring the runtime Any-Type layout (field 0 -> `.any` tag, field 1 ->
the type id), so `type_of` of an Any-held Type evaluates as it does at
runtime and execution continues.
- emit_llvm.zig: `runComptimeSideEffects` no longer swallows a side-effect
bail; 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: examples/0613-comptime-print-any-type.sx (all five lines print,
exit 0). Resolves issue 0096.
A backtick raw value-shadow receiver (`` `f64 := … `` then `` `f64.epsilon ``,
`` `s8.max ``) was misclassified as the builtin numeric-limit accessor by the
shared compile-time evaluators. The sibling `isFloatValuedExpr` already guards
this with an `is_raw` check, but `evalConstFloatExpr` / `evalConstIntExpr` did
not — so once a raw value-shadow's field read flowed into the unified float→int
narrowing rule or an array-dim count, the float folder returned the BUILTIN
`f64.epsilon` (2.22e-16) and wrongly errored, and the integer folder turned
`` `s8.max `` into the builtin `127` (a fabricated 127-element array).
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 (an observable later reassignment),
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 `s64`
truncates its field value (11.5 → 11, identical to `b.epsilon`), and `` `s8.max ``
as an array dimension is rejected as a non-constant count (identical to `b.max`).
The bare builtin path is unchanged.
Regression (issue 0095 / F0.11-7):
- 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)
- program_index.test.zig "a backtick raw-shadow receiver is a field read, not a
numeric-limit fold (F0.11-7)"
specs.md + readme.md note the value-shadow rule extends into the narrowing/count
contexts.
The shared compile-time integer folder (`evalConstIntExpr`) accepts an
integral float literal/const as an integer leaf (`[4.0]` → 4) and then
applied INTEGER arithmetic to the whole expression — so `5.0 / 2.0` folded
as `divTrunc(5,2)` = 2 instead of float division (`2.5`). The bug fired at
all FIVE unified-rule sites (typed local, field default, param default,
typed const, array dimension), because the typed sites evaluate through
`evalConstFloatExpr` (which delegates the node to the int folder) and the
count sites through `foldCountI64` (int folder first).
Fix at the single root: `evalConstIntExpr`'s `.div` arm refuses to fold a
division whose lhs/rhs is float-valued (`isFloatValuedExpr`), so the value
surfaces through `evalConstFloatExpr` + 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).
`isFloatValuedExpr` judges a const-leaf by VALUE (`moduleConstIsFloatTyped`
recurses into the const's value with the existing cycle-guard frame), so an
untyped float-EXPRESSION const (`ME :: 4.0 + 1.0`, placeholder type s64) is
caught at both the count path and — via `foldComptimeFloatInit`'s guard —
the typed-binding path. A backtick RAW receiver (`` `f64.epsilon ``) is a
field read, not a float limit (is_raw check, issues 0092/0093).
Regression: examples/1147 (negative — `5.0 / 2.0` errors at all five sites
plus untyped float-EXPR const div); 0168 extended (positive — `6.0 / 2.0`,
`12.0 / 4.0`, `[6.0/2.0]`, `xx (5.0/2.0)` → 2); unit tests "the int folder
refuses a FLOAT division" and "moduleConstIsFloatTyped judges a const by
VALUE". specs.md + readme.md state the float-`/` rule.
The compile-time float evaluator lagged the integer one: it had no
numeric-limit field-access arm, so `y : s64 = f64.true_min + 0.5` (=0.5)
silently truncated to 0 even though the direct `f64.true_min` already
errored; the arm-by-arm audit also found a missing `%` arm, so
`y : s64 = 5.5 % 2.0` (=1.5) silently truncated to 1.
Bring evalConstFloatExpr to PARITY with evalConstIntExpr:
- Add a `.field_access` arm resolving a builtin FLOAT numeric-limit
accessor (`f64.max`, `f32.epsilon`, `f64.true_min`, …) via the SAME
`type_resolver.floatLimitFor` that `lowerNumericLimit` uses — the float
twin of the int evaluator's `integerLimitFor` arm.
- Add a `.mod` arm via `@rem` (matching evalConstIntExpr and codegen's
`frem`): `6.0 % 4.0` folds to 2 (via int delegation), `5.5 % 2.0` = 1.5
is rejected.
The two evaluators now share every leaf/operator shape, so no
compile-time-const float form escapes the unified float→int rule at one
site while folding at another. All five sites (local/field/param/const/
array-dim) stay consistent.
Regression: 0168 (positive) adds `f64.max - f64.max` → 0, `6.0 % 4.0` → 2,
integer-limit `s8.max`/`[u8.max]` unregressed, `xx` escapes for both new
forms; 1146 (negative) adds `f64.true_min + 0.5` and `5.5 % 2.0` erroring
at a binding site; program_index.test.zig covers the floatLimitFor arm and
the `%` arm. specs.md + readme.md state the parity. issues/0095 RESOLVED
banner gains the attempt-5 entry.
The compile-time count fold (array dimension / Vector lane / value-param) was
integer-only: it folded a DIRECT integral float literal (`[4.0]`, `[N]` with
`N : f64 : 4.0`) but rejected an INTEGRAL expression built from a non-integral
float-const leaf (`[F + 1.5]` = 4.0, `F : f64 : 2.5`) — and a const folded from
one (`[K]` with `K : s64 : F + 1.5`) — as "must be a compile-time integer
constant". This was the last of issue 0095's five narrowing sites (local /
field / param / const / array-dim) still diverging.
Route the count fold through the SAME compile-time float evaluation 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` (dim/lane/u32 value-param), the non-u32
value-param gate, and `emitModuleConst`'s integer-const materialization all
delegate to it, so a const's emitted value and its use as a count come from
one fold (no parallel integral check, no two-resolver divergence — issue 0083).
- 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") — the cast-escape advice the binding sites give does
not apply in a count 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 and type-ALIAS forms emit the identical message.
- `type_bridge.StatelessInner.lookupFloatName` (via `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.
`inline for` range bounds are spec endpoints, not counts (specs.md §2), so they
keep the int-only fold deliberately (no silent-truncation bug there).
Relaxes the F0.4 `examples/1132` wording: a non-integral float const dim now
reports the precise "non-integral float" message (it still errors).
Regression: 0168 (positive — `[F + 1.5]s64`, `[KF]s64`, alias `ArrFE` all fold
to len 4), 1146 (negative — `[F + 0.25]s64` errors), 1132 (precise wording), and
a `foldCountI64`/`foldDimU32` unit test. issues/0095 marked RESOLVED (attempt 4).
specs.md + readme.md state the unified rule across all five sites.
Completes issue 0095: a non-integral float→int narrowing via a FLOAT-const
leaf (`F : f64 : 2.5; y : s64 = F + 0.25` = 2.75) silently truncated to 2.
`evalConstFloatExpr` delegated only INTEGER leaves to `evalConstIntExpr` and
had no float-const leaf arm, so the unified rule never saw the value.
- program_index.zig: add `moduleConstFloat`/`moduleConstFloatFramed` — the f64
twin of `moduleConstInt` (same `isCountableConstType` gate, same cyclic-
definition frame), recovering a numeric module const's value via
`evalConstFloatExpr`. Add `lookupFloatName` to `ModuleConstCtx` and the
`.identifier`/`.type_expr` leaf arms to `evalConstFloatExpr` 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.
- lower.zig: add `Lowering.lookupFloatName` delegating to `moduleConstFloat`.
Route `typedConstInitFits`' integral-fold check through `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 : s64 : F + 1.5` = 4.0 now folds; `K : s64 : F + 0.25` errors).
A LOCAL `::` const leaf is a scope ref (not in the const tables) so neither
the int nor float evaluator folds it — float now matches int exactly there.
Regression: examples/1146 (negative) + 0168 (positive) extended with
float-const-leaf cases at local/field/param/const; unit test in
program_index.test.zig covers the leaf resolution (F→2.5, F+0.25→2.75,
F+1.5→4.0). specs.md + readme.md state the rule covers any compile-time-const
float expression incl. float-typed const leaves. issues/0095 banner updated.
Gate: zig build + zig build test green; 447 examples pass, 0 failed.
Completes issue 0095 (attempt 2). The attempt-1 coerce arm only caught a direct
`const_float` literal, so a non-integral const-folded float EXPRESSION still
truncated silently at a typed local / field default / param default:
M :: 2;
local : s64 = M + 0.5; // → 2 (silent truncation — BUG; now ERRORS)
fld : s64 = M + 0.5; // field default — same
take(x : s64 = M + 0.5) // param default — same
while the typed-CONST site already errored. The integral expression
(`M + 2.0` → 4) folded but the runtime/explicit-cast paths must stay untouched.
Fix:
- New `program_index.evalConstFloatExpr` — the f64 counterpart to
`evalConstIntExpr`, delegating every integer subtree back to it (no parallel
integer logic) and adding only the float literal / unary-negate / `+ - * /`
arms. Pure (no diagnostics, no resolution side effects).
- `Lowering.foldComptimeFloatInit` applies the unified rule to a typed-binding
initializer EXPRESSION: an integral comptime float folds to its `constInt`, a
non-integral one errors, a genuine runtime float / `xx`-cast falls through to
the normal path. It runs `evalConstFloatExpr` FIRST (pure) so a `$pack[i]`
argument is never spuriously type-resolved outside an active binding, then
gates on `isFloat(inferExprType)` so a plain comptime int is left alone.
Wired into the typed-local path, the three struct field-default sites (via a
shared `lowerCoercedDefault`), and the call-argument loop (covers expanded
param defaults).
- 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). The typed-CONST non-integral diagnostic
therefore reads "cannot implicitly narrow non-integral float …" instead of
the stale "initializer is a float literal / floating-point expression".
Tests: examples/1146 (negative) extended with non-integral const-EXPRESSION
cases at local/field/param; examples/0168 (positive) extended with integral
const-EXPRESSION folds and `xx (M + 0.5)` truncation; examples/1143 reconciled
to the aligned const message (G/BAD/BAD2 stay errors); unit test
`evalConstFloatExpr folds comptime float expressions`. Full gate green (447).
Issue 0095: a typed local/param/field silently TRUNCATED a float initializer
to an integer annotation (`y : s64 = 1.5` → 1) with no diagnostic. Agra ruled
the UNIFIED rule (Option B): an implicit float→int in a typed binding behaves
like the array-dimension rule —
- an INTEGRAL compile-time float FOLDS to its int (`4.0` → 4, `-2.0` → -2);
- a NON-integral float is a COMPILE ERROR (`1.5`, `4.5`);
- explicit `xx` / `cast(T)` ALWAYS truncates (the escape hatch).
Applied consistently to 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).
- `Builder.constFloatInfo` reads a compile-time `const_float` back from its
Ref (value + span).
- `coerceToType` is now the IMPLICIT path: 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 folding via `evalComptimeInt`); `emitModuleConst`
/ `constExprValue` / `globalInitValue` fold an integral float to its int and
reject a non-integral one — relaxing F0.7's blanket float rejection.
Tests: examples/0168 (positive: local/field/param/const fold, xx/cast
truncate), examples/1146 (negative: local/param/field error), integral-float
const cases added to examples/0162; non-integral const cases in 1143 stay
errors. specs.md + readme.md document the unified rule, cross-referencing the
array-dim rule. issues/0095 marked RESOLVED.
Migrate lowerAssignment's `.field_access` target onto the shared
`fieldLvaluePtr` resolver, deleting its duplicated union / promoted /
tuple / vector / struct walk. All three lvalue field-store sites —
single-assign, address-of (lowerExprAsPtr), and multi-assign
(lowerMultiAssign) — now resolve through the one resolver, removing the
issue-0083 two-resolver divergence.
Fold vector-lane resolution into `fieldLvaluePtr` (reusing
vectorLaneIndex) so the single resolver covers struct fields, union
direct fields, promoted anonymous-struct union members, tuple elements,
and vector lanes — null only on a genuine miss, which every caller turns
into the read path's `emitFieldError` diagnostic.
`fieldLvaluePtr` now types every field GEP `*field_ty` (the convention
the single-assign path always used), not the bare field value type:
emitStore unwraps one pointer level to find the stored value's type.
The earlier lowerExprAsPtr / lowerMultiAssign walks typed the GEP with
the bare field 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 onto the one `*field_ty` resolver preserves single-assign
and fixes that pre-existing multi-assign / address-of clobber.
The types.zig `.unresolved` tripwire is untouched; no `.s64` / `.void` /
`.unresolved` default remains.
Regression: examples/0167-types-ptr-to-aggregate-field-store.sx (a
`*Pair` field stored via all three lvalue sites leaves the neighbour
intact) + a lowering unit test asserting the `*field_ty` GEP convention.
Completes the issue-0094 fix. attempt-1 made single-assign and address-of
diagnose a missing struct field; the stress-review found two remaining defects
in that change:
1. lowerMultiAssign's `.field_access` target kept the pre-fix shape — a
struct-only loop that defaulted `field_idx 0` / `field_ty .unresolved` on a
miss, then built the GEP and stored unconditionally. A missing field
(`p.q, y = 2, 3`) silently wrote field 0 (printed `x=2 y=3`, no diagnostic),
and a valid promoted-union / tuple member at a non-zero offset corrupted
field 0 instead of its own slot.
2. attempt-1's new union branch in lowerExprAsPtr resolved only DIRECT union
field names, so `@v.x` on a promoted anonymous-struct member reported
"field 'x' not found on type 'Vec2'" even though `v.x = 41` worked.
Both lvalue-pointer sites and the multi-assign store now route through one
shared resolver, `fieldLvaluePtr`, that handles struct fields, union direct
fields, promoted anonymous-struct union members, and tuple elements, and
returns null (no field-0 / `.unresolved` default) on a genuine miss. Each
caller emits the read path's `emitFieldError` on null. This collapses the
three previously-divergent field-lvalue walks into one, fixing the
multi-assign missing-field corruption, the promoted-member over-rejection,
and (as a side effect of correct resolution) non-zero-offset promoted-union
and tuple multi-assign stores. The types.zig tripwire is untouched.
Regression tests:
- examples/1145 extended: multi-assign missing field (`p.r, y`) errors, exit 1.
- examples/0166 (new): promoted union member written and address-of'd,
including a non-zero-offset member (`@v.y`), compiles and runs.
- src/ir/lower.test.zig: multi-assign missing-field field-not-found unit test.
Assigning to a nonexistent struct field (`p.q = 2` where Point has no `q`)
aborted the compiler with the `.unresolved` LLVM tripwire instead of a source
diagnostic (issue 0094). The lvalue field lookup never diagnosed a miss:
- `lowerAssignment`'s `.field_access` target left `field_ty = .unresolved` when
no struct field matched, then built `ptrTo(field_ty)` and stored — so a
pointer-to-`.unresolved` reached LLVM emission and tripped the panic.
- `lowerExprAsPtr`'s `.field_access` fallback returned
`structGepTyped(obj_ptr, 0, .s64, obj_ty)` on a miss — a silent field-0/`.s64`
default that mislowered the lvalue.
Both sites now reuse the read path's `emitFieldError` (the exact facility
`lowerFieldAccessOnType` uses), so read and write reject identically with
`field 'q' not found on type 'Point'`. `lowerExprAsPtr` also resolves
union/tagged-union fields via `union_gep` (the old `.s64` fallback was silently
standing in for union field access — e.g. `u.a[0] = v`), so that path is fixed,
not just made loud. The `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, both
sites error, exit 1.
- examples/0165-types-nested-struct-field-assign.sx — positive, nested struct
field write + address-of a matched field still work.
- src/ir/lower.test.zig — lowering unit test asserting the field-not-found
diagnostic for a missing-field assignment.
The 7 type-only builtins doc claimed all of them accept a runtime Type
value, but only type_name and type_is_unsigned do. The other five
(size_of, align_of, field_count, type_eq, is_flags) are compile-time-only
— a runtime Type value (type_of(x)) yields 'unresolved type' since
runtime reflection is deferred. Reword both docs to the accurate scope.
Verified: type_name(type_of(x))=u32, type_is_unsigned(type_of(x))=true;
size_of(type_of(x)) / align_of(type_of(x)) -> error: unresolved type.
The Type Category Matching example showed the old signed-only arm
(case int: result = int_to_string(xx val);), which would reproduce the
pre-fix unsigned mis-rendering (u64 -> -1) if followed. Update it to mirror
library/modules/std.sx:370 — branch on type_is_unsigned(type) so unsigned
types route to uint_to_string, with a one-line clause explaining the split.
`type_name` / `type_is_unsigned` on an `Any` argument unconditionally read
the Any's payload as a TypeId index. That is correct only when the Any holds
a Type value (`{ .any, tid }`); for an Any holding a runtime *value*
(`av : Any = 6`, tag s64, payload 6) it returned `types[6]` — `type_name(av)`
gave "u8" and `type_is_unsigned(av)` gave true.
Both backends now branch on the Any's runtime type-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)` → "s64", `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` 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-.any / select for the runtime path; both
reflection arms route through it. The two backends agree.
- examples/0164-types-reflection-any-tag.sx: regression pinning type_name /
type_is_unsigned / print on an Any holding a value vs a Type.
- src/ir/interp.test.zig: unit test for `reflectTypeId`.
- 22 .ir snapshots: the new select appears in every std-importing program's
IR (any_to_string embeds these builtins) — benign, verified structurally
identical apart from the three new instructions.
- issues/0090, specs.md: documented the Any-tag rule.
size_of, align_of, field_count, type_name, type_eq, type_is_unsigned,
and is_flags silently reinterpreted a value argument as a type:
type_is_unsigned(6) read 6 as a TypeId index (types[6] = u8 -> true),
size_of(6)/size_of(true) sized its typeof (8), type_name(6) returned
types[6]'s name. Per Agra's ruling, all 7 now strictly require a type
(compile-time): a value argument is a compile error.
One shared guard (Lowering.reflectionTypeArgGuard, run at the top of
tryLowerReflectionCall) classifies each arg via reflectionArgIsType: a
spelled / compile-time type or generic type parameter (isStaticTypeArg),
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 with "<builtin> expects a type, got '<type>'". The
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).
Regression: examples/1144-diagnostics-reflection-builtin-needs-type.sx
(reject cases across all 7, exit 1). Unit test: reflectionArgIsType in
lower.test.zig. specs.md / readme.md document the strict type
requirement (and add the previously-undocumented align_of, type_eq,
type_is_unsigned). issues/0090 RESOLVED banner updated.
Resolves issue 0090. The `{}` integer formatter mis-rendered both ends of
the 64-bit range:
- `int_to_string` computed the magnitude as `0 - n`, which overflows for
`s64::MIN` (its magnitude is unrepresentable as a positive s64) — the
value stayed negative, the digit loop ran zero times, so only `-`
printed. It now extracts digits straight from `n` (per-digit
`|n % 10|`, `n` truncating toward zero), never negating MIN.
- `any_to_string`'s `case int:` formatted every integer as s64, so a u64
all-ones value printed as `-1`. There was no `uint` type-category to
distinguish signedness. Added an additive `type_is_unsigned(T)`
reflection builtin (static fold + dynamic interp/LLVM paths, mirroring
`type_name`), backed by the new `TypeTable.isUnsignedInt` predicate, and
a `uint_to_string` formatter (unsigned decimal via long-division over
four 16-bit limbs). `case int:` routes through `type_is_unsigned(type)`.
The 16-bit-limb split is factored into a shared `decompose_u16x4`, now
reused by `int_to_hex_string` (no second unsigned-math routine).
Regression: examples/0046-basic-int-formatter-extremes pins both extremes
plus a width spread; unit tests cover `isUnsignedInt`. Docs (specs.md
representation note, readme std API) updated for unsigned/extreme `{}`
behavior. IR snapshots refreshed for the two new std functions.
`ExprTyper.inferType`'s binary-op arm inferred every non-comparison op
from the LHS alone, so `M + 0.5` (s64 + f64) statically typed as s64
while `0.5 + M` typed as f64 — operand-order-dependent. The value path
(`lowerBinaryOp`) already promoted int×float → float, so static
inference disagreed with the value: `M + 0.5` formatted as a truncated
int and a typed const `BAD : s64 : M + 0.5` was accepted+truncated
(issue 0088 mixed-numeric escape).
Extract the value path's inline promotion into a shared
`Lowering.arithResultType(lhs, rhs)` and reuse it at both sites, so
arithmetic / bitwise / shift inference reports exactly the type the
lowered value carries — int LHS × float RHS → the float, order-
independent. The value-path behavior is unchanged (the block is moved
verbatim into the helper), so no IR shifts; the suite stays green. The
typed-const validation reuses `inferExprType`, so this auto-closes the
escape with no change to the validation logic.
- examples/1143: BAD/BAD2 (`s64 : M + 0.5`, `s64 : 0.5 + M`) rejected
in both operand orders.
- examples/0162: MF/MFR (`f64 : M + 0.5`, `f64 : 0.5 + M`) fold to 2.5.
- examples/0163 (new): pins the inference fix in a value context
(`print("{}", n + 0.5)` formats the float, both orders, +-*/, f32).
- expr_typer.test.zig: arithResultType + mixed-arithmetic inference.
- specs.md / readme.md: document the numeric-promotion rule.
- issues/0088: RESOLVED banner notes the inferExprType root fix.
Attempt 1 rejected only LITERAL initializers that mismatch a typed module
const's annotation; a const-EXPRESSION initializer escaped, so the same
issue-0088 root remained for `M :: 2; N : string : M + 2` — accepted at exit 0,
folding `[N]s64` to 4 and printing N as an integer.
Root cause: `registerTypedModuleConst` validated only the enumerated literal
node kinds; any other kind fell through to `else => {}`, and pass 0
pre-registers binary_op/unary_op consts as a `.s64` placeholder that was never
reconciled with the annotation.
Fix — validate by TYPE, not by node kind:
- lower.zig: `registerTypedModuleConst` now covers literals AND const-expressions
(binary_op/unary_op) through one path. `typedConstInitFits` keeps the literal
arms and routes any non-literal through the new `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 the `type mismatch` diagnostic
(a const-expression is described by its inferred type, e.g. "an integer
expression") and evicts the pass-0 placeholder; a match registers the const at
its resolved annotation type (the same `put` the literal path always did), so a
const-expression folds and emits at its declared type.
- `literalKindName` → `initializerDescription` (+ `constExprDescription`) so the
message is accurate for both a literal and a const-expression initializer.
Regression:
- examples/1143: extended with `E : string : M + 2` and `V : string : -M`
(const-expr mismatches → exit 1, pinned diagnostics).
- examples/0162: extended with `KE : s64 : M + 2` (used as a count + printed) and
`WE : f32 : M + 2` (over-rejection guard — valid const-exprs still work).
- program_index.test.zig: count-gate test extended with a binary_op value node
declared `string` (must not fold as a count).
Docs: specs.md §3 + readme.md generalized from "initializer literal" to cover
constant expressions; issues/0088 RESOLVED banner updated.
A typed module-level constant whose initializer did not match its
annotation was silently accepted: `N : string : 4` compiled, then
`print(N)` segfaulted (an integer emitted as a `string` const → a bogus
pointer) and `[N]s64` folded `N` to 4 as an integer count. Issue 0088.
Root cause: `registerTypedModuleConst` stored the annotation type but never
validated the initializer literal against it, and
`program_index.moduleConstInt` folded a const into a count by inspecting
the initializer node alone, ignoring `ModuleConstInfo.ty`.
Fix at the declaration (kills both symptoms):
- lower.zig: `registerTypedModuleConst` now validates the initializer via
`typedConstInitFits` (arms mirror `emitModuleConst`'s faithful-emit
precondition: int→int/float, float→float, bool→bool, string→string,
null→pointer/optional, `---`→any). A mismatch emits a `type mismatch`
diagnostic at the initializer span and does not register the const (also
evicting the pass-0 placeholder). Not routed through
`coercionResolver().classify`: that runtime-coercion planner is unsound
here (null's natural type is void → false-rejects `*T`; bool is 1 bit →
false-accepts s64).
- program_index.zig: `moduleConstInt` now takes the `TypeTable` and gates
the fold on `isCountableConstType(ci.ty)` (integer of any width, or a
float), so a non-numeric typed const can never fold into a count off its
initializer node. Callers in lower.zig and type_bridge.zig updated.
Regression:
- examples/1143-diagnostics-typed-module-const-mismatch.sx (negative, exit 1)
- examples/0162-types-typed-module-const-roundtrip.sx (positive)
- program_index.test.zig: gate-on-declared-type unit test
Docs: specs.md §3 Constant Binding + readme.md note the compatibility rule.
Writing a Vector lane (`v.x = …`, `.y/.z/.w` + colour aliases) panicked
with "unresolved type reached LLVM emission". The store path had no
vector branch: a `.field_access` target on a Vector fell through to
struct-field lookup, matched nothing, left `field_ty = .unresolved`, and
built a `ptrTo(.unresolved)` that tripped the LLVM emission guard. The
read path resolved the lane fine — the two had diverged (issue-0083
two-resolver class).
Extract a shared `Lowering.vectorLaneIndex` resolver and route BOTH paths
through it. The read path (`lowerFieldAccessOnType`) delegates to it,
dropping its silent `else 0` fallback. A new vector branch in
`lowerAssignment` GEPs a typed pointer to the lane (`structGepTyped`) and
stores via `storeOrCompound` (plain + compound). `emitStructGep` now
addresses a vector base type with a `[0, lane]` GEP. A non-lane field now
reports field-not-found on both paths instead of silent-lane-0 / panic.
Regression: examples/1506-vectors-lane-store.sx (panicked pre-fix, now
reads back written values) + a vectorLaneIndex unit test. Resolves issue
0086; spec documents element assignment.
Foundation milestone close — the minimal exit-code / --json contract
`dist` relies on, in pure sx (no compiler change).
- EX_OK (0) / EX_USAGE (64, sysexits.h) / EX_UNAVAILABLE (70) named
constants in std.cli.
- exit_ok() / exit_usage() terminators routing through the canonical
process.exit(code: u8) — removes the hand-rolled cli_bail_exit `_exit`
binding; the unsupported-platform path now uses proc.exit(EX_UNAVAILABLE).
- --json read is parsed.json (already parsed by F3.2); documented as the
detection point with a stdout-pure / stderr-human convention.
- examples/0718-modules-cli-exit-json.sx exercises the contract: json true
with --json / false without, EX_USAGE == 64, and a usage path that exits
64 via exit_usage() (expected .exit = 64).
- readme.md gains a std.cli command-line-interface subsection.
The issue-0092 fix guarded the numeric-limit accessor intercept against
raw value shadowing using only lexical Scope.lookup. The ordinary
identifier field-access path resolves a value through THREE sources
(scope / program_index.global_names / program_index.module_const_map),
so a backtick raw identifier bound at module scope — a global
`` `f64 := Box.{…} `` or a module constant `` `f64 :: Box.{…} `` — still
folded `` `f64.epsilon `` to the numeric limit instead of reading the
value's field (issue 0093, plus the module-const variant: same root
cause, same fix).
Fix: a single shared helper Lowering.identifierBindsValue(name) that
returns true when the name resolves through scope OR global_names OR
module_const_map. Used in BOTH lowerNumericLimit (lower.zig) and the
numeric-limit inference arm (expr_typer.zig) so the two resolvers can't
desync (issue-0083 class). A bare `f64.epsilon` / `s32.max` (a
.type_expr receiver) still folds even when a raw value of the same
spelling is bound — the bare receiver is never value-shadowed.
- examples/0161: extended to exercise all three binding kinds — a
GLOBAL `` `f32 ``, a MODULE-CONST `` `s16 ``, and LOCAL
`` `f64 ``/`` `s32 ``/`` `u8 `` — each reading its field while the
bare spelling still folds.
- src/ir/expr_typer.test.zig: unit test pinning the global +
module-const sources of the shared guard.
- issues/0093: RESOLVED banner (3-source root cause + fix, module-const
variant folded in).
- specs.md / readme.md: numeric-limit shadow note now source-agnostic
(local / global / module-const).
The numeric-limit accessor intercept (NL.1 integer `.min`/`.max`, NL.2 float
`.epsilon`/`.min_positive`/`.true_min`/`.inf`/`.nan`) treated ANY receiver
whose text matched a builtin numeric type name as a TYPE receiver, without
first checking for 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
(issue 0092).
Fix: for `.identifier` receivers, prefer an in-scope value binding
(`Scope.lookup`) over the fold — defer to ordinary field lowering when the
identifier resolves to a value. `.type_expr` receivers are unambiguous types
and are never shadowed, so a bare `f64.epsilon`/`s32.max` still folds even in a
scope where `` `f64 `` is bound (the parser classifies a bare builtin name as a
`.type_expr`). Mirrored in expr_typer.zig so inference matches lowering
(avoids the issue-0083 two-resolver desync). Float-only-on-int and
non-numeric-receiver errors are unchanged.
- src/ir/lower.zig: value-binding guard in lowerNumericLimit.
- src/ir/expr_typer.zig: same guard in the numeric-limit inference arm.
- src/ir/expr_typer.test.zig: unit test pinning the two-resolver agreement.
- examples/0161-types-numeric-limit-value-shadow.sx: regression — raw
`` `f64 ``/`` `s32 ``/`` `u8 `` value reads coexisting with bare folds.
- issues/0092: RESOLVED banner.
- specs.md / readme.md: receiver-vs-shadowing-value-binding note.
Finish NL.2 on top of the WIP compiler impl (2e9e4fe): f32/f64 expose
.min/.max plus the float-only .epsilon/.min_positive/.true_min/.inf/.nan,
folded via the shared lowerNumericLimit intercept + builder.constFloat.
- examples/0159: pins every f32/f64 accessor by untagged-union bit
reinterpret against exact IEEE-754 hex (true_min read before any
arithmetic — FTZ/DAZ), plus the defining-property checks
((1+eps)!=1 / (1+eps/2)==1, inf>max, min==-max, true_min<min_positive,
true_min>0, nan!=nan).
- examples/0160: float-only accessor on an int (s32.epsilon/u8.inf/
s64.true_min) and any accessor on a non-numeric type compile-error
cleanly (exit 1, pinned stderr).
- type_resolver.test.zig: floatLimitFor bit-pattern + property tests for
f32/f64, isLimitField coverage, null for non-float/non-limit fields.
- specs.md Numeric Limits: float accessors + the min=-max / min_positive=
smallest-normal / epsilon=ULP-of-1.0 / true_min=smallest-subnormal
clarifications, with the mandatory FTZ/DAZ flush-to-zero caveat.
readme.md overview updated.
A protocol method signature omits the receiver; a bare `self` has no type, so
`protocol { … :: (self) … }` fails at parse with 'expected :'. Correct the three
member-exemption doc snippets (readme.md, specs.md, issues/0089) to the valid
signature form, matching examples/0158's `Speaker :: protocol { s2 :: () -> s64; }`.
The member-name exemption applies only to identifier-classified reserved
spellings (s1..s64, u1..u64, bool, string, void, usize, isize, Any). f32/f64
are lexer keywords (token.zig kw_f32/kw_f64) and member-name slots require an
identifier token, so a bare f32/f64 field/tag/method name is rejected at parse;
the backtick is required there too. specs.md + readme.md corrected.
AGRA RULING (issue 0089, attempt 7): bare reserved-name MEMBER positions are
intentionally exempt from the reserved-type-name rule, and the implementation
already does the right thing — this is a docs + one-example change, no code.
The exempt member positions are struct FIELD names, union TAG names, and protocol
method-SIGNATURE names: they sit in a member slot, are reached via obj.name (or
dispatched by string), and are never type-classified, so they never mis-lower.
The backtick is optional there. 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 still needs the backtick, exactly like a free
function (cf. examples/1122) — and every bare reserved-name value binding /
declaration name still errors (0076 preserved).
- specs.md / readme.md: replace the "every binding site" / "any binding site"
overclaim with the precise rule — required positions (value bindings +
declaration names + impl method definitions) vs the exempt member-name
positions (field / tag / protocol signature; backtick optional).
- examples/0158-types-reserved-name-member-exempt.sx: pins the exempt behavior —
bare reserved-name struct fields + union tag read & written bare AND via
backtick, and a protocol with a bare reserved-name method dispatched through
the protocol (impl definition takes the backtick).
- issues/0089: document the member-name exemption in the RESOLVED banner + add
0158 to the regression list.
Gate: zig build, zig build test, bash tests/run_examples.sh — all green
(430 passed, 0 failed, 0 timed out).
The codegen-side resolver was already raw-aware for the universal model;
the sema/LSP editor index (the second classifier) only honored the DIRECT
raw type. A COMPOUND raw type (`*`s2`, `?`s2`, `[N]`s2`, `[]`s2`, `[*]`s2`)
stores its inner type-name as a bare string on the Type info struct, and
every resolution site re-read it with skip_builtin=false — so the index
reclassified a user type named `s2` as the builtin int, diverging from
codegen (issue-0083 class, LSP surface only; codegen unchanged).
Structural cure: every compound info struct (Pointer/Optional/Slice/
ManyPointer/Array) carries a REQUIRED is_raw bit (no default — a future
construction site cannot drop it). is_raw is set at every construction
site (resolveTypeNode arms, fieldType arms, variadic slice, .ptr/slice_expr
derivation, for-loop by-ref, substType) and passed as skip_builtin at every
resolution site (elementTypeOf, field-access pointer unwrap, index, deref,
optional unwrap/null-coalesce, if/while optional binding, match subject).
Optional-unwrap + deref sites converted from Type.fromName/pointerPointeeType
(builtin-only, divergent) to resolveTypeNameStr(name, is_raw); the now-dead
pointerPointeeType removed.
Tests: src/sema.test.zig gains pointer/optional/array raw-vs-bare
regressions (raw → user type, bare → builtin control) — each FAILS on
pre-fix sema, PASSES after — plus a parameterized-raw coverage test.
Closes the remaining three F0.6 findings so the universal backtick raw
identifier holds in BOTH classifiers and at EVERY parser construction site.
1. Struct-body constants thread is_raw + name_span. The struct-body const
forms (untyped `` `s2 :: 5 `` and typed `` `s2 : T : v ``) built the
const_decl node without name_span/is_raw, so a backtick const was falsely
rejected and a bare reserved-name const caretted at 1:1. They now capture
both. Structural cure: `ast.ConstDecl`'s name_span + is_raw carry NO
default, so the compiler rejects any construction site that omits them
(mirrors checkBindingName's required `is_raw` arg). FnDecl keeps its
defaults — every parser fn_decl routes through parseFnDecl whose
`name_is_raw` is a required parameter (equivalent guarantee).
2. Raw identifier in TYPE position flows through the normal continuations.
parseTypeExpr no longer returns a terminal type_expr for a raw atom; the
raw flag rides the atom through the qualified-path / Closure / parameterized
continuations, so `` `s2(s64) ``, `` *`s2 ``, `` ?`s2 `` all parse.
ParameterizedTypeExpr carries is_raw; resolveParameterizedWithBindings
skips the `Vector` intrinsic when raw.
3. sema/LSP (the second classifier) honors is_raw. Type.fromTypeExpr returns
null for a raw type_expr; resolveTypeNode skips the builtin classifier when
raw; resolveTypeNameStr takes a skip_builtin arg threaded from te/id.is_raw
(compound inner names pass false). A backtick reserved-name annotation now
resolves to the user type in the editor index, not the builtin.
Tests: examples/0156 (struct-body const), 0157 (parameterized raw type +
wrappers), 1142 (bare struct-body const errors, caret on name); src/sema.test.zig
pins the LSP raw-type resolution (fail-before verified). Gate: 365 unit tests,
429 examples, 0 failed.
AGRA ruling (attempt 4): `` `name `` is THE LITERAL identifier `name`, usable in
EVERY position — the backtick only means "treat this token as a plain identifier,
never the reserved keyword/type", and is never part of the name's text.
- Raw in TYPE position is now VALID (reverses attempt-2 "raw is not a type"):
`parseTypeExpr` emits a raw `type_expr`; `TypeResolver.resolveNamed` gains a
`skip_builtin` flag (threaded from `te.is_raw` via lower.zig + type_bridge) so a
`` `s2 `` reference resolves to a `` `s2 ``-declared type (struct/enum/union/alias),
else a normal "unknown type 's2'" error (reportIfUnknownType skips the builtin
exemption when raw). Bare `s2` in type position stays the builtin int.
- Every declaration-name site is is_raw-exemptible: `is_raw` added to TypeExpr +
StructDecl/EnumDecl/UnionDecl/ErrorSetDecl/ProtocolDecl/ForeignClassDecl/UfcsAlias/
NamespaceDecl/ImportDecl/CImportDecl/LibraryDecl; parser threads name_is_raw to
every decl parse fn; namespace imports carry it through imports.addNamespace.
Typed-const path (`` `s2 : s64 : 5 ``) now threads name_span+is_raw (fixes the
1:1-caret bug).
- Check<->exemption made structurally symmetric: checkBindingName/checkDeclName take
is_raw as a REQUIRED argument and skip inside the check, so no call site can
validate a name without honoring the exemption (the desync cause of prior rounds).
- Bare reserved-name declarations of every kind still error (0076 preserved);
`#import c` foreign names stay auto-raw + bare-callable.
specs.md + readme.md updated to the universal model. issue 0089 RESOLVED banner
rewritten. Examples: replace 1139 (raw-not-a-type) with 0154 (raw type reference);
add 0155 (typed const + union tag) and 1141 (bare type-decl negatives).
Gate: zig build + zig build test + run_examples (426 passed, 0 failed).
A bare reserved-type-name `::` declaration was silently accepted, and the
attempt-2 lowerCall rewrite then made a bare `s2 :: (…) {…}` function callable —
bypassing the backtick rule for handwritten sx. The reserved-name binding check
covered `:=` / typed-local / param / captures but NOT the `::` declaration form.
- ast: `ConstDecl`/`FnDecl` carry `is_raw` + `name_span` threaded from the parser
(parseConstBinding / parseFnDecl, all call sites incl. struct/impl methods).
- semantic_diagnostics: reject a bare reserved spelling at EVERY declaration-name
site — const, function (incl. struct/impl methods), struct/enum/union/error-set,
protocol, foreign-class, ufcs alias, namespaced/library/c-import name. Backtick
(`is_raw`) and the compiler's `#builtin` definition (`string :: []u8 #builtin`)
are the only exemptions; a value whose node is itself a named decl defers to
that node's own check.
- c_import: synthesized foreign fn_decls are `is_raw = true`, so a C function
whose own name collides with a reserved spelling (`int s2(int);`) imports and
bare-calls unedited.
- lower: scope the `.type_expr`→`.identifier` call rewrite to a callee FnDecl of
RAW provenance (`is_raw`) — only a backtick / `#import c` foreign fn can carry a
reserved-name spelling, so a non-raw match never gets rewritten.
- examples: 0153 (positive — backtick `::` const + fn, bare + tick call), 1140
(negative — bare `::` const + fn rejected).
- docs: specs.md + readme.md state the backtick is required at every binding site
including `::` const / function / type declarations; issue 0089 banner updated.
Completes the issue-0089 backtick raw-identifier / `#import c` exemption
across all remaining identifier positions and closes three boundary gaps
the F0.6 review found.
1. Exhaustive raw-binding coverage. The `is_raw` bit now threads through
`ast.Identifier` and EVERY binding/capture form — `IfExpr`/`WhileExpr`
optional bindings, `ForExpr` capture + index, `MatchArm` capture,
`CatchExpr`/`OnFailStmt` tag bindings, `DestructureDecl` per-name, and
the protocol-default-body / foreign-class method param lists — not just
`var_decl`/`param`. `UnknownTypeChecker` skips the reserved-name check at
each arm when raw, so a backtick works in every identifier position while
a bare reserved spelling still errors (issue 0076 preserved).
2. Raw identifier is never a type. `parseTypeExpr`'s atom rejects a raw
identifier in type position (`x : `s2 = 1`, `List(`s2)`) with an accurate
diagnostic instead of silently type-classifying it.
3. Reserved-name function bare-callable. A bare `s2(4)` parses its callee as
a `.type_expr` (reserved spelling); `lowerCall` now rewrites a type_expr
callee to an identifier when a function of that name is in scope, so a
backtick-declared sx fn and a `#import c` foreign fn whose C name collides
with a reserved type spelling both resolve by their bare name.
(`TypeName(val)` is not a cast, so there is no ambiguity.)
Tests: examples/0152 (every control-flow/capture form + bare ref/call/member
access), examples/1054 (catch/onfail tag bindings), examples/1139 (raw in
type position rejected), examples/1220 extended (foreign reserved-name
function bare-call). 0076 negatives 1119/1121/1122/1123/1124/1125 stay green.
Gate: zig build + zig build test + 422 examples pass. specs.md + readme.md
updated; issues/0089 RESOLVED banner refreshed.
Reserved type-name spellings (s1, s2, u8, …) can now be used as value
identifiers two ways, resolving issue 0089:
1. Backtick raw identifier: a leading backtick (`s2) lexes to an
.identifier token carrying a new Token.is_raw flag, with the backtick
excluded from the text. A raw identifier is never type-classified — the
parser skips Type.fromName for it — so it is always a value identifier.
The flag threads to VarDecl.is_raw / Param.is_raw at binding sites, and
the reserved-type-name check (UnknownTypeChecker) skips raw bindings.
Because the token tag stays .identifier, the escape works in every
position (local, global, param, field, fn name, struct member, later
reference) with no per-site parser change.
2. #import c exemption: c_import.zig synthesizes foreign decls with
Param.is_raw = true, so generated C param names that collide with
reserved type names (s1, s2) import unedited.
A bare reserved-name binding in sx still errors (issue 0076 preserved):
the is_raw-gated skip only fires for backtick / foreign names, and a raw
binding's address-of / autoref lowering stays correct because every
occurrence is an .identifier, never a .type_expr.
Tests: examples/0151 (backtick, every position),
examples/1220 (foreign exemption, compiled+run), lexer unit tests.
1119 (bare-binding rejection) stays green. specs.md + readme.md updated.
emitCmpNe lowered float `!=` to `LLVMRealONE` (ordered not-equal), which
is false when either operand is NaN. That made `nan != nan` false in
native code — breaking the canonical `x != x` NaN test, making `!=`
non-complementary with `==` for NaN, and disagreeing with the interpreter.
Change the float predicate to `LLVMRealUNE` (unordered not-equal): true
if either operand is NaN OR they are unequal. For all non-NaN operands
`UNE` ≡ `ONE`, so only NaN-involving comparisons change (toward correct).
The integer predicate (`LLVMIntNE`) and `emitCmpEq` (`OEQ`) are unchanged,
so `nan == nan` stays false and `!=` is now the exact complement of `==`.
- Regression: examples/0150-types-float-ne-unordered-nan.sx (fails before,
passes after; also pins #run/comptime == runtime agreement).
- specs.md: documents float comparison / NaN semantics (Operators).
- Resolves issue 0091 (issues/0091-float-ne-ordered-nan.md).
A field-like access on a builtin INTEGER type name folds to a compile-time
constant of the queried type, driven by (width, signedness) arithmetic:
sN: min=-(2^(N-1)), max=2^(N-1)-1; uN: min=0, max=2^N-1
for every width s1..s64 / u1..u64 (not just power-of-two), plus usize/isize.
- type_resolver.zig: extract the single width parser (parseWidthInt) reused by
resolveNamed AND the new accessors (no second parser — issue-0083 class);
add resolveBuiltinName / integerWidthSign / integerLimitBits / integerLimitFor.
- lower.zig: lowerNumericLimit intercept beside the error.X / Struct.CONST /
pack-arity identifier-receiver intercepts; folds ints via constInt, emits a
clean diagnostic for a non-numeric receiver (bool/string/void/Any/noreturn),
falls through for floats (NL.2).
- expr_typer.zig: mirror the result type so inferExprType reports the queried type.
- program_index.zig: recognize the accessors in the comptime-int / array-dim path
so [u8.max]T (255) / [s16.max]T (32767) work; [u64.max]T is rejected oversized.
- u64.max / usize.max stored as the all-ones bit pattern with TYPE u64 (i64 -1),
asserted via union { u: u64; s: s64 } reinterpret.
Docs: specs.md numeric-limits subsection (formulas + result-type + u64 note);
readme.md language overview. Examples 0148 (positive) / 0149 (negative-receiver).
Unit tests for the value computation in type_resolver.test.zig.
Gate: zig build, zig build test (359/359), tests/run_examples.sh (416 ok, 0 failed).
Per Agra ruling: user-facing sx changes must update all relevant docs
including readme.md. Replaces the prior 'Original syntax sketches. Do
not modify.' rule so the docs-track-changes review criterion is
enforceable from the next step onward.
The count description claimed every count must be "positive integral",
which is wrong: zero is context-dependent. Verified at HEAD — an array
dimension (`[0]s64`) and a generic value-param count (`Box(0)`, $N:u32)
both accept zero as a length-0 instantiation, while a `Vector` lane
count stays strictly positive (`Vector(0,f32)` rejected). Negatives are
rejected for array dims and unsigned value-params, but a signed
value-param accepts a negative; only the integral requirement (folds
4.0, rejects 4.5) is common to all three.
Split the count paragraph into per-consumer bullets stating the exact
range each accepts. Range-bound paragraph unchanged. Pin the zero
contrast with examples 0147 (array-dim + value-param zero accepted) and
1505 (Vector zero-lane rejected). No compiler-code change.
specs.md lumped `inline for` / `for` range bounds in with counts (array
dimension, Vector lane count, generic value-param count) under the
count negative-rejection rule. A range bound is a range ENDPOINT, not a
count: negative endpoints are valid and an empty/inverted range runs zero
iterations. The compiler already implements this correctly (Agra ruling:
spec-text bug, no code change).
- specs.md: counts and range bounds are now described separately. Counts
reject negatives; bounds accept any compile-time integer (negatives
valid, integral floats fold) but still reject a non-integral float
because the loop cursor must be an integer.
- examples/0612-comptime-inline-for-range-bounds.sx: `inline for -2..1`
and `for -2..1` both sum -3; `inline for 0..(-2.0)` runs zero
iterations (empty range). Runtime/comptime parity asserted.
- examples/1138-diagnostics-inline-for-non-integral-bound.sx: a
non-integral float bound `inline for 0..4.5` is a clean diagnostic,
exit 1 (must-be-integer still applies to bounds).
Count consumers (1132/1133/1134/1135) unchanged and green.
A failed value-param bind on a type-returning function (e.g.
`MakeC :: ($K: Count, $T: Type) -> Type { return [K]T; }` with
`a : MakeC(5_000_000_000, s64)`) emitted its correct range diagnostic
but then `instantiateTypeFunction` returned `null`, so
`resolveParameterizedWithBindings` fell through to an empty-struct
placeholder named after the function. The binding `a` got that
placeholder type, so a later `a.len` cascaded a bogus second error
`field 'len' not found on type 'MakeC'`.
The struct binder (`instantiateGenericStruct`) already returns
`.unresolved` here; the type-fn binder now matches it — a 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 an aliased
constraint, a non-const arg, and an unknown type arg.
Regression: examples/1137-diagnostics-value-param-type-fn-no-cascade.sx
Three adjacent cells of the shared count surface still diverged from the
rest; all now route through the same leaf+fold+narrow+diagnose path.
1. Aliased integer constraint bypassed the value-param range gate — only
builtin constraint names matched intTypeRange, so Box(5_000_000_000)
with `$K: Count` (Count :: u32) compiled and bound a truncated value.
resolveValueParamArg (shared by both the struct AND type-fn binder) now
resolves the constraint to its underlying builtin via
canonicalIntConstraintName (Count -> u32, Small -> s8) 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 — moduleConstInt read only a literal RHS node. It now folds
every const's RHS through the shared evalConstIntExpr, cycle-guarded
(mutual / self cycles fold to null, not a stack overflow), and pass-0
pre-registers expression-RHS consts. N :: M + 1 == 3 at every consumer:
dim (direct + alias), Vector lane, value-param (struct + type-fn),
inline for.
3. Stateful resolveArrayLen still fabricated length 0 after a failed fold;
it now returns null -> the .unresolved sentinel (no fabrication). The
binding's lowering never reaches sizeOf (alloca defers it; hasErrors
aborts first) and a field access on an already-diagnosed .unresolved
value is poison-suppressed (emitFieldError), so a failed-fold dim emits
ONE clean diagnostic with no panic.
Regressions: examples/0146 (full positive matrix — every consumer x leaf
form), 1135 (aliased u32 + s8 overflow), 1136 (direct non-const dim halts
cleanly). The cascade cleanup also tightened 1502/1503 to one diagnostic.
Unit test added for moduleConstInt expression-folding + cycle detection.
Item 2 (Agra ruling): a compile-time INTEGRAL float (`4.0`, `N : f64 :
4.0`, `N :: 4.0`) used as an array dimension / Vector lane / generic
value-param count / `inline for` bound now folds to its integer at the
shared leaf — `program_index.floatToIntExact`, used by both the
`.float_literal` arm of `evalConstIntExpr` and `moduleConstInt`. All four
consumers route through the one evaluator, so `[4.0]s64` lays out the same
`[4]s64` uniformly; a non-integral (`4.5`) or negative value stays
rejected by the downstream `foldDimU32` gate. Pass-0 now pre-registers
float-valued module consts for forward-alias parity with int consts.
Item 1: a generic value-param bind (`Box($K: u32)`) never range-checked
the folded arg, so `Box(5_000_000_000)` compiled and ran. The bind now
range-checks against the param's declared type — a `u32` count through the
shared `foldDimU32` gate (making program_index's "single u32 gate for
value-param counts" doc true), any other integer type through the new
`program_index.intTypeRange` — and emits a clean "value N does not fit in
u32 parameter K" otherwise. The declared type is threaded via a new
`TemplateParam.value_type`.
Regressions: examples 0145 (integral-float array dim), 1504 (Vector lane),
0611 (inline-for bound), 0209 (value-param integral-float), 1132
(non-integral float dim rejected), 1133 (negative float dim rejected),
1134 (oversized u32 value-param rejected) + program_index float-fold unit
tests. Gate: zig build, zig build test, 406/0 run_examples.
The stateless alias-registration array-dim path collapsed foldDimU32's
distinct .too_large / .below_min outcomes into null, so an oversized type
alias (Big :: [5000000000]s64) emitted the FALSE 'an array dimension is not
a compile-time integer constant' message while the direct form correctly
reported 'array dimension 5000000000 does not fit in u32'.
Add program_index.reportDimError as the single source of dim-error wording
(the stateful path now emits through it too) and type_bridge.foldArrayDim to
surface the DimU32 reason at the alias-registration site. An oversized/negative
alias dim now routes to reportDimError for the same precise message as the
direct form; a genuinely non-const alias dim keeps the alias-specific message.
Regression: examples/1131-diagnostics-array-dim-oversized-u32-alias.sx
Two remaining siblings in F0.4's comptime-int path.
1. Type-returning function with a value param used as a TYPE annotation
(`b : Make(N, s64)` where `Make :: ($K: u32, $T: Type) -> Type`):
- `isValueParamPosition` (semantic_diagnostics) now also skips a value
param of a `fn_ast_map` type-returning function, so `N` is not walked
as the type name "N" ("unknown type 'N'").
- `resolveParameterizedWithBindings` routes a type-returning-function
name to `instantiateTypeFunction` (the `.call` path already did).
- `instantiateTypeFunction` resolves a general return-type expression
(`return [K]T`) with bindings active — not just struct/union returns.
`Make(N, s64)`, `Make(M + 1, s64)`, `Make(3, s64)` all resolve to one
`[3]s64`.
2. Oversized dim/lane fold panicked the compiler (0087): an array dim /
Vector lane folded to a valid i64 (5e9) then narrowed to u32 with an
unchecked `@intCast`. New single gate `program_index.foldDimU32` folds
via `evalConstIntExpr` then range-checks `[min, maxInt(u32)]`; the three
narrowing sites (resolveArrayLen stateful + stateless, resolveVectorLane)
all route through it and emit a clean diagnostic + halt instead of
panicking. Value-param args stay i64 until used as a dim/lane, where the
same gate checks them.
Regressions: examples/0208 (value-param type function), examples/1130
(oversized array dim clean halt), examples/1503 (oversized Vector lane
clean halt). Marks issue 0087 RESOLVED.
Gate: zig build, zig build test, bash tests/run_examples.sh — 398 passed,
0 failed, 0 timed out.
While fixing 0083 (attempt 5) noticed a distinct, pre-existing bug:
writing to a Vector component (`v.x = 1.0`) aborts with "unresolved type
reached LLVM emission" in emitStore. Reading a lane works; a literal lane
count triggers it, so it is NOT the lane-count class. Confirmed
reproducible on the pristine pre-attempt-5 compiler (not introduced by
the lane-count fix). The standard vector idiom (`.[…]` construction +
component reads / arithmetic, examples/1500) is unaffected. Filed for a
separate session; not worked around here.
Attempts 1–4 fixed the array-dimension paths but the same length-0
fabrication class survived on every other site that resolves a
compile-time integer. Unify them all on the single shared
`program_index.evalConstIntExpr` so they cannot diverge:
- All three Vector lane resolvers (resolveTypeCallWithBindings,
resolveParameterizedWithBindings, resolveArrayLiteralType) and both
generic value-param binders (instantiateGenericStruct,
instantiateTypeFunction) hand-rolled an `else => 0` switch. A
module-const lane `Vector(N, f32)` fabricated a 0-lane `<0 x float>`
(LLVM "huge alignment" abort); a value-param `Vec(N, f32)` fabricated
a 0 binding / wrong mangled name. They now fold through the shared
evaluator and emit a clean diagnostic + `.unresolved` on a non-const
operand (resolveVectorLane / resolveValueParamArg) — never 0.
- evalComptimeInt (inline-for bounds) delegated to the shared evaluator,
so `inline for 0..M` / `0..(M+1)` fold like array dims. The `<pack>.len`
leaf moved into the shared folder via a new `ctx.lookupPackLen`.
- The unknown-type semantic checker no longer walks a value-param
position (`Vector(N, …)` / `Vec(N, …)`) as a type name (was reporting
"unknown type 'N'").
- The parameterized-type-arg parser and the function-body 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 array-dim sibling that the same heuristic broke).
Regressions: examples/1501 (named-const + const-expr lane, direct +
alias, 3/4-lane reads), 1502 (runtime lane clean-halts, exit 1, no LLVM
crash), 0207 (Vec(N)/Vec(M+1) == Vec(3) instantiation), 0610 (inline-for
const bounds). Shared-evaluator unit test extended with the pack-len arm.
zig build && zig build test && bash tests/run_examples.sh: 395 passed,
0 failed.
A constant-FOLDABLE expression array dimension (`[M + 1]`, `[M * N]`,
`[N - M]`, nested `[M + N - 1]`, parenthesised `[(M + 1) * 2]`, mixing
untyped and typed module consts) was wrongly rejected as "not a
compile-time integer constant" even though every operand is
compile-time-known. Attempts 1-3 resolved only a bare named-const dim or
a literal; an expression dim 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 over literals and
named/typed module consts, recursively (parentheses carry no AST node).
The leaf-name lookup is delegated via `ctx.lookupDimName`, so the
stateful body-lowering path (`Lowering`, which also sees comptime
constants and generic `$N` values) and the stateless registration path
(`type_bridge.StatelessInner`, module consts only) share the EXACT SAME
folding logic and cannot diverge — an expression dim via a type alias
resolves identically to the direct form.
No-fabrication discipline unchanged: a genuinely non-comptime dimension
(runtime local, non-comptime call, unbound name) or arithmetic that
overflows / divides by zero still yields null -> `.unresolved` -> the
same clean compile-halting diagnostic, never a fabricated length.
- examples/0144-types-const-expr-array-dim.sx: every expression form,
direct vs alias, scalar / string / struct element types (fails on the
pre-fix compiler, passes after).
- examples/1129 re-pointed at a genuinely non-const dimension
(`[get()]s64`, a runtime call) so it still proves the stateless
clean-halt (a foldable expression is no longer an error).
- program_index.test.zig: unit test for evalConstIntExpr folding and
clean-halt-on-non-const.
A type alias whose dimension is a named const (`Arr :: [N]T`) resolves its
dimension eagerly during scanDecls pass 1, on the stateless registration path,
which can only read `module_const_map`. Typed consts (`N : s64 : 16`) register
only in pass 2 and a forward-declared untyped const had not registered yet, so
the stateless resolver saw an empty table, printed a non-fatal warning,
fabricated length 0, and continued — yielding a 0-byte alloca, garbage reads,
and a segfault for slice/struct elements.
- scanDecls pass 0 pre-registers every integer-valued module const before any
type alias resolves, so typed, untyped, and forward-referenced consts all
resolve identically.
- Both dim resolvers now share `program_index.moduleConstInt`, so the stateful
body-lowering path and the stateless registration path cannot diverge.
- `resolveArrayLen` returns `?u32`; `resolveCompound` yields `.unresolved` on
null instead of a 0-length array. The stateful path emits a diagnostic; the
alias-registration path surfaces an unresolved alias as a clean compile error
that aborts the build. The Vector lane-count `else => 0` is fixed the same way.
Regressions: examples/0143 (typed-const dim direct + via alias for s64/string/
struct, forward-ref alias, nested) and examples/1129 (an unresolvable computed
dim halts with a clean diagnostic + non-zero exit). Both fail on the pre-fix
compiler (garbage/segfault; warning+exit0) and pass after.
Makes the F0.4 fixes exhaustive across every resolution / nesting path.
0083 — named-const array dimension, stateless paths. Attempt 1 fixed the
stateful resolver (direct local decls, struct fields, params, returns) but the
binding-free registration-time resolver (`type_bridge`, used for type aliases
`Arr :: [N]T` and inline union/enum field types) still resolved a named dim with
a silent `else 0`, so `Arr :: [N]s64; a : Arr` and `union { a: [N]s64 }` were
still miscompiled (garbage / bus error). Thread the module-global const table
(`ProgramIndex.module_const_map`) 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 dim on
the binding-free path, which the stateful path hard-errors) now bails LOUDLY
instead of fabricating a 0 length.
0085 — nested slice-literal elements. `lowerArrayLiteral` lowered each element
with the element type as target but appended the raw value. A nested `.[...]`
element at a slice element type (`[][]s64`) still lowers to an aggregate array
`[N]T`, so the outer aggregate held raw arrays where slice {ptr,len} headers
were expected — indexing the inner slice read a garbage pointer and segfaulted.
After lowering each element, coerce a same-element array to the slice element
type via the existing `array_to_slice` op. The coercion recurses with the
nesting, so `[][]T` and deeper materialize at every level — local-bound AND
direct-call-argument forms.
Regressions (fail-before/pass-after demonstrated on the pre-fix compiler):
examples/0140-types-named-const-array-dim.sx — extended with type-alias,
nested [N][M]T, and union-field named dims (s64 / string / struct elems)
examples/0142-types-nested-slice-literal-elements.sx — [][]s64 + [][]string,
local-bound vs direct-arg
src/ir/type_bridge.test.zig — named-const dim resolves to literal length
Gate: zig build, zig build test, bash tests/run_examples.sh (388 passed).
Issues 0083 and 0085 marked RESOLVED.
Two silent-miscompile codegen fixes:
0083 — named-const array dimension. `TypeResolver.resolveCompound`'s array
arm resolved the dimension with `if int_literal ... else 0`, so a named const
(`N :: 16; [N]T`) hit the silent `else 0`: the array became 0-length / 0-byte
and element access ran out of bounds (garbage for scalars, bus error for
slice/pointer/struct elements). The arm now delegates the dimension to
`inner.resolveArrayLen` (symmetric with `inner.resolveInner` for the element).
The stateful `Lowering.resolveArrayLen` evaluates it as a compile-time integer
across the comptime-constant / generic-value / module-global const tables and
emits a diagnostic — no fabricated length — when it isn't one.
0084 — `.[...]` literal passed directly as a call arg. `lowerArrayLiteral`
always yields an aggregate array value; the array→slice conversion is the
caller's job. The local-bound var-decl path did it, but the call-arg coercion
path had no array→slice arm, so `classify([N]T, []T)` returned `.none` and the
raw array was passed where a slice was expected (callee read its {ptr,len}
header off the wrong bytes → 0 / garbage / segfault). `classify` now returns a
new `.array_to_slice` plan for same-element `[N]T → []T`, and `coerceToType`
emits the existing `array_to_slice` op — identical to the local-bound path.
Regressions (fail-before/pass-after demonstrated on the pre-fix compiler):
examples/0140-types-named-const-array-dim.sx (s64 + string + struct elems)
examples/0141-types-slice-literal-direct-call-arg.sx (string + []s64)
Gate: zig build, zig build test, bash tests/run_examples.sh (387 passed).
Issues 0083 and 0084 marked RESOLVED.
Example 0717 now asserts the (token, index) Diag for ALL SIX raise sites
in cli.sx, closing the two the reviewer found still unasserted:
- zero-arg UnknownCommand: parse([], ...) -> index -1, token ""
(the args.len == 0 sub-branch of cli.sx:237, distinct from the
one-arg too-few form already covered at index 0 / token args[0]).
- TooManyFlags (cli.sx:256): a command declaring 17 flag specs (> the
inline 16 cap) is rejected, not truncated -> index -1, token command.
The three index==-1 cases (zero-arg, too-many, missing-req) seed their
Diag with a sentinel before parse, so each assertion proves parse WROTE
the -1/"" rather than merely matching the `.{}` default. Verified
non-vacuous: flipping any expected value makes that line FAIL.
Test-only: cli.sx logic and src/ are untouched.
Extend example 0717 to pin the offending token VIEW and its args index
for every failure the parser's Diag populates: unknown-command,
unknown-group, too-few-args, missing-value, value-eats-flag, and the
missing-required index. Closes the test-coverage gap flagged in review;
cli.sx parser logic unchanged.
Extend std/cli.sx with a zero-heap argument parser that the caller drives
over a logical argv ([]string), separate from the F3.1 os_args accessor.
Grammar: <group> <command> [--flag VALUE | --bool]... [--json] [-- rest...]
- (group, command) dispatched against a caller-provided Command table;
no match -> error.UnknownCommand.
- value-taking vs boolean flags fixed by each command's FlagSpec list;
--json is a reserved global boolean surfaced as parsed.json.
- `--` or the first bare operand ends flag parsing; the remainder is
parsed.rest (operand views).
Heap discipline (heap-discipline.md): zero heap, zero copy. group/command/
flag values/rest are all VIEWS into args. Parsed is a by-value stack struct;
flag presence/values live in a fixed [16]FlagValue inline array indexed by
spec position (no per-flag allocation, no context.allocator). The flag-spec
list and command table are caller storage passed as views.
Failure surfacing (no silent skip): unknown command, unknown flag, a
value-flag missing its value, and an absent required flag each raise a
specific CliError variant; a caller-owned Diag records the offending token
(index + view) before each raise, since error tags carry no data.
examples/0717 drives the parser over explicit []string vectors: a valid
group/command/--flag/--bool/--json case (asserting parsed values + that
values are views into argv), subcommand dispatch, `--`/bare-operand
separators, and the five failure variants each asserted via destructure +
Diag. zig build && zig build test && run_examples.sh green (385 passed).
The global-init constant serializers in emit_llvm.zig printed a diagnostic
on an unserializable value and then RETURNED an undef/null placeholder and
CONTINUED emitting. For a comptime `#run` global that yields a function
reference (`fp :: #run pick();` where pick returns a function), the build
fell through to the JIT and segfaulted calling through the undef pointer
(exit 134) — a silent miscompile dressed up as a printed error.
Route every genuine bail in the serialization family through a new
`failGlobalInit` helper: it sets `comptime_failed` (so core.generateCode
aborts with a non-zero exit after emit()) and returns an undef placeholder
that never ships, because the halt fires before object emission / JIT. This
covers the comptime func_ref leaf, the require_resolved aggregate func_ref
leaf, the top-level + vtable func_ref globals, the comptime-init catch, and
the remaining heap-walk / aggregate-shape bails. Unresolved-function
diagnostics now name the function instead of its (stdlib-unstable) IR index.
The require_resolved=false Pass-0 placeholder is unchanged (func_map is
empty until Pass 1; the aggregate is re-emitted with require_resolved=true).
Regression: examples/1128-diagnostics-comptime-global-funcref-rejected.sx —
a `#run` global returning a function ref now exits 1 with the diagnostic
(was: exit 134 segfault). Fail-before/pass-after verified.
A module-global initialized with an enum literal silently zero-initialized
to the first tag (`chosen : Color = .green` read back as `.red`), and an
enum tag inside a global array/struct was rejected as non-constant. The
constant serializer had no enum-literal arm.
Add `Lowering.constEnumLiteral`: serialize an enum literal to a
`ConstantValue.int` holding the variant's tag value, resolved against the
destination enum type and respecting explicit variant values; the global's
type drives the backing width at emit time. Wired into `globalInitValue`
(scalar global) and `constExprValue` (array element / struct field / nested
aggregate). A non-enum destination or unknown variant is diagnosed loudly,
never silently zero-initialized. The compiler-injected OS/ARCH globals now
serialize to their real `.unknown` tag (6 / 4); runtime reads are unchanged
(they resolve through comptime_constants), so only the static initializer in
the pinned .ir snapshots changes.
Remove the silent `func_ref => orelse LLVMConstNull` fallbacks in the LLVM
constant emitters: 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, array,
struct field, explicit-value enum u16 stride, struct-array with enum field);
negative: examples/1127-diagnostics-global-enum-literal-bad-variant.sx.
Mark issue 0082 RESOLVED.
A module-global aggregate initializer rejected a `null` literal in a
pointer (or optional-pointer) field as "must be initialized by a
compile-time constant". `Lowering.constExprValue` had no `.null_literal`
arm, so the null leaf returned no constant and the whole aggregate looked
non-constant — even though `null` is the compile-time zero pointer (a
top-level scalar `p : *s64 = null;` already serialized fine).
Add `.null_literal => .null_val` to constExprValue. While here, make the
two LLVM constant emitters exhaustive: emitConstAggregate and the
top-level init_val switch in emit_llvm.zig previously ended in a silent
`else => LLVMConstNull(...)` catch-all (the 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). The
reject-loud path for genuinely non-constant fields is preserved.
Regression: examples/0138 (array-of-struct null ptr fields, array of
all-null pointers, nested struct-in-struct null ptr) and the negative
examples/1126 (null ptr field beside a non-const field still errors).
Fail-before/pass-after verified.
A module-global array of struct literals (`pairs : [2]Pair = .[ .{...}, .{...} ]`)
was emitted as `zeroinitializer`, silently dropping every declared field — reads
returned 0 with no diagnostic. Global struct literals and struct-with-array
already worked; the gap was struct literals used as ARRAY elements.
Root cause: `Lowering.constExprValue` (the const-aggregate serializer for global
initializers) had no `.struct_literal` arm. `constArrayLiteral` serialized each
element through `constExprValue`, so a struct-literal element returned null,
collapsing the whole array initializer to null; `globalInitValue` then emitted no
payload and the LLVM backend zero-initialized the global — the same silent-zero
class as 0071/0072, one level inside an array literal.
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 silently zeroing. `emitConstAggregate`
already recurses over nested aggregates, so `sx run` (JIT) and `sx build` (AOT)
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.
Marks issues 0079 (already resolved) and 0080 RESOLVED.
A store to a module-global array element (`g[i] = v`) was silently dropped:
a subsequent `g[i]` read the array's initializer, not `v`. Constant index,
variable index, and cross-function stores were all affected, in both `sx run`
and `sx build`. Global scalars and local arrays were fine.
Root cause: `Lowering.lowerExprAsPtr` (the lvalue/address path) handled only
local identifiers. A module-global identifier fell through to the value
fallback `lowerExpr`, which emits `global_get` — loading the whole array by
value. The LLVM backend's `emitIndexGep` then allocas a throwaway temp, copies
the value in, and GEPs into the temp, so the store wrote a discarded copy.
Fix: teach `lowerExprAsPtr`'s identifier arm about globals — emit `global_addr`
(a pointer into the global's live storage), or `global_get` for a pointer-typed
global (mirroring the local pointer case). Route the `address_of(index_expr)`
array base through `lowerExprAsPtr` too so `&g[i]` is likewise an lvalue into
the global. `index_gep` now GEPs directly into the global for const and variable
index, across functions. This also fixes global struct field stores, which
shared the same root cause.
Regression: examples/0136-types-global-array-element-store.sx (const-index,
var-index, cross-function store on a scalar global array; struct-element array
for stride; nested-array global for the recursive lvalue). Fails on the pre-fix
compiler, passes after.
Add library/modules/std/cli.sx: a pure-sx command-line argument accessor
backed by the macOS C runtime (_NSGetArgv/_NSGetArgc), no compiler change.
os_argc() -> s64
os_args(buf: []string) -> []string
Zero heap, zero per-arg allocation: os_args fills a caller-provided buffer
(stack array) with string VIEWS over the process's own argv block, which
lives for the whole process. The returned slice header is a by-value stack
return; nothing touches context.allocator.
Documents the `sx run` reality: under `sx run <prog.sx> ...` the process
argv is the interpreter's argv (sx, run, prog.sx, ...), not a program's
logical args. This accessor reports the real process argv truthfully;
mapping to logical args is a later consumer concern (distribution P3.1).
Non-macOS platforms bail loudly (message + _exit) rather than returning a
silent empty.
examples/0716-modules-cli-argv.sx asserts only deterministic structural
invariants (argc >= 1, argv[0] non-empty, os_argc() == filled length).
Add 0715-modules-json-suite as the single comprehensive pinned suite for
std.json (mirrors 0711 for std.hash), alongside the focused 0713/0714 demos:
- ROUND-TRIP build->write->parse->write over a document covering EVERY value
kind (a string with every escape form \" \\ \b \f \n \r \t plus a \u00XX
control, integers 0 / negative / s64 MIN / s64 MAX, bool, null, array,
nested object) with insertion-order assertions, exact writer bytes, and
parse-then-rewrite idempotence.
- DECODE positives: \/, the full named-escape set, \uXXXX (BMP 1- and 2-byte)
plus a surrogate pair, the escaped control forms, and raw multi-byte UTF-8
round-tripping through writer + reader.
- MALFORMED matrix: one assertion per JsonParseError variant and its key
edges (UnexpectedToken, UnexpectedEnd, BadEscape, BadNumber incl. leading
zero / lone '-' / fraction / exponent / overflow, TrailingGarbage,
BadControlChar), each asserted to raise.
Pure test work: src/ and library/ untouched, no json.sx change needed. Every
model is built through an explicit Arena allocator (heap discipline).
parse_string scanned for `"` and `\` but accepted every other byte,
including raw control characters. RFC 8259 §7 requires those bytes to be
escaped inside a string; an unescaped one is invalid JSON and must surface
a parse error, not be silently accepted.
Add `BadControlChar` to JsonParseError and reject any unescaped byte < 0x20
in the string body scan (which gates the decode path too, so escaped forms
like \t/\n/ still decode correctly; 0x20 and 0x7F are not over-rejected).
Regression test in examples/0714: raw 0x09/0x0A/0x00 each raise
BadControlChar via `?`/`!`; a positive case proves the escaped forms still
decode to the right bytes. All prior assertions kept.
Issue 0078 (string == as an and/or operand emitting an invalid PHI) is
resolved on this branch, so the example no longer needs the split that
worked around it. Restore the natural combined assertion
sub.items[0].key == "k" and sub.items[0].val.str == "v"
(one nested-pair report), and the in_range containment helper to
return x >= lo and x < hi;
Drop the now-stale issues/0078 references. Re-captured expected stdout
(nested-key/nested-val -> nested-pair). json.sx and src/ untouched.
A string `==`/`!=` used as an operand of a short-circuit `and`/`or` emitted
invalid LLVM (`PHI node entries do not match predecessors!`). String compares
expand into their own memcmp sub-CFG during LLVM emission, so the operand
finishes in a later basic block (`str.merge`) than the one the IR block
started in. `fixupPhiNodes` wired the short-circuit merge PHI's incoming edge
to `block_map[ir_block]` (the block the IR block started as), recording a
stale predecessor (`%entry`/`%and.rhs.0`).
Fix: record the builder's actual insertion block after emitting each IR
block's instructions (`term_block_map`, via `LLVMGetInsertBlock`) and use it
as the PHI predecessor. General — corrects the incoming block for any operand
that emitted intermediate basic blocks (string `==`, value `match`, …), not
just string `==`.
Regression: examples/0045-basic-string-eq-short-circuit.sx (string `==` on
both sides of `and` and of `or`, plus a match-value + enum-payload `==` shape).
Fails (LLVM abort) pre-fix, passes after.
Add the JSON reader (parser) to library/modules/std/json.sx, the inverse
of the F2.1 writer over the same value model: insertion-ordered objects,
arrays, strings (full unescaping incl. \uXXXX + surrogate pairs), s64
integers, bool, null.
Heap discipline (binding): exactly two allocation kinds, both through the
EXPLICIT `alloc` parameter, never the implicit context allocator —
composite backing stores (Array/Object.items via add/put) and decoded
escaped-string buffers (bounded by the raw span). Un-escaped string
values are zero-copy VIEWS into the input buffer (valid only while it
lives); scalars carry no heap.
Failure surfacing (hard contract): malformed input raises a meaningful
JsonParseError variant (UnexpectedToken / UnexpectedEnd / BadEscape /
BadNumber / TrailingGarbage) on the error channel, never a bogus value.
Trailing non-whitespace is TrailingGarbage; fractions/exponents,
out-of-s64 magnitudes, and leading zeros are BadNumber. Number
accumulation runs in negative space so s64 MIN parses exactly.
examples/0714-modules-json-reader.sx asserts the parsed structure
(insertion order, every kind), proves the view-vs-decoded heap split by
pointer containment, round-trips back through the writer byte-for-byte,
decodes a surrogate-pair into 4 UTF-8 bytes, and checks every malformed
variant.
Filed issues/0078: a string `==` (or any sub-CFG operand) used in a
short-circuit `and`/`or` emits invalid LLVM IR (stale PHI predecessor),
hit while writing the example's assertions and worked around there by not
combining comparisons with `and`/`or`. src/ untouched.
Close the coverage gap from attempt 1: example 0713 now builds integer
fields holding s64 MIN (-9223372036854775808) and s64 MAX
(9223372036854775807) — plus zero, a small negative, and a small positive —
and asserts the EXACT emitted bytes. This permanently pins the edge that
write_int is specifically engineered for (folding positives into negative
space so MIN's non-representable-positive magnitude serializes correctly).
s64 MIN is expressed as (0 - 9223372036854775807 - 1) because its magnitude
is not a representable positive s64 literal.
Test hygiene: stream to a repo-local, gitignored .sx-tmp/ path (created if
missing) instead of a fixed /tmp name, and unlink it right after read-back
so nothing leaks. Writer/model logic and src/ are untouched.
Add library/modules/std/json.sx — the JSON value model and writer
(reader lands in a later step).
Value model: a tagged union over null/bool/integer(s64)/string/array/
object. Objects are an ORDERED list of (key,value) pairs preserving
INSERTION ORDER (no hash map, never sorted/deduped). Integers only — no
fraction/exponent this milestone.
Heap discipline:
- Scalars carry no heap; string values are VIEWS into caller memory
(never copied into the node).
- Composite nodes (Array/Object) own growable child storage, allocated
through an EXPLICIT allocator parameter on the builder methods
(arr.add(v, alloc) / obj.put(key, val, alloc), mirroring List.append)
— never the implicit context allocator.
- The writer adds ZERO output allocations: it emits into a caller-
provided Sink, either a fixed []u8 buffer (overflow raises, never
truncates) or streaming straight to an fs.File through a small caller
staging buffer (no whole-document string; peak memory O(staging)).
Integer digits format in a stack [20]u8; s64 MIN is handled by
formatting in negative space. Sink/IO/overflow surface on the !
error channel.
examples/0713-modules-json-writer.sx builds a nested object + array +
string with every escape kind + negative int + bool + null, then asserts
the EXACT bytes (insertion order, escaping) from both the buffer sink and
the file-streaming sink, plus the overflow-raises path.
Make the SHA-256 digest path allocation-free (foundation heap-discipline):
- final() and sha256_hex() now return the 64-char lowercase hex digest as
a [64]u8 by value on the stack; the cstring(64) heap allocation is gone.
- sha256_file() streams the file in fixed 64KB stack chunks via open_file/
File.read/File.close (defer-closed on every path) instead of slurping it
with read_file; peak memory is O(chunk), not O(filesize).
Tests (compare via a zero-copy string view over the [64]u8):
- 0710 updated to the by-value API (output unchanged).
- 0711 known-answer vectors: "", "abc", NIST-56/112, padding boundaries
{0,55,56,57,63,64,65,119,120}, and 1000 / 1,000,000 'a' repeats, each
pinned to its published digest (cross-checked with shasum -a 256).
- 0712 streaming equivalence (one-shot == byte-at-a-time == split-mid-block
== split-on-boundary) plus sha256_file(temp) == in-memory digest.
src/ untouched. zig build && zig build test && tests/run_examples.sh green.
Add a pure-sx streaming SHA-256 (FIPS 180-4) stdlib module, importable
as `#import "modules/std/hash.sx";`. All 32-bit word arithmetic is done
in s64 and masked back with `& MASK32`, so digests are deterministic and
platform-independent — no shelling out, no native crypto.
API:
- init() -> Sha256 (by-value *self pattern)
- update(*Sha256, string) (multi-block + partial-block buffering)
- final(*Sha256) -> string (32-byte digest as lowercase hex)
- sha256_hex(string) -> string (one-shot)
- sha256_file([:0]u8) -> ?string (digest of a file via fs.read_file)
Verified against FIPS/NIST known-answer vectors and `shasum -a 256`:
"" , "abc", the 56- and 112-byte multi-block vectors, 1000×'a', and the
64/65-byte block boundaries; chunked update() matches the one-shot call.
examples/0710-modules-sha256.sx pins the KAT vectors + the streaming
invariant; gate green (zig build, zig build test, run_examples 370/0/0/0).
The reserved-type-name binding diagnostic fired correctly but underlined the
enclosing statement / if / while / for / match / protocol / #objc_class block
because every binding-name check reused the parent `node.span`.
Thread each binding name's own span through the AST and parser, and pass it to
`checkBindingNames`:
- ast: add name spans to VarDecl, DestructureDecl, If/WhileExpr, ForExpr
(capture + index), MatchArm, Catch/OnFailStmt, Protocol/ForeignMethodDecl.
- parser: populate each span at the binding site from the name token's loc;
destructure reuses each target identifier's own span.
- semantic_diagnostics: every checkBindingName call now passes the binding's
own span — no site falls back to node.span. fn/lambda params already used
Param.name_span.
Carets now land on the offending identifier itself. New regression
examples/1125 asserts the protocol default-body and sx-defined #objc_class
method param spans; 0125/1119-1124 expected updated to the precise carets.
The reserved/builtin-type-name binding diagnostic was a hand-walked subset
of binding-bearing AST nodes with a silent `else => {}`, so each review
found another syntactic binding form that bypassed it and hit the original
LLVM verifier abort: destructure names (`s2, x := …`), `impl` method
params/locals, and `if` / `while` / `for` / match-arm / `catch` / `onfail`
captures.
Rewrite `checkBindingNames` (src/ir/semantic_diagnostics.zig) as an
EXHAUSTIVE `switch` over every `Node.Data` tag with NO `else` arm — a future
binding-bearing node type now fails to compile until it is handled here, so
coverage is enforced by the compiler instead of a hand-maintained list. The
check stays in the pre-lowering semantic pass rather than moving to the
`Scope.put` scope-registration choke point: lowering is lazy, so an
uncalled function's bindings never reach `Scope.put`, yet they must still be
rejected at their declaration (e.g. the never-called `takes_u8` in 1119).
No lowering special-case; `lower.zig` unchanged.
Regression tests (fail-before: LLVM abort or silent accept → pass-after:
clean diagnostic, exit 1):
- 1121 control-flow: destructure, if/while bindings, for capture+index,
match-arm capture
- 1122 impl-block method: reserved param AND reserved local
- 1123 catch + onfail tag bindings
- 1124 destructure name reserved in an imported module
Existing 0125 / 1119 / 0135 / 1120 tests kept; full suite 368 passed.
The issue-0076 reserved-type-name binding diagnostic only ran over main-file
decls, so an imported module (or the stdlib) could still declare `s2 := ...`
and reach lowering, where the address-of family loads the whole aggregate and
passes it by value to a `ptr` param — LLVM verifier abort.
Extend coverage to every compiled module: a dedicated `checkBindingNames` walk
(in semantic_diagnostics.zig) visits every var/`:=`/typed-local binding name and
function/lambda/struct-method parameter at any depth, with NO main-file filter,
descending the `namespace_decl` that a `mod :: #import` wraps so imported-module
decls are reached. It tracks each module's source_file (save/restore per node)
so the diagnostic renders against the imported module's text. Rejection still
defers to the parser's `Type.fromName` classifier; the unknown-type check (0064)
stays main-file-only. No lowering special-case; `.identifier`-only address-of
paths are unchanged.
Stdlib audit: the only reserved-name bindings under library/ were two `u1`
locals in ui/renderer.sx (UV coords) — renamed to u_min/u_max/v_min/v_max.
Regression test: examples/1120-diagnostics-imported-reserved-type-name.sx (+
companion mod.sx) — an imported `s2 := ...` now emits the clean diagnostic at
the import's declaration site (exit 1), not an LLVM abort.
Resolves issues 0076 (coverage extension) and 0077.
A value binding (local/global `var` or a parameter) spelled as a
reserved/builtin type name parses as a `.type_expr` rather than an
`.identifier` (parser.zig, via `Type.fromName`), so the address-of
family in lower.zig never saw a scoped local and mis-lowered it —
loading the aggregate and passing it by value to a `ptr` parameter
(LLVM verifier abort, or a silent `*self`-mutation-losing copy).
Add a declaration-site diagnostic in semantic_diagnostics.zig
(`UnknownTypeChecker.checkBindingName`): reject any parameter name or
`var` binding name (`:=` / typed-local / global forms) 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; the `.identifier`-only address-of paths are
correct once type-shaped names can never be bound. The rejected
attempt-1 `bareVarName` approach was never landed.
Tests:
- 0125-types-type-named-var-rejected: `:=` form (s2) rejected
(repurposed from the old test that asserted the now-illegal behavior).
- 1119-diagnostics-reserved-type-name-as-identifier: parameter (u8),
typed-local (s64, bool), `:=` (string) forms rejected.
- 0135-types-self-streaming-nonreserved: positive — `*self` streaming
with non-reserved names accumulates correctly via both call styles.
- 0904-optionals: renamed incidental locals s1/s2 -> filled/empty.
The trace docs predated the current formatter. Corrected against the real
output (library/modules/trace.sx to_string + examples/expected/1025-errors-
trace-format.stderr):
- error-handling.md: replace the obsolete trace example ("error trace:" /
"raised error.X" / "at func (file:line)") with the real format —
"error return trace (most recent call last):" + per-frame "func at
file:line:col" + source line + caret.
- debugger.md: drop the stale "(planned)" marker on the trace formatter
(it is implemented); the tag-name table note now cites the failable-main
reporter's "unhandled error reached main: error.X" line, not a
nonexistent "raised error.X" trace line.
The `type_name` / `type_eq` reflection builtins resolved their Type arg's IR
type via `getRefIRType(...) orelse TypeId.s64`, then gated `== .any`. A failed
must-succeed lookup silently became `.s64` (`!= .any`), classifying a boxed
`Any` arg as bare i64 and reading the wrong value with no diagnostic.
Add the sibling classifier `LLVMEmitter.reflectArgRepr`, which routes the
lookup through `argIRTypeOrFail` (the issue-0074 `.unresolved` resolver) and
returns `{ boxed, bare, unresolved }`. The three emit sites in ops.zig
(`type_name` + `type_eq` x2) now switch on it: `.boxed` extracts the Any value
field, `.bare` uses the value directly, `.unresolved` hits a hard `@panic`
tripwire — never silently treated as bare. Real args always resolve, so the
happy path is byte-identical (suite stays 361/0, zero snapshot churn).
Secondary `lower.zig` `null_literal`/`undef_literal => target_type orelse .void`
confirmed intentional (typeless-literal default deliberately handled by
emitConstNull/emitConstUndef as null-ptr / undef-i64) — left with an invariant
comment, not the `.unresolved` tripwire.
Regression test in emit_llvm.test.zig asserts the loud path: fail-before with
`orelse .s64` yields `.bare`; pass-after yields `.unresolved`.
Discovered during the 0074 fix + a codebase-wide silent-type-fallback sweep.
getRefIRType(...) orelse TypeId.s64 at ops.zig:1023/1049/1055 (type_name/type_eq).
Blocker; to be resolved before the arch-refactor stream closes.
Four FFI call-arg lowering sites resolved an argument's IR type via
`getRefIRType(arg_ref) orelse .void` — a silent fallback to the load-bearing
real type `.void`. A failed lookup there is a codegen invariant violation, but
`.void` is treated by downstream `toLLVMType` → `abiCoerceParamType` →
`coerceArg` as a legitimate void-typed foreign argument, corrupting the call
ABI with no diagnostic.
Add one shared resolver `LLVMEmitter.argIRTypeOrFail` that returns the
dedicated `.unresolved` sentinel on a failed lookup — never `.void`/`.s64` — so
the failure cannot masquerade as a real type and trips `toLLVMType`'s existing
hard `@panic` tripwire at the call site. Route all four sites through it:
- src/ir/emit_llvm.zig JNI constructor (NewObject) arg loop
- src/backend/llvm/ops.zig objc_msgSend arg loop
- src/backend/llvm/ops.zig JNI non-virtual call arg loop
- src/backend/llvm/ops.zig JNI Call<Type>Method arg loop
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) in src/ir/emit_llvm.test.zig asserts an
unresolvable FFI arg ref now yields `.unresolved`, not the old silent `.void`.
The interp's .trace_frame op only yields the packed value; the separate
sx_trace_push call op is executed by the interp as a foreign call via
host_ffi/dlsym, so the prior 'no sx_trace_push call runs' / 'never calls
sx_trace_push' phrasing was wrong. The packed low word is the op's
span.start (a source byte offset), not an IR instruction offset; renamed
every ir_offset/offset reference to span.start.
The compiled backend builds each trace Frame global as an LLVM named-struct
constant over the cached getFrameStructType() layout (file, line, col, func,
line_text) via LLVMConstNamedStruct -- a type-safe LLVM struct, not the sx
Frame TypeId / normal struct-emission path. Also correct the file field to
the source basename (full paths live in DWARF).
The .trace_frame op is niladic: it carries no operand and no GlobalId.
The compiled backend yields the interned Frame global's address as the
op's value (reflection.emitTraceFrame); the interpreter yields a packed
(func_id, ir_offset) as the op's value and never calls sx_trace_push
(recovered later by .trace_resolve). The sx_trace_push call is a separate
call op emitted by lower.zig at each push site, consuming the op's value.
Reword every passage that stated the old/wrong model: the niladic
invariant is about the op (not the push site emitting only one
instruction); reflection yields the op's value rather than lowering a
push; the interp returns the packed value rather than calling the foreign
sx_trace_push via host_ffi dlsym.
Name the niladic op `.trace_frame` (no `.trace_frame_push` op exists) in
the trace-path roadmap, matching the rest of the doc and src/ir/inst.zig.
Describe the `.trace_frame` arm as building/interning the Frame global and
yielding its address as the op's value; the separate sx_trace_push call is
emitted by the lowerer via normal call lowering, not by the arm itself.
Refresh the debugging architecture reference for the A7.2 relocation:
DWARF emission lives in src/backend/llvm/debug.zig (DebugInfo) and the
interned Frame / tag-name tables in src/backend/llvm/reflection.zig
(Reflection); emit_llvm.zig is the orchestrator that owns LLVMEmitter and
dispatches to them. Behavior is unchanged; only the file-and-function map,
the 'what's emitted' home, and the debugEnabled() owner are corrected.
Remove the legacy parallel type model's compiler-like surface. The
compiler pipeline resolves/lowers/lays out against canonical
src/ir/types.zig (TypeId/TypeTable); src/types.zig.Type is now strictly
editor-indexing + parse-time name metadata.
- src/types.zig: delete the type-resolution surface (widen, bitWidth,
isImplicitlyConvertibleTo) and every helper left dead once it was gone
(eql, isInt/isFloat/isSigned/isUnsigned, isTuple/isVector, and the
already-unused classification predicates isEnum/isUnion/isString/
isStringLike/isAny/optionalChild/sliceElementType/manyPointerElementType/
vectorElementType/isFunctionType/isClosureType/isCallable). Keep the Type
union plus the display/name-classification helpers sema/lsp/parser use
(fromName, fromTypeExpr, toName, displayName, isStruct/isOptional/isSlice/
isPointer/isManyPointer/isArray, pointerPointeeType). Seal the file with a
doc comment.
- src/sema.zig: inferExprType no longer calls Type.widen for arithmetic;
it approximates the display type as the left operand's (no second
resolver in the editor index).
- src/ir/type_bridge.zig: delete the dead bridgeType (legacy Type -> TypeId)
function + its sole sx_types import; resolveAstType and the AST->TypeId
path are untouched.
- src/ir/ir.zig: drop the bridgeType re-export.
- src/ir/type_bridge.test.zig: drop the two bridgeType tests (function gone).
Gate: zig build, zig build test (exit 0), tests/run_examples.sh 361/0,
zero examples/expected churn.
Remove the last compiler dependency on sema as semantic truth and stop
publishing as-you-type sema diagnostics from the LSP.
- core.zig: drop dead `Compilation.analyze()`, the `sema_result` field,
and the sema->diagnostics merge; drop the now-orphaned sema import.
The CLI pipeline (parse -> resolveImports -> generateCode) never called
analyze(), so this removes only dead code.
- lsp/server.zig: rename `analyzeAndPublish` -> `refreshEditorIndex` and
delete its sema-diagnostic publish (and the now-unused `semaToLspDiags`).
The editor index (doc.sema) is still refreshed for nav/refs/completion/
tokens. On-save/on-open diagnostics still come solely from the canonical
compiler pipeline in `runProjectCheck` (unchanged).
- Document sema as an editor-indexing API (doc.sema field comment).
Intended behavior change: as-you-type sema diagnostics no longer publish;
on-save canonical diagnostics are the sole source. CLI compile output and
the 361-example suite are unchanged (361/0, zero snapshot churn).
Move the final inline emitInst handler groups (terminators, box/unbox-Any,
reflection, switch-branch, closure-creation, vector, block-param, misc) into
the Ops facade in src/backend/llvm/ops.zig. emitInst is now pure dispatch:
every arm delegates to self.ops().*, leaving only setInstDebugLocation plus
one-line delegations.
Widen the shared infra the moved bodies reach (emitFailableMainRet, getBlock,
anyTag, isSignedTypeEx, coerceToI64/coerceToI64Signed/coerceFromI64,
emitFieldValueGet) to pub on LLVMEmitter; helper and ref-tracking sections
stay put. Pure relocation: emitted LLVM IR byte-identical, zero snapshot churn.
Relocate the struct, enum, union, array/slice, tuple, and optional
opcode handler bodies out of emitInst into the existing Ops facade.
Each moved arm now delegates via self.ops().emit<Op>(...); shared infra
stays on LLVMEmitter, with resolveAggregate/resolveGepStructType widened
to pub as the GEP handlers require. Pure relocation, behavior-preserving:
zero snapshot churn (361/0).
Relocate the Calls (objc_msg_send / jni_msg_send / call / call_indirect)
and Call-extensions (call_builtin / compiler_call / call_closure) emitInst
handler groups out of emit_llvm.zig into the existing Ops facade. Each
emitInst arm now delegates via self.ops().emit<Op>(...). Behavior-preserving
pure relocation; emitted LLVM IR is byte-identical (361/0 examples, no
snapshot churn).
Shared call infra stays on LLVMEmitter, widened pub only as the moved
bodies require: extractSlicePtr, loadJniFn, getObjcMsgSendValue, the math
F32/F64 declarators + types, getOrDeclareWrite/getWriteType, ffiCtors,
materializeByvalArg, emitCStringGlobal, emitJniConstructor, and the Jni
slot-offset constants. emitJniConstructor remains in emit_llvm.zig (A7.3
decision); the moved jni arm calls it via self.e.emitJniConstructor(...).
Relocate the `// ── Memory ──`, `// ── Globals ──`, `// ── Conversions ──`,
and `// ── Pointer ops ──` opcode handler bodies out of `emitInst` in
src/ir/emit_llvm.zig into the existing `Ops` facade in
src/backend/llvm/ops.zig. Each `emitInst` arm now delegates via
`self.ops().emit<Op>(...)`. Widen `emitConversion`, `coerceArg`, and
`getRefIRType` to `pub` (the only helpers the moved bodies call).
Pure relocation: zero snapshot churn.
Move the Constants/Arithmetic/Bitwise/Comparisons/Logical opcode handler
bodies out of emitInst into a new Ops facade in src/backend/llvm/ops.zig.
emitInst's scalar arms now delegate via self.ops().*; the shared infra they
call (mapRef/resolveRef/matchBinOpTypes/emitCmp/emitCmpOrdered/emitStrCmp/
emitStringConstant/reflection + isFloatOrVecFloat/isSignedType) stays on
LLVMEmitter, widened to pub as needed. Pure relocation: zero snapshot churn.
Move getOrCreateJniSlots (the cls/methodid slot-cache builder) out of
emit_llvm.zig into the FfiCtors backend *LLVMEmitter facade. Behavior-preserving
— self.* -> self.e.* only.
- FfiCtors gains getOrCreateJniSlots (pub). The jni_slots cache + mangleJniKey
stay on LLVMEmitter; mangleJniKey is widened to pub (the facade calls it back,
like lazyDeclareCRuntime/emitPrivateCString), and JniSlotPair is widened to pub
(the facade returns it; the call site consumes it). 1 call site routed via
ffiCtors().
- emitJniConstructor intentionally NOT moved in this slice: it is emission-heavy
(resolveRef/mapRef/coerceArg/getRefIRType/extractSlicePtr/loadJniFn/
emitCStringGlobal — 100+ internal callers for the first two), so relocating it
would pub-expose the emitter's core value-emission machinery. Consistent with
A7.2 keeping emitFieldValueGet in emit_llvm.zig. Pending an explicit decision.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(JNI anchors 1402/1408/1418/1425 green, no churn).
Backend-FFI .ir inventory + scaffolding for the Obj-C/JNI runtime-constructor
extraction (Phase A7.3). No code moved.
Inventory (recorded in ARCH-SAFETY.md): the existing FFI .ir set already pins the
core constructor emission — emitObjcSelectorInit (sel_registerName via 1309/1329/
1332), emitObjcClassInit (objc_getClass), emitObjcDefinedClassInit class
registration + ivars + method IMP table (objc_allocateClassPair / class_addIvar /
class_addMethod / objc_registerClassPair via 1309/1332), and getOrCreateJniSlots /
emitJniConstructor (GetMethodID via 1402/1418/1408).
Gaps closed (2 new .ir snapshots) for the ARCH-SAFETY-named metadata not covered
by 1309:
- 1319-ffi-objc-property-sx-defined: property getter/setter IMPs (_get/_set/
class_addMethod x8).
- 1314-ffi-objc-class-dealloc-roundtrip: alloc/dealloc IMPs.
Both path-free + idempotent (verified across two captures; trailing newline
trimmed). Suite count unchanged (snapshots on existing examples).
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (no churn
beyond the 2 new .ir).
Move the DWARF debug-info emission out of emit_llvm.zig into a DebugInfo backend
*LLVMEmitter facade (field `e`). Behavior-preserving relocation — self.* ->
self.e.* only.
- src/backend/llvm/debug.zig (DebugInfo): debugEnabled + diFileFor (private) +
initDebugInfo / beginFunctionDebug / endFunctionDebug / setInstDebugLocation /
finalizeDebugInfo (pub). The mutable DI state (di_builder/di_cu/di_files/
di_scope/current_func_file) + the shared source map (import_sources/main_file)
stay on LLVMEmitter; the facade reads/writes them via self.e.*.
- Routed the 5 pass-order call sites in LLVMEmitter.emit (init/finalize/
begin/end/setInstDebugLocation) through a new debugInfo() accessor.
- setDebugContext stays on LLVMEmitter (shared-state setter; callers in main.zig/
core.zig/test). sourceForFile stays on LLVMEmitter and is widened to pub — it is
shared with reflection's trace-frame emission (emitTraceFrame), not debug-only.
- No DI logic / module-flag / DWARF-version / scope-line change.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (no churn).
Move the LLVM type-mapping and C-ABI coercion helpers out of emit_llvm.zig into
the first src/backend/llvm/ modules. Behavior-preserving relocation — the only
rewrites are module plumbing and self.* -> self.e.* facade access.
- src/backend/llvm/types.zig (TypeLowering): toLLVMType + toLLVMTypeInfo.
- src/backend/llvm/abi.zig (AbiLowering): abiCoerceParamType / abiCoerceParamTypeEx
/ needsByval / materializeByvalArg.
- Both are backend *LLVMEmitter facades (field `e`) — the backend analogue of the
IR-side *Lowering facades, NOT a *Lowering facade. They reach the cached LLVM
handles, IR type table, module data layout, builder, and the memoizing
composite-type getters via self.e.*.
- LLVMEmitter stays the facade: toLLVMType (~97 callers) + abiCoerceParamType /
abiCoerceParamTypeEx / needsByval / materializeByvalArg kept as thin wrappers
delegating through new typeLowering()/abiLowering() accessors. Zero caller
churn. toLLVMTypeInfo deleted (sole caller moved).
- Widened getStringStructType / getAnyStructType / getClosureStructType to pub
(the moved toLLVMTypeInfo calls them back; their memoization stays on
LLVMEmitter). verifySizes stays in emit_llvm.zig (size-assertion pass, not type/
ABI lowering). No ABI/type logic, branch order, diagnostic text, or snapshot
changed. Circular import (emit_llvm <-> backend/llvm) resolves via the pointer
facade.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(1202 .ir + the 2 ABI unit tests unchanged, no churn).
Codex review of d6078c2 flagged a blank line at EOF in the new
examples/expected/1202-ffi-cc-c-large-aggregate.ir. Collapse the trailing
newlines to a single one so `git diff --check` is clean. Test-safe: the runner
reads both expected and actual IR through $(...) command substitution, which
strips trailing newlines, so the comparison is unaffected (1202 still ok).
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0.
Test-first scaffolding for LLVM backend modularization (Phase A7.1) before the
type/ABI helpers move into src/backend/llvm/{types,abi}.zig. Visibility-only
change to the targets — no behavior change. Closes the ARCH-SAFETY "no generic
ABI snapshot" gap.
- 2 new emit_llvm.test.zig tests:
- abiCoerceParamType across every C-ABI size bucket: <=8 -> i64, 9-16 ->
[2 x i64], >16 -> ptr, HFA (all-float/all-double, <=4 fields) -> unchanged,
string -> ptr, slice -> ptr, scalar -> unchanged. Built via a local
internStruct helper (field slice in the module arena -> no testing-allocator
leak); asserts against emitter.cached_* + LLVMArrayType2.
- needsByval: true only for >16-byte non-HFA struct; false for <=16 / HFA /
string / slice / non-struct.
- 1 new .ir snapshot: 1202-ffi-cc-c-large-aggregate (the canonical callconv(.c)
>16-byte byval example that directly documents abiCoerceParamType) — pins the
byval param path end-to-end (5 byval + entry reload + 2 sret from Arena.init).
Path-free + idempotent (verified across two captures). Suite count unchanged
(snapshot added to an existing example).
- Widened abiCoerceParamType + needsByval to pub (visibility only;
abiCoerceParamTypeEx/materializeByvalArg/verifySizes stay private — move with
callers in sub-step 2). No logic touched.
- Recorded the A7.1 coverage inventory + residual gaps (wasm32 usize->i32 branch,
fn-ptr large-aggregate 1203/1204) in ARCH-SAFETY.md.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (no churn
beyond the new 1202 .ir).
Relocate the two pure JNI decision helpers out of lower.zig into
jni_descriptor.zig (already the JNI helper module), alongside the descriptor
derivation. Behavior-preserving move — no facade, since neither takes *Lowering.
- jniMangleNativeName(allocator, foreign_path, method_name) and
isJniReturnTypeSupported(table, ret_ty) moved verbatim as pub free fns; added a
types import + TypeId alias to jni_descriptor.zig.
- Rerouted lower.zig's 2 call sites (synthesizeJniMainStub; the JNI return-type
guard at lower.zig:6000) through jni_descriptor.* — lower.zig already imported
the module.
- Moved the 2 unit tests lower.test.zig -> jni_descriptor.test.zig (re-pointed to
desc.*; a standalone TypeTable.init replaces the Module setup). Dropped the
now-unused lower_mod alias.
- Stayed in lower.zig per PLAN A6.2 step 5/6: jniMapParamType (trivial resolveType
wrapper), synthesizeJniMainStub(s), lowerJniCall, lowerJniConstructor,
lowerSuperCall, getJniEnvTlFids. Java rendering stays in jni_java_emit.zig.
Phase A6 complete.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(9 JNI .ir snapshots + 26 14xx examples green, no churn).
Test-first scaffolding for the JNI FFI domain (Phase A6.2) before the pure
helpers move out of lower.zig. Visibility-only change — no behavior change.
- 2 new lower.test.zig tests for the pure JNI helpers lacking unit coverage:
- jniMangleNativeName: `/`->`_` separator, `_`->`_1` escape (path AND method),
`Java_` prefix, `_sx_1` infix (2 cases lock all rules).
- isJniReturnTypeSupported: void/bool/s32/s64/f32/f64 + pointer/many-pointer
-> true; other widths (s8/s16/u8/u32/u64) + by-value struct -> false.
- JNI descriptor derivation (writeType/deriveMethod) is already extracted into
jni_descriptor.zig (15 tests) — not part of A6.2.
- Widened jniMangleNativeName -> pub (file-scope free fn; isJniReturnTypeSupported
already pub). Reached from the test via ir_mod.lower.*. No logic touched.
- Recorded the A6.2 coverage inventory + residual emission-bound gaps
(synthesizeJniMainStub*/lowerJniCall/lowerJniConstructor/lowerSuperCall/
getJniEnvTlFids stay in lower.zig; jniMapParamType is a trivial resolveType
wrapper) in ARCH-SAFETY.md.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(no .ir churn; 9 JNI .ir snapshots green).
Move the pure Obj-C decision helpers out of lower.zig into src/ir/ffi_objc.zig
behind an ObjcLowering *Lowering facade (Principle 5, like the A4/A5 resolvers).
Behavior-preserving relocation — the only non-self.l rewrites are facade
plumbing.
Moved verbatim (self. -> self.l. for Lowering members):
- deriveObjcSelector (selector derivation)
- objcTypeEncodingFromSignature + appendObjcEncoding + bailObjcEncoding +
the ObjcEncodingStack type
- objcPropertyKind + the ObjcPropertyKind enum
- isObjcClassPointer
- objcDefinedStateStructType + objcStateAllocatorType
Emission-heavy code stays in lower.zig per PLAN A6.1 step 6: emitObjc* IMP
builders, lowerObjc*Call, registerObjc*, declareObjc*, the lookupObjc* property/
state lookups, and the Self-substitution resolvers.
- Call sites rerouted through a new objc() accessor: 15 in lower.zig, 1 in
expr_typer.zig, 39 in lower.test.zig (the A6.1 scaffolding tests now drive the
facade). No Lowering wrappers kept. Barrel-wired ffi_objc + ObjcLowering.
- No new visibility widening beyond sub-step 1's two pubs — the facade reads
self.l.{alloc,module,program_index,diagnostics} (fields) + the already-pub
resolveType. lower.zig -478 (->16615); ffi_objc.zig 428.
- Doc-only re-home: the property-IMP getter/setter comment was attached (a
pre-existing artifact) to the moving ObjcPropertyKind enum, two decls away from
its real subject emitObjcDefinedClassPropertyImps (which had no doc). Re-homed
it there so the move neither orphans a `///` block (Zig errors on a dangling doc
comment) nor misattributes it to ensureArcRuntimeDecls.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(48 13xx Obj-C examples + 4 Obj-C .ir snapshots green, no churn).
Codex review of 0012228 noted isObjcClassPointer's contract is
`fcd.runtime == .objc_class or fcd.runtime == .objc_protocol`, but the new tests
only exercised the class case. Test-only fix (no visibility/behavior change —
still exactly the two pub widenings from the parent commit):
- isObjcClassPointer: add a *NSCopying case where NSCopying is a registered
.objc_protocol foreign class -> true (alongside the .objc_class *NSString case).
- objcPropertyKind: add a *NSCoding protocol-pointer field -> strong default
assertion, since it uses the same class/protocol object-pointer predicate.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0.
Move the diagnostic-only Pass 1e (ERR E1.7 cleanup-absorption + E1.8 value-slot
liveness) out of lower.zig into src/ir/error_flow.zig behind an ErrorFlow
*Lowering facade (Principle 5, like ErrorAnalysis/CoercionResolver). Behavior
preserved exactly — pure relocation.
Moved verbatim (self. -> self.l. for Lowering members; sibling calls stay on the
facade; provenHas is a file-local free fn): checkErrorFlow, analyzeFnBody,
flowWalk, flowStmt, flowIf, flowMatch, flowExpr, applyRefinement,
provenAdd/provenClone/provenIntersect, registerFailableDestructure,
checkCleanupBody/checkCleanupNode/cleanupReject, plus the FlowCtx/ProvenSet types.
- lowerRoot routes the single call site through
self.errorFlow().checkErrorFlow(decls); no Lowering wrapper kept (only the
pipeline calls it, no unit-test caller). New errorFlow() accessor.
- The pass takes AST decls + ProgramIndex + diagnostics only — independent of IR
Builder state (PLAN-ARCH A5.2 success criterion).
- New pub: exprIsFailable (only widening; inferExprType/errorChannelOf already
pub). lower.zig -389 (->17030); error_flow.zig 407. Barrel-wired in ir.zig.
- No .test.zig: diagnostic-pass altitude (functions return only bool + emit
diagnostics) — guarded by example anchors 1046-1053 (incl. scaffolding
1051/1052/1053). Phase A5 complete.
Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(anchors 1046-1053 all ok, no .ir churn).
Codex review of 95895a3 found 1051 reached neither lambda arm it claimed to
pin: the lambda arrived only as a var_decl initializer, which routes through
checkCleanupNode's `.var_decl` arm -> cleanupReject(lambda) -> early-return
(a lambda literal is not failable), so the `.lambda` stop never ran; and its
accepted-direction `if !err` guard would still pass with flowExpr's lambda
recursion removed.
Scaffolding-only fix (no compiler change):
- 1051: add a bare lambda STATEMENT `() -> !E { failing(); };` in the cleanup
body so checkCleanupNode sees a `.lambda` node directly and stops (the bare
failable inside is accepted; were the arm to recurse it would reject like
1052). Output byte-identical — only the .sx gained the statement.
- 1053-errors-nested-lambda-liveness-reject (exit 1): an E1.8 value-slot read
inside a never-called nested lambda, rejected only because flowExpr recurses
via `.lambda => analyzeFnBody`. Remove that arm and the diagnostic vanishes
-> suite fails. This is the discriminating negative 1051 lacked.
Gate: zig build test, bash tests/run_examples.sh -> 361/0.
Test-first scaffolding for the path-sensitive error-flow pass
(checkErrorFlow/analyzeFnBody/flowWalk/flowIf/checkCleanupBody) before it
moves into src/ir/error_flow.zig. No compiler change — both examples lock
current behavior.
- 1051-errors-cleanup-closure-boundary (accepted): a closure literal inside a
`defer` body is its own function boundary — the E1.7 cleanup rule and the
parser's try/raise ban both stop at the lambda, and E1.8 value-slot liveness
runs per-boundary. Pins checkCleanupNode's `.lambda` stop + flowExpr's
`.lambda` recursion. Constructible since issue 0073 (0310).
- 1052-errors-cleanup-transitive-reject (exit 1): the E1.7 cleanup check is
transitive — bare failables nested in an `if` (both branches), a nested
block, and a `while` body all reject. Pins checkCleanupNode's recursive arms,
distinct from 1049's direct-body case.
No .test.zig/.ir: diagnostic-pass altitude (checkErrorFlow/A2.4 precedent) —
the pass returns no fact object and emits no IR.
Gate: zig build, zig build test, run_examples.sh -> 360/0.
A closure literal declared inside a `defer` body segfaulted the compiler.
Root cause: lowerLambda never opened its own `func_defer_base` window. Every
other function-lowering entry (lowerFunction / monomorphizeFunction /
monomorphizePackFn) saves func_defer_base, sets it to defer_stack.items.len, and
restores it — 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 returned, which drained again → infinite recursion
→ stack-overflow SIGSEGV (the failable variant surfaced one frame out, in
expandCallDefaults→lookupFn reading a clobbered scope).
Fix: lowerLambda now saves func_defer_base + the defer_stack length, sets the
base to the current length (a fresh window), and restores both on exit — so a
lambda's `return` drains only its own defers.
Regression: examples/0310-closures-closure-literal-in-defer.sx — a closure
declared and called inside a `defer`; verifies `body` then `defer closure: 42`
at scope exit (exit 0). Issue 0073 marked RESOLVED; repro promoted from
issues/0073-*.sx.
zig build, zig build test, tests/run_examples.sh (358/0) all green.
Minimal repro (issues/0073-...sx): a non-failable, uncalled closure literal
declared inside a `defer` body crashes the compiler with a SIGSEGV in
lowerLambda (src/ir/lower.zig). Isolation shows the trigger is "a closure
literal lowered inside a defer body" — not failability, not whether it's called
(closures and failable closures lower fine outside a defer). Pre-existing
lowering bug, unrelated to the A5 error-analysis extraction; surfaced while
writing an A5.2 cleanup-absorption test example.
Filed per the IMPASSIBLE RULE: work paused pending a fix in another session.
Error-set convergence now lives in src/ir/error_analysis.zig behind a *Lowering
facade (ErrorAnalysis), mirroring the other domain extractions. Moved verbatim:
- convergeInferredErrorSets (whole-program inferred-`!` SCC fix-point),
- convergeClosureShapeSets,
- collectErrorSites / collectClosureShapes (the AST collectors).
Added ErrorFacts (the PLAN-ARCH shape: inferred_error_sets + shape_inferred_sets)
+ a facts() view over the maps, which stay on Lowering for now (consumers read
them via self.*). recordClosureShape and its deep type/shape helper web stay in
Lowering; it reaches the moved collectErrorSites via self.errorAnalysis().
Lowering keeps convergeInferredErrorSets / convergeClosureShapeSets as thin pub
wrappers (the lowering pipeline + the E1.4b unit test call them); collectErrorSites
/ collectClosureShapes are deleted (no fallback). New pub: isErrorTagLiteralNode /
callTargetName / astIsPureBareInferred / astPureNamedSet / containsTag /
namedSetTags / recordClosureShape (the moved collectors / facade reach them).
lower.zig net -216 lines.
The 2 convergence unit tests (transitive SCC across a try edge; closure-shape
union) moved from lower.test.zig to error_analysis.test.zig and now drive the
facade directly; the E1.4b test stays in lower.test.zig via the wrapper. Module
named error_analysis.zig, NOT errors.zig (src/errors.zig is the DiagnosticList).
zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir churn.
Test-first scaffolding ahead of extracting src/ir/error_analysis.zig — no code
change to the convergence targets (convergeInferredErrorSets /
convergeClosureShapeSets / collectErrorSites / collectClosureShapes).
Adds 2 unit tests via the already-pub convergence functions (no new exposure):
- convergeInferredErrorSets transitive/SCC: a `caller :: () -> ! { try raiser(); }`
with no direct raise converges to raiser's {Foo} across the try edge — the
whole-program fixpoint A5.1 must preserve. (Today's E1.4b test only covered a
direct raiser + the empty-set warning.)
- convergeClosureShapeSets: a bare-`!` closure literal `() -> ! { raise error.Bar }`
inside a host fn unions {Bar} into one shape_inferred_sets entry.
Adds 2 .ir snapshots (first .ir for these error forms), vetted clean
(idempotent, path-free, no #run): 1006-errors-inferred-error-sets (inferred-set
error-channel shapes) and 1009-errors-catch (catch lowering). 1004-errors-try
was already pinned.
PLAN-ERR is complete/idle, so the A5 overlap risk is low (the target functions
are stable, not in-flight). The sub-step-2 module will be named
src/ir/error_analysis.zig, NOT errors.zig (src/errors.zig is the DiagnosticList).
zig build, zig build test, tests/run_examples.sh (357/0) all green.
Coercion classification now lives in src/ir/conversions.zig behind a *Lowering
facade (CoercionResolver), mirroring CallResolver / GenericResolver /
ProtocolResolver. Two pure classifiers:
- classify(src, dst) -> CoercionPlan (15 kinds: no_op / unbox_any / box_any /
closure_to_fn_reject / tuple_elementwise / optional_unwrap / void_to_optional /
optional_wrap / erase_protocol / int_to_float / float_to_int / ptr_int_bitcast /
widen / narrow / none) — the built-in coercion ladder.
- classifyXX(src, dst) -> XXPlan (unbox_any / no_op / erase_protocol /
protocol_to_pointer / coerce) — the xx-operator head.
coerceToType and lowerXX now `switch (classify…)` then emit; branch order
mirrors the originals exactly and every arm reproduces the prior lowering — the
f32/f64 Any match dispatch, buildProtocolErasure (lowerXX) vs buildProtocolValue
(coerceToType), tuple/optional recursion, and the user-Into fallback + pointer
materialization + recursion-guard/diagnostics (which stay in lowerXX /
tryUserConversion). IR emission stays entirely in Lowering; the classifiers are
pure. lowerXX keeps the operand's lowered Ref type as src_ty. `.none` means no
built-in applies (pass through; the Into fallback runs) — no silent default.
New pub: isFloat / isIntEx / typeBitsEx / resolveConcreteTypeName (the classifier
reads them); coercionResolver() accessor. lower.zig net -54 lines.
conversions.test.zig drives CoercionResolver directly: the full classify ladder
(no-op, Any box/unbox, widen/narrow, int<->float, ptr<->int, optional
wrap/unwrap, void->optional, tuple, closure-reject, .none for two unrelated
structs), erase_protocol for a concrete source, and classifyXX (all 5 kinds incl.
protocol-to-pointer vs coerce and pointer-materialization -> coerce).
zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir churn.
Test-first scaffolding ahead of extracting src/ir/conversions.zig — no code
change to the coercion targets (lowerXX / coerceToType / coerceOrErase /
buildProtocolErasure / tryUserConversion / failable-adapter selection).
Adds 4 .ir snapshots (first .ir for 01xx/09xx/10xx), each captured surgically
via `sx ir | normalize_ir`, path-free, idempotent, and print-free at IR-gen time
(0114-types-build-block-convert was rejected — it prints `--- void / 0 args ---`
+ sx source at IR-gen):
- 0107-types-int-cmp-in-float-ternary numeric int<->float coercion
- 0903-optionals-optional-roundtrip optional wrap/unwrap
- 0904-optionals-any-to-string-optional xx unbox_any + optional
- 1004-errors-try error-channel adapter/coercion
Protocol erasure + user Into are already pinned by the 04xx snapshots
(0400/0413/0414/0416); duplicate-conversion rejection by the 0410/0411/0412
anchors.
Adds 1 unit test via the public surface (no new exposure, mirroring A4.1/A4.2
sub-step 1): optionalOfFlattened — the optional wrap/flatten coercion rule
(T -> ?T; ?T -> ?T, never ??T; contrasted with the non-flattening optionalOf).
The lowerXX/coerceToType/coerceOrErase/buildProtocolErasure decisions are private
+ emission-bound, so their CoercionPlan unit tests land with the extracted module
in sub-step 2.
zig build, zig build test, tests/run_examples.sh (357/0) all green.
Factor the lookup/planning half of the protocol emission functions into
protocols.zig, keeping IR emission in Lowering (PLAN-ARCH A4.2 final increment):
- protocolMethodInfos(proto) — the dispatch method table = which methods
getOrCreateThunks must thunk. getOrCreateThunks now does PLANNING via this +
EMISSION (createProtocolThunk loop) in Lowering.
- findVisibleImpls(entries, out) — moved verbatim (pure BFS over the import
graph; the cross-module visibility selection behind the 0410 path).
tryUserConversion calls it via the resolver.
- matchPackImpl(src_ty, pack_key) -> ?PackImplMatch — the pure pack-impl
matching loop (prefix + return match) + convert-method find, returning the
matched entry + convert fd + src params/ret. tryPackImplMatch consumes it; the
binding + monomorphise + call emission stays in Lowering.
Emission untouched: createProtocolThunk, buildProtocolValue, and the
monomorphise+call tails of tryUserConversion / tryPackImplMatch remain in
Lowering. The reentrancy guard, key-build, and the Into no-visible / duplicate /
recursive diagnostics stay in tryUserConversion (byte-for-byte). lower.zig net
-94 lines. No new pub exposure (uses the existing ParamImplEntry /
PackParamImplEntry / formatTypeName surface).
protocols.test.zig +3: protocolMethodInfos (method table + null-for-unknown, no
silent empty default); findVisibleImpls (falls open with no graph; filters to
here + transitive imports); matchPackImpl (selects on prefix+return; null for
non-closure source / unknown key).
zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
churn; the 0410/0411/0412 diagnostics are byte-for-byte preserved.
Move the registration functions behind the protocols.zig facade, per PLAN-ARCH
A4.2 ("then registration", keeping IR emission in Lowering):
- registerProtocolDecl (protocol struct + dispatch method table + vtable type),
- registerImplBlock (concrete impl -> <Target>.<method> in fn_ast_map + default-
method synthesis),
- registerParamImpl (parameterised impl -> param_impl_map / param_impl_pack_map
+ the same-file duplicate diagnostic),
- synthesizeDefaultMethod (facade-private; its only caller moved too).
Moved verbatim with self. -> self.l. facade rewrites. Emission stays in
Lowering: the registry calls self.l.declareFunction (the extern-stub primitive)
but the thunk/value builders (createProtocolThunk / buildProtocolValue /
tryUserConversion / getOrCreateThunks) are NOT moved.
Lowering keeps registerProtocolDecl as a thin pub wrapper (scan pass + 7
unit-test callers); registerImplBlock / registerParamImpl /
synthesizeDefaultMethod deleted (no fallback), the 2 scan call sites routed
through protocolResolver(). New pub: declareFunction (8 callers, emission infra),
ParamImplEntry / PackParamImplEntry (the registry constructs them; stay as
Lowering nested types). State maps remain on Lowering; the facade reads/writes
self.l.* (migrate once planning lands).
protocols.test.zig +2: registerImplBlock records Circle.draw in fn_ast_map (and
packArgConformsTo then sees it); registerParamImpl flags a same-file duplicate
impl Into(s64) for IntCell (the 0412-class, unit level).
zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
churn; the 0410/0411/0412 rejection diagnostics are byte-for-byte preserved.
Move the pure protocol/impl conformance lookups into one module,
src/ir/protocols.zig, behind a *Lowering facade (ProtocolResolver), mirroring
GenericResolver / CallResolver. Per PLAN-ARCH A4.2 ("move pure lookup first;
keep emission in Lowering"), this increment moves only the read-only queries:
- getProtocolInfo (is a type a registered protocol + its method table),
- hasImplPlain (have the (protocol, type) thunks been materialized),
- packArgConformsTo (impl-declaration-level conformance for ..xs: P).
Registration (registerProtocolDecl / registerImplBlock / registerParamImpl) and
all IR emission (createProtocolThunk / buildProtocolValue / tryUserConversion /
getOrCreateThunks) stay in Lowering for the later increments. The state maps
(protocol_thunk_map / param_impl_map on Lowering, protocol_decl_map /
protocol_ast_map in ProgramIndex) stay put; the facade reads them via self.l.* —
no map migration.
Lowering keeps getProtocolInfo as a thin pub wrapper (~9 callers incl.
calls.zig); hasImplPlain + packArgConformsTo are deleted (no fallback), their 3
call sites (computeHasImpl x2, the pack-conformance check x1) routed through
self.protocolResolver(). formatTypeName widened to pub (the lookups use it);
protocolResolver() accessor added.
protocols.test.zig (wired into the barrel) drives ProtocolResolver directly:
getProtocolInfo (registered vs builtin/plain-struct + wrapper delegation),
hasImplPlain (thunk-map materialization), packArgConformsTo (non-parameterised
requires <ty>.<m> in fn_ast_map; trivially-true for an erased protocol value;
false for unknown protocol).
zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
snapshot churn; the 0410/0411/0412 rejection anchors still pass.
Adds the one deferred A4.1 coverage item: a focused unit test for
GenericResolver.buildTypeBindings inferring a type param from value args
(strategy 2) with widest-match — add(1,2) => T=s64, and add(1.0,2) / add(1,2.0)
=> T=f64 regardless of argument order.
Previously this inference path was guarded only by the 0200 .ir snapshot; the
unit test pins it directly against the new generics.zig API. Test-only.
zig build test and tests/run_examples.sh (357/0) green.
Generic substitution and monomorphization-key construction now live in one
module, src/ir/generics.zig, behind a *Lowering facade (GenericResolver),
mirroring CallResolver / ExprTyper. Moved verbatim:
- mangleTypeName + mangleParamList (the mono-key fragment builder),
- mangleGenericName (generic mono key), appendComptimeValueMangle (comptime-value
fragment),
- buildTypeBindings (call-site type-param inference), inferGenericReturnType
(generic return resolution).
inferGenericReturnType now uses a scoped TypeBindingScope (enter/exit with defer)
instead of a manual type_bindings save/restore — the PLAN-ARCH A4.1 "scoped
substitution env" shape; a generics.test.zig assertion confirms the prior
bindings are restored (the issue-0048/0050 leak class, for this field).
Lowering keeps a thin pub mangleTypeName wrapper delegating to
genericResolver().mangleTypeName, because ~30 cross-cutting callers (impl-map
keys, conversion keys, shape keys) reach it well beyond generics. mangleParamList
(sole caller was mangleTypeName) moved fully. The other 4 originals are deleted
(no fallback); their 6 call sites now go through self.genericResolver()
(calls.zig via self.l.genericResolver()).
matchTypeParam / extractTypeParam / isTypeParamDecl widened to pub (the moved
substitution logic calls them); genericResolver() accessor added. The 2
mangleTypeName / inferGenericReturnType unit tests moved from lower.test.zig to
generics.test.zig (driving GenericResolver directly) and wired into the barrel.
monomorphizeFunction / monomorphizePackFn intentionally stay in lower.zig (they
save/restore three fields across nested mono and call emission helpers) — a
heavier scoped-env adoption deferred to an optional sub-step 3.
zig build, zig build test, and tests/run_examples.sh (357/0) all green — no .ir
snapshot churn, confirming the move preserved mono-key/substitution output.
The 0524-packs-generic-fn-pack-state-leak example has a #run that prints at
IR-gen time, and tests/run_examples.sh captures `sx ir ... 2>&1`, so its .ir
snapshot was contaminated with #run stdout (`0: len=0` ...) instead of pure IR.
Remove 0524.ir — pack-state isolation (the issue-0048/0050 class) stays guarded
by 0524's existing runtime .stdout/.exit, where a leaked outer pack_arg_types
would corrupt the printed len= sequence.
Replace it with 0513-packs-pack-mixed-comptime.ir, which is print-free at
IR-gen time (clean, idempotent, path-free) and additionally locks the
comptime-value mono-key path (appendComptimeValueMangle): the IR shows
tagged(7,..) vs tagged(9) producing distinct monos
@tagged__ct_7__pack_s64_s64_s64 / @tagged__ct_9__pack.
zig build, zig build test, tests/run_examples.sh (357/0) all green.
Test-first scaffolding ahead of extracting src/ir/generics.zig — no code change
to the refactor targets (buildTypeBindings / mangleGenericName / monomorphize* /
inferGenericReturnType / mangleTypeName).
Adds the first non-FFI generic/pack .ir snapshots (closing the ARCH-SAFETY §3
gap for this phase), each captured surgically via `sx ir | normalize_ir`,
path-free and idempotent:
- 0200-generics-generic generic fn, type-param inference + mono
- 0201-generics-generic-struct generic struct instantiation
- 0507-packs-pack-mono-dedup mono-key dedup (same shape => one mono)
- 0518-packs-pack-value-dispatch pack value dispatch (monomorphizePackFn)
- 0524-packs-generic-fn-pack-state-leak pack-state isolation (issue-0048/0050
class; guards the future scoped-env change)
Adds 2 unit tests via the existing public surface (no new pub exposure,
mirroring the A3.2 sub-step-1 cadence):
- mangleTypeName: pins the mono-key fragment encoding per type shape
(s64 / ptr_X / opt_X / SL_X / mptr_X / AR_n_X / vec_n_X / struct-name / tu_X_Y).
- inferGenericReturnType: explicit type-arg path binds $T and resolves the
-> T return (pair(s64,..) => s64, pair(f64,..) => f64).
The internal substitution/mono-key unit tests (comptime-value mangle,
buildTypeBindings strategies, scoped-env isolation) land with the generics.zig
extraction in sub-step 2, as A3.2's plan-object tests landed with CallPlan.
zig build, zig build test, tests/run_examples.sh (357/0) all green.
lowerCall re-derived the namespace-vs-value (receiver-prepend) decision with a
19-line block duplicating the exact identifier/type_expr + scope/global walk
that CallResolver already owns (objectIsValue, the negation of is_namespace).
This boundary determines whether the receiver is prepended, so it must agree
with the plan's free_fn_ufcs (prepends) vs namespace_fn (does not)
classification from fa59a9d.
Make CallResolver.objectIsValue pub and set
is_namespace = !self.callResolver().objectIsValue(fa.object)
so plan and lowering share one boundary definition and can never drift.
`!objectIsValue` matches the old block case-for-case (non-identifier => value;
identifier/type_expr in scope/global => value; else => namespace), so this is a
behavior-identical substitution.
Deeper switch(plan.kind) routing of lowerCall is intentionally NOT done here: it
is not behavior-preserving as-is. `plan` is typing-only and coarser than
`lowerCall` — its method/namespace arms carry comptime / generic /
generic-template / #compiler / type-constructor dispatch `plan` does not model,
and its value-receiver kinds (struct_method/protocol_dispatch/foreign_instance)
do not gate on objectIsValue, so a type-name receiver (Point.make()) could be
mis-classified vs the namespace/static call lowerCall actually performs. Driving
prepend decisions off plan.kind would mis-prepend; objectIsValue is the correct
single source, hence routing the boundary specifically. PLAN-ARCH A3.2 success
criteria met (shared classifier; no duplicated return-type logic; plan tests;
stable .ir snapshots).
zig build, zig build test, tests/run_examples.sh (357/0) all green.
CallPlan collapsed two different field-access dispatches onto namespace_fn:
a true namespace call (`pkg.fn()`, no receiver) and free-function UFCS
(`c.bump()`, receiver prepended + `*T` fixup). Return typing was preserved
either way, but sub-step 3 could not consume the plan — it would have had to
re-classify the AST to decide whether to prepend the receiver.
Add a distinct `free_fn_ufcs` kind and a plan(c) branch, inserted after the
struct-method block and gated on `objectIsValue` (the negation of lowerCall's
`is_namespace`: a non-identifier receiver is always a value; an
identifier/type_expr is a value iff it names a local or a global). The branch
sets prepends_receiver = true and reads prepends_ctx from the resolved FuncId
(best-effort, like direct_fn). namespace_fn now means strictly "receiver is a
namespace/type prefix".
New test `plan: free-function UFCS prepends receiver, distinct from
namespace_fn` covers a scope-bound `c.bump()` against a lowered free fn:
asserts free_fn_ufcs kind, func target, prepends_receiver, prepends_ctx, and
preserved s32 return type.
zig build, zig build test, tests/run_examples.sh (357/0) all green; return
typing unchanged.
Introduce CallPlan — the single classification record for a call: kind (14
variants), return_type, a Target union (builtin/func/named/protocol_method/
foreign_method/constructed/none), variant tag, and the prepends_receiver /
prepends_ctx / expands_defaults properties the selected dispatch implies.
Move call recognition into CallResolver.plan(c) (branch order preserved
exactly) and reimplement resultType(c) as plan(c).return_type — the typing
consumer converges onto the plan first. lowerCall is untouched; routing it
through plan(c) is sub-step 3.
10 plan-object tests assert kind/target/variant + receiver/ctx/default
properties for every pinned call form: builtin/reflection, lazy + resolved
direct fn (incl. default-arg expansion + __sx_ctx prepend), closure /
default-conv vs C-conv fn-pointer, protocol dispatch, struct/UFCS #compiler
method, foreign instance vs static, qualified + dot-shorthand enum
construction, namespace fn, and the unresolved fallthrough.
Widen for the new collaborator only: resolveVariantIndex -> pub (plan resolves
the variant tag); Scope/Binding + init/deinit/put -> pub (so unit tests can
stand up a lexical scope for closure/fn-ptr callees without a full lowering).
zig build, zig build test, and tests/run_examples.sh (357/0) all green; no
behavior change.
The module doc and the `.call` arm comment still said call result typing
"stays in Lowering" and "converges in A3.2". As of 7f3a7b3 calls are routed to
CallResolver (calls.zig); update both comments to name the current owner. The
`.call` arm still delegates through Lowering.inferExprType — that's the routing
path to the owner, not a claim that Lowering owns the typing.
Comment-only. Gate: zig build, zig build test, run_examples.sh -> 356/0.
Move call-result-type discovery out of Lowering into a new src/ir/calls.zig
(CallResolver): the A3.1 Lowering.inferCallType body moves verbatim into
CallResolver.resultType. inferExprType's `.call` arm now delegates via
callResolver(); Lowering.inferCallType is gone.
CallResolver is a *Lowering facade (Principle 5, like ExprTyper/PackResolver):
call typing reads live lexical-scope / target-type state and the function /
foreign-class / protocol resolver helpers, so it borrows *Lowering. Transform
was `self.` -> `self.l.` plus the file-local static `resolveBuiltin(` ->
`Lowering.resolveBuiltin(`.
Widened to pub only what the facade actually consumes: resolveTypeArg,
inferGenericReturnType, resolveFuncByName, getProtocolInfo,
resolveForeignMethodReturnType, the static resolveBuiltin, and Scope.lookupFn.
resolveTypeArg widening is genuinely required here — the `cast` builtin's
result type calls it.
calls.test.zig adds focused tests (builtin/reflection classification, unknown
callee -> unresolved) for the scope-free paths. Barrel-wired in ir.zig.
This is the relocation half of PLAN-ARCH A3.2; call LOWERING (lowerCall) still
owns its own dispatch, and the CallPlan convergence (one plan shared by typing
and lowering, deleting the duplicated qualified/bare/lazy logic) remains.
Behavior-preserving. Gate: zig build, zig build test (incl. new CallResolver
tests), bash tests/run_examples.sh -> 356/0. lower.zig 18598 -> 18413.
Move the structural / non-call arms of Lowering.inferExprType into a new
src/ir/expr_typer.zig (ExprTyper): literals, unary/binary ops, try/catch, if,
block, field access, identifier/type-name, struct/tuple literals,
index/slice/deref, null-coalesce, caller_location, and the no-value statement
shapes. ExprTyper is a *Lowering facade (Principle 5, same as PackResolver) —
expression typing reads live lexical-scope / pack / target-type state and ~14
resolver helpers, so it borrows *Lowering rather than re-threading every field;
the plan's TypeResolver/ProgramIndex/ResolveEnv ideal is the later-phase target
as that state lifts into an explicit context (documented in the module doc).
Lowering.inferExprType is now a 2-arm dispatcher: `.call => inferCallType(c)`
(call result typing stays in Lowering until A3.2), else delegates to
ExprTyper.inferType. The call arm body moved verbatim into the new
Lowering.inferCallType (the by-value `|c|` capture became a `*const ast.Call`
param; the lone `&c` -> `c`).
14 Lowering helper methods consumed by the facade were widened to pub
(orIsFailableChain, orChainSuccessType, errorChannelOf, failableSuccessType,
isObjcClassPointer, lookupObjcPropertyOnPointer,
lookupObjcDefinedStateFieldOnPointer, getElementType, optionalOfFlattened,
getStructFields, isKnownTypeName, comptimeIndexOf, packArgNodeAt, resolveType)
plus Scope.lookup — the same pub-for-facade step PackResolver took. Fields need
no change (Zig fields are always cross-file accessible).
expr_typer.test.zig adds focused unit tests (literal shapes, comparison vs
arithmetic, unary not/negate, deref of non-pointer) for the scope-free
structural arms. Barrel-wired in ir.zig.
Behavior-preserving. Gate: zig build, zig build test (incl. new ExprTyper
tests), bash tests/run_examples.sh -> 356/0. lower.zig ~18774 -> 18598.
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 a global like `g : s32 = K.x;` was emitted with
no payload and silently zero-initialized (g=0).
Make the `else` emit a diagnostic — "global '<name>' must be initialized by a
compile-time constant" — instead of a null payload, so no unsupported shape can
silently zero. Two arms added alongside:
- `.null_literal => .null_val`: a `*void = null` global was previously a
no-payload zero-init; this preserves the exact LLVMConstNull emission (fixes
3 ffi examples that regressed on the first cut).
- 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) is intentionally
not implemented: a typed struct const like K is not registered in
module_const_map, so it would require new plumbing whose writes are read at
runtime — out of scope. The diagnostic is the issue-sanctioned outcome.
Regression: examples/1118-diagnostics-global-non-const-initializer-rejected.sx
(exit 1). Gate: zig build, zig build test, run_examples.sh -> 356/0.
registerTopLevelGlobal's init_val switch serialized only literal / array-
literal / struct-literal initializers. An identifier initializer
(`K : A : 42; g : A = K;`) fell through to `else => null`, so the global was
emitted with no payload and silently zero-initialized (printed g=0).
Extract the initializer serialization into globalInitValue and add an
.identifier arm that materializes the global's static value from
ProgramIndex.module_const_map (typed module consts are registered in the same
scanDecls pass-2 just before, via registerTypedModuleConst). An identifier
that names no usable constant now emits a diagnostic instead of silently
zeroing — a global has no run site for a dynamic initializer.
Other initializer shapes (enum-literal shorthand, etc.) keep their established
static-lowering behavior; enum-literal globals' zero-init is load-bearing for
`inline if OS == ...` in the stdlib, so it stays out of scope here. This pass
only closes the identifier/module-const hole.
Regression: examples/0134-types-global-init-from-module-const.sx (g=42, exit
42). Gate: zig build, zig build test, run_examples.sh -> 355/0.
Issue 0069's resolveForwardIdentifierAliases fixpoint runs at the END of
scanDecls, but top-level var_decl globals and typed module constants had
their annotations resolved via resolveType(ta) inside the SAME scan loop,
before the fixpoint. So a forward identifier alias (`A :: B; B :: s32;`)
used as a global's type (`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 `K : A : 42;` silently mistyped the constant instead).
Split scanDecls into two passes: pass 1 registers function/type/alias facts,
then resolveForwardIdentifierAliases converges the aliases, then pass 2
registers var_decl globals (registerTopLevelGlobal) and typed module
constants (registerTypedModuleConst) 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.
One incidental IR snapshot reorder (examples/1309: user globals now emit
after foreign-class globals — semantically identical, program still exits 0).
Regression: examples/0133-types-forward-alias-global.sx (forward-alias global
+ typed const). Gate: zig build, zig build test, run_examples.sh -> 354/0.
scanDecls' `.identifier` alias branch registered `A :: B` into
ProgramIndex.type_alias_map only when `B` was already known (in
type_alias_map or the TypeTable). A forward target declared later
(`MyChain :: MyInt; MyInt :: s32;`) 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 as `unknown type 'MyChain'`.
Add 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 (forward alias +
forward chain). Gate: zig build, zig build test, run_examples.sh -> 353/0.
The A2.4 unknown-type pass (semantic_diagnostics) added 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{}` (the program ran, printing it).
Fix: collectDeclaredTypeNames and harvestScopeDecls now gate the const-name-add
on a new constValueIntroducesType — true only when the value introduces a 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: examples/1117-diagnostics-value-const-as-type-rejected.sx (exit 1).
Issue-0064 regressions 1111-1116 and the 0115 aliases stay green. Gate: zig
build, zig build test, run_examples 352/0.
Moves the issue-0064 unknown-type pass (checkUnknownTypeNames + 11 helpers:
collectDeclaredTypeNames, harvestScopeDecls, checkStructFieldTypes,
checkFnSignatureTypes, checkScope, walkBodyTypes, checkCastTarget,
checkTypeNodeForUnknown, reportIfUnknownType, isBuiltinTypeName, isIdentLike)
out of Lowering into a new src/ir/semantic_diagnostics.zig (UnknownTypeChecker).
The checker holds borrowed references (alloc, *DiagnosticList, *TypeTable,
*ProgramIndex, main_file) — not *Lowering — and queries the canonical facts:
declared top-level names from ProgramIndex, primitives from
TypeResolver.resolvePrimitive, registered concrete types from the TypeTable.
The AST decl/scope walk stays (it collects LOCAL type decls, which ProgramIndex
doesn't track — a per-pass scope need, not a parallel authoritative list).
Lowering.lowerRoot builds the checker only when diagnostics are active and runs
it; the 12 functions are deleted from lower.zig. Barrel-wired in ir.zig.
Example snapshots (issue-0064 regressions 1111-1115) are the guard, matching the
checkErrorFlow precedent (no .test.zig).
Phase A2 complete. Gate: zig build, zig build test, run_examples 351/0.
`size_of((s32, 1))` treated the tuple literal as a tuple TYPE: for the non-type
element `1` it emitted a `std.debug.print` and substituted `.s64` for that field,
then compiled and printed a bogus size — a silent fabricated type (the forbidden
silent-fallback pattern).
Fix:
- type_bridge.resolveTupleLiteralAsType: a non-type element now yields
`.unresolved` (no `.s64`, no debug print) — 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
isTypeShapedAstNode, emits a user-facing diagnostic at the offending element's
span, and returns `.unresolved`. Wired into resolveTypeArg (size_of/align_of/…)
and the resolveTypeWithBindings name-fallback; type_bridge builds the tuple
only after validation passes.
Regression: examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx
(exit 1 + diagnostic). Valid `(s32, s32)` still works (0115). Gate: zig build,
zig build test, run_examples 351/0.
Codex corrective step before the A2 merge gate: A2.3 left type_bridge with a
parallel structural type-resolution algorithm and an inline tuple-literal-spread
shape in lower.zig with a `.void` fallback.
Finding 1 — single owner for structural shapes:
- TypeResolver.resolveCompound is now the sole structural type-shape
constructor. Namespaced on `table` (so the stateless type_bridge can call it)
and extended to own function types, plain `Closure(P...) -> R`, and plain
positional/named tuples (it already owned *T/[*]T/[]T/?T/[N]T). It returns
null only for the pack-shaped forms that need caller state (`Closure(..p)`,
spread tuples); OOM yields `.unresolved`.
- type_bridge: deleted its 8 independent structural resolvers
(resolveArray/Slice/Pointer/ManyPointer/Optional/Function/Closure/TupleType).
resolveAstType delegates those node kinds to resolveCompound via a binding-free
StatelessInner adapter. The only residual stateless shape code is two tiny
fallbacks for the pack-shaped forms resolveCompound defers
(resolveClosurePackShape — used by Into(Block) at registration time —
and resolveTupleSpreadShape) plus resolveParameterizedType (kept:
generic-instantiation convergence is A4.1 per PLAN-ARCH).
- lower.zig: stateful resolveTypeWithBindings uses resolveCompound; the
`.function_type_expr` switch arm is gone. PackResolver.resolveFunctionTypeWithBindings
deleted (subsumed). Plain closures/tuples now resolve via resolveCompound in
both paths; only pack closures / spread tuples reach PackResolver.
Finding 2 — no `.void` failure fallback in lower.zig pack handling:
- the inline tuple_literal-with-spread type assembly moved into
PackResolver.resolveTupleLiteralType (returns ?TypeId; OOM `catch return .void`
became `catch return .unresolved`).
Alias result preserved: TypeTable.aliases stays gone; no table.aliases reads;
ProgramIndex.type_alias_map threaded explicitly.
type_resolver.test.zig: resolveCompound test rewritten (namespaced + new
function/closure/tuple/pack-shape arms, arena-backed). Gate green: zig build,
zig build test, run_examples 350/0.
A2-merge gate: both parts in one commit, behavior-preserving (350/0).
Part 1 — retire the TypeTable.aliases borrow (build-enforced):
- type_bridge.zig: add `AliasMap` and thread it as an explicit param through
every name-resolving fn (resolveAstType, bridgeType, resolveTypeName, the
compound resolvers, resolveTupleLiteralAsType, resolveParameterizedType, the
inline enum/struct/union + error resolvers). resolveTypeName now forwards the
threaded map to TypeResolver.resolveNamed instead of reading table.aliases.
- lower.zig: all 31 resolveAstType callers pass
&self.program_index.type_alias_map; drop the lowerRoot loan.
- types.zig: remove the now-unused TypeTable.aliases field.
- type_bridge.test.zig: alias test passes alias_map explicitly; other calls
pass null.
Part 2 — pack projections get one owner + no .void failure sentinel:
- New packs.zig (PackResolver, a *Lowering facade): moves
resolveClosure/Tuple/FunctionTypeWithBindings, packTypeElems, packTypeArgs,
elementProtocolTypeArg out of Lowering. Call sites route through
Lowering.packResolver(); barrel-wired in ir.zig.
- The missing-projection `orelse .void` in packTypeArgs now emits a diagnostic
and fills the slot with .unresolved (the tripwire sentinel), never a real
.void; OOM `catch return .void` in the moved fns became .unresolved too.
Legitimate no-return-type `else .void` defaults are preserved.
- packs.test.zig: packTypeArgs bound/unbound/no-constraint/no-state cases +
the missing-projection backstop (diagnostic + .unresolved slot).
Architecture phase A2.2 -- behavior-preserving. TypeResolver gains the
generic-binding and bare-name resolution it now owns:
- resolveBinding(node, env): $T / bare return-type T lookup via an explicit
ResolveEnv (no hidden Lowering state).
- resolveNamed(name, table, alias_map): the full bare-name algorithm (primitive
-> arbitrary-width int -> string-prefix [*]/*/?/[:0]u8 -> already-registered
-> alias(alias_map) -> empty-struct stub), MOVED from
type_bridge.resolveTypeName so it is single-sourced.
- resolveName(self, name): resolves through the canonical alias source
ProgramIndex.type_alias_map -- the compiler path no longer reads the
TypeTable.aliases borrow.
Lowering.resolveTypeWithBindings: the `if (self.type_bindings)` block (the $T
lookup plus parameterized/call/closure/function arms that were redundant with
the unconditional handling below) collapses to one resolveBinding delegation via
a new resolveEnv() snapshot; the bare-name fallback routes type_expr/identifier
to resolveName (index-based alias), other node kinds still to resolveAstType.
type_bridge.resolveTypeName becomes a 1-line delegate to resolveNamed, passing
its TypeTable.aliases borrow as the alias source. Single algorithm; the alias
map stays single-sourced in ProgramIndex.
Deferred to A2.3: removing the TypeTable.aliases borrow (its ~30 resolveAstType
callers must converge onto TypeResolver first) and type_bridge's stateless
compound resolvers. A2.2 #3 (templates/protocols/type-fns via ProgramIndex) was
already satisfied by A1.1b.
Tests: resolveBinding ($T bound/unbound/no-env), resolveName (alias->primitive,
alias->pointer via ProgramIndex), resolveNamed (width-int, string-prefix,
unknown->stub).
No new fallback path; no duplicate truth. Gate green: zig build, zig build test,
bash tests/run_examples.sh (350 passed, 0 failed).
lower.zig 19372->19367; type_bridge.zig 647->592; type_resolver.zig 90->159.
Architecture phase A2.1 -- behavior-preserving. Introduce src/ir/type_resolver.zig
as the canonical AST-type-node -> TypeId resolver (Principle 1), starting with:
- ResolveEnv: the explicit resolution-context shape (Principle 2) -- type/pack/
comptime bindings + target_type. Defined now; consumed as A2.2/A2.3 move the
cases that need it.
- TypeResolver.resolvePrimitive(name): the builtin keyword table, MOVED here from
type_bridge.resolveTypePrimitive (now a re-export -> single source; its 7
callers are unaffected; no import cycle).
- TypeResolver.resolveCompound(node, inner): the structural compound types
*T / [*]T / []T / ?T / [N]T. Element types recurse via inner.resolveInner (an
anytype callback) so generic structs / bindings in element position keep their
full stateful resolution.
Lowering.resolveTypeWithBindings duplicated the 5 simple compounds across its
bindings and no-bindings blocks (10 arms). Both are replaced with a single
self.typeResolver().resolveCompound(node, self) delegation; adds
Lowering.resolveInner (recursion hook) + typeResolver() (by-value view).
Deliberately deferred: tuples, closures, and function types stay on the existing
pack-aware helpers (resolveClosure/Tuple/FunctionTypeWithBindings); A2.3 owns
their pack-projection logic.
Tests: src/ir/type_resolver.test.zig (resolvePrimitive keyword/null cases;
resolveCompound for all 5 + null for non-compound; ResolveEnv defaults), wired
into the ir.zig barrel.
No new fallback path; no duplicate truth. Gate green: zig build, zig build test,
bash tests/run_examples.sh (350 passed, 0 failed). lower.zig 19393 -> 19372.
Architecture phase A1.2 — documentation/comment only, no behavior change.
Resolve the ambiguity over which type model compiler decisions trust:
- src/sema.zig: file-level module doc stating it is the editor symbol/type
index for the language server (navigation/completion), NOT a compiler
semantic pass. Its Type values are editor metadata; the compiler uses the
canonical TypeId/TypeTable model in src/ir/. sx requires no as-you-type type
checking -- authoritative diagnostics are produced on save by the canonical
pipeline. Added notes on SemaResult, Analyzer, resolveTypeNode, inferExprType.
No public API renamed (would churn LSP call sites).
- src/types.zig: note that Type is editor metadata only, not compiler truth;
do not expand for new compiler semantics (A8 deletes/reduces it).
- src/ir/types.zig: fix stale TypeTable.aliases comment -- it borrows
Lowering.program_index.type_alias_map (post-A1.1b).
Deleting the LSP's parallel sema diagnostic stream is A8.1, not this step.
Gate green: zig build, zig build test, bash tests/run_examples.sh (350 passed).
Architecture phase A1.1b — mechanical storage relocation. Move the 9
declaration-fact maps out of the Lowering state bag into ProgramIndex:
high-fanout: fn_ast_map, foreign_class_map, global_names, type_alias_map
medium-fanout: struct_template_map, protocol_decl_map, protocol_ast_map,
module_const_map, ufcs_alias_map
168 self.<map> sites in lower.zig repointed to self.program_index.<map>;
external readers repointed too (core.zig foreign_class_map iteration;
lower.test.zig fn_ast_map / foreign_class_map). No duplicate storage, no
fallback path; zig build enforces no missed reference.
The four maps whose value types were Lowering-private pull those types into
program_index.zig as pub (GlobalInfo, StructTemplate + TemplateParam,
ProtocolDeclInfo + ProtocolMethodInfo, ModuleConstInfo); lower.zig aliases
them at file scope so call sites are unchanged.
Behavior is preserved exactly:
- per-map allocator unchanged — import_flags/fn_ast_map/global_names use the
lowering allocator (ProgramIndex.init), the other 7 keep their page_allocator
inline defaults;
- ProgramIndex.deinit frees only the 10 owned maps, never the borrowed
module_scopes / import_graph;
- TypeTable.aliases still borrows &self.program_index.type_alias_map, loaned at
lowerRoot with the same late-binding lifetime.
Extends program_index.test.zig with declaration-map round-trips (fn AST, type
alias, global, module const, foreign class, protocol decl/AST, struct template,
ufcs alias).
Registration logic (registerStructDecl / registerProtocolDecl /
registerForeignClassDecl, ...) stays in Lowering, writing through the index.
Gate green: zig build, zig build test, bash tests/run_examples.sh
(350 passed, 0 failed). lower.zig 19433 -> 19393 lines.
Architecture phase A1.1a. Introduce src/ir/program_index.zig as the single
storage owner for declaration-name / import / visibility facts, and move the
three low-fanout maps out of the Lowering state bag:
- import_flags (owned by ProgramIndex)
- module_scopes (borrowed pointer into a core.zig-owned map)
- import_graph (borrowed pointer into a core.zig-owned map)
Lowering embeds one ProgramIndex by value and reaches every moved fact through
self.program_index.<field>; later phases hand collaborator modules a
*ProgramIndex instead of *Lowering. 8 call sites in lower.zig + 2 setters in
core.zig repointed. No duplicate storage, no fallback path; zig build enforces
no missed reference.
Mutation-heavy registration (registerStructDecl etc.) stays in Lowering and
now writes import_flags through the index. High-fanout maps are deferred to
A1.1b.
Adds src/ir/program_index.test.zig (init-empty, import_flags round-trip,
borrowed-view ownership) wired into the ir.zig barrel.
Behavior-preserving: zig build, zig build test, and bash tests/run_examples.sh
(350 passed, 0 failed) all green.
Diagnostics embed the absolute source path, but normalize() only scrubbed
hex addresses, so expected snapshots baked in the canonical checkout path
(/Users/agra/projects/sx/...). The suite only passed when run from that exact
directory; from a git worktree all 44 path-printing diagnostics mismatched.
Collapse any absolute `.../examples/` or `.../issues/` prefix to the repo-
relative form. The rule runs through normalize(), which is applied identically
to both expected and actual output, so it can only reconcile path noise — it
cannot desync an otherwise-matching pair. No snapshots regenerated.
Suite now reports 350 passed / 0 failed from a worktree as well as the
canonical tree.
Closes the two residual silent holes in the unknown-type diagnostic:
- Nested closure / function bodies. The body walk stopped at closure and
nested-fn boundaries, so a typo'd type in a closure's local annotation
silently became a 0-field struct. `walkBodyTypes` now descends control
flow and expressions to re-enter each closure / nested fn via `checkScope`,
which accumulates that scope's generic + value-`Type` params onto the
parent's — so an inner closure still sees the outer function's `$T` (no
false positive) while a genuine unknown is flagged at any nesting depth.
`harvestScopeDecls` collects type-decl names across the whole body
(including nested scopes) up front so locals are never false-flagged.
- Cast targets. `cast(T)` where `T` is a value-`Type` param (no `$`) cast to
a fabricated empty struct silently; it now gets the tailored `$T` hint. An
unknown *literal* cast target already errors via value resolution, so it's
left to that path — no double diagnostic.
Suite: 350 passed, 0 failed. Regressions: examples/1114 (nested-closure
annotation), 1115 (cast value param).
The signature/field check missed body-level type positions: a local
annotation naming a non-existent type flowed through the empty-struct stub
untouched, so `v: Coordnate = 5` silently compiled and ran (the value
dropped) — an invalid program accepted with no diagnostic.
`checkUnknownTypeNames` now also walks each main-file function body
(`checkBodyTypes`): local var/const type annotations — including inside
if / loop / match / push / defer / onfail blocks and decl-value blocks — are
validated with the enclosing function's generic params in scope, and
body-local `T :: struct/enum/union` declarations are collected first
(`collectBodyDeclNames`) so legitimate locals aren't false-flagged. Nested
function/closure bodies are their own scope and are not descended (safe
under-coverage); explicit `cast(T)` already surfaces its own `unresolved`
diagnostic and is left to it.
Regression: examples/1113 (local annotation of a non-existent type, exit 1).
An identifier used in a type position that resolved to nothing fell through
to `type_bridge.resolveTypeName`'s empty-struct-stub fallback, silently
interning a 0-field struct named after the identifier. A value parameter
mistakenly used as a type (`(T: Type, ...) -> T`, missing the `$`) or a
typo'd type name therefore compiled and ran, rendering as `T{}`.
New post-scan diagnostic pass `checkUnknownTypeNames` (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
and foreign-class opaque types still depend on it during the scan — and the
pass runs before body lowering, so `hasErrors()` halts the build before any
stub reaches codegen.
A value param used as a type gets a tailored hint to write `$T: Type`; a
genuine unknown gets "unknown type 'X'". Imported concrete types are
recognized via the type table, and inline compound spellings (`[:0]u8`),
arbitrary-width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are
exempted to avoid false positives.
Regressions: examples/1111 (tailored hint) + 1112 (typo'd field type).
2026-06-02 10:24:30 +03:00
605 changed files with 115590 additions and 9630 deletions
| `current/CHECKPOINT-ERR.md` | **Active** ERR progress tracker. Update after every step. |
| `implementation_plan.md` | Archive of completed work (closures, protocols, etc.). Do not pick up tasks from here. |
| `readme.md` | Original syntax sketches. Do not modify. |
| `readme.md` | User-facing language overview — **maintained**. Update it whenever a user-facing sx change lands (new/changed syntax, semantics, gating diagnostics, language behavior), per the docs-track-changes rule. |
| `CLAUDE.md` | This file. Session instructions. |
| `library/modules/platform/bundle.sx` | sx-side `.app` / `.apk` bundler. See "Bundling lives in sx" above. |
| `library/modules/fs.sx`, `library/modules/process.sx` | POSIX stdlib for the bundler + general consumer use. |
mirrored by the cached LLVM **literal (anonymous) struct type**`getFrameStructType()`
(`src/ir/emit_llvm.zig`). The reflection builder
(`src/backend/llvm/reflection.zig`) assembles each push site's global as an
LLVM **named-struct constant** over that cached type via
`LLVMConstNamedStruct` — a type-safe LLVM struct, not hand-packed bytes
(which would risk the "8-bytes-assumed" clobber class of bug). It does
**not** derive the layout from the sx `Frame``TypeId`, nor route through
the normal struct-emission path. `file`/`func`/`line_text` strings are
interned into a shared pool so a path shared by N push sites is stored once
— the table stays tiny. The `file` field is the source basename (full paths
live in DWARF), so trace output is machine-independent and snapshot-testable.
### Push and clear sites
@@ -193,8 +209,9 @@ stripped without affecting traces.
### What's emitted
In [`src/ir/emit_llvm.zig`](../src/ir/emit_llvm.zig), gated on the same
debug opt levels + a wired source map (`setDebugContext`):
In [`src/backend/llvm/debug.zig`](../src/backend/llvm/debug.zig) (the
`DebugInfo` helper, driven from `emit_llvm`'s `emit()` pipeline), gated on
the same debug opt levels + a wired source map (`setDebugContext`):
- one `DICompileUnit` + `DIFile` on the main file,
- a `DISubprogram` per emitted function (`LLVMSetSubprogram`),
@@ -237,10 +254,12 @@ both the trace path and the DWARF path. Items marked ✅ exist today;
|---|---|
| [`src/core.zig`](../src/core.zig) | `Compilation`: owns `import_sources` (file→source map), constructs the emitter, calls `setDebugContext` + `emit`; re-enters the interpreter for `#run`/post-link |
| [`src/ir/lower.zig`](../src/ir/lower.zig) | AST→IR. Stamps `Inst.span`; emits push/clear at failure/absorb sites; `tracesEnabled` gate; declares the `sx_trace_*` externs |
| [`src/ir/emit_llvm.zig`](../src/ir/emit_llvm.zig) | IR→LLVM. Builds the interned `Frame` table; lowers the push op to a pointer push; emits all DWARF metadata |
| [`src/ir/interp.zig`](../src/ir/interp.zig) | Comptime IR interpreter. Lowers the push op to a packed `(func_id, offset)`; resolves comptime frames |
| [`src/ir/emit_llvm.zig`](../src/ir/emit_llvm.zig) | IR→LLVM orchestrator. Owns `LLVMEmitter` + the source map (`setDebugContext`); dispatches the `.trace_frame` op and the DWARF passes to the helpers below |
| [`src/backend/llvm/reflection.zig`](../src/backend/llvm/reflection.zig) | `Reflection`: builds the interned `Frame` table + the tag-name / type-name tables; yields the `.trace_frame` op's value (the `Frame` global's address) — the `sx_trace_push` call itself is emitted by `lower.zig` |
| [`src/backend/llvm/debug.zig`](../src/backend/llvm/debug.zig) | `DebugInfo`: builds all DWARF metadata (compile unit, per-function subprograms, per-instruction `DILocation`) |
| [`src/ir/interp.zig`](../src/ir/interp.zig) | Comptime IR interpreter. The `.trace_frame` op yields a packed `(func_id, span.start)`; the separate `sx_trace_push` call op runs as a foreign call (dlsym); `.trace_resolve` recovers comptime frames |
| [`src/errors.zig`](../src/errors.zig) | `SourceLoc.compute(source, offset) → {line, col}`; the `import_sources` map type |
| [`src/ir/inst.zig`](../src/ir/inst.zig) | `Inst.span`, `Function.source_file`, the `Op` union (home of the trace-push op) |
| [`src/ir/inst.zig`](../src/ir/inst.zig) | `Inst.span`, `Function.source_file`, the `Op` union (home of the `.trace_frame` op) |
| [`library/vendors/sx_trace_runtime/sx_trace.c`](../library/vendors/sx_trace_runtime/sx_trace.c) | the thread-local ring buffer + `sx_trace_report_unhandled` |
| [`library/modules/trace.sx`](../library/modules/trace.sx) | the formatter (`to_string` / `print_current`) |
| **Tag-name table** | tag id → name string | tiny (per distinct tag) | **yes, always** — `{}` interpolation, the `main` wrapper, and the trace's "raised error.X" line need names even in release |
| **Tag-name table** | tag id → name string | tiny (per distinct tag) | **yes, always** — `{}` interpolation and the failable-`main` reporter's `error: unhandled error reached main: error.X` line need names even in release |
| **`Frame` location table** | push site → `{file,line,col,func}` | small (interned strings; per push site) | **debug / `--release-traces` only** — rides the trace-mode gate |
| **DWARF (`.debug_line` / `DISubprogram`)** | PC → file:line:col, for *debuggers* | larger (per source position) | **debug / `--release-traces` only**, strippable; consumed by `lldb`/`gdb`, never by the trace formatter |
@@ -455,7 +480,7 @@ a Mach-O debug map, never register JIT DWARF.
// An array dimension that is not a compile-time integer constant is a hard
// error, not a silently-fabricated 0-length array. Here a type alias's
// dimension is a runtime function call (`get()`), which is genuinely not
// compile-time-known — the registration-time resolver cannot evaluate it.
//
// (A const-FOLDABLE expression dimension such as `[M + 1]` is NOT an error — it
// folds; see examples/0144-types-const-expr-array-dim.sx. Only a dimension with
// a genuinely runtime operand halts here.)
//
// Regression (issue 0083): the stateless resolver printed a non-fatal warning
// and fabricated length 0, then let compilation continue — producing a 0-byte
// alloca and corrupt element access. It now yields the `.unresolved` sentinel,
// which the alias registration surfaces as this diagnostic, aborting the build
// with a non-zero exit.
#import "modules/std.sx";
get :: () -> s64 { return 5; }
BadArr :: [get()]s64;
main :: () {
a : BadArr = ---;
a[0] = 7;
print("a0={}\n", a[0]);
}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.