Compare commits

..

356 Commits

Author SHA1 Message Date
agra
ded106333b docs(design): execution-model roadmap + reify implementation stream
Add the async-first execution-model roadmap (comptime JIT spine, colorblind
fibers/Io, atomics, hot-reload) with all seven decisions resolved and
three-way reviewed, and carve the first stream: comptime type_info/reify
(PLAN-REIFY + checkpoint) — the codebase-validated foundation for channel
result types and race's synthesized tagged union.
2026-06-16 16:43:29 +03:00
agra
b6a7378af4 feat(dist): bundled-zig link backend for hermetic macOS/Linux/Windows builds
Drive a bundled `zig` as `zig cc` for the AOT link step, supplying lld + CRT
+ libc (musl/glibc/mingw) so `sx build` produces native binaries with no host
toolchain. Default Linux output is static musl (portable-anywhere).

- src/zig_backend.zig: discover zig ($SX_ZIG / bundled-next-to-exe / PATH);
  bundled-vs-PATH provenance gates auto-activation.
- src/target.zig: selectZigLinker + emitZigLinkArgv + zigTargetTriple, dispatched
  before the per-OS branches; macOS/Linux/Windows in scope.
- src/ir/emit_llvm.zig: LLVMNormalizeTargetTriple so vendor-less zig triples
  (e.g. x86_64-windows-gnu) parse to the correct OS/object format (COFF not ELF).
- src/main.zig: --self-contained / --no-self-contained; linux-musl, linux-musl-arm,
  windows-gnu shorthands; de-vendor linux/linux-arm to match the corpus runner.
- examples/1660: Windows Win32 print-42 + exit(0) via kernel32 (ir-only off-Windows).

Auto-activates only for a bundled zig; a PATH-only zig engages under
--self-contained, so native dev/CI builds are never silently rerouted.

Docs: readme Cross-Compilation, design/bundled-zig-link-backend-design.md, current/PLAN-DIST.md.
2026-06-16 15:56:06 +03:00
agra
0e0ee40528 docs(asm): symbol refs are portable — explain the auto-:c mechanism
Updates the symbol-operand guide: x86 now uses the same plain %[fn] as
aarch64, and a 'How the portability works' note explains the mechanism
(compiler auto-injects LLVM's :c modifier for "s" operands, equivalent
to GCC :P/%P0 for x86 calls, no-op on aarch64, overridable). Drops the
stale per-arch :P guidance; checkpoint updated.
2026-06-16 09:05:15 +03:00
agra
066ba54346 feat(asm): portable symbol refs — auto-inject :c operand modifier
A `%[name]` that references a symbol ("s") operand without an explicit
modifier now lowers to `${N:c}` (LLVM 'bare constant — no punctuation')
instead of `${N}`. This makes `bl %[fn]` / `call %[fn]` portable across
targets with no per-arch knowledge: x86 would otherwise render `$cb`
(an invalid call target, requiring a hand-written `:P`); aarch64 is
unaffected. Verified `:c` is equivalent to `:P` for x86-64 calls (both
emit R_X86_64_PLT32), and correct for branch targets, RIP-relative
addressing, and `$`-prefixed absolute immediates.

renderAsmTemplate injects `:c` only for symbol operands lacking an
explicit modifier (asmNamedIsSymbol helper); an explicit `%[name:X]`
still wins (escape hatch). x86 example 1659 drops its `:P` for the same
plain `%[fn]` as aarch64 1656. Snapshots regen to `${N:c}`. zig build
test green (668 corpus, 446 unit).
2026-06-16 09:04:23 +03:00
agra
79042ab9ab docs(asm): note x86 %[fn:P] call modifier + checkpoint x86 coverage 2026-06-16 08:37:09 +03:00
agra
17e3b91eb9 test(asm): x86_64 cross-arch siblings for place + symbol operands
Adds ir-only x86_64 examples mirroring the aarch64 feature examples, so
each emit path is locked on both arches:
- 1657 read-write `+`  → "incq ${0}", "=r,0" (tied input)
- 1658 indirect `=*m`   → "movq $$42, ${0}", "=*m"(ptr elementtype i64)
- 1659 symbol `"s"`     → "call ${2:P}", direct call to an exported sx fn

Each is x86-pinned (ir-only on this aarch64 host — the .ir is the
assertion; runs on x86_64-linux, main returns 0 on success / 1 if the
asm misbehaved). x86 templates validated by cross-emitting an object
(LLVM's integrated assembler accepts them; objdump confirms 1659 is a
direct `call` reloc to cb). Note: x86 direct calls need the `P` operand
modifier (`%[fn:P]`); aarch64 `bl %[fn]` needs none. Pure additive
locks, no compiler change. zig build test green (668 corpus, 446 unit).
2026-06-16 08:36:33 +03:00
agra
a0face7571 docs(asm): document symbol operands ("s") + checkpoint
Adds a 'Symbol inputs — "s" = fn' section to docs/inline-assembly.md
(direct bl/call, portability, the export-vs-callconv linkage point) and
logs the symbol-operand + round-trip work in CHECKPOINT-ASM.
2026-06-16 08:26:22 +03:00
agra
10f4137cbd feat(asm): symbol operands ("s") — direct call/branch to a function
A `"s"` input operand feeds a function/global symbol; the template's
%[name] emits the platform-mangled name, so `bl %[fn]` / `call %[fn]`
branches DIRECTLY to it (PC-relative, no register load — one fewer
indirection than register-indirect `blr`).

Lowering: an `"s"` input lowers its RHS normally (a function name →
`ptr @fn`); the rejection added last commit is removed. Emit: a symbol
operand is passed with its OWN llvm type (LLVMTypeOf) and no coercion —
the function value is a `ptr`, and the old coerce-to-register-int path
mistyped it and failed the verifier. New asmIsSymbol helper.

Verified on aarch64: examples/1656 (sx → asm → bl _cb → sx → 42); the
emitted asm is a direct `bl <_cb>` (objdump-confirmed), IR constraint
`...,s,...`(ptr @cb). Flipped 1656 from the rejection lock to a runnable
aarch64 example. zig build test green (665 corpus, 446 unit).
2026-06-16 08:24:53 +03:00
agra
c187122531 test(asm): reject symbol "s" operands cleanly + lock (symbol-op prep)
A symbol operand (constraint "s") feeds a function/global symbol whose
mangled name the template emits — enabling a DIRECT `bl %[fn]` (one
fewer indirection than register-indirect `blr`). Until now `"s" = fn`
fell through to emit and produced an LLVM-verifier crash (param type
mismatch). Reject it at lowering with a clear diagnostic instead, and
lock that with examples/1656-platform-asm-symbol-operand.sx. The next
commit implements it and flips the example to run (→ 42).
2026-06-16 08:19:18 +03:00
agra
1346a2d020 test(asm): round-trip example — asm calls back into an sx function
Adds examples/1655-platform-asm-callback-into-sx.sx: a global-asm
trampoline (_caller) that `bl _cb` back into an `export`ed sx function.
Demonstrates the sx → asm → sx round trip and that `export` (external
linkage + stable C symbol + C ABI) is what makes the callback symbol
resolvable — `callconv(.c)` alone leaves it internal and it DCE's away.
Runs under the JIT on aarch64-macos (→ 42); ir-only elsewhere. Locks
current behavior; no compiler change.
2026-06-16 07:55:05 +03:00
agra
e7eeecc0f3 docs: move inline-asm design doc to a top-level design/ folder
Moves docs/inline-asm-design.md -> design/inline-asm-design.md (the
internal design record now lives under design/, separate from the
user-facing docs/). Updates all links: current/CHECKPOINT-ASM.md,
current/PLAN-ASM.md, current/PLAN-EXTERN-EXPORT.md (../docs -> ../design)
and docs/inline-assembly.md (same-dir -> ../design).
2026-06-16 07:46:01 +03:00
agra
b4d1ce78c3 docs(asm): add user-facing inline-assembly guide
Adds docs/inline-assembly.md — a how-to guide for inline assembly in the
docs/error-handling.md style: mental model, operands (inputs / value
outputs / naming + auto-naming rule), the result-type table, volatile,
clobbers, all three `-> @place` forms (write-through / read-write /
indirect-memory), multi-instruction `#string` templates, global asm +
lib-less extern, the JIT/AOT-yes vs `#run`-no execution model, a
cookbook (read-register, x86_64 syscall, divmod), and rules of thumb.
All aarch64 snippets are verified to run; x86_64 ones are labeled. The
design doc (docs/inline-asm-design.md) stays as the internal rationale;
this guide is the user-facing companion, linked from readme.md.
2026-06-16 07:41:14 +03:00
agra
73f5f0ed11 docs(asm): checkpoint comptime-call guard (1654) 2026-06-16 07:29:56 +03:00
agra
ab7fc393b6 test(asm): pin loud failure of #run into a module-asm symbol
Adds examples/1654-platform-asm-global-comptime-call.sx — the comptime
guard. A module-asm symbol only exists after assemble+link; the comptime
interpreter resolves extern calls via host dlsym, where it's absent, so
`#run my_add(…)` fails with a clear diagnostic ("comptime extern call:
symbol not found via dlsym") rather than misfiring. Runtime calls work
(1648/1653). dlsym-miss precedes asm assembly, so arch-independent — no
.build. Locks current behavior; no compiler change.
2026-06-16 07:29:38 +03:00
agra
66e1e39418 docs(asm): correct stale 'AOT only' module-asm prose (JIT works)
sx run compiles to an object before ORC relocation, so module asm is
assembled in and its symbols resolve at JIT main execution. Corrected
the Phase F note, Current state, and Next step; the only real boundary
is a compile-time #run into a module-asm symbol (loud dlsym-miss).
2026-06-16 07:25:32 +03:00
agra
e954f044d8 test(asm): global asm runs under the JIT (sx run), not just AOT
Adds examples/1653-platform-asm-global-jit.sx — a module-scope asm { … }
block executed via `sx run` (no `aot`). sx run compiles the module to an
in-memory object (the integrated assembler assembles the `module asm`
into it), then ORC relocates and runs it, so a module-asm symbol IS
resolvable at JIT main execution — the long-assumed "AOT only" limit was
stale. Sibling of 1648 (same feature via AOT). Locks current behavior
(exit 42); no compiler change.
2026-06-16 07:24:09 +03:00
agra
d5aee7a222 docs(asm): checkpoint indirect-memory =*m — inline asm feature-complete 2026-06-16 07:10:31 +03:00
agra
cb6c032c58 feat(asm): indirect-memory =*m place outputs
Implements indirect-memory (`=*m`) `-> @place` outputs — the last
substantive asm feature. Unlike a write-through `=` output (which
returns a value that is then stored), an indirect output passes the
place ADDRESS to the asm and the asm writes through it; there is no
return slot.

emitInlineAsm:
  - indirect outputs are excluded from the LLVM return type;
  - their pointer is passed as an opaque `ptr` call arg, placed FIRST
    (the arg-consuming constraint order is: output-section indirect
    pointers, then inputs, then read-write tied seeds);
  - each indirect arg gets an `elementtype(T)` call-site attribute
    (required in the opaque-pointer era), T = the pointee type;
  - the store-back loop skips indirect outputs (already written).
New asmIsIndirect helper. Lowering stops rejecting `*` (constraint kept
verbatim; `=*m` reaches the constraint string as-is). asmOperandIndex
is unchanged — indirect outputs still count as operands, so `%[name]`
${N} numbering holds.

Verified by running on aarch64: store-through-pointer (str x9, %[out]
→ 42, IR `=*m,~{x9}` with `ptr elementtype(i64)`) and a mixed case
(indirect + value output + input → `=*m,=r,r`, indirect ptr arg first,
${0}/${1}/${2} correct). 1652 flipped from the rejection lock to a
runnable aarch64 example (ir-only elsewhere). zig build test green
(661 corpus, 446 unit).
2026-06-16 07:09:17 +03:00
agra
2a43713d7f test(asm): lock indirect-memory =*m rejection (Phase G prep)
Adds examples/1652-platform-asm-indirect-mem.sx exercising a `=*m -> @x`
indirect-memory place output. Currently rejected loudly at lowering
("not yet implemented"); this locks that behavior as a passing test.
The next commit implements indirect-memory outputs and flips this
example to run end-to-end (store-through-pointer → 42).
2026-06-16 07:05:05 +03:00
agra
59469f2b2f docs(asm): checkpoint x86_64 syscall-write example (1651) 2026-06-16 06:39:14 +03:00
agra
cdd920b692 test(asm): x86_64 Linux syscall-write example (ir-only lock)
Adds examples/1651-platform-asm-x86-syscall-write.sx — the canonical
inline-asm use case: `write(2)` via a raw x86_64 `syscall` (SYS_write
in rax, fd/buf/count pinned to rdi/rsi/rdx, rcx+r11+memory clobbered,
byte count returned in rax). Exercises register-pinned inputs, a pinned
value output, a pointer input (*u8 -> rsi), and clobbers(.…) lowering
together.

x86-pinned via .build { "target": "x86_64-linux" }: ir-only on this
aarch64 host (the .ir snapshot locks the exact constraint string
`={rax},{rax},{rdi},{rsi},{rdx},~{rcx},~{r11},~{memory}` — the §II.11
silent-miscompile risk zone), runs natively on x86_64-linux printing
"ok\n" (hand-authored .stdout, asserted only in execute mode).

Pure additive test coverage — no compiler change (lock commit).
zig build test green (660 corpus, 446 unit).
2026-06-16 06:38:13 +03:00
agra
9e7661b915 docs(asm): checkpoint 0138 resolved — output-to-const rejection done 2026-06-16 06:30:22 +03:00
agra
2a954ceeb6 fix(0138): diagnose @scalar-const address-of (no storage)
A scalar `::` constant folds to its value and has no storage. The
unary `.address_of` lowering (src/ir/lower/expr.zig) skipped the
alloca path (is_alloca == false) and resolveGlobalRef (scalar consts
get no storage global), falling through to the generic addr_of arm,
which reinterpreted the folded value as a pointer:
`inttoptr (i64 <value> to ptr)`. That wild pointer segfaulted on
deref and emitted invalid stores for inline-asm `-> @const`.

Diagnose instead, in the address_of(identifier) path: a non-alloca,
non-ref-capture, non-pack-elem scope binding (local scalar const) and
a module_const_map name not backed by storage (module scalar const)
both report "cannot take the address of constant '<name>' — a scalar
'::' constant has no storage …" and return a placeholder Ref. Chose
diagnose over materializing read-only storage (consistent with the
fold-only scalar model). Array/struct consts keep real storage and
stay addressable (@K/@LIT unchanged).

Also gives the ASM stream's planned output-to-const rejection for
free — asm `-> @const` lowers through the same path. Regression:
examples/1177-diagnostics-addr-of-const-rejected.sx. Resolves 0138.
2026-06-16 06:29:36 +03:00
agra
c760b92548 issue(0138): @const address-of yields wild pointer; ASM output-to-const BLOCKED
Filed issues/0138: `@const` (address-of a `::` comptime constant) lowers
to `inttoptr (i64 <value> to ptr)` — segfaults on deref, invalid store for
asm `-> @const`. Root cause in src/ir/lower/expr.zig .address_of (not asm).
Marked CHECKPOINT-ASM Next step BLOCKED on 0138 for the output-to-const
rejection item.
2026-06-15 23:18:37 +03:00
agra
97a4050462 docs(asm): checkpoint Phase G — read-write + place outputs 2026-06-15 23:08:24 +03:00
agra
4128416d48 feat(asm): read-write + place outputs
Implements read-write (`+r` / `+{reg}`) `-> @place` outputs. LLVM has
no `+` constraint, so a read-write place lowers to:

  - an output `=` constraint (return slot, stored back through the
    place after the call), with the leading `+` rewritten to `=`; plus
  - a TIED input constraint (the decimal index of that output) appended
    after the regular inputs, seeded with the place's loaded value
    passed as a call arg.

Tied inputs are appended last so existing operand indices (%[name] ->
${N}) are undisturbed; asmOperandIndex stays correct. Lowering no longer
rejects `+` (indirect `*` still rejected). emitInlineAsm grows the
arg/param arrays by the rw count, loads each seed, and emits the tied
constraint.

Verified by running: increment-in-place (41 -> 42) and a mixed case
(rw place + regular input + value output) producing the textbook
"=r,=r,r,0" constraint with correct ${N} indices. 1650 flipped from
the rejection lock to a runnable aarch64-pinned example (ir-only
elsewhere). zig build test green (658 corpus, 446 unit).
2026-06-15 23:07:38 +03:00
agra
335ac52374 test(asm): lock read-write + place-output rejection (Phase G prep)
Adds examples/1650-platform-asm-rw-place.sx exercising a `+r -> @x`
read-write place output. Currently rejected loudly at lowering
("not yet implemented"); this locks that behavior as a passing test.
The next commit implements read-write outputs and flips this example
to run end-to-end (increment-in-place → 42).
2026-06-15 23:00:48 +03:00
agra
967005621a feat(asm): Phase 2 — -> @place write-through outputs
An asm result can be STORED through a place (a local / struct field) instead of
returned; the place output does not join the result tuple.

- parser.zig: `-> @place` parses `@place` as an ordinary address-of expression
  → an out_place operand (the in-function form; reuses the existing `@` prefix).
- inst.zig: AsmOperand gains out_ty (the output slot's value type) so emit can
  build the combined return struct without re-deriving from Inst.ty.
- lower/expr.zig: out_place operand = the lowered @place address, out_ty = the
  pointee. Read-write (`+`) and indirect-memory (`*`) constraints rejected loudly
  (not yet implemented) rather than miscompiled.
- ops.zig emitInlineAsm: the LLVM return type is built from ALL outputs
  (out_value + out_place); after the call, out_place slots are stored through
  their address and out_value slots rebuild the sx result. Fast path when there
  are no place outputs (the struct return IS the result — pure-value asm IR
  unchanged).

Verified: write-to-local (42), struct field, mixed value+place (v=10 b=20), `+`
rejected. Locked with 1649-platform-asm-place-output (mixed, runs on aarch64).

zig build test green (657 corpus, 446 unit).
2026-06-15 22:47:34 +03:00
agra
b8800a234c docs(asm): add Inline Assembly section to readme
Documents the `asm { … }` expression (template + `-> Type` / `= expr` operands +
clobbers), the §II.5 auto-naming rule (register pin → implicit name; echo form
rejected), the result-shape rule (0→void+volatile / 1→T / N→tuple), `#string`
multi-instruction templates, and top-level global asm + lib-less `extern`
call-into. Per the docs-track-changes rule (inline asm is a landed user-facing
feature). Examples are ones verified running in the corpus.
2026-06-15 22:28:10 +03:00
agra
4d75b9323c feat(asm): Phase F — global (module-scope) asm
A top-level `asm { "tmpl", };` block (template only) lowers to LLVM `module asm`;
a lib-less `extern` declaration calls into the symbols it defines (the import
direction reuses the existing C-FFI extern path — no new surface).

- ast.zig: asm_global node (AsmGlobal { template }).
- parser.zig: parseAsmGlobal, dispatched from parseTopLevel on kw_asm — rejects
  `volatile` and any operands/clobbers (template only). The in-function asm
  expression form stays in parsePrimary.
- module.zig: Module.global_asm list; lower/decl.zig captures each template in
  lowerMainAndComptime (the real top-level pass — lowerDecls is dead for
  top-level); emit_llvm.zig emit() appends each via LLVMAppendModuleInlineAsm in
  source order.
- the new node forced asm_global arms in sema.zig (analyzeNode +
  findNodeAtOffset) and semantic_diagnostics.zig (checkBindingNames).

Verified end-to-end: an aarch64 `_my_add` global routine, called via `extern`,
returns 42 — AOT only (the ORC JIT doesn't link module-asm symbols; global-asm
symbols live in the final linked binary). Locked with 1648-platform-asm-global
({ "aot": true, "target": "macos" } → AOT build+run on aarch64, ir-only else).

zig build test green (656 corpus, 446 unit).
2026-06-15 22:22:29 +03:00
agra
d3c6ffed5a feat(asm): Phase E — multi-output asm returns tuples
Replaces the N>1 "Phase E" bail with a shared asmResultType helper (lowering +
inferType) that derives the result type from the out_value operands: 0→void,
1→T, N→a named tuple (fields named via the §II.5 effective-name rule).

Key realization: toLLVMType(tuple) already produces a literal struct {T1,…,Tn} —
exactly what LLVM's multi-output inline asm returns — so emit needs NO change.
Building the op with a tuple result type makes the asm call return the struct,
which IS sx's tuple value (destructured by the normal tuple_get path).

inferType's .asm_expr arm now also delegates to asmResultType (single owner), so
`return asm`, `x := asm`, and `q, r := asm` all agree on the type.

Verified end-to-end on aarch64: split(0x1234)→(lo=52,hi=18), a udiv/msub
divmod→(3,2). IR: `call { i64, i64 } asm "divq ${4}",
"={rax},={rdx},{rax},{rdx},r,~{cc}"(…)` → extractvalue → tuple.

1640 → the x86_64 multi-output IR lock (ir-only); 1647 → a multi-output example
that runs on aarch64.

zig build test green (655 corpus, 446 unit).
2026-06-15 21:55:38 +03:00
agra
5a5e04c6d5 feat(asm): Phase C.1 + D — inline asm codegen (runs end-to-end)
lowerAsmExpr stops bailing and builds the inline_asm op: resolves each operand's
effective name (§II.5 — explicit [name] else the {reg} pin), interns
template/constraints/clobbers, lowers input Refs, derives the result TypeId
(0→void, 1→T). Adds the last deferred validation (every %[name] must name an
operand). Multi-output (N>1) bails with a named "Phase E" diagnostic.

emitInlineAsm (backend/llvm/ops.zig) ports Zig's airAssembly: assembles the LLVM
constraint string (outputs → inputs → ~{clobber}, ',' → '|'), rewrites the
template (%[name]→${N}, %%→%, $→$$, %=→${:uid}), then LLVMGetInlineAsm +
LLVMBuildCall2 (AT&T dialect). Dispatch wired in emit_llvm.zig (replacing the C.0
@panic tripwire).

inferType gains an .asm_expr arm (expr_typer.zig) so a bare `x := asm {…-> T}`
binding types correctly — without it the binding inferred .unresolved and
silently produced 0.

llvm_shim.c: LLVMInitializeNativeAsmParser() — the JIT must assemble inline asm
at run time.

Verified end-to-end on the aarch64 host: `mov`/`add` with register-class inputs
and a value output run (exit 42/99), `nop volatile` runs (exit 0). IR is
textbook: `call i64 asm "add ${0},${1},${2}", "=r,r,r"(…)`.

Locked with 1645 (aarch64 add, runs; ir-only on non-aarch64) + 1646 (:= binding).
Updated 1640 (now Phase-E bail) + 1642 (now runs).

zig build test green (654 corpus, 446 unit).
2026-06-15 21:39:54 +03:00
agra
6c08de8ec1 feat(asm): Phase C.0 — add inline_asm IR op (lock, no behavior change)
Adds the `inline_asm: InlineAsm` opcode to the IR Op union (inst.zig): interned
template + operand list (role/name/constraint/operand) + interned clobber names
+ has_side_effects; the result rides on Inst.ty (void / scalar / tuple).

The new variant forces coverage in the exhaustive Op switches:
- interp.zig: loud bailDetail — inline asm is never comptime-evaluable.
- print.zig: an IR-dump arm.
- emit_llvm.zig: a @panic TRIPWIRE — emit lands in Phase D, and until then
  lowerAsmExpr still bails, so no inline_asm op is ever created. Reaching emit
  would mean lowering switched over before emit was ready; crash loudly rather
  than miscompile.

No behavior change: lowering still bails, the op is constructed only in the new
`inline_asm op shape` unit test (inst.test.zig).

zig build test green (652 corpus, 446 unit).
2026-06-15 21:00:12 +03:00
agra
5f444aae26 feat(asm): Phase B.1 — operand-name validation (echo + duplicates)
Extends lowerAsmExpr with a pinnedRegister(constraint) helper and two §II.5
operand-naming checks, in the compile path before the codegen bail:

- reject the echo form `[eax] "={eax}"` — a label identical to the register its
  own constraint pins is redundant (the operand is already auto-named after the
  register); the useful form is a label that differs (`[quot] "={rax}"`);
- reject duplicate operand names (ambiguous %[name] / result field).

Locked with 1643-platform-asm-echo-name and 1644-platform-asm-duplicate-name.

zig build test green (652 corpus, 445 unit).
2026-06-15 20:41:41 +03:00
agra
1040b8c776 feat(asm): Phase B.0 — validate asm shape in the compile path
Restructures the .asm_expr lowering arm into lowerAsmExpr, which validates the
asm shape with specific named diagnostics BEFORE the not-yet-implemented codegen
bail, so the user sees the real problem first. Two checklist items enforced:

- template must be a compile-time-known string ("..." or #string), not a
  runtime expression;
- an asm with no value outputs must be `volatile` (else its effects could be
  deleted) — mirrors Zig's rule.

Valid shapes still bail with the "codegen not yet implemented" message. Result-
type derivation + the operand auto-naming rule stay deferred to Phase C, where a
real IR op makes the result type observable/testable.

Locked with 1641-platform-asm-missing-volatile (the volatile error) and
1642-platform-asm-nop-volatile (no-output + volatile accepted → codegen bail).

zig build test green (650 corpus, 445 unit).
2026-06-15 20:35:43 +03:00
agra
f8e029d719 feat(asm): Phase A.1 — parse asm { … } into AsmExpr; loud lowering bail
`asm volatile? { "tmpl", [name]? "constraint" (-> Type | = expr), …,
clobbers(.…) }` now parses into a flat-operand AsmExpr/AsmOperand (ast.zig +
parser.zig parseAsmExpr, dispatched from parsePrimary on .kw_asm). `volatile`
and `clobbers` are recognized contextually (not reserved). `-> @place`
write-through is rejected with a clear "Phase 2" parse error.

Codegen is not implemented yet (IR op + LLVM emit are Phases C–E), so lowering
bails LOUD + named via an explicit .asm_expr arm in lower/expr.zig (not the
generic unknown_expr else) — emitPlaceholder makes hasErrors() abort the build
on the message.

The new asm_expr tag forced (and got) arms in three exhaustive Node.Data
switches: sema.zig analyzeNode + findNodeAtOffset, semantic_diagnostics.zig
checkBindingNames — each recurses into template + operand payloads.

Design: adopted the operand auto-naming rule (design §II.5) — name auto-derived
from a {reg} pin, explicit [name] only when it differs or for register-class
operands, echo form rejected. Typing-stage rule; parser stores name: ?[]const u8.

Locked with examples/1640-platform-asm-parse.sx (multi-output divmod: named
operands, register pins, clobbers — parses then bails, called from main).

Also files issue 0137 (pre-existing, orthogonal: `sx run` with no `main`
segfaults via an unguarded JIT entry lookup in target.zig — not an asm bug).

zig build test green (648 corpus, 445 unit).
2026-06-15 20:21:25 +03:00
agra
3c9ecd0b42 feat(asm): Phase A.0 — add kw_asm keyword + lex test
`asm` now lexes as a dedicated `kw_asm` keyword (Token.Tag + keyword map entry).
`volatile` and `clobbers` stay out of the global keyword table — they are
recognized contextually only inside an `asm { … }` body (PLAN-ASM Deviation 4).

- token.zig: kw_asm tag + `.{ "asm", .kw_asm }` map entry.
- lsp/server.zig: classifyToken exhaustive switch gained the .kw_asm arm
  (the new enum value forced coverage — intended tripwire).
- lexer.test.zig (new, wired into root.zig barrel): locks `asm`->kw_asm and
  `volatile`/`clobbers`->identifier.

Lock commit (behavior-locking passing test). zig build test green (445 unit).
2026-06-15 18:32:34 +03:00
agra
c92d11e748 docs(asm): Phase 0.2 — document <name>.build sidecar; Phase 0 complete
CLAUDE.md §Testing + §Test-layout now describe the optional `<name>.build` JSON
config (aot + target keys, ir-only arch-gating, unknown-key-is-error) and list
it among the `expected/` files, replacing the stale standalone `.aot` marker
prose. Closes Phase 0 (corpus target-gating); next is Phase A (kw_asm keyword).
2026-06-15 18:20:33 +03:00
agra
0095584105 test(asm): Phase 0.1 — corpus ir-only branch for cross-target examples
When a `.build` target doesn't match the host, the runner can't execute the
example here, so it verifies via `sx ir --target` only: asserts exit + the `.ir`
snapshot (stdout) + diagnostics (stderr), never `.stdout`. An `.ir` snapshot is
REQUIRED in ir-only mode — its absence is a loud failure, never a silent pass.

- corpus_run.test.zig: ir_only flag (target set & !hostMatchesTarget); first
  dispatch arm runs `sx ir`, sets act_exit/act_err/act_ir; skip stdout in both
  update and verify modes; require ir_raw.
- lock fixture 1639-platform-target-cross (asm-free main, target x86_64-linux,
  checked-in .ir). Verified: corrupt .ir => IR mismatch; delete .ir => require
  failure.

Test-infra only; no compiler code. zig build test green (647 corpus, 444 unit).
2026-06-15 18:19:17 +03:00
agra
c88f4fbcef test(asm): Phase 0.0 — corpus target-gating + .build JSON config
Adds per-example build/run directives to the corpus runner via an optional
`expected/<name>.build` JSON sidecar (`BuildConfig { aot, target }`), replacing
the standalone `.aot` marker. Threads `--target` into the run/build/ir spawns
and gates the execute path on host arch+os match; a cross-target example fails
loudly ("ir-only mode not yet implemented") pending Phase 0.1.

- corpus_run.test.zig: BuildConfig + std.json parse (unknown-key => error),
  hostMatchesTarget (shorthand-expand + arch/os token match, arm64->aarch64),
  withTarget argv helper; unit tests for both.
- migrate 1226/1227 `.aot` markers -> `.build` { "aot": true }.
- lock fixture 1638-platform-target-host (`.build` { "target": "macos" }).

Test-infra only; no compiler code. zig build test green (646 corpus, 444 unit).
2026-06-15 17:37:35 +03:00
agra
d6a9c4f0c4 fix(diagnostics): locate import parse errors in the imported file
A parse error raised while resolving an `#import` was rendered against the
ROOT file's source — the caret landed on an unrelated line (often a comment)
even though the message named the correct imported file.

Two compounding causes:
- core.zig wired `diagnostics.import_sources` only AFTER import resolution
  returned, but a parse error aborts mid-resolution (before that wiring), so
  the renderer had no imported sources and fell back to the root file. Wire it
  (and seed the main-file source) BEFORE resolving.
- imports.zig emitted the diagnostic at the importer's `#import` span instead
  of the parser's actual error offset inside the imported file, and didn't pin
  the diagnostic's source_file to that file.

parser.zig now records `err_end` alongside `err_offset` for a proper caret
width. New `DiagnosticList.addFmtInFile` renders against an explicit source
file; imports.zig uses it with `importErrSpan(&p)`.

Regression test: examples/1176-diagnostics-import-parse-error-location
(importer + deliberately-broken companion; caret must land in the companion).
2026-06-15 15:09:40 +03:00
agra
fe9bd75e09 chore: track *.vsix via Git LFS
Add .gitattributes routing *.vsix through Git LFS and convert the committed
extension vsix to an LFS pointer. Keeps the reproducible build artifact in the
repo without growing normal history on each rebuild. Future-only — existing
vsix blobs remain in history (a `git lfs migrate` rewrite would be needed to
purge those, deferred since origin/master is shared).
2026-06-15 13:39:39 +03:00
agra
8a3bdbe7b5 chore(ffi-linkage): add MIT license to vscode extension
LICENSE file (MIT, © 2025 agra — matching the repo-root LICENSE) +
`license: "MIT"` manifest field + README license section. Clears vsce's
missing-license warning; rebuilt the vsix.
2026-06-15 13:33:21 +03:00
agra
f3c9747f5a chore(ffi-linkage): post-stream polish — vscode keywords + vestigial param + extension metadata
Two post-stream follow-ups flagged in CHECKPOINT-EXTERN-EXPORT.md, plus a
reproducible vscode-extension packaging setup:

- parser: drop the vestigial `RuntimeClassPrefix.is_extern` field and
  `parseRuntimeClassDecl`'s `is_extern` param. Always false since the
  `#foreign` token was deleted; the postfix `extern`/`export` keyword is the
  sole reference-vs-define decider. No behavior change (644 corpus / 442 unit).
- vscode grammar: highlight `extern`/`export` as `storage.modifier.sx`.
- vscode packaging: declare `@vscode/vsce` as a devDep + add `package` /
  `vscode:prepublish` scripts so the vsix rebuilds reproducibly (was an
  ambient tool). Add repository/homepage/bugs (Gitea), icon (swipelab logo,
  256x256), galleryBanner, README with cover banner. Rebuilt the vsix.
2026-06-15 12:57:07 +03:00
agra
c1ab2cbfc0 docs(ffi-linkage): checkpoint — PHASE 9 COMPLETE, FFI-linkage stream DONE (9.4 gate passes) 2026-06-15 11:19:26 +03:00
agra
b9cfe2554f refactor(ffi-linkage): Phase 9.3/9.4 — purge 'foreign' from issues/*.md; GATE PASS
Rewrote 20 issue writeups to the extern/runtime-class vocabulary (#foreign→extern,
foreign_class_map→runtime_class_map, parseForeignClassDecl→parseRuntimeClassDecl,
findForeignMethodInChain→findRuntimeMethodInChain, dedupeForeignSymbol→
dedupeExternSymbol, is_foreign_c_api→is_extern_c_api, stale filename refs to the
renamed examples, foreign-class→runtime-class, bare foreign→extern). Renamed
issues/0043-…-foreign-class-…→…-runtime-class-….

PHASE 9 COMPLETE — 9.4 GATE PASSES: zero 'foreign' across src/library/examples/
issues/docs/editors/specs/readme/CLAUDE, excluding only the SQLite API constant
SQLITE_CONSTRAINT_FOREIGNKEY + vendored sqlite3.c/.h (upstream third-party).
Suite green (644 corpus / 443 unit, 0 failed).
2026-06-15 11:18:35 +03:00
agra
b52d424369 refactor(ffi-linkage): Phase 9.3 — rename *-foreign* example files → extern/runtime names
git-mv the 10 foreign-named example families to extern/runtime-class names + update
every #import/#include/#source ref, stale comment ref, and the 1172 stderr snapshot
(path + 'extern symbol' message). Renames: 0729…-foreign→…-extern, 1172-diagnostics-
foreign-symbol-conflict→…-extern-symbol-conflict, 1205/1207 ffi-foreign-global→
ffi-extern-global, 1216/1217 ffi-…-foreign-(in-method|result-chain)→…-extern-…,
1219-ffi-foreign→1219-ffi-extern, 1306 objc-foreign-class-chained→objc-runtime-class-
chained, 1318 objc-property-foreign→objc-property-extern-class. DEDUP: deleted
1218-ffi-foreign-cvariadic (identical to 1229-ffi-extern-cvariadic; updated 1229's
twin ref) + the orphaned 1620 dir. Also purged editors/vscode tmLanguage (#foreign
dropped from the directive highlighter) + 1220.h/issues-0030.sx comment refs. Suite
green (644 corpus / 443 unit, 0 failed).
2026-06-15 11:14:35 +03:00
agra
9719432e79 refactor(ffi-linkage): Phase 9.3 — purge remaining 'foreign' from library/docs/example comments
Capital-Foreign + stale-identifier comment refs: library (Foreign Java types→Runtime,
foreign-class→runtime-class, foreign_class_map→runtime_class_map); docs/debugger
(foreign call→extern call); docs/fork-c ledger (foreign_class_map, protocol/foreign→
runtime-class); docs/inline-asm-design Deviation-6 obsolete #foreign-vs-extern design
RESOLVED to the landed extern/export reality; example comments (parseForeignClassDecl→
parseRuntimeClassDecl, checkForeignRefs→checkExternRefs, Foreign decls→Extern). Docs/
comments only — no build impact.
2026-06-15 11:03:29 +03:00
agra
dfae690b31 refactor(ffi-linkage)!: Phase 9.0 — delete the hash_foreign token (src is foreign-free)
Per user directive (total purge): remove the hash_foreign token entirely rather than
keep it for a friendly deprecation message. Deleted: the token enum (token.zig), the
lexer keyword entry + directive-list mention + lex test (lexer.zig), the 4 parser
rejection sites + 2 lookahead clauses + the runtime-class prefix #foreign peek arm
(parser.zig), and the lsp completion arm (server.zig). '#foreign' now lexes as an
invalid '#' token → a generic 'expected ;' parse error (no migration hint — the
accepted UX cost of zero-foreign). Deleted examples/1176-diagnostics-foreign-removed
(its purpose, the friendly rejection, no longer exists).

src/ now contains ZERO 'foreign' (case-insensitive). Suite green (645 corpus / 443
unit, 0 failed). Remaining for the 9.4 gate: issues/*.md prose + example filenames.
2026-06-15 10:59:59 +03:00
agra
811a280517 refactor(ffi-linkage): Phase 9.3 — purge 'foreign' from comments (src caps + examples + docs)
src/: ~21 capital-Foreign comments the case-sensitive verify grep missed
(Foreign-class→Runtime-class, Foreign path→Runtime path, Foreign decls→Extern decls,
FOREIGN function→extern function) across calls/inst/ffi_objc/jni_descriptor/emit_llvm/
c_import/lower.*/ops. src 'foreign' now = ONLY the hash_foreign token + 4 rejection
messages (9.0-delete targets). examples/*.sx comments → extern/runtime-class (1219
stdout regen; KEPT 1176). docs/inline-asm-design + debugger purged. Comments only —
no build impact. 9.0 ratified: DELETE hash_foreign token next.
2026-06-15 10:52:56 +03:00
agra
dc51c4b5bf refactor(ffi-linkage): Phase 9.3-src — purge 'foreign' from src/ comments + a user-facing diagnostic
Reword every 'foreign' comment to the extern/runtime-class vocabulary matching the
renamed identifiers (foreign call→extern call, foreign class→runtime class, foreign
path→runtime path, the #foreign-literal comment mentions → extern, etc.). Also fixes
two USER-FACING issues: the 'expected … #foreign … after type annotation' parse error
no longer advertises the removed keyword, and the Android 'no #jni_main' help
diagnostic now shows '#jni_class(…) extern' instead of the rejected '#foreign
#jni_class'. Removed the now-dead prefix-#foreign-vs-postfix conflict branch in
parseRuntimeClassDecl (the caller rejects #foreign before it runs).

src/ now contains 'foreign' ONLY in the hash_foreign token machinery + its 4
rejection messages — the deprecation mechanism (kept per the 9.0 recommendation; the
message MUST name #foreign to guide migration). Snapshot-neutral; suite green
(646 corpus / 444 unit, 0 failed).
2026-06-15 09:35:00 +03:00
agra
e99383fcb4 docs(ffi-linkage): Phase 9.3-docs — purge 'foreign' from specs/readme/CLAUDE
Reword to the extern/runtime-class vocabulary: 'Foreign Function Interface' heading →
'C Interop'; 'foreign class'→'runtime class'; '#import c foreign decls'→'extern decls';
'foreign function calls'→'extern function calls'; the host_ffi #foreign("c") ref →
extern; the bundling 'foreign calls'→'extern calls'. Docs-only; zero 'foreign' left in
specs.md/readme.md/CLAUDE.md.
2026-06-15 09:24:55 +03:00
agra
145b6d8eff docs(ffi-linkage): checkpoint — Phase 9.1+9.2 IDENTIFIER purge COMPLETE; comment/doc/issues text purge remains 2026-06-15 09:21:55 +03:00
agra
8cca3b9dde refactor(ffi-linkage): Phase 9.2d — rename foreign_path → runtime_path (coupled .sx↔.zig↔hook)
The JNI/runtime-class path (Decision 5, Runtime* family). Coordinated across the
hook boundary so the BuildOptions accessor + its registered hook string stay in sync:
- src/: RuntimeClassDecl.foreign_path→runtime_path, splitForeignPath→splitRuntimePath,
  foreignPathToJavaName→runtimePathToJavaName, jni_main_foreign_paths→
  jni_main_runtime_paths, hookJniMainForeignPathAt→hookJniMainRuntimePathAt, and the
  hook string 'BuildOptions.jni_main_foreign_path_at'→'…runtime_path_at'.
- library/: build.sx accessor jni_main_foreign_path_at→jni_main_runtime_path_at +
  bundle.sx call sites + the  local var → runtime_path + a comment.
- specs.md: the accessor name + <foreign_path_with_dots> doc refs.
- Regenerated 37 .ir snapshots: every program importing build declares the renamed
  @BuildOptions.jni_main_runtime_path_at hook stub — symbol-name change only (verified
  the .ir diff is ONLY this rename; reverted orthogonal empty-file normalization).
Suite green (646 corpus / 444 unit, 0 failed).
2026-06-15 09:20:30 +03:00
agra
a15a868391 refactor(ffi-linkage): Phase 9.2b-fix — use is_extern (not new is_reference) for the runtime-class ref flag
Per user feedback: don't introduce new terminology. The RuntimeClassDecl
reference-vs-define flag (set by the postfix 'extern' modifier, == old prefix
'#foreign #objc_class') is named is_extern, matching the keyword that drives it
and the existing is_extern on VarDecl/IR. Renamed is_reference→is_extern,
is_reference_eff→is_extern_eff; updated the field comment. Snapshot-neutral; green.
2026-06-15 09:06:19 +03:00
agra
d27be42a93 refactor(ffi-linkage): Phase 9.2c — rename extern-ref validators → Extern (linkage)
checkForeignRefs→checkExternRefs, validateForeignRefs→validateExternRefs,
collectForeignRefTargets→collectExternRefTargets — these police 'extern LIB' library
references (linkage axis), so Extern not Runtime. Snapshot-neutral; suite green.
2026-06-15 09:03:35 +03:00
agra
5c8af6eb73 refactor(ffi-linkage): Phase 9.2b — rename runtime-class fns + state → runtime_* / is_reference
The runtime-class object-model identifiers (Decision 5): parse/lower/find/resolve/
register/stamp fns Foreign→Runtime (parseRuntimeClassDecl, lowerRuntimeMethodCall,
findRuntimeMethodInChain, resolveRuntimeMethodReturnType, registerRuntimeClassDecl,
runtimeClassStructType, runtimeKindForOffset, …); state foreign_class_map→
runtime_class_map, current_foreign_class/_method→current_runtime_*, the
foreign_class_decl union variant→runtime_class_decl, foreign_method/static/instance/
class→runtime_*; and the reference-vs-define flag is_foreign→is_reference (+
is_foreign_eff→is_reference_eff) now that it only lives on RuntimeClassDecl.
Snapshot-neutral; suite green (646/444).

Remaining 9.2: the foreign_path family (coupled .sx hooks: jni_main_foreign_path_at
spans build.sx/bundle.sx/compiler_hooks.zig/specs.md) + the extern-ref validators
(checkForeignRefs etc. → Extern, linkage not runtime) + bare 'foreign' comments.
2026-06-15 09:01:04 +03:00
agra
3354446412 refactor(ffi-linkage): Phase 9.2a — rename runtime-class TYPE names → Runtime* (Decision 5)
Mechanical, collision-free PascalCase renames (object-model axis, not linkage):
ForeignClassDecl→RuntimeClassDecl, ForeignMethodDecl→RuntimeMethodDecl,
ForeignClassMember→RuntimeClassMember, ForeignFieldDecl→RuntimeFieldDecl,
ForeignRuntime→RuntimeKind, ForeignClassPrefix→RuntimeClassPrefix. Snapshot-neutral;
suite green (646/444). Remaining 9.2: snake_case state (foreign_class_map,
current_foreign_class, foreign_path [coupled to .sx hooks], the foreign_class_decl
union variant) + the parse/lower/resolve fn names + ForeignClassDecl.is_foreign flag.
2026-06-15 08:57:53 +03:00
agra
7ffdc7d2a2 refactor(ffi-linkage): Phase 9.1d — eliminate the foreign_expr AST node
The last linkage-family 'foreign' carrier. Migrated c_import.zig auto-synthesis
(#import c {#include}) to build the extern shape (empty-block body + extern_export
= .extern_) instead of a foreign_expr body — the Phase 5.0 fn-body flip applied to
auto-synth. With nothing left building it, deleted the foreign_expr union variant +
ForeignExpr struct (ast.zig) and every reader: the dead-arm switch cases (sema,
resolver, generic, call, semantic_diagnostics, lsp), the coalescing reads in
decl.zig (is_foreign local, cc/rename/dedup/variadic/visibility gates) + pack.zig,
and checkForeignRefs (now reads extern_lib only). 9.1 LINKAGE PURGE COMPLETE — all
that remains in src/ is the runtime-class family (9.2) + comments. Snapshot-neutral
(the #import c examples 1215/1216/1217 + sqlite 1624 exercise the synth path); suite
green (646 corpus / 444 unit, 0 failed).
2026-06-15 08:54:56 +03:00
agra
98264b8640 docs(ffi-linkage): checkpoint — Phase 9.1a/b/c done (linkage purge started); collision analysis + scoped gate + ordered remaining plan 2026-06-15 08:48:33 +03:00
agra
cd147942e4 refactor(ffi-linkage): Phase 9.1c — delete dead VarDecl legacy foreign fields
VarDecl carried BOTH the legacy is_foreign/foreign_lib/foreign_name AND the new
is_extern/extern_lib/extern_name (parallel forms coalesced during the migration).
The global #foreign parse path now rejects, so the legacy trio is write-dead and
read in only 3 coalescing sites (decl.zig). Simplified those readers
(vd.extern_name orelse vd.name; vd.is_extern) and deleted the dead fields. Build
confirms no other setter/reader. Snapshot-neutral; suite green (646/444).

Remaining linkage (9.1): foreign_expr (25, still built by c_import.zig auto-synth)
+ ForeignClassDecl.is_foreign (runtime-class, → 9.2). Runtime-class family (9.2,
Decision 5) is the big remaining src/ rename.
2026-06-15 08:46:59 +03:00
agra
b78e7ddeb1 refactor(ffi-linkage): Phase 9.1b — rename 'foreign symbol' diagnostic + panic to 'extern'
The dup-C-symbol diagnostic (decl.zig) and the resolveFuncByName panic (call.zig)
now say 'extern symbol' instead of 'foreign symbol' — the keyword-neutral internal
wording catches up to the extern-only surface. Intentional snapshot regen of 1172
(the only assertion of this message). Suite green (646/444).
2026-06-15 08:42:59 +03:00
agra
b838f6383f refactor(ffi-linkage): Phase 9.1a — rename collision-free linkage identifiers
Mechanical src/ rename of the linkage-family identifiers whose extern_* target is
collision-free: callForeign→callExtern, marshalForeignArg→marshalExternArg,
dedupeForeignSymbol→dedupeExternSymbol, foreign_name_map→extern_name_map,
is_foreign_c_api→is_extern_c_api. Snapshot-neutral (internal only); suite green
(646 corpus / 444 unit, 0 failed).

Deferred (need per-site analysis — target name already exists): is_foreign↔is_extern
(38 existing), foreign_lib/foreign_name↔extern_lib/extern_name (15/16 existing),
foreign_expr (still built by c_import.zig auto-synthesis). Runtime-class family
(ForeignClassDecl etc. → Runtime*, Decision 5) is Phase 9.2.
2026-06-15 08:39:59 +03:00
agra
7ca074e1b0 docs(ffi-linkage): checkpoint — PHASE 8 COMPLETE (cutover); Decision 6 ratified (purge everything); Phase 9 next 2026-06-15 08:08:33 +03:00
agra
3811311e12 feat(ffi-linkage)!: Phase 8.1 — parser hard-rejects #foreign (cutover)
The prefix #foreign linkage directive is removed. All four parse sites
(const-with-type, data global, fn body, runtime-class prefix) now reject it with
a migration message ('#foreign has been removed; use the postfix extern (import) /
export (define) linkage keyword instead'); added a span-aware failAt for the
runtime-class case (the lookahead consumes the token before the reject decision).
Greens the Phase 8.0 xfail 1176.

- Deleted obsolete tests: 1174 (#foreign+postfix conflict — unreachable now that
  #foreign alone is rejected) and 1620 (#foreign nosuchunit lib-ref — superseded by
  the extern twin 1231). Their assertions tested #foreign-specific behavior.
- Removed the GATE A→B unit test + lowerSrcToIr helper (lower.test.zig): it locked
  #foreign ≡ extern through the migration; with #foreign gone there is nothing to
  compare. Converted the in-source 'parse void function with foreign body' parser
  test to the surviving postfix 'extern' spelling (identical resulting AST).
- specs.md + readme.md drop #foreign; document extern/export as the sole C-linkage
  surface.

extern_export in parseFnDecl is now const (the fn-body arm that mutated it is gone).
Suite green (646 corpus / 444 unit, 0 failed). NOTE: comment-only #foreign in
examples + issues/*.md prose + internal foreign_* identifiers remain for Phase 9
(now unblocked: Decision 6 = purge everything).
2026-06-15 08:06:05 +03:00
agra
8180faf839 test(ffi-linkage): Phase 8.0 — xfail #foreign-removed rejection diagnostic
Add examples/1176-diagnostics-foreign-removed.sx pinning the DESIRED Phase 8 cutover
behavior: a bare '#foreign' decl must be rejected with a clear migration message
('#foreign has been removed; use the postfix extern/export'). RED — '#foreign' still
parses (routes onto extern) so the decl compiles and exits 0 instead of erroring.
The very next commit (8.1, parser hard-reject) greens it.
2026-06-15 07:40:12 +03:00
agra
d132aab232 refactor(ffi-linkage): Phase 8 pre-cutover — migrate multi-file example companions
The Phase 7 batches globbed top-level examples/*.sx and missed #foreign decls in
SUBDIRECTORY companion files. Migrate the 4 incidental fn decls (behavior-preserving):
0729/a.sx + b.sx (same-name 'absval' libc authors), 1617/c.sx (pcap_lib_version),
1623/mod.sx (unit_in_mod_answer) — all '#foreign LIB "csym";' → 'extern LIB
"csym";'. Parents all marker'd → corpus-validated; empty snapshot diff. Comment
lines left for Phase 9.3. Suite green (647/444).
2026-06-15 07:36:57 +03:00
agra
720556b24e refactor(ffi-linkage): Phase 8 pre-cutover — migrate keyword-neutral diagnostic tests
Migrate the two #foreign-bearing diagnostic tests whose assertions survive the
cutover, with INTENTIONAL snapshot regens (reviewed):
- 1172 (foreign-symbol-conflict): decl '#foreign libc "getenv"' → 'extern libc
  "getenv"'. Still tests the dup-C-symbol conflict; the 'foreign symbol already
  bound' message is the keyword-neutral INTERNAL wording (renamed to 'extern symbol'
  in Phase 9.1), so it persists — only the echoed source line + caret moved.
- 1228 (non-transitive C-import visibility): its identity was the #foreign≡extern
  equivalence lock, now historical (structural via the A→B gate + unified AST). The
  identifier 'c_foreign_abs' itself contained 'foreign' (would fail the Phase 9.4
  gate), so converted c.sx/b.sx/main to two foreign-free extern symbols
  (c_abs_one/c_abs_two); still pins per-symbol non-transitive visibility.

Reverted the orthogonal 0→1-byte empty-stdout normalization on 1228/1231 (known
writeGolden idempotency quirk, not a behavior change). Suite green (647/444).
2026-06-15 07:34:23 +03:00
agra
2cce6a3a26 refactor(ffi-linkage): Phase 8 pre-cutover — migrate identity ffi-foreign-* test decls
Migrate the DECLS of the 7 identity-#foreign feature tests to extern/export
(1205-global/-helper, 1207, 1218-cvariadic, 1219, 1306, 1318): fn/global markers →
extern, the 2 objc import classes (1306/1318) → postfix '#objc_class("X") extern {'.
Behavior-preserving (A→B gate + existing extern twins guarantee identical output);
empty snapshot diff, corpus-validated. Comment-only #foreign in these files is left
for the Phase 9.3 doc/comment purge (comments aren't parsed → not cutover-critical).
Suite green (647 corpus / 444 unit, 0 failed).
2026-06-15 07:28:20 +03:00
agra
8b91677a1b docs(ffi-linkage): checkpoint — Phase 7 migratable work COMPLETE (7.1-7.4); next Phase 8 cutover 2026-06-15 07:05:11 +03:00
agra
1a8991ab27 refactor(ffi-linkage): Phase 7.4 — migrate straggler examples #foreign→extern
16 fn/global examples across categories (0415/0602/0603/1024/1025/1605/1607-1609/
1611/1616/1619/1622/1628/1635/1636): bare '#foreign'→'extern'. All cls=0 (no class
forms). Marker'd ones (1605/1609/1611 + the rest) corpus-validated; the 3 unmarked
uikit importers (1607/1608/1616) verified byte-identical via 'sx ir' probes.
Empty snapshot diff; suite green (647 corpus / 444 unit, 0 failed).

LEFT comment-only/provenance #foreign (0716/0729 + issues/0030-extern-global +
extern-test files 1223-1231/1332/1348/1349/1426) and the keep-list (identity
ffi-foreign-* + foreign-asserting diagnostics 1172/1174/1219/1228/1620) for Phase 8.
2026-06-15 07:03:53 +03:00
agra
2888f6fc00 refactor(ffi-linkage): Phase 7.3 — migrate 14xx ffi-jni examples #foreign→extern
13 JNI examples migrated (1410-1419/1423/1424/1425): import runtime classes
'#foreign #jni_class("X") {' → '#jni_class("X") extern {'. 1417 (all-runtimes)
also exercises #jni_interface/#objc_class/#objc_protocol/#swift_class/#swift_struct/
#swift_protocol — all take the postfix modifier (verified by probe), migrated via a
generalized '#foreign #<directive>("X") {' → '… extern {' rewrite. No 14xx snapshot
asserts on 'foreign'; empty snapshot diff, corpus-validated.

KEPT comment-only #foreign in 1426 (jni-extern-class test, no decls). Suite green
(647 corpus / 444 unit, 0 failed).
2026-06-15 07:00:32 +03:00
agra
a68f7c2e64 refactor(ffi-linkage): Phase 7.2 — migrate 13xx ffi-objc examples #foreign→extern
18 obj-c examples migrated (1308/1311-1317/1319/1320/1321/1341-1347): import
runtime classes '#foreign #objc_class("X") {' → '#objc_class("X") extern {'
(prefix→postfix) + fn/comment '#foreign'→'extern'. No 13xx snapshot asserts on
'foreign' text → all behavior-preserving; empty snapshot diff, corpus-validated.

Per the keep-list policy: KEPT identity-#foreign tests 1306/1318 (filename
ffi-*-foreign*); LEFT comment-only #foreign in the extern/export test files
1332/1348/1349 (no decls). Bare defined #objc_class examples (no #foreign) untouched
— not a purge target. Suite green (647 corpus / 444 unit, 0 failed).
2026-06-15 06:53:33 +03:00
agra
496390e442 docs(ffi-linkage): checkpoint — Phase 7.1 done + keep-list policy; next Phase 7.2 (13xx objc) 2026-06-15 06:50:26 +03:00
agra
731fb8de64 refactor(ffi-linkage): Phase 7.1 — migrate incidental 12xx ffi examples #foreign→extern
12 plain-C examples that use #foreign incidentally (as FFI plumbing, output
unchanged): 1200/1206/1209-1215/1220/1221/1222. Blanket keyword swap; all fn/global
markers (no class forms in 12xx). Empty snapshot diff; corpus validates directly
(all marker'd). Suite green (647 corpus / 444 unit, 0 failed).

KEPT on #foreign (deferred to Phase 8 cutover): identity-#foreign feature tests
(filename ffi-foreign-*: 1205/1207/1216/1218/1219), the equivalence test 1228, and
the diagnostics that assert on #foreign source/message (1172/1174/1620). Comment-only
provenance prose (1223/1229/1230/1231) left intact per Decision-6-recommended.
2026-06-15 06:49:36 +03:00
agra
d3425fa287 docs(ffi-linkage): checkpoint — Phase 6.4+6.5 done; PHASE 6 COMPLETE (library/ #foreign-free); next Phase 7 2026-06-15 06:25:16 +03:00
agra
32a7628297 refactor(ffi-linkage): Phase 6.5 — migrate gpu/ #foreign→extern; library/ now #foreign-free
Final Phase 6 batch: gpu/gles3.sx (eglGetProcAddress + 1 comment) and gpu/metal.sx
(MTLCreateSystemDefaultDevice), bare fn markers → 'extern'. Verified byte-identical
'sx ir' on importers 1610 (gles3 via uikit GLView) + 1606 (metal). **PHASE 6
COMPLETE — zero #foreign remains anywhere under library/.** Empty snapshot diff;
suite green (647 corpus / 444 unit, 0 failed). Next: Phase 7 (examples + issues).
2026-06-15 04:48:30 +03:00
agra
666a2e20e1 refactor(ffi-linkage): Phase 6.4 — migrate ffi/ #foreign→extern
Pure source rename across objc/objc_block/raylib/sdl3/wasm (~51 sites): fn-decl
markers (bare / 'objc' LIB ref) → 'extern …', and objc.sx's 2 import runtime
classes '#foreign #objc_class("X") {' → '#objc_class("X") extern {'. No bare
defined classes. Behavior-preserving. objc + objc_block validated directly by the
50 marked 13xx corpus examples (incl. import classes 1300/1301 + defined classes
1339/1349); raylib/ffi-sdl3/wasm (no marked importers on host) verified by
byte-identical 'sx ir' probes pre/post. Empty snapshot diff; suite green (647
corpus / 444 unit, 0 failed).
2026-06-15 04:45:55 +03:00
agra
48a8769d19 docs(ffi-linkage): checkpoint — Phase 6.3 (std) done; next Phase 6.4 (ffi) 2026-06-15 04:36:28 +03:00
agra
59f90d2939 refactor(ffi-linkage): Phase 6.3 — migrate std/ #foreign→extern
Pure source rename across 11 std modules (~60 sites): cli/core/fmt/fs/log/
net/kqueue/process/socket/thread/time/trace. All fn-decl markers — bare
'#foreign;', '#foreign libc;'/'#foreign tlib;' (LIB ref), and
'#foreign libc "csym";' (LIB+rename) → the same 'extern …' tail (extern carries
the identical [LIB] ["csym"] axis). Plus 2 stale comment mentions (fmt/fs).
No class forms in std. These modules ARE host-corpus-exercised, so the empty
snapshot diff is direct validation. Suite green (647 corpus / 444 unit, 0
failed).
2026-06-15 04:35:52 +03:00
agra
0fbcee7e36 docs(ffi-linkage): checkpoint — Phase 6.2 (platform) done; next Phase 6.3 (std) 2026-06-15 04:33:06 +03:00
agra
2cd5d7ba82 refactor(ffi-linkage): Phase 6.2 — migrate platform/ #foreign→extern/export
Pure source rename across uikit/android/android_jni/sdl3 (~64 #foreign sites):
- 30 fn decls '… #foreign;' → '… extern;'
- 34 import runtime classes '#foreign #objc_class/#jni_class("X") {' →
  '#objc_class/#jni_class("X") extern {' (prefix → postfix modifier)
- 4 defined Sx* obj-c classes '#objc_class("X") {' → '… export {'

Behavior-preserving (AST already unified post-Phase-5.0). Verified byte-identical
IR via 'sx ir' on the uikit importers 1610 + 1606 (which compile uikit incl. the
4 defined Sx* classes on host) and an sdl3 probe; android.sx (host-incompatible,
only compiles under OS==.android) verified by an identical 4-error dedup set (the
keyword-neutral 'foreign symbol already bound' message is unchanged). Empty
snapshot diff; suite green (647 corpus / 444 unit, 0 failed).
2026-06-15 04:32:20 +03:00
agra
32e83c90cc docs(ffi-linkage): checkpoint — Phase 6.1 (sqlite) done; next Phase 6.2 (platform) 2026-06-15 04:26:12 +03:00
agra
410a52e4ca refactor(ffi-linkage): Phase 6.1 — migrate vendors/sqlite #foreign→extern
Pure source rename: all 97 'sqlite3_* ... #foreign sqlib "csym";' fn decls
→ 'extern sqlib "csym";' (+ the one stale header-comment reference). The
extern_lib axis references the 'sqlib' #import c unit identically to #foreign
sqlib, so IR/output is byte-identical. Empty snapshot diff; example 1624
(vendor-sqlite-module) stdout byte-unchanged. Suite green (647 corpus / 444
unit, 0 failed).
2026-06-15 04:25:34 +03:00
agra
346d4a81c3 docs(ffi-linkage): checkpoint — Phase 5.1 done; PHASE 5 COMPLETE, next Phase 6 2026-06-15 04:22:24 +03:00
agra
93e7b6f727 test(ffi-linkage): Phase 5.1 — annotate A→B gate post-flip + add fn-rename case
Phase 5.0 flipped the fn-decl and data-global #foreign parser paths onto the
same extern-named AST that postfix extern produces, so the A→B gate's fn/global
cases are now STRUCTURALLY identical (guaranteed by construction, not empirically
equal). Annotate the gate header to record this and keep it as a regression
tripwire against a future reader re-diverging the two spellings or a revert of
the flip. Add a fn-rename case (extern_name axis: c_abs -> "abs") to broaden
coverage beyond bare import. Test-only; suite green (647 corpus / 444 unit, 0
failed). PHASE 5.1 COMPLETE → PART B Phase 5 done; next Phase 6 (migrate stdlib).
2026-06-15 04:21:36 +03:00
agra
bde284ee21 docs(ffi-linkage): checkpoint — Phase 5.0 parser routing COMPLETE (fn-body flip landed)
fn-decl #foreign body marker now builds the unified extern AST. All four
#foreign parser paths resolved (global + fn-body flipped; const-with-type
dead; runtime-class already coalesced). Decision 7 ratified (accept churn).
Next: Phase 5.1 (confirm A->B gate covers post-flip) then Phases 6-7
(source #foreign->extern rename in stdlib + examples).
2026-06-15 04:05:04 +03:00
agra
6b94bb6bba refactor(ffi-linkage): Phase 5.0 — flip fn-decl #foreign body marker onto the extern AST
The fn-body `#foreign [LIB] ["csym"]` marker now builds the SAME shape postfix
`extern` produces — extern_export = .extern_ + extern_lib/extern_name + an
empty-block body — instead of a `foreign_expr` body. With all four prereqs
landed (visibility, variadic, plain-free classification, lib-ref validation),
every downstream reader coalesces is_foreign with extern_export, so the IR and
runtime behavior are byte-identical (full corpus + the A->B gate stay green).

The surface keyword is no longer on the AST, so a `#foreign`-spelled decl now
yields `extern`-worded diagnostics — the single accepted churn (Decision 7):
example 1620's lib-ref error flips '#foreign library' -> 'extern library'.
Parser-surface diagnostics (conflict/expected-token) fire on the literal keyword
and are unaffected. c_import auto-synthesis still emits foreign_expr bodies (not
this step), so both shapes still coexist. Parser unit test updated to assert the
extern shape.

647 corpus / 444 unit, 0 failed. The const-with-type (dead) + runtime-class
(already coalesced) paths need no flip — Phase 5.0 parser routing is complete.
2026-06-15 04:03:51 +03:00
agra
4dca38881e docs(ffi-linkage): checkpoint — prereqs 3 & 4 done (4/4); fn-body flip de-risked, Decision 7 open
Plain-free classification + extern lib-ref validation closed (the 3rd and
4th extern/#foreign divergences). All four fn-path prereqs now done. The
fn-decl #foreign->extern flip is scoped: IR zero-churn, only example 1620's
lib-ref wording churns. Records Decision 7 (interim diagnostic wording) as
the one gate before executing the flip.
2026-06-15 03:55:09 +03:00
agra
ad6aed3d7a fix(ffi-linkage): Phase 5.0 prereq — validate extern LIB refs like #foreign
checkForeignRefs now reads a library reference from either spelling — the
legacy #foreign body (foreign_expr.library_ref) or the new extern keyword
(extern_lib) — and validates both against the declared #library / #import c
units. The diagnostic names the surface keyword the user wrote (#foreign vs
extern), so example 1620 (#foreign) is byte-unchanged and example 1231
(extern) gets the parallel 'extern library ... not declared'. Greens 1231.

647 corpus / 444 unit, 0 failed.
2026-06-15 03:53:03 +03:00
agra
38c32400f5 test(ffi-linkage): Phase 5.0 prereq — xfail extern undeclared-library ref unvalidated
An `extern LIB "csym"` ref must name a declared #library / #import c unit,
like its `#foreign LIB` twin (example 1620). Today checkForeignRefs reads
only foreign_expr.library_ref and skips the extern keyword's extern_lib, so
a bogus `extern nosuchunit "abs"` compiles silently (the symbol resolves
via the default image and runs). Expected pins the DESIRED compile-time
diagnostic; the next commit extends checkForeignRefs to green it. Fourth
extern/#foreign divergence and a prerequisite for the fn-decl migration.

647 corpus (1231 xfail), 444 unit.
2026-06-15 03:53:03 +03:00
agra
3c94c14b5e fix(ffi-linkage): Phase 5.0 prereq — exclude extern imports from plain-free-fn classification
isPlainFreeFn / isPlainFreeFnDecl excluded a #foreign body but classified
an empty-block extern fn as a plain free function, so existing extern fns
were wrongly counted in the bare-call ambiguity verdict (and eligible for
the out-of-line-slot / shadow-author pass). Both predicates now also
exclude extern_export == .extern_ (an external C symbol with no
sx-lowerable body, name-keyed first-wins dispatch like #foreign); export
keeps a real body and stays plain-free. Greens example 1230 — same-name
extern authors compile like their #foreign twins (0729).

646 corpus / 444 unit, 0 failed.
2026-06-15 03:46:34 +03:00
agra
270652186e test(ffi-linkage): Phase 5.0 prereq — xfail extern same-name authors wrongly ambiguous
Two flat imports each declare `absval` via `extern libc "abs"` (the
`extern` twin of example 0729's `#foreign` form). Like its #foreign twin,
this must compile + run (prints 7), not error as an ambiguous bare-call
collision.

Today `isPlainFreeFn` / `isPlainFreeFnDecl` exclude a `#foreign` body but
classify an empty-block `extern` fn as a plain free function, so the two
extern authors ARE counted in the bare-call ambiguity verdict and the call
errors. A third extern/#foreign divergence (after visibility + variadic)
and a prerequisite for migrating the fn-decl `#foreign` path onto `extern`.

646 corpus (1230 xfail), 444 unit.
2026-06-15 03:43:18 +03:00
agra
b5411efeb8 docs(ffi-linkage): checkpoint — extern C-variadic prereq DONE; both fn-path prereqs cleared
Next step is the fn-decl #foreign body-marker migration onto extern
(behavior-preserving single refactor commit; lowering + both prereq
gates already coalesce is_foreign with extern_export).
2026-06-14 21:06:35 +03:00
agra
0fdc82154f fix(ffi-linkage): Phase 5.0 prereq — map extern C-variadic tail to ... like #foreign
Two gates were keyed on the `#foreign` (foreign_expr) body shape only:
- declareFunction: the is_variadic drop (decl.zig) — a variadic extern
  kept its trailing slice param in the IR signature.
- packVariadicCallArgs: the call-site early-out (pack.zig) — extras were
  slice-packed instead of passed through the C `...` slot.

Both now also fire for `extern_export == .extern_`, so a variadic
`extern` drops the trailing `..args: []T`, sets is_variadic, and passes
extras through the C ABI with default argument promotion — byte-identical
to its `#foreign` twin. Greens example 1229.

645 corpus / 444 unit, 0 failed.
2026-06-14 21:05:40 +03:00
agra
9a2c78d6b9 test(ffi-linkage): Phase 5.0 prereq — xfail extern C-variadic tail loses its ...
A trailing `..args: []T` on an `extern` fn must map to the C `...` tail
like its `#foreign` twin (example 1218). Today the variadic handling in
both declareFunction (is_variadic drop) and packVariadicCallArgs
(call-site early-out) is gated on `#foreign` only, so a variadic
`extern` keeps the trailing slice param and slice-packs the extras —
garbage at the C ABI (probe: sum_ints(3,10,20,30) → 53316585, not 60).

Example 1229 pins the DESIRED correct output; the next commit extends
both gates to cover extern and greens it. Prerequisite for migrating the
fn-decl `#foreign` path onto `extern`.

645 corpus (1229 xfail), 444 unit.
2026-06-14 21:03:13 +03:00
agra
28d38f2f2f docs(ffi-linkage): checkpoint — Phase 5.0 visibility-gate prereq DONE; const-with-type/runtime-class findings
- Mark deferred prereq (b) visibility-gate equivalence CLOSED (1228).
- Record const-with-type as a dead path (deferred per user) and the
  runtime-class prefix as already-coalesced (no Phase 5.0 change).
- Next step is the fn-path variadic prerequisite.
2026-06-14 20:58:31 +03:00
agra
7d8ba1aabc fix(ffi-linkage): Phase 5.0 prereq — police lib-less extern like #foreign in c_import_bare gate
The non-transitive C-import visibility gate (`isVisible(.c_import_bare)`)
only recognised the legacy `#foreign` body shape; a bare `extern` fn
(empty-block body + extern_export == .extern_) escaped via the
`body != foreign_expr -> return true` arm and was caught only by the
general isNameVisible gate, yielding the generic 'not visible' wording
instead of the C-specific 'C function not visible; add #import' one.

Now both lib-less spellings route to visibleOverEdges, and a library-
bound `extern LIB` (like a library-bound `#foreign LIB`) stays
unconditionally visible. This makes a future fn-decl `#foreign`->`extern`
migration byte-identical at this gate. Greens example 1228.

644 corpus / 444 unit, 0 failed.
2026-06-14 20:57:19 +03:00
agra
717c35d26d test(ffi-linkage): Phase 5.0 prereq — xfail lib-less extern/#foreign C-import visibility equivalence
Cross-module example (main → b → c) referencing c's lib-less C imports
transitively. The non-transitive C-import gate (lower/decl.zig
c_import_bare) must police the legacy `#foreign` form and the new
`extern` keyword IDENTICALLY — same 'C function not visible' diagnostic,
not the generic top-level-name wording. Today the extern twin escapes the
c_import_bare gate (body is an empty block, not foreign_expr) and is only
caught by the general isNameVisible gate, yielding the generic message.
Expected snapshot pins the DESIRED equivalent wording; the next commit
aligns the gate to green it. Prerequisite for migrating the fn-decl
`#foreign` path onto `extern`.

443/444 corpus (1228 xfail), 444 unit.
2026-06-14 20:47:48 +03:00
agra
47aaf3662a docs(ffi-linkage): checkpoint — Phase 5.0 global path routed (Part B started) 2026-06-14 20:32:48 +03:00
agra
e5ddfbe09a refactor(ffi-linkage): Phase 5.0 — route #foreign global decl onto extern AST
Part B begins: `#foreign` becomes an alias for `extern`. First of the four
`#foreign` parser paths to migrate — the data-global form
(`name : T #foreign [lib] ["csym"];`). It now builds the SAME extern-named
VarDecl (`is_extern`/`extern_lib`/`extern_name`) that the postfix `extern`
global path already produces, instead of `is_foreign`/`foreign_lib`/`foreign_name`.

Behavior-preserving: lowering coalesces the two forms identically — the symbol
name is `extern_name orelse foreign_name orelse name` (decl.zig:1119), and both
`is_foreign` and `is_extern` feed the same `.is_extern` IR flag + early-return
(decl.zig:1127,1141). The A->B gate already proved fn/global/class lower to
byte-identical IR, so the corpus locks this with zero snapshot churn.

Suite green: 10/10 steps, 444/444 unit, 643 corpus, 0 failed.

The fn-decl, const-with-type, and runtime-class `#foreign` paths still build the
legacy AST; they migrate next (the fn path needs the deferred visibility-gate +
variadic alignment first).
2026-06-14 20:31:50 +03:00
agra
9ad04e2dda docs(ffi-linkage): note -Dupdate-goldens churn resolved (1-byte conform) 2026-06-14 16:17:26 +03:00
agra
0d39a1e168 test(ffi-linkage): conform 5 empty-stderr goldens to canonical 1-byte form
Eliminates the recurring -Dupdate-goldens churn: these 5 were 0-byte
outliers while 484 other empty goldens use the writeGolden-produced
1-byte "\n" form. The corpus runner trims trailing newlines on both
sides during verify, so both forms passed — but regen always rewrote
them to 1-byte. Conforming them makes -Dupdate-goldens idempotent.
2026-06-14 16:16:58 +03:00
agra
fde767913b docs(ffi-linkage): ratify decision 5 (Runtime*Class*); decision 6 stays open 2026-06-14 16:10:01 +03:00
agra
aafcbf6d78 docs(ffi-linkage): checkpoint — Phase 4 complete, Part A done, A→B gate locked 2026-06-14 16:08:01 +03:00
agra
422c6577cf feat(ffi-linkage): reject extern+export on one decl (Phase 4) — 1175 green 2026-06-14 16:06:22 +03:00
agra
847a027fb1 test(ffi-linkage): xfail extern+export mutual-exclusion diagnostic (Phase 4) 2026-06-14 15:46:33 +03:00
agra
a8e0a8961b docs(ffi-linkage): document extern/export linkage keywords (Phase 4) 2026-06-14 15:40:37 +03:00
agra
4101cbc3e7 feat(ffi-linkage): reject #foreign + postfix extern/export combo (Phase 4) — 1174 green 2026-06-14 15:39:27 +03:00
agra
dd927c2e94 test(ffi-linkage): xfail #foreign+postfix conflict diagnostic (Phase 4) 2026-06-14 15:37:25 +03:00
agra
5d4a2c26c1 test(ffi-linkage): GATE A→B — #foreign ≡ extern IR for fn/global/class (Phase 4) 2026-06-14 15:31:09 +03:00
agra
d4f683f525 docs(ffi-linkage): checkpoint — Phase 3 complete (aggregate extern/export) 2026-06-14 15:14:09 +03:00
agra
91d70bd864 test(ffi-linkage): lock postfix extern (jni) + export (objc defined) aggregates (Phase 3.1) 2026-06-14 15:13:23 +03:00
agra
a9a6d53dc0 feat(ffi-linkage): postfix extern/export on #objc_class aggregate (Phase 3.1) — 1348 green 2026-06-14 15:08:18 +03:00
agra
9f1d7be105 docs(ffi-linkage): checkpoint — Phase 3.0 xfail logged 2026-06-14 15:01:19 +03:00
agra
0bde545f24 test(ffi-linkage): xfail postfix extern on #objc_class aggregate (Phase 3.0) 2026-06-14 15:00:37 +03:00
agra
5ba8d302c2 docs(ffi-linkage): checkpoint — Phase 2 (export) complete + JIT spike findings 2026-06-14 14:55:15 +03:00
agra
23feea6a0c feat(ffi-linkage): consume export "csym" rename (Phase 2.2) — 1227 green
The define path now honors the optional `export … "csym"` symbol-name
override (gap iii). declareFunction's rename branch fires for `export` too:
the extern stub is declared under the C name and the sx→C mapping recorded
in foreign_name_map. lazyLowerFunction then resolves the stub by that C
name (via foreign_name_map) so the body promotes into the C-named function
— emitting `define @triple_c` instead of `@sx_triple`. sx-side call sites
to the sx name resolve through the same map (verified: 5*5 prints 25).

example/1227 greens: the companion C calls `triple_c` and prints
call_triple(7) = 22. Bare export (1226) is unaffected (no rename → sx
name). Suite green (638 corpus / 443 unit). Phase 2 (`export`) complete.
2026-06-14 14:53:37 +03:00
agra
66d9169e59 test(ffi-linkage): xfail export "csym" rename (Phase 2.2)
example/1227 exposes the sx fn `sx_triple` to C under the symbol `triple_c`
via `export "triple_c"`; the companion C calls `triple_c` by that name.
RED: the define path emits the fn under its sx name (`sx_triple`) and
ignores the parsed `extern_name`, so the C reference to `triple_c` is
undefined at AOT link. The next commit consumes the rename on the define
path (gap iii) and greens it.
2026-06-14 14:48:35 +03:00
agra
a47ef20ad3 feat(ffi-linkage): lower export fns (Phase 2.1) — example 1226 green
`export` (define + expose) now lowers to a defined C-ABI symbol with
external linkage and no implicit sx context — the four export-gap
conditions in src/ir/lower/decl.zig:

- (i) linkage: force `.external` for `extern_export == .export_` on both
  define paths (lowerFunctionBodyInto, lowerFunction), beside the
  OS-called entry points.
- (ii) C ABI: promote call_conv to `.c` on the define paths and in the
  declareFunction extern-stub cc.
- (iv) no ctx: funcWantsImplicitCtx returns false for any non-`.none`
  modifier (extern AND export), so no `__sx_ctx` slot is prepended.
- force-lower: an `export` fn is a lowering root (like `main`) in
  lowerMainAndComptime — its purpose is external consumption, so it must
  emit a body even when no sx code calls it; otherwise lazy lowering
  leaves it a bodiless `declare`.

example/1226 now builds + runs via the AOT corpus mode: the companion C
calls `sx_square` by name and prints 37 / 82. Suite green (637 corpus /
443 unit). The optional `export "csym"` rename (gap iii) is Phase 2.2.
2026-06-14 14:45:16 +03:00
agra
6a539ca057 test(ffi-linkage): xfail export fn called from C via AOT (Phase 2.0)
Phase 2 of the extern/export stream verifies `export` (define + expose a
C-ABI sx symbol) end-to-end. C->sx-by-name linkage cannot work under the
corpus's `sx run` JIT mode — a JIT-resident symbol is invisible to a
dlopen'd C dylib's flat-namespace lookup — so this lands a new AOT
execution mode for the corpus: an `expected/<name>.aot` marker switches an
example from JIT `sx run` to a `sx build` + execute flow, linking the sx
object with its C `#source` companions into a native binary.

example/1226 defines `sx_square :: (n: i32) -> i32 export { ... }` and a
companion .c that declares `extern int sx_square(int)` and calls it back.
RED: with `export` not yet lowered, the AOT link fails with an undefined
`_sx_square` (the define path still emits it `internal` + with an implicit
ctx slot, and lazy lowering leaves an uncalled export fn as a bodiless
declare). Phase 2.1 greens it.

Also retires the standalone `tests/run_examples.sh` runner — `zig build
test` (src/corpus_run.test.zig) is now the sole corpus runner, and the
shell mirror would have needed its own AOT-mode port to stay in lockstep.
verify-step.sh drops its redundant step (zig build test already runs the
corpus); CLAUDE.md documents the `.aot` mode.
2026-06-14 14:41:33 +03:00
agra
6932426c41 feat(ffi-linkage): lower extern data globals (Phase 1.2d) — Phase 1 complete
Parser: a 'kw_extern' branch in the var-decl-with-type-annotation path
(beside #foreign) parses 'name : type extern [LIB] ["csym"];' into
VarDecl.is_extern/extern_lib/extern_name; the trailing diagnostic now
lists 'extern'. Lowering: registerTopLevelGlobal uses
extern_name orelse foreign_name orelse name for the C symbol and sets
is_extern = is_foreign or is_extern; globalInitValue returns null (no
initializer) for extern globals too.

examples/1225 green: '__stdinp : *void extern;' lowers to
'@__stdinp = external global ptr'; @__stdinp reads non-null. Suite
green (636 corpus / 443 unit).

Phase 1 done: extern functions (bare + rename) and data globals (bare +
rename) all work, behavior-equivalent to the matching #foreign form.
export (Phase 2), aggregates (Phase 3), docs + A->B gate (Phase 4)
remain. green commit.
2026-06-14 13:39:05 +03:00
agra
235f74a8c9 test(ffi-linkage): xfail example for extern data global (Phase 1.2c)
Add examples/1225-ffi-extern-global.sx — '__stdinp : *void extern;'
references libSystem's stdin pointer via the bare 'extern' modifier on
a typed var decl (the extern-named counterpart of the #foreign global
in examples/1205). Hand-authored snapshot expects the success output.

RED: 1225 is the sole corpus failure (636 ran, 1 failed) — parse error,
'extern' after a type annotation is not yet accepted in the var-decl
path. Phase 1.2d parses it and lowers the extern global.

xfail commit per the cadence rule.
2026-06-14 13:33:50 +03:00
agra
5777ff62ad feat(ffi-linkage): consume extern LIB "csym" rename for fns (Phase 1.2b)
parseFnDecl parses the optional [LIB] ["csym"] tail after the
extern/export keyword into FnDecl.extern_lib/extern_name (mirrors
'#foreign LIB "csym"'). declareFunction unifies the symbol-name
override: rename_c_name = foreign_expr.c_name (for #foreign) OR
fd.extern_name (for extern) -> declare under the C name and map sx->C
in foreign_name_map; the dedupe guard now covers extern too.

examples/1224 green: 'c_abs :: (n) -> i32 extern "abs";' resolves
c_abs to libc abs -> c_abs(-42) = 42. 1223 (bare extern) unregressed.
Suite green (635 corpus / 443 unit).

extern_lib is parsed + stored but not a linking driver — like
'#foreign libc', it references a lib; the #library decl + build flags
remain the separate linking axis (decision 4). green commit.
2026-06-14 13:30:59 +03:00
agra
5f946a3d44 test(ffi-linkage): xfail example for extern fn rename (Phase 1.2a)
Add examples/1224-ffi-extern-fn-rename.sx — 'c_abs :: (n) -> i32
extern "abs";' binds C's abs via the optional symbol-name override.
Hand-authored expected captures the success output (c_abs(-42) = 42).

RED: 1224 is the sole corpus failure (635 ran, 1 failed) — parse error,
the '"abs"' string after 'extern' is not yet accepted. Phase 1.2b
parses the optional [LIB] ["csym"] tail and consumes the rename.

xfail commit per the cadence rule.
2026-06-14 13:26:57 +03:00
agra
18c43984e1 feat(ffi-linkage): lower extern fns as C imports (Phase 1.1)
Route a bare 'extern' fn declare-only, exactly like a lib-less #foreign
import. Six edits in decl.zig, each mirroring an existing foreign_expr
guard so the empty-block placeholder body is never lowered:

  1. funcWantsImplicitCtx: suppress the implicit __sx_ctx for .extern_
  2. declareFunction: add is_extern_decl
  3. ...and include it in the C-ABI calling-convention promotion
  4. lazyLowerFunction: .extern_ -> declareFunction (declare-only)
  5. lowerFunction: .extern_ in the declare-only guard
  6. lowerFunctionBodyInto: never promote/lower an extern stub

examples/1223 now green: 'extern' abs lowers to 'declare i32 @abs(i32)'
(external linkage, C ABI, no ctx param) and the call resolves against
the default-linked libc -> abs(-7)=7, abs(42)=42. The 1.0b hand-authored
snapshot matched byte-exact (no regen). Suite green (634 corpus / 443
unit). green commit (makes the 1.0b xfail pass; adds no new test).
2026-06-14 13:17:00 +03:00
agra
78e304f552 test(ffi-linkage): xfail example for extern fn binding (Phase 1.0b)
Add examples/1223-ffi-extern-fn.sx — binds libc 'abs' via bare 'extern'
(sx name = C symbol, no rename). Hand-authored expected/ captures the
SUCCESS output (abs(-7)=7 / abs(42)=42, exit 0).

RED: 1223 is the sole corpus failure (634 ran, 1 failed) — it parses
then errors at sema ('body produces no value') because lowering does
not yet route extern fns through declareExtern. Phase 1.1 wires the
lowering and turns this green.

xfail commit per the cadence rule (no commit both adds a test and makes
it pass).
2026-06-14 13:06:54 +03:00
agra
df6b675e67 feat(ffi-linkage): fn-path accepts postfix extern/export + lib/name fields (Phase 1.0a)
parseFnDecl now calls parseOptionalExternExport() after the callconv
slot and stores the modifier on FnDecl.extern_export. For 'extern' the
body is ';' (an empty-block placeholder — the modifier carries the
linkage, no *_expr node, per the naming constraint). Both fn-decl
lookahead predicates (isFunctionDef, hasFnBodyAfterArrow) now treat
kw_extern/kw_export as fn-body markers beside kw_callconv, so
'(...) -> R extern;' is recognized as a fn def rather than a fn-type
const.

Per user feedback, decision 4 ("library separate") is REVISED: extern
carries an optional LIB + "csym" axis mirroring '#foreign LIB "csym"',
so it is a true #foreign superset (Gate A->B requirement — the Part B
migration of 466 #foreign uses across 6 libs must preserve each
symbol's library). Added FnDecl.extern_lib/extern_name and
VarDecl.extern_lib (beside is_extern/extern_name).

All unconsumed by lowering: extern parses, but a fn still errors at
sema (body produces no value). Suite green (443 unit / 633 corpus).
lock commit.
2026-06-14 13:02:42 +03:00
agra
62a3b46f6e feat(ffi-linkage): extern/export parser+AST plumbing, unconsumed (Phase 0.1)
Add ast.ExternExportModifier { none, extern_, export_ } beside
CallingConvention; FnDecl.extern_export and VarDecl.is_extern/extern_name
fields (all defaulting to absent); and Parser.parseOptionalExternExport()
mirroring parseOptionalCallConv.

None of this is consumed by a decl path yet — no user-facing behavior
change, corpus diff empty. Two inline parser unit tests pin the helper's
keyword mapping and the field defaults. Phase 1.0 wires the helper into
the fn-decl path. lock commit.
2026-06-14 12:48:56 +03:00
agra
bf6ef8370f feat(ffi-linkage): add kw_extern/kw_export tokens (Phase 0.0)
Lex 'extern' and 'export' as keywords beside 'callconv': new token.Tag
variants + keywords StaticStringMap entries + LSP semantic-token keyword
classification. Adds a 'lex linkage keywords' unit test.

Tokens only — parser/AST plumbing and lowering land in later phases.
Corpus sweep confirmed no .sx identifier collides with the new reserved
words. lock commit per the cadence rule.
2026-06-14 12:40:35 +03:00
agra
78f7bb7857 ... 2026-06-14 12:21:37 +03:00
agra
c562fe236d docs(plans): inline-asm design + ASM and FFI-linkage plans/checkpoints
Two new workstreams:
- ASM: inline assembly — asm { "tmpl", "=r" -> T, "r" = expr, clobbers(.…) },
  multi-return tuples; lowers via the existing llvm_api.c (no shim).
- FFI-linkage: add extern/export postfix keywords, migrate every #foreign onto
  them, then purge 'foreign' from the tree (end-state invariant).

Drop current/ from .gitignore so plans + checkpoints are tracked normally
(the dir was ignored; only checkpoints had been force-added). Includes
docs/inline-asm-design.md. specs.md change left uncommitted.
2026-06-14 12:16:10 +03:00
agra
e386a0d0b4 fix: reject direct assignment to a tagged-union variant member
A tagged union (enum-with-payload) is laid out { tag, payload }, but a
direct member write `s.rect = payload` lowered to a payload-only store
(union_gep into field 1) with no tag store — the discriminant went stale,
so a later match/== took the wrong arm with no diagnostic (issue 0136).
The read path already distinguishes tagged unions (enum_payload/enum_tag);
the write path treated them like plain unions.

A variant is set via construction (`s = .variant(payload)`, which writes
both tag and payload). A direct member write can't safely set the tag (the
active variant isn't known at the write site), so it is now rejected with a
diagnostic pointing to construction. A new diagTaggedUnionVariantWrite guard
— reusing the shared fieldLvalueResolve matcher, applied at both store sites
(lowerAssignment, lowerMultiAssign) — fires only for a whole-variant write
on a tagged union. Plain `union` writes and nested sub-field writes
(`s.rect.w = ...`) are unaffected.

Resolves issue 0136. Tests: examples/0185 (rejected), 0186 (nested write +
construction still work). specs.md / readme.md updated.
2026-06-13 21:18:40 +03:00
agra
4d32a4d4fb fix: propagate union-member type to a struct-literal RHS
Assigning a struct literal to a named-struct member of a plain union
(`u.b = .{ ... }`) lowered the RHS as .unresolved and tripped the
LLVM-emission tripwire: lowerAssignment's .field_access target-type
path used getStructFields, which returns nothing for a union, so the
literal never received its target type.

Unify the lvalue field matcher into a pure fieldLvalueResolve consumed
by both fieldLvaluePtr (GEP builder) and the target-type path, so the
store slot and the RHS target type can't diverge (covers union direct +
promoted members, tuple/vector lanes, and structs).

Resolves issue 0133 (depended on 0135). Regression test: examples/0184.
Notes the now end-to-end union path in issue 0132.
2026-06-13 18:55:41 +03:00
agra
8c47268539 fix: xx pack[i] to a protocol target heap-copies the element
Erasing a single comptime-pack element to a protocol value
(`xx sources[0]` with a protocol target) tripped the pack-as-value
error: buildProtocolErasure treated the index_expr as an lvalue and
took its address via lowerExprAsPtr, whose .index_expr arm lowers the
bare pack as a value (a pack is comptime-only with no runtime storage).

isLvalueExpr now reports a comptime pack index as an rvalue, decided
via the same packArgNodeAt predicate the value path uses — so the value
and lvalue paths can't diverge on what counts as a pack element — and
erasure heap-copies the already-materialized element instead.

Resolves issue 0135. Regression tests: examples/0547, 0548.
2026-06-13 18:55:10 +03:00
agra
d3f5cb20cb fix: visibility-aware type resolution for protocol method signatures
`registerProtocolDecl` resolved each method's param/return type NAME
through the flat, visibility-unaware `type_bridge.resolveAstType`, so a
type name colliding across modules bound to the wrong author. In the
repro the user's `Event` enum collides with the stdlib `event.Event`
struct (pulled in by `modules/std.sx`): the protocol grabbed the stdlib
struct, typed an inferred `g_plat.one_event()` as a fieldless struct,
bound the `case .key_up:(e)` payload to `.unresolved`, and emitted
"enum literal '.escape' has no destination type to resolve against".

Resolve both param and return types through
`resolveTypeInSource(pd.source_file, …)` — the visibility-aware resolver
pinned to the protocol's own declaring module, keeping the `Self → *void`
short-circuit. Brings the non-parameterized path to parity with
`instantiateParamProtocol` and concrete-fn signatures. No silent default:
not-visible / ambiguous names still diagnose and poison with `.unresolved`.

Closes issue 0132 — the protocol-return case left open by f13f4ab (which
fixed the enum/union/inline/error-set registration class). Regression
test: examples/0417-protocols-protocol-return-name-collision.sx.
2026-06-13 15:44:11 +03:00
agra
45befed698 docs(issues): correct 0132 root cause; file 0133 and 0134
- 0132: rewrite to the verified root cause -- protocol method signature
  registration resolves type names via flat findByName and picks the wrong
  same-name author. Original payload-field hypothesis kept as superseded;
  repro switched to canonical `impl ... for` syntax. Still open (the
  protocol path is unchanged).
- 0133: assigning a struct literal to a union member panics ("unresolved
  type reached LLVM emission"); pre-existing, surfaced while testing.
- 0134: a same-name `error` set collapses into a namespaced import's set --
  error-set declarations lack per-decl nominal identity (E6a gap); this is
  what keeps the 0132-class error-ref resolution dormant.
2026-06-13 13:41:30 +03:00
agra
f13f4abfb1 fix: visibility-aware type-name resolution at registration time
Enum payloads, union fields, inline struct/enum/union field types, and
named error-set references now resolve through the visibility-aware
`inner` recursion hook (the same seam `resolveCompound` uses) instead of
the flat `findByName`. A bare type name in any of these positions now
selects the querying module's OWN author over a same-name namespaced
import -- the own-wins rule already applied to top-level named references
and struct fields.

- buildEnumInfo / buildUnionInfo / resolveInlineEnum / resolveInlineStruct
  / resolveInlineUnion / resolveErrorType take the `inner: anytype` seam;
  registerEnumDecl / registerUnionDecl and the struct-const annotation
  pass `self` (visibility-aware); resolveAstType passes the stateless `si`.
- resolveTypeWithBindings routes inline type decls and named error refs
  through `self` instead of delegating to flat resolveAstType.

Regression tests: examples/0781 (top-level enum payload over a namespaced
import), examples/0784 (inline struct field). Addresses issue 0132's
broader latent class; the protocol-return case (0132 primary) is a
separate registerProtocolDecl fix and stays open. The error-set reference
path is in place but dormant pending error-set per-decl nominal identity
(issue 0134).
2026-06-13 13:41:11 +03:00
agra
ab3c9202ff test: run example corpus in zig build test; sx ir → stdout
`zig build test` now runs the full examples/ + issues/ regression corpus
alongside the Zig unit tests, driven by a pure-Zig test
(src/corpus_run.test.zig) — no shell script in the build path. It spawns
the installed `sx` per example (subprocess-isolated, per-run timeout),
diffs stdout/stderr/exit and optional `sx ir` snapshots, and fails the
build on any mismatch. The file list is enumerated at runtime, so new
examples are covered with no test edit.

- `sx ir` / `ir-dump` now write to stdout (fd 1) instead of stderr, so
  the dumps can be piped/redirected.
- `zig build test -Dupdate-goldens` regenerates snapshots in-build,
  byte-identical to the legacy `run_examples.sh --update`; on mismatch
  the runner prints how to regenerate.
- run_examples.sh kept (still used by tools/verify-step.sh) and made
  portable to a bare macOS: timeout/gtimeout fallback, bash 3.2-safe
  empty-array handling.
- CLAUDE.md: document the new workflow.
2026-06-13 09:41:56 +03:00
agra
39488133c9 fix: vendored sqlite builds SQLITE_THREADSAFE=1 — std.thread exists now
THREADSAFE=0 was correct when sx had no threads; with std.thread (S6)
and std.http's pooled dispatch (S7b), concurrent connections corrupted
sqlite's unprotected globals (caught live: distd under ab -c20 died
with free-of-unallocated inside yy_reduce). Serialized mode is
sqlite's own default and safe for every consumer; per-connection use
across threads is the supported pattern.
2026-06-12 22:38:43 +03:00
agra
e57a27205e feat: std.http pooled handler dispatch (PLAN-HTTPZ S7b)
thread_pool_count = 0 (default) keeps handlers inline on the loop
thread — the measured fast path (BENCH-HTTPZ.md). N > 0 dispatches
each parsed request to a std.thread Pool of N workers, completing the
httpz two-pool shape: the connection freezes as CONN_HANDLING (no
reads, growth, eviction, or recycling — the worker borrows views into
its read buffer), the worker runs the handler under a per-job arena
and serializes into job-owned bytes, the completion queues under the
PoolState mutex, and the loop wakes through the new std.event wake
channel (kqueue EVFILT_USER + EV_CLEAR; the epoll twin maps to
eventfd), attaches the response, compacts the buffer, and resumes
keep-alive/pipeline handling. A full backlog sheds with 503. Stale
completions (generation mismatch after close) are dropped. Pool mode
requires the server's constructing allocator to be thread-safe
(GPA/malloc), documented on the knob.

PoolState lives behind a heap pointer (it embeds a Mutex and is shared
with workers; the Server struct itself is returned by value).
serialize_response/run_handler_job share one serialize_bytes.

examples/1633 gains the pooled section (GET, body echo, 404 across
worker threads) plus the loop-wake path exercised end to end; AOT run
five times. examples/1632 unchanged but the Event struct gains `user`.
2026-06-12 22:31:27 +03:00
agra
7f23bb7530 feat: std.thread — Thread, Mutex/Cond, bounded worker Pool (PLAN-HTTPZ S6)
pthread bindings with darwin opaque sizes (mutex 64B, cond 48B; glibc
divergence is a C3 per-OS item). Mutex/Cond initialize IN PLACE and
Pool lives behind Pool.create's heap pointer — POSIX sync objects are
address-sensitive, so nothing here moves after setup. Thread.spawn
takes the C2 re-entry contract entry (callconv(.c), fabricates its own
Context); Pool workers do exactly that with a per-worker malloc-backed
GPA, then run default-conv tasks inside it. submit returns false on a
full backlog (httpz thread_pool backpressure); shutdown finishes
queued work and joins every worker.

examples/1637 pins: 4 raw threads x 1000 locked increments, 100 pool
tasks summing exactly once across 4 workers, a held worker + full
backlog refusing the next submit, clean shutdown. JIT + AOT (AOT run
three times). The std.sx barrel carries thread; .ir snapshot regen is
the usual renumbering.
2026-06-12 22:21:40 +03:00
agra
4fa12853ed test: pin C function pointers into sx (PLAN-HTTPZ C2 — pre-existing)
Both halves of the C2 contract already work in JIT and AOT; these
examples pin them. 1635: libc qsort drives an sx callconv(.c)
comparator passed by name as a typed fn-pointer param. 1636: a real
pthread enters sx through a callconv(.c) entry, fabricates its own
Context (push Context with a local GPA), and runs default-conv sx code
that allocates through it — the re-entry contract std.thread (S6)
stands on. Also unblocks the sqlite callback APIs (hooks/UDFs) left
unbound by design in P5.1.
2026-06-12 22:14:14 +03:00
agra
fe4f059a52 issue 0131: RESOLVED (d7808f6) 2026-06-12 21:52:11 +03:00
agra
d7808f69a3 fix: protocol method calls arity-check (issue 0131)
emitProtocolDispatch now requires the user-arg count to equal the
protocol method's parameter list — exact, since protocol signatures
have no defaults, packs, or variadics — and emits the same
"expects N arguments, but M were given" diagnostic plain calls get.
Previously extra args were silently dropped (and missing args left the
thunk reading garbage). The dispatch gains the call-site span for the
diagnostic. examples/1634 pins the rejection; full sweep confirms no
existing code relied on the leniency.
2026-06-12 21:51:56 +03:00
agra
ff94b004c4 issue 0131: protocol method calls silently drop extra arguments 2026-06-12 21:44:07 +03:00
agra
81fa50c77d fix: std.http dealloc_bytes calls match the Allocator protocol arity
The protocol declares dealloc_bytes(ptr) — the size argument I passed
at three sites was silently accepted and dropped by the compiler
(issue 0131); these calls would stop compiling the moment that
diagnostic gap is fixed.
2026-06-12 21:43:19 +03:00
agra
f3aa2716ae fix: std.http per-request arenas are backed by the server's own allocator
No conjured GPA: the arena chunks come from own_alloc (captured at
Server.init), so all server memory flows from the allocator the app
constructed it with — the point of the implicit context model.
2026-06-12 21:37:39 +03:00
agra
0db4262833 fix: std.http dispatch + error responses run under a per-request arena
Handler and serialization allocations through the implicit context die
with the request; response bytes survive via the own_alloc copy made
inside the push scope. Without this every request leaked its render
concats into the loop's long-lived context.
2026-06-12 21:34:22 +03:00
agra
8641441fad feat: std.http read buffers grow on demand toward read_buf_cap
read_buf_cap is now the per-request LIMIT, not a preallocation: slots
start at 16K, double when full (one-step sizing when a Content-Length
declares the body), and keep their grown capacity for slot reuse. At
the limit the refusal distinguishes oversized headers (431) from an
oversized body (413). Unblocks A1: distd accepts multi-hundred-MB
artifact uploads — preallocating that per slot was never an option.
examples/1633 adds a body past the initial capacity echoing intact.
2026-06-12 21:29:13 +03:00
agra
3a97019aa7 feat: std.http handler carries an opaque ctx word (PLAN-HTTPZ A1 prep)
Server.init(cfg, handler, ctx); the handler signature gains a usize
third argument delivered verbatim per dispatch — typically a pointer
to the app's own state, since the server owns the call site. A bare
(req, resp) handler had no way to reach app state without globals.
examples/1633 pins the round trip.
2026-06-12 21:22:42 +03:00
agra
721793b4bf feat: std.http — single-worker HTTP/1.1 server core (PLAN-HTTPZ S7a)
The httpz shape, one worker, handlers inline over the std.event Loop:
nonblocking accept, per-connection state machine (reading -> writing ->
keepalive/close) with incremental parsing (request line, headers,
Content-Length body), partial-write continuation via on-demand write
interest, pipelined-request draining, and timeouts as EVICTION —
request-delivery and keepalive-idle deadlines on the monotonic clock,
checked after I/O each tick. Keep-alive is the HTTP/1.1 default;
Connection header, HTTP/1.0, or the per-connection request_count cap
turn it off. Config mirrors httpz: port/backlog/max_conn/read_buf_cap/
timeout_request_ms/timeout_keepalive_ms/request_count.

API: Server.init(cfg, handler) + tick(max_wait_ms); run() is the
forever-tick loop. tick makes the server drivable single-threaded —
examples/1633 runs a live server and its client sockets in ONE thread,
pinning: GET with keep-alive, actual connection reuse, the request cap
answering Connection: close then EOF, POST body echo, 404 routing, and
a half-header client evicted at the request deadline while a healthy
client keeps being served. Verified under sx run AND sx build.

Connection slots and read buffers are reused across connections
(httpz's min_conn/buffer-pool spirit); response buffers are allocated
per response and freed on completion. Serialization happens while
request views are valid, the served bytes are compacted, and only then
does sending start — write_more's pipelining check must see only the
remainder. The std.sx barrel carries http; .ir snapshot regen is the
usual mechanical renumbering.

S7b adds worker counts + the handler thread pool (needs C2/S6); the
epoll backend activates with the linux target (S4/S7c).
2026-06-12 21:16:56 +03:00
agra
92e220ee24 feat: std.event — OS-neutral readiness Loop over kqueue (PLAN-HTTPZ S5)
Loop.init/close, add_read/del_read/add_write/del_write with a
per-registration udata word, and wait() normalizing backend events
into Event{fd, udata, readable, writable, eof, err, nbytes}. The epoll
twin (S4) slots in behind this surface when the linux target lands.
No timer registrations by design: request/keepalive eviction is
deadline math — deadline_in/expired/remaining_ms over std.time's
monotonic clock, with remaining_ms feeding wait's timeout. std.sx
barrel carries ; .ir snapshot regen is the usual mechanical
renumbering. examples/1632 pins idle timeout (and that it honors the
deadline), readable with fd/udata/nbytes, immediate writability on an
empty send buffer, and the eof flag on peer close; JIT + AOT.
2026-06-12 21:05:56 +03:00
agra
1017657c90 feat: std/net/kqueue — raw kqueue/kevent bindings, darwin (PLAN-HTTPZ S3)
32-byte darwin struct kevent, EVFILT_READ/WRITE/TIMER, EV_* flags, and
three thin helpers: kev_change (one registration entry), kq_apply
(immediate change, no drain), kq_wait (bounded drain, EINTR reissued,
negative timeout = forever). Off the std.sx barrel by design — the
OS-neutral facade over this and the epoll twin is std.event (S5).
examples/1631 pins zero-cost idle timeout, READ readiness with pending
byte count + udata round-trip, and EV_EOF on peer close; verified under
sx run AND sx build.
2026-06-12 20:57:25 +03:00
agra
659c43c8d6 feat: std.socket nonblocking surface — fcntl, errno, typed _nb wrappers (PLAN-HTTPZ S2)
set_nonblocking (C-variadic fcntl), errno via __error (darwin; C3
selects per-OS), and accept_nb/read_nb/write_nb returning a typed
SockErr — WouldBlock / Closed / Fault — so readiness-loop callers never
parse -1/errno pairs. EINTR retries internally; accept_nb skips
ECONNABORTED. Adds connect, shutdown, socketpair, AF_UNIX, SHUT_*.
examples/1630 pins the result algebra on a socketpair and a nonblocking
TCP listener (WouldBlock on empty backlog, accept after loopback
connect); verified under sx run AND sx build. The .ir snapshot regen is
mechanical: new std decls shift @str/@tag.str numbering and grow the
type table (179 -> 185).
2026-06-12 20:53:35 +03:00
agra
da2f76b383 feat: std.time — wall + monotonic clocks over clock_gettime (PLAN-HTTPZ S1)
now_secs (CLOCK_REALTIME, epoch seconds) and mono_ms (CLOCK_MONOTONIC,
process-local milliseconds for deadlines). Clock ids are darwin's; the
per-OS selection mechanism is PLAN-HTTPZ C3. No error channel: with
module-constant clock ids and a stack timespec, clock_gettime is total.
std.sx namespace tail carries the time alias; examples/1629 pins epoch
plausibility, monotone advance, and the alias carry.
2026-06-12 20:33:01 +03:00
agra
f6b0029249 fix: AOT build cache disabled for programs with top-level #run
The JIT path already guards its object cache with hasTopLevelRun (the
#run interp executes during codegen; a cache hit skips codegen and
loses its effects). The build path had no such guard, so a second
'sx build --cache' of any app with a '#run configure_build()' block
linked WITHOUT the build.sx config — no link flags (m3te: undefined
SDL3 symbols), and on a binary-level hit the output path and bundling
would have been wrong too. Both cache levels and both save sites now
share the guard; #run-free programs keep full cache behavior
(verified: second build hits the binary cache in <1ms; m3te's
build/--cache/build sequence now links and bundles both times).
2026-06-12 19:11:18 +03:00
agra
9fb7290861 feat: duplicate C exports across #import c units are a compile diagnostic
All units share one link namespace (per-unit isolation is PLAN-C C3.2,
deferred), so a symbol defined by two units previously died inside the
JIT dylib link or the AOT link with raw linker spew. The clang shim
gains sx_clang_object_exported_symbols (llvm::object scan: defined +
global, format-specific excluded) and compileCToObjects cross-checks
every unit object — collisions name both source files. Scan failures
are non-fatal; the linker remains the backstop. Covers JIT and native
AOT; the emcc path still relies on wasm-ld's own error.
2026-06-12 19:01:43 +03:00
agra
6114b51073 feat: emcc C compiles go through the object cache
compileCWithEmcc now probes/saves .sx-cache/c-<key>.o with the same
content key as the native path (source + declared headers + transitive
deps + defines/flags/incdirs), keyed by the emcc --version line and the
wasm triple so emsdk upgrades and wasm32/64 variants never collide with
each other or with native objects. Cache hits hand the linker the cache
path directly. objectMagicOk accepts the wasm magic. Verified: warm
wasm build of a c-unit drops 1.85s -> 0.61s (emcc -c skipped).
2026-06-12 18:52:43 +03:00
agra
4b9324e585 feat: transitive quoted includes participate in the c-object cache key
The key previously covered the #source bytes + the block's DECLARED
headers, so a unit whose impl is a thin wrapper over an undeclared
header (vendors/kb_text_shape: two-line impl.c, all code in
kb/kb_text_shape.h) would serve STALE cached objects after an
upstream upgrade. collectIncludeDepBytes now walks the transitive
closure of quoted #include lines (includer-dir first, then -I dirs;
angle/system includes never participate; unresolvable names skip) and
the dep contents fold into the key — no sidecar, no compare logic, a
changed header is just a different key. Verified live: appending to
kb_text_shape.h mints a new cache entry; reverting hits the old one.
2026-06-12 18:48:56 +03:00
agra
b06776d6e9 library: vendors/kb_text_shape + vendors/file_utils; modules/ffi/stb_truetype.sx retired
kb_text_shape (v2.10, JimmyLefevre) had been LOST from the sx tree —
ffi/stb_truetype.sx referenced repo paths that no longer existed (and
nothing runs glyph_cache, so the dangling unit never fired). The
trimmed copy returns from the m3te project as a proper vendor:
curated c/kbts_api.h decls over the full upstream header, README with
provenance, and examples/1627 pinning context + font creation so the
unit compiles and runs in-suite. file_utils (in-house asset-read
helper with the Android AAssetManager hook) gets the same unit shape.

modules/ffi/stb_truetype.sx is gone: glyph_cache imports the three
vendored units (stb_truetype, kb_text_shape, file_utils) directly.
2026-06-12 17:58:23 +03:00
agra
58af806b7a library: vendors/stb_image + vendors/stb_truetype — stb ships with sx
The stb headers move from the repo-root vendors/ (resolvable only
with CWD = sx repo) into library/vendors/ following the sqlite
convention — bindings module + c/ sources + provenance README — so
'#import "vendors/stb_image/stb_image.sx"' (image v2.30 + image_write
v1.16) and '#import "vendors/stb_truetype/stb_truetype.sx"' (v1.26)
work from any consumer via the stdlib search paths. modules/ffi/stb.sx
dissolves into the stb_image vendor; modules/ffi/stb_truetype.sx keeps
its non-stb text-shaping companions and re-imports the vendored unit.
examples/1625 pins a deterministic in-memory BMP decode; examples/1626
pins font init + metric invariants against the system Helvetica.
2026-06-12 17:50:21 +03:00
agra
2097772ec9 library: vendors/sqlite — SQLite ships with sx
The vendored amalgamation (3.53.2, public domain) plus the curated
bindings move from the distribution repo into the sx library:
'#import "vendors/sqlite/sqlite.sx"' gives any sx program SQLite
with no system dependency and no build flags — the bindings declare
the C as a named #import c unit (pinned defines + -O2), compiled
through the object cache and shadow-proof via unit-first resolution.
examples/1624 pins the version and a typed round trip in-suite.
2026-06-12 17:39:26 +03:00
agra
8dedacb23f fix(C4): dedup c-import units by normalized content, not node identity
One module imported through several aliased chains materializes one
c_import_decl copy per chain, each carrying a differently-spelled
relative path to the same file (src/app/../repo/../db/../../vendor/x.c
vs src/db/../../vendor/x.c). Dedup now keys on lexically-normalized
sources/includes + defines + flags, so the unit compiles and links
exactly once — pointer-identity dedup linked it once per chain and
died with duplicate symbols at AOT link.
2026-06-12 17:27:06 +03:00
agra
e9b500a232 fix(C4): collectCImportSources recurses into nested namespaces
A named #import c unit declared inside an aliased module sits two
namespace levels deep in the merged tree; the one-level walk (the
extractLibraries/0130 pattern in c_import form) never collected it,
so the unit silently never compiled and its symbols resolved from
whatever process image carried the same names — surfaced by C4's
sqlite migration, where only the version pin could tell the OS copy
from the vendored one.
2026-06-12 17:16:03 +03:00
agra
44f4aab51c test(C3.3): #define/#flags reach a unit consumed only by hand-curated #foreign decls 2026-06-12 17:09:07 +03:00
agra
1bad29e72e feat(C3.1): #foreign refs are validated — must name a #library or a named #import c unit
validateForeignRefs walks the merged tree (libraries + named c units,
nested namespaces included) and diagnoses any #foreign whose ref names
neither — a typo'd ref previously compiled and resolved silently
through whatever image carried the symbol. Decls synthesized from
#include headers carry no ref and are exempt. Flips the C0.2b pin;
zero collateral across the 608 other examples.
2026-06-12 17:09:07 +03:00
agra
0bd8f3e5ce feat(C2): unit-first JIT symbol resolution — program-owned dylibs beat process images
runJITFromObject now takes priority dylibs (the #import c unit's
linked objects first, then #library deps in declaration order) and
attaches a per-path search generator for each AHEAD of the
process-wide fallback, so a vendored symbol can never lose to a
same-named export of an image the host process happens to carry
(libz via LLVM, libsqlite3 via CoreServices). loadLibrary reports
the name dlopen succeeded on; the c-import handle records its dylib
path; temp link inputs are per-pid so concurrent runs can't clobber
each other. Flips the C0.3 shadowing pin to from_unit: true.
2026-06-12 16:56:35 +03:00
agra
2a2f43eada test(C1.4): objectMagicOk — corrupt/truncated cache entries recompile, never poison the link 2026-06-12 16:48:27 +03:00
agra
053314b3ea feat(C1.2): persistent content-addressed cache for compiled #source units
compileCToObjects now probes .sx-cache/c-<key>.o before invoking the
embedded clang and writes fresh objects back (per-pid temp + copy, the
main object cache's pattern). Default on for both JIT and AOT — the
temp-compile-and-delete behavior it replaces was strictly worse. A
cached entry must carry an object-file magic (Mach-O/ELF) or it falls
back to a fresh compile; no cache failure can fail a build. Cold/warm
verified via --time: the object compile disappears on the warm run.
2026-06-12 16:48:02 +03:00
agra
10f5a4318d test(C1.1): cSourceCacheKey — content key for compiled #source units
Source bytes, declared-header CONTENT (header edits invalidate),
defines/flags/include dirs in order, LLVM version, and target
triple/sysroot all participate; section tags keep equal strings in
different roles distinct. Pure function + variance property tests;
nothing consumes it yet.
2026-06-12 16:43:42 +03:00
agra
1009181638 test(C0.3): pin JIT shadowing — a #source unit's zlibCompileFlags loses to the process-loaded OS libz (xfail for C2.1) 2026-06-12 16:38:02 +03:00
agra
22455c706b test(C0.2): pin curated-bindings shape — #source-only unit + hand #foreign works (JIT+AOT), but the ref is decorative (xfail for C3.1) 2026-06-12 16:38:02 +03:00
agra
2808d5df82 test(C0.1): pin named #import c block — name namespaces decls, #define/#flags reach the unit's compile 2026-06-12 16:38:02 +03:00
agra
d739c5bf11 fix(0130): #library/#framework collection recurses into nested namespaces
extractLibraries/extractFrameworks walked the merged root plus exactly
one namespace_decl level, so a #library reached through two or more
aliased imports never made it to the AOT link line or the JIT dlopen
list. Both walks now recurse over namespace_decl children.

Regression: examples/1617-modules-library-nested-namespace.sx binds
libpcap (not in the compiler's loaded images, so the JIT cannot mask
the miss via RTLD_DEFAULT) behind two aliased imports.
2026-06-12 15:59:36 +03:00
agra
1d17b0abcf lang: introduce cstring — the C-boundary string (Odin model)
cstring is ONE pointer to a null-terminated u8 buffer, C's char*: thin
(8 bytes, no length; cstring_len walks to the terminator), crossing
#foreign boundaries verbatim in both directions, with ?cstring as the
nullable case lowering to the same bare pointer (null = absent).

Conversion discipline mirrors Odin: a string LITERAL coerces implicitly
(its bytes are terminated constants); any other string is rejected with
a diagnostic naming to_cstring (it may be an unterminated view); and
cstring never coerces to string implicitly — from_cstring(c) is the
explicit zero-copy view, pricing the strlen.

Plumbing: TypeId/TypeInfo builtin slot 18 (first_user 19), name
classifiers, size/align/name tables, LLVM ptr lowering, the ?T pointer
niche, the xx pointer ladder, the literal-gated coercion plan
(isConstString + data_ptr), and the reserved-spelling set. std gains
cstring_len/from_cstring/to_cstring (fmt.sx, re-exported); the old
cstring(size) allocator helper is renamed alloc_string everywhere;
getenv migrates to (name: cstring) -> ?cstring as the canonical user
and env() drops its manual strlen/memcpy.

Pinned: examples/1222 (FFI both directions, literal coercion,
?cstring null paths, round trip) and examples/1173 (both coercion
diagnostics); FAIL pre-feature. The alloc_string rename + getenv
signature shift the .ir snapshots — regenerated. zig build test
426/426; run_examples 604/604.

Spec: reserved spelling + cstring section + C-interop rows.
2026-06-12 14:50:53 +03:00
agra
d88bdd7242 fix(0128): foreign cstring returns + conflicting same-symbol bindings
Two genuine defects behind the 0128 filing (whose original repros were
both poisoned by binding getenv, which std already declares -> *u8):

1. Re-declaring a C symbol was silent first-wins: every call through
   the later declaration was typed by the older signature. Foreign
   registration now dedupes — equal signatures share one FuncId,
   conflicting ones are diagnosed.

2. Foreign -> string / -> ?string returns read garbage: C returns one
   char*, but the LLVM signature declared the fat {ptr,i64} (len =
   register garbage), and ?string was mis-declared SRET (the hidden
   out-pointer landed in the callee's first arg register). cstrRetKind
   now classifies such returns, declares them as plain ptr (never
   sret), and the call site synthesizes {ptr, strlen} via a
   branch-guarded strlen (NULL -> {null,0} / optional null), wrapping
   {string, i1} for ?string.

?[:0]u8 itself resolves fine (it is ?string); the spelling works in
return, param, local, and alias positions.

Regression: examples/1221 (plain + optional non-null + NULL paths) and
examples/1172 (conflict diagnostic); both FAIL pre-fix. The extern
dedupe collapses duplicate libc decls, so affected .ir snapshots were
regenerated. zig build test 426/426; run_examples 602/602;
distribution suite 21/21.
2026-06-12 14:13:01 +03:00
agra
a8fbded567 fix(0129): logical not is truthiness-aware, not a bit flip
The unary .not arm emitted bool_not (LLVM bitwise Not) for every
operand. Correct on i1; on an error binding — an error-set value, u32
tag at the LLVM level — a bitwise not of a nonzero tag stays nonzero,
so 'if !e' held even on a SET error and its branch read the
uninitialized success value (real segfault in the distribution repo's
sqlite tests). Plain integers had the same hole ('!7' was '~7').

Now: bool keeps bool_not; integers and error-set operands lower as the
truthiness complement (cmp_eq against a typed zero); anything else is
diagnosed instead of silently bit-flipped.

Regression: examples/1057 (set error: !e must not hold; success: !e
holds with a real value; integer truthiness) + examples/1171 (!"text"
diagnosed); both FAIL pre-fix. zig build test 426/426;
tests/run_examples.sh 600/600.
2026-06-12 13:36:54 +03:00
agra
ba37d0b393 issues: file 0128 — [:0]u8 FFI returns silently u8; ?[:0]u8 unresolved panic
[:0]u8 aliases string (fat) and params already ABI-thin to char*, but
a foreign -> [:0]u8 return silently resolves to plain u8, and ?[:0]u8
never resolves at all (LLVM emission panic) even though ?string works.
Design contract recorded: ?[:0]u8 lowers to a nullable char* at the
boundary, length synthesized on the sx side; until then such returns
must be diagnosed, not mis-typed.
2026-06-12 13:21:19 +03:00
agra
1bc60d3a35 fix(0098): enum literal resolves against the unwrapped optional child; non-enum targets are diagnosed
lowerEnumLiteral resolved the variant against the raw destination type,
so any non-enum destination fell into resolveVariantValue's silent
return-0 tail with the enum_init stamped as the wrong type:

  - ?E destinations produced variant 0 mis-typed as the optional
    (observed as variant 0 OR null, layout-dependent);
  - builtin destinations (i64) silently became 0;
  - unknown variants of real enums silently became variant 0;
  - a destination-less literal panicked LLVM emission (unresolved
    type reached codegen).

Now: optional destinations unwrap to the child enum (the coercion
layer's .optional_wrap handles E -> ?E), and the remaining shapes are
diagnosed — unknown variant (with the variant list, via the new
emitBadEnumVariant twin of emitBadVariant), non-enum destination, and
no destination (cascade-guarded: silent when the destination's type
already failed to resolve and was reported).

Regression tests: examples/0183 (return/assign/reassign into ?Enum,
non-zero variants, null path) + examples/1169/1170 (each diagnostic);
all three FAIL on pre-fix master. zig build test 426/426;
tests/run_examples.sh 598/598.
2026-06-12 12:35:20 +03:00
agra
d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +03:00
agra
515ecebea7 fix(0127): namespaced generic calls type their result from the call's bindings
The plan producer's namespace-fn arms returned the declared return type
without checking type_params, so a qualified generic call's result
carried the unbound T stub: print boxed it as 'T{}', and a non-s64
binding failed LLVM verification (pack monomorphized for the stub,
call returning double). Both fn_ast_map-backed arms now classify
generic callees as generic_fn and infer the return through
inferGenericReturnType, mirroring the bare-identifier path.
2026-06-12 08:53:25 +03:00
agra
70363dda56 issues: file 0127 — namespaced generic call's result mis-types as the unbound T stub 2026-06-12 08:40:45 +03:00
agra
309f48e1b5 fix(0126): array args bind []$T generic params; uninferrable type params diagnose at the call
extractTypeParam's slice arm only extracted from slice-typed args, so
first(a) with a : [3]s64 at first :: (xs: []$T) -> T left T unbound
and the mono body reached LLVM emission carrying the .unresolved
sentinel (panic). The arm now also extracts from array args via the
array's element type — mirroring the array→slice promotion concrete
slice params already perform; the existing arg coercion handles the
rest.

lowerGenericCall additionally diagnoses any still-uninferrable TYPE
param at the call site instead of monomorphizing unbound — the
deliberate string-at-[]$T gap used to hit the same sentinel panic and
now errors with a source-located message. Comptime value params
($N: u32) and ..$Ts packs bind through their own dispatch and stay
exempt.

Regressions: examples/0212-generics-array-arg-slice-param.sx (scalar /
u8 / struct elements + the slice spelling) and
examples/1168-diagnostics-generic-param-uninferrable.sx (string arg
diagnostic) — both failed pre-fix.
2026-06-12 08:31:45 +03:00
agra
b625b74046 issues: file 0126 — array arg at a []$T param leaves T unbound, panics LLVM emission 2026-06-12 08:21:42 +03:00
agra
837b5d375f fix(0124): large stack arrays lower to in-place access, not first-class values
Two lowering sites materialized a local array as a whole LLVM value;
the legalizer scalarizes each such op into one SelectionDAG node per
element, and at ~64K elements the DAG combiner segfaults
(DAGCombiner::visitMERGE_VALUES → ReplaceAllUsesWith).

- lowerVarDecl: an array-typed `---` initializer emits NO store — the
  slot stays uninitialized instead of receiving a whole-array undef
  store. The tuple zero-init carve-out stays; non-array `---` keeps
  the undef store. The interp is unchanged either way (slots start
  .undef).
- lowerIndexExpr: element reads on an array with addressable storage
  GEP the storage and load one element — the general-expression
  sibling of 0110's lowerFor fix — without value-lowering the object
  (a dead whole-array load would still reach the DAG). Storage-less
  arrays keep the index_get fallback.

Sibling shape filed as 0125: any_to_string's per-array-type arms still
pass the array by value, so a 64K+ array type + any {} print crashes.

Regression: examples/0055-basic-large-stack-array.sx (sx build
segfaulted pre-fix). 22 .ir snapshots re-pinned: removed undef stores
and ig.tmp spills, in-place gep+load (instruction-shape-only churn,
reviewed).
2026-06-12 08:19:20 +03:00
agra
7c7bb2076a issues: file 0125 — any_to_string's array arms materialize every interned array type by value 2026-06-12 08:18:32 +03:00
agra
47110b37cf issues: file 0124 — 64K+ stack arrays emit whole-aggregate ops that segfault LLVM 2026-06-12 07:55:46 +03:00
agra
0e890b9992 checkpoint: MEM record-keeping — facade completion, phase ground truth, 2.2 state
Brings the MEM checkpoint up to the 2026-06-11 sessions: the std.sx
pure-re-export facade arc (49a36bb/c75cd9c + issues 0120-0122), the
allocator primitive rename (88bae3c), opt-in UFCS (a47ea14), Phase 2.2
typed helpers (84e0fb0), BufAlloc by-value init (51194a2); next step
Phase 2.3 diagnostic wrappers. Older 2026-05-25-era records fold into
collapsed details blocks.
2026-06-12 07:41:48 +03:00
agra
5b18048abb ir: AST-callee param typing pins the callee module's source context
The two not-yet-lowered fn_ast_map paths in resolveCallParamTypes (the
qualified `ns.f(...)` call and the plain free-fn call) resolved each
param type in the CALL SITE's visibility context, so a namespaced
import's param type that is bare-visible only in its own module
diagnosed "type 'X' is not visible" at calls whose caller never names
the type bare. Route both through the E4 source pin
(resolveParamTypeInSource), as the method paths already do.

A generic callee's bare T leaves are not nominal names in that module:
astCalleeParamTypes installs the call's inferred $T -> concrete
bindings (the one binding builder) before resolving, or the pin turns
the unbound leaf into "unknown type 'T'" (regressed examples/0129
through math/scalar.sx's clamp).

Regression: examples/0840 (namespaced fn with a module-bare param
type; failed "not visible" pre-pin).
2026-06-12 07:41:18 +03:00
agra
7f2b8b5cde fix(0123): wrong arg counts to fixed-arity fns error at the call site
checkCallArity compares the supplied count against the declared params
(min = params without trailing defaults, max = params.len, unbounded
past a variadic) at the five plain dispatch sites in lowerCall — bare
selected-author + lazy, namespace alias-gate + qualified, struct
method, ufcs. Pack / comptime / generic / #compiler / #builtin callees
keep their own dispatch. The method/ufcs sites also gain the
appendDefaultArgs fill the generic-instance leg already had, so
trailing defaults work on dot-calls instead of emitting under-arity
calls. lowerStmt's local fn_decl arm now registers a pointer into the
AST node in fn_ast_map, not a stack temporary that aliased every later
local fn.
2026-06-12 01:42:59 +03:00
agra
7d1e23ecc6 issues: file 0123 — wrong arg counts to fixed-arity fns reach LLVM emission 2026-06-12 00:40:00 +03:00
agra
b3b78e25c0 std: organise the facade — thematic groups, aligned columns
Same 47 re-exports, regrouped: compiler-resolved types, type-system/
reflection builtins, output + libc escape hatch, formatting (print/
format up front, the *_to_string family together), string ops +
allocation helpers, fmt internals marked as legacy-surface, List, tail.
Zero output-stream diffs; 37 .ir snapshots re-pinned (renumbering from
decl reordering). Gates: suite 588/588, zig build test 0, m3te 23/23,
game builds + bundles.
2026-06-11 21:46:03 +03:00
agra
c75cd9c63c std: drop the redundant flat mem.sx import from the facade
The flat #import of mem.sx predated the namespace tail — the tail's
mem :: #import already puts mem.sx in the program graph, which is all
the ufcs helpers (context.allocator.create/alloc/free/clone) and the
CAllocator default-context machinery need; std.sx itself references no
mem name. Probe-verified the full mem surface + all gates: suite
588/588, zig build test 0, m3te 23/23, game builds + bundles. The
double import was also duplicating lowered IR — the 37 re-pinned .ir
snapshots net ~2.5k lines smaller; output streams byte-identical.
2026-06-11 21:25:32 +03:00
agra
49a36bb492 std: the prelude becomes a pure re-export facade — implementations move to std/core.sx, std/fmt.sx, std/list.sx
std.sx now contains only alias declarations (the re-export mechanism:
own decls carry one flat-import level) over three part-files: core.sx
(builtins, libc escape hatch, Source_Location/Allocator/Context/Into,
the reserved `string` decl — which needs and permits no alias), fmt.sx
(print/format/any_to_string/string ops/cstring/alloc_slice), list.sx
(List). The namespace tail is unchanged; the part-file namespaces
(core/fmt/list) carry alongside it. Consumer surface is byte-identical
— every bare prelude name resolves through the aliases (0120/0121
machinery). 37 .ir snapshots re-pinned: pure string-constant
renumbering from the changed import graph (digit-normalized diff is
empty). Gates: zig build test 426/426, suite 588/588, m3te 23/23,
game SxChess builds + bundles.
2026-06-11 19:25:49 +03:00
agra
340be402a5 ir: whole-program passes pin the source context per decl (fix 0122)
convergeClosureShapeSets, checkErrorFlow, and the unknown-type loop ran
under whatever current_source_file the previous phase left behind —
closure-literal annotations resolved (and reject/unknown-type
diagnostics rendered) against an arbitrary module. Latent while std.sx
was a single file (the ambient happened to be the main file); the
re-export facade restructure exposed it. Each walk now pins
setCurrentSourceFile per decl / per fn (body.source_file is already
stamped by resolveImports). Coverage: examples 0129/1047/1049/1052/
1053/1056 against the facade std.sx. Gates: zbt 426/426, suite 588/588.
2026-06-11 19:24:46 +03:00
agra
721369a711 lang: fn aliases dispatch like their target (fix 0121) — scan-time registration through the shared alias-chain walk
Renamed fn aliases failed for EVERY kind (the filed pack-only scope was
a same-name confound: same-name re-exports already resolved through the
name-keyed fn_ast_map). scanDecls now follows ident-/ns.X-RHS const
alias chains (aliasedFnDecl; 0120's hop walk extracted as
followAliasChain) and registers the alias name in fn_ast_map
(absent-only), so every dispatch path — early pack/comptime/generic,
plain lazy-lower, plan-side typing — sees the target decl unchanged.
my_print :: s.print; / my_format :: s.format; now work (the std.sx
re-export shape). Regression: examples/0546 (+rich). Gates: zig build
test 0, suite 588/588.
2026-06-11 18:47:16 +03:00
agra
f2db8ecc53 lang: generic struct head aliases bind the template (fix 0120) — alias-follow from each author's source in head selection; loud unknown-type on the .call type tail
BoxAlias :: Box; / Box :: r.Box; now resolve instantiation, methods,
annotations, and chains through the aliased template, and re-export one
flat-import level as ordinary own decls (the facade shape the std.sx
restructure needs). selectGenericStructHead consults aliasedStructTemplate
(nominal.zig) before the global template map — own-wins/single-flat alias
author, each hop pinned to the alias author's source, ns.X RHS through
namespaceAliasVerdictFrom, depth-capped. resolveTypeCallWithBindings'
silent .unresolved tail (panicked in LLVM emission) now diagnoses
"unknown type". Also aligns the stale pre-existing calls.test.zig UFCS
plan test with the opt-in model (a47ea14). Regression: examples/0211
(+rich/+facade). Gates: zig build test 426/426, suite 587/587.
2026-06-11 18:09:01 +03:00
agra
51194a26d8 mem: BufAlloc.init returns the state by value — full buffer usable, no header carve 2026-06-11 17:31:20 +03:00
agra
a47ea1416e lang: opt-in UFCS — ufcs-marked fns + alias dot-dispatch, generic binding via receiver; one binding builder for plan-side generic returns 2026-06-11 17:04:51 +03:00
agra
84e0fb0752 mem: typed allocation helpers + drop bare malloc/free (Phase 2.2); resolve 0119 as |>-contract clarification 2026-06-11 16:17:39 +03:00
agra
3e10809d7e issues: file 0119 — UFCS generic free functions unresolved 2026-06-11 15:46:49 +03:00
agra
88bae3c9f5 mem: rename Allocator primitives to alloc_bytes/dealloc_bytes (Phase 4 naming pulled forward, Agra-approved) 2026-06-11 15:33:35 +03:00
agra
ca5bd52262 lang: reject unbindable $T-only generic returns at declaration (audit follow-up) 2026-06-11 15:00:52 +03:00
agra
40805e08ec lang: inline for element form over packs — multi-iterable parity 2026-06-11 14:42:59 +03:00
agra
03dc10bba3 fix(0118): cast accepts compound type args; compound type literals are first-class Type values 2026-06-11 14:09:22 +03:00
agra
c229f697bd docs: const-aggregate semantics + unchecked-pointer contract (PLAN-CONST-AGG step 6) 2026-06-11 13:54:35 +03:00
agra
40a94c4734 issues: file 0118 — cast(<compound type>) unresolved 2026-06-11 13:47:38 +03:00
agra
679653fda8 lang: struct consts migrate to const globals, inline fallback (PLAN-CONST-AGG step 4)
A struct constant whose every field serializes — literals, enum tags,
nested aggregates, and (new) const EXPRESSIONS over named consts /
const-aggregate leaves ('r = K + 1', 'g = LIT.r', 'b = A[1]') — becomes
an immutable global: one storage, reads load/GEP it, '@LIT' is
addressable, dead-global elimination drops unused ones. constExprValue
gained a fold-through tail (evalConstIntExpr/evalConstFloatExpr,
source-aware), which also enables const-expression ELEMENTS in array
consts.

A const with a NON-serializable field (a call, a runtime read) keeps
inline re-lowering, and that per-use evaluation is now the documented
contract for the class (pinned: 'CALL.r' reads 1 then 2, side effects
run per use; '#run' is the evaluate-once tool).

Examples: 0180 (migrated shapes + @ptr + copy independence),
0181 (the inline-fallback contract). m3te (23/23) + game rebuilt green.
2026-06-11 12:58:17 +03:00
agra
c23b76c7d6 lang: const-aggregate comptime folds (PLAN-CONST-AGG step 3)
An array const's '.len' and 'K[<const idx>]' element reads, and a
struct const's field ('LIT.r'), fold as compile-time integer leaves —
usable in array dimensions and other constants' initializers. All
source-aware (the SELECTED author's elements, folded in the author's
context with the cyclic-definition frame); a const out-of-range index
diagnoses at fold time, never wraps.

- evalConstIntExpr gains the three ctx hooks (lookupConstAggLen /
  lookupConstArrayElem / lookupConstStructField) + an index_expr arm;
  all five ctx implementations extended (stateless tiers fold null).
- Array consts dual-register in module_const_map (value = the literal
  node) so the folders see elements; bare reads still hit the GLOBAL
  arm first, so no double emission.
- Untyped consts whose RHS is a const-aggregate leaf ('L :: K.len',
  'E :: K[1]', 'R :: LIT.r') register in a pass 2b AFTER aggregates,
  gated on the receiver naming a const aggregate — a namespaced member
  ('F :: m.PI_ISH') is never mis-typed by the count placeholder.

Examples: 0179 (folds in dims + const exprs), 1163 (OOB diagnostic).
2026-06-11 12:44:43 +03:00
agra
7f3bd69bd9 lang: reject writes through constants (PLAN-CONST-AGG step 2, fixes 0116)
Any assignment / compound-assignment whose target chain is ROOTED at a
constant — a const-flagged global (array consts, #run consts) or a
module value const (struct consts incl.) — diagnoses 'cannot assign
through constant X' at compile time. A struct const's field write used
to compile and bus-error at runtime (issue 0116); scalars misfired
silently. A deref along the chain (p.*) breaks the root — pointer
writes stay the documented escape until the const-ness steps; a local
shadowing the const name stays writable.

Also: typed struct constants ('W : Color : Color.{...}') register —
the shape list skipped struct_literal, leaving the typed form
unresolved while the untyped one worked.

Examples: 1162 (all rejection shapes incl. the 0116 crash repro),
0178 (typed struct const reads + copy independence).
2026-06-11 12:33:34 +03:00
agra
8908e78943 lang: array-typed '::' consts as immutable globals (PLAN-CONST-AGG step 1)
K : [4]s64 : .[...] and the untyped A :: .[1, 2, 3] register as
is_const globals: one storage, reads GEP it, dead-global elimination
drops unused ones, source-aware reads come free via selectGlobalAuthor.

- registerConstArrayGlobal (scanDecls pass 2): typed via the annotation
  (array-ness + dimension/count checked), untyped via element-type
  unification — all ints s64; ANY float promotes the element type to
  f64 with ints converting exactly; bool/string homogeneous; a
  non-numeric mix or non-inferable element asks for an annotation.
- constExprValue converts int elements into float destinations exactly
  (the int+float promotion rule, element-wise).
- emitGlobals marks is_const globals LLVMSetGlobalConstant — also flips
  the comptime-backed #run globals and __sx_default_context to
  'constant' (37 pinned IR snapshots regenerated; runtime unchanged).
- Element shapes: nested arrays, struct elements, strings, bools.
  Non-constant elements / dim mismatch / mixed types diagnose loudly.

Examples: 0177 (feature matrix incl. @K reads through *[4]s64 — needs
the 0117 fix), 1159/1160/1161 (diagnostics), 0837 repointed to values.
2026-06-11 12:26:26 +03:00
agra
82d6b8da0e fix(0117): pointer-to-array indexing auto-derefs
A '*[N]T' receiver in an index expression reached LLVM emission with an
unresolved element type and tripped the panic sentinel — no read or
write spelling worked. ptrToArrayElem on Lowering recognises the shape;
the index READ path GEPs the pointee array through the pointer value
and loads the element; the write / compound-assign / lvalue /
addr-of-element paths and the expression typer resolve the element type
through the same helper (their GEP machinery already handled a pointer
base). Kept out of getElementType so slice paths don't half-accept a
raw pointer base.

Regression: examples/0176 (read, write, compound, element ptr + deref).
2026-06-11 12:15:45 +03:00
agra
57979ed8e6 issues: file 0117 — indexing through *[N]T panics at LLVM emission
Pre-existing (plain locals repro it); found pinning @K reads for
PLAN-CONST-AGG step 1, which is now blocked on it. No deref spelling
works: p[2] hits the unresolved-type tripwire, (*p)[2] doesn't parse.
2026-06-11 12:02:03 +03:00
agra
a2bd5b0438 plan: expand PLAN-CONST-AGG scope — struct-const migration, full pointer const-ness, const decay 2026-06-11 11:40:08 +03:00
agra
4f1a9738c7 plan: aggregate consts (PLAN-CONST-AGG) + file 0116 const-write hole
Array-typed '::' consts as immutable globals (approved design: storage
global + untyped inference + comptime-fold layer + const-write rejection
covering the pre-existing struct-const write crash, issue 0116).
2026-06-11 11:18:43 +03:00
agra
cfe5804288 examples: un-rot 1606-platform-metal-clear
ShaderHandle lives in modules/gpu/types.sx; bare-type visibility is
non-transitive (0763), so the example imports it directly. Builds for
ios-sim again.
2026-06-11 10:51:01 +03:00
agra
330c3aeef7 std: full namespace tail — fs/process/socket/json/cli/hash/test
With 0115's own-wins globals landed, the remaining tail modules join
std.sx: every '#import "modules/std.sx"' now carries mem/xml/log/fs/
process/socket/json/cli/hash/test as namespaces (trace stays a direct
import).

Enablers in the same change:
- emit: dead-global elimination — a plain-data global no instruction
  references is not emitted, so tail modules' data (hash's 64-entry K
  table, OS/ARCH/POINTER_SIZE) stays out of binaries that don't use it.
  Comptime-backed globals keep their #run evaluation. 37 pinned IR
  snapshots regenerated (dead globals dropped + string renumbering from
  the larger module).
- 1055/1056 stop pinning the global error-tag ordinal (it shifts with
  program composition); they assert nonzero + tag identity + name.
- specs/readme/CLAUDE.md tail docs updated.
2026-06-11 10:49:39 +03:00
agra
0b13498e25 fix(0115): source-aware global selection — own-wins for module globals
The globals registry (global_names) was last-wins across modules with no
per-importer gate: any module's bare K could read/write/type against an
unrelated module's same-named global (hash.sx's K table hijacked every
user K once std's namespace tail pulled hash into the program), and an
own const of an unsupported shape borrowed another module's const and
panicked at the unresolved-type tripwire.

- var_decl joins RawDeclRef: module globals are selectable raw authors.
- selectGlobalAuthor (the globals analogue of F2's selectModuleConst):
  own author wins, one flat-visible author resolves, >=2 distinct flat
  authors diagnose loudly, authored-but-not-visible diagnoses, and a
  compiler-synthesized global (no raw author) emits untracked. A var_decl
  author whose per-source registration was deduped at flat-merge (two
  modules declaring the same extern symbol) serves the symbol's
  registration.
- All bare-identifier global sites route through it: value read, addr-of,
  assignment (store + compound), lvalue address, fn-ptr call, call param
  typing, and expression type inference.
- selectModuleConst gains .own_opaque: an own const author with no
  materialized per-source value (e.g. an array '::' const) blocks
  borrowing another module's same-named const — the read diagnoses
  cleanly instead of panicking.
- The fn-as-VALUE arm admits raw-facts-only authors: an own fn whose name
  a flat-merge collision dropped from the global decl list (first-wins)
  now resolves via author selection for func_ref/closure/Any shapes too.

Regressions: examples 0835 (own const vs flat array global), 0836 (main
const vs namespaced array global, incl. inference), 0837 (own array
const never borrows cross-module — clean unresolved).
2026-06-11 10:47:30 +03:00
agra
37bea63302 issues: file 0115 — same-name consts of different shapes collide across modules
Scalar K vs array K in two modules: minimal repro panics (unresolved-type
LLVM tripwire), the std-tail topology silently clobbers (0786 family reads
hash.sx's SHA table as its own K). Blocks the PLAN-STDLIB full-tail
follow-up; co-blockers (eager global emission, 0601 comptime-meta,
error-int shifts) noted in the issue.
2026-06-11 09:24:11 +03:00
agra
fbbfcb268c fix(0114): gate alias-qualified calls to one-level carry, pin to target
The lowerCall namespace branch routed alias.fn() through the global
qualified registration (first-wins) at any import depth, and through the
global last-wins bare map for comptime/generic members. Plain-identifier
alias roots now resolve via the carry-aware namespaceAliasVerdict:

- visible alias (own edge or ONE flat hop): the member dispatches the
  TARGET module's own fn (namespaceFnMember + fd-keyed bareAuthorFuncId),
  so two modules' same-named aliases each call their own target.
- two direct flat imports carrying the alias to distinct targets:
  loud ambiguity diagnostic.
- alias only reachable beyond one hop: "namespace 'X' is not visible".
- foreign / builtin / #compiler members keep the literal-symbol path.

Regressions: examples 0832 (two-hop), 0833 (carried collision),
0834 (own-target pin / first-wins repair).
2026-06-11 09:16:03 +03:00
agra
22552075d5 imports: retire the is_pub sketch on NamespaceTarget
Every namespace alias is module surface under the carry rule — the
planned pub-import front-end form is superseded; no per-edge visibility
flag is needed.
2026-06-11 08:49:17 +03:00
agra
b8748aee16 docs: alias carry semantics + stdlib layout (step 4)
- specs §9: Namespace Alias Carry section (one level, own-wins, ambiguity,
  no chaining — 0114 noted for the still-ungated bare-call path), the
  three-tier import resolution (file dir -> cwd -> stdlib search path /
  SX_STDLIB_PATH), a Standard Library Layout section, real-layout examples
  replacing the stale modules/std/std.sx ones.
- readme: carry-rule teaser with the std namespace-tail example (verified
  to compile and run as written).
- CLAUDE.md: file-roles rows for std.sx/std//ffi//math//build.sx,
  tests/fixtures, and the PLAN-STDLIB tracker.
2026-06-11 08:46:17 +03:00
agra
698f75d79a lang: reject dir-vs-file ambiguous #import
An extensionless import path that names a directory next to a same-named
.sx file ('modules/std' with both modules/std.sx and modules/std/ present)
no longer silently resolves to the directory — it errors and asks for the
explicit .sx spelling. Exemption: a file importing its own companion
directory (X.sx importing X/, the multi-file test layout) stays legal —
the sibling file is the importer itself, so the directory is the only
sensible target.
2026-06-11 08:37:36 +03:00
agra
12bf61a9fc std: restructure step 3 — ffi/ moves, build.sx, math dir spelling, fixtures
- objc.sx, objc_block.sx (from std/) + sdl3/opengl/raylib/stb/stb_truetype/
  wasm vendor bindings (from modules/ root) -> modules/ffi/
- std/uikit.sx deleted: platform/uikit.sx already declares UIApplicationMain
  and imports objc; '#framework "UIKit"' cannot live in a file imported on
  macOS targets (unconditional link directive, UIKit is iOS-only), so the
  three iOS-only examples carry the 3-line glue inline. 1607/1608/1616 also
  un-rotted (dead ns_string -> 'xx "..."' Into conversions, callconv(.c)
  msgSend fn-ptrs) — all three build for ios-sim/ios again.
- math/math.sx -> math/scalar.sx; one spelling '#import "modules/math"'
  everywhere (4 pinned IR snapshots regenerated: dir import adds Vec2/Mat4
  to the type tables).
- compiler.sx -> build.sx (imports, CLAUDE.md bundling table, specs.md).
- testpkg/ + test_c.sx -> tests/fixtures/ (resolve CWD-relative from repo
  root, same as vendors/).
- library-internal imports use full modules/... paths (std.sx tail,
  platform/bundle.sx, fixtures).
2026-06-11 08:37:22 +03:00
agra
59f0aa7716 std: restructure — std/ modules, namespace tail, std/xml.sx
allocators/fs/process/socket/log/trace/test move under modules/std/
(allocators.sx becomes std/mem.sx; the Allocator protocol moves into
the std.sx prelude, impls stay in mem.sx). New std/xml.sx holds
xml_escape as xml.escape. std.sx gains the carried namespace tail —
flat-importing std.sx now also provides mem./xml./log. — with the
remaining modules (fs/process/socket/json/cli/hash/test) deferred from
the tail until the global last-wins maps are fully own-wins (pulling
them into every closure collides bare names corpus-wide; they stay
direct imports: modules/std/fs.sx etc.). log.sx's internal emit
renamed log_emit (it clobbered consumer fns named emit program-wide).
bundle.sx uses xml.escape via the carried alias. Consumer import paths
swept mechanically; .ir snapshots recaptured for the larger std
closure. m3te + game build unchanged.
2026-06-11 06:10:59 +03:00
agra
ee00db849c lang: qualified namespace members in value position + alias carry
Two coupled capabilities on the road to the std restructure
(current/PLAN-STDLIB.md, issue 0114):

1. alias.Type.method() / alias.Type as a call head, alias.CONST, and
   alias.Enum.variant now resolve — previously only alias.fn() and
   type-position alias.Type worked. objectIsValue treats an
   alias-rooted field_access as a type head; the call path strips the
   alias to the existing Type.method machinery; lowerFieldAccess
   resolves alias.CONST pinned to the target module and alias.Enum.x
   as a typed enum literal; resolveTypeWithBindings resolves qualified
   type_exprs pinned to the target.

2. The carry rule: namespaceAliasTarget resolves an alias from the
   file's own edges first, then from DIRECT flat imports (one level),
   diagnosing two distinct carried targets as ambiguous. All qualified
   shapes work through a carried alias — the std.sx namespace tail
   (mem.GPA.init() etc.) is now expressible.

Regression: examples/0831-modules-namespace-alias-carry.sx (direct +
carried, all seven shapes).
2026-06-11 05:52:10 +03:00
agra
2025bb361b issues: file 0114 — namespace aliases leak transitively, collide first-wins
Found while probing the alias-carry design for the stdlib restructure
(plan in current/PLAN-STDLIB.md): qualified members register globally
with no per-importer gate, so an alias is usable any number of flat
hops away, and same-name registrations silently first-win. The carry
rule's one-level + ambiguity semantics fix both; repro and fix shape
in the issue.
2026-06-11 05:36:22 +03:00
agra
83ec2536af lang: catch/onfail error bindings take parens
try foo() catch (e) { }   // legal
try foo() catch e { }     // parse error with a migration hint

Same capture style as the for-loop. All four catch shapes keep working
with the parenthesized binding — block, bare-expression body, and the
== match sugar — and the no-binding forms are unchanged. onfail follows
the same rule (onfail (e) { }); its expression-cleanup form is
disambiguated by the paren-group-before-brace lookahead, so
onfail (f()); stays an expression cleanup.

AST unchanged; the printer renders the parens; the #run escape help
text updated. Corpus migrated (57 catch + 3 onfail bindings, in-source
parser test strings, specs incl. grammar rules, readme untouched —
no catch examples there).

Regression: examples/1157-diagnostics-catch-binding-needs-parens.sx;
re-captured stderr for 1010/1013/1037/1123 (migrated source echoed in
carets + help text).
2026-06-10 23:05:02 +03:00
agra
12149eb548 fix(0113): negated-literal global initializers fold as constants
globalInitValue had no unary_op arm, so g : s64 = -1; fell into the
catch-all 'must be initialized by a compile-time constant' even though
constExprValue already folds negate(literal) for the module-const
identifier route. The new arm routes through constExprValue and applies
the direct-literal rules to the folded value: checkIntLiteralFits on
ints (g : s8 = -300 gets the range diagnostic), and a negated float at
an integer global narrows only when integral (-4.0 folds to -4, -4.5
errors). Binary-op initializers keep the specific non-constant
diagnostic.

Regression: examples/0175-types-negative-literal-global.sx.
2026-06-10 22:39:52 +03:00
agra
67313e1dad fix(0112): out-of-range int literals error instead of silently wrapping
checkIntLiteralFits range-checks a literal against its integer target
(builtins + custom widths via intLiteralRange; width-64 types skip —
every representable literal is a legal bit pattern there) and diagnoses
with the type's range and an xx/cast hint. Wired into the .int_literal
arm (covers decls, assignments, call args, struct-literal fields),
lowerStructConstant, and globalInitValue.

A negated literal now folds to a single constant so -128 range-checks
as -128 rather than as an out-of-range +128 intermediate. An explicit
xx operand skips the check — truncation stays available on request
(cast(T) was already exempt: its value arg lowers without the target).

examples/0300-closures-lambda.sx pinned 133 wrapping to -3 through an
s3 param — the exact class this outlaws; updated to a fitting value.

Found during the fix and filed separately: issue 0113 (negated-literal
global initializers rejected as non-constant; pre-existing).

Regressions: examples/1156-diagnostics-int-literal-out-of-range.sx,
examples/0174-types-int-literal-boundaries.sx.
2026-06-10 22:28:24 +03:00
agra
fea5617e4e lang: slice ranges take the same bound markers as for-header ranges
xs[1..=3] (end inclusive), xs[0<..<4] (both exclusive), xs[..=2]
(prefix form with markers, implicit 0 start), xs[2<..] (open end,
exclusive start), and xs[..] (whole collection) — lowered as lo+1 /
hi+1 on the existing subslice op. Strings slice through the same path.
An explicit end marker requires an end expression, matching the
for-header rule.

Regression: examples/0052-basic-slice-range-bounds.sx.
2026-06-10 22:12:45 +03:00
agra
f513c11ea6 test(0051): pin expression bounds at either end of range tokens
x+2..=42 (expression start, 39 iterations summing 897),
x+2<..<x*21 (expressions both ends, 5..41), 0..x*3 (expression end).
Expression parsing stops at the range lexeme from either side, so any
expression works in either position — now pinned.
2026-06-10 22:02:34 +03:00
agra
fd14ab5694 lang: range bound markers — '=' inclusive / '<' exclusive on either side of '..'
Each side of '..' takes an optional bound marker, defaulting to
start-inclusive, end-exclusive (a..b == a=..<b; a..=b stays the short
end-inclusive spelling):

    for 0<..<N (i) { }   // 1 .. N-1   (both exclusive)
    for 0=..=N (i) { }   // 0 .. N     (both inclusive)
    for 0<..=N (i) { }   // 1 .. N
    for 0..<N  (i) { }   // 0 .. N-1   (explicit default)
    for xs, 2<.. (x, i)  // open range, exclusive start: i = 3, 4, ...

The nine lexemes are single tokens (maximal munch on '<'/'='/'..'), so
expression parsing never sees the leading marker as a comparison; '<',
'<<', '<=', '==', '=>' lex unchanged. An explicit end marker makes the
end expression mandatory; open forms are a.. / a<.. / a=... Works in
runtime, multi-iterable, and inline-for headers.

Regression: examples/0051-basic-for-range-bounds.sx (full matrix, open
start-marked ranges, comptime unroll, runtime bounds, lexer
non-regression); 1152's pinned message generalized.
2026-06-10 20:55:31 +03:00
agra
116af2359e lang: multi-iterable for loops — drop ':', add '..=', open ranges, arrow bodies
The for header is now a comma-separated list of iterables with a
positional capture group and no ':' separator:

    for xs (x) { }                    // collection
    for 0..n (i) { }                  // range (end exclusive)
    for 1..=5 (a) { }                 // ..= inclusive end
    for xs, 0.. (x, i) { }            // index idiom (replaces (x, i))
    for xs, ys (x, y) { }             // parallel (zip) iteration
    for xs (x) => sum += x;           // arrow body (full statement)

First-iterable-wins: the first iterable's length drives the loop and
must be bounded; the other positions follow by their own cursors (a
non-first range's end is not consulted or evaluated; a shorter
non-first collection is read past its length on mismatch). The old
single-iterable index capture is replaced by the trailing open range.

Capture/call disambiguation is positional: the paren group immediately
before '{' or '=>' is the capture, every earlier top-level group is a
call. 'for zip(a, b) (x, y)' calls zip; 'for f(n) { }' reads (n) as
the capture and errors with a parenthesize/add-capture hint. The old
':' form errors with a migration hint.

Lowering is unified across forms: one cursor slot per position (ranges
start at their start, collections at 0), all advanced together, the
first position's bound terminating. inline for keeps the single
bounded comptime range.

Migrated the full corpus (examples, library modules, issue repros,
in-source test strings). New coverage: examples/0050 (the full feature
surface) and examples/1149-1155 (seven diagnostic faces). specs.md For
Loop section + grammar rewritten; readme teaser updated.
2026-06-10 20:30:55 +03:00
agra
c640e88513 docs(specs): make defer semantics on break/continue/return exits explicit
The Defer section only said 'when the enclosing scope block exits', which
left the break/continue paths implicit — the exact ambiguity issue 0108
hid behind. State all three exit kinds and the break/continue-outside-loop
diagnostic.
2026-06-10 17:45:14 +03:00
agra
3cc34d54c1 fix(0108): break/continue run the loop body's pending defers
lowerBreak/lowerContinue emitted a bare br, and the enclosing block's
emitBlockDefers — seeing the terminator — discarded the pending entries
on the assumption a return had already drained them. The breaking
iteration's defers were silently skipped, leaking whatever the cleanup
released.

Lowering.loop_defer_base records the defer-stack height at each loop's
body start (while / for / range-for, saved and restored alongside
break_target); break/continue drain non-onfail entries down to it in
LIFO order via the non-truncating emitLoopExitDefers before branching.
Truncation stays with the lexical block exits — the same entries still
belong to the fall-through path after the branch containing the break.
break/continue outside a loop now diagnose instead of no-op'ing.

Regression: examples/0049-basic-defer-break-continue.sx (for and while,
break and continue, nested-block LIFO drain).
2026-06-10 17:43:58 +03:00
agra
bf47146085 fix(0110): for-over-array by-value fetch reads one element, not a full copy
lowerFor's by-value element fetch emitted index_get on the array VALUE;
the emitter realizes that as a whole-array spill to a stack temp + GEP,
per iteration — O(N^2) bytes copied per loop (and pre-0109 it also grew
the stack per iteration, segfaulting a [4096]s64 loop).

When the iterable is an array with addressable storage (and not deref'd
from a pointer, whose identifier alloca holds the pointer rather than
the array), the fetch is now index_gep on the storage + one element
load. Storage-less arrays keep the index_get fallback. The loaded
element remains a copy — mutating the capture does not write back.

Regression: examples/0048-basic-for-array-large.sx (sum over 4096
elements + by-value copy-guard).
2026-06-10 17:34:35 +03:00
agra
878c4226a6 fix(0109): hoist all per-instruction allocas to the function entry block
An alloca built at its use site re-executes on every pass through that
block, and LLVM reclaims allocas only at ret — so loop-body locals,
nested-loop index slots, and emitter spill temps (ig.tmp, sret slots, ABI
coercion temps, byval materialization) grew the stack per iteration and
long loops segfaulted on stack exhaustion.

New LLVMEmitter.buildEntryAlloca inserts after existing entry-block
allocas and restores the builder position; every LLVMBuildAlloca site
reachable during instruction emission now routes through it.
Initialization stores stay at the use site (per-iteration re-init is
unchanged), and entry slots become mem2reg-promotable. The 35 .ir
snapshot diffs are pure alloca position moves (type multisets verified
identical per file).

Regression: examples/0047-basic-loop-local-stack-reuse.sx (segfaulted
pre-fix on both the 1M-iteration body-local loop and the 3M-iteration
nested loop).
2026-06-10 17:27:11 +03:00
agra
e81780e32e fix(0111): unannotated decl literals no longer adopt the fn return type
lowerVarDecl (unannotated) and lowerDestructureDecl now clear target_type
around the initializer lowering: a declaration without annotation provides
no target, so int/float literals take their spec defaults (s64/f64) instead
of the enclosing function's implicit-return type (x := 0 in a -> s8 fn was
s8; big := 3000000000 in -> s32 silently wrapped to -1294967296).

Regression: examples/0173-types-int-literal-default-s64.sx. The remaining
explicit-annotation wrap (x : s8 = 300) is filed as issue 0112.
2026-06-10 17:21:44 +03:00
agra
2b8041a828 cleanup: drop resolved-issue citations from src comments
Sweep all src/**.zig comments that cite resolved issues (issue NNNN /
fix-NNNN / KB-N): the invariant or mechanism each comment states is
kept; the historical citation is dropped, per the no-conclusion-comments
rule. Pure-history parentheticals are removed outright. References to
the 16 still-open issues (0030, 0041-0056) are untouched, as are test
NAMES carrying regression provenance (matching the sanctioned
"Regression (issue NNNN)" example-header convention).

Also removes the issues/0019-import-non-transitive-c-scope/ fixture dir
— the issue is superseded and its behavior is covered by
examples/0706-modules-import-non-transitive.sx (the .md writeup stays).
issues/0030's repro .sx stays: that issue is an open feature request.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 16:34:17 +03:00
agra
8b2a6598a9 drop gitflow 2026-06-10 16:27:16 +03:00
agra
5b304a29c1 fix(KB-9): move Lowering/ProgramIndex maps off page_allocator defaults
16 Lowering map fields and 8 ProgramIndex map fields were declared with
`= ....init(std.heap.page_allocator)` field defaults that init() never
replaced — every instance really allocated page-at-a-time outside the
compilation allocator, invisible to leak checking and never reclaimed.

All 24 now init explicitly with the compilation allocator (module.alloc
/ the init alloc param), which is arena-backed in both the driver
(main's arena) and the test suites (per-test arenas), so backing is
reclaimed at teardown. ProgramIndex's struct doc no longer claims the
page_allocator defaults.

Six lower.test.zig tests that constructed Module with bare
std.testing.allocator leaked once the checker could finally see these
maps; they now use the same per-test ArenaAllocator idiom as the rest
of the file and the facade test suites.

Gate: zig build OK; zig build test 426/426 (6/6 steps, leak-clean);
run_examples 541/0; zero expected/ snapshot churn.
2026-06-10 16:19:52 +03:00
agra
82500931ce cleanup(B-review): fix stale section banners, merge alias block, prune imports
Review follow-up to the ARCH-B split (comment/import hygiene only, no
code changes):

- Section banners that travelled to the wrong file with the B1-B8 cuts
  are reworded to describe the section that actually follows (e.g.
  stmt.zig's trailing "Expression lowering", expr.zig's "Control flow"
  before lowerChainedComparison) or deleted where nothing follows
  (4 trailing-at-EOF banners). ffi.zig's facade note no longer claims
  the IMP builders "stay here" (they live in lower/objc_class.zig);
  protocol.zig's namespace-lookup banner now points at
  pack.zig:resolvePackProjection for the orchestrator.
- lower.zig's two lower/expr.zig alias blocks (B8.1 + B8.2 appends)
  merged into one.
- 448 unused header decls pruned from the 15 lower/*.zig files (each
  had inherited lower.zig's full import block; pruned to fixpoint so
  cascading type-extraction consts went too).

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 16:09:24 +03:00
agra
a7822d73cc Merge branch 'arch-b' (phase B8: lower/expr.zig + lower/closure.zig) 2026-06-10 14:43:04 +03:00
agra
89b3789973 refactor(B8.3): move lambdas + captures to lower/closure.zig
Verbatim relocation of the 5-method closure cluster (lowerLambda,
bare-fn trampoline, closure-to-bare-fn adapter, capture collection, env
sizing) into src/ir/lower/closure.zig. 5 aliases on Lowering keep all
call sites unchanged. Method pub-flip: typeAlignBytes.

Resolves the B7.1 flag: CaptureInfo relocates from lower/call.zig to
lower/closure.zig (its domain home, next to collectCaptures); the
Lowering type alias is repointed so external references are unchanged.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 14:43:04 +03:00
agra
35e35adb0a refactor(B8.2): move expr core + operators to lower/expr.zig
Verbatim relocation of the 8-method core expression cluster (lowerExpr
dispatch, ref-capture pointee, binary ops, tuple ops/lex-compare/
membership, chained comparison, emitCmp) appended to
src/ir/lower/expr.zig. 8 aliases on Lowering keep all call sites
unchanged.

Method pub-flips: isArithOperand, isBitwiseOperand, isOrderingOperand,
lowerLambda, binOpSymbol. expr.zig reaches arithResultType,
exprIsFailable, binOpSymbol via Lowering-namespace alias consts.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 14:40:44 +03:00
agra
0592c9dc97 refactor(B8.1): move expression literals/field/index to lower/expr.zig
Verbatim relocation of the 30-method expression cluster (struct/array/
tuple/enum/tagged-enum literals, init blocks, field access on values and
types, optional chains, numeric limits, indexing, slicing, deref, force
unwrap, null coalesce) into src/ir/lower/expr.zig — one contiguous
1,372-line cut. 30 aliases on Lowering keep all call sites unchanged.

Nested StructConstInfo stays on Lowering (field type of
struct_const_map), flipped pub and reached via an alias const, alongside
headNameOfCallee.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 14:38:11 +03:00
agra
1790d808cc Merge branch 'arch-b' (phase B7: lower/call.zig + lower/pack.zig + lower/generic.zig) 2026-06-10 14:35:52 +03:00
agra
ef4059bdde refactor(B7.3): move generics emission to lower/generic.zig
Verbatim relocation of the 39-method generics cluster (function/struct/
union monomorphisation + instantiation, type-arg resolution and
matching, type-name formatting/mangling, type-category tags, head-gate
author selection, value-param resolution + diagnostics) plus four
single-home nested types (HeadTemplate, HeadName, HeadTypeGate,
GenericStructMethod) into src/ir/lower/generic.zig. 39 aliases on
Lowering keep all call sites unchanged.

Method pub-flips: packResolver, resolveVectorLane. generic.zig reaches
five relocated/static helpers via Lowering-namespace alias consts
(inferExprType, isNamedTypeKind, resolveBuiltin, structMethodFn,
typeFnAuthor).

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 14:35:52 +03:00
agra
6b07346d98 refactor(B7.2): move pack/variadic lowering to lower/pack.zig
Verbatim relocation of the 19-method pack cluster (pack element/value
projection, spread refs, pack indexing + diagnostics, pack-to-slice,
variadic arg lowering/packing, pack-slice materialisation, pack-fn
calls + monomorphisation, pack projection resolution, isPackFn/
isPackParam predicates) into src/ir/lower/pack.zig. 19 aliases on
Lowering keep all call sites unchanged.

ProjectionPosition and PackProjection move from lower/protocol.zig to
lower/pack.zig (their domain home, next to resolvePackProjection);
the Lowering type aliases are repointed, so external references
(lower.test.zig) are unchanged.

Method pub-flip: findReturnValueType.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 14:30:34 +03:00
agra
54db29e60a refactor(B7.1): move call lowering to lower/call.zig
Verbatim relocation of the 18-method call cluster (lowerCall moved
whole, context diagnostics, foreign-call helper, builtin/function
resolution, generic + runtime-dispatch calls, reflection calls + guards,
default-arg expansion, call param typing) into src/ir/lower/call.zig.
18 fn aliases keep all call sites unchanged.

CaptureInfo (closure-domain type that sat inside the run) travelled and
is re-exposed via a Lowering type alias; candidate to relocate to
lower/closure.zig in B8.3.

Method pub-flips: callResolver, createBareFnTrampoline,
ensureGenericInstanceMethodLowered, fixupMethodReceiver,
getStructTypeName, isStaticTypeArg, lowerPackFnCall, packSpreadRefs,
packVariadicCallArgs, refCapturePointee, resolveParamTypeInSource,
typeSizeBytes, headNameOfCallee.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 14:27:20 +03:00
agra
c38dd67b83 Merge branch 'arch-b' (phase B6: lower/ffi.zig + lower/objc_class.zig) 2026-06-10 14:24:13 +03:00
agra
b163c4a3fc refactor(B6.2): move Obj-C defined-class emission to lower/objc_class.zig
Verbatim relocation of the 23-method defined-class cluster (IMP/property
emission: class/alloc/static/dealloc IMPs, property getters/setters +
ARC runtime decls, defined-state field access, property/method chain
lookup, string-constant globals) plus the single-home
ObjcDefinedStateField type into src/ir/lower/objc_class.zig. 23 aliases
on Lowering keep all call sites (incl. expr_typer.zig facade and
lower/stmt.zig) unchanged. Zero pub-flips — all callees were already
public from earlier steps.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; all 37
.ir snapshots byte-identical, zero expected/ churn.
2026-06-10 14:14:49 +03:00
agra
638a048cc5 refactor(B6.1): move Obj-C/JNI call lowering to lower/ffi.zig
Verbatim relocation of the 24-method FFI cluster (selector/class-object
interning, FFI intrinsic + JNI calls, foreign instance/static method
lowering, super calls, foreign-class registration, Obj-C defined-class
method registration, JNI env TLS fids, JNI main-stub synthesis) plus
the file-scope jniMapParamType (no alias needed — all callers moved)
into src/ir/lower/ffi.zig. 24 aliases on Lowering keep all call sites
unchanged.

Method pub-flips: emitObjcDefinedAllocAndInit, findForeignMethodInChain.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; all 37
.ir snapshots byte-identical, zero expected/ churn.
2026-06-10 14:03:21 +03:00
agra
1af80a4c38 Merge branch 'arch-b' (phase B5: lower/protocol.zig + lower/coerce.zig) 2026-06-10 14:00:01 +03:00
agra
e884e87f80 refactor(B5.2): move coercions to lower/coerce.zig
Verbatim relocation of the 19-method coercion cluster (lowerXX, user
conversions, protocol erasure, default-value construction, zero values,
coerceToType implicit/explicit ladder, C-variadic promotion, call-arg
coercion) plus the nested single-home CoerceMode enum into
src/ir/lower/coerce.zig. 19 aliases on Lowering keep all call sites
unchanged.

Method pub-flip: prependCtxIfNeeded. ParamImplEntry stays a Lowering
nested type (field type of param_impl_map) and is reached via an alias
const.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:58:09 +03:00
agra
8990bd4978 refactor(B5.1): move protocol emission to lower/protocol.zig
Verbatim relocation of the 13-method protocol cluster (protocol decl
registration, param-protocol instantiation, thunk creation, vtable
globals, protocol-value construction, dispatch emission, impl lookup)
into src/ir/lower/protocol.zig. 13 fn aliases on Lowering keep all call
sites unchanged.

Two pub nested types travelled with the run (ProjectionPosition,
PackProjection) and are re-exposed via Lowering type aliases; they are
pack-domain types and may relocate to lower/pack.zig in B7.2.

Method pub-flips: allocViaContext, callForeign, genericInstanceMethod,
monomorphizeFunction.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:54:59 +03:00
agra
c10aaa1482 Merge branch 'arch-b' (phase B4: lower/decl.zig + lower/nominal.zig) 2026-06-10 13:52:11 +03:00
agra
5928d9f067 refactor(B4.2): move nominal-type registration to lower/nominal.zig
Verbatim relocation of the 23-method nominal-type cluster (struct/enum/
union/error-set registration, anon-type qualification, nominal-id
stamping, shadow-slot reservation, named-type interning, generic struct
templates + alias registration) plus the nested ShadowTypeDecl union
into src/ir/lower/nominal.zig. 23 aliases on Lowering keep all call
sites unchanged.

Method pub-flip: instantiateGenericStruct. nominal.zig reaches
VisibleStructAuthor and structDeclOfRaw (both relocated to decl.zig in
B4.1) via Lowering-namespace alias consts.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:51:15 +03:00
agra
13f5fc57c1 refactor(B4.1): move decl scan + fn-lowering entry to lower/decl.zig
Verbatim relocation of the 52-method declaration cluster (lowerRoot,
scanDecls, entry-point checks, const/global registration, forward-alias
resolution, declareFunction + qualified registration, visibility,
lazy/deferred function lowering, same-name author selection) plus four
nested types (BareCallee, SelectedFunc, TypeHeadResolution,
VisibleStructAuthor) and the file-scope isExportedEntryName (all callers
moved with it) into src/ir/lower/decl.zig. 52 fn aliases + 3 type
aliases on Lowering keep all call sites (incl. calls.zig facade)
unchanged.

Method pub-flips: assertInstanceMapsCoincide, emitDefaultContextGlobal,
headFnLeak, initializerDescription, instantiateTypeFunction,
lookupObjcDefinedClassForMethod, lowerObjcDefinedClassMethods,
registerForeignClassDecl, registerGenericStructAlias,
registerNamespacedForeignClasses, reserveShadowSlot,
resolveTypeCallWithBindings, selectGenericStructHead,
synthesizeJniMainStubs, typeResolver, hasComptimeParams, isPlainFreeFn,
topLevelTypeDecl, isPackFn. Nested-type exposure: FnBodyReentry (+
enter/restore), ShadowTypeDecl.key/name/isGeneric.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:47:41 +03:00
agra
1efafdbdf7 Merge branch 'arch-b' (phase B3: lower/stmt.zig + lower/control_flow.zig) 2026-06-10 13:43:19 +03:00
agra
8adfc1dd50 refactor(B3.2): move control flow to lower/control_flow.zig
Verbatim relocation of the 14-method control-flow cluster (if/while/
for/match lowering incl. comptime-inline variants, break/continue, block
plumbing: freshBlock, freshBlockWithParams, currentBlockHasTerminator,
ensureTerminator) into src/ir/lower/control_flow.zig. 14 aliases on
Lowering keep all call sites unchanged.

Method pub-flips: computeHasImpl, headTypeGate, inferMatchResultType,
resolveTypeCategoryTags, isTypeCategoryMatch. Unqualified references in
moved bodies (ComptimeValue nested type, isTypeCategoryMatch static)
resolved via file-scope alias consts — bodies stay verbatim.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:18:07 +03:00
agra
3dbd3ce072 refactor(B3.1): move statement lowering to lower/stmt.zig
Verbatim relocation of the 24-method statement cluster (block/stmt
dispatch, var/const/local-fn decls, return, assignment + compound ops,
multi-assign/destructure, push, defer/onfail/cleanup) plus the nested
single-home FieldLvalue type into src/ir/lower/stmt.zig. 24 aliases on
Lowering keep all call sites unchanged.

Method pub-flips: buildDefaultValue, buildProtocolErasure,
diagPackAsValue, diagnoseMissingContext, emitError, emitFieldError,
ensureTerminator, getExprAlloca, getJniEnvTlFids, isPackName,
lazyLowerFunction, lowerObjcDefinedStateForObj, lowerObjcPropertySetter,
recordLocalTypeName, registerEnumDecl, registerErrorSetDecl,
registerStructDecl, registerUnionDecl, zeroValue.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:14:51 +03:00
agra
de6e826d21 Merge branch 'arch-b' (phase B2: lower/comptime.zig) 2026-06-10 13:12:18 +03:00
agra
856299ce36 refactor(B2.1): move comptime hooks + const folding to lower/comptime.zig
Verbatim relocation of the 26-method comptime cluster (comptime eval
hooks, #insert, comptime calls/deps/substitution, source-const folding
and module-const selection) plus the three nested const-selection types
(SelectedConst, ConstAuthor, ConstSourcePin) into src/ir/lower/
comptime.zig. 26 fn aliases + SelectedConst type alias on Lowering keep
all call sites unchanged.

Shared file-scope helpers stay in lower.zig per the helpers-stay-home
rule, now pub: ConstFoldFrame, constFoldFrameContains, SourceConstCtx.
Method pub-flips: findVariantIndex, putGlobal, tryLowerAsExpr,
lowerVariadicArgs, resolver, setCurrentSourceFile, diagNonIntegralNarrow,
lowerStmt, stampCallerSource, resolveParamType, resolveReturnType.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 13:11:33 +03:00
agra
91d01d0048 Merge branch 'arch-b' (phase B1: lower/error.zig) 2026-06-10 13:05:43 +03:00
agra
b240810b6f refactor(B1.1): move error-channel lowering to lower/error.zig
Verbatim relocation of the 53-method error cluster (error typing,
raise/failable, try/catch/or, inferred-set convergence, trace runtime
hooks) out of the Lowering struct into src/ir/lower/error.zig as free
functions taking *Lowering. Each gets a pub-const alias on Lowering, so
every call site compiles unchanged (decl-alias method resolution).

Pub-flips (callees now referenced cross-file): lowerExpr, coerceToType,
freshBlock, freshBlockWithParams, emitErrorCleanup,
currentBlockHasTerminator, lowerBlock, lowerBlockValue.

Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero
expected/ snapshot churn.
2026-06-10 12:58:46 +03:00
agra
74607c4dbc Merge branch 'wt-stdlib-base'
Some checks failed
Build / build-linux (push) Has been cancelled
Build / build-windows (push) Has been cancelled
2026-06-09 23:07:21 +03:00
agra
2d68beb053 refactor(R3): migrate headTypeGate + bareVisibleStructDecl to single walk
headTypeGate and bareVisibleStructDecl were using the same
moduleTypeAuthor + flatTypeAuthorCount pattern that selectNominalLeaf
used before R2. Migrated both to a single collectVisibleAuthors call
with inline type-specific resolution, matching the R2 pattern.

Deleted now-unused helpers: moduleTypeAuthor, FlatTypeAuthor,
moduleTypeAuthorTid, FlatTypeAuthorCount, flatTypeAuthorCount.
Net: -76 lines.

541/541 regression tests pass. 426/426 unit tests pass.
2026-06-09 22:37:18 +03:00
agra
3b4df4ab8d refactor(R2): selectNominalLeaf uses single collectVisibleAuthors walk
Replaces the 3 separate author-collection calls inside selectNominalLeaf
(moduleTypeAuthor + ownConstDeclIsPendingAlias + flatTypeAuthorCount +
forwardAliasOrUndeclared) with a single collectVisibleAuthors call plus
inline type-specific resolution. The flat walk now handles:
- own named type: resolved or forward (slot not yet interned)
- own const_decl: resolved alias or pending (own wins over flat)
- flat named types: ambiguous / resolved / forward
- flat const_decl pending aliases: pending (for forward aliases in imports)

Deletes 3 now-unused helpers: forwardAliasOrUndeclared, constAuthor,
ownConstDeclIsPendingAlias. Net: -17 lines.

541/541 regression tests pass. Issue 0107 repro still outputs 300.
2026-06-09 22:31:12 +03:00
agra
010e644897 fix(R1): own const_decl pending alias wins over flat imports (issue 0107)
When a module declares `A :: B; B :: u64;` and both a flat import and a
namespaced import export `B :: u8`, the flat import's B was discovered by
flatTypeAuthorCount before the own B :: u64 was processed — binding A to
u8 and silently truncating values.

Fix: ownConstDeclIsPendingAlias guard added to selectNominalLeaf between
the own-alias check and the flat-import walk. If the querying module has
an own const_decl for the name that is not yet in type_aliases_by_source,
return .pending so the forward-alias fixpoint resolves it correctly.

Regression: examples/0830-modules-flat-ns-same-name-forward-alias.sx
(x : A = 300 prints 300, not 44). 541/541 tests pass.
2026-06-09 22:16:47 +03:00
agra
5ed54a08ee refactor(R0): delete ResolvedProgram pre-pass; add resolveBare/resolveQualified
Removes the S2.x pre-pass and its 10 NodeRefTable maps — 1934 net lines
deleted. The Resolver gains two lazy functions: resolveBare(name, from,
domain) and resolveQualified(target, name), each returning ResolvedAuthors
(verdict + author set). verdictOver and authoredAsDomainAnywhere move from
ResolvePass to Resolver as private methods. All domain-predicate helpers
(eligibleKind, structDeclOf, fnDeclOf, etc.) are promoted to pub.

Test file trimmed from 1352 to 396 lines; old pre-pass population tests
replaced by focused resolveBare / resolveQualified verdict tests.

540/540 regression tests pass. Zero behavior change.
2026-06-09 21:55:53 +03:00
agra
ccae961dc4 Merge branch 'flow/stdlib/S2.2' into wt-stdlib-base 2026-06-09 16:07:37 +03:00
agra
2ae0ab1cff additive: compute resolver type-demanded verdicts 2026-06-09 15:55:33 +03:00
agra
a8d57521ac feat(stdlib/S2.2): selection verdicts in the resolver [additive]
Move the author-SELECTION SEMANTICS (the verdicts) into the resolver. Every
`.authors` ResolvedRef now carries the verdict the resolver COMPUTES above the
collector — own-wins / single-flat-visible / ≥2-ambiguous / not-visible /
type-vs-value domain-filtered — evaluated over the DOMAIN-ELIGIBLE subset of the
collected author set (`eligibleKind`). This folds the per-kind selection the old
lower-side selectors carried (selectNominalLeaf / flatTypeAuthorCount /
selectModuleConst / selectPlainCallableAuthor / selectGenericStructHead /
headTypeGate / headFnLeak) into ONE uniform computation, closing the
protocol / error-set / foreign per-kind surfaces (E6c/d/e) as resolver behavior.

Template/pack grammar stays carried as `.template` / `.pack` refs — NO
`sig_registration_mode`.

ADDITIVE / PARALLEL / UNCONSUMED: lowering still reads the old selectors, so the
verdict changes no generated byte. No file outside resolver.zig reads
ResolvedRef, so byte-identity is structural. ResolvedRef.authors is wrapped into
{ set, verdict } (the RAW set is preserved; the verdict filters).

Resolver unit tests prove the verdicts on real Phase A facts: the five bare-type
outcomes incl. the type-vs-value filter, and the resolver-target classes the old
selectors get WRONG — 0811 error-set / 0821 protocol-head / 0829 generic-struct
all → ambiguous (two flat authors, none own) and → own-wins (own author present).
The resolver-target corpus stays xfail in run_examples (unconsumed until S3.9);
verdicts asserted via the harness, not by flipping goldens.

Gate: zig build && zig build test (430) && tests/run_examples.sh (540 byte-identical),
all exit 0; tests/resolver-target 18 xfail unchanged.
2026-06-09 15:29:17 +03:00
agra
42acf93401 Merge branch 'flow/stdlib/S2.1c' into wt-stdlib-base 2026-06-09 15:01:41 +03:00
agra
3b1415a287 fix(stdlib/S2.1c): preseed top-level UFCS aliases [additive] 2026-06-09 14:50:51 +03:00
agra
59681e0a09 feat(stdlib/S2.1c): foreign-class + struct-const + UFCS — final 3 domains on the owning pass [additive]
On the S2.1a owning traversal, populate the last three ResolvedProgram side
tables, closing planspec S2.1's full-population acceptance (all ten domains):

  - foreign_class_refs: a bare reference whose collected author is a
    foreign_class_decl is routed here (its own domain) instead of the bare
    type/value/callable table.
  - struct_const_refs: a Type.CONST field access whose base resolves to a
    struct author carrying that const member (mirrors lowering's struct_const_map).
  - ufcs_refs: a ufcs_alias decl (alias -> target author) plus its UFCS-rewrite
    call sites (alias(args), incl. pipe-desugared), via a global traversal-ordered
    alias map mirroring lowering's flat ufcs_alias_map.

Still PARALLEL / UNCONSUMED / RAW: lowering reads the OLD selectors, no consumer
cut over, ResolvedRef stays raw. Byte-identical vs baseline (540 examples).

Population proof extended: a new resolver.test fixture exercises all ten domains
at once and asserts each side table non-empty + node-keyed for the three new ones.
2026-06-09 14:32:27 +03:00
agra
2301b60bb4 Merge branch 'flow/stdlib/S2.1b' into wt-stdlib-base 2026-06-09 14:11:56 +03:00
agra
1f10036a1a fix(stdlib/S2.1b): namespace-qualified parameterized heads [additive] 2026-06-09 14:01:23 +03:00
agra
6b41d113f2 feat(stdlib/S2.1b): namespace-qualified + 3 head domains on the owning pass [additive]
On the S2.1a exhaustive traversal, populate four more ResolvedProgram side
tables, still RAW / PARALLEL / UNCONSUMED:

- namespace-qualified references: an `alias.member` field_access whose base
  alias is a NamespaceEdges[ambient_source] target resolves via
  collectNamespaceAuthors into namespace_refs, keyed by the access node.
- the three HEAD domains at parameterized_type_expr heads, binned by the
  resolved author's decl kind: a struct with type params -> generic_struct_heads,
  a fn/const-wrapped fn with type params -> type_fn_heads, a protocol ->
  protocol_heads. RAW: the whole author set is recorded with no winner picked;
  a name authored as >1 head kind lands a distinct entry in every matching table.

Lowering still reads the old selectors and resolved_program has no consumer, so
generated output is byte-identical. ResolvedRef stays RAW (selection is S2.2);
generics stay symbolic. S2.1c (foreign-class / struct-const / UFCS) owns the
remaining three tables.

Extends the population proof: a resolver unit test asserting all four tables are
non-empty + node-keyed with the expected RAW authors.

Gate (all exit 0): zig build; zig build test (All 427 mod + exe + LSP sweep 574);
tests/run_examples.sh (540 passed, byte-identical); tests/resolver-target
(18 xfail, 0 leaked); m3te ios-sim via the main sx binary.
2026-06-09 13:38:59 +03:00
agra
51ab730b74 Merge branch 'flow/stdlib/S2.1a' into wt-stdlib-base 2026-06-09 13:17:36 +03:00
agra
1fb65e9e75 additive: complete resolver traversal gaps 2026-06-09 13:05:23 +03:00
agra
b46ad8b7a7 feat(stdlib/S2.1a): resolver.zig owning pass + ResolvedProgram scaffold + 3 bare-name domains [additive]
Turn src/ir/resolver.zig from a raw author-collection facade into the OWNING
resolution pass: one exhaustive recursive AST walk (exhaustive switch over
ast.Node.Data with NO else arm, so a new node kind is a compile error here
rather than a silently unvisited subtree) populating a ResolvedProgram.

- ResolvedProgram: all 10 node-keyed side tables declared as
  AutoHashMap(*const ast.Node, ResolvedRef) + symbolic TemplateParamId/
  PackParamId registries. ResolvedRef is the S2.1 RAW form — collected author
  identity (AuthorSet, own ∪ flat), NO verdict (own-wins/ambiguity is S2.2).
- Populate the 3 bare-name domains (type / value-const / callable heads) via
  collectVisibleAuthors(.user_bare_flat); record $T / ..$Ts / $pack[i] as
  SYMBOLIC template/pack refs, never TypeIds. The 7 head/qualified/foreign
  domains stay declared-but-empty (S2.1b/c own them).
- Slot via Compilation.resolveProgram() after the program_index facts are
  wired and before lowerRoot; ResolvedProgram owned on Compilation, borrowed
  *ResolvedProgram lent to ProgramIndex (lowerToIR signature unchanged).
- Population proof unit test over real Phase A facts: the 3 tables are
  non-empty, keyed by node identity, and carry symbolic template/pack refs.

ADDITIVE / PARALLEL / UNCONSUMED: lowering still reads the OLD selectors, so
single-author output is byte-identical. Gate green: zig build; zig build test
(425/425, LSP smoke 574 files no crash); run_examples (540 passed, 0 failed,
byte-identical incl. FFI 12xx-14xx + 1615 ios-sim); resolver-target (18 xfail
unchanged).
2026-06-09 12:29:27 +03:00
agra
b29037b257 Merge branch 'flow/stdlib/S1' into wt-stdlib-base 2026-06-09 11:50:06 +03:00
agra
cd7510067f refactor(stdlib/S1.2): delete module_fns; dissolve legacy_direct_any [additive]
Delete module_fns as a separate function-author fact source. Its authors
already live in the module_decls raw facts, so lowerRetainedSameNameAuthors now
reads function authors straight out of module_decls (filtered to *FnDecl via
fnDeclOfRaw) — the same path → name → RawDeclRef store, fn-filtered. Remove
imports.ModuleFns / FnIndex / indexModuleFns / buildModuleFns / fnDeclOf, the
Compilation.module_fns field + its build + wiring, and ProgramIndex.module_fns.

Remove VisibilityMode.legacy_direct_any (the quarantined own-scope-plus-full-
import_graph mode): no production caller passed it, so the collectVisibleAuthors
and isVisible switch arms that handled it are dead and go too, collapsing
VisEdgeSet to the single flat-import walk. No semantic fallback is introduced;
import_graph stays the transitive-visibility source for findVisibleImpls.

Additive: the old maps stay active and lowering still consumes them — no
lowering consumer is cut over to the DeclTable (that is S3), and no resolution
behavior changes. Tests that drove the removed symbols are rerouted through
module_decls / the flat-edge walk.

Gate over the baseline-green corpus: zig build, zig build test (424/424),
bash tests/run_examples.sh (540 passed) — all exit 0; single-author output
byte-identical; multi-author 0722–0740 stdout/exit unchanged.
2026-06-09 11:36:04 +03:00
agra
8058be2538 feat(stdlib/S1.1): DeclId for every declaration — additive DeclTable [additive]
Build a DeclTable in parallel with the import facts: every RawDeclRef
(source / imported / namespaced / C-imported) gets a stable DeclId carrying
source path, display name, AST node identity, span, and DeclKind. Namespace
targets record their members' DeclIds (NamespaceTarget.member_ids). A generic
struct's template is keyed by DeclId in a parallel struct_template_by_decl
store, written alongside the live name-keyed struct_template_map.

A Debug-only round-trip cross-check (RawDeclRef -> DeclId -> AST node ptr)
asserts the table identifies the same node across the corpus, run from
buildDeclTable and pinned by a unit test.

Additive (S0.1 class: mirror): the old maps stay active and lowering still
consumes them; nothing reads the DeclTable / struct_template_by_decl for
selection yet (the S4 cutover does). Generated IR + output bytes are unchanged
by construction.

Gate over the baseline-green corpus: zig build, zig build test (424/424),
bash tests/run_examples.sh (540 passed) — all exit 0; single-author output
byte-identical (37 .ir snapshots unchanged).
2026-06-09 11:25:04 +03:00
agra
864a14e42b Merge branch 'flow/stdlib/S0' into wt-stdlib-base 2026-06-09 11:00:22 +03:00
agra
ca8bc85120 docs(fork-c/S0): correct two doc-accuracy lines (base-equivalence wording + grounded FFI count)
attempt-2 review fixes (docs-only; contract mechanics confirmed sound):
- README + S0.2 grep-clean: 'S0 HEAD == base' / 'S0 == base' were inaccurate
  (HEAD carries the docs/examples/tests diff). Reword to: production/compiler
  behavior is base-equivalent — zero src/ changes, single-author output
  byte-identical to base by construction — HEAD is a distinct commit, not base.
- S0.3 ledger: drop the stale '116-class corpus' FFI wording for the grounded
  live count (96 entry trees / 95 active markers), matching the S0.1 count note.

No partition / manifest / examples / harness change. Gate green:
zig build + zig build test (LSP sweep 574, no crash) + run_examples (540/0);
m3te ios-sim build via main binary exit 0.
2026-06-09 10:47:42 +03:00
agra
1ce3a4e9e0 docs(fork-c/S0): setup contract — byte-baseline + commit-discipline, E6b disposition + two-corpus partition, A–E6 reuse/delete ledger
S0 of the ratified Fork C plan (zero-legacy name-resolution redesign, S0→S6).
Pure setup/documentation: NO production code change, NO behavior change.
Single-author output byte-identical to wt-stdlib-base by construction.

Deliverables under docs/fork-c/ (docs/, not current/, because current/ is
gitignored and the contract must be committed):

S0.1 — byte-baseline + commit-discipline: the committed examples/expected/*
snapshots are the single-author byte-identity reference; the zero-diff repro is
`zig build && zig build test && bash tests/run_examples.sh`. Resolver-target set
explicitly excluded + listed. Commit-classification rule: mirror | consumer-cutover | deletion.

S0.2 — E6b disposition + two-corpus partition: transitional E6b src NOT merged
(grep-clean: no resolveRegistrationSigTypeInSource / sig_registration_mode /
e6br_gate.test.zig on baseline). Harvested 0811–0829 trees + goldens (never the
src), empirically partitioned by running each through the base compiler vs the
E6b target:
  - baseline-green (mirror-equivalence): 0795–0798 (merged) + 0823, 0828 — given
    examples/expected/ markers, locked into the S0 baseline.
  - resolver-target (known-wrong old behavior): 0811–0822, 0824–0827, 0829 + the
    re-filed E6BR-5 nested-pattern regression — a listed xfail harness under
    tests/resolver-target/ (manifest + TARGET goldens, NO active marker), flips
    active+green at S3.9. 0811/0829 noted as old-selector-wrong on the E6b-unmerged
    base; E6BR-5 subsumed by the whole-AST resolver, NOT an E6b attempt-6.

S0.3 — A–E6 reuse/delete ledger: every load-bearing A–E6 artifact mapped REUSED
(Fork C home) or DELETED/TRANSITIONAL (S3/S6 phase); E6c/d/e dropped, F/H/I/K
absorbed/superseded.

Gate over the baseline-green corpus: zig build + zig build test (LSP corpus sweep
574 files, no crash) + bash tests/run_examples.sh (540 passed, 0 failed) all exit 0.
2026-06-09 10:29:23 +03:00
agra
1f755284d9 Merge branch 'flow/stdlib/E6a' into wt-stdlib-base 2026-06-09 00:05:21 +03:00
agra
b9a67d1042 fix(stdlib/E6a): adopt forward struct stub for recursive enum/union (E6A-1)
attempt-1's per-decl enum/union register path panicked on any valid
self- or mutually-referential top-level enum/union: a `*Name` field in
the body is resolved through the stateless `type_resolver.resolveNamed`,
which has no kind context and forward-stubs an as-yet-unregistered name
as a STRUCT. `internNamedTypeDecl` then `findByName`-adopted that struct
stub and called `updatePreservingKey`, whose kind-stability assert tripped
on struct -> enum/union (types.zig:446). The corpus had no recursive
enum/union, so the gate missed it.

Fix: when the slot `findByName` returns is a wrong-kind forward struct
placeholder (empty-fields struct) for an enum/union/tagged_union
registration, re-key it in place (`replaceKeyedInfo`) under the same
TypeId instead of `updatePreservingKey`. This mirrors how a self-ref
struct adopts its own (same-kind) forward stub; the new helper
`adoptsForwardStructStub` gates the re-key precisely to that case, so a
struct adopting a struct stub and every non-recursive enum/union stay on
the byte-identical `updatePreservingKey`/fresh-intern path.

Regression 0799 (single-author): self-ref union linked cells
(`next: *Node`), self-ref enum/tagged-union (`branch: *Tree`), and a
mutual-ref pair (A holds *B, B holds *A); builds and walks each recursive
link. Fail-before: panic at registerUnionDecl on eed2f99. Pass-after:
exit 0, "union=7 enum=42 mutual=99".

Gate: zig build && zig build test && run_examples.sh all exit 0
(538 passed, 0 failed; 0795-0798 + 0752-0794 + FFI byte-identical);
m3te ios-sim build via the main binary exit 0.
2026-06-08 23:55:46 +03:00
agra
eed2f99f76 feat(stdlib/E6a): per-decl nominal identity for enum + union decls
Give top-level ENUM and UNION decls per-decl nominal identity so two
same-name flat enums/unions intern DISTINCT nominal TypeIds instead of
collapsing to one global last-wins entry. Establishes the reusable
non-struct register path the later E6 kind-steps (E6b error-set, E6c
protocol, E6d foreign-class) extend.

Registration side (was: stateless `type_bridge.resolveInlineEnum/Union`
`findByName` last-wins short-circuit, no Lowering access):
- Split the type_bridge inline builders into a body-BUILDER
  (`buildEnumInfo` / `buildUnionInfo`) + the existing thin interner
  wrappers (field-type positions keep the legacy single-slot path).
- Add `Lowering.registerEnumDecl` / `registerUnionDecl` mirroring
  `registerStructDecl`: build the TypeInfo, intern via
  `internNamedTypeDecl(decl_key, name_id, info, nominal_id)` under the
  per-decl nominal identity (reserved slot id, else `shadowNominalId`).
- Reroute all six enum/union registration dispatch sites (scanDecls
  const-wrapped + top-level, lowerDecls/comptime, block-local, local
  const) to the new path.

Shared infra generalized ONCE:
- Pass-0b genuine-shadow pre-pass now reserves struct/enum/union shadow
  slots of the MATCHING kind, grouped by (kind, name), via a kind-generic
  `topLevelTypeDecl` / `reserveShadowSlot`. A forward/self/mutual ref to a
  shadow name binds to the reserved nominal TypeId.
- `namedRefTid` consults `type_decl_tids` for `.enum_decl`/`.union_decl`
  before the global `findByName`.

No new per-kind resolution path: selectNominalLeaf / headTypeGate /
flatTypeAuthorCount already gate every kind. Single-author /
phantom-double-spelling names keep nominal_id 0 (byte-identical corpus).

Regressions 0795-0798 (enum + union: ambiguity over every bare-type form,
and own-wins with distinct nominal TypeIds), fail-before/pass-after:
0795/0797 exit 0 -> exit 1 with the loud "type is ambiguous" diagnostic;
0796 silently printed `own=.east` -> correct `own=.north`; 0798 hard
`field 'm' not found` error -> correct `own=5 dep=9`.

Gate: zig build && zig build test (423/423) && run_examples.sh (537/537)
all exit 0; m3te ios-sim build via the main binary exit 0.
2026-06-08 23:18:29 +03:00
agra
e5db824477 Merge branch 'flow/stdlib/E5' into wt-stdlib-base 2026-06-08 22:19:36 +03:00
agra
919c7bd855 fix(stdlib/E5): source-aware value-const TYPE inference (F4)
Value-const SELECTION was source-aware for emission/folding (F2/R1/F1), but
expression TYPE inference still read the global last-wins `module_const_map`,
so an inferred return type / coercion on a same-name const borrowed another
module's const TYPE (mixed-type same-name consts were never exercised by the
attempt-1 same-typed goldens).

- expr_typer.zig: the `.identifier` const path now selects via the source-aware
  `selectModuleConst` (own-wins / one-flat-visible) instead of the global
  `module_const_map`. The global map still gates "is this a const name?"; an
  unpartitioned registration-only author emits its global type, and an ambiguous
  bare reference yields `.unresolved` (the emission path diagnoses loudly).
- lower.zig: expose `selectModuleConst` so the type-inference path shares the one
  author selector emission/folding already use.

Audited every `module_const_map` read: emission (4102) and global-init copy
(1447) were already source-aware (attempt-1); the binds-a-value predicate (6400)
is a boolean, not a type read; the in-`selectModuleConst` read (13842) is the
unwired fallback. No sibling inference site leaks.

examples: 0793 mixed-type own-wins inference (A's `K:s32` yields `1`, not the
global `f64`'s `1.000000`); 0794 mixed-type bare → loud ambiguous (exit 1), the
inference change does not mask the ambiguity. Prior E5 surfaces (0786-0792), the
0105 set (0752-0758), E1-E4 type surfaces (0763-0785) and FFI byte-identical;
533 markers green.
2026-06-08 22:07:12 +03:00
agra
189774712f test(stdlib/E5): pin 0786-0792 value-const same-name golden markers
Track the 21 examples/expected/078x golden markers (exit/stdout/stderr for
0786-0792) generated alongside 5df4ac6. The E5 source change and example
sources were committed there; these regression markers were generated on disk
(the example gate passes against them) but left untracked, leaving the tree
dirty and the new regressions unpinned in git. No source or golden content
changes — markers verified byte-for-byte against the current binary via
run_examples.sh (531 passed, 0 failed).

- 0786 own-wins (a=1 b=2)
- 0787 bare same-name two-flat-visible -> loud ambiguous (exit 1)
- 0788 expr-chain value+dimension coherent (a_len=2 a_val=2 b_len=11 b_val=11)
- 0789 imported expr-const nested leaves pinned to author source (val=2 len=2)
- 0790 cross-module same-name cycle-guard, no false cycle (m=3 len=3)
- 0791 multi-level cross-module chain (big=102 bk=11)
- 0792 struct-field registration-time dimension (a_sz=2 b_sz=7)
2026-06-08 21:40:33 +03:00
agra
5df4ac61a7 fix(stdlib/E5): source-aware same-name VALUE consts (own-wins / ambiguous / cross-module expr-chains)
Re-land the value-const analog of the E1-E4 type work, reconciled onto the
current source-keyed resolver and hardened. A same-name VALUE const declared in
multiple flat-imported modules is now resolved per declaring source, not the
global last-wins `module_const_map`.

- imports.zig: `isPerSourceDecl` retains every non-function `const_decl`
  per-source (value consts + type aliases), so each same-name author reaches
  registration as a distinct author of its own module. Functions and var_decls
  keep first-wins.
- lower.zig:
  * `selectModuleConst` over `module_consts_by_source` — own-wins; exactly one
    flat-visible resolves; >=2 flat-visible bare -> loud ambiguous (consistent
    with the 0755 type / 0724 fn / 0782 generic ambiguities). Rewires every
    consumer: `comptimeIntNamed`, the runtime-id read, the global-init read,
    and the float-name path (`lookupFloatName` / `nameIsFloatTyped`).
  * `SourceConstCtx` + `foldSourceConstInt`/`Float` + `sourceConstIsFloatTyped`
    fold a selected const's RHS with nested same-name leaves re-selected in
    their own author source, so VALUE and array-DIMENSION results are coherent.
  * `pinConstAuthorSource` pins each fold level to the SELECTED const's author
    (F1), including multi-level cross-module chains.
  * cycle guard keyed on (name, author-source), not name alone (F3), so
    same-name nested consts across modules do not trip a false cycle.
  * `emitModuleConst` takes the author source and pins while folding/lowering.
  Registration-time struct/inline-type field dimensions route through the now
  source-aware stateful reader; the type-alias dimension path resolves each
  alias against its own author's consts.
- program_index.zig: expose `isFloatConstType` / `isCountableConstType` for the
  source-aware folds.

examples: 0786 own-wins, 0787 ambiguous (exit 1), 0788 expr-chain value+dim
coherent, 0789 leaf-author-pin, 0790 cross-module cycle-guard (F3), 0791
multi-level cross-module chain, 0792 struct-field registration-time dim.
Single-author corpus byte-identical (524 prior markers green); 531 total.
2026-06-08 21:29:31 +03:00
agra
37c3b1e1f4 Merge branch 'flow/stdlib/E4' into wt-stdlib-base 2026-06-08 20:50:24 +03:00
agra
6406d0fb1f fix(stdlib/E4): collapse generic-struct author matrix into four choke-points
The generic-struct author-selection matrix {bare,qualified} × {site} × {layout,
body} drifted per-site across 12 attempts because method bodies were resolved by
bare template name in `fn_ast_map["Box.method"]`, independent of which author
produced the instance's layout. Collapse it into four choke-points so
layout-author ≡ body-author by construction:

  CP-1 `selectGenericStructHead` — the single layout-head selector every generic
       struct head site funnels through (alias-RHS .call/.parameterized, array-
       literal, static head, resolveTypeCall/ParameterizedWithBindings). Emits the
       visibility / missing-member diagnostics inline; returns a control-flow-only
       union. No head site reads `struct_template_map` for selection directly.
  CP-2 author stamp — non-optional `decl: *StructDecl` on `StructTemplate` (set at
       the sole producer `buildGenericStructTemplate`) + `struct_instance_author`
       written at `instantiateGenericStruct` from the SAME `tmpl` that builds the
       layout; re-stamped on the dedup fast-path so an instance is never returned
       without an author.
  CP-3 alias metadata copy — mirror template/bindings/author from the mangled
       instance onto the alias display name, so an `ABox`-typed receiver is a
       first-class dispatch instance (Counter-2).
  CP-4 `genericInstanceMethod` / `ensureGenericInstanceMethodLowered` — the single
       body reader: inline methods select via the stamped author (`structMethodFn`,
       source-pin follows for free); impl-block methods fall back to the template-
       keyed `fn_ast_map` entry. Routes the four bespoke body sites (static head,
       instance dispatch, param typing, protocol thunk) + the new qualified static
       head (`a.Box(s64).make(7)`, finding #2).

A debug assert locks `struct_instance_author` / `struct_instance_template` keyset
coincidence so a future third writer that forgets the author trips a test.

Goldens 0777/0778/0780 (bare instance method — ptr/by-value/param-typed, finding
#1), 0779/0785 (qualified static head + missing member, finding #2), 0783 (alias
instance dispatch, Counter-2), 0782 (ambiguity containment). 0414/0415/0543 and
the FFI suites stay green.
2026-06-08 20:34:53 +03:00
agra
7ba64d5756 fix(stdlib/E4): bare generic static-method head selects the visible author (type + method)
The static-method-call head `Box(s64).make(7)` was the last uncovered bare-
generic-head instantiation site: it gated visibility with `headTypeLeak` but
then instantiated the global last-wins `struct_template_map` entry and ran the
name-keyed `Box.make` from `fn_ast_map`, so a NON-visible 2-flat-hop same-name
template (and its method) won. `size_of(Box(s64))` picked the visible `b.Box`
(8) while `Box(s64).make(7)` returned a `c.Box`-shaped (16) value.

Route the static-method head through the single bare-VISIBLE author for BOTH
the instantiated type layout AND the method body: split the existing visible-
author selection into `bareVisibleStructDecl` (returns the StructDecl + source;
single selection point, `bareVisibleStructTemplate` now delegates to it — no
drift) and source-pin the method body via the author's own `sd.methods`
(`structMethodFn`) instead of the last-wins `fn_ast_map`. Ambiguity (>1 visible
author) is already diagnosed by the pre-existing `headTypeLeak` gate.

Exhaustive bare-head instantiation-site audit (all callers reaching
`instantiateGenericStruct` / `struct_template_map` for a bare head): .call alias,
.parameterized_type_expr alias, resolveType .call, resolveTypeCallWithBindings,
resolveParameterizedWithBindings — all already route through the visible-author
selection; the static-method head was the only remaining one and is now covered.

Regression 0776: bare generic static-method head with a 2-hop same-name template
asserts the visible author's layout (xtype=8, x reachable); fail-before xtype=16.
2026-06-08 19:06:13 +03:00
agra
246883073c fix(stdlib/E4): bare generic head selects visible author; qualified missing-member diagnoses
E4 non-transitive type rule had two generic-head author-selection holes:

#1 A BARE generic struct head / alias with a single bare-VISIBLE author still
   instantiated a NON-visible 2-flat-hop same-name template, because the
   `.unregistered` gate arm fell through to the global last-wins
   `struct_template_map` winner. Add `bareVisibleStructTemplate`: after the
   visibility gate passes, select the source-keyed template authored by the
   single bare-visible author (own-wins, else the one 1-hop flat author) and
   instantiate THAT instead of the global map's last-wins entry. Null (→ the
   global map, byte-identical) when the visible author IS the canonical one
   (the common single-author case) or the picture isn't a clean single author.
   Applied at every bare generic-struct head/alias site (annotation `.call` /
   `.parameterized_type_expr`, alias-registration `.call` /
   `.parameterized_type_expr`, array-literal head).

#2 A QUALIFIED head `a.Box(..)` whose namespace `a` authors no member `Box`
   silently fell back to the bare global template, instantiating an unrelated
   module's `Box`. Add `qualifiedMemberMissing`: a qualified head whose known
   namespace lacks the member now emits "namespace 'a' has no member 'Box'" and
   poisons with `.unresolved`; a qualified head NEVER reaches the bare global map.

Regressions: 0774 (bare head + bare alias, 2-hop same-name → size=8 alias=8,
fail-before 16 16); 0775 (qualified missing member → diagnostic + exit 1,
fail-before size=16 exit 0).
2026-06-08 18:39:53 +03:00
agra
8c59acbd25 fix(stdlib/E4): qualified generic alias head a.Box(..) selects the namespace author
The const-decl alias-registration path treated a qualified generic head
(`ABox :: a.Box(s64)`) only as a gate exemption, then read the bare last-wins
`struct_template_map` — so `ABox` and `BBox` both instantiated whichever
same-name template won globally (both size 16). attempt-9 routed the annotation
head sites through `qualifiedStructTemplate`; this applies the same selection to
the two alias-registration branches (.call and .parameterized_type_expr) before
the bare fallback, and extracts the shared instantiate-and-register logic into
`registerGenericStructAlias`.

ABox :: a.Box(s64) now resolves to a's template (size 8); BBox :: b.Box(s64) to
b's (size 16). Regression 0773 pins it (fail-before alias a=16 b=16, after a=8
b=16).
2026-06-08 17:56:29 +03:00
agra
eb7636d0f3 fix(stdlib/E4): qualified generic head ns.Box(..) selects the namespace author
A qualified generic type head `ns.Box(args)` was stripped to its bare name and
read from the last-wins `struct_template_map`, so the namespace qualifier never
selected the template author: `a.Box(s64)` and `b.Box(s64)` (two namespaces each
authoring a same-name `Box($T)` with different layouts) both instantiated the
global same-name template. The documented ambiguity escape hatch ("qualify it as
ns.Box") silently produced the wrong layout.

Select the template via the namespace edge (importer -> alias -> NamespaceTarget)
instead of the bare map, at both the .call and parameterized-type-expr head
sites. Two same-name templates instantiated with the same args would also collide
on the mangled name `Box__s64`, so tag the non-canonical author's mangled name
with its source (the canonical bare-map author keeps the untagged name -> no
churn for single-author generics).

Extract `buildGenericStructTemplate` so the bare registration and the new
namespace-qualified selection share one template builder.

Regression: examples/0772 — two namespaces each authoring Box($T) with different
layouts; ns_a.Box(s64) and ns_b.Box(s64) resolve to their own module's template
(sizes 8 and 16). Fail-before on 566de96 (a=16 b=16), pass-after (a=8 b=16).
2026-06-08 17:19:41 +03:00
agra
566de96821 fix(stdlib/E4): type-fn head gate selects the TYPE-FUNCTION author (ordinary fn must not vouch)
attempt-7 made the type-fn head gate kind-aware (a non-function no longer
vouches), but it still accepted ANY function author: a directly-visible
ORDINARY function (`Make :: () -> s32`, zero `$`-params) authorized a hidden
2-flat-hop type-function head (`Make :: ($T) -> Type`), so `size_of(Make(s64))`
silently instantiated the 2-hop type-fn and printed `size=8` at exit 0.

Narrow the author view from "any fn_decl" to "a TYPE-FUNCTION" via a new
`typeFnAuthor` predicate (`fnDeclOfRaw` + `type_params.len > 0`), the same
discriminator every instantiation site uses to recognize a type-fn head. Both
`flatFnAuthorVisible` and `flatFnAuthorAmbiguous` now count only type-fn
authors, so a same-name ordinary function — which cannot be the type head being
instantiated — does not vouch for a 2-hop type-fn head.

Regression 0771: main -> b (`Make :: () -> s32` ordinary fn + flat-imports c)
-> c (`Make :: ($T) -> Type`); `size_of(Make(s64))` -> "type 'Make' is not
visible", exit 1 (fail-before on 94c3cd7: size=8 exit 0). 0770 (non-fn vouch),
0769 (type-fn ambiguity), 0768/0767/0766-0763, 0208/0210 (valid type-fn heads),
0544/0706/0105 and FFI all green & byte-identical.
2026-06-08 16:43:01 +03:00
agra
94c3cd7507 fix(stdlib/E4): kind-aware type-fn head gate (non-fn must not vouch)
The type-fn head visibility check (`headFnLeak`) used the module-scope
NAME predicate `isNameVisible`, so a same-name 1-hop NON-function (a value
const `Make :: 123`) reported the name "visible" and let the global
`fn_ast_map` type-fn — whose real author is 2 flat hops away — silently
instantiate. `size_of(Make(s64))` printed 8 at exit 0 instead of a
visibility diagnostic.

Decide visibility from the ELIGIBLE FUNCTION authors directly reachable
from the use site (`flatFnAuthorVisible`, mirroring `flatFnAuthorAmbiguous`'s
fn-only author view): visible iff the own author or a 1-hop flat-import
author is a `fn_decl`. A non-function does not vouch. Guarded to fall open
when the import facts aren't wired (comptime / directory imports), mirroring
`headTypeGate`. Own / scope-local / 1-hop / directly-imported type-fn heads
still resolve; 0769 ambiguity unchanged.

Regression: examples/0770-modules-type-fn-head-non-transitive (main → b
[`Make :: 123` + flat-imports c] → c [`Make :: ($T) -> Type`]); the bare
`Make(s64)` head emits "type 'Make' is not visible", exit 1.
2026-06-08 16:03:23 +03:00
agra
cb9ef381b5 fix(stdlib/E4): own-wins at non-leaf bare-type sites + type-fn head ambiguity
attempt-6: address Adi's two in-scope findings (#3 deferred to E6).

#1 E4-own-author-type-arg (silent-wrong): the bare-TYPE gate returned
`.proceed` for the querying source's OWN author, so the non-leaf sites
(reflection / type-arg / array-literal / type-value / match arm) dropped it
and re-resolved a same-name flat import via global `findByName`. headTypeGate
now resolves the own author to ITS per-source TypeId (mirroring
selectNominalLeaf's own-wins, 0754); the type-as-value and type-match sites,
which only consumed the poison bit and re-resolved globally, now route through
the gate and use the `.resolved` author. size_of(Widget) with an own + imported
Widget now yields main's own size, not the import's.

#2 E4-type-fn-head-ambiguity (silent-wrong): headFnLeak only checked
isNameVisible, so two flat same-name type-returning functions both reported
"visible" and one was silently instantiated. It now diagnoses >=2 distinct
direct flat type-fn authors (no own author) as ambiguous before the
isNameVisible short-circuit, consistent with the parameterized struct /
protocol heads and the leaf (0755/0767). Own / single / diamond-collapse
type-fn heads still resolve.

Regressions: 0768 (own-wins at every non-leaf bare-type site, fail-before
reflection=16 -> pass-after 8) and 0769 (two flat Make type-fns -> ambiguity
diagnostic exit 1). README: own-wins + type-fn-head ambiguity at every bare-type
site.
2026-06-08 15:22:10 +03:00
agra
382f78f49b fix(stdlib/E4): carry full author outcome through the bare-TYPE gate (ambiguity at every site)
attempt-4 gated every bare-type-reference site for VISIBILITY via a boolean
leak-check that only caught not-visible and DROPPED the ambiguous outcome, so two
DIRECT flat same-name type authors (the 0755/0105 ambiguity case) fell through to
a global findByName / struct_template_map pick at the non-leaf sites.

Unified author-outcome fix (one path, every site consumes it):

- flatTypeAuthorCount: ≥2 distinct flat authors that do NOT all collapse onto one
  shared TypeId are now `.ambiguous` even when none carries a concrete TypeId yet —
  two same-name GENERIC TEMPLATES (template name registered in no findByName slot)
  are a genuine collision, exactly like two registered structs. Identical-target
  authors (diamond import / two aliases onto the same target) still collapse to
  `.one`, so all valid cases stay byte-identical.

- headTypeGate: the complete source-aware author outcome (.proceed / .resolved /
  .ambiguous / .not_visible) for an unqualified bare TYPE head, emitting the loud
  ambiguity diagnostic (consistent with the leaf / 0755) or the not-visible
  diagnostic. headTypeLeak is now its poison-vs-proceed projection, so every head /
  instantiation / alias-decl / match site poisons on ambiguity with the right
  message. Reflection / type-arg and array/vector-literal identifier heads consume
  `.resolved` to use the source-keyed TypeId, never a global findByName pick.

Regression examples/0767: size_of(Thing) / Nums.[1,2] / Box(s64) / t:Type=Thing /
case Thing: with two direct flat same-name authors each emit the ambiguity
diagnostic, exit 1 (fail-before on bb8f7dc: exit 0 / cascade). 0763/0764/0765/0766
/0755/0706/0544/0105 + FFI byte-identical. README: bare-type ambiguity is enforced
at every reference site.
2026-06-08 14:15:34 +03:00
agra
bb8f7dc5ec fix(stdlib/E4): route reflection/literal/value/match bare-type sites through the non-transitive gate
attempt-3 closed the leaf + parameterized-head leaks but several more
sites still resolved an UNQUALIFIED type name via the global
type_alias_map / findByName / type_bridge.resolveAstType without the
single-hop visibility gate, so a 2-flat-hop bare type leaked through:

  - resolveTypeArg (reflection / size_of / align_of / type_name / type_eq):
    identifier + type_expr leaves now gate via headTypeLeak; the wrapped /
    structural forms (*T, [N]T, []T, ?T, fn-ptr, tuple) route through the
    already-gated resolveTypeWithBindings so each inner leaf recurses the
    source-aware resolveNominalLeaf.
  - resolveTupleLiteralTypeArg: each element leaf is resolved through the
    source-aware resolver before the delegated build, so (COnly, s64) is
    gated.
  - resolveArrayLiteralType (T.[...] typed array/vector-literal head):
    identifier + type_expr leaves gate via headTypeLeak.
  - type-as-value lowerExpr identifier (x: Type = COnly, x == COnly).
  - type-category match arm (case COnly:).

Qualified ns.X / 1-hop / source-pinned library-internal references stay
exempt (the gate falls through for reachable / unauthored names, and
returns the existing "unresolved type" diagnostic for genuinely-undeclared
names). README notes the type gate holds wherever a bare type name is
named. New regressions 0765 (2-hop reject) / 0766 (1-hop pass).
2026-06-08 13:18:51 +03:00
agra
4f99fb0d85 fix(stdlib/E4): gate unqualified parameterized type heads non-transitively
attempt-3: extend the E4 single-hop bare-TYPE gate to parameterized type
HEADS (the constructor-head analog of the bare-leaf gate). Before this, the
head lookup hit the global struct_template_map / protocol_ast_map /
fn_ast_map *before* any source-aware visibility check, so a 2-flat-hop
imported generic struct/protocol/type-fn remained bare-visible (e.g.
`Box(s64)` when main imports only b.sx and b.sx imports c.sx).

- headTypeLeak: generic-struct / parameterized-protocol heads use the same
  type-author single-hop model as the bare-leaf gate (moduleTypeAuthor +
  flatTypeAuthorCount + localTypeInSource + nameAuthoredAsTypeAnywhere).
- headFnLeak: type-returning-function heads use single-hop function
  visibility (isNameVisible), exempting scope-local mangled type-fns.
- Gated at every unqualified head site: resolveParameterizedWithBindings,
  resolveTypeCallWithBindings, the scanDecls alias-decl dispatch (poisoning
  the alias with .unresolved on leak), resolveArrayLiteralType, and the
  generic-static-method call path. Namespaced (`ns.Box(..)`) heads are an
  explicit qualified reach and stay exempt. Source-pinned instantiation
  (E3/E4) is preserved, so library-internal heads still resolve where they
  are visible.

Regression: examples/0764-modules-import-generic-head-non-transitive
(2-hop `Box(s64)` -> "type 'Box' is not visible", exit 1; direct #import
resolves). Fails-before on a250964 (printed 3), passes-after.

README: note the non-transitive rule covers parameterized type heads.

Gate: zig build 0, zig build test 0 (LSP 522, 423/423), run_examples
505/0, FFI 12xx/13xx/14xx green, 0706/0763/0544/0105 green & byte-identical,
m3te ios-sim build+launch exit 0.
2026-06-08 12:37:00 +03:00
agra
a250964ced fix(stdlib/E4): source-pin pack-fn fixed-prefix param types to the defining module
E4's pack-fn source-pin was incomplete: an imported pack function's
fixed-prefix (non-pack) parameter types were resolved in the CALLER's
module, so a param whose type is bare-visible only in the pack fn's own
module was wrongly rejected with "type 'X' is not visible" — even though
the equivalent plain fn (typed via the source-pinned call-arg path) ran
fine.

Two sites in the pack-mono path re-resolved the fixed-prefix param type
in the caller's context:
  - lowerPackFnCall: the call-site arg-typing pass (to contextually type
    the arg from its param) — fires first.
  - monomorphizePackFn: the body parameter binding, after the caller
    source was restored from the signature build.

Both now resolve via resolveParamTypeInSource(fd.body.source_file, &p),
pinning to the pack fn's defining module — matching the already-pinned
signature build, the body lowering, and the cross-module call-arg typing
sites. The call-site arg itself is still lowered AFTER, in the caller's
context (issue 0106).

Regression: examples/0544-packs-imported-pack-fn-fixed-param-source-pin
(main -> lib -> dep; `Needs` two flat hops away, never named in main).
Fails pre-fix with "type 'Needs' is not visible"; passes after. A control
plain fn in the same lib already ran, isolating the pack-mono path.
2026-06-08 11:52:23 +03:00
agra
9d5143aee6 fix(stdlib/E4): source-pin sx-defined objc-class IMP trampolines + finish non-transitive bare-TYPE gate
Final E4 piece: the IMP trampolines emitted for an sx-defined #objc_class
resolved their method-signature types (e.g. -> BOOL) at whatever lowering
site triggered emission, not the class's defining module — so under the
single-hop bare-TYPE gate a 2-flat-hop objc type (BOOL via uikit->objc)
leaked as 'not visible' when m3te's main triggered emission.

- ast.ForeignClassDecl gains source_file (stamped by resolveImports, like
  ProtocolDecl/StructTemplate); stampFnBodySource stamps the decl + each
  bodied method body.
- emitObjcDefinedClassImps pins current_source_file to fcd.source_file for
  the whole per-class emission (alloc/dealloc/method/property IMPs).
- Removes the BOOLLEAF debug probe.

Completes E4: bare-TYPE visibility is single-hop non-transitive across all
member kinds; every instantiation kind (generic struct/fn, pack fn, param
protocol, type fn, objc-block, objc-class IMP) is source-pinned to its
defining module. Full gate green; m3te ios-sim builds + launches (exit 0).
2026-06-08 11:22:05 +03:00
agra
33a6f5c650 wip(E4): partial source-pin + non-transitive flip [stdlib E4 attempt-1 WIP checkpoint]
Incomplete WIP from a worker killed at the 55-min wall (large blast radius:
core source-pin + ~8 example migrations + ~10 library module migrations).
Committed so the resumed session continues on a clean tree. May not build.
2026-06-08 11:12:08 +03:00
agra
4d539eeacd Merge branch 'flow/stdlib/E3' into wt-stdlib-base 2026-06-08 10:14:27 +03:00
agra
3816bfff47 fix(diag): source-key local_type_names so a caller block-local can't leak into an imported template field [stdlib E3 attempt-5]
A block-local type is visible only within the source that declares it. The
global `local_type_names` set was source-insensitive, so an imported generic
template's field (resolved in the template's source context, attempt-4) could
bind a type the CALLER declared block-local — silently compiling an undeclared
imported field instead of diagnosing it.

Key `local_type_names` by declaring source. The bare-TYPE gate now resolves a
local only when the query originates in the local's own source (R2 preserved);
a same-name block-local of a DIFFERENT source routes to the undeclared path so
the leak surfaces (`unknown type '...'`, exit 1) instead of escaping via the
`registered` catch-all that would otherwise resolve the globally-registered
cross-source local.

Regression: examples/0762 — imported `Bad :: struct($T) { x: T; y: LocalOnly; }`
with `LocalOnly` declared only in the caller `main` now errors in lib.sx
(fail-before on 8162170 printed `1 9` exit 0).
2026-06-08 10:02:33 +03:00
agra
81621703ca fix(diag): imported generic struct field with bad type → diagnostic, not .unresolved/silent stub [stdlib E3 attempt-4]
attempt-3 closed the MAIN-file value-param-as-type quadrant (0172) in the
UnknownTypeChecker, but the checker only walks main-file decls — an IMPORTED
generic struct's field with a bad type name was never checked. Worse, the
generic-struct INSTANTIATION resolved its field type nodes in the (possibly
cross-module) instantiation site's source context, not the template's module.
So for `Bad :: struct($N: u32) { x: N; }` declared in an imported module and
used as `Bad(3)` from main, the field `x: N` resolved against the main file:
the value-param-as-type leaf poisoned it with `.unresolved` and PANICKED at
LLVM emission, and the genuinely-undeclared sibling (`y: Missing` in a generic
import, distinct from the non-generic 0759 case) silently fabricated a 0-field
stub.

Root cause + uniform fix: capture the declaring module on each StructTemplate
and resolve its field type nodes in THAT source context during
instantiateGenericStruct. The source-aware nominal leaf then classifies main vs
imported by the TEMPLATE's file, so both failure modes are diagnosed at the
right authority for every quadrant — main + imported, undeclared name + value
param used as a type:
- imported `.undeclared` field → the existing leaf emits "unknown type 'X'"
  (now reached because `from` is the template's module, not main).
- imported value-param-as-type → the `is_generic` leaf, when the name is bound
  as a comptime VALUE (`comptime_value_bindings`), emits the tailored
  "'N' is a value parameter, not a type" hint (gated to non-main; the
  UnknownTypeChecker owns the main-file case). Caught in every type position
  (`x: N`, `*N`, `[3]N`, `?N`). A genuinely-unbound type param (`$R`) stays a
  silent `.unresolved`.

No `.unresolved` reaches LLVM for these cases (hasErrors halts after lowering);
the emit_llvm `.unresolved` @panic tripwire stays as the last-resort sentinel.
Valid value-param VALUE positions (`[N]u8` dim, `Vector(N,T)` lane) and
`$T:Type`/`$T:Protocol` type-param fields still resolve.

Regressions:
- 0760-modules-imported-generic-value-param-as-field-type (panic-before / clean
  diagnostic-after).
- 0761-modules-imported-generic-undeclared-field (silent-compile-before / clean
  diagnostic-after).
0171/0172/0759 stay green; main-file quadrants emit exactly one error.

Gate: zig build; zig build test (423/423 + LSP corpus sweep); run_examples 501
passed / 0 failed (prior 499 byte-identical); m3te ios-sim build exit 0.
2026-06-08 09:37:52 +03:00
agra
a0390a63ab fix(diag): generic VALUE param ($N: u32) used as a field/annotation type → diagnostic (no .unresolved LLVM panic) [stdlib E3 attempt-3]
The generic-struct field checker (attempt-2) accepted ALL struct type
params as valid type-name leaves, including VALUE params. The parser
marks any reference to a struct's own param `is_generic` (so `x: T`
resolves without `$`), and it marks a value param `$N: u32` the same
way — so `Bad :: struct($N: u32) { x: N; }` instantiated `Bad(3)` slipped
past the unknown-type walk, resolved the field's type leaf to the
`.unresolved` sentinel, and panicked at LLVM emission instead of
diagnosing.

Distinguish TYPE params (`$T: Type`, `$T: SomeProtocol`, the `..$Ts`
pack) from VALUE params (`$N: u32`) using the binder's own classification
rule (lower.zig). A value param named in a type position now gets the
tailored "'N' is a value parameter, not a type" hint, exit 1, before
codegen. Two dispatch paths covered: the `is_generic` struct-field path
(reportIfValueParamInTypePosition) and the non-generic annotation path
(reportIfUnknownType in-scope filter). A value param in a VALUE position
(array dim `[N]u8`, `Vector` lane) still resolves.

Regression: 0172-types-value-param-as-field-type (panic-before / clean
diagnostic-after). 0171 and 0759 stay green; 499 markers, prior
byte-identical.
2026-06-08 09:02:54 +03:00
agra
a4906975bd fix(diag): undeclared type in a main-file generic struct field → diagnostic (no silent stub) [stdlib E3 attempt-2]
Closes the main-file carveout left by attempt-1 (4072689): a genuinely-
undeclared type used as a field type inside a MAIN-file GENERIC struct still
fell through the type leaf's empty-struct stub and silently compiled —
`Box :: struct($T: Type) { good: T; bad: MissingType; }` with `b : Box(s64)`
exited 0 and printed a value instead of reporting `unknown type 'MissingType'`.

Root cause: `UnknownTypeChecker` is the main-file diagnostic authority (the
type leaf defers to it for `.undeclared` names there), but
`checkStructFieldTypes` SKIPPED every generic struct outright ("its fields
reference `$T`, resolved at instantiation"), so the undeclared name was never
examined. The sibling `walkBodyTypes` `.struct_decl` arm skipped body-local
generic structs the same way.

Fix (semantic_diagnostics.zig, checker only — no leaf change):
- `checkStructFieldTypes`: stop skipping generic structs; walk the field
  types with the struct's OWN type params (`$T`, `$N`, `..$Ts`) passed as the
  in-scope set. A param name resolves; any OTHER bare name that is neither
  declared nor a generic param is reported. Value-param positions (a `Vector`
  lane count, a `$N: u32` arg) are still skipped inside
  `checkTypeNodeForUnknown` / `isValueParamPosition`.
- `walkBodyTypes` `.struct_decl`: same close for body-local structs — the
  local struct's own type params join the enclosing scope's in-scope params
  (so it can name both the outer fn's `$T` and its own), any other bare field
  type is still flagged.

The `..$Ts` pack field `(..$Ts)` parses to a `spread_expr` inside the tuple,
which hits `checkTypeNodeForUnknown`'s `else` arm — never walked — so the pack
examples (0538-0543, 0414) stay green. The checker walks only MAIN-file decls,
so library generic structs (List, Map) are untouched.

Regression: examples/0171-types-undeclared-type-in-generic-struct-field — the
reviewer's exact shape; `unknown type 'MissingType'` at the field, exit 1.
Fail-before on 4072689 (prints 7, exit 0), pass-after.

Gate: zig build; zig build test (423/423 + LSP corpus sweep 514); run_examples
498 passed / 0 failed (prior 497 byte-identical); m3te ios-sim build exit 0.
2026-06-08 08:36:45 +03:00
agra
4072689afe fix(lower): genuinely-undeclared type → diagnostic + .unresolved (no silent stub) [stdlib E3]
Phase E3: remove the silent empty-struct fall-throughs in type resolution for
genuinely-undeclared names, replacing them with a real "unknown type" diagnostic
+ the dedicated `.unresolved` sentinel (already present, with the sizeOf @panic
tripwire) — the REJECTED-PATTERN this project bans.

Split `TypeHeadResolution.undeclared` into `.forward` (a real author not interned
yet — self/forward/mutual/foreign reference, adopted on registration via
internNamedTypeDecl) vs `.undeclared` (NO author anywhere). `resolveNominalLeaf`:
- `.pending` / `.forward` keep the empty-struct stub the type adopts on register.
- `.undeclared` in a NON-main (imported/library) module — which the
  UnknownTypeChecker trusts and never walks — emits "unknown type 'X'" + poisons
  with `.unresolved`. In the MAIN file the checker owns the diagnostic (and a
  valid unbound generic leaf legitimately lands here), so the leaf keeps the
  legacy stub and does not double-report.

Also convert the `parameterized_type_expr` constructor-head fallback
(resolveParameterizedWithBindings): an unresolvable base now emits + returns
`.unresolved` (mirroring the `.call`-node sibling) instead of a 0-field stub
that mis-sizes `b.field` / `b.len`. Threads the reference span through both
callers.

Triage of the other empty-struct sites (all load-bearing on the green suite or
unable to distinguish forward from undeclared — KEPT): resolveNamed's legacy
namer (forward/generic/Self/foreign-opaque: R/Self/Object/Array), the
foreign-class struct + JNI Self placeholders, the shadow-slot reservation, the
type_bridge stateless pack/generic namer, and the struct-literal inference
fallback (front-run by the leaf; 0 suite hits).

Regression: examples/0759-modules-undeclared-type-in-import — an undeclared type
in an imported module now errors (exit 1) instead of silently compiling (the
pre-fix code printed `thing.x = 42`, exit 0).

Gate: zig build; zig build test (423/423 + LSP corpus sweep); run_examples 497
passed / 0 failed (prior 496 byte-identical); m3te ios-sim build exit 0.
2026-06-08 08:10:42 +03:00
agra
27acfb8229 Merge branch 'flow/stdlib/E2' into wt-stdlib-base 2026-06-08 07:38:40 +03:00
agra
f8efa25416 revert(stdlib): narrow E2 to the 0105 type/alias close; defer value consts to E5 [stdlib E2 attempt-6]
Scope-narrowing revert of the value-const same-name sub-area (attempts 3-5),
per PO/Agra ruling. The 0105 type/alias close (per-source nominal struct
identity, source-keyed type aliases, F1 self/mutual refs, anon-struct
regression) is kept intact; cross-module same-name VALUE consts move to step E5.

- imports.zig: narrow `isPerSourceDecl` so a `const_decl` is retained
  per-source ONLY when its RHS introduces a TYPE (alias / inline type decl).
  VALUE consts (literal / value-expression RHS) and functions keep the pre-E2
  first-wins name-merge. Restores value-const reads to exactly the
  wt-stdlib-base (pre-E2) first-wins behavior.
- lower.zig / program_index.zig: restored to the pre-value-const state
  (66d10c0) — removes selectModuleConst / SourceConstCtx / pinConstAuthorSource
  / SelectedConst and the rewired comptimeIntNamed / float / runtime /
  global-init const reads; value-const reads return to the global path.
- examples: drop 0759-0762 (value-const own-wins / ambiguous / expr-chain-dim
  / leaf-author-pin) — they move to E5.

Kept green: 0752-0758 (same-name structs distinct + own-wins + ambiguous + self
/mutual ref), 0756 (alias per-source), 0170 (anon-struct field distinct).

Gate: zig build + zig build test (423/423, LSP sweep 513 no-crash) +
run_examples (496/0, prior markers byte-identical) + m3te ios-sim build exit 0.
2026-06-08 07:28:31 +03:00
agra
4666fb1941 fix(lower): pin nested-leaf source to the SELECTED const's author — close F1 R1 [stdlib E2 attempt-5]
A same-name expression const read from another module folded its nested
leaves (`M` inside `K :: M + 1`) from the CALLER's source, not the source
that authored the selected const. A unique imported `K` became ambiguous
when the reading module also flat-imported a different same-name `M`.

`selectModuleConst` now returns the author SOURCE alongside the const info
(`SelectedConst`), and the fold/lower of a selected const's RHS pins
`current_source_file` to that author for the duration (`pinConstAuthorSource`)
— so `K :: M + 1` defined in `a.sx` always folds `M` against `a.sx`,
coherently whether `K` is read as a runtime value or used as an array
dimension. Each recursion level pins to its own selected author's source.

Single-author programs pin to the source they were already in → byte-
identical (499 prior examples unchanged). Genuine ambiguity at the read
site (0760) is still caught before any pin.

Regression: examples/0762-modules-same-name-const-leaf-author-pin
(`a.sx M::1; K::M+1`, `b.sx M::10`, main flat-imports both, reads K as
value AND `[K]u8` dimension → val=2 len=2). Fail-before on 8518b66
(`'M' is ambiguous` / "array dimension must be a compile-time integer
constant"), pass-after.
2026-06-08 07:02:59 +03:00
agra
8518b66cec fix(lower): propagate source-aware const selection into expression-chain folds — close F2 R1 [stdlib E2 attempt-4]
attempt-3 made the value-const READ source-aware (own-wins / ambiguous) but
the dimension/count fold of a SELECTED const's RHS still recursed through the
global last-wins `module_const_map`, so a nested same-name leaf came from the
wrong module. Reviewer R1: a.sx `M::1; K::M+1`, b.sx `M::10; K::M+1`, with both
`[K]u8` (a_len) and `return K` (a_val) — pre-fix `a_len=11 a_val=2`, an
INCOHERENCE for the same const `K` (a_val read A's chain; a_len read B's `M`).

`comptimeIntNamed` delegated to `moduleConstIntWith(global_map, ...)`, whose
leaf ctx (`ModuleConstCtx`) resolved nested names through the global map. The
value path (`emitModuleConst` -> `foldCountI64(ci.value, self)`) folds through
`self`, so its leaves bounce back to the source-aware `comptimeIntNamed` — which
is why a_val was already correct.

- New `SourceConstCtx` (lower.zig): the leaf-resolution twin of `ModuleConstCtx`,
  but every nested const leaf re-selects its OWN source author via
  `selectModuleConst` (own-wins / ambiguous), never the global last-wins map.
  `ConstFoldFrame` cycle-guards a const whose RHS references another const.
- `comptimeIntNamed` / `lookupFloatName` / `nameIsFloatTyped` now fold the
  selected `ci`'s RHS through `SourceConstCtx` (via `foldSourceConstInt` /
  `foldSourceConstFloat` / `sourceConstIsFloatTyped`). This makes the dimension
  and value reads of a shadowed expression-chain const coherent.
- Drop the now-unused `moduleConst{Int,Float,IsFloatTyped}With` wrappers from
  program_index.zig; expose `isCountableConstType` / `isFloatConstType`.

Single-author -> byte-identical (the selected `ci` IS the global one and every
nested leaf has one author). The stateless `type_bridge` registration-time const
reader still folds leaves through the global map, but realistic dim sites (struct
fields, array aliases — probed) resolve via the stateful path and stay coherent
under import-order swaps; no reachable wrong-dimension found (tracked follow-up,
byte-identical single-author).

Regression: examples/0761-modules-same-name-const-expr-chain-dim — a_len=2
a_val=2, b_len=11 b_val=11. Fail-before on 72f06a1 (`a_len=11`), pass-after.

Gate: zig build + zig build test (423/423, LSP sweep 515 clean) + run_examples
(499/0, 498 prior byte-identical + 0761) + m3te ios-sim build exit 0.
2026-06-08 01:06:44 +03:00
agra
72f06a109b fix(lower): source-aware value-const resolution (own-wins / ambiguous) — close F2 [stdlib E2 attempt-3]
E2 retained per-source const declarations but left the const READ path on the
global last-wins `module_const_map`, so a module's OWN reference to a same-name
const bound the LAST global author (F2: a.sx `K::1`, b.sx `K::2`, main flat-imports
both → both read B's K). Complete the const analog of the type (`selectNominalLeaf`)
and callable (`selectPlainCallableAuthor`) source-aware models.

- `selectModuleConst`: own-wins; exactly one flat-visible author → it; ≥2 distinct
  flat-visible → `.ambiguous` (loud diagnostic, consistent with 0755/0724); none
  → `.none`. Reads the SELECTED author's per-source value (`module_consts_by_source`)
  and folds its RHS over the global leaf map, so a const-EXPRESSION chain
  (`N :: M + 1`, M flat-imported) still resolves M.
- Rewire `comptimeIntNamed` / `lookupFloatName` / `nameIsFloatTyped`, the runtime
  identifier path, and the global-init-from-const path through it; drop the now
  subsumed `moduleConstBareInvisible` gate.
- program_index: `moduleConst{Int,Float,IsFloatTyped}With` fold a selected `ci`.

Examples: 0759 (own-wins value const, a=1 b=2) + 0760 (two-flat-visible →
ambiguous). Single-author byte-identical (run_examples 498/0, 496 prior unchanged;
zig build test 423/423; corpus sweep 515 no-crash; m3te ios-sim exit 0).
2026-06-08 00:32:07 +03:00
agra
66d10c00bb fix(lower): reserve genuine same-name struct shadows before fields — close F1 [stdlib E2 attempt-2]
A self / forward / mutual reference inside a same-name struct shadow bound to
the FIRST same-name author (another module's struct) instead of its own nominal
TypeId: registerStructDecl resolved a shadow's field types BEFORE registering its
decl key in type_decl_tids, so namedRefTid fell through to the name-only
findByName first-author fallback (F1).

Fix: a genuine same-name struct shadow (≥2 DISTINCT struct decls author the name
in the scanned decl set) reserves ALL its authors' distinct nominal slots up-front
in scanDecls — the first at id 0, the rest at fresh nonzero ids — BEFORE any field
resolves. Every self / forward / mutual ref to the shadow name then resolves via
type_decl_tids to its OWN nominal TypeId.

Gating on the scanned decls, not nameHasMultipleTypeAuthors (the raw import facts
over-count a single file reached via two un-normalized import spellings, e.g.
math/matrix44), keeps single-real-decl names on the legacy id-0 post-field path —
byte-identical (494 prior markers unchanged, single-author old==new).

internNamedTypeDecl now takes the precomputed nominal_id; no-drift + single
graph-walk invariants untouched; generics / enum / union / error-set stay legacy.

Regressions: 0757 (self-ref *Box → reads B's own field), 0758 (forward + mutual
*Node/*Box between two shadows). Fail-before on d98ad5c
("field 'y'/'m' not found"), pass-after.
2026-06-07 23:51:46 +03:00
agra
d98ad5c14f feat(stdlib): per-decl nominal identity + same-name shadows — close 0105 [stdlib E2]
Make same-name top-level types in different sources DISTINCT nominal types
instead of collapsing last-wins in the type table (issue 0105).

Registration:
- internNamedTypeDecl assigns a per-decl nominal_id and populates
  type_decl_tids. The first author of a name keeps nominal_id 0 (byte-identical
  to pre-E2); a genuine cross-module shadow (>=2 distinct normalized-path
  authors per the import facts) gets a fresh id -> a distinct TypeId.
- mergeFlat/addOwnDecl stop first-wins-dropping per-source decls (named types +
  non-fn const_decls) so every same-name author reaches registration; functions
  and var_decls (incl. #foreign extern globals) keep first-wins.

Resolution (selectNominalLeaf):
- own-author wins; else flatTypeAuthorCount over the transitive flat closure:
  >=2 distinct -> .ambiguous (loud diagnostic + poison); exactly one -> resolved;
  a flat author not yet findByName-registered -> .undeclared stub (not a leak).
- struct-literal type names route through the same source-aware leaf.
- lazyLowerFunction pins the function's own source before resolving its return
  type, so a shadowed signature type resolves in its module, not the caller's.

Codegen:
- mangleTypeName appends __n<id> for nonzero nominal_id so same-name shadows get
  distinct monomorph symbols (struct_to_string__Box vs __Box__n1).

Library hygiene:
- rename trace.sx's compiler-contracted Frame -> TraceFrame (+ the two compiler
  findByName sites) so it never collides with a UI/geometry Frame; the layout is
  structural (getFrameStructType / SxFrame), name-independent.

Examples: 0752-0756 pin the five 0105 cases (distinct fields / same fields /
own-wins / ambiguous / alias per-source); 0170 pins the folded anon-struct-field
regression.
2026-06-07 22:57:28 +03:00
agra
4b2a067991 wip(E2): partial nominal-identity/shadow work [stdlib E2 attempt-1 WIP checkpoint]
Incomplete WIP from a worker killed at the 30-min wall; committed as a
checkpoint so the resumed session continues on a clean tree. May not build.
2026-06-07 22:17:07 +03:00
agra
a8c7404a2f Merge branch 'flow/stdlib/E1.5' into wt-stdlib-base 2026-06-07 21:24:33 +03:00
agra
d2eb4c2af4 fix(lower): source-aware initial scan registration for identifier-RHS aliases [stdlib E1.5 attempt-2]
E1.5 attempt-1 made the forward-alias FIXPOINT source-aware but left the
EARLIER path — the `scanDecls` identifier-RHS alias branch — resolving the
RHS through the GLOBAL `type_alias_map` / global `findByName` (last-wins
across modules). When a namespaced import is scanned BEFORE a forward alias
`A :: B; B :: u64;`, dep's same-name `B :: u8` already sits in the global map,
so the early scan bound `A` to dep's `u8` and the per-source fixpoint guard
(`aliasResolvedInSource`) then skipped `A` — re-opening 0105 one layer down
(reviewer R1).

Cut the scan registration over to `selectNominalLeaf(rhs, src, is_raw)`,
resolving `B` AS SEEN FROM the alias's OWN source. Only the `.resolved`
outcome is written via the unified `putTypeAlias`; `.pending` / `.undeclared`
/ `.not_visible` leave `A` UNWRITTEN so the source-aware fixpoint re-tries it
once the local `B` registers. No raw `type_alias_map.put` / global `findByName`
selection reintroduced (E1 no-drift invariant). resolver.zig untouched
(single graph-walk invariant).

Also thread the backtick raw flag (`identifier.is_raw`) into BOTH the scan
registration and the fixpoint `selectNominalLeaf` calls, so a raw-RHS alias
(`` RawAlias :: `s2 ``) resolves to the nominal `` `s2 `` author, not the
builtin `s2` spelling (fixes 0154 under the new scan path; closes the same
latent hardcode in the fixpoint).

Regression: examples/0751-modules-forward-alias-ns-before — the reviewer's
exact ordering (ns import with `B :: u8` BEFORE `A :: B; B :: u64;`). Fails
on 2d34993 (`forward A` = 44, dep's u8) and passes after (= 300, local u64).
0750 + 0132/0133 + the full suite stay byte-identical (488/0).
2026-06-07 21:12:33 +03:00
agra
2d34993586 feat(lower): source-aware forward-alias fixpoint [stdlib E1.5]
resolveForwardIdentifierAliases now resolves a forward alias A :: B against
B AS SEEN FROM A's own source via selectNominalLeaf (E1's source-keyed
nominal leaf over type_aliases_by_source / moduleTypeAuthor), never the
global type_alias_map / global findByName. The already-resolved guard is
per-source (aliasResolvedInSource). .pending routes back into the fixpoint;
.undeclared / .not_visible leave A unwritten (no global last-wins leak).

This is the sequencing pin before E2: a global fixpoint binds A to a
same-name B authored by a different module (e.g. a namespaced import that
pollutes the global alias map last-wins), re-opening 0105 one layer down
once shadows register. Writes stay on the unified putTypeAlias helper (E1
no-drift invariant); the single graph-walk in resolver.zig is untouched.

Regression: examples/0750-modules-forward-alias-source-aware — a forward
alias A :: B with main's own B :: u64 and a namespaced same-name B :: u8;
A must bind main's u64 (300), not the global last-wins u8 (44).
2026-06-07 20:43:01 +03:00
agra
4a10c9a291 Merge branch 'flow/stdlib/E1' into wt-stdlib-base 2026-06-07 19:42:07 +03:00
agra
78ef2ea3d8 fix(lower): single unified writer for the three decl-fact maps; close param-alias leak [stdlib E1 attempt-5]
Route EVERY write of type_alias_map / module_const_map / global_names (and
their *_by_source analogues) through one helper per map
(putTypeAlias/putModuleConst/putGlobal/dropModuleConst). The global put and the
by-source put are now inseparable, so no write-site can mirror one side and
miss the other — the dual-write drift that leaked ns-only aliases past the
source-aware bare-TYPE gate. Grep-clean: no raw .put/.remove to the three maps
outside the helpers (mirrors the no-raw-TypeTable.update discipline).

The generic-struct instantiation alias sites (Secret :: Box(s32), both the
.call and .parameterized_type_expr branches) previously registered only a named
struct in the TypeTable and never reached type_aliases_by_source, so
moduleTypeAuthor missed them and a bare ns-only use leaked (exit 42, no
diagnostic). Routing those writes through the unified putTypeAlias lands the
alias in the per-source cache and the leak closes BY CONSTRUCTION — a flat use
still resolves to the same TypeId findByName would, a ns-only use is rejected.

Regression 0749 (ns-only Secret :: Box(s32) bare -> "type 'Secret' is not
visible"): fail-before on daf4bbc exit 42 no diagnostic, pass-after exit 1.
Single-author resolution byte-identical (486 passed / 0 failed). resolver.zig
single graph-walk untouched; generic/param-protocol/Vector/type-fn stay legacy.
2026-06-07 19:31:13 +03:00
agra
daf4bbc862 fix(resolver): treat type aliases as bare-TYPE authors in both gate directions [stdlib E1 attempt-4]
R4: a type alias is a `const_decl`, not a named-type decl, so the bare-TYPE
visibility gate ignored aliases — a namespaced-only alias leaked bare (silent
empty-struct stub, no diagnostic) and a flat-visible alias was poisoned by an
invisible same-name named type. Unify both type-author kinds (named type AND
alias) behind one per-module predicate `moduleTypeAuthor`, returning the author
KIND so resolution is decoupled from `findByName` timing (a forward/self
reference like `next: *ArenaChunk`, unregistered mid-registration, is still
recognised as an author and falls to the legacy stub instead of a false
"not visible"). The leak detector `nameAuthoredAsTypeAnywhere` now also scans
`type_aliases_by_source`. Single source of truth across named types, top-level
aliases, and parameterized/type-fn aliases — leak side and false-rejection side.

Behavior-preserving for single-author names (full suite byte-identical, paths
normalized). Generic / parameterized-protocol / Vector / type-function heads
stay legacy (0210). Block-local `Name :: <type>` remains a value const under the
reserved-name duality (pre-existing; the gate handles it safely, no leak).

Regressions: 0747 (ns-only alias bare -> not visible), 0748 (flat-visible alias
not poisoned by ns-only same-name struct). Both fail-before on 4bd57c8 /
pass-after here.
2026-06-07 18:41:01 +03:00
agra
4bd57c857e fix(resolver): type-author-aware + local-safe bare-TYPE gate; R3 model escalated [stdlib E1 attempt-3]
R1 (type-author-aware gate): the bare-TYPE visibility gate now requires a
flat-import-reachable TYPE author (struct/enum/union/error-set/protocol/foreign
class). A same-name flat VALUE/FUNCTION no longer makes a namespaced-only TYPE
bare-visible — the name-only `m.names.contains` check (attempt-2) is replaced by
`moduleAuthorsType` (kind-checked via `RawDeclRef`). Regression 0745.

R2 (no local false-positive): a block-local type clobbers the global type-table
entry for its name (`registerStructDecl`'s findByName-orelse-intern +
updatePreservingKey), so it IS the resolved type — never a namespaced-only leak.
A new `local_type_names` set, populated at both block-local type-decl paths,
exempts such names from the gate. Regression 0746.

readme.md: drop the false "transitively" claim — flat-import bare visibility for
functions and constants is NON-transitive (0706).

R3 (foundational model consistency) is ESCALATED, not resolved here — see the
attempt-3 worker report. Ground truth: making the TYPE gate single-hop (to match
the value/function model) breaks ~19 tests, ~13 of them library-INTERNAL generic
refs (e.g. `List.append`'s `alloc: Allocator`, lowered in the caller's source
context). That needs source-pinning generic instantiation to the template's
defining module — a separate architectural piece beyond E1's leaf-cut scope, and
proven risky (a `monomorphizeFunction` pin broke 4 FFI objc-block tests and did
not even take, since template method bodies lack a reliable `source_file`). The
TYPE gate therefore stays on the (type-author-aware) transitive flat closure for
E1; the non-transitive reconciliation is a routed follow-up.
2026-06-07 17:51:09 +03:00
agra
7188481761 feat(resolver): complete source-aware nominal-TYPE leaf — bare ns-only types not visible [stdlib E1 attempt-2]
Completes the F1 deliverable the reviewer flagged: the bare TYPE leaf still
returned the global `findByName` match BEFORE any visibility check, so a type
declared only behind a namespaced import leaked bare. Now the registered-type
branch of `selectNominalLeaf` is gated on bare-flat visibility (the type analog
of Phase B's value/function tightening): a bare reference to a namespaced-only
import's TYPE errors ("type 'X' is not visible; #import the module that declares
it") and poisons to `.unresolved` — never the leaked global match, never a
silent empty-struct stub.

Visibility gate is the TRANSITIVE flat-import closure (`typeBareVisible`), not
the single-hop `collectVisibleAuthors`/`isNameVisible`: a flat import is
transitive for resolution, so a type two flat hops away (`CAllocator`, via
`main → std.sx → allocators.sx`) stays bare-visible while a namespaced-only type
(reached solely over a namespace edge) does not. The gate applies ONLY to a
TOP-LEVEL author (`module_decls`) — a LOCAL type / generic-param / fabricated
empty-struct stub is findByName-registered but authored in no module, so it
resolves ungated and byte-identically (its own diagnostics still fire). The
compiler-synthesized default-Context emission falls open (`CAllocator` is
infrastructure, independent of the program's import style). The closure walk
lives in lower.zig, so resolver.zig keeps its single graph-walk.

A namespaced callee's declared return type now resolves in the callee's own
module context (`resolveTypeInSource` over `qualified_fn_source`) — a `Value`
returned by `json.parse` is visible inside `json.sx`, not at the call site
(issue-0100-F1 source-pin analog).

Migrates 0719 (flat-imports `cli` for its types, keeps `cli` namespaced for the
same-name `cli.parse`). Adds 0743 (bare ns-only struct → not visible) and 0744
(bare ns-only enum → not visible) regressions. 0742 (ns-only const) + 0210
(generics stay legacy) unchanged. readme updated.

Gate: zig build / zig build test (LSP sweep, no crash) / run_examples 481/0;
m3te ios-sim exit 0; 0743/0744 fail-before on 7cd12b0 (compiled, no diagnostic)
/ pass-after (clean "not visible").
2026-06-07 16:49:15 +03:00
agra
7cd12b0ed5 feat(resolver): source-aware nominal leaf (Lowering-side) + ns-const tightening [stdlib E1]
Route the Lowering-side bare type leaf through the source-keyed caches (E0):
nominal author via collectVisibleAuthors(.user_bare_flat) + alias via
type_aliases_by_source, instead of the global findByName first-match. The
binding-free resolveAstType path + registration sites stay on the global
compat readers (move later). Single-author resolution byte-identical (no
shadows yet). Folded req #1: a namespaced-only import's const is no longer
bare-visible in array-dim/comptime-scalar position. Adds regression 0742
(ns-only bare const) and 0210 (generics/Vector/type-fn stay legacy).

Salvaged from a worker killed at the wall before commit; manager verified
the gate at ground truth (zig build test exit 0; run_examples 479/0 with
0210+0742 ok, prior 477 byte-identical; m3te ios-sim exit 0; folded fix
confirmed fail-before on master 7ffc0c1 exit 0 / pass-after exit 1).
2026-06-07 15:24:43 +03:00
agra
a5d6c940dd Merge branch 'flow/stdlib/E0' into wt-stdlib-base 2026-06-07 14:30:15 +03:00
agra
662142c388 feat(resolver): source-keyed alias/const/global caches, write-side only [stdlib E0]
Phase E0 of the unified resolver (R5 §#4): add the source-partitioned
analogues of the global `type_alias_map` / `module_const_map` /
`global_names`, keyed `source path -> name -> X`, and POPULATE them from
the existing scan. Purely additive and behavior-preserving — the global
maps remain the ONLY readers; the read-side cutover to
`selectedAuthor.source` is E1.

ProgramIndex:
- type_aliases_by_source / module_consts_by_source / globals_by_source
  (StringHashMap of inner StringHashMap), owned + freed on deinit.
- put{TypeAlias,ModuleConst,Global}BySource + removeModuleConstBySource
  helpers; retain `module.alloc` to lazily create inner per-source maps.

lower.zig scan: every global `type_alias_map`/`module_const_map`/
`global_names` write (and each module_const_map.remove) now mirrors into
its by-source analogue, keyed by the registering decl's source
(decl.source_file / current_source_file), the analogue of module_fns.

Tests:
- program_index.test.zig: same alias/const/global name under two sources
  lands two distinct entries (not last-wins); compat globals stay
  single-keyed; removeModuleConstBySource scoped to its source.
- lower.test.zig: end-to-end two-source namespace fixture — the scan
  populates the by-source caches per declaring source while the global
  maps stay single-keyed by name.

Gate: zig build + zig build test (423, incl. 2 new) + run_examples
(477, byte-identical) + m3te ios-sim build, all exit 0.
2026-06-07 14:17:08 +03:00
agra
c839c60233 Merge branch 'flow/stdlib/D' into wt-stdlib-base 2026-06-07 13:39:29 +03:00
agra
09666cb90e feat(types): nominal identity + key-safe TypeTable mutation, ban raw update [stdlib D]
Phase D of the unified resolver: make the TypeTable safe to key by nominal
identity before same-name type shadows land (Phase E). Behavior-preserving —
nominal_id=0 means structural (today's keying, byte-identical); single-author
names intern to the same TypeId as before.

types.zig:
- StructInfo/EnumInfo/UnionInfo/TaggedUnionInfo/ErrorSetInfo gain
  `nominal_id: u32 = 0`. hash/eql fold it into the nominal arms ONLY, and only
  when nonzero, so legacy (structural) interning hashes/compares byte-identically.
- internNominal(info, nominal_id): stamps the id into the nominal arm then
  interns; nonzero id on a non-nominal info trips an assert.
- updatePreservingKey(id, info): field-fill that asserts the intern key is
  unchanged (replaces the forward-decl stub→full pattern).
- replaceKeyedInfo(id, info): the one legitimate re-key (anon rename
  __anon → Parent.field); removes the stale key and installs the new one.
- findUniqueByName: quarantined findByName that asserts ≤1 match.
- type_decl_tids: decl-node → TypeId identity map (the fn_decl_fids analogue),
  consumed by the resolver in Phase E.

Ban raw TypeTable.update outside types.zig (the acceptance bar): every caller
in lower.zig / type_bridge.zig / protocols.zig is reclassified — forward-decl
field fills route through updatePreservingKey, qualifyAnonType's rename through
replaceKeyedInfo. The raw `update` method is removed. Inline named type-decl
registration ("current winners") routes through internNominal(info, 0).

Tests (types.test.zig): forward-decl field fill (stable key), anon rename
(re-key), generic struct instantiation, type-returning function, parameterized
protocol value struct, same display-name → distinct nominal ids, plus an
old==new assertion (internNominal(.,0) byte-identical to legacy intern),
findUniqueByName, and the type_decl_tids identity map.

Gate: zig build (0), zig build test (421/421), run_examples (477, byte-identical),
m3te ios-sim build via worktree binary (0). No shadows registered; stubs intact.
2026-06-07 13:27:10 +03:00
agra
7ffc0c112f Merge branch 'wt-stdlib-base' 2026-06-07 12:57:52 +03:00
agra
e4d58b2abb Merge branch 'flow/stdlib/C' into wt-stdlib-base 2026-06-07 12:55:05 +03:00
agra
82fc71ccbe fix(lower): route early pack/comptime dispatch through SelectedFunc [stdlib C attempt-3]
lowerCall's early pack/comptime/generic dispatch keyed off the first-wins
winner (`fn_ast_map.get(early_name)`) BEFORE the main dispatch consumes the
selected same-name author. Under a genuine flat same-name collision where the
caller's own author is a plain free fn but the first-wins winner is a comptime
pack `(..$args)` (or comptime-param / generic), the early path invoked the
WINNER — so `CallResolver.plan` (which selects the own plain author) and
lowering disagreed about which function a bare call names.

Confirms reviewer finding C-review-1. The earlier manager ground-truth got
`show_b=2` because it used a slice variadic `(..xs: []s64)` — NOT a pack fn
(`isPackParam` false), so it never hit the early dispatch. The reviewer used a
comptime pack `(..$args)` (`isPackFn` true), which does. Both observations are
correct for their respective shapes; the bug is real for the comptime-pack
winner.

Fix: the early dispatch reads the SAME author the selector chose
(`sel_author.decl`) when a collision rerouted the call, else the winner
(common path, byte-identical). The selector only ever returns a plain free fn
(`isPlainFreeFn` excludes type-params / comptime / pack), so a selected author
falls through to the main dispatch that binds it via `SelectedFunc`.

Regression: examples/0741-modules-flat-same-name-bare-pack-winner — a.sx
(imported first) authors `f` as a comptime pack (first-wins winner); b.sx
authors its own plain `f`; b's bare `f()` must return 2 (own author), not 1
(the pack). Fails on 2dd6c3c (b: f() = 1), passes after.

Gate: zig build + zig build test (412/412) + run_examples (477/0) +
m3te ios-sim exit 0.
2026-06-07 12:40:00 +03:00
agra
2dd6c3c13b fix(resolver): share plan SelectedFunc across consumers + route UFCS through selector [stdlib C attempt-2]
Address Phase C review (C-1, C-2): make CallResolver.plan's SelectedFunc the
single shared call author consumed by the lower-call sites instead of each
re-resolving; route free-fn value-receiver UFCS through the selector in plan so
plan typing and lowering pick the same author under a flat same-name collision.
Adds regression 0740-modules-flat-same-name-ufcs-typing.

Salvaged from a worker killed at the wall during its final gate step; manager
verified the gate at ground truth (zig build test exit 0; run_examples 476/0 with
0722-0735 + 0740 ok; m3te ios-sim exit 0).
2026-06-07 12:05:12 +03:00
agra
9568f7689f feat(resolver): route plain bare-call author through Phase B collector via SelectedFunc [stdlib C]
Phase C of the unified resolver (R5 §C, §#3). Re-base the plain bare-name
call author onto the Phase B collector behind one shared SelectedFunc, so
every call-path consumer reads ONE author and they can no longer disagree
(fix-0102 F2). Behavior-preserving: 0722-0735 byte-identical, run_examples
stays at 475.

- SelectedFunc {decl, source, materialized?} replaces ResolvedAuthor in
  BareCallee.func; CallPlan.Target gains a `selected` arm (calls.zig).
- selectPlainCallableAuthor: resolveBareCallee's body verbatim over
  resolver.collectVisibleAuthors (.user_bare_flat) — the ONE graph-walk.
  fnDeclOfRaw mirrors imports.fnDeclOf so the collector's all-domain authors
  reproduce module_fns' fn-only view; every byte of the negative space is
  preserved (own==winner → .none; non-plain-free → .none; filter-before-count;
  ≥2 distinct → .ambiguous). No eager materialization.
- selectedFuncId materializes the FuncId on demand (shadow-only), caching into
  materialized — null until a site needs it (0102d: a shadow taken as a value
  never lowers the winner).
- Six consumers route through the one selector: lowerCall variadic packing,
  free-fn UFCS, fn-value, closure(fn), resolveCallParamTypes, and
  expandCallDefaults (decl-only, no materialization). plan() produces the
  SelectedFunc as `.selected`. Generic/comptime/foreign/builtin stay legacy.
- lower.test.zig: wire module_decls; selectPlainCallableAuthor verdicts
  (own-winner → .none; ≥2 flat → .ambiguous; own-shadow → decl+source, fid
  round-trips, materialized null).

Gate: zig build + zig build test (412 ok) + run_examples (475, byte-identical)
+ m3te ios-sim build exit 0.
2026-06-07 11:02:08 +03:00
agra
f2de1a9710 Merge branch 'flow/stdlib/B' into wt-stdlib-base 2026-06-07 10:27:42 +03:00
agra
fc112200c7 docs(readme): stop quoting per-kind ns-import diagnostics (mid-migration) [stdlib B attempt-8]
The namespaced-only bare-visibility behavior is non-uniform and partial during
the resolver migration: runtime const/fn use errors, comptime/array-dim const
positions still compile, const-aliases report 'unresolved', and bare types still
resolve. Rewrite the note to state the durable rule (a namespaced import binds
only its alias; reach members as m.name; bare-name visibility joins over flat
imports only) and that bare references to ns-only members are being phased out
and do not yet resolve uniformly across name kinds. No specific diagnostic
string, no completeness/uniformity claim. Doc-only; no code path touched.
2026-06-07 10:04:40 +03:00
agra
aa2b79683b docs(readme): scope ns-only bare-visibility claim to values/functions [stdlib B attempt-7]
Phase B tightened bare VALUE/FUNCTION visibility through a namespaced-only
import (isNameVisible/isCImportVisible -> 'not visible'). Bare TYPE names
from such an import still resolve today; type-name visibility tightens in a
later resolver phase. Correct the README so it no longer claims all bare
names from a namespaced import error.
2026-06-07 09:51:57 +03:00
agra
0369cb001f fix(lower): stamp caller source on variadic comptime pack args [stdlib B attempt-6]
lowerComptimeCall stamped the caller's source onto fixed comptime `$`-params
so their substituted bare names resolve in the caller's visibility context,
but the variadic comptime pack branch (`..$args`) recorded the pack-arg slice
without stamping. Those nodes are later re-lowered via packArgNodeAt under the
defining-module pin, so a caller-owned helper in a formatted-arg position
(`std.print("{}", caller_fn())`) was checked against the metaprogram's module
and rejected as "not visible". Stamp every pack-arg node with the caller source,
mirroring the fixed-param treatment — completing Problem 1 for pack args.

Regression: examples/0739-modules-comptime-pack-arg-caller-context.sx
(two caller-owned s64 helpers in std.print pack positions; fail-before both
"not visible", pass-after prints "42 7"). No exemption flag, no silent default.
2026-06-07 09:31:17 +03:00
agra
8875a28641 fix(lower): resolve substituted caller comptime $-args in caller context [stdlib B attempt-5]
attempt-3 pinned current_source_file to the metaprogram's defining module
across the whole body lowering (lowerComptimeCall / monomorphizePackFn). That
pin also covered caller-provided comptime $-arg nodes spliced into the body by
substituteComptimeNodes — but those are CALLER-authored and must resolve in the
caller's visibility context, not the callee's. Result: a caller-owned helper
passed to an imported metaprogram errored "'<name>' is not visible".

Fix: stamp each comptime $-arg node with the caller's source_file at the cpn
build site (stampCallerSource, in lowerComptimeCall + monomorphizePackFn);
lowerExpr switches current_source_file to a node's source_file when present, so
the substituted subtree resolves against the caller while the surrounding callee
code keeps the defining-module pin. No exemption / fall-open.

Regression: examples/0738-modules-comptime-arg-caller-context.sx — a caller-owned
helper passed as a comptime-ONLY $-arg through a namespaced import. Fail-before
(attempt-3 binary): "'caller_name' is not visible". Pass-after: prints
"hello world", exit 0. Comptime-only, so it does not exercise issue 0107.

0106 RESOLVED banner extended (point 3: body=defining context, substituted
$-args=caller context). run_examples 473 -> 474; zig build test 412/412.
2026-06-07 09:07:27 +03:00
agra
b62223edaf fix(lower): pin defining-module context for pack/comptime metaprograms; drop #insert exemption [stdlib B attempt-3]
ROOT FIX for issue 0106's library-metaprogram half — no exemption.

attempt-2 masked the 0106 fallout with an `in_insert_expansion` flag that
made the visibility adapters fall open during ANY `#insert` expansion,
including a USER's `#insert <expr>` — so a bare reach into a namespaced-only
import from user `#insert` code wrongly compiled (Adi's blocker). The flag
was the wrong shape. This removes it and fixes the real cause.

Root cause: a metaprogram's body (`std.print` / `std.format` / `log.*`,
whose `#insert build_format(fmt)` + `#insert "out(result);"` reference
std-internal bare names) was lowered under the CALL SITE's
`current_source_file`, so those names were policed against the consumer's
imports. Normal functions get this right via `lowerFunctionBodyInto`, which
pins `func.source_file`; the two monomorphizers don't:
  - `monomorphizePackFn`   — bare `print(...)` / `format(...)` (pack path).
  - `lowerComptimeCall`    — namespaced `std.print` / `log.warn` (reached via
                             the field-access `hasComptimeParams` branch).

Fix: both paths now save/set/restore `current_source_file` to the body's
DEFINING module around the BODY lowering only (call-site args stay in the
caller's context). The defining path is stamped onto each function body node
by `resolveImports` (`stampFnBodySource`), mirroring `Function.source_file`.
So library internals resolve in std.sx/log.sx naturally, while a USER's
`#insert <expr>` is still checked in the user's context.

- Exemption GONE: `in_insert_expansion` flag + both adapter fall-open checks
  deleted; `isNameVisible`/`isCImportVisible` are byte-identical adapters.
- New pinned regression: examples/0737-modules-insert-bare-not-visible.sx
  (+ a.sx) — a USER `#insert secret()` into a namespaced-only import errors
  ('secret' is not visible). fail-before exit 0 on the attempt-2 binary /
  pass-after exit 1.
- face #1 (0736) still errors; face #2 (0015/0700/0718/1030) pass again WITH
  NO exemption — the metaprogram body resolves in its own module.
- run_examples 472 -> 473; zig build test 412/412; m3te ios-sim build exit 0.
- issues/0106 RESOLVED banner updated (root cause + no-exemption fix).
2026-06-07 06:09:28 +03:00
agra
6f2bf84293 fix(lower): #insert-expansion visibility exemption + close 0106 [stdlib B attempt-2]
Folds the coupled 0106 fix into Phase B. attempt-1 tightened the bare-name
visibility adapters (isNameVisible/isCImportVisible) to the flat_import_graph
edge set via the unified isVisible(.user_bare_flat/.c_import_bare) predicate;
that surfaced issue 0106 — std.print / log.* expand `#insert build_format(fmt)`
(comptime call) and `#insert "out(result);"` (inserted stmt) in the CONSUMER's
current_source_file, so their library-internal bare names were policed against
the consumer's imports and errored (run_examples 471 -> 467).

Fix: a precise, named exemption. Lowering.in_insert_expansion is set across
lowerInsertExprValue (the comptime eval + the parsed-back statements); the two
visibility adapters fall open while it is set — mirroring the existing
UFCS-alias / mangled-local "compiler indirection" exemptions. NOT a blanket
skip: scoped to #insert-expanded code, ordinary bare references stay policed.
Library-internal call bodies (build_format's concat/substr) already resolve in
the defining module — lowerFunctionBodyInto pins their current_source_file.

The flat tightening stays: a bare reference to a namespaced-only import's
internal name now correctly errors ('<name>' is not visible). This is the
Agra-ratified user-visible semantics change.

- face #1 pinned: examples/0736-modules-namespaced-only-bare-not-visible.sx
  (+ a.sx) — exit 1 + stderr; fail-before (import_graph compiled it, exit 0) /
  pass-after (flat set errors, exit 1).
- face #2 restored: examples 0015 / 0700 / 0718 / 1030 pass again.
- run_examples 471 -> 472 (the new regression).
- issues/0106 marked RESOLVED; readme.md documents namespaced-only visibility.

Collectors + unified predicate from attempt-1 (resolver.zig) unchanged; nothing
routes resolution AUTHOR-SELECTION through them yet (that is Phase C).
2026-06-07 05:17:23 +03:00
agra
7158337c73 wip(resolver): collectors + unified predicate + tightened adapters [stdlib B, BLOCKED on 0106]
Collectors (resolver.zig: collectVisibleAuthors/collectNamespaceAuthors + AuthorSet
+ VisibilityMode, 4 unit tests) + unified visibility predicate + isNameVisible/
isCImportVisible adapters routed to flat modes. Tightening surfaces issue 0106
(stdlib comptime expansion relies on the over-permissive import_graph join), so
run_examples is 467/471 here. attempt-2 folds in the coupled comptime-context fix.
2026-06-07 04:52:56 +03:00
agra
35457cb614 Merge branch 'flow/stdlib/A' into wt-stdlib-base 2026-06-07 00:06:52 +03:00
agra
5f06b6504f fix(imports): diagnose namespace-alias dup + propagate buildImportFacts errors [stdlib A attempt-2]
Two defects from the Phase A attempt-1 review.

F1 — duplicate-name diagnostic missed NAMESPACE ALIASES (silent first-win).
`addNamespace` unconditionally put the alias into scope/own_decls, so a
same-module collision between an authored decl and a `dup :: #import "…"`
alias compiled clean in the fn-then-alias order (the scalar
ModuleRawDeclIndex silently first-won). Now `addNamespace` returns a bool
and refuses a same-module duplicate (mirroring addOwnDecl); the call site
surfaces it via the new `reportDuplicateName` (the import_decl node has no
declName, so the alias name is passed explicitly). The C-import namespace
site gets the same guard. Both orders now emit "duplicate top-level
declaration 'X'" and exit nonzero (alias-then-fn was already caught by
addOwnDecl seeing the alias in scope).

F2 — buildImportFacts errors were swallowed by `else |_| {}` in core.zig
(REJECTED-PATTERN catch-all leaving the borrowed store silently empty).
`resolveImports` returns !void, so the call is now a plain `try` and a
build failure propagates instead of producing a stale/empty store.

Tests: extend the dup-name regression with fn-vs-namespace-alias
collisions in both orders. No resolution behavior change (no lower.zig
edits; run_examples 471 byte-identical); m3te ios-sim builds via the
worktree binary.
2026-06-06 23:54:51 +03:00
agra
b5ec121645 feat(imports): buildImportFacts raw-fact store (ModuleRawDeclIndex + NamespaceEdges) [stdlib A]
Phase A of the unified resolver (R5 locked design). Additive infrastructure
with NO behavior change — builds the import-side raw-fact store; nothing
consumes it yet.

- imports.zig: add RawDeclRef / RawAuthor / ModuleRawDeclIndex / ModuleDecls /
  NamespaceTarget / NamespaceEdges, plus buildImportFacts (mirrors
  buildModuleFns) producing a scalar per-module name→RawDeclRef index + the
  namespace edges. Callable without IR lowering (LSP reuses it later).
- ast.zig: NamespaceDecl gains target_module_path, captured at resolution time
  (the resolved_path otherwise lost on the node) so the namespace edge records
  the alias target.
- imports.zig: same-module duplicate top-level name is now DIAGNOSED
  ("duplicate top-level declaration 'X'") where addOwnDecl would silently drop
  the second author — replaces the discarded `_ =` at the three call sites.
- program_index.zig: borrowed views module_decls / namespace_edges (like
  module_fns); deinit does not free them.
- core.zig: build the facts alongside buildModuleFns and point the borrowed
  views at them.
- imports.test.zig: index unit tests (flat / directory / namespaced file /
  namespaced directory / C-import namespace / same-name fn / same-name struct /
  value-vs-type same spelling / raw const_decl) + the duplicate-name diagnostic
  regression (fails pre-fix, passes after).

Gate (worktree): zig build, zig build test (incl. LSP corpus sweep), and
run_examples (471, byte-identical) all green; m3te ios-sim build exits 0.
2026-06-06 23:34:32 +03:00
2083 changed files with 588562 additions and 56483 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.vsix filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,87 +0,0 @@
name: Build
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
jobs:
build-linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: master
- name: Install LLVM 18
run: sudo apt-get update && sudo apt-get install -y llvm-18-dev gcc
- name: Build
run: zig build -Dllvm-prefix=/usr/lib/llvm-18
- name: Test
run: zig build test -Dllvm-prefix=/usr/lib/llvm-18 --summary all
- name: Package
run: tar czf sx-linux-x86_64.tar.gz -C zig-out/bin .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: sx-linux-x86_64
path: zig-out/bin/
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: sx-linux-x86_64.tar.gz
build-windows:
runs-on: windows-latest
env:
LLVM_PREFIX: C:\LLVM\llvm-18.1.8-windows-amd64-msvc17-msvcrt
steps:
- uses: actions/checkout@v4
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: master
- name: Download LLVM 18
run: |
Invoke-WebRequest -Uri "https://github.com/vovkos/llvm-package-windows/releases/download/llvm-18.1.8/llvm-18.1.8-windows-amd64-msvc17-msvcrt.7z" -OutFile "$env:TEMP\llvm.7z"
7z x "$env:TEMP\llvm.7z" -oC:\LLVM
- name: Setup MSVC
uses: ilammy/msvc-dev-cmd@v1
- name: Build
shell: cmd
run: zig build -Dstatic-llvm -Dllvm-prefix=%LLVM_PREFIX% -Dtarget=x86_64-windows-msvc
- name: Test
shell: cmd
continue-on-error: true
run: zig build test -Dstatic-llvm -Dllvm-prefix=%LLVM_PREFIX% -Dtarget=x86_64-windows-msvc --summary all
- name: Package
run: Compress-Archive -Path zig-out\bin\* -DestinationPath sx-windows-x86_64.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: sx-windows-x86_64
path: zig-out/bin/
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: sx-windows-x86_64.zip

3
.gitignore vendored
View File

@@ -3,5 +3,4 @@ zig-out
.DS_Store
.vscode/
.sx-cache
.sx-tmp
current/
.sx-tmp

102
CLAUDE.md
View File

@@ -16,7 +16,7 @@ Procedure:
- **Symptom** — one-line summary + observed vs expected.
- **Reproduction** — minimal sx code (inline fenced block). Must
reproduce the bug standalone, no project dependencies beyond
`modules/std.sx` / `modules/allocators.sx`.
`modules/std.sx` / `modules/std/mem.sx`.
- **Investigation prompt** — a ready-to-paste prompt the user can
drop into a fresh session to fix the bug. Should include: the
suspected area of the compiler (file + function), what the
@@ -73,9 +73,9 @@ There is no "wrap up first" option.
a lookup fails in the compiler. Examples of the pattern to root out:
```zig
// NEVER write this — lookup fails, return s64 and pretend nothing
// NEVER write this — lookup fails, return i64 and pretend nothing
// happened. Any caller asking `what type is this?` gets a lie.
return self.module.types.findByName(name_id) orelse .s64;
return self.module.types.findByName(name_id) orelse .i64;
// NEVER write this — same shape, dressed up:
return scope.lookup(name) orelse default_type;
@@ -86,9 +86,9 @@ These defaults silently produce wrong results in cases the implementer
didn't think of. The classic failure mode: the default coincidentally
matches the size/shape of one common case, so the test suite passes
and the bug ships invisibly. issue-0042 lived for years because
`resolveTypeArg`'s `orelse .s64` returned 8 bytes for unresolved
`resolveTypeArg`'s `orelse .i64` returned 8 bytes for unresolved
type-alias names — coincidentally correct for any 8-byte target
(`s64`, `*T`, `f64`, function pointers), and silently wrong for
(`i64`, `*T`, `f64`, function pointers), and silently wrong for
everything else.
**Required:** when a lookup that *must* succeed fails, emit a
@@ -102,7 +102,7 @@ as a silently-corrupted size or alignment.
`void` is a real, heavily-checked type (void returns, void params, "no
value" markers), and pervasive `if (ty == .void) { skip / return-nothing }`
checks would silently swallow the failure — trading one silent default
(`.s64`) for another (`.void`) one layer down. The same objection rules
(`.i64`) for another (`.void`) one layer down. The same objection rules
out `noreturn` (diverging expressions) and any other load-bearing builtin.
Instead, add a **distinct** `.unresolved`-style `TypeId` whose sole meaning
is "resolution failed". A dedicated value (1) can't be mistaken for a real
@@ -311,7 +311,7 @@ LongLived :: struct {
}
// Direct allocs too:
grow_buf :: (self: *LongLived, n: s64) {
grow_buf :: (self: *LongLived, n: i64) {
self.buf = self.own_allocator.alloc(n);
}
}
@@ -405,7 +405,10 @@ any can be advanced independently.
After any code change:
```sh
zig build # must compile
zig build test # must pass
zig build test # must pass — runs the Zig unit tests
# AND the full examples/ + issues/
# regression corpus (a failing example
# fails the build)
```
After completing a phase's final step, run the phase's end-to-end verification command listed in `current/PLAN.md`.
@@ -415,9 +418,25 @@ After completing a phase's final step, run the phase's end-to-end verification c
After any compiler change:
1. **Build**: `zig build && zig build test`
2. **Run regression tests**: `bash tests/run_examples.sh`
- Every test must show `ok` (currently 324)
- Zero failures, zero timeouts
- `zig build test` runs the unit tests **and** the example/issue corpus as
one suite — a failing example fails the build. The corpus is driven by a
pure-Zig test (`src/corpus_run.test.zig`) that spawns the installed `sx`
binary per example (subprocess-isolated, with a per-run timeout), so no
shell script is involved.
2. **Regenerate snapshots**: `zig build test -Dupdate-goldens`
- Flips the corpus test to write each example's expected
`.exit`/`.stdout`/`.stderr` (+ `.ir` where one already exists) from
freshly-normalized output instead of asserting against it. This is the
preferred way to update snapshots — no shell script needed.
- A test is still keyed off its `expected/<name>.exit` marker, so seed an
empty marker first for a brand-new example (see "Adding a feature").
`zig build test` is the only way to run the corpus — there is no standalone
shell runner (the legacy `tests/run_examples.sh` was removed). Per-example
build/run directives live in an optional `expected/<name>.build` **JSON** sidecar
(see "Test layout" below): `{ "aot": true }` switches an example from JIT `sx run`
to a `sx build` + execute flow (needed to exercise a C-ABI symbol exported FROM sx
— a JIT-resident symbol is invisible to a dlopen'd C dylib); `{ "target":
"x86_64-linux" }` threads `--target` and arch-gates the example.
### Test layout
@@ -436,6 +455,7 @@ split into three streams (no more merged `2>&1`) plus an optional IR snapshot:
<root>/expected/XXXX-category-name.stdout # normalized stdout
<root>/expected/XXXX-category-name.stderr # normalized stderr
<root>/expected/XXXX-category-name.ir # optional `sx ir` snapshot
<root>/expected/XXXX-category-name.build # optional JSON build/run directives
```
A test is any `<name>.sx` with an `expected/<name>.exit` marker. The runner
@@ -443,14 +463,28 @@ scans two roots: `examples/` (the feature suite) and `issues/` (pinned bug
repros). Multi-file tests keep companions (`.c`/`.h`, imported `.sx`, fixture
dirs) under the same `XXXX-` prefix.
The optional `<name>.build` JSON sidecar carries per-example directives
(unknown keys are a hard error — never silently ignored):
- `"aot": true` — build a native binary and execute it instead of JIT `sx run`.
- `"target": "<triple|shorthand>"` — thread `--target` into every `sx`
invocation and gate on the host. If the target's arch+os **match** the host,
the example runs normally; if they **mismatch** (e.g. `x86_64-linux` on an
aarch64 host), the runner switches to **ir-only** mode — it skips
run/build/exec and asserts only `.exit` + `.ir` + `.stderr` from
`sx ir --target` (`.stdout` is not asserted). An `.ir` snapshot is **required**
in ir-only mode (its absence is a loud failure). This is how arch-pinned
examples (e.g. x86_64 inline-asm) are tested on a non-matching dev host while
still running end-to-end on a matching CI runner.
### Snapshot integrity
**Never run `--update` while tests are failing.** The `--update` flag blindly overwrites expected output with whatever the compiler produces — including error messages. If you update snapshots during a broken state, the test suite will "pass" against garbage output and real regressions become invisible.
**Never regenerate snapshots while tests are failing.** `-Dupdate-goldens` (and the legacy `--update`) blindly overwrite expected output with whatever the compiler produces — including error messages. If you regenerate during a broken state, the test suite will "pass" against garbage output and real regressions become invisible.
Safe workflow:
1. Fix the code until `bash tests/run_examples.sh` passes against the **existing** snapshots.
2. Only run `--update` when you've intentionally changed output (new feature, new test, changed formatting).
3. After `--update`, review the diff (`git diff examples/expected/ issues/expected/`) to confirm no error messages or empty output were captured.
1. Fix the code until `zig build test` passes against the **existing** snapshots.
2. Only run `zig build test -Dupdate-goldens` when you've intentionally changed output (new feature, new test, changed formatting).
3. After regenerating, review the diff (`git diff examples/expected/ issues/expected/`) to confirm no error messages or empty output were captured.
### Adding a new language feature
@@ -461,19 +495,19 @@ There is no monolithic smoke file — each feature is its own focused example.
2. Run it: `./zig-out/bin/sx run examples/XXXX-<category>-<name>.sx`
3. Seed the marker and capture expected output:
`: > examples/expected/XXXX-<category>-<name>.exit` then
`bash tests/run_examples.sh --update`
4. Verify all tests still pass: `bash tests/run_examples.sh`
`zig build test -Dupdate-goldens`
4. Verify all tests still pass: `zig build test`
### Test file roles
| File | Purpose |
|------|---------|
| `examples/XXXX-category-name.sx` | Focused feature example — one feature per file. |
| `examples/expected/XXXX-category-name.{exit,stdout,stderr}` | Expected exit code + the two output streams. Regenerate with `--update`. |
| `examples/expected/XXXX-category-name.{exit,stdout,stderr}` | Expected exit code + the two output streams. Regenerate with `zig build test -Dupdate-goldens`. |
| `examples/expected/XXXX-category-name.ir` | Optional `sx ir` snapshot — present only where lowering shape is locked. |
| `issues/NNNN-slug.md` | Open-issue / bug-report writeup (mark RESOLVED in a banner when fixed; the `.md` stays). |
| `issues/NNNN-slug.sx` (+ `issues/NNNN-slug/`) | The issue's minimal repro, co-located with the `.md`. A repro with an `issues/expected/NNNN-slug.exit` marker runs in the suite; unpinned ones don't. |
| `tests/run_examples.sh` | Test runner. Scans `examples/` and `issues/`; compares stdout/stderr/exit (+ optional IR) per test. |
| `src/corpus_run.test.zig` | The corpus runner inside `zig build test` — spawns `sx` per example, diffs stdout/stderr/exit (+ optional IR); regenerates snapshots under `-Dupdate-goldens`. |
### Unit test file convention
@@ -496,8 +530,8 @@ All Zig unit tests live in separate `*.test.zig` files alongside the source they
open bug, `issues/NNNN-slug.{md,sx}` (repro co-located with the writeup).
2. Run it: `./zig-out/bin/sx run <path>.sx`
3. Seed the marker (`: > <root>/expected/<name>.exit`) and capture expected:
`bash tests/run_examples.sh --update`
4. Verify: `bash tests/run_examples.sh`
`zig build test -Dupdate-goldens`
4. Verify: `zig build test`
### Resolving an open issue
@@ -505,8 +539,8 @@ When a bug filed under `issues/NNNN-slug.{md,sx}` is fixed:
1. Move the repro into the feature suite as a regression test:
`git mv issues/NNNN-slug.sx examples/XXXX-<category>-<name>.sx`.
2. Seed `examples/expected/XXXX-<category>-<name>.exit`, capture with `--update`,
and review the diff.
2. Seed `examples/expected/XXXX-<category>-<name>.exit`, capture with
`zig build test -Dupdate-goldens`, and review the diff.
3. Tighten the example's comment header to describe the feature (keep a one-line
`Regression (issue NNNN)` note for provenance).
4. Mark `issues/NNNN-slug.md` RESOLVED with a short banner (root cause + fix +
@@ -536,17 +570,17 @@ The compiler shrinks to: parse → IR → codegen → link → invoke a sx
function. Codesigning / Info.plist / AndroidManifest / javac / d8 /
aapt2 / zipalign / apksigner / framework embed / entitlements / asset
trees all run in the IR interpreter post-link via libc / process.run
foreign calls.
extern calls.
| File | Role |
|------|------|
| [library/modules/platform/bundle.sx](library/modules/platform/bundle.sx) | All four targets (macOS, iOS sim, iOS device, Android). Branches on `BuildOptions.is_macos / is_ios / is_ios_device / is_ios_simulator / is_android` accessors. |
| [library/modules/fs.sx](library/modules/fs.sx) | POSIX file stdlib (open / read / write / copy / mkdir / unlink / chmod / rename / exists / basename / dirname). |
| [library/modules/process.sx](library/modules/process.sx) | popen-based `run(cmd) -> ?ProcessResult` + `env(name)` + `find_executable(name)`. |
| [library/modules/compiler.sx](library/modules/compiler.sx) | `BuildOptions` setters + accessors. Adding a new bundling parameter = add a setter here + a hook in compiler_hooks.zig. |
| [library/modules/std/fs.sx](library/modules/std/fs.sx) | POSIX file stdlib (open / read / write / copy / mkdir / unlink / chmod / rename / exists / basename / dirname). |
| [library/modules/std/process.sx](library/modules/std/process.sx) | popen-based `run(cmd) -> ?ProcessResult` + `env(name)` + `find_executable(name)`. |
| [library/modules/build.sx](library/modules/build.sx) | `BuildOptions` setters + accessors. Adding a new bundling parameter = add a setter here + a hook in compiler_hooks.zig. |
| [library/modules/platform/android.sx](library/modules/platform/android.sx) | `AndroidPlatform` (state-on-struct, no module globals). `sx_android_*` helpers take `plat: *AndroidPlatform` as first arg. `logical_w` field drives `dpi_scale = pixel_w / logical_w` so consumer's design-width fits any physical resolution. |
| [src/ir/compiler_hooks.zig](src/ir/compiler_hooks.zig) | `BuildConfig` + every `BuildOptions.*` hook. Hook registry is in `Registry.registerDefaults`. |
| [src/ir/host_ffi.zig](src/ir/host_ffi.zig) | `dlsym(RTLD_DEFAULT)` + arity-switched cdecl trampolines. Lets `#foreign("c")` decls resolve at `#run` / post-link time against host libc. |
| [src/ir/host_ffi.zig](src/ir/host_ffi.zig) | `dlsym(RTLD_DEFAULT)` + arity-switched cdecl trampolines. Lets `extern "c"` decls resolve at `#run` / post-link time against host libc. |
| [src/main.zig](src/main.zig) | After `target.link()`, threads target_triple + frameworks + jni_main emissions into BuildConfig, then invokes the post-link callback by FuncId (or by `<module>.bundle_main` name). `--bundle` / `--apk` flags feed `bundle_path`; auto-fallback to `post_link_module = "platform.bundle"` when bundle_path is set without a registered callback. |
Specifics in [specs.md §10.5](specs.md). The full bundling pipeline
@@ -554,7 +588,7 @@ spec — what runs per Apple target vs Android, what each accessor
returns, the BuildConfig forwarded from main.zig — lives there.
Wiring a new bundling step:
1. Add the parameter as a setter on `BuildOptions :: struct #compiler { ... }` in [library/modules/compiler.sx](library/modules/compiler.sx).
1. Add the parameter as a setter on `BuildOptions :: struct #compiler { ... }` in [library/modules/build.sx](library/modules/build.sx).
2. Add the `BuildConfig` field + setter hook + accessor hook in [src/ir/compiler_hooks.zig](src/ir/compiler_hooks.zig). Register both in `Registry.registerDefaults`.
3. Optionally forward a CLI flag in [src/main.zig](src/main.zig) before the post-link invocation.
4. Read the accessor from [library/modules/platform/bundle.sx](library/modules/platform/bundle.sx).
@@ -572,8 +606,16 @@ Wiring a new bundling step:
| `current/CHECKPOINT-LANG.md` | **Active** LANG progress tracker. Update after every step. |
| `current/PLAN-ERR.md` | **Active** ERR implementation plan (`!` errors, `try` / `catch` / `or` / `onfail`, return traces). |
| `current/CHECKPOINT-ERR.md` | **Active** ERR progress tracker. Update after every step. |
| `current/PLAN-STDLIB.md` | STDLIB restructure plan — **COMPLETE** (alias carry rule + std/ffi/math layout + full namespace tail). |
| `current/PLAN-CONST-AGG.md` | **Active** aggregate-consts + const-ness plan (array/struct `::` consts as immutable globals, const-write rejection, comptime folds, `*const`/`[]const` with full propagation, const decay/slicing). Progress tracked in its `## Status` section — no separate checkpoint file. |
| `implementation_plan.md` | Archive of completed work (closures, protocols, etc.). Do not pick up tasks from here. |
| `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/std.sx` | The prelude FACADE — pure re-exports (alias decls) over the part-files `std/core.sx` (builtins, libc escape hatch, Context/Allocator/Into/Source_Location/string), `std/fmt.sx` (print/format/*_to_string/string ops), `std/list.sx` (List) + the namespace tail (`mem`/`xml`/`log`/`fs`/`process`/`socket`/`json`/`cli`/`hash`/`test` carried to flat importers). No implementations live here. |
| `library/modules/std/` | Stdlib modules: core, fmt, list (the prelude part-files — consumers reach them through std.sx, not directly), mem (allocators), fs, process, socket, json, cli, hash, xml, log, trace, test — all but trace and the part-files carried by the std.sx tail; direct file imports give bare access. |
| `library/modules/ffi/` | FFI bindings: objc, objc_block, sdl3, opengl, raylib, stb, stb_truetype, wasm. |
| `library/modules/math/` | scalar / vector2 / matrix44 — one spelling: `#import "modules/math"` (directory import). |
| `library/modules/build.sx` | `BuildOptions` compile-time build DSL. See "Bundling lives in sx" above. |
| `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. |
| `library/modules/std/fs.sx`, `library/modules/std/process.sx` | POSIX stdlib for the bundler + general consumer use. |
| `tests/fixtures/` | Test-only import fixtures (testpkg/, test_c.sx) — resolve CWD-relative from the repo root, not via the stdlib search path. |

View File

@@ -193,28 +193,49 @@ pub fn build(b: *std.Build) void {
run_cmd.addArgs(args);
}
// Corpus paths for the LSP corpus-sweep test (src/lsp/corpus_sweep.test.zig).
// Inject absolute corpus dirs at configure time so the in-process analyzer
// sweep is CWD-independent; the test still ENUMERATES the directory
// contents at runtime (new examples are covered with no test edit).
// Corpus paths for the corpus tests (src/lsp/corpus_sweep.test.zig — the
// in-process analyzer sweep — and src/corpus_run.test.zig — the end-to-end
// example/issue runner). Inject absolute corpus dirs + the installed `sx`
// binary path at configure time so the tests are CWD-independent; the
// runner still ENUMERATES the directory contents at runtime, so new
// examples are covered with no test edit.
const corpus_opts = b.addOptions();
corpus_opts.addOption([]const u8, "examples_dir", b.path("examples").getPath(b));
corpus_opts.addOption([]const u8, "issues_dir", b.path("issues").getPath(b));
corpus_opts.addOption([]const u8, "library_dir", b.path("library").getPath(b));
// Absolute path to the installed `sx` binary the corpus runner spawns per
// example. The runner test depends on the install step (below) so this
// exists — and so the sibling library/ tree the binary loads is in place.
corpus_opts.addOption([]const u8, "sx_exe", b.getInstallPath(.bin, "sx"));
// `zig build test -Dupdate-goldens` flips src/corpus_run.test.zig from
// verify mode to regenerate mode: it overwrites each example's expected
// .exit/.stdout/.stderr (+ .ir where one exists) with freshly-normalized
// output instead of asserting against it. The in-build equivalent of the
// legacy `run_examples.sh --update`.
const update_goldens = b.option(
bool,
"update-goldens",
"Regenerate example/issue snapshots instead of verifying them (use with `zig build test`)",
) orelse false;
corpus_opts.addOption(bool, "update_goldens", update_goldens);
mod.addOptions("corpus_paths", corpus_opts);
const mod_tests = b.addTest(.{
.root_module = mod,
});
const run_mod_tests = b.addRunArtifact(mod_tests);
// src/corpus_run.test.zig spawns the installed `sx` binary per example, so
// the mod test binary must not run until `zig-out/bin/sx` + `zig-out/library`
// are installed. This is what folds the full example/issue regression suite
// into `zig build test` — no shell script, just a Zig test.
run_mod_tests.step.dependOn(b.getInstallStep());
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests");
const test_step = b.step("test", "Run unit tests + the example/issue regression suite");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
}

View File

@@ -319,3 +319,54 @@ extern "C" LLVMMemoryBufferRef sx_clang_compile_to_object(
llvm::StringRef(obj_buf.data(), obj_buf.size()), "c_import.o");
return llvm::wrap(buf.release());
}
/* ------------------------------------------------------------------ */
/* Exported-symbol scan over a compiled object buffer */
/* ------------------------------------------------------------------ */
#include <llvm/Object/ObjectFile.h>
extern "C" SxCSymbolList *sx_clang_object_exported_symbols(
LLVMMemoryBufferRef obj,
char **out_error)
{
using namespace llvm;
MemoryBufferRef ref(
StringRef(LLVMGetBufferStart(obj), LLVMGetBufferSize(obj)),
"sx-c-import-object");
auto objOrErr = object::ObjectFile::createObjectFile(ref);
if (!objOrErr) {
if (out_error)
*out_error = strdup(toString(objOrErr.takeError()).c_str());
return nullptr;
}
std::vector<std::string> names;
for (const object::SymbolRef &sym : (*objOrErr)->symbols()) {
auto flagsOrErr = sym.getFlags();
if (!flagsOrErr) { consumeError(flagsOrErr.takeError()); continue; }
uint32_t flags = *flagsOrErr;
if (flags & object::SymbolRef::SF_Undefined) continue;
if (!(flags & object::SymbolRef::SF_Global)) continue;
if (flags & object::SymbolRef::SF_FormatSpecific) continue;
auto nameOrErr = sym.getName();
if (!nameOrErr) { consumeError(nameOrErr.takeError()); continue; }
names.push_back(nameOrErr->str());
}
SxCSymbolList *list = (SxCSymbolList *)malloc(sizeof(SxCSymbolList));
list->num_names = (int)names.size();
list->names = (const char **)malloc(sizeof(char *) * (names.empty() ? 1 : names.size()));
for (size_t i = 0; i < names.size(); i++)
list->names[i] = strdup(names[i].c_str());
return list;
}
extern "C" void sx_clang_free_symbol_list(SxCSymbolList *list)
{
if (!list) return;
for (int i = 0; i < list->num_names; i++)
free((void *)list->names[i]);
free(list->names);
free(list);
}

View File

@@ -50,6 +50,19 @@ LLVMMemoryBufferRef sx_clang_compile_to_object(
const char **args, int num_args,
char **out_error);
/* --- Exported (defined, global) symbols of an object buffer --- */
typedef struct {
const char **names;
int num_names;
} SxCSymbolList;
SxCSymbolList *sx_clang_object_exported_symbols(
LLVMMemoryBufferRef obj,
char **out_error);
void sx_clang_free_symbol_list(SxCSymbolList *list);
#ifdef __cplusplus
}
#endif

394
current/CHECKPOINT-ASM.md Normal file
View File

@@ -0,0 +1,394 @@
# sx Inline Assembly — Checkpoint (ASM stream)
Companion to `current/PLAN-ASM.md`; design in
[design/inline-asm-design.md](../design/inline-asm-design.md). Update after every
commit, one step at a time per the cadence rule (no commit may both add a test
and make it pass).
## Last completed step
**G (indirect-memory `=*m` place outputs)** — the LAST substantive asm feature.
Unlike a write-through `=` output (which returns a value then stored), an
indirect output passes the place ADDRESS to the asm and the asm writes through
it — no return slot. `emitInlineAsm` (`src/backend/llvm/ops.zig`): indirect
outputs are excluded from the LLVM return type; their pointer is an opaque `ptr`
call arg placed **first** (arg-consuming constraint order = output-section
indirect pointers → inputs → read-write tied seeds); each gets an
`elementtype(T)` call-site attribute (required in the opaque-pointer era) via
`LLVMCreateTypeAttribute`/`LLVMAddCallSiteAttribute`; the store-back loop skips
them. New `asmIsIndirect(e, op)` helper. Lowering (`lowerAsmExpr`) stops
rejecting `*` (constraint kept verbatim, `=*m` reaches the constraint string
as-is). `asmOperandIndex` unchanged — indirect outputs still count as operands,
so `%[name]``${N}` holds. Verified by **running** on aarch64: store-through-
pointer (`str x9, %[out]` → 42, IR `"=*m,~{x9}"(ptr elementtype(i64) …)`) and a
mixed case (indirect + value output + input → `"=*m,=r,r"`, indirect ptr arg
first, `${0}/${1}/${2}` correct). Two commits per cadence: (1)
`examples/1652-platform-asm-indirect-mem.sx` locked the rejection; (2) implemented
+ flipped 1652 to a runnable aarch64-pinned example (`{ "target": "macos" }`,
ir-only elsewhere). `zig build test` green (661 corpus, 446 unit). Files:
`src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`, `examples/1652-*`.
Prior: **G (read-write `+` place outputs)** — a `+r` / `+{reg}` `-> @place` output is now
implemented. LLVM has no `+` constraint, so a
read-write place lowers to: an output **`=`** constraint (return slot, stored back
through the place after the call; the leading `+` rewritten to `=` in
`appendAsmConstraints`), **plus** a **tied input** (the decimal index of that
output) appended **after** the regular inputs, seeded with the place's loaded
value passed as a call arg. Tied inputs come **last** so existing operand indices
(`%[name]``${N}`) are undisturbed — `asmOperandIndex` unchanged. Lowering
(`lowerAsmExpr`) no longer rejects `+` (indirect `*` still rejected loudly).
`emitInlineAsm` (`src/backend/llvm/ops.zig`): grows arg/param arrays by the rw
count (`n_args = n_inputs + n_rw`), loads each seed (`asm.rw.seed`), emits the
tied constraint, and the existing store-back path writes the modified output back.
New `asmIsReadWrite(e, op)` helper. Verified by **running**: increment-in-place
(41→42, IR `"=r,0"`) and a mixed case (rw place + regular input + value output) →
textbook `"=r,=r,r,0"` with correct `${N}` indices and args `(input, seed)`. Two
commits per cadence: (1) `examples/1650-platform-asm-rw-place.sx` locked the
rejection; (2) implemented + flipped 1650 to a runnable aarch64-pinned example
(`{ "target": "macos" }`, ir-only elsewhere). `zig build test` green (658 corpus,
446 unit). Files: `src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`,
`examples/1650-*`.
Prior: **2**`-> @place` write-through outputs. An asm result can be **stored through
a place** (local / struct field) instead of returned; the place output does NOT
join the result tuple. Parser: `-> @place` parses the `@place` as an ordinary
address-of expression → an `out_place` operand (`src/parser.zig`). Lowering
(`lowerAsmExpr`): out_place operand = the lowered `@place` address, `out_ty` =
the pointee; read-write (`+`) and indirect-memory (`*`) constraints rejected
loudly (not yet implemented). Added `out_ty: TypeId` to the IR `AsmOperand`
(`src/ir/inst.zig`) so emit builds the **combined** return struct (ALL outputs).
`emitInlineAsm` rewrite (`src/backend/llvm/ops.zig`): the LLVM return type is now
built from every output's `out_ty`; after the call, out_place slots are
`store`d through their address and out_value slots rebuild the sx result — with a
**fast path** (no place outputs → the asm's struct return IS the result, so
pure-value asm IR is unchanged). Verified: write-to-local (`get42`→42), struct
field (`@p.b`), mixed value+place (`v=10 b=20`), `+` rejected. Locked with
`examples/1649-platform-asm-place-output.sx` (mixed, runs on aarch64). `zig build
test` green (657 corpus, 446 unit). Files: `src/parser.zig`, `src/ir/inst.zig`,
`src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`, `examples/1649-*`.
Prior: **F** — global (module-scope) asm. A top-level `asm { "tmpl", };` block (template
only) lowers to LLVM `module asm`, and a lib-less `extern` calls into the symbols
it defines. New `asm_global` AST node (`src/ast.zig`) + `parseAsmGlobal`
(`src/parser.zig`, dispatched from `parseTopLevel` on `kw_asm`) — rejects
`volatile` and any operands/clobbers. The node forced (and got) arms in the same
three `Node.Data` switches as `asm_expr` (`sema.zig` ×2, `semantic_diagnostics.zig`).
`Module` gains a `global_asm: ArrayList([]const u8)` (`src/ir/module.zig`);
`lowerMainAndComptime` captures each template (the dead `lowerDecls` is NOT the
top-level pass — `lowerRoot` Pass 2 uses `lowerMainAndComptime`); `emit_llvm.zig`'s
`emit()` appends each via `LLVMAppendModuleInlineAsm` (source order). Verified
end-to-end: an aarch64 `_my_add` global routine called via `extern` returns 42.
Locked with `examples/1648-platform-asm-global.sx`
(`.build { "aot": true, "target": "macos" }` → AOT build+run on aarch64, ir-only
elsewhere). `zig build test` green (656 corpus, 446 unit). **(Correction, later:
module asm ALSO runs under the JIT — `sx run` compiles to an in-memory object,
the integrated assembler assembles the `module asm` into it, ORC relocates and
runs it, so the symbol is resolvable at JIT main execution. The original "AOT
only" note was wrong; see 1653 for the JIT sibling. The genuine boundary is a
COMPILE-TIME `#run` call into a module-asm symbol, which fails loud via host
dlsym-miss — see 1654.)** Files: `src/ast.zig`, `src/parser.zig`, `src/sema.zig`,
`src/ir/semantic_diagnostics.zig`, `src/ir/module.zig`, `src/ir/lower/decl.zig`,
`src/ir/emit_llvm.zig`, `examples/1648-*`.
Prior: **E** — multi-output tuples. **Inline asm now returns tuples.** Replaced the
N>1 bail with a shared `asmResultType` helper (`src/ir/lower/expr.zig`, mixed
into `Lowering`) that derives the result type from the `out_value` operands
(0→void, 1→T, N→named tuple, named via the §II.5 effective-name rule). The key
realization: `toLLVMType(tuple)` already produces a literal struct `{T1,…,Tn}`
exactly LLVM's multi-output asm return — so **emit needed NO change**; building
the op with a tuple result type makes the asm call return the struct, which IS
sx's tuple value (destructured by the normal `tuple_get` path). `inferType`'s
`.asm_expr` arm now also delegates to `asmResultType` (single owner), so
`return asm`, `x := asm`, and `q, r := asm` all agree on the type. Verified
end-to-end on aarch64: `split(0x1234)``(lo=52, hi=18)`, a udiv/msub divmod→
`(3, 2)`. IR is textbook: `call { i64, i64 } asm "divq ${4}",
"={rax},={rdx},{rax},{rdx},r,~{cc}"(…)` → extractvalue → tuple. Converted 1640 to
the x86_64 multi-output IR lock (ir-only) + added `1647-platform-asm-aarch64-multi`
(runs on aarch64). `zig build test` green (655 corpus, 446 unit). Files:
`src/ir/lower/expr.zig`, `src/ir/lower.zig`, `src/ir/expr_typer.zig`,
`examples/164{0,7}-*`.
Prior: **C.1 + D** — inline asm CODEGEN (lowering builds the op + LLVM emit). **Inline
assembly now runs end-to-end.** `lowerAsmExpr` (`src/ir/lower/expr.zig`) stops
bailing: it resolves each operand's effective name (§II.5 auto-naming), interns
template/constraints/clobbers, lowers input `Ref`s, derives the result `TypeId`
(0→void, 1→T), and builds the `inline_asm` op. Added a `%[name]`-references-a-
real-operand check (the last deferred validation). Multi-output (N>1) still bails
loudly ("Phase E"). `emitInlineAsm` (`src/backend/llvm/ops.zig`, port of Zig's
`airAssembly`): assembles the LLVM constraint string (outputs→inputs→`~{clobber}`,
`,``|`), rewrites the template (`%[name]``${N}`, `%%``%`, `$``$$`, `%=`
`${:uid}`), then `LLVMGetInlineAsm` + `LLVMBuildCall2` (AT&T). Dispatch wired
(`emit_llvm.zig`, replacing the C.0 `@panic`). **`llvm_shim.c`**: added
`LLVMInitializeNativeAsmParser()` — the JIT must assemble inline asm at run time.
Verified end-to-end: aarch64 `add`/`mov` run on the host (exit 42), `nop volatile`
runs (1642 now exit 0), IR is textbook (`call i64 asm "add ${0},${1},${2}",
"=r,r,r"(…)`). Locked with `examples/1645-platform-asm-aarch64-add.sx` (runs on
aarch64, ir-only elsewhere via `.build` + `.ir`). Also added the `inferType`
`.asm_expr` arm (`src/ir/expr_typer.zig`, 0→void / 1→T) — without it a bare
`x := asm {…-> T}` binding inferred `.unresolved` and silently produced 0;
regression-locked with `examples/1646-platform-asm-value-binding.sx`. Updated
1640 (now Phase-E bail) + 1642 (now runs). `zig build test` green (654 corpus,
446 unit). Files: `src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`,
`src/ir/emit_llvm.zig`, `src/ir/expr_typer.zig`, `llvm_shim.c`,
`examples/164{0,2,5,6}-*`.
Prior: **C.0** — IR op `inline_asm` (lock; no behavior change). Added `inline_asm:
InlineAsm` to the IR `Op` union + the `InlineAsm` struct (`template: StringId`,
`operands: []const AsmOperand` {role/name/constraint/operand}, `clobbers:
[]const StringId`, `has_side_effects`) in `src/ir/inst.zig` — all strings
interned, operands in source order, result on `Inst.ty`. The new variant forced
(and got) arms in two exhaustive `Op` switches: `src/ir/interp.zig` (loud
`bailDetail` — inline asm is never comptime-evaluable) and `src/ir/print.zig`
(IR dump). `src/ir/emit_llvm.zig` gets a `@panic` **tripwire** — emit lands in
Phase D, and until then `lowerAsmExpr` still bails so no `inline_asm` op is ever
created (reaching emit would be a lowering-switched-over-too-early bug). Unit
test `inline_asm op shape` in `src/ir/inst.test.zig`. `zig build test` green
(652 corpus, 446 unit). Files: `src/ir/inst.zig`, `src/ir/interp.zig`,
`src/ir/print.zig`, `src/ir/emit_llvm.zig`, `src/ir/inst.test.zig`.
Prior: **B.1** — operand-name validation (design §II.5 auto-naming rule). Extended
`lowerAsmExpr` with a `pinnedRegister(constraint)` helper (`"={eax}"``eax`,
`"+{rax}"``rax`, `"=r"`→null) and two checks: (1) **reject the echo form**
`[eax] "={eax}"` — a label identical to its own pinned register is redundant
(the operand is already auto-named after the register); (2) **reject duplicate
operand names** (ambiguous `%[name]` / result field). Locked with
`examples/1643-platform-asm-echo-name.sx` + `1644-platform-asm-duplicate-name.sx`.
`zig build test` green (652 corpus, 0 failed; 445 unit). Files:
`src/ir/lower/expr.zig`.
Prior: **B.0** — asm shape validation (compile-path diagnostics). Restructured the
`.asm_expr` lowering arm into `lowerAsmExpr` (`src/ir/lower/expr.zig`, mixed into
`Lowering` in `src/ir/lower.zig`): it validates BEFORE the not-yet-implemented
codegen bail, so the user sees the real problem first. Two checklist items now
enforced with named diagnostics: (1) **template must be a compile-time-known
string** (`"..."` / `#string`); (2) **no value outputs ⇒ must be `volatile`**
(mirrors Zig — a result-less asm could be deleted). Valid shapes still bail with
the "codegen not yet implemented" message. Result-type derivation + auto-naming
stay deferred to a later step (observable only once Phase C produces a real IR
op). Locked with `examples/1641-platform-asm-missing-volatile.sx` (volatile
error) + `1642-platform-asm-nop-volatile.sx` (volatile no-output accepted →
codegen bail). `zig build test` green (650 corpus, 0 failed; 445 unit). Files:
`src/ir/lower/expr.zig`, `src/ir/lower.zig`, `examples/164{1,2}-*`.
Prior: **A.1** — parse `asm { … }` + loud lowering bail (folded A.1+A.2 into one honest
lock commit, since the loud bail IS current correct behavior — cadence option
(a)). Added `AsmExpr`/`AsmOperand` to `src/ast.zig` + the `asm_expr` `Node.Data`
arm; `parseAsmExpr` in `src/parser.zig` (`parsePrimary` `.kw_asm` dispatch) —
parses the template, flat operand list (`[name]? "constraint" -> Type` value
output / `= expr` input), and `clobbers(.…)`; `volatile`/`clobbers` recognized
contextually via `isContextualWord`. The new `asm_expr` tag forced (and got)
arms in three exhaustive `Node.Data` switches: `src/sema.zig` `analyzeNode` +
`findNodeAtOffset`, `src/ir/semantic_diagnostics.zig` `checkBindingNames` (all
recurse into template + operand payloads). Lowering bails LOUD + named in
`src/ir/lower/expr.zig` ("inline assembly codegen is not yet implemented…") via
an explicit `.asm_expr` arm (not the generic `unknown_expr` else) returning
`emitPlaceholder`. `-> @place` write-through is rejected with a clear "Phase 2"
parse error. Locked with `examples/1640-platform-asm-parse.sx` (multi-output
`divmod`, named operands, register pins, clobbers — parses then bails; called
from `main`). `zig build test` green (648 corpus, 0 failed; 445 unit). Files:
`src/ast.zig`, `src/parser.zig`, `src/sema.zig`, `src/ir/semantic_diagnostics.zig`,
`src/ir/lower/expr.zig`, `examples/1640-*`.
Prior: **A.0**`kw_asm` keyword (first compiler code). Added the `kw_asm` `Token.Tag`
variant + `.{ "asm", .kw_asm }` keyword-map entry in `src/token.zig`; `volatile` /
`clobbers` deliberately stay OUT of the global table (contextual). New exhaustive
`Tag` switch in `src/lsp/server.zig` `classifyToken` flagged the missing arm (the
intended coverage tripwire) — added `.kw_asm` to the keyword group. Lock test in
new `src/lexer.test.zig` (`asm``kw_asm`, `volatile`/`clobbers``identifier`),
wired into the `src/root.zig` barrel as `lexer_tests`. `zig build test` green (648
corpus, 0 failed; 445 unit, 0 failed — +1). Files: `src/token.zig`,
`src/lexer.test.zig`, `src/root.zig`, `src/lsp/server.zig`.
Prior: **0.2** — CLAUDE.md docs for `<name>.build`; **Phase 0 COMPLETE**.
**0.1** — corpus runner **ir-only branch** for cross-target examples. Replaced
0.0's loud placeholder bail: when `cfg.target` doesn't match the host (`ir_only`),
`sweepRoot` skips run/build/exec and verifies via `sx ir --target` only —
asserting `.exit` (ir cmd) + `.ir` (normalized stdout) + `.stderr`, never
`.stdout` (write skipped in update mode, assertion skipped in verify mode). An
`.ir` snapshot is **required** in ir-only mode — its absence is a loud failure
("needs an .ir snapshot for ir-only mode"). Locked with
`examples/1639-platform-target-cross.sx` (asm-free `main :: () -> i64 { return 0;
}`), `.build` `{ "target": "x86_64-linux" }`, + checked-in `.ir`. Verified both
guards fire: corrupting the `.ir` → IR mismatch; deleting it → the require-failure.
`zig build test` green (647 corpus, 0 failed; 444 unit). Files:
`src/corpus_run.test.zig`, `examples/1639-*`.
## Current state
**Inline assembly works end-to-end: 0, 1, and N value outputs (tuples).** Full
pipeline: lex (A.0) → parse (A.1) → validate (B.0/B.1 + `%[name]` check) → IR op
(C.0) → lower-builds-op + LLVM emit + JIT asm-parser init (C.1/D) → multi-output
tuples (E). Register-class + register-pinned operands, inputs, **symbol operands
(`"s"` → direct `bl`/`call` to a function/global by mangled name)**, clobbers,
`#string` multi-instruction templates, `%[name]`/`%%` rewriting, and the §II.5
auto-naming rule all work and execute on the host JIT. Global `asm { … }` (Phase F) works via
lib-less `extern` under BOTH the JIT (`sx run` → 1653) and AOT (1648) — `sx run`
compiles to an object, so the integrated assembler bakes the `module asm` symbol
in and ORC resolves it. All three `-> @place` output forms now work and execute
on aarch64: **write-through** `=` (Phase 2), **read-write** `+` (tied input), and
**indirect-memory** `=*m` (pointer arg + `elementtype`, asm writes through it).
**Inline assembly is now feature-complete — no substantive features remain.** The
x86_64 syscall-write ir-only example is DONE (1651). Global asm runs under both
JIT (1653) and AOT (1648). `readme.md` now has an "Inline Assembly" section.
Known orthogonal bug: **issue 0137**`sx run` on a program with no `main`
segfaults (`src/target.zig:256-273`, unguarded JIT entry lookup). Pre-existing,
asm-independent; does NOT block the ASM stream (every example has a `main`).
Phase EF feasibility already confirmed against the live tree
(`LLVMGetInlineAsm` / `LLVMBuildCall2` / `LLVMAppendModuleInlineAsm` in LLVM@19
`Core.h`; ERR-stream `extractvalue`→tuple in `emit_llvm.zig:726-927`; lib-less
`extern`, 60 sites; `--target` a global CLI flag).
## Next step
**Inline assembly is feature-complete.** All substantive features are done:
0/1/N value outputs (tuples), register-class + pinned operands, inputs, clobbers,
`#string` templates, `%[name]`/`%%`/`$`/`%=` rewriting, §II.5 auto-naming, global
`asm { … }` (AOT), and all three `-> @place` output forms — write-through (`=`),
read-write (`+`), and indirect-memory (`=*m`). The x86_64 syscall-write ir-only
example (1651) and the output-to-`const` rejection (issue 0138) are also done.
Global asm runs under BOTH the JIT (`sx run` → object → ORC; 1653) and AOT (1648)
— the earlier "AOT only / `sx run` mishandles module-asm" note was stale and has
been corrected. The one genuine boundary is a COMPILE-TIME `#run` into a
module-asm symbol: the interpreter resolves externs via host dlsym, the symbol
isn't linked yet, so it already fails loud (`comptime extern call: symbol not
found via dlsym`) — pinned by 1654.
Remaining work, all **polish** (optional):
- None substantive. Possible niceties: tighten the `#run`-into-module-asm error
text to name module-asm specifically; broaden clobber validation to a checked
per-arch enum (design doc Phase 4).
Orthogonal: **issue 0137** (no-`main` JIT segfault).
Done since last: output-to-`const` rejection (issue 0138), x86_64 syscall-write
ir-only example (1651).
Orthogonal: **issue 0137** (no-`main` segfault).
## Log
- (init) Plan + design doc written; ASM stream opened.
- (0.0) Corpus runner target-gating: `<name>.build` JSON config (replaces `.aot`
marker), `--target` threading, `hostMatchesTarget` execute-gate, loud
cross-target placeholder bail. Migrated 1226/1227 `.aot``.build`; locked with
1638 fixture + unit tests. `zig build test` green.
- (0.1) ir-only branch: cross-target examples verify via `sx ir --target` only
(exit+ir+stderr, no stdout; `.ir` required). Locked with 1639 fixture; verified
corrupt-.ir → mismatch and missing-.ir → loud failure. `zig build test` green.
- (0.2) docs: CLAUDE.md documents `<name>.build` JSON sidecar (aot + target +
ir-only gating), replacing stale `.aot` marker prose. **Phase 0 COMPLETE.**
- (A.0) `kw_asm` keyword in token.zig (+ map entry); LSP `classifyToken` switch
coverage; lock test in new `lexer.test.zig` (wired via root.zig). `volatile` /
`clobbers` stay contextual identifiers. `zig build test` green (445 unit, +1).
- (A.1) parse `asm { … }``AsmExpr` + loud lowering bail; `asm_expr` arms in 3
exhaustive `Node.Data` switches; `-> @place` rejected (Phase 2). Adopted operand
auto-naming rule (design §II.5). Locked with 1640 fixture. Filed orthogonal
issue 0137 (no-`main` JIT segfault). `zig build test` green (648 corpus, 445 unit).
- (B.0) asm shape validation in `lowerAsmExpr`: comptime-string template +
no-output⇒volatile, with named diagnostics before the codegen bail. Locked with
1641 (volatile error) + 1642 (volatile accepted). `zig build test` green (650
corpus, 445 unit).
- (B.1) operand-name validation: `pinnedRegister` helper + reject echo form
(`[eax] "={eax}"`) and duplicate names. Locked with 1643 + 1644. `zig build
test` green (652 corpus, 445 unit).
- (C.0) IR op `inline_asm: InlineAsm` + interp `bailDetail` + print arm + emit
`@panic` tripwire (Phase D). No behavior change (lowering still bails). Unit
test `inline_asm op shape`. `zig build test` green (652 corpus, 446 unit).
- (C.1+D) CODEGEN — `lowerAsmExpr` builds the op (effective names, interned
strings, input Refs, 0/1 result type) + `%[name]` validation; `emitInlineAsm`
(constraint string + template rewrite + `LLVMGetInlineAsm`/`BuildCall2`, AT&T);
`inferType` arm; `LLVMInitializeNativeAsmParser` for the JIT. **Inline asm runs
end-to-end.** N>1 bails (Phase E). Locked with 1645 (aarch64 add, runs) + 1646
(`:=` binding); updated 1640/1642. `zig build test` green (654 corpus, 446 unit).
- (E) multi-output tuples — `asmResultType` helper (0→void/1→T/N→named tuple),
shared by lowering + `inferType`. `toLLVMType(tuple)` == LLVM multi-output
struct, so emit unchanged; the asm struct return IS the sx tuple. Runs on
aarch64 (1647: `split``(lo,hi)`); 1640 → x86 multi-output IR lock (ir-only).
`zig build test` green (655 corpus, 446 unit).
- (F) global asm — `asm_global` AST node + `parseAsmGlobal` (top-level, rejects
volatile/operands); `Module.global_asm` captured in `lowerMainAndComptime`;
`emit()` appends via `LLVMAppendModuleInlineAsm`; call-into via lib-less
`extern`. AOT-verified (1648, `_my_add`→42). `zig build test` green (656 corpus).
- (docs) readme.md "Inline Assembly" section (b8800a2).
- (2) `-> @place` write-through — `out_place` operand; `out_ty` on the IR
AsmOperand; `emitInlineAsm` builds the combined output struct + splits
(out_place → store-through, out_value → result), fast-path when no places.
`+`/`*` rejected. Locked with 1649 (mixed, runs). `zig build test` green (657
corpus, 446 unit).
- (G) read-write `+` place outputs — `+` lowers to an output `=` + a tied input
(output-index constraint) seeded with the place's loaded value, tied inputs
appended last (operand indices undisturbed). `appendAsmConstraints` rewrites
`+``=`; `emitInlineAsm` grows args by the rw count + loads seeds;
`asmIsReadWrite` helper. Lowering stops rejecting `+` (`*` still rejected). Two
commits (cadence): 1650 locked the rejection, then flipped to a runnable
aarch64 example (`"=r,0"` IR). `zig build test` green (658 corpus, 446 unit).
- (0138) output-to-`const` rejection — fixed the underlying general bug: scalar
`@const` (address-of a folded `::` constant) reinterpreted the value as a
pointer (`inttoptr`). `src/ir/lower/expr.zig` `.address_of` now diagnoses a
scalar const (local + module) instead of falling through; array/struct consts
keep storage. asm `-> @const` gets the clean diagnostic for free (same path).
Regression `examples/1177-diagnostics-addr-of-const-rejected.sx`. Issue 0138
RESOLVED. `zig build test` green (659 corpus, 446 unit).
- (x86 syscall) x86_64 Linux `write(2)` via raw `syscall` — locks the constraint
string `={rax},{rax},{rdi},{rsi},{rdx},~{rcx},~{r11},~{memory}` (register-pinned
inputs + pinned value output + pointer input + clobbers). ir-only on aarch64
(`.ir` asserted), runs on x86_64-linux (hand-authored `"ok\n"` stdout).
`examples/1651-platform-asm-x86-syscall-write.sx`. Pure additive lock, no
compiler change. `zig build test` green (660 corpus, 446 unit).
- (G indirect) indirect-memory `=*m` place outputs — the place address is passed
as an opaque `ptr` arg (with an `elementtype(T)` call-site attr), placed before
inputs; asm writes through it; no return slot; store-back skips it.
`asmIsIndirect` helper; lowering stops rejecting `*`. Verified by running on
aarch64 (store-through → 42; mixed indirect+value+input → `"=*m,=r,r"`). Two
commits (cadence): 1652 locked the rejection, then flipped to a runnable aarch64
example. **Inline asm now feature-complete.** `zig build test` green (661 corpus,
446 unit).
- (jit) explored "asm in JIT": found it ALREADY works — `sx run` emits an
in-memory object (integrated assembler bakes in both in-function inline asm and
`module asm`), then ORC relocates+runs it. The stale "AOT only / `sx run`
mishandles module-asm" checkpoint prose was corrected. Locked global-asm-under-
JIT with `examples/1653-platform-asm-global-jit.sx` (`{ "target": "macos" }`, no
aot, → 42). `zig build test` green (662 corpus, 446 unit).
- (comptime guard) pinned the one genuine module-asm boundary:
`examples/1654-platform-asm-global-comptime-call.sx``#run` into a module-asm
symbol fails loud (`comptime extern call: symbol not found via dlsym`) because
the interpreter resolves externs via host dlsym before link. Arch-independent
(no `.build`). `zig build test` green (663 corpus, 446 unit).
- (round trip) `examples/1655-platform-asm-callback-into-sx.sx` — global-asm
trampoline that `bl _cb` back into an `export`ed sx function (sx→asm→sx, → 42).
Documented that `export` (external linkage + C symbol + C ABI) is what makes
the callback resolvable; `callconv(.c)` alone leaves it `internal` (DCE'd).
`zig build test` green (664 corpus, 446 unit).
- (symbol ops) symbol operands (`"s"`) — feed a function/global symbol; the
template emits its platform-mangled name so `bl %[fn]` is a DIRECT branch (one
fewer indirection than register-indirect `blr`, portable — no hardcoded `_`).
Emit passes the operand with its own llvm type (LLVMTypeOf), no coercion
(`asmIsSymbol` helper); lowering lowers the function RHS to `ptr @fn`. Decided
AGAINST mirroring Zig (which has no symbol operand — 483 std asm sites, none
call a function) because the direct `bl` matters. Two commits (cadence): 1656
locked the rejection (replacing an LLVM-verifier crash), then implemented +
flipped to a runnable aarch64 example (objdump-confirmed direct `bl <_cb>`).
`zig build test` green (665 corpus, 446 unit).
- (x86 cross-arch) ir-only x86_64 siblings so each emit path is locked on BOTH
arches: 1657 read-write (`"incq ${0}","=r,0"`), 1658 indirect (`"movq $$42,
${0}","=*m"`(ptr elementtype)), 1659 symbol (`"call ${2:P}"`, direct call). x86
templates validated by cross-emitting an object (integrated assembler accepts;
objdump confirms 1659's direct `call` reloc). Pure additive locks. `zig build
test` green (668 corpus, 446 unit).
- (symbol portability) made `%[fn]` portable across arches — `renderAsmTemplate`
auto-injects LLVM's `:c` modifier (`${N}``${N:c}`) for symbol (`"s"`) operands
lacking an explicit modifier (`asmNamedIsSymbol` helper). Without it x86 renders
`$cb` (a bad `call` target needing a hand-written `:P`); aarch64 unaffected.
Verified `:c``:P` for x86-64 calls (both → `R_X86_64_PLT32`). Explicit
`%[fn:X]` still wins (escape hatch). 1659 dropped its `:P` → same plain `%[fn]`
as aarch64 1656; both IRs regen to `${N:c}`. `zig build test` green (668 corpus,
446 unit).
## Known issues
- **0138** — RESOLVED. `@const` (address-of a `::` comptime constant) yielded a
wild pointer (`inttoptr (i64 <value> to ptr)`). Fixed by diagnosing scalar
`@const` in `src/ir/lower/expr.zig` `.address_of` (no storage; array/struct
consts unaffected). Delivered the ASM "output-to-`const` rejection" for free.
Regression `examples/1177-diagnostics-addr-of-const-rejected.sx`.
- **0137** — `sx run` on a program with no `main` segfaults (unguarded JIT entry
lookup, `src/target.zig:256-273`). Pre-existing, asm-independent. Filed
`issues/0137-jit-run-no-main-segfault.md`. Does not block A.1.

View File

@@ -0,0 +1,861 @@
# sx `extern`/`export` + `#foreign` retirement — Checkpoint (FFI-linkage stream)
Companion to `current/PLAN-EXTERN-EXPORT.md` — one merged plan: **Part A** adds
`extern`/`export`, **Part B** migrates `#foreign` and purges `foreign`. Update after
every commit, one step at a time per the cadence rule.
## Last completed step
**Phase 9 COMPLETE — total `foreign` purge; 9.4 GATE PASSES.** **THE ENTIRE
FFI-LINKAGE STREAM (Parts A + B, Phases 09) IS DONE.** Final commits: 9.0 token
delete (`dfae690`), 9.3 src/docs/example/library comment purge (`811a280`, `e99383f`,
`dc51c4b`, + the capital-Foreign sweep), 9.3 example filename renames + dedup
(`b52d424`), 9.3/9.4 issues/*.md purge + GATE (`b9cfe25`).
- **9.0:** deleted the `hash_foreign` token entirely (token/lexer/parser/lsp + the lex
test); `#foreign` now → a generic "expected ';'" parse error (accepted UX cost);
deleted the obsolete 1176 rejection test.
- **9.1/9.2:** all internal identifiers renamed (linkage→`extern`/`is_extern`,
runtime-class→`Runtime*`/`runtime_*` per Decision 5, `foreign_path``runtime_path`
across the build-hook boundary); `foreign_expr` node eliminated.
- **9.3:** purged every `foreign` COMMENT (src caps + lowercase, examples, docs incl.
the obsolete inline-asm Deviation 6, editors/vscode grammar) + renamed all 10
`*-foreign*` example files (+ companions/expected/refs) to extern/runtime names
(dedup'd 1218↔1229, removed orphan 1620 dir) + rewrote 20 issues/*.md writeups +
renamed issues/0043.
- **9.4 GATE:** `grep -rniIE 'foreign' src/ library/ examples/ issues/ docs/ editors/
specs.md readme.md CLAUDE.md` → **0**, excluding only the legitimate keeps:
`SQLITE_CONSTRAINT_FOREIGNKEY` (SQLite API const) + vendored `library/vendors/sqlite/
c/*` (upstream third-party C). No `foreign`-named files in the tree (node_modules +
.sx-tmp are gitignored third-party/scratch). Suite green (644 corpus / 443 unit, 0
failed).
### Prior: Phase 9.3 — text/comment purge (src + docs + example comments) (commits
`e99383f` docs, `dc51c4b` src, + examples purge STAGED pending a classifier outage —
commit message ready; `git commit` the staged `examples/` changes when Bash is back).
`foreign` is now purged from: **all `src/` comments** (reworded to extern/runtime-class;
fixed 2 user-facing diagnostics — the type-annotation parse error no longer lists
`#foreign`, and the Android no-`#jni_main` help shows `#jni_class(…) extern`), **specs/
readme/CLAUDE** ("Foreign Function Interface"→"C Interop", etc.), and **all example .sx
comments** (1219 stdout labels Foreign→Extern, snapshot regenerated). Suite green
(646/444) throughout; snapshot-neutral except the intentional 1219 regen.
**What still contains `foreign` (the analyzed keep-list + the not-yet-done):**
- **KEEP (gate-exempt):** `src/` `hash_foreign` token + lexer entry + `lex hash_foreign`
test (`#foreignx`) + the 4 parser rejection messages ("`#foreign` has been removed…");
`1176-diagnostics-foreign-removed.sx` (its `#foreign` decl + comments ARE the rejection
test); `SQLITE_CONSTRAINT_FOREIGNKEY` + vendored `library/vendors/sqlite/c/*`.
- **NOT YET DONE:** example FILENAMES (`*-foreign*.sx` + the `0729`/`1205`/`1218`/`1219`/
`1306`/`1318`/`1216`/`1217` families) and their `#import`/`#include`/`#source` path refs
+ `expected/` files — needs a git-mv rename step; and **`issues/*.md`** (~20 writeups).
### Prior: Phase 9.1 + 9.2 — internal IDENTIFIER purge COMPLETE (commits 9.1a `b838f63`,
9.1b `b78e7dd`, 9.1c `cd14794`, 9.1d `7ffdc7d`, 9.2a `3354446`, 9.2b `5c8af6e`,
9.2b-fix `a15a868`, 9.2c `d27be42`, 9.2d `8cca3b9`). **Every `foreign` IDENTIFIER in
`src/` is renamed** — the only `foreign` left in `src/` is COMMENTS + the kept token
(`hash_foreign` + its `#foreignx` lexer-boundary test) + the rejection-message string.
Suite green (646/444) at every commit.
- **9.1d** eliminated the `foreign_expr` AST node: migrated `c_import.zig` auto-synth
to the extern shape, deleted the node + `ForeignExpr` + all readers.
- **9.2a/b/c/d** ran the runtime-class family rename (Decision 5 → `Runtime*`):
types `ForeignClassDecl`→`RuntimeClassDecl` etc.; fns `parseForeignClassDecl`→
`parseRuntimeClassDecl`, `lowerForeignMethodCall`→`lowerRuntimeMethodCall`, …; state
`foreign_class_map`→`runtime_class_map`, `foreign_class_decl` variant→
`runtime_class_decl`; the extern-ref validators → `Extern` (linkage, `checkExternRefs`);
the reference flag → **`is_extern`** (per user: reuse existing terminology, not a new
`is_reference`); and `foreign_path`→`runtime_path` COUPLED across the hook boundary
(build.sx accessor `jni_main_runtime_path_at` + the registered hook string +
bundle.sx + specs.md), with 37 `.ir` snapshots regenerated for the renamed
`@BuildOptions.jni_main_runtime_path_at` declare stub (symbol-name change only).
- **9.1a/b/c** (linkage): 5 collision-free renames (callExtern, …); "foreign symbol"
diagnostic + panic → "extern symbol" (1172 regen); deleted dead VarDecl legacy fields.
### Prior: Phase 9.1 (partial) — internal linkage-identifier purge (commits `b838f63` 9.1a,
`b78e7dd` 9.1b, `cd14794` 9.1c). **PHASE 9 STARTED.** Decision 6 = PURGE EVERYTHING,
scoped (user, 2026-06-15): purge `foreign` from **all `.sx` files + all documentation +
all our Zig (`src/`)**, analyzing each grep hit — **legitimate keeps stay**
(SQLITE_CONSTRAINT_FOREIGNKEY + other SQLite API constant names, vendored
`library/vendors/sqlite/c/*`, `1176-diagnostics-foreign-removed.sx` [the rejection test
MUST contain `#foreign`], the parser rejection-message string + `hash_foreign` token
[kept so `#foreign` keeps its friendly deprecation error]).
- **9.1a** (`b838f63`): 5 collision-free linkage renames — `callForeign`→`callExtern`,
`marshalForeignArg`→`marshalExternArg`, `dedupeForeignSymbol`→`dedupeExternSymbol`,
`foreign_name_map`→`extern_name_map`, `is_foreign_c_api`→`is_extern_c_api`.
- **9.1b** (`b78e7dd`): the "foreign symbol already bound" diagnostic (decl.zig) +
resolveFuncByName panic (call.zig) → "extern symbol". Intentional 1172 regen.
- **9.1c** (`cd14794`): **deleted** the dead `VarDecl.is_foreign`/`foreign_lib`/
`foreign_name` fields (the global `#foreign` path rejects → write-dead; 3 coalescing
readers in decl.zig simplified to `vd.extern_name`/`vd.is_extern`).
All snapshot-neutral except the intentional 1172 regen; suite green (646/444).
**COLLISION ANALYSIS (done — drives the rest of 9.1/9.2):**
- `is_foreign` lives on FnDecl?(no — flipped to `extern_export`), **VarDecl (deleted in
9.1c)**, and **ForeignClassDecl (ast.zig:903 — STILL LIVE**, distinguishes runtime-class
reference vs define; renamed in 9.2, not 9.1).
- `is_extern`/`extern_lib`/`extern_name` already exist (VarDecl + IR insts) — so the
old `foreign_*` linkage names could NOT be blind-renamed onto them; 9.1c deleted the
dead VarDecl trio instead of renaming.
- `foreign_expr` (25) is **still BUILT by `c_import.zig` auto-synthesis** (`#import c
{#include}` synthesizes fn bodies as `foreign_expr`). To eliminate it: migrate that
synth path to build the extern shape (empty-block body + `extern_export = .extern_`),
exactly the Phase 5.0 fn-body flip but for auto-synth — THEN delete the `foreign_expr`
node + all readers. This is the last 9.1 item.
### Prior: Phase 8 — CUTOVER: parser hard-rejects `#foreign` (`feat!` commit `3811311`,
preceded by the 8.0 xfail `8180faf` + 3 pre-cutover `refactor`s `2cce6a3`/`720556b`/
`d132aab`). **PHASE 8 COMPLETE.** The prefix `#foreign` linkage directive is removed:
all four parse sites (const-with-type 316, data global 425, fn body 2065, runtime-class
prefix via caller 260) reject it with the migration message *"`#foreign` has been
removed; use the postfix `extern` (import) / `export` (define) linkage keyword
instead"*; added a span-aware `failAt` for the runtime-class case (the lookahead
consumes the token before the reject decision). New example **1176**
(`diagnostics-foreign-removed`) pins it. **Pre-cutover migrations** (all green,
behavior-preserving): the 7 identity `ffi-foreign-*` test DECLS (`2cce6a3`), the two
keyword-neutral diagnostic tests 1172 + 1228 with intentional snapshot regens
(`720556b`), and the 4 multi-file example companions Phase 7 missed (0729/a+b, 1617/c,
1623/mod — `d132aab`). **Deleted** obsolete tests 1174 (`#foreign`+postfix conflict, now
unreachable) + 1620 (`#foreign nosuchunit`, superseded by extern twin 1231), the GATE
A→B unit test + `lowerSrcToIr` helper (nothing left to compare), and converted the
in-source `parse void function with foreign body` parser test to postfix `extern`.
specs.md + readme.md document `extern`/`export` as the sole C-linkage surface. Suite
green (646 corpus / 444 unit, 0 failed).
### Prior: Phase 7.4 — migrate straggler examples `#foreign`→`extern` (`refactor` commit
`1a8991a`). **PHASE 7 MIGRATABLE WORK COMPLETE (7.17.4 done).** Migrated 16 fn/global
examples across categories (0415/0602/0603/1024/1025/1605/1607-1609/1611/1616/1619/
1622/1628/1635/1636). Marker'd ones corpus-validated; the 3 unmarked uikit importers
(1607/1608/1616) verified byte-identical via `sx ir` probes. Empty snapshot diff; suite
green (647/444).
**Phase 7 net result:** every example that uses `#foreign` *incidentally* (FFI plumbing,
output-preserving) is now on `extern`/`export`. The **24 files still holding `#foreign`
are exactly the intended keep-list**, all deferred to the Phase 8 cutover:
- **`foreign`-asserting diagnostics** (migrating changes a snapshot): 1172, 1174, 1219
(stdout label), 1228 (equivalence test), 1620.
- **Identity `ffi-foreign-*` feature tests** (real decls; rename/dedup at cutover):
1205-global, 1205-global-helper, 1207, 1218, 1219, 1306, 1318.
- **Comment-only / provenance prose** (decls=0; `#foreign` only in comments): 0716, 0729,
1216, 1223, 1224, 1225, 1229, 1230, 1231, 1332, 1348, 1349, 1426, + issues/0030.
**Lesson (7.3):** the robust class-prefix transform is the GENERAL form
`s/#foreign\s+#(\w+)\((\"[^\"]*\")\)\s*\{/#$1($2) extern {/` — 1417 also used
`#jni_interface`/`#objc_protocol`/`#swift_class`/`#swift_struct`/`#swift_protocol`, and a
`#(objc|jni)_class`-only regex left `extern` in *prefix* position → parse error. All such
directives accept the postfix modifier (probed). Bare defined `#objc_class`/`#jni_class`
examples (no `#foreign`) were left untouched — not a purge target (define→export is an
optional consistency pass, deferrable).
### Prior: Phase 7.1 — migrate incidental 12xx ffi examples (`refactor` commit `731fb8d`).
Migrated 12 plain-C examples (1200/1206/1209-1215/1220/1221/1222); established the
keep-list policy above. Phase 7.2 (`a68f7c2`): 18 13xx obj-c examples (prefix→postfix
classes). Phase 7.3 (`2888f6f`): 13 14xx jni examples incl. 1417 multi-runtime.
### Prior: Phase 6.5 — migrate `gpu/` `#foreign`→`extern`; `library/` now `#foreign`-free
(`refactor` commit `32a7628`). **PHASE 6 COMPLETE.** Final batch: gpu/gles3.sx
(eglGetProcAddress + 1 comment) + gpu/metal.sx (MTLCreateSystemDefaultDevice), bare
fn markers → `extern`. Verified byte-identical `sx ir` on importers 1610 (gles3) +
1606 (metal). **Zero `#foreign` remains anywhere under `library/`** — verified by
`grep -rln '#foreign' library/` → no matches. Suite green (647 corpus / 444 unit, 0
failed).
### Prior: Phase 6.4 — migrate `ffi/` `#foreign`→`extern` (`refactor` commit `666a2e2`).
objc/objc_block/raylib/sdl3/wasm (~51 sites): fn markers + objc.sx's 2 import runtime
classes (prefix→postfix `extern`). objc + objc_block validated by the 50 marked 13xx
corpus examples (incl. import classes 1300/1301 + defined classes 1339/1349);
raylib/ffi-sdl3/wasm verified by byte-identical `sx ir` probes pre/post. Empty snapshot
diff; suite green.
### Prior: Phase 6.3 — migrate `std/` `#foreign`→`extern` (`refactor` commit `59f90d2`).
Pure source rename across 11 std modules (~60 sites):
cli/core/fmt/fs/log/net.kqueue/process/socket/thread/time/trace. All fn-decl markers
— bare `#foreign;`, `#foreign libc;`/`#foreign tlib;` (LIB ref), and
`#foreign libc "csym";` (LIB+rename) → the same `extern …` tail (`extern` carries the
identical `[LIB] ["csym"]` axis); plus 2 stale comment mentions (fmt/fs). No class
forms in std. These modules ARE host-corpus-exercised → empty snapshot diff is direct
validation. Suite green (647 corpus / 444 unit, 0 failed). Remaining Phase 6 batches:
6.4 ffi (~50, has runtime classes), 6.5 remainder.
### Prior: Phase 6.2 — migrate `platform/` `#foreign`→`extern`/`export` (`refactor` commit
`2cd5d7b`). Pure source rename across uikit/android/android_jni/sdl3 (~64 sites):
30 fn `#foreign;`→`… extern;`; 34 import runtime classes
`#foreign #objc_class/#jni_class("X") {`→`#…_class("X") extern {` (prefix→postfix);
4 defined `Sx*` obj-c classes `#objc_class("X") {`→`… export {`. Behavior-preserving;
empty snapshot diff. **Verification (these modules are largely uncompiled by the
host corpus** — bundle examples import `bundle.sx`, not the runtime modules; android.sx
only compiles under `OS==.android`): byte-identical `sx ir` on uikit importers 1610 +
1606 (which DO compile uikit incl. the 4 defined `Sx*` classes on host) and an sdl3
direct-import probe; android.sx verified by an identical 4-error dedup set (host
pthread clashes — the keyword-neutral "foreign symbol already bound" dedup message is
unchanged, and the probe parsed all migrated `extern` jni classes + EGL fns cleanly
before hitting them). Suite green (647 corpus / 444 unit, 0 failed). Remaining Phase 6
batches: 6.3 std (~60), 6.4 ffi (~50), 6.5 remainder.
### Prior: Phase 6.1 — migrate `vendors/sqlite` `#foreign`→`extern` (`refactor` commit
`410a52e`). **PART B PHASE 6 STARTED.** Pure source rename: all 97
`sqlite3_* … #foreign sqlib "csym";` fn decls → `extern sqlib "csym";` (+ the one
stale header-comment reference, line 9). The `extern_lib` axis references the `sqlib`
`#import c` unit identically to `#foreign sqlib`, so IR/output is byte-identical —
empty snapshot diff (only `sqlite.sx` changed), and example 1624
(`vendor-sqlite-module`) stdout byte-unchanged. Suite green (647 corpus / 444 unit,
0 failed).
### Prior: Phase 5.1 — annotate A→B gate post-flip + add fn-rename case (`test` commit
`93e7b6f`). **PHASE 5 COMPLETE → PART B Phase 5 done.** The A→B gate
(`lower.test.zig`) already asserted `#foreign` ≡ `extern`/`export` byte-identical IR
for fn / global / Obj-C class; post-Phase-5.0 the fn-decl + data-global paths build
the SAME extern-named AST, so cases 1/2 are now STRUCTURALLY identical (guaranteed by
construction, not coincidence). Annotated the gate header to record this and keep it
as a regression tripwire (catches a future reader re-diverging the spellings, or a
revert of the flip); case 3 (runtime class) stays behaviorally — not structurally —
equal via the single `is_foreign_eff` field. Added a fn-rename case (case 2b,
`extern_name` axis: `c_abs` → `"abs"`) to broaden coverage beyond bare import
(verified IR-identical via `sx ir` probe before adding). Test-only, no snapshot churn.
Suite green (647 corpus / 444 unit, 0 failed).
### Prior: Phase 5.0 — fn-decl `#foreign` body-marker FLIP (`refactor` commit `6b94bb6`).
**PHASE 5.0 PARSER ROUTING COMPLETE.** The fn-body `#foreign [LIB] ["csym"]` marker
now builds the SAME extern AST postfix `extern` produces (`extern_export = .extern_`
+ `extern_lib`/`extern_name` + empty-block body) instead of a `foreign_expr` body.
Behavior-preserving — all four prereqs (visibility, variadic, plain-free, lib-ref)
ensure every downstream reader coalesces `is_foreign` with `extern_export`, so IR +
runtime are byte-identical (full corpus + A→B gate green). Decision 7 churn realised:
example 1620's lib-ref error flips "#foreign library" → "extern library" (the only
snapshot moved; hand-edited, not regen). Parser unit test updated to assert the extern
shape. Spot-checked 1219/1218/0729 (foreign rename / cvariadic / same-name) end-to-end.
**All four `#foreign` parser paths now resolved:** global (`e5ddfbe`) + fn-body
(`6b94bb6`) flipped onto extern; const-with-type is dead (deferred); runtime-class is
already coalesced (`is_foreign_eff`). `c_import.zig` auto-synthesis STILL emits
`foreign_expr` bodies (Phase 6+), so both shapes coexist — every reader stays dual.
Suite green (647 corpus / 444 unit, 0 failed).
### Prior: Phase 5.0 prereqs 3 & 4 — plain-free classification + extern lib-ref validation
(plain-free: xfail `2706521` → fix `3c94c14`; lib-ref: xfail `38c3240` → fix
`ad6aed3`). Two MORE extern/#foreign divergences found while de-risking the fn-path
flip, both now closed. **FOUR prereqs total done — the fn-decl flip fully de-risked.**
- **Prereq 3 (plain-free):** `isPlainFreeFn`/`isPlainFreeFnDecl` (resolver.zig:178,
generic.zig:815) excluded a `#foreign` body but classified an empty-block `extern`
fn as a plain free fn — so existing extern fns were wrongly counted in the bare-call
ambiguity verdict (example: two same-name `extern libc "abs"` authors errored
ambiguous, while the `#foreign` twin 0729 compiles). Both predicates now also
exclude `extern_export == .extern_`; `export` (real body) stays plain-free. Example
**1230**.
- **Prereq 4 (lib-ref validation):** `checkForeignRefs` (c_import.zig) validated only
`foreign_expr.library_ref`, so a bogus `extern nosuchunit "abs"` compiled silently
while `#foreign nosuchunit` errors (1620). Now reads the lib ref from EITHER spelling
and names the surface keyword in the diagnostic (so 1620 stays byte-unchanged).
Example **1231**.
- Two OTHER classifying sites probed and found BENIGN for extern (no flip prereq):
namespace/qualified dispatch (`registerQualifiedFn` decl.zig:2208, namespace gate
call.zig:729) — a namespaced `extern` fn resolves identically to its `#foreign` twin
(probe: `cm.c_abs(-9)` → 9 both ways; the registered qualified alias resolves to the
same extern symbol).
### Prior: Phase 5.0 prereq — extern C-variadic tail (xfail `9a2c78d` → fix `0fdc821`) — the SECOND deferred fn-path prerequisite. **BOTH original fn-path prereqs done.** The C-variadic `...` handling was keyed on the `#foreign` (`foreign_expr`)
body shape at two sites — the `is_variadic` drop in `declareFunction`
(`decl.zig:2097`) and the call-site early-out in `packVariadicCallArgs`
(`pack.zig:302`). A variadic `extern` therefore kept its trailing slice param and
slice-packed the extras → garbage at the C ABI (probe: `sum_ints(3,10,20,30)` →
53316585, not 60). Both gates now also fire for `extern_export == .extern_`, so a
variadic `extern` drops the `..args: []T`, sets `is_variadic`, and passes extras
through the C `...` slot with default argument promotion — byte-identical to its
`#foreign` twin. New example **1229** (`1229-ffi-extern-cvariadic`, JIT `#source`,
int-sum + double-avg). Suite green (645 corpus / 444 unit, 0 failed).
### Prior: Phase 5.0 prereq — visibility-gate equivalence (xfail `717c35d` → fix `7d8ba1a`) — the first of the two deferred fn-path prerequisites.
The non-transitive C-import visibility gate (`isVisible(.c_import_bare)`,
`decl.zig:2249`) used to recognise only the legacy `#foreign` body shape; a bare
`extern` fn (empty-block body + `extern_export == .extern_`) escaped the gate via
the `body != foreign_expr → return true` arm and was caught only by the general
`isNameVisible` gate — yielding the generic "not visible" wording instead of the
C-specific "C function not visible; add #import" one. Now BOTH lib-less spellings
route to `visibleOverEdges`, and a library-bound `extern LIB` (like `#foreign LIB`)
stays unconditionally visible — so a future fn-decl `#foreign`→`extern` migration
is byte-identical at this gate. New cross-module example **1228**
(`examples/1228-ffi-extern-c-non-transitive`, main → b → c) pins the equivalence:
referencing c's lib-less `#foreign` AND `extern` twins transitively both produce
the identical C-specific diagnostic. Suite green (644 corpus / 444 unit, 0 failed).
**Empirical finding** (probe, not yet acted on): the bare-extern twin was NEVER a
silent visibility hole — the general `isNameVisible` gate already denied it; only
the *diagnostic wording* diverged. The fix aligns the wording + gate ownership.
### Prior: Phase 5.0 (global path) (`refactor` lock, commit `e5ddfbe`) — **PART B STARTED.**
First of the four `#foreign` parser paths migrated onto the extern AST: the
data-global form `name : T #foreign [lib] ["csym"];` now builds the same
extern-named `VarDecl` (`is_extern`/`extern_lib`/`extern_name`) that postfix
`extern` already produces, instead of `is_foreign`/`foreign_lib`/`foreign_name`.
Behavior-preserving — lowering coalesces both forms identically
(`decl.zig:1119,1127,1141`), so zero snapshot churn. The fn-decl, const-with-type,
and runtime-class `#foreign` paths still build the legacy AST.
### Prior: Phase 4 (green) — **PHASE 4 COMPLETE → PART A DONE; GATE A→B LOCKED.** Four pieces:
(1) **GATE A→B unit test** (`lower.test.zig`, `lowerSrcToIr` helper + "GATE A→B" test) —
asserts `#foreign` and `extern`/`export` lower to byte-identical printed IR for a sample
fn, data global, and Obj-C runtime class. This is the hard gate: Part B may not start
migrating `#foreign` until it's green. Verified live (negative-probe: mutating one side
fails the assertion). (2) **Diagnostic — `#foreign` + postfix conflict** (1174): prefix
`#foreign` combined with postfix `extern`/`export` on an aggregate is now a clean parse
error (was a confusing internal "compiler bug" during class synthesis). (3) **Diagnostic
— `extern`+`export` mutual exclusion** (1175): both keywords on one fn decl is a clean
error (was bare "expected ';'"). (4) **Docs**: `specs.md` + `readme.md` document the three
`extern`/`export` axes (fns, globals, aggregates) alongside `#foreign` (which stays
documented until the Part B cutover). Suite green (643 corpus / 444 unit, 0 fail).
NOTE: `extern`+`callconv` redundancy needs no diagnostic — `callconv(.c) extern` is a
harmless dup (both `.c`), and any non-`.c` callconv already errors on its own.
### Prior: Phase 3.1 (green) — **PHASE 3 COMPLETE.** Postfix `extern`/`export` on `#objc_class`/
`#jni_class` aggregates fully works. `parseForeignClassDecl` now parses an optional
`extern`/`export` modifier in the slot **between** the `("X")` directive args and the `{`
body (`parser.zig:~1409`): `extern`→`is_foreign_eff = true` (reference an existing runtime
class, == legacy `#foreign`); `export`→`is_foreign_eff = false` (define + register a new sx
class, == bare `#objc_class` with no `#foreign`). The modifier maps straight onto the same
`is_foreign` decision the prefix `#foreign` already fed the node, so **no `objc_class.zig`/
lowering change was needed** — the new surface reuses the existing reference-vs-define path.
Examples: **1348** (objc `extern` import, dispatches `NSObject.alloc().init()` → green via
JIT), **1349** (objc `export` defined class, `SxBar.alloc()`/`bump`/`get` → `counter: 2`),
**1426** (jni `extern` import, parse-only `parse-only ok`). Suite green (641 corpus / 443
unit, 0 fail).
### Prior: Phase 2.2 (green) — **PHASE 2 COMPLETE.** `export` (define + expose) fully works:
external linkage + C ABI + no sx ctx + force-lowered root + optional `"csym"` rename.
All four export-gap conditions filled in `decl.zig`: (i) `.external` linkage for
`extern_export == .export_` on both define paths (`lowerFunctionBodyInto`,
`lowerFunction`); (ii) C-ABI promotion on the define paths + `declareFunction` stub cc;
(iv) `funcWantsImplicitCtx` returns false for any non-`.none` modifier; **force-lower**:
`export` fns are lowering roots in `lowerMainAndComptime` (else an uncalled export fn
stays a bodiless `declare`); (iii) `export … "csym"` declares the stub under the C name
+ `lazyLowerFunction` promotes the body into it via `foreign_name_map`. Examples **1226**
(bare export, C calls `sx_square` → 37/82) + **1227** (`export "triple_c"`, C calls
`triple_c` → 22) green via the new **AOT corpus mode**. Suite green (638 corpus / 443
unit, 0 fail).
**AOT corpus mode + run_examples.sh retired.** C→sx-by-name can't link under the
corpus's `sx run` JIT mode (a JIT-resident symbol is invisible to a dlopen'd C dylib's
flat-namespace lookup), so an `expected/<name>.aot` marker switches an example to a
`sx build` + execute flow. The standalone `tests/run_examples.sh` was deleted —
`zig build test` is now the sole corpus runner (verify-step.sh + CLAUDE.md updated).
## Current state
Syntax: bare `extern`/`export`, postfix after `callconv(.c)`, `extern ⇒ callconv(.c)`.
**Decision 4 revised** (user 2026-06-14): `extern` carries an optional `LIB`+`"csym"`
axis (`extern_lib`/`extern_name`) like `#foreign`; the `#library` decl + build-flag
linking stays separate. **`extern` (PHASE 1) + `export` (PHASE 2) FULLY WORKING.**
extern: functions — bare (`f :: (…) -> R extern;`) AND renamed (`extern [LIB] "csym"`);
data globals — bare + renamed. export: functions — bare (`f :: (…) -> R export {…}`)
AND renamed (`export "csym"`); external linkage, C ABI, no ctx, force-lowered as a root.
All behavior-equivalent to the matching `#foreign` form. `extern_lib` is parsed + stored
but is a *reference* only — actual linking stays the `#library`/build-flag axis.
**Aggregates DONE (Phase 3)**: postfix `extern`/`export` on `#objc_class`/`#jni_class`
(reference vs define+register). **Interplay/diagnostics/docs DONE (Phase 4)** + the
**A→B GATE IS LOCKED** (`#foreign` ≡ `extern`/`export` IR for fn/global/class). **PART A
COMPLETE.** Part B `foreign` footprint to purge: 643 lines / ~57 identifiers in `src/` +
~28 doc lines. End-state invariant: **zero `foreign`** (Phase 9.4 gate). Examples: 1223
(extern bare fn), 1224 (extern fn rename), 1225 (extern bare global), 1226 (export bare fn,
AOT), 1227 (export fn rename, AOT), 1348 (objc extern class), 1349 (objc export class), 1426
(jni extern class), 1174/1175 (interplay diagnostics).
## Next step
**NONE — the FFI-linkage stream is COMPLETE.** `extern`/`export` fully replace
`#foreign`; the keyword is rejected; zero `foreign` remains in the gated tree (Parts
A + B, Phases 09 all done; the 9.4 gate passes). This stream can be archived.
Follow-ups (both DONE 2026-06-15, post-stream polish):
- ✅ Added `extern`/`export` to the editors/vscode tmLanguage keyword list as a
`storage.modifier.sx` pattern (`editors/vscode/syntaxes/sx.tmLanguage.json`).
- ✅ Dropped the vestigial `RuntimeClassPrefix.is_extern` field +
`parseRuntimeClassDecl`'s `is_extern` param (always-false dead path; the postfix
`extern`/`export` keyword is the sole reference-vs-define decider). Suite green
(644 corpus / 442 unit, 0 failed).
--- (historical: the finish-Phase-9 plan, now done) ---
**PART B — finish Phase 9: example FILENAME renames + `issues/*.md` + 9.0/9.4.**
(All `src/` identifiers + AST node + all comments/docs/example-comments are DONE.)
0. **FIRST: commit the staged `examples/` comment purge** (a classifier outage blocked
the commit; changes are `git add`ed). Message: "refactor(ffi-linkage): Phase
9.3-examples — purge 'foreign' from example .sx comments".
1. **Example filename rename** (git-mv step, snapshot-careful): rename the `*-foreign*`
example files to extern/runtime names and update every `#import`/`#include`/`#source`
ref + the `expected/<name>.*` companions. Families: `0729-modules-flat-same-name-foreign`
(+ `/a.sx`,`/b.sx` dir), `1205-ffi-foreign-global`(+`-helper`), `1207-ffi-foreign-global-from-helper`,
`1216-ffi-08-foreign-in-method`(+`.h`/`.c`), `1217-ffi-09-foreign-result-chain`(+`.h`/`.c`),
`1218-ffi-foreign-cvariadic`(+`.c`), `1219-ffi-foreign`, `1306-ffi-objc-foreign-class-chained-dispatch`,
`1318-ffi-objc-property-foreign`. ⚠ A renamed file with an `.ir`/`.stderr` snapshot that
echoes its own path will need that snapshot regenerated (intentional). Pick new names
that drop "foreign" (e.g. `…-extern-global`, `…-extern-in-method`, `…-runtime-class-chained-dispatch`).
NOTE: keep `1176-diagnostics-foreign-removed.sx` name (it's the rejection test — fine to keep "foreign").
2. **issues/*.md** (~20) — rewrite writeup prose `#foreign`/`foreign`→`extern`/`runtime-class`.
2b. **`docs/*.md`** — ALSO in the gate scope (was missed; the gate areas are now
`src/ library/ examples/ issues/ docs/ specs.md readme.md CLAUDE.md`). `docs/debugger.md`
referenced the renamed `callForeign` (fixed → `callExtern`, UNCOMMITTED with the staged
batch); sweep all of `docs/` for stale renamed-identifier refs + `foreign` prose.
3. **9.0 surface decision — RATIFIED (user, 2026-06-15): DELETE the `hash_foreign` token.**
The user explicitly flagged token.zig:121 + lsp/server.zig:1693 "this also needs to
go" — total purge, accept `#foreign`→generic error (no friendly migration hint). This
is the LAST src change; it is load-bearing → needs a build + test + 1176 regen (do it
when mutating Bash is back). Steps:
- token.zig: remove `hash_foreign` enum (121).
- lexer.zig: remove the `.{ "#foreign", Tag.hash_foreign }` map entry (91), drop
`#foreign` from the directive-list comment (72), DELETE the `lex hash_foreign` test
(626-631, incl. `#foreignx`).
- parser.zig: remove the 4 `self.current.tag == .hash_foreign` rejection sites (268
caller / 327 / 419 / 2024) + their messages, AND the 2 lookahead refs (`hasFnBody…`
~3658 + ~3676). ⚠ Decide what `#foreign` lexes to with no keyword entry (likely an
error/unknown-directive token) and confirm the parser surfaces a sane error.
- lsp/server.zig: remove the `.hash_foreign,` arm (1693).
- **1176-diagnostics-foreign-removed**: its expected stderr is the now-deleted
"`#foreign` has been removed…" message → it WILL change. Regen 1176's snapshot to
whatever the generic post-deletion error is (intentional), OR delete 1176 entirely
(its purpose — a friendly rejection — no longer exists). Recommend: keep 1176 as a
"`#foreign` is no longer a directive" regression, regen its snapshot. NOTE: after
this, 1176 may still contain `#foreign` in its SOURCE (the rejected token) — that's
the only legitimately-remaining `foreign` in `.sx`, OR rename/rework it to avoid even
that if the gate must be absolute.
4. **9.4 gate** — `grep -rniIE 'foreign'` over `.sx` + docs + `src/` → 0 (no keep-list
left except possibly 1176's source token + `SQLITE_CONSTRAINT_FOREIGNKEY` + vendored C).
--- (historical: the prose-purge plan, now mostly done) ---
**PART B — finish Phase 9: the COMMENT / DOC / issues text purge** (all `src/`
identifiers + the AST node are already done; remaining is prose). Lower-risk than the
renames (text only, mostly snapshot-neutral) but needs per-instance reading — NOT a
blind sed. Footprint: `src/` ~205 (all comments now), `examples/*.sx` ~100 comments,
`issues/*.md` ~20 files, docs (specs/readme/CLAUDE).
Order:
1. **src/ comments** (~200) — reword `foreign`→`extern`/`runtime-class` to match the
renamed identifiers. KEEP: the rejection-message string, the `hash_foreign` token +
its `#foreignx`/`lex hash_foreign` test, and any comment that legitimately explains
the cutover (it must name `#foreign` to say it's removed). The ast.zig FnDecl comment
still says "mirroring `#foreign LIB "csym"` (foreign_lib/foreign_name)" — reword.
2. **examples/*.sx comments** — the deferred provenance comments (full list in the prior
Next-step revision / git). ⚠ Many CONTRAST `#foreign` vs `extern` — reword to stay
coherent. ⚠ `1219-ffi-foreign.sx` prints `"foreign-rename: {}"`/`"=== 15. Foreign ==="`
to STDOUT — changing those regens its snapshot (intentional). `1176`/`1216` legitimately
discuss `#foreign` removal — keep minimal `#foreign` mentions where the test IS about it.
3. **issues/*.md** (~20) — rewrite writeup prose to `extern`/`export`/`runtime-class`.
4. **docs** — specs.md (rename "Foreign Function Interface" heading → "C Interop"; the
`#import c` "foreign declarations" prose; the comptime "foreign function calls" line;
§3344 "foreign code can't observe the error channel"), readme.md (211-212 the `#import
c` exemption prose), CLAUDE.md (host_ffi `#foreign("c")` ref → `extern`; "foreign calls").
5. **9.0 surface decision** (recommend KEEP `hash_foreign` token + rejection for a good
deprecation — then it + the message + 1176 + `#foreignx` are permanent gate-exempt keeps).
6. **9.4 gate** — `grep -rniIE 'foreign'` over `.sx` + docs + `src/` minus the keep-list → 0.
KEEP-LIST (gate-exempt): `SQLITE_CONSTRAINT_FOREIGNKEY` + SQLite API names, vendored
`library/vendors/sqlite/c/*`, the `hash_foreign` token + `#foreignx` test + rejection
message string, `1176-diagnostics-foreign-removed.sx` (rejection test must contain it).
**Gate (scoped per user 2026-06-15):** `grep -rniIE 'foreign'` → 0 across `.sx` files,
all docs, and our `src/` Zig — EXCLUDING the legitimate keeps listed in Last completed
step (SQLite API names, vendored C, the rejection test/message + `hash_foreign` token).
Remaining, in suggested (dependency-safe) order:
1. **9.1d — eliminate `foreign_expr`** (last linkage item): migrate `c_import.zig`
auto-synthesis to build the extern shape instead of a `foreign_expr` body (the Phase
5.0 fn-body flip applied to auto-synth), then delete the `foreign_expr` AST node +
`ForeignExpr` + all readers (25). Snapshot-neutral; verify full corpus (the `#import c`
examples 1215/1216/1217 + sqlite 1624 exercise it).
2. **9.2 — runtime-class family rename → `Runtime*` (Decision 5).** The BIG one, do as
small per-identifier commits with `zig build` after each (snapshot-neutral). Targets
(counts): `ForeignClassDecl`(65)→`RuntimeClassDecl` · `foreign_path`(62)→`runtime_path`
· `foreign_class_map`(44) · `current_foreign_class`(34)/`_method` · `ForeignMethodDecl`(31)
· `foreign_class_decl`(30) · `foreign_expr`-gone-by-now · `ForeignClassMember`(20) ·
`ForeignFieldDecl`(15) · `ForeignClassDecl.is_foreign`(the live one)→e.g. `is_reference`
· `parse/tryParseForeignClass*` · `lowerForeign{Method,Static}Call` ·
`findForeign*InChain` · `resolveForeign*` · `register*ForeignClass*` · `*ForeignRefs` ·
`ForeignRuntime` · `current_foreign_class`/`_method`. ⚠ COUPLED .sx↔.zig hook names:
`jni_main_foreign_path_at`/`jni_main_foreign_paths`/`hookJniMainForeignPathAt`/
`foreignPathToJavaName`/`splitForeignPath` span build.sx + bundle.sx + compiler_hooks.zig
+ specs.md (2975/3049) — rename all four sites together.
3. **9.x-src-comments** — the ~200 bare-`foreign` comments in `src/` (rename last, since
many reference identifiers that 9.1d/9.2 rename; do AFTER those so the comment text
matches the new names).
4. **9.3-examples comments** — the deferred `.sx` provenance comments (0716, 0729, 1205/
1207/1216/1218/1219/1220, 1223-1231, 1306/1308/1315/1318/1320/1321/1331/1332/1348/1349,
1412/1414/1417/1418/1419/1426, 0117/0415, 1140/1141/1125, issues/0030.sx). ⚠ Many
CONTRAST `#foreign` vs `extern` ("no `#foreign`, no `#library`") or reference renamed
internals — rewrite each to stay coherent (NOT blind sed). ALSO: `1219-ffi-foreign.sx`
prints `"foreign-rename: {}"` to STDOUT — changing it regens the snapshot (intentional).
5. **9.3-issues** — `issues/*.md` writeups (~20 files) → rewrite `#foreign`/`foreign` to
`extern`/`export`/`runtime-class` per the renames.
6. **9.3-docs** — specs.md (12: rename "Foreign Function Interface" heading → "C Interop";
the `#import c` "foreign declarations" prose; the jni_main_foreign_path_at refs with #2),
readme.md (2), CLAUDE.md (2: host_ffi `#foreign("c")` ref + "foreign calls").
7. **9.0 surface decision** — keep `hash_foreign` token + rejection (recommended: good
deprecation) vs delete it. If kept, the token + the rejection-message string + 1176 are
permanent legitimate keeps; the gate excludes them.
8. **9.4 gate** — `grep -rniIE 'foreign'` over the gated set minus the keep-list → 0.
- **6.2 verification note (carry forward):** the `platform/` runtime modules
(uikit/android/android_jni) are NOT compiled by any marker'd host corpus test — verify
future platform-adjacent migrations via direct `sx ir` on importers (1610/1606 compile
uikit on host) or import probes, not the corpus alone.
- **Phases 67** (`refactor` batches, empty snapshot diff per batch): migrate the
stdlib + examples from `#foreign` spelling to `extern`. Because the AST is already
unified, this is a pure SOURCE rename (`#foreign LIB "sym";` → `… extern LIB "sym";`
for fns; the global/const forms similarly), and IR/output must be byte-identical per
batch. NOTE: `c_import.zig` auto-synthesis (`#import c {#include}`) still BUILDS
`foreign_expr` bodies internally — that's a compiler-internal path, migrated separately
(likely Phase 8/9 area), not a source-spelling change.
- **Then Phase 8** (cutover: hard-reject the `#foreign` keyword) and **Phase 9** (purge
all `foreign` identifiers — needs Decision 5 [done, `Runtime*Class*`] + Decision 6
[open, historical carve-out]).
**Watch items carried forward:**
- `c_import.zig:262` auto-synthesis still emits `foreign_expr` — both shapes coexist
until that path is migrated; keep every `body.data == .foreign_expr` reader dual
(checked exhaustively this stream).
- const-with-type `#foreign` parser path (`parser.zig:316`) is still on `foreign_expr`
but DEAD (registers no const); migrate or delete it at the Phase 8 cutover.
- The `decl.zig:2055` "foreign symbol … already bound" dedupe message is keyword-neutral
and fires for both forms — no churn, but reword to "extern" at cutover for consistency. Route the fn-decl `#foreign` path so a
`#foreign` fn builds the SAME extern AST that postfix `extern` already produces,
instead of a `foreign_expr` body. This is the highest-value path (the bulk of
`#foreign` usage). Key sub-questions to resolve before/while routing:
- The `foreign_expr` node carries `library_ref` + `c_name`; the `extern` fn carries
`extern_export = .extern_` + `extern_lib` + `extern_name` on the FnDecl with an
empty-block body. Migration = the parser's fn-body `#foreign` arm
(`parser.zig:~2062`) builds the extern shape (set `extern_export`, map
`library_ref→extern_lib`, `c_name→extern_name`) rather than a `foreign_expr`.
- Lowering ALREADY coalesces the two at every fn site checked this stream
(`decl.zig` 2088/2124/2132/2156/2324/2531 read `is_foreign OR extern_export`),
and the two prereq gates (visibility `decl.zig:2249`, variadic `decl.zig:2097` +
`pack.zig:302`) now do too — so the migration should be behavior-preserving with
ZERO snapshot churn. VERIFY with the A→B gate test (`lower.test.zig`) + a full
`zig build test` after routing; any churn means a site still reads `foreign_expr`
structurally and must be coalesced first.
- ⚠ This ALSO migrates the **const-with-type** path implicitly IF it shares the same
`foreign_expr`→extern reshape (it builds `const_decl{value=foreign_expr}`). Decide:
reshape the const path's value node alongside, or leave the dead const path on
`foreign_expr` until Phase 8 cutover. The const path is dead (see findings below),
so leaving it is acceptable; but the parser arm is shared-ish — check whether the
fn-body arm change touches it.
- Cadence: because the migration is behavior-preserving (no churn), it's a single
`refactor`/lock commit (like the 5.0 global-path commit `e5ddfbe`), NOT an
xfail→fix pair.
**Investigation findings (this session — reorder the remaining paths):**
- **const-with-type** (`parser.zig:316`, `name :: type_expr #foreign`) is a
**DEAD path**: it builds `const_decl{value = foreign_expr}`, but
`registerTypedModuleConst` (`decl.zig:848-851`) bails on a `foreign_expr` value
(`else => return`), so it registers no const and emits no symbol — a probe
(`g_abs :: FP #foreign "abs";`) returns `unresolved 'g_abs'` at the use site, and
the form is used NOWHERE in `library`/`examples`/`issues`. Its migration target is
ambiguous because the `foreign_expr` value node is SHARED with the fn-decl path,
which isn't migrated yet. **Decision (user, 2026-06-14): defer it — migrate it
alongside the fn-decl path once `foreign_expr`'s extern shape is decided.** The
checkpoint's old "lowest-risk, route to the extern-named shape" note is wrong: the
"confirm the value-node lowering path coalesces" gate can't be met (nothing lowers it).
- **runtime-class prefix** (`parser.zig:~1351`, `#foreign #objc_class/#jni_class`) is
**ALREADY coalesced**: both prefix `#foreign` and postfix `extern` feed the single
`is_foreign_eff`→`is_foreign` field on `foreign_class_decl` (`parser.zig:1421-1432`),
so there is NO Phase 5.0 AST change for it — only the Phase 9.2 `Runtime*Class*`
rename remains. Drop it from the Phase 5.0 path list.
So Phase 5.0's real remaining work collapses to: the fn-path variadic prereq, then
the fn-decl `#foreign` body-marker migration. const-with-type + runtime-class need
no standalone Phase 5.0 commit.
Then Phase 5.1 (`lock`): unit test that `#foreign` and `extern` produce identical IR (the
A→B gate already covers fn/global/class — extend or reuse `lowerSrcToIr`). Then Phases 67
migrate stdlib + examples (empty snapshot diff per batch), Phase 8 cutover (hard-reject
`#foreign`), Phase 9 total `foreign` purge.
**⚠ CONFIRM BEFORE PART B (Open decisions 5 & 6):** runtime-class rename target
(`Runtime*Class*` recommended vs `Extern*Class*`) and the historical carve-out (keep
`issues/*.md` provenance, gate live tree only — recommended). These decide Phase 9 renames;
the plan says confirm before Phase 9, but worth raising with the user before sinking Part B
effort. **Also pick up the two Deferred items below at the start of Part B** (the
visibility-gate equivalence in particular needs a cross-module example).
**FUTURE MILESTONE — C→sx-by-name in JIT (`sx run`).** Investigated this session
(user-requested spike, RESOLVED feasible-but-blocked). Adding the C `#source` objects
directly into the ORC JITDylib (`LLVMOrcLLJITAddObjectFile`) instead of dlopen'ing a
dylib makes C↔sx cross-references resolve both ways in one link domain — proven: a
~20-line spike ran 1226 via `sx run` (37/82) and all 13 existing `#source` FFI examples
still passed. BLOCKER: C objects using `_Thread_local` (the return-trace runtime
`sx_trace.c`) SIGABRT under JITLink — MachO thread-local-variable handling needs the ORC
`MachOPlatform` set up (the bare `LLVMOrcCreateLLJIT` default doesn't), and C
constructors/`__mod_init_func` won't run without ORC initializer support. 42 `errors-*`
examples crashed in the spike. A real impl needs a C++ shim in `llvm_shim.c`
(`LLJITBuilder().setObjectLinkingLayerCreator(...)` + `MachOPlatform::Create`) — its own
milestone, NOT Phase 2/3 scope. The AOT `.aot`-marker corpus mode is the pragmatic test
path and works today. Spike fully reverted (target.zig/main.zig at HEAD).
**Deferred (carry into Part B):** (a) ~~docs~~ — DONE in Phase 4 (`specs.md`/`readme.md`
document `extern`/`export`; `#foreign` stays until the Part B cutover); (b) ~~visibility-gate
equivalence~~ — **DONE** (`717c35d`/`7d8ba1a`): the `c_import_bare` gate now polices a
lib-less `extern` fn identically to its lib-less `#foreign` twin (same C-specific
diagnostic); a library-bound `extern LIB` stays unconditionally visible. Locked by the
cross-module example 1228. (Empirical: the bare-extern twin was never a silent hole — the
general `isNameVisible` gate already denied it; only the diagnostic wording diverged.)
## Open decisions
Part A ratified (bare / postfix / `⇒ callconv(.c)` / lib-separate). Part B:
- **Decision 5 RATIFIED** (user, 2026-06-14): runtime-class rename target = `Runtime*Class*`
(object-model axis, not linkage). Drives the Phase 9.2 identifier renames.
- **Decision 6 RATIFIED** (user, 2026-06-15): **PURGE EVERYTHING** — the Phase 9.4 gate is
absolute, including `issues/*.md` writeups (NOT the recommended keep-provenance default).
Every `#foreign`/`foreign` reference in the gated tree (`src/ library/ examples/ issues/
specs.md readme.md CLAUDE.md`) is rewritten to `extern`/`export`; provenance lives in git
history + `(Regression issue NNNN)` notes, not the keyword spelling.
- **Decision 7 RATIFIED** (user, 2026-06-15): **accept the churn** — `#foreign`-spelled
decls produce `extern`-worded diagnostics; example 1620 regenerated (only snapshot moved).
Aligns with Part B's extern-only end state; the interim oddity is cosmetic and removed at
the Phase 8 cutover. Landed in the fn-body flip `6b94bb6`. (Original framing below.)
— interim diagnostic wording for `#foreign`-spelled decls (gated the fn-body flip). Once the flip lands, a `#foreign`-spelled fn builds the extern AST, so any
diagnostic that reads the unified AST can no longer tell the user wrote `#foreign` vs
`extern`. Concretely, example 1620's lib-ref error flips "#foreign library…" →
"extern library…". Options: **(A, recommended)** accept the narrow churn — regen 1620 as
intentional; it aligns with Part B's `extern`-only end state and the interim oddity
(`#foreign` source → "extern" message) is cosmetic and short-lived (Phase 8 cutover
removes `#foreign`). **(B)** retain a one-bit surface marker on `FnDecl` (`wrote_foreign`)
so interim diagnostics stay keyword-accurate (zero churn, small extra plumbing, marker
deleted at cutover). Affects only diagnostic wording — IR/behavior identical either way.
## Log
- (9.0 + 9.3 + 9.4) **PHASE 9 COMPLETE — STREAM DONE; 9.4 GATE PASSES.** Deleted the
hash_foreign token (9.0, `dfae690`); purged all `foreign` comments incl. capital-F
(src/examples/docs/editors); renamed 10 `*-foreign*` example files + dedup'd 1218
(`b52d424`); rewrote 20 issues/*.md + renamed 0043 (`b9cfe25`). Gate: zero `foreign`
in the gated tree except `SQLITE_CONSTRAINT_FOREIGNKEY` + vendored sqlite c/. Suite
green (644/443). User flagged several leftover areas mid-purge (docs/, editors/,
capital-Foreign comments, the token) — all addressed.
- (9.3 src capital-Foreign) Fixed the case-sensitivity gap — my earlier src verify grep
was case-sensitive, missing ~21 capital `Foreign`/`FOREIGN` comments (Foreign-class→
Runtime-class, Foreign path→Runtime path, Foreign decls→Extern decls, FOREIGN function→
extern function, etc.) across calls/inst/ffi_objc/jni_descriptor/emit_llvm/c_import/
lower.* /ops.zig. All reworded via Edit (comments only — no build impact). UNCOMMITTED
(mutating Bash blocked by a classifier outage). After this, src `foreign` = ONLY the
`hash_foreign` token machinery + 4 rejection messages (the 9.0-delete targets).
- (9.0 RATIFIED) User: DELETE the hash_foreign token (total purge). Pending build+regen.
- (9.3 text purge) Purged `foreign` from all `src/` comments (`dc51c4b`), specs/readme/
CLAUDE (`e99383f`), and all example .sx comments (STAGED, commit pending a classifier
outage). Fixed 2 user-facing diagnostics (type-annotation error, Android jni_main help).
1219 stdout labels Foreign→Extern (regen). Suite green (646/444). Remaining: example
FILENAMES + issues/*.md + the 9.0 token decision + 9.4 gate.
- (9.2a-d) **RUNTIME-CLASS IDENTIFIER PURGE COMPLETE** (Decision 5 → `Runtime*`).
9.2a types (`3354446`), 9.2b fns+state+`is_extern` flag (`5c8af6e`, fixed `a15a868`
per user: reuse `is_extern` not new `is_reference`), 9.2c extern-ref validators →
`Extern` (`d27be42`), 9.2d `foreign_path`→`runtime_path` coupled across the build-hook
boundary + 37 `.ir` regens (`8cca3b9`). `src/` now has ZERO `foreign` identifiers
(only comments + the kept token/message remain). Suite green throughout.
- (9.1d) Eliminated the `foreign_expr` AST node — migrated `c_import.zig` auto-synth to
the extern shape, deleted the node + all readers. `refactor` `7ffdc7d`.
- (9.1c) Deleted dead `VarDecl.is_foreign`/`foreign_lib`/`foreign_name` (global `#foreign`
rejects → write-dead); 3 decl.zig readers simplified to `vd.extern_name`/`vd.is_extern`.
Snapshot-neutral; suite green (646/444). `refactor` `cd14794`.
- (9.1b) "foreign symbol already bound" diagnostic + resolveFuncByName panic → "extern
symbol"; intentional 1172 regen. Suite green. `refactor` `b78e7dd`.
- (9.1a) **PHASE 9 STARTED.** 5 collision-free linkage renames (callForeign→callExtern,
marshalForeignArg, dedupeForeignSymbol, foreign_name_map→extern_name_map,
is_foreign_c_api). Snapshot-neutral; suite green. `refactor` `b838f63`. Decision 6
scoped by user: purge `.sx` + docs + our `src/` Zig, keep legitimate hits (SQLite API
names, vendored C, the rejection test/message + hash_foreign token).
- (8.1 cutover) **PHASE 8 COMPLETE.** Parser hard-rejects `#foreign` at all 4 sites
(const/global/fn-body via `self.fail`; runtime-class via `self.failAt` at the caller,
new helper); greens xfail 1176. Deleted obsolete 1174 + 1620, the GATE A→B test +
`lowerSrcToIr` helper; converted the in-source parser test to postfix `extern`;
`extern_export` → `const`. specs.md + readme.md drop `#foreign`. Suite green
(646/444). `feat!` `3811311`.
- (8.0 xfail) Added `1176-diagnostics-foreign-removed.sx` pinning the desired rejection.
RED (still accepted). `test`/xfail `8180faf`.
- (8 pre-cutover) Migrated the 4 multi-file example companions Phase 7 missed
(0729/a+b, 1617/c, 1623/mod). `refactor` `d132aab`.
- (8 pre-cutover) Migrated keyword-neutral diagnostics 1172 (decl→extern, message stays
internal "foreign symbol") + 1228 (→ two foreign-free extern symbols c_abs_one/_two),
intentional snapshot regens reviewed. `refactor` `720556b`.
- (8 pre-cutover) Migrated the 7 identity `ffi-foreign-*` test decls to extern/export
(decls only; comments left for Phase 9.3). `refactor` `2cce6a3`.
- (7.4 stragglers) **PHASE 7 MIGRATABLE WORK COMPLETE.** Migrated 16 fn/global examples
(0415/0602/0603/1024/1025/1605/1607-1609/1611/1616/1619/1622/1628/1635/1636) `#foreign`→
`extern`; 1607/1608/1616 (unmarked) verified by `sx ir` probes. 24-file keep-list remains
by design (deferred to Phase 8). Suite green (647/444). `refactor` `1a8991a`.
- (7.3 14xx) Migrated 13 jni examples (1410-1419/1423/1424/1425). 1417 (all-runtimes) hit
a parse-error trap: a `#(objc|jni)_class`-only regex left `extern` in PREFIX position on
`#jni_interface`/`#objc_protocol`/`#swift_*` lines → fixed with the GENERAL
`#foreign #(\w+)("X") {`→`#$1("X") extern {` rewrite (all such directives accept the
postfix modifier, probed). Kept 1426 (comment-only). Suite green. `refactor` `2888f6f`.
- (7.2 13xx) Migrated 18 obj-c examples (1308/1311-1321/1341-1347): prefix→postfix import
classes + fn markers. Kept identity 1306/1318, comment-only 1332/1348/1349. No 13xx
snapshot asserts on foreign. Suite green. `refactor` `a68f7c2`.
- (7.1 12xx) **PHASE 7 STARTED.** Migrated 12 incidental plain-C examples
(1200/1206/1209-1215/1220/1221/1222) `#foreign`→`extern`; output byte-identical,
empty snapshot diff, corpus-validated. Established the keep-list policy (see Last
completed step): kept 1172/1174/1620/1228 + ffi-foreign-* (1205/1207/1216/1218/1219)
+ comment-only 1223/1229/1230/1231 for Phase 8. Suite green (647/444). `refactor`
`731fb8d`.
- (6.5 gpu) **PHASE 6 COMPLETE.** Migrated `gpu/gles3.sx` + `gpu/metal.sx` (3 sites);
`library/` now `#foreign`-free (`grep -rln '#foreign' library/` → 0). Verified
byte-identical `sx ir` on importers 1610/1606. Suite green (647/444). `refactor`
`32a7628`.
- (6.4 ffi) Migrated `ffi/` objc/objc_block/raylib/sdl3/wasm (~51 sites): fn markers +
objc.sx's 2 import classes (prefix→postfix `extern`). objc/objc_block validated by 50
marked 13xx examples; raylib/ffi-sdl3/wasm by `sx ir` probes pre/post. Empty snapshot
diff; suite green (647/444). `refactor` `666a2e2`.
- (6.3 std) Migrated 11 `std/` modules (~60 sites): cli/core/fmt/fs/log/net.kqueue/
process/socket/thread/time/trace. All fn-decl markers (bare / `libc`|`tlib` LIB ref /
`libc "csym"` rename) → `extern …` + 2 comment mentions; no class forms. Host-corpus-
exercised → empty snapshot diff validates. Suite green (647/444). `refactor` `59f90d2`.
- (6.2 platform) Migrated `platform/` (uikit/android/android_jni/sdl3, ~64 sites):
30 fn `#foreign;`→`extern;`, 34 import classes prefix `#foreign #objc/jni_class`→
postfix `… extern {`, 4 defined `Sx*` objc classes → `… export {`. Behavior-
preserving, empty snapshot diff. Verified byte-identical `sx ir` on uikit importers
1610/1606 + sdl3 probe; android via identical 4-error dedup set (host-only module).
Suite green (647/444). `refactor` `2cd5d7b`. NOTE: these runtime modules aren't in
the marker'd host corpus — verified out-of-band.
- (6.1 sqlite) **PHASE 6 STARTED.** Migrated `vendors/sqlite/sqlite.sx`: 97
`#foreign sqlib "csym";` fn decls → `extern sqlib "csym";` (+ line-9 comment).
`extern_lib` references the `sqlib` `#import c` unit like `#foreign sqlib`; IR
byte-identical, empty snapshot diff, example 1624 stdout unchanged. Suite green
(647/444). `refactor` `410a52e`.
- (5.1 gate annotate) **PHASE 5 COMPLETE.** Annotated the A→B gate header
(`lower.test.zig`) to record that post-Phase-5.0 the fn/global `#foreign` paths
build the same extern-named AST → cases 1/2 are structurally (not coincidentally)
identical; the gate stays as a regression tripwire. Added fn-rename case 2b
(`c_abs` → `"abs"`, `extern_name` axis), IR-identical per a `sx ir` probe.
Test-only, no snapshot churn. Suite green (647/444). `test` `93e7b6f`.
- (5.0 fn-body flip) **PHASE 5.0 PARSER ROUTING COMPLETE.** Flipped the fn-body
`#foreign` parser arm (`parser.zig:~2062`) onto the extern AST (empty-block body +
`extern_export = .extern_` + extern_lib/extern_name); `extern_export` made `var` so
the body arm can route onto it. Updated the parser unit test to assert the extern
shape. Behavior-preserving via the four prereqs; only example 1620's lib-ref message
churned ("#foreign library"→"extern library", Decision 7, hand-edited). Suite green
(647 corpus / 444 unit). `refactor` `6b94bb6`.
- (5.0 prereq plain-free xfail) Added `1230-ffi-extern-same-name-authors` (two flat
authors of `absval` via `extern libc "abs"`; the `extern` twin of `#foreign` 0729).
RED — extern authors wrongly counted as ambiguous (646/1 fail). `test`/xfail `2706521`.
- (5.0 prereq plain-free fix) `isPlainFreeFn`/`isPlainFreeFnDecl` now also exclude
`extern_export == .extern_` (external C symbol, no sx body; name-keyed first-wins like
`#foreign`); `export` stays plain-free. 1230 green (`absval = 7`). Suite green (646/444).
`fix`/green `3c94c14`.
- (5.0 prereq lib-ref xfail) Added `1231-ffi-extern-undeclared-lib` (`extern nosuchunit
"abs"` — bogus lib ref). RED — compiles silently (extern lib ref unvalidated).
`test`/xfail `38c3240`.
- (5.0 prereq lib-ref fix) `checkForeignRefs` (c_import.zig) now reads the lib ref from
either spelling (foreign_expr.library_ref OR extern_lib) and names the surface keyword,
so 1620 (#foreign) is byte-unchanged and 1231 (extern) gets "extern library … not
declared". 1231 green. Suite green (647/444). `fix`/green `ad6aed3`. **ALL FOUR fn-path
prereqs DONE → fn-body flip de-risked; awaiting Decision 7 (interim wording).**
- (5.0 prereq variadic xfail) Added `1229-ffi-extern-cvariadic` (JIT `#source`,
int-sum + double-avg, `extern` C-variadic). Expected snapshot pins the DESIRED
correct output. RED (variadic `extern` slice-packs extras → garbage:
`sum_ints(3,10,20,30)` → 53316585; doubles → 0.0). `test`/xfail `9a2c78d`.
- (5.0 prereq variadic fix) Extended the two C-variadic gates — the `is_variadic`
drop in `declareFunction` (`decl.zig:2097`) and the early-out in
`packVariadicCallArgs` (`pack.zig:302`) — to fire for `extern_export == .extern_`
as well as a `foreign_expr` body. 1229 green (`60` / `2.000000`). Suite green
(645 corpus / 444 unit, 0 failed). `fix`/green `0fdc821`. **BOTH fn-path prereqs
DONE → fn-decl `#foreign` body-marker migration unblocked.**
- (5.0 prereq vis xfail) Added cross-module example `1228-ffi-extern-c-non-transitive`
(main → b → c). Main references c's lib-less `#foreign` + `extern` twins
transitively; expected snapshot pins the DESIRED equivalent C-specific
diagnostic for both. RED (extern twin gets the generic "not visible" wording —
443/444). `test`/xfail commit `717c35d`; the fix greens it.
- (5.0 prereq vis fix) Extended `isVisible(.c_import_bare)` (`decl.zig:2249`) to
switch on the body: a `foreign_expr` body OR an `extern_export == .extern_` decl
with no lib both route to `visibleOverEdges`; a library-bound decl stays
unconditionally visible. 1228 green — both twins emit "C function not visible".
Suite green (644 corpus / 444 unit, 0 failed). `fix`/green commit `7d8ba1a`.
**Deferred prereq (b) CLOSED.** Investigation this session also found
const-with-type is a DEAD parser path (defer per user) and the runtime-class
prefix is already coalesced (no Phase 5.0 change) — see Next step.
- (5.0 global) **PART B STARTED.** Routed the `#foreign` data-global parser path
(`parser.zig:425`) onto the extern-named `VarDecl` (`is_extern`/`extern_lib`/
`extern_name`) — the same AST postfix `extern` builds. Behavior-preserving
(lowering coalesces both at `decl.zig:1119,1127,1141`); zero snapshot churn. Suite
green (444/444 unit, 643 corpus). `refactor` lock, commit `e5ddfbe`. Remaining
Phase 5.0 paths: const-with-type (316), fn-body (2059, needs visibility+variadic
prereqs), runtime-class prefix (1305).
- (init) Plan written; FFI-linkage stream opened.
- (merge) Folded FOREIGN-MIGRATION in as Part B; deleted the split plan + checkpoint.
- (0.0) Added `kw_extern`/`kw_export` tokens + keyword-map entries + LSP keyword
classification + `lex linkage keywords` test. Suite green; no identifier collisions
in the corpus. `lock` commit.
- (0.1) Added `ast.ExternExportModifier` + `FnDecl.extern_export` +
`VarDecl.is_extern`/`extern_name` + `parseOptionalExternExport()` (unconsumed) + 2
parser unit tests. Suite green (443/633). `lock` commit.
- (1.0a) Wired fn-path extern parsing (`parseFnDecl` + both lookahead predicates) +
added `FnDecl.extern_lib`/`extern_name` + `VarDecl.extern_lib` per user feedback
(decision 4 revised: extern carries an optional lib axis). Unconsumed by lowering.
Suite green (443/633). `lock` commit.
- (1.0b) Added `examples/1223-ffi-extern-fn.sx` + hand-authored success snapshots.
RED (634 ran, 1 failed — sema `body produces no value`). `xfail` commit; 1.1 greens it.
- (1.1) Wired extern fn lowering (6 edits in `decl.zig`, all declare-only routing
mirroring `foreign_expr`): `funcWantsImplicitCtx` + `declareFunction` cc +
`lazyLowerFunction`/`lowerFunction`/`lowerFunctionBodyInto` guards. 1223 green;
`declare i32 @abs(i32)` (C ABI, no ctx). Suite green (634/443). `green` commit.
- (1.2a) Added `examples/1224-ffi-extern-fn-rename.sx` (`c_abs :: … extern "abs";`) +
hand-authored success snapshot (`c_abs(-42) = 42`). RED (635 ran, 1 failed — parse
error: `"abs"` after `extern` not yet accepted). `xfail`; 1.2b greens it. (Also
recovered a formatter-clobbered `parser.zig` — see Known issues.)
- (1.2b) `parseFnDecl` parses the optional `[LIB] ["csym"]` tail into
`extern_lib`/`extern_name`; `declareFunction` unifies the rename (foreign c_name OR
extern_name → declare under C name, map sx→C) and extends the dedupe guard to
extern. 1224 green (`c_abs`→`abs`); 1223 unregressed. Suite green (635/443).
`green` commit. extern_lib parsed+stored (lib linking stays the `#library` axis).
- (1.2c) Added `examples/1225-ffi-extern-global.sx` (`__stdinp : *void extern;`,
mirrors `#foreign` global 1205) + success snapshot. RED (636 ran, 1 failed — parse
error: var-decl `extern` not accepted). `xfail`; 1.2d greens it.
- (1.2d) Parser `kw_extern` branch in the var-decl path (`[LIB] ["csym"]` →
`is_extern`/`extern_lib`/`extern_name`) + `registerTopLevelGlobal`/`globalInitValue`
consume `is_extern`. 1225 green (`@__stdinp = external global ptr`). Suite green
(636/443). `green` commit. **PHASE 1 COMPLETE** — `extern` fns + globals fully work.
- (JIT spike) User-requested feasibility investigation of C→sx-by-name in `sx run`
(JIT). Verdict: feasible via `LLVMOrcLLJITAddObjectFile` (C objects into the ORC
JITDylib) — proven by a throwaway spike — but blocked by JITLink MachO TLV handling
(`sx_trace.c`'s `_Thread_local` SIGABRTs without the ORC `MachOPlatform`). Own future
milestone (see Next step). Spike reverted; no commit.
- (2.0) Added the **AOT corpus mode** (`expected/<name>.aot` → `sx build` + execute) to
`corpus_run.test.zig` + retired `tests/run_examples.sh` (verify-step.sh/CLAUDE.md
updated) + `examples/1226-ffi-export-fn.{sx,c,h}` (C calls `sx_square` back). RED (AOT
link fails: `_sx_square` undefined — export not lowered). `xfail`; 2.1 greens it.
- (2.1) Filled export gaps i/ii/iv in `decl.zig` (`.external` linkage + `.c` cc on both
define paths; `funcWantsImplicitCtx` false for any non-`.none` modifier) + force-lower
export fns as roots in `lowerMainAndComptime`. 1226 green via AOT (37/82). Suite green
(637/443). `green` commit.
- (2.2a) Added `examples/1227-ffi-export-fn-rename.sx` (`export "triple_c"`, C calls
`triple_c`). RED (define path emits `@sx_triple`, ignores `extern_name` → C ref
undefined). `xfail`; 2.2b greens it.
- (2.2b) `declareFunction` rename branch fires for `export` (stub under C name +
sx→C in `foreign_name_map`); `lazyLowerFunction` resolves the stub by that C name so
the body promotes into the C-named function (`define @triple_c`). sx-side call sites
resolve via the same map (probe: 5*5→25). 1227 green (22); 1226 unregressed. Suite
green (638/443). `green` commit. **PHASE 2 COMPLETE** — `export` fully works.
- (3.0) Added `examples/1348-ffi-objc-extern-class.sx` (postfix `extern` on `#objc_class`,
new spelling of `#foreign #objc_class`). RED (parser: `expected '{'` after the
directive). Hand-authored green snapshots. `xfail` commit; 3.1 greens it.
- (3.1a) Wired the postfix `extern`/`export` aggregate slot in `parseForeignClassDecl`
(optional modifier between `("X")` and `{`; `var is_foreign_eff` overrides the passed
`is_foreign`, threaded into the `foreign_class_decl` node). No lowering change — reuses
the existing `is_foreign` reference-vs-define path. 1348 green. Suite green (639/443).
`green` commit. **PHASE 3 COMPLETE.**
- (3.1b) Behavior-lock: added `examples/1426-ffi-jni-extern-class.sx` (jni `extern`,
parse-only) + `examples/1349-ffi-objc-export-class.sx` (objc `export` defined class,
`counter: 2`). Both pass against the 3.1a parser change (locked in their own commit per
the cadence rule). Suite green (641/443). `lock` commit. (Note: `-Dupdate-goldens`
newline-normalizes empty stderr → reverted unrelated 1226/1227 churn, kept new stderr
0-byte per repo convention; runner normalizes both.)
- (4.gate) **GATE A→B** — added `lowerSrcToIr` helper + "GATE A→B" test to `lower.test.zig`:
`#foreign` ≡ `extern`/`export` byte-identical printed IR for fn / global / Obj-C class.
Verified live via negative-probe (mutate one side → assertion fails). Behavior-lock; the
equivalence was prototyped first with `sx ir` (LLVM IR byte-identical for all three).
Suite green (641/444). `test` commit.
- (4.diag1) Added `examples/1174-diagnostics-foreign-postfix-conflict.sx` — prefix `#foreign`
+ postfix `export` on an aggregate previously surfaced a confusing internal
"emitObjcDefinedClassAllocImp … compiler bug". `xfail` (golden = clean message) → `green`:
`parseForeignClassDecl` rejects the combo at the postfix keyword (`failFmt`). Suite green.
- (4.docs) `specs.md` (new "`extern`/`export` linkage keywords" subsection after the
`#foreign` FFI docs) + `readme.md` (C Interop section) document the three axes. `docs` commit.
- (4.diag2) Added `examples/1175-diagnostics-extern-export-conflict.sx` — `extern export` on
one fn decl previously gave bare "expected ';'". `xfail` (golden = clean message) → `green`:
`parseFnDecl` rejects a second linkage keyword after `parseOptionalExternExport`. Suite
green (643/444). **PHASE 4 COMPLETE → PART A DONE.**
- (golden-fix) **`-Dupdate-goldens` churn RESOLVED.** Root cause was NOT a code bug:
`writeGolden` always writes `content + "\n"` (empty → canonical 1-byte `\n`, used by 484
of 489 empty goldens). The 5 churning stderr files [1226/1227/1348/1349/1426] were 0-byte
*outliers* (verify trims trailing `\n` so both forms passed, but regen always rewrote them
to 1-byte). Conformed all 5 to the 1-byte form → `-Dupdate-goldens` is now idempotent, no
more churn. (Separately: a flaky `0712-sha256-streaming` >10s timeout appears only under
concurrent `zig build` load — not a real failure; re-run serially.)
## Known issues
- **Workflow hazard (1.2):** an editor format-on-save (or `zig fmt`) clobbered the
working-tree `src/parser.zig` between commits — it reformatted one-liners AND
silently dropped my `hasFnBodyAfterArrow` extern edit, reverting 1223 to a parse
error. Recovered with `git checkout src/parser.zig` (HEAD had the correct,
committed version). **After any Edit-tool change to a file the IDE may have open,
rebuild + run the affected example before trusting the edit.**

View File

@@ -30,7 +30,7 @@ build_block_convert($args, $R)`.
| issue-0050 fix | `5316bf7` | Same isolation pattern as 0048 applied to `monomorphizeFunction`. |
| 5.1.A xfail | `3bd6f26` | `build_block_convert(args: []Type, $ret: Type) -> string` undefined — pin output format via `examples/176-build-block-convert.sx` across 3 void shapes + 1 non-void shape. |
| 5.1.B fix | `aeb950b` | Builder added to `library/modules/std/objc_block.sx`. Emits nested `callconv(.c)` trampoline + Block literal source. |
| 5.2.A xfail | `f5342e9` | Generic `Into(Block)` impl absent — `Closure(s64, s64) -> void` (uncovered by hand-rolled impls) emits the "no Into(Block) for cl_s64_s64__void" diagnostic per `examples/177-generic-into-block.sx`. |
| 5.2.A xfail | `f5342e9` | Generic `Into(Block)` impl absent — `Closure(i64, i64) -> void` (uncovered by hand-rolled impls) emits the "no Into(Block) for cl_i64_i64__void" diagnostic per `examples/177-generic-into-block.sx`. |
| 5.2.B fix | `165b621` | Generic impl `Closure(..$args) -> $R` added with `#insert build_block_convert($args, $R)`. `lowerExpr`'s `.comptime_pack_ref` + `resolveTypeArg` + `type_bridge.isTypeShapedAstNode` extended so impl-mono `$args` (pack_bindings) and `$R` (type_bindings) resolve in both expr and type positions. |
| 5.3 | `2eaf932` | Delete hand-rolled `__block_invoke_void` + `__block_invoke_bool` + the two per-shape impls. The generic impl covers both at runtime. |
@@ -40,11 +40,11 @@ What's now possible end-to-end (from
```sx
#import "modules/std/objc_block.sx";
main :: () -> s32 {
cl := (a: s64, b: s64) => { g_a = a; g_b = b; };
main :: () -> i32 {
cl := (a: i64, b: i64) => { g_a = a; g_b = b; };
blk : Block = xx cl; // generic impl mono'd for
// Closure(s64, s64) -> void
invoke_fn : (*Block, s64, s64) -> void callconv(.c) = xx blk.invoke;
// Closure(i64, i64) -> void
invoke_fn : (*Block, i64, i64) -> void callconv(.c) = xx blk.invoke;
invoke_fn(@blk, 10, 20);
0;
}
@@ -53,9 +53,9 @@ main :: () -> s32 {
The `xx cl : Block` site monomorphises the generic
`Into(Block) for Closure(..$args) -> $R` impl. Inside the impl
mono, `#insert build_block_convert($args, $R)` evaluates the
builder at comptime with `$args = [s64, s64]` and `$R = void`,
builder at comptime with `$args = [i64, i64]` and `$R = void`,
and substitutes the resulting source — a nested
`__invoke :: (block_self: *Block, arg0: s64, arg1: s64) -> void
`__invoke :: (block_self: *Block, arg0: i64, arg1: i64) -> void
callconv(.c) { ... }` trampoline plus the Block literal that
points its `invoke` slot at `@__invoke`. Stack-local block layout
matches Apple's published spec; UIKit / Foundation consumers can
@@ -96,7 +96,7 @@ generic Into(Block) builder body rests on.
|---|---|---|
| 4A.bare.1.A | `c792642` | Expected-failing lock-in for bare `$args` (parser rejection diff). |
| 4A.bare.1.B | `5a4a19b` | Parser makes `[` optional after `$<pack_name>`; new `ComptimePackRef` AST node + sema no-op arms + `lowerExpr` arm calling new `buildPackSliceValue(arg_types)` helper. Helper emits `alloca [N x Any]`, one `const_type(arg_tys[i])` per slot, then a `{data_ptr, len}` slice aggregate. emit_llvm's `const_type` arm relaxed to silent undef-i64 (storage of Type values in runtime aggregates is harmless; loud bail moves to USE sites). |
| 4A.bare.4.A | `95e61d8` | Expected-failing lock-in for `type_name(list[i])` silently returning "s64" via `resolveTypeArg`'s catch-all `else => .s64`. |
| 4A.bare.4.A | `95e61d8` | Expected-failing lock-in for `type_name(list[i])` silently returning "i64" via `resolveTypeArg`'s catch-all `else => .i64`. |
| 4A.bare.4.B | `d99c0fd` | `tryLowerReflectionCall` splits on new `isStaticTypeArg(node)` helper. Static args fold to const_string (today's fast path); dynamic args emit `callBuiltin(.type_name, [arg_ref])` for the interp's arm. emit_llvm's reflection-builtin arm relaxed to silent undef-i64 — same reasoning as const_type: storage-position misuse is impossible, use-site misuse caught by the interp arm's `asTypeId orelse bailDetail`. |
| 4A.bare.5 | `2162662` | End-to-end smoke `examples/172-pack-builder-smoke.sx`. `describe(..$args)` walks `$args` at #run time, calls `type_name(list[i])` per position. Four call shapes (empty, one-arg, two-arg, four-mixed) verify the full chain works. |
@@ -106,7 +106,7 @@ What now works end-to-end (from `examples/172-pack-builder-smoke.sx`):
describe :: (..$args) -> string {
list := $args;
s := "[";
i : s64 = 0;
i : i64 = 0;
while i < list.len {
if i > 0 { s = concat(s, ", "); }
s = concat(s, type_name(list[i]));
@@ -117,7 +117,7 @@ describe :: (..$args) -> string {
}
#run { print("{}\n", describe(true, 3.14, "x", 99)); }
// → [bool, f64, string, s64]
// → [bool, f64, string, i64]
```
The pack flows through a real `[]Type` slice value; the loop
@@ -130,7 +130,7 @@ Known follow-ups (not blocking step 5):
- `type_eq` / `has_impl` dynamic-arg dispatch — should follow
the same `isStaticTypeArg` split that `type_name` got in
4A.bare.4.B. Today their dynamic-arg case still silently
folds via the same `resolveTypeArg .s64` fall-through.
folds via the same `resolveTypeArg .i64` fall-through.
Wire when a real use case needs them.
- `has_impl` interp arm — still bails "not yet wired".
Needs a protocol-map snapshot on `Interpreter.init`.
@@ -160,7 +160,7 @@ helpers, source-language `$args[$i]` in expression position.
| 4.0 foundation | `ac60d98` | New `Op.const_type: TypeId` opcode (dedicated, never piggybacks on `const_int`). Interp emits `Value.type_tag(tid)`. emit_llvm bails loudly (Type is comptime-only; LLVM never sees one). `Value.asTypeId() ?TypeId` helper. `evalCmp` arm for `.type_tag, .type_tag` — TypeId equality. Mixed `.type_tag` vs `.int` falls through to `typeErrorDetail`. Zig unit tests confirm the variant. |
| 4.1 reflection arms | `9600ba5` | `BuiltinId.type_name` / `.type_eq` / `.has_impl` for the interp-time fallback when lowering can't fold the call statically. Static-arg calls keep the existing `tryLowerReflectionCall` const-emission fast path. `has_impl` interp arm bails with "not yet wired" — interp-time has_impl needs a queryable snapshot of the host's protocol maps (its own follow-up). emit_llvm bails loudly on all three (comptime-only). |
| 4.2 audit + bitcast guard | `55c72af` | `box_any`/`unbox_any` audit: layout was already correct (tag stays `.int`; value field can be `.type_tag`). `bitcast` interp arm guards against `.type_tag → <non-Any, non-identity>` casts — catches the `xx val to string` shape in `any_to_string`'s `case type:` arm that pre-dates type_tag and would silently mis-coerce. |
| 4.3 source construction | `fd03b58` | Parser accepts `$<pack>[<int_literal>]` in expression position (yields the same `pack_index_type_expr` AST node already used in type positions in step 3). Lowering: `lowerExpr` arm emits `const_type(arg_tys[index])`; `resolveTypeArg` arm reads `pack_arg_types[name][index]` directly so lower-time fold paths (`tryLowerReflectionCall`, `tryConstBoolCondition`) see the bound TypeId rather than falling through to the `.s64` silent-arm default. |
| 4.3 source construction | `fd03b58` | Parser accepts `$<pack>[<int_literal>]` in expression position (yields the same `pack_index_type_expr` AST node already used in type positions in step 3). Lowering: `lowerExpr` arm emits `const_type(arg_tys[index])`; `resolveTypeArg` arm reads `pack_arg_types[name][index]` directly so lower-time fold paths (`tryLowerReflectionCall`, `tryConstBoolCondition`) see the bound TypeId rather than falling through to the `.i64` silent-arm default. |
Audit summary — every Value-switch in interp.zig was checked
for silent fall-through. Findings:
@@ -180,11 +180,11 @@ What's now possible end-to-end (from `examples/169-pack-value-dispatch.sx`):
```sx
show :: (..$args) -> string => type_name($args[0]);
show(42) // "s64"
show(42) // "i64"
show("hi") // "string"
describe :: (..$args) -> string {
inline if type_eq($args[0], s64) { return "got s64"; }
inline if type_eq($args[0], i64) { return "got i64"; }
inline if type_eq($args[0], string) { return "got string"; }
...
}
@@ -231,29 +231,29 @@ What works:
- `x : $args[1] = args[1]` — local-var annotation.
- `fp : (*void, $args[0]) -> $args[1] = handler;` — fn-pointer
type literal (the shape step 5's generic trampoline body needs).
- `inline if type_eq($args[0], s64) { ... }` (when the `$args[0]`
- `inline if type_eq($args[0], i64) { ... }` (when the `$args[0]`
argument is in a type position — `type_eq` reads call args via
`resolveTypeArg` which routes to `resolveTypeWithBindings`).
- `has_impl(Hash, s64)` (plain protocols).
- `has_impl(Into(Block), s64)` (parameterised protocols).
- `has_impl(Hash, i64)` (plain protocols).
- `has_impl(Into(Block), i64)` (parameterised protocols).
New tests:
- `examples/165-pack-type-position.sx` — return type + local
var annotation; two heterogeneous call shapes (s64+string,
string+s64) confirm distinct monos.
var annotation; two heterogeneous call shapes (i64+string,
string+i64) confirm distinct monos.
- `examples/166-pack-type-position-three.sx` — `args[2]` (third
element) as return type across three (s64,s64,string),
(bool,f64,s64), (string,string,bool) shapes.
element) as return type across three (i64,i64,string),
(bool,f64,i64), (string,string,bool) shapes.
- `examples/167-pack-type-fnptr.sx` — fn-pointer type literal
with `$args[$i]` in both param + return positions.
- `examples/168-pack-reflection-intrinsics.sx` — type_name,
type_eq (with inline-if folding), has_impl for both plain
(Allocator/CAllocator) and parameterised (custom Wrap(s64)
for s32) protocols.
(Allocator/CAllocator) and parameterised (custom Wrap(i64)
for i32) protocols.
Out of scope (deferred):
- `$args[$i]` in EXPRESSION position (the parser only accepts
it in type positions today — `type_eq($args[0], s64)` works
it in type positions today — `type_eq($args[0], i64)` works
because the call-arg path resolves through `resolveTypeArg`,
but bare `$args[0]` as a value would need an extra parser arm).
- `$args[$i]` in struct field types.
@@ -280,7 +280,7 @@ $args[1], ...) -> $R` per-position types.
Closes the nested-comptime-call + return bug. The pack-fn face
was incidentally fixed by step 2b's mono refactor (pack-fn
calls now bypass the inline-return-slot setup that leaked into
nested comptime). The plain `($x: s32)` comptime face stayed
nested comptime). The plain `($x: i32)` comptime face stayed
on the inline path until this fix.
`createComptimeFunction` wraps a comptime expression into a
@@ -305,7 +305,7 @@ only `func` / `current_block` / `inst_counter` / `scope` /
fn-local flags the wrapper's lowering needs fresh.
`examples/issue-0046.sx` (regression test): `helper :: ($x:
s32) -> s64 { print("inside\n"); return 42; }` called from
i32) -> i64 { print("inside\n"); return 42; }` called from
`main`. Pre-fix: interp panic at storeAtRawPtr. Post-fix:
prints "inside" then "n=42".
@@ -339,7 +339,7 @@ New tests:
`log_count(items: []Any)`.
- `examples/163-pack-runtime-index.sx` — `while i < args.len
{ args[i] }` over a 4-arg pack.
- `examples/164-pack-mixed-comptime.sx` — `tagged($tag: s32,
- `examples/164-pack-mixed-comptime.sx` — `tagged($tag: i32,
..$args)` called with different `tag` values gets distinct
monos (`tagged__ct_7__pack_*`, `tagged__ct_9__pack`).
@@ -364,7 +364,7 @@ caller's basic block.
`examples/158-pack-mono-dedup.sx` confirms end-to-end:
`count(), count(1), count(2), count(1,2,3), count("x", true)`
produces `0 1 1 3 2` at runtime AND emits exactly 4 monos in
IR — the two `s64` calls share one mono.
IR — the two `i64` calls share one mono.
Plumbing in `src/ir/lower.zig`:
- `isPackFn(fd)` — true when the only comptime param is a
@@ -492,7 +492,7 @@ Out of scope:
"unresolved 'result'" because nested comptime inlining
loses the scope where stdlib's `#insert build_format`
declared `result`. Same class as the
`helper :: ($x: s32) -> s64 { print(...); return 42; }`
`helper :: ($x: i32) -> i64 { print(...); return 42; }`
pattern; pre-dates step 2. Worth filing if step 2's later
slices need it; today's typed-indexing test exercises only
field access and arithmetic, no nested print.
@@ -516,7 +516,7 @@ Root cause was broader than packs: `format`/`print` use arrow
form (`=> expr`) or `#insert`-only bodies, so no stdlib comptime
fn took the `return`-with-trailing-statements path. Step 1.b
made `..$args` parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first body to
`foo :: (..$args) -> i64 { return 42; }` was the first body to
hit it.
Fix in `src/ir/lower.zig`:
@@ -575,10 +575,10 @@ New plumbing in [src/ir/lower.zig](../src/ir/lower.zig):
concrete source closure during monomorphisation.
`examples/155-pack-impl-match.sx` flips from the
"no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to
"no Into(Block) for cl_i32_bool__bool" lock-in diagnostic to
"pack impl match ok": one user-declared
`impl Into(Block) for Closure(..$args) -> $R` covers a
`Closure(s32, bool) -> bool` source that stdlib has no
`Closure(i32, bool) -> bool` source that stdlib has no
hand-rolled impl for. The constructed Block isn't invoked
(invoke=null) — the test exercises matching + monomorphisation,
not the trampoline (step 5 of the plan).
@@ -607,9 +607,9 @@ Replaces a hand-rolled Into impl in stdlib once step 2 + step
Pinned today's matching behaviour ahead of 1d.B. A user-declared
`impl Into(Block) for Closure(..$args) -> $R` registers under a
pack-shaped source key in `param_impl_map`; the xx site mangles
the concrete `Closure(s32, bool) -> bool` source and finds
the concrete `Closure(i32, bool) -> bool` source and finds
nothing → the existing focused diagnostic fires ("no `Into(Block)
for cl_s32_bool__bool` impl — add a per-signature
for cl_i32_bool__bool` impl — add a per-signature
`__block_invoke_<sig>` trampoline + Into impl..."). The pack impl
is reachable in the file but never considered.
@@ -691,7 +691,7 @@ current parser-rejection behavior so the next commit's parser
extension shows up as a behavior shift.
New: `examples/150-pack-parse.sx` declares
`foo :: (..$args) -> s64`. Today's parser hits `..` where it
`foo :: (..$args) -> i64`. Today's parser hits `..` where it
expects a parameter name (after parsing the leading `dollar`
sigil that doesn't appear) and emits "expected parameter name"
at column 9 of line 15. Expected output captures this rejection.
@@ -742,7 +742,7 @@ lookup. Infrastructure only; populated but not yet read.
Added stand-ins for the opaque Obj-C runtime types to
`library/modules/std/objc.sx`: `id`, `Class`, `SEL` resolve to
`*void`; `BOOL` to `s8`. All zero-cost at the LLVM layer; the
`*void`; `BOOL` to `i8`. All zero-cost at the LLVM layer; the
header's old caveat about lacking aliases is gone.
`141-objc-type-aliases.sx` exercises them against the real macOS
Obj-C runtime via `isKindOfClass`.
@@ -793,13 +793,13 @@ Four design questions still open (see roadmap).
A trailing variadic param on a `#foreign` declaration now maps to the
C calling convention's `...` instead of sx's slice-packing path. Drops
the existing per-arity shim pattern (`__log_2i :: (prio, tag, fmt, a:
s32, b: s32) -> s32 #foreign __android_log_print;`) for a single
i32, b: i32) -> i32 #foreign __android_log_print;`) for a single
declarative form:
```sx
sx_ffi_sum_ints :: (n: s32, args: ..s32) -> s64 #foreign;
sx_ffi_sum_ints :: (n: i32, args: ..i32) -> i64 #foreign;
main :: () -> s32 {
main :: () -> i32 {
print("{}\n", sx_ffi_sum_ints(3, 10, 20, 30)); // → 60
}
```
@@ -821,15 +821,15 @@ locks in the green state in one commit):
getting boxed into a typed slice.
3. **C default argument promotion**. New `promoteCVariadicArgs`
([src/ir/lower.zig](src/ir/lower.zig)) applies the standard
promotions to args past the fixed param count: `bool/s8/s16/u8/u16
s32` via sext/zext, `f32 → f64` via fpext. Wired into the two
promotions to args past the fixed param count: `bool/i8/i16/u8/u16
i32` via sext/zext, `f32 → f64` via fpext. Wired into the two
`lowerCall` paths right after `coerceCallArgs`.
`examples/ffi-foreign-cvariadic.sx` + `.c` lock the matrix end-to-end:
`sum_ints(3, 10, 20, 30) → 60`, `sum_ints(0) → 0`, `avg_doubles(2,
1.5, 2.5) → 2.0`, `avg_doubles(3, 1.0, 2.0, 3.0) → 2.0`, and a
null-terminated `count_args` chain of `*u8` strings → `3`. All four
return shapes (s64 / f64 / s32) and three element types (s32 / f64 /
return shapes (i64 / f64 / i32) and three element types (i32 / f64 /
*u8) exercise the variadic-slot ABI through the C `va_arg` machinery
in the .c helper.
@@ -887,7 +887,7 @@ plus 2 codegen fixes surfaced along the way.**
|------|-------------------------------|---------------------------------------------------------------------------------------|
| 0.0 | tests/cross_compile.sh | empty tuple list, exits 0; skip-with-warning when toolchains missing |
| 0.1 | ffi-01-primitives.sx | every primitive type round-trips through `#import c { #source / #include }` |
| 0.2 | ffi-02-small-struct.sx | Vec2 (8 B), Vec4f (16 B HFA), Pair64 (2×s64), Quad32 (4×s32) — four ABI slots |
| 0.2 | ffi-02-small-struct.sx | Vec2 (8 B), Vec4f (16 B HFA), Pair64 (2×i64), Quad32 (4×i32) — four ABI slots |
| 0.3 | ffi-03-large-struct.sx | Big24 (24 B), Big48 (48 B) via byval params + sret return |
| 0.4 | ffi-04-fp-struct.sx | FQuad (16 B HFA), DQuad (32 B HFA — UIEdgeInsets-shape) |
| 0.5 | ffi-05-string-args.sx | [:0]u8, sx `string` slice-decay, [*]u8 + len, mutate-via-C, C-returned pointer |
@@ -1110,8 +1110,8 @@ describes is implemented in [src/ir/lower.zig](../src/ir/lower.zig)
Apple's runtime DSL encoding table:
- `v` = void, `i` = s32, `q` = s64, `f` = f32, `d` = f64, `B` = bool,
- `c` = s8/BOOL, `C` = u8, `s` = s16, `S` = u16, `l/L` = long,
- `v` = void, `i` = i32, `q` = i64, `f` = f32, `d` = f64, `B` = bool,
- `c` = i8/BOOL, `C` = u8, `s` = i16, `S` = u16, `l/L` = long,
`Q` = u64, `*` = `[*]u8`,
- `@` = id (object), `#` = Class, `:` = SEL, `^v` = `*void`.
- Struct: `{Name=field0field1...}`, nested + cycle-broken.
@@ -1159,7 +1159,7 @@ landing the parallel JNI codegen.
| 1.29 | `uikit_create_gl_context` — `alloc` / `initWithAPI:` / `setCurrentContext:` + duplicate of 1.27's screen-scale read | done |
| 1.30 | `uikit_subscribe_keyboard_notifications` — first standalone 4-keyword selector exercise (`addObserver:selector:name:object:`) | done |
| 1.31 | `uikit_scene_will_connect_ios` — biggest cluster; the iOS scene-lifecycle entry. UIWindow / UIViewController / SxGLView wiring; EAGL drawable-properties dict build; `nativeScale` + `setContentScaleFactor:` DPI path; `displayLinkWithTarget:selector:` + run-loop install. Exercises every return shape used in uikit.sx. Net -44 lines (104 → 60). | done (b3558c3) |
| 1.32 | `uikit_keyboard_will_change_frame` — `userInfo` / `objectForKey:` / `CGRectValue` / `doubleValue` / `unsignedLongValue` / `screen.bounds`. First standalone exercise of `#objc_call(CGRect)` (HFA, structurally equivalent to UIEdgeInsets) and `#objc_call(u64)` (LLVM-equivalent to s64). Net -14 lines. Runtime-verified by the locked-in test `examples/ffi-objc-call-12-rect-u64-returns.sx` (ac78490). | done (e1d300c) |
| 1.32 | `uikit_keyboard_will_change_frame` — `userInfo` / `objectForKey:` / `CGRectValue` / `doubleValue` / `unsignedLongValue` / `screen.bounds`. First standalone exercise of `#objc_call(CGRect)` (HFA, structurally equivalent to UIEdgeInsets) and `#objc_call(u64)` (LLVM-equivalent to i64). Net -14 lines. Runtime-verified by the locked-in test `examples/ffi-objc-call-12-rect-u64-returns.sx` (ac78490). | done (e1d300c) |
| 1.33 | **uikit.sx sweep — all remaining dispatch sites.** `renderbufferStorage:fromDrawable:` (bool, GL setup); `presentRenderbuffer:` (bool, every frame); `targetTimestamp` / `duration` (f64, every frame in `uikit_gl_view_tick`); `bounds` (CGRect, `uikit_compute_layer_pixel_size`); `locationInView:` (CGPoint HFA, every touch); `anyObject` (*void, every touch). First standalone `#objc_call(CGPoint)` exercise. Net -15 lines. Runtime-verified end-to-end: tapped a black pawn in iOS-sim chess and the move played correctly (1...d5, 2...d4). | done |
Verification per cluster: zig build / zig test / run_examples /
@@ -1178,9 +1178,9 @@ the work that remains is lowering + emit_llvm.
| 1.15 | `#jni_call(void)` codegen — new `.jni_msg_send` IR opcode + emit_llvm expansion: load `*env` for the vtable, GEP into slots 31 (GetObjectClass), 33 (GetMethodID), 61 (CallVoidMethod). No method-ID caching yet; static dispatch + non-void returns drop to `LLVMGetUndef` until 1.18+. | done (134c197 xfail + 9afcaa5 fix) |
| 1.16 | Lock in pre-caching IR shape — two `#jni_call` sites with literal `("noop", "()V")` emit two independent `GetMethodID` calls. IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`. | done (13018ef) |
| 1.17 | Literal-keyed slot interning — `JniMsgSend.cache_key: ?CacheKey` carries the literal `(name, sig)` pair from `lower.zig`; `emit_llvm.getOrCreateJniSlots` interns `@SX_JNI_CLS_<key>` and `@SX_JNI_MID_<key>` globals per unique pair; per-call lowering does null-check + lazy populate via `GetObjectClass` → `NewGlobalRef` (slot 21) → `GetMethodID` on miss. Two literal sites now share one slot pair. | done (0d883b4) |
| 1.18 | `#jni_call(s32)` → CallIntMethod (vtable slot 49). One arm added to the `call_method_offset` switch; reuses the 1.17 cache. | done (1d7ea72 xfail + ebcfe4c fix) |
| 1.18 | `#jni_call(i32)` → CallIntMethod (vtable slot 49). One arm added to the `call_method_offset` switch; reuses the 1.17 cache. | done (1d7ea72 xfail + ebcfe4c fix) |
| 1.18+ | Lift JNI vtable offsets into a `const Jni` named-constants struct. Pre-loaded Object/Boolean/Long/Float/Double slots so 1.191.22 are one-line switch arms. | done (c1877fc) |
| 1.19 | `#jni_call(s64)` → CallLongMethod (vtable slot 52). One arm added. | done (da5b635 xfail + 5945a8c fix) |
| 1.19 | `#jni_call(i64)` → CallLongMethod (vtable slot 52). One arm added. | done (da5b635 xfail + 5945a8c fix) |
| 1.20 | `#jni_call(f64)` → CallDoubleMethod (vtable slot 58). First non-integer JNI return. | done (xfail + ca4ba75 fix) |
| 1.21 | `#jni_call(bool)` → CallBooleanMethod (vtable slot 37). | done (xfail + b0e8659 fix) |
| 1.22 | `#jni_call(*void)` → CallObjectMethod (vtable slot 34). Pointer-return detected via `TypeInfo.pointer | .many_pointer` ahead of the primitive switch. LocalRef cleanup deferred — chess consumes objects inline. | done (xfail + b5694cc fix) |
@@ -1200,7 +1200,7 @@ All ten sub-steps (1.151.24) shipped. `#jni_call(T)` and
`#jni_static_call(T)` lower to JNI vtable indirection with shared
`(name, sig)` literal-keyed slot interning (one `jclass GlobalRef` +
one `jmethodID` per unique pair, populated lazily on the first
matching call). Return-type matrix covers `void` / `s32` / `s64` /
matching call). Return-type matrix covers `void` / `i32` / `i64` /
`f64` / `bool` / `*T`. Static dispatch skips `GetObjectClass` and
uses the parallel `GetStaticMethodID` + `CallStatic<Type>Method`
family. Both OS gates verified by `cross_compile.sh` (3/3 tuples
@@ -1284,14 +1284,14 @@ alias; no lowering yet.
| # | What | Status |
|-----|---|---|
| 2.8 | `src/ir/jni_descriptor.zig` + `.test.zig`. `writeType` appends one JNI descriptor for an sx type AST node; `deriveMethod` returns the full `(args)ret` descriptor for a `ForeignMethodDecl`, skipping the implicit `self` on instance methods. `Context.enclosing_path` resolves `*Self` to its `L<path>;` form. Primitive table-driven (void→V, bool→Z, s8/u8→B, s16→S, u16→C, s32→I, s64→J, f32→F, f64→D); arrays `[]T`/`[*]T`/`[N]T` → `[<elem>`. Cross-class `*Foo` → explicit error (lands in 2.9). 10 unit tests pass. **Cadence note**: landed as single commit since internal compiler functions don't have a sx-level snapshot surface yet — the rule re-applies at 2.11 where call-site lowering becomes end-to-end observable. | done (21c4906) |
| 2.8 | `src/ir/jni_descriptor.zig` + `.test.zig`. `writeType` appends one JNI descriptor for an sx type AST node; `deriveMethod` returns the full `(args)ret` descriptor for a `ForeignMethodDecl`, skipping the implicit `self` on instance methods. `Context.enclosing_path` resolves `*Self` to its `L<path>;` form. Primitive table-driven (void→V, bool→Z, i8/u8→B, i16→S, u16→C, i32→I, i64→J, f32→F, f64→D); arrays `[]T`/`[*]T`/`[N]T` → `[<elem>`. Cross-class `*Foo` → explicit error (lands in 2.9). 10 unit tests pass. **Cadence note**: landed as single commit since internal compiler functions don't have a sx-level snapshot surface yet — the rule re-applies at 2.11 where call-site lowering becomes end-to-end observable. | done (21c4906) |
| 2.9 | Cross-class `*Foo` resolves via `Context.classes: ?*const ClassRegistry` (a `StringHashMap` of sx alias → foreign path). `*Self` and `*Foo` share one code path. Retired `CrossClassRefNotYetSupported` in favour of `UnknownClassAlias`, which fires for both "no registry provided" and "alias not in registry". | done (5188265) |
| 2.10 | `deriveMethod` short-circuits to the `jni_descriptor_override` (2.6 escape-hatch) when present, returning the override verbatim through an `allocator.dupe`. Bypasses normal derivation entirely — including resolution failures, which lets users escape `UnknownClassAlias` errors for synthetic-method cases. | done (ca840ff) |
## Phase 2B complete (signature derivation)
`src/ir/jni_descriptor.zig` handles every shape the parser can hand it:
- Primitive types: `void/bool/s8..s64/u8/u16/f32/f64` → JNI single-char.
- Primitive types: `void/bool/i8..i64/u8/u16/f32/f64` → JNI single-char.
- Arrays / slices / many-pointers: `[<elem>` (recursive).
- `*Self` → `L<enclosing_path>;`.
- `*Foo` → looks up Foo's foreign path in the supplied registry.
@@ -1431,8 +1431,8 @@ When sx grows the cross-target story far enough:
forwarding, R.1R.5 retiring the legacy NativeActivity surface — all
landed; chess on Pixel runs end-to-end as the integration witness).
JNI return + parameter type validation lives in lowering with source-
spanned diagnostics; Call<T>Method coverage spans bool / s8 / s16 /
u16 / s32 / s64 / f32 / f64 / pointer; varargs promotion is wired.
spanned diagnostics; Call<T>Method coverage spans bool / i8 / i16 /
u16 / i32 / i64 / f32 / f64 / pointer; varargs promotion is wired.
Phase 3 step 3.0 landed (for real this time): `inst.method(args)` on
an `#objc_class` / `#objc_protocol` receiver derives the selector via
@@ -1534,7 +1534,7 @@ type-check.
turned out to be a red herring: the actual root cause was that
`inferExprType` for a chained call `Cls.static().instance(...)` never
looked the inner call's foreign-class declaration up, so the outer
dispatch saw a `.s64` receiver, the `foreign_class_map.get(...)` lookup
dispatch saw a `.i64` receiver, the `foreign_class_map.get(...)` lookup
missed, and lowering emitted `error: unresolved 'method'`. The macOS
target appeared to work because `inline if OS == .ios { ... }` strips
the gated body before lowering — eliding every call that would have
@@ -1832,11 +1832,11 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
`emitFunctionDecl` ([src/ir/emit_llvm.zig:682](src/ir/emit_llvm.zig#L682))
passes `is_var_arg=1` to `LLVMFunctionType` accordingly. New
`promoteCVariadicArgs` applies C default argument promotion
(`bool/s8/s16/u8/u16 → s32`, `f32 → f64`) to extras past the fixed
(`bool/i8/i16/u8/u16 → i32`, `f32 → f64`) to extras past the fixed
param count. `packVariadicCallArgs` early-outs for foreign+variadic
so the slice-packing path is bypassed entirely. New test
`examples/ffi-foreign-cvariadic.sx` + `.c` exercise s64 / f64 / s32
returns through C `va_arg` over s32 / f64 / `*u8` element types.
`examples/ffi-foreign-cvariadic.sx` + `.c` exercise i64 / f64 / i32
returns through C `va_arg` over i32 / f64 / `*u8` element types.
Stale-snapshot drift from in-progress std.sx additions (`xml_escape`,
`path_join`, `BuildOptions.set_post_link_*`) re-pinned in 12
expected files — verified all diffs were dead-decl additions, string
@@ -1878,10 +1878,10 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
nonvirt : CallNonvirtualByteMethod=70 / Char=73 / Short=76
static : CallStaticByteMethod=120 / Char=123 / Short=126
Each variant's `.jni_msg_send` return-type switch grew rows for
`.s8` / `.s16` / `.u16` (jbyte / jshort / jchar). New
`.i8` / `.i16` / `.u16` (jbyte / jshort / jchar). New
`LLVMEmitter.jniPromoteVararg(val, raw_ty)` handles the call-site
promotion that JNI's variadic Call<T>Method runtime expects:
s8 / s16 → SExt to i32
i8 / i16 → SExt to i32
u8 / u16 / bool → ZExt to i32
f32 → FPExt to f64
Pointers and wide types pass through unchanged. Wired into all
@@ -1895,8 +1895,8 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
hiding the more useful "unsupported return/parameter type at
this token" diagnostic. New cross-compile test
`examples/114-jni-promoted-narrow-types.sx` exercises a
`#jni_class` returning `s8 / s16 / u16` and a varargs method
taking `(s8, s16, u16, f32)`; IR shows the expected
`#jni_class` returning `i8 / i16 / u16` and a varargs method
taking `(i8, i16, u16, f32)`; IR shows the expected
`sext i8 → i32`, `sext i16 → i32`, `zext i16 → i32`, and
`double 1.5e+00` (FPExt folded for the constant) at the call
site. Tests 112 / 113 migrated to use `u32` (Java has no
@@ -1910,7 +1910,7 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
methods) and rejects unsupported parameter types at the type
token's span; `lowerJniCall` validates each method arg's TypeId
post-lowering against the arg expression's span. Same supported
set as returns (bool / s32 / s64 / f32 / f64 / pointer) minus
set as returns (bool / i32 / i64 / f32 / f64 / pointer) minus
`void` for params. Refactor splits `validateJniReturnType` /
`validateJniParamType` over a shared `validateJniType` core that
formats the diagnostic with a "return type" / "parameter type"
@@ -1923,7 +1923,7 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
`examples/113-jni-unsupported-param-type.sx` locks in the
parameter-type diagnostic shape (e.g.
`examples/113-jni-unsupported-param-type.sx:16:30: error: JNI
call 'Foo.take': unsupported parameter type 's8' (...)`). 143
call 'Foo.take': unsupported parameter type 'i8' (...)`). 143
host + 9 cross tests green; chess on Pixel still builds clean.
- 2026-05-20: JNI return-type validation lifted from emit_llvm
@@ -1931,11 +1931,11 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
in `src/ir/lower.zig`) so the diagnostic carries the
return-type slot's source span. New
`Lowering.validateJniReturnType` helper mirrors the supported
set in emit_llvm's `.jni_msg_send` switch (void / bool / s32 /
s64 / f32 / f64 / pointer types); a `*Foo.bad()` call where
set in emit_llvm's `.jni_msg_send` switch (void / bool / i32 /
i64 / f32 / f64 / pointer types); a `*Foo.bad()` call where
`bad()` returns an unsupported type now produces e.g.
`examples/112-jni-unsupported-return-type.sx:15:29: error:
JNI call 'Foo.bad': unsupported return type 's8' (JNI lowering
JNI call 'Foo.bad': unsupported return type 'i8' (JNI lowering
supports ...)`. emit_llvm's diagnostic stays as defense in
depth — it would only fire if a future IR path bypasses the
lowering check. New focused test
@@ -1969,13 +1969,13 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
- 2026-05-20: chess-on-Pixel size bug fixed by refactoring
`library/modules/platform/android.sx` to zero module-level
globals. Root cause: android.sx exported `g_viewport_w : s32 = 0`
and `g_viewport_h : s32 = 0` at module scope; chess's `main.sx`
globals. Root cause: android.sx exported `g_viewport_w : i32 = 0`
and `g_viewport_h : i32 = 0` at module scope; chess's `main.sx`
declared its own `g_viewport_w : f32 = 800.0` at module scope.
When chess `#import`ed android.sx, the imported public global
shadowed chess's local decl for the unqualified name resolution,
so chess's writes (`g_viewport_w = fc.viewport_w`) silently
clobbered android.sx's s32 with the logical f32 cast to s32 (414
clobbered android.sx's i32 with the logical f32 cast to i32 (414
instead of 1080). `Gles3Gpu.pixel_w` then fed `glViewport(0,0,
414,831)`, clipping rendering to a 414-pixel box in the GL-
bottom-left. Refactor moved every piece of Android backend state
@@ -2008,7 +2008,7 @@ zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_co
`lowerObjcStaticCall` route through the same helper so the IR Ref's
recorded ret_ty matches what `inferExprType` reports. Pre-fix:
`UIWindow.alloc().initWithWindowScene(scene)` (and any other chained
shape) collapsed the inner ret to `.s64`, the next dispatch's
shape) collapsed the inner ret to `.i64`, the next dispatch's
`foreign_class_map.get(...)` missed, and lowering emitted
`error: unresolved 'initWithWindowScene'`. The "lazy-lower" wording in
the issue file is a red herring — the bug fires on direct calls too;

View File

@@ -5,8 +5,41 @@ Tracking checkpoint for the mem.sx Zig-aligned implementation
## Last completed step
- **`resolveType(null) → .s64` silent fallback removed.** `resolveType`
now takes a non-optional `*const Node`; the `null → .s64` branch is
**Allocator primitive rename — `alloc``alloc_bytes`,
`dealloc``dealloc_bytes` (`88bae3c`, 2026-06-11).** Phase 4's naming
piece pulled forward per Agra's call (Option A in the 2.2 naming fork):
the bare names free up NOW so the Phase 2.2 helpers land under their
FINAL Appendix-A names (`alloc(T,n)` / `free(s)` / `create` / `destroy`
/ `clone` / `resize` / `mem_realloc`) with zero later churn. Phase 4
shrinks to signature expansion (size/align params + resize/remap +
deinit) only.
What changed (signatures unchanged — 2-method era continues):
- `std.sx` protocol decl; `std/mem.sx` 6 impls + internals; library
call sites (glyph_cache/json/state/renderer); 13 example .sx files
(incl. the two custom `Tracer` impls in 0306/0808 — caught broken
mid-step by stdout-diff review BEFORE pinning; the first `--update`
had captured their broken output, restored + fixed + re-pinned).
- Compiler: `interp.zig` thunk-name lookups
(`__thunk_CAllocator_Allocator_alloc_bytes`/`_dealloc_bytes`) — the
ONE hard name coupling; `allocViaContext` + default-context emission
are slot-positional (rename-safe); ffi.zig's `"alloc"` check is the
Obj-C `Cls.alloc()` intercept (unrelated, untouched).
- 37 `.ir` snapshots re-pinned (thunk symbols + reflection
field-name strings); all 37 verified stdout-clean before update;
path-noise churn reverted.
- External repos migrated + gated: game (`main.sx`, SxChess.app
builds + bundles) and m3te (`board_fx.sx`, `main.sx`,
`tools/key_particle.sx`; tools/run_tests.sh 23/23). Obj-C zero-arg
`.alloc()` calls are NOT protocol calls — excluded by pattern.
Gates: zig build 0, zig build test 0, suite 582/582, m3te 23/23,
game rebuilt.
<details><summary>Prior steps (2026-05-25 era)</summary>
- **`resolveType(null) → .i64` silent fallback removed.** `resolveType`
now takes a non-optional `*const Node`; the `null → .i64` branch is
gone. Callers that legitimately had no annotation handle it
themselves: top-level `var_decl` at `lower.zig:630` infers from the
initializer or diagnoses if neither is present (matches the
@@ -21,7 +54,7 @@ Tracking checkpoint for the mem.sx Zig-aligned implementation
...`), which makes the always-non-null path obvious from the type.
Real-world impact: `g_pi := 3.14;` at the top level used to be
silently typed as `s64`. Now it infers as `f64`. Regression at
silently typed as `i64`. Now it infers as `f64`. Regression at
`examples/137-toplevel-var-type-inference.sx` (count/pi/flag — int /
float / bool inferred correctly). 159/159 example tests + chess
clean.
@@ -128,7 +161,7 @@ Tracking checkpoint for the mem.sx Zig-aligned implementation
recursive heap content).
Also closes the type-inference half of the same bug: `NAME :: #run
expr;` with no annotation used to default to `s64` (silent fallback
expr;` with no annotation used to default to `i64` (silent fallback
in `resolveType(null)`). `lowerComptimeGlobal` now infers from the
expression's return type when no annotation is provided. The
silent fallback in `resolveType` itself is left in place for other
@@ -140,8 +173,66 @@ Tracking checkpoint for the mem.sx Zig-aligned implementation
of zeros, on both interp and codegen paths. 156/156 example tests
+ chess clean.
</details>
## Current state
**std.sx-as-pure-re-exports plan COMPLETE** (2026-06-11,
Agra-directed end-to-end). std.sx is now a facade of alias
declarations only (`49a36bb`): implementations live in std/core.sx
(builtins, libc escape hatch, Context/Allocator/Into/Source_Location/
`string` — the reserved name needs and permits no alias), std/fmt.sx
(print/format/any_to_string/string ops), std/list.sx (List); the
namespace tail is unchanged and `core`/`fmt`/`list` carry alongside
it. Consumer surface byte-identical; 37 .ir snapshots re-pinned
(pure renumbering, digit-normalized diff empty).
Issues filed AND resolved along the way (all same-day,
Agra-authorized): 0120 generic-struct head aliases (`f2db8ec`,
example 0211), 0121 fn aliases of every kind incl. comptime-pack
(`721369a`, example 0546), 0122 whole-program passes pinning the
source context per decl (`340be40` — latent on master, exposed by
the facade; coverage 0129/1047/1049/1052/1053/1056). Protocol
aliases (plain + Into's xx path) and #builtin/#foreign aliases
probe-verified working; param-protocol impl dot-calls are a designed
opt-in gap, not a bug.
Gates at completion: zig build test 426/426, suite 588/588,
m3te 23/23, game SxChess builds + bundles. Suite baseline 588.
**(2026-06-11) Phase-by-phase ground truth** (verified against the
tree; the sections below this one are the 2026-05-25 era record):
- Phase 1 DONE (xx heap-copy via context.allocator; serializer; the
whole implicit-Context refactor).
- Phase 2.1 DONE-equivalent: allocators.sx became `std/mem.sx` via the
STDLIB restructure (`59f0aa7`).
- **Primitive rename DONE (`88bae3c`)** — see "Last completed step".
Protocol is `alloc_bytes(size)` / `dealloc_bytes(ptr)` (2-method
era signatures).
- Phase 2.2 helpers: NOT YET — next step. Final names per Appendix A:
`create(a,$T)->*T`, `alloc(a,$T,n)->[]T`, `destroy(a,ptr)`,
`free(a,slice)`, `clone(src,a)`, `resize(slice,a,n)`,
`mem_realloc(a,ptr,old,new,align)`. The `free` helper requires
REMOVING std.sx's bare `malloc`/`free` foreign decls (lines ~13-16;
`libc_malloc`/`libc_free` aliases already exist) and migrating ~30
bare uses (mostly examples) — fold into the helpers step.
- Phase 2.3: TrackingAllocator exists; FailingAllocator +
LoggingAllocator missing.
- Phase 3 (caller migration to helpers): not started.
- Phase 4: now signature-expansion only (alloc_bytes gains alignment;
dealloc_bytes gains size+align; + resize/remap/deinit) — naming
piece already landed.
- Phase 5 (--leak-check + specs Memory chapter): not started.
- NOTE: plan's "init returns Allocator via xx heap-copy" section is
SUPERSEDED by the by-value convention (CLAUDE.md "Allocator
construction"); BufAlloc.init still returns *BufAlloc (state lives
in the caller's buffer — review at Phase 4 whether to align).
- Suite baseline 582; gates now: zig build && zig build test &&
bash tests/run_examples.sh (+ m3te 23/23 + game build for
std-touching steps). The old 148-159 counts below are historical.
<details><summary>2026-05-25 era state (historical)</summary>
Phase 0.0c shipped (allocator API on one-line `init` returning `*T`;
TrackingAllocator added). 148/148 tests pass.
@@ -164,7 +255,7 @@ Phase 0 spike outcomes:
inst.zig BuiltinId, lower.zig (registry + return-type + reflection
handler), interp.zig (fallback), sema.zig (allowed-builtins list),
lsp/server.zig (both completion tables), library/modules/std.sx.
Smoke coverage added in `examples/50-smoke.sx` (u8/s32/s64/Point).
Smoke coverage added in `examples/50-smoke.sx` (u8/i32/i64/Point).
- **0.7** `#import` transitivity — surfaced and fixed via issue-0038.
- **0.8** `#foreign("c")` rename syntax — confirmed
`#foreign libc "name"`.
@@ -179,7 +270,7 @@ verification: `size_of(*u8)=8`, `size_of(Ptr where Ptr::*u8)=8`,
`size_of(?u8)=2`, `size_of(Maybe where Maybe::?u8)=2` — all clean on
interp + codegen.
Also landed during 0041/0042: the silent `.s64` fallback in
Also landed during 0041/0042: the silent `.i64` fallback in
`resolveTypeArg` is gone — unresolved type names now emit a real
diagnostic. Surfaced and removed two bogus `size_of(Complex)` /
`size_of(Sx)` calls in `examples/10-generic-struct.sx` that were
@@ -243,7 +334,7 @@ What landed:
store honours the destination width — no more "assume 8 bytes"
silent clobber. Regression test at
`examples/132-comptime-typed-store-widths.sx` exercises every
primitive width (u8/u16/u32/u64, s8..s64, bool, f32, f64) via
primitive width (u8/u16/u32/u64, i8..i64, bool, f32, f64) via
comptime checksums compared to runtime checksums.
- Call-convention mismatch at bare-fn → fn-pointer coercion is now
a compile error (commit `f886d5f`). The chess-debug sweep that
@@ -270,8 +361,77 @@ bypass) is FULLY CLOSED. User-typed `context.allocator.X` flows
through the real protocol vtable at codegen *and* runs the same
chain at comptime in the interp. No remaining shortcut.
</details>
## Current state
**Opt-in UFCS landed (`a47ea14`, 2026-06-11)** — the canonical dot
surface now works: `context.allocator.create(Session)`,
`slice.clone(a)`. Agra specified the model in-session (three
clarifying rulings): free-fn dot-calls are OPT-IN via
`name :: ufcs (params) { body }` (NEW declaration form) or
`name :: ufcs target;` (alias); plain fns are direct/`|>`-only with a
tailored rejection. Implementation inverted TWO pre-existing gaps:
unannotated fns used to dot-dispatch (removed; 6 example files
audited + migrated, ZERO reliance in m3te/game) and aliases did NOT
dot-dispatch at all (0036 only ever pinned direct+pipe). Generic ufcs
fns bind `$T` from the receiver; protocol receivers dispatch own
methods first, fall through to ufcs fns for non-members
(protocolHasMethod gate in lower/call.zig). Root-cause bonus:
`inferGenericReturnType` now delegates to `buildTypeBindings` (ONE
binding builder) — structured generic params (`[]$T`) no longer type
direct calls as `T{}` stubs. mem.sx helpers marked `ufcs`; specs.md
§UFCS rewritten around the opt-in matrix. Tests: 0053 (matrix), 1166
(rejection), 0838 re-pinned (dot+pipe+direct). Gates: 585/585, zbt 0,
m3te 23/23, game builds. 0119 RESOLVED (final banner).
**Phase 2.2 DONE (`84e0fb0`, 2026-06-11).** The 0119 block resolved as
a LANGUAGE RULING, not a compiler fix (Agra, in-session): dot-form UFCS
on generic free functions is not the contract — UFCS free-fn
dot-dispatch is the annotated `ufcs` alias mechanism (concrete
targets), and the FLUENT spelling for free functions is the pipe:
`context.allocator |> create(Session)` desugars at parse time to the
direct call, which dispatches generics through normal monomorphization
(verified for protocol + slice receivers). specs.md §UFCS corrected
(it overstated "generic functions" for the dot form). Issue 0119
carries the RESOLVED banner; residual unfiled corner: a `ufcs` alias
naming a generic target doesn't dot-dispatch either.
Landed:
- `std/mem.sx` typed helpers, era-complete bodies, final names:
`create(a,$T)->*T`, `destroy(a,*$T)`, `alloc(a,$T,n)->[]T`,
`free(a,[]$T)`, `clone(src,a)`, `resize(slice,a,n)` (fresh storage +
copy + free-old; old slice dangles), `mem_realloc(a,ptr,old,new,
align)` (alloc+copy+dealloc; align unhonored until the protocol
carries alignment — documented inline). NO zero-init (Zig-aligned).
- std.sx bare `malloc`/`free` decls REMOVED (libc_malloc/libc_free
stay as the raw escape hatch); users migrated: examples
0205/0604/0804/0806/0808/1610 + game/chess/pieces.sx.
- Regression: examples/0838-memory-helpers.sx (whole surface, direct +
`|>` spellings, TrackingAllocator balances 8/8). 37 .ir re-pins
(constant-pool renumbering from the removed decls — the
ISSUE-MEM-004 cascade; all verified stdout-clean pre-update).
- KNOWN GAP: `string` does NOT bind a `[]$T` param (probe: "unknown
type 'T'") — string-clone story deferred (sx string is [:0]u8-shaped;
decide at Phase 4/5).
Gates: zig build 0, zbt 0, suite 583/583, m3te 23/23, game SxChess.app
builds.
## Next step
**Phase 2.3 — diagnostic wrappers**: `FailingAllocator` (delegates to
parent while budget remains, then alloc returns null) and
`LoggingAllocator` (tag-prefixed prints, delegates) in `std/mem.sx`,
2-method-era bodies, by-value `init` per the CLAUDE.md convention.
Then Phase 3 (migrate std.sx/library/example callers to the helpers —
NOTE std.sx itself cannot import mem.sx (circular); its internals keep
alloc_bytes), Phase 4 (protocol signature expansion: alignment + size
on the primitives, resize/remap/deinit — naming already landed),
Phase 5 (--leak-check + specs Memory chapter).
<details><summary>2026-05-25 era next-step record (historical)</summary>
Phase 1.3 (closure env allocation through context) shipped in commit
`8e21cc5`. Phase 1.4 (codegen serializer for all interp Value
variants) shipped this session. Phase 1.2 (free / malloc through
@@ -300,6 +460,8 @@ Open follow-ups, in roughly the order they make sense:
the canonical buffer/string case is already handled by `[]T` /
`string`.
</details>
## Phase 0.3 audit findings — chess allocator usage (closed)
After Step 5 / matchContextAllocCall removal, every consumer call
@@ -324,7 +486,84 @@ Allocator value naturally.
## Log
- **2026-05-25 (latest)** — `resolveType(null) → .s64` removed.
- **2026-06-11 (latest)** — Redundant flat `#import "modules/std/mem.sx"`
dropped from the facade (`c75cd9c`, Agra spotted it): the tail's
`mem ::` import already covers the graph needs (ufcs helpers +
CAllocator); the double import was duplicating lowered IR (~2.5k
lines across 37 re-pinned .ir snapshots, output byte-identical).
Gates: suite 588/588, zbt 0, m3te 23/23, game builds.
- **2026-06-11 (prior)** — std.sx restructured to a pure re-export
facade (`49a36bb`): all implementations moved to std/core.sx /
std/fmt.sx / std/list.sx; std.sx = alias decls + namespace tail.
En route, two more issues filed AND resolved: 0121 fn aliases
(`721369a` — renamed aliases were broken for EVERY fn kind, not
just packs; scan-time fn_ast_map registration via the shared
alias-chain walk; example 0546) and 0122 ambient source-context
bugs in convergeClosureShapeSets / checkErrorFlow / unknown-type
loop (`340be40` — latent on master, exposed by the facade).
Probe-verified before executing: protocols (plain + Into xx path),
#builtin / #foreign aliases, reserved `string` (no alias needed or
possible). 37 .ir re-pins (pure renumbering). Gates: zbt 426/426,
suite 588/588, m3te 23/23, game builds + bundles.
- **2026-06-11 (prior)** — Issue 0120 filed AND resolved (Agra-directed
same-session fix). Found probing the std.sx-as-pure-re-exports
restructure: generic-struct head alias (`BoxAlias :: Box;`) lowered
silently to `.unresolved` → LLVM backend panic; cross-module
`Box :: r.Box;` re-export invisible. Fix: `selectGenericStructHead`
follows const-alias decls hop-by-hop from each ALIAS AUTHOR's source
(`aliasedStructTemplate`, nominal.zig; `namespaceAliasVerdictFrom`
for `ns.X` RHS), checked before the template map so a facade's
same-name re-export beats an invisible global template; plus the
missing "unknown type" diagnostic on the `.call` type-head tail
(resolveTypeCallWithBindings). Also fixed PRE-EXISTING stale unit
test (calls.test.zig UFCS plan — predated a47ea14's opt-in model;
master was 425/426). specs.md Type Aliases + readme re-export
section + Decisions Log updated. Regression: examples/0211 (+rich/
+facade companions). Gates: zbt 426/426, suite 587/587.
- **2026-06-11 (prior)** — BufAlloc.init by-value (`51194a2`, Agra
request): init no longer carves its state struct off the buffer's
head (`-> BufAlloc`, plain literal; the old `-> *BufAlloc` cost 24
bytes of every buffer and returned null under min-size). The
CLAUDE.md by-value convention now holds for ALL allocators.
Regression: examples/0839 (full-capacity 64+64 on a 128 buffer —
failed pre-fix; exact-fit, overflow, reset). 0129's pinned output
unchanged. .ir churn: init's signature (sret) + renumbering.
Gates: 586/586, zbt 0, m3te 23/23, game builds.
- **2026-06-11 (earlier)** — Opt-in UFCS (`a47ea14`). Agra's model:
dot-calls opt-in via `:: ufcs (...)` marker or `:: ufcs target;`
alias; plain fns direct/`|>`-only. Parser (ufcs-fn form,
FnDecl.is_ufcs), call-plan + lowering gates (calls.zig,
lower/call.zig), protocol-receiver fall-through, generic dispatch
with receiver-bound `$T`, inferGenericReturnType → buildTypeBindings
(fixes pre-existing `T{}` mis-typing of structured-param generics).
6 reliant examples migrated; mem helpers marked ufcs; specs §UFCS
rewritten; tests 0053+1166+0838. 585/585, zbt 0, m3te 23/23, game
builds. 0119 closed with the full arc in its banner.
- **2026-06-11 (later)** — Phase 2.2 shipped (`84e0fb0`). Typed helpers
in std/mem.sx (create/destroy/alloc/free/clone/resize/mem_realloc,
era-complete bodies, no zero-init); bare malloc/free dropped from
std.sx (6 example files + game pieces.sx migrated to libc_*). The
0119 "blocker" resolved as Agra's language ruling: generic free fns
are NOT dot-rewritten — fluent spelling is `|>` (parse-time desugar
→ direct call → normal monomorphization; verified on protocol +
slice receivers). specs.md §UFCS corrected; 0119 RESOLVED banner.
Regression examples/0838 (direct + |> spellings; tracker 8/8).
37 .ir re-pins (const-pool renumbering). Gates: 583/583, zbt 0,
m3te 23/23, game builds. String-clone deferred (string doesn't bind
[]$T — known gap noted).
- **2026-06-11** — Allocator primitive rename (`88bae3c`): protocol
`alloc``alloc_bytes`, `dealloc``dealloc_bytes` (2-method-era
signatures unchanged). Phase 4's naming piece pulled forward (Agra
Option A) so Phase 2.2 helpers land final-named once. Touched:
std.sx decl, mem.sx 6 impls, 4 library files, 13 examples (incl.
two custom Tracer impls — initially missed, caught by pre-pin
stdout review after the first --update captured their broken
output; restored, fixed, re-pinned), interp.zig thunk-name strings,
37 .ir snapshots. Externals migrated + gated: game (SxChess.app
builds) + m3te (23/23). Suite 582/582, zbt 0. Discriminator note:
Obj-C `.alloc()` is zero-arg; Allocator `.alloc(size)` has an arg —
the sed keyed on `\.alloc\((?!\))`.
- **2026-05-25 (latest)** — `resolveType(null) → .i64` removed.
Signature changed to non-optional `*const Node`; 12 callers
surveyed and classified. The three unguarded ones — top-level
`var_decl` at `lower.zig:630` (now mirrors lowerVarDecl's
@@ -334,7 +573,7 @@ Allocator value naturally.
`if (x != null)` blocks; cleaned up to optional-payload syntax.
`examples/137-toplevel-var-type-inference.sx` proves the visible
win: `g_pi := 3.14;` at module scope now infers `f64` (used to be
silent `s64`). 159/159 + chess clean.
silent `i64`). 159/159 + chess clean.
- **2026-05-25 (penultimate)** — Phase 1.4a shipped. `valueToLLVMConst`
takes IR `TypeId` (not LLVM type) + an interpreter handle.
String/slice fat pointers are serialized by capturing the
@@ -377,8 +616,8 @@ Allocator value naturally.
at line 676 now passes `global.name` so the diagnostic locates the
offending `#run` site. `lowerComptimeGlobal` (`lower.zig:6384`)
infers the return type from the expression when the user omits
the type annotation — closes the silent-s64 default for `NAME ::
#run expr;` bindings. The broader `resolveType(null) -> .s64`
the type annotation — closes the silent-i64 default for `NAME ::
#run expr;` bindings. The broader `resolveType(null) -> .i64`
fallback is left in place for other callers — flagged for a
follow-up audit. Regression at
`examples/134-comptime-aggregate-global.sx`. 156/156 + chess green.
@@ -448,7 +687,7 @@ Allocator value naturally.
(`Tracer.count = 1`) — interp + codegen parity. 152/152 +
chess green.
- **2026-05-24** — issue-0041 and issue-0042 both fixed end-to-end.
Also removed the silent `.s64` fallback in `resolveTypeArg`,
Also removed the silent `.i64` fallback in `resolveTypeArg`,
guarded the two upstream callers (`buildTypeBindings`,
`inferGenericReturnType`) with `type_bridge.isTypeShapedAstNode`,
and fixed three parser regressions introduced by the 0041 work
@@ -520,8 +759,8 @@ Allocator value naturally.
`mi.ret_type == void_ptr`, but `*void` is overloaded — both
Self-disguised-as-*void AND a literal `-> *void` return appear as
the same `TypeId`. With `target_type` leaking from the enclosing
function's return type (e.g. `main -> s32`), every `*void` return
was loaded as `s32`, yielding 0 → null. Fix: stash `ret_is_self`
function's return type (e.g. `main -> i32`), every `*void` return
was loaded as `i32`, yielding 0 → null. Fix: stash `ret_is_self`
on `ProtocolMethodInfo` during `registerProtocolDecl` (set when
the AST return-type node is the `Self` type-expr), and gate the
unbox on that flag. Regression at
@@ -542,12 +781,12 @@ Allocator value naturally.
## Known issues (discovered during execution)
### ISSUE-MEM-001: Type inference defaults `p := malloc(64)` to `s64`
### ISSUE-MEM-001: Type inference defaults `p := malloc(64)` to `i64`
**Severity:** medium (workaround exists; bites unexpectedly).
**Symptom:** Writing `p := malloc(64)` (no explicit type) infers
`p: s64` instead of `p: *void`. Subsequent `free(p)` then fails LLVM
`p: i64` instead of `p: *void`. Subsequent `free(p)` then fails LLVM
verification with "Call parameter type does not match function
signature!" because `free` expects `ptr` but receives `i64`.
@@ -556,8 +795,8 @@ or `xx malloc(64);` at the call site.
**Reproduction:**
```sx
main :: () -> s32 {
p := malloc(64); // p inferred as s64
main :: () -> i32 {
p := malloc(64); // p inferred as i64
free(p); // LLVM verify fails: ptr expected, i64 given
0;
}
@@ -565,7 +804,7 @@ main :: () -> s32 {
**Root cause:** Likely in the inference path for `:=` declarations
when the RHS is a `*void`-returning #builtin. The compiler defaults
the binding to s64 instead of matching the return type. To
the binding to i64 instead of matching the return type. To
investigate in a future session.
**Status:** Open. Not blocking mem.sx work but worth fixing as a
@@ -681,9 +920,9 @@ where users need the underlying state (TrackingAllocator).
its auto-unbox path on `mi.ret_type == void_ptr`, but the same
`TypeId` covers both Self-disguised-as-*void and a literal
`-> *void`. With `target_type` leaking from the surrounding
function (e.g. `main -> s32`), every protocol call returning
function (e.g. `main -> i32`), every protocol call returning
`*void` got its result loaded as `sizeof(target_type)` bytes — for
`s32` that's the first 4 bytes of the malloc'd block, which were
`i32` that's the first 4 bytes of the malloc'd block, which were
zero, comparing equal to null.
**Fix:** Tag `ProtocolMethodInfo` with `ret_is_self: bool`, set in

View File

@@ -0,0 +1,32 @@
# CHECKPOINT-REIFY — comptime `type_info` / `reify` (async-first foundation, step 3)
Companion to [PLAN-REIFY.md](PLAN-REIFY.md). Update after every step (one step at a
time, per the cadence rule).
## Last completed step
**None — stream just carved.** Design validated (3 codebase reviewers; all five reify
contracts confirmed feasible). No code written yet.
## Current state
- The plan + the five locked contracts exist in `PLAN-REIFY.md`; design-of-record is
`design/execution-evolution-roadmap.md` §7 step 3 + §8.1.
- **Nothing built.** `reify`/`type_info`/`field_type` do not exist in the compiler.
- Confirmed against the source (anchors in the plan): type minting via
`intern`/`internNominal` is programmatic and AST-free; type-fns memoize by mangled
name; enum codegen is fully type-table-driven (zero AST coupling); recursive
forward-declaration (reserve→complete) already exists for source types.
## Next step
**Phase 0.0 (lock):** add `TypeInfo`/`EnumInfo`/`EnumVariant` data types + bodyless
`#builtin` decls for `reify`/`type_info`/`field_type` to `library/modules/std/core.sx`
(parsed, unimplemented → loud bail), with a unit test that the decls parse. Then 0.1
(xfail: `examples/06xx-comptime-reify-enum.sx`) → 0.2 (green: implement `reify(.enum_)`).
## Known issues
None yet.
## Log
- **Stream carved.** Selected as the first async-first foundation: `reify` gates both
channel result types (`RecvResult($T)`) and `race`'s synthesized union, is fully
validated (3 reviewers), and is a self-contained compiler/type-system feature
testable in isolation (`06xx` comptime). Generic-enum syntax dropped in its favor.

167
current/PLAN-ASM.md Normal file
View File

@@ -0,0 +1,167 @@
# sx Inline Assembly — Implementation Plan (ASM stream)
**Design source of truth:** [design/inline-asm-design.md](../design/inline-asm-design.md).
This plan turns that doc's §II.7 stage-map + §II.8 phasing into ordered,
commit-sized, testable steps. Read the design doc first — this file is the
*how/when*, not the *what/why*.
**Surface (decided):**
`asm volatile { "template", "=r" -> T, "r" = expr, clobbers(.cc, .memory) }`
— brace block; `->` output / `=` input; `clobbers(.…)` dot-name list; N `-> Type`
outputs return a tuple; templates are pure AT&T (via LLVM).
**Feasibility (confirmed):** sx links LLVM@19; `src/llvm_api.zig` `@cImport`s
`llvm-c/Core.h`, so `llvm_api.c.*` already exposes `LLVMGetInlineAsm` (9-arg),
`LLVMInlineAsmDialectATT`, `LLVMBuildCall2`, `LLVMAppendModuleInlineAsm`. No shim.
**Relationship to other streams:**
- Phases AE (the inline-asm *expression*) are independent of EXTERN-EXPORT.
- Phase F (global asm) consumes `extern`/`export` to import/expose asm symbols —
do it **after** `PLAN-EXTERN-EXPORT.md` Phase 2.
## Cadence (IMPASSIBLE)
No commit may both add a test AND make it pass. Each feature step is either a
behavior-locking PASSING test, or an xfail test the *next* commit turns green.
Arch-pinned tests live in `examples/16xx-platform-asm-*` and declare their target
via the `expected/<name>.target` sidecar marker (Phase 0). Never regenerate
snapshots while red.
## Phase 0 — corpus target-gating (test-infra prerequisite; no compiler code)
**Why first.** The flagship v1 examples are `x86_64` (syscall-write, divmod,
cpuid) but the dev host is `aarch64`-Darwin, and the corpus runner
([src/corpus_run.test.zig](../src/corpus_run.test.zig)) currently (a) never threads
a per-example `--target` and (b) has no host-arch gate — its only skip is "marker
has no `.sx`". So D.0's `…-syscall-write` markers asserting exit/stdout describe
output the harness *cannot* produce on this host, which would violate the cadence
rule (the "next commit turns it green" can never happen). Phase 0 closes that gap.
It touches **only the runner + two fixtures** — zero compiler code, zero risk to
AE, and unblocks every arch-pinned asm example.
**Marker taxonomy (the cleanup).** The runner currently spreads per-example
*directives* across standalone boolean/value sidecars (`.aot` now, `.target`
proposed, more later). Replace that sprawl with **one optional config file,
`expected/<name>.build`**, holding all build/run directives; the output snapshots
(`.exit` / `.stdout` / `.stderr` / `.ir`) stay separate — they are
machine-regenerated data, not config. `.exit` remains the **test-discovery key**
(every test has one; `.build` is optional).
**`.build` format** — JSON, parsed with `std.json`:
```json
{ "aot": true, "target": "x86_64-linux" }
```
Parse via `std.json.parseFromSlice(BuildConfig, …)` into
`struct { aot: bool = false, target: ?[]const u8 = null }`. Field defaults cover
omitted keys; `std.json`'s default `ignore_unknown_fields = false` makes an
**unknown key a loud `error.UnknownField`** (surfaced as a runner failure, never a
silent ignore — CLAUDE.md no-silent-default rule). Extensible: future `"cpu"`,
`"link"`, `"cwd"` are just new optional struct fields, no new sidecar file and no
custom parser.
**What the directives do:**
1. **`target = <triple|shorthand>`** threads `--target <value>` into every `sx`
invocation for that example (`run` / `build` / `ir``--target` is a global
flag, confirmed [main.zig:39](../src/main.zig#L39)), AND **host-match selects
the mode.** The runner parses the leading `arch` + `os` tokens of the resolved
triple and compares them to `@import("builtin").target` (normalizing
`arm64``aarch64`):
- **match** → *execute* exactly as today (`sx run`, or `aot` build+exec) with
the target threaded, plus the `.ir` diff if an `.ir` snapshot exists. ⇒ an
x86_64 example gives **real end-to-end coverage on an x86_64 CI runner**.
- **mismatch** → **ir-only**: run *only* `sx ir <file> --target <t>`; assert
`.exit` (the ir command's exit), `.ir` (normalized stdout), and `.stderr`
(diagnostics, normally empty). Do **not** run/build/exec; do **not** assert
`.stdout`. An `.ir` snapshot is **required** in ir-only mode — its absence is
a loud runner failure ("arch-pinned <name>: ir-only mode requires an .ir
snapshot"), never a silent pass. Robust even if `sx ir` treats `--target` as
a partial no-op: the `inline_asm` op carries the template + constraint string
verbatim, so the IR snapshot still locks the exact thing §II.11 flags as
silently-miscompiling (the constraint assembler + template rewrite).
2. **`aot`** is the existing JIT-vs-build+exec switch, just relocated from the
standalone `.aot` marker into `.build`.
**Negative compile-error examples need NO `.build`.** `…-missing-volatile`
(no-output-without-`volatile`) is a Sema diagnostic raised before codegen/JIT, so
plain `sx run` reports it identically on any host — it stays a normal example with
no config file.
**update-goldens interaction:** in ir-only mode, `-Dupdate-goldens` writes `.exit`
(ir exit) + `.ir` (+ `.stderr` if non-empty) and skips `.stdout`. Execute mode
(incl. `aot`) is unchanged. `.build` is hand-authored — update-goldens never
writes it.
| Step | Commit | What | Files |
|---|---|---|---|
| 0.0 | lock | Add `BuildConfig` + `std.json` parse of `expected/<name>.build` (unknown-key ⇒ `error.UnknownField`); **migrate** the 2 existing `.aot` markers → `.build` (content `{ "aot": true }`) and delete them; thread `target`'s `--target` into the spawned argv; add `hostMatchesTarget(value) bool` (arch+os token parse, `arm64``aarch64`) gating the **execute** path. Lock with `examples/16xx-platform-target-host.sx` (trivial `main`) + a `.build` `{ "target": "<host arch triple>" }` (still runs+passes) and unit `test`s for the JSON parse + `hostMatchesTarget`. | `src/corpus_run.test.zig`, `examples/expected/1226-*.{aot→build}`, `…/1227-*`, + fixture |
| 0.1 | lock | Implement the **mismatch ⇒ ir-only** branch (skip run/build/exec; assert `.exit`+`.ir`+`.stderr` from `sx ir --target`; require `.ir`). Lock with `examples/16xx-platform-target-cross.sx` (asm-free `() -> i64 { return 0; }`) + `.build` `{ "target": "x86_64-linux" }` + a checked-in `.ir` snapshot — exercises ir-only on the arm64 host. | `src/corpus_run.test.zig` + fixture |
| 0.2 | docs | Update CLAUDE.md §"Test layout"/§"Testing" to document `.build` (format + `aot`/`target` keys) replacing the standalone `.aot` marker prose (lines ~435, ~492). | `CLAUDE.md` |
Both 0.0 and 0.1 are **lock** commits: the runner change and the fixture that
exercises it land together and pass the moment they land (the mechanism works
immediately — nothing is left red), which is the cadence rule's "lock in current
behavior" flavor, not a feature red→green. No asm lowering is gated on either.
**Phase 0 verification:** `zig build test` green; deliberately corrupt the
cross-target `.ir` fixture and confirm the runner reports an IR mismatch (proves
ir-only actually asserts, isn't a no-op); delete it and confirm the
"requires an .ir snapshot" failure fires.
**Estimated runner delta:** ~7090 lines (sidecar read + `--target` argv threading
+ `hostMatchesTarget` + the ir-only branch + update-mode tweak). Within the
"no step > ~500 new lines" rule; well under the read budget.
## Phase A — keyword + AST + parser (parses; no codegen)
| Step | Commit | What | Files |
|---|---|---|---|
| A.0 | lock | add `kw_asm` keyword + map entry; unit lex test `asm → kw_asm` | `src/token.zig`, `src/lexer.zig` + `.test.zig` |
| A.1 | xfail | parse `asm { … }``AsmExpr`/`AsmOperand` in `parsePrimary`; pin an AST/`sx ir` parse snapshot; lowering still `bailDetail("inline asm codegen unimplemented")` | `src/ast.zig` (:85 union arm, :721 structs), `src/parser.zig` (parsePrimary), `src/ir/interp.zig` |
| A.2 | green | parse-shape snapshot lands green; the unimplemented bail is loud + named | — |
## Phase B — sema / typing
| Step | Commit | What | Files |
|---|---|---|---|
| B.0 | xfail | result-type rule (0→`void` / 1→`T` / N→named-or-positional tuple) + checklist (no-output⇒`volatile`, layout, comptime-string template) — pin error messages | `src/ir/expr_typer.zig` |
| B.1 | green | typing + diagnostics implemented; `.unresolved` sentinel on failure (no silent default) | `src/ir/expr_typer.zig`, `src/ir/semantic_diagnostics.zig` |
## Phase C — IR op + lowering
| Step | Commit | What | Files |
|---|---|---|---|
| C.0 | lock | add `inline_asm: InlineAsm` to `Op` + `AsmOperand` (role/name/constraint/operand) + interp `bailDetail` arm; unit tests for the IR shape | `src/ir/inst.zig` (:80), `src/ir/interp.zig` |
| C.1 | xfail→green | `lowerAsmExpr` in `lowerExpr` dispatch — interns template/constraints/clobber-names, lowers input `Ref`s, sets result `TypeId` | `src/ir/lower/expr.zig` |
## Phase D — LLVM emit (single value-output; the core)
| Step | Commit | What | Files |
|---|---|---|---|
| D.0 | xfail | `examples/16xx-platform-asm-syscall-write.sx` + `…-register-read.sx` + `…-no-output-volatile.sx` + `…-missing-volatile.sx` (expected compile error) — all red | examples + `expected/` markers |
| D.1 | green | `emitInlineAsm`: **port `FuncGen.airAssembly`** — constraint-string assembler (outputs `=`/`+`, inputs, `clobbers(.name)``~{name}`), `%[name]``${N}` / `%%` / `%=` template rewriter, `LLVMGetInlineAsm`+`LLVMBuildCall2`, `sideeffect=volatile`, AT&T dialect | `src/ir/emit_llvm.zig` (emitInst dispatch + handler) |
| D.2 | green | lock the template-rewrite + constraint string via an `expected/*.ir` snapshot on `…-template-subst.sx` | examples |
**Phase D verification:** `zig build test`; the syscall example runs on
`x86_64-linux`; IR snapshot matches the design doc's worked `sys_write` lowering.
## Phase E — multi-return tuples + `clobbers(.…)`
| Step | Commit | What | Files |
|---|---|---|---|
| E.0 | xfail | `…-asm-multi-return.sx` (`divmod``(quot,rem)`, `cpuid`→4-tuple) red | examples |
| E.1 | green | N `out_value` → LLVM struct return + `extractvalue i` → sx tuple (named when operands named); `clobbers(.name)` dot-name lowering finalized | `src/ir/emit_llvm.zig`, `src/ir/lower/expr.zig` |
## Phase F — global asm (needs EXTERN-EXPORT Phase 2)
| Step | Commit | What | Files |
|---|---|---|---|
| F.0 | xfail | top-level `asm { … }` decl parsed (reject operands/`volatile`); `…-asm-global.sx` (defines a symbol, imported via `extern`) red | `src/parser.zig`, `src/ast.zig` |
| F.1 | green | lower `asm_global``c.LLVMAppendModuleInlineAsm`; comptime-call guard (dlsym-miss is loud); blocks concatenate in source order | `src/ir/lower/decl.zig`, `src/ir/emit_llvm.zig`, `src/ir/interp.zig` |
## Phase G — later (own steps when scheduled)
`-> @place` write-through + read-write (`"+r" -> @place`) + indirect-memory
(`"=*m"`) outputs · `%=` unique-id · output-to-const rejection · Intel-dialect
opt-in · naked functions (`callconv(.naked)`, coordinate with EXTERN-EXPORT).
## Open decisions (design doc §II.10)
Dialect (AT&T-only v1, recommended) · `volatile` contextual-keyword (recommended)
· brace separator comma (recommended) · `clobbers(.name)` dot-name sugar now →
checked per-arch `Clobber` enum later (Phase 4 of the design doc).
## End-to-end verification (per phase)
`zig build && zig build test`; for arch-pinned examples confirm they run on a
matching host or assert on `sx ir`/`.s` snapshots. After intentional output
changes only: `zig build test -Dupdate-goldens`, then review the diff.

124
current/PLAN-DIST.md Normal file
View File

@@ -0,0 +1,124 @@
# PLAN-DIST — bundle `zig` as sx's hermetic link/libc backend
## Goal
`sx build` produces a native binary by driving a **bundled `zig`**
(`zig cc`) as the linker, so a distributed sx on Linux needs no system
`cc`/lld/libc/CRT. `sx run` (JIT) is unaffected — it never links.
This is the "be like Zig" move: reuse Zig's hermetic toolchain (lld +
crt objects + musl/glibc, all bundled in the `zig` distribution) instead
of building our own lld-in-process + libc-from-source pipeline.
> **Configuration surface** (env vars, flags, resolution order,
> activation truth table, target→ABI map, distribution layout) is
> specified in [../design/bundled-zig-link-backend-design.md](../design/bundled-zig-link-backend-design.md) — the design-of-record
> for how the backend is configured. Keep the two files in sync.
## Locked decisions
1. **Default Linux output ABI = static musl** (`x86_64-linux-musl`,
`-static`). Output runs on ANY Linux with zero deps — the property
that makes Zig binaries portable. glibc/dynamic only via explicit
`--target x86_64-linux-gnu`.
2. **Activation = auto** when a bundled/resolvable `zig` exists AND the
user passed no `--linker`. Falls back to system `cc` otherwise.
3. **Dev uses PATH `zig`** (0.16.0 already installed). Defer copying a
vendored toolchain into `libexec/` until Phase 3 packaging.
## Why `zig cc`, not raw `ld.lld`
`zig cc` is a clang-compatible driver, so it slots into the **existing**
cc-style argv branch in `src/target.zig` almost unchanged, and supplies
lld + crt objects + musl/glibc automatically per `-target`. Driving
`ld.lld` directly would force us to locate/pass crt1.o/crti.o/libc
ourselves — exactly the work we're avoiding.
## Key code anchors (verified)
- Linker selection hook: `TargetConfig.getLinker()``src/target.zig:194-196`
(`self.linker orelse "cc"`).
- Unix `cc`-style link branch: `src/target.zig:524-564` (this is where
the zig backend hooks in; `-o`/`-L`/`-l`/extra objects already pass
through clang-compatibly).
- Exe-relative resolution pattern to mirror for finding zig:
`src/imports.zig:204-227` (`discoverStdlibPaths`, `$SX_STDLIB_PATH`
override + `<exe>/..` candidates).
- `--linker` CLI flag parsing: `src/main.zig:87-90`.
- Emit triple (must agree with link target): `src/ir/emit_llvm.zig`
(`LLVMSetTarget`, ~L246-284).
## Phases
### Phase 0 — Resolve a bundled/host zig
- New `src/zig_backend.zig`: `discoverZig(alloc) -> ?[]const u8`.
Resolution order:
1. `$SX_ZIG` env override.
2. `<exe>/../libexec/zig/zig` (install layout, Phase 3).
3. `<exe>/../../zig-bundle/zig` (dev vendored layout, Phase 3).
4. `zig` on `PATH` (dev fallback — active now).
- Add `SX_DEBUG_ZIG` trace, matching existing `SX_DEBUG_*` hooks.
- No behavior change yet; just resolution + a debug/print hook to confirm.
### Phase 1 — `zig cc` link backend (core change)
- `src/target.zig`: generalize the linker from a single token to a
**driver argv**. Today `getLinker()` returns one string at `argv[0]`;
introduce a `LinkBackend` so the internal backend contributes
`{zigPath, "cc"}` as leading entries.
- In the Unix branch (L524-564), when backend = zig:
- prepend `zig cc`,
- append `-target <mapped triple>`,
- add `-static` for musl,
- everything else (`-o`, `-L`, `-l`, extra objects, extra link flags)
passes through unchanged.
- Add `sxTripleToZig()` mapping (sx shorthand/triple → zig `-target`);
unspecified-on-Linux → `x86_64-linux-musl`.
- Align emit triple: when the zig backend is selected, set the LLVM
module triple in `emit_llvm.zig` to match the link target
(x86_64-linux), so the `.o` links cleanly against musl crt.
### Phase 2 — Activation
- Auto-enable: if `discoverZig()` succeeds and no `--linker` override,
use the zig backend for `sx build`. System `cc` remains the fallback.
- Optional explicit `--self-contained` / `--no-self-contained` to force.
- Confirm `sx run`/JIT path is untouched (no link step).
### Phase 3 — Distribution packaging
- `build.zig`: a `dist` step assembling
- `bin/sx` (built with `-Dstatic-llvm`),
- `libexec/zig/` (vendored zig binary **and its `lib/`**, copied from a
pinned ziglang.org release per host arch),
- `library/` (stdlib),
into a relocatable tarball.
- Pin the zig version (currently 0.16.0).
### Phase 4 — Verify & lock
- Manual first: `sx build hello.sx` (auto zig backend) then `file`/`ldd`
the output → expect "statically linked".
- Honor snapshot-integrity + FFI-cadence rules before adding a corpus
test (host/arch-gated, likely a `.build` sidecar).
## Risks / watch
- **Bundle size**: zig + its `lib/` ≈ 5060 MB.
- **gnu vs musl ABI**: pure codegen objects link fine against musl;
TLS/stack-protector are the only realistic friction. Aligning the emit
triple (Phase 1) covers the common path.
- **macOS/Windows cross** via the same `zig cc -target` is nearly free
after Phase 1, but Apple-SDK linking has caveats — scope to Linux
target first; treat the rest as follow-up.
- **c_import.zig** also shells `cc` for C imports (JIT). Out of scope
here; same backend can absorb it later.
## Status
- [x] Phase 0 — resolve zig (`src/zig_backend.zig`)
- [x] Phase 1 — zig cc link backend (`target.zig` + `emit_llvm` triple normalize)
- [x] Phase 2 — activation (`--self-contained`/`--no-self-contained`; auto on bundled zig)
- [ ] Phase 3 — dist packaging (vendor `zig` into `libexec/`)
- [ ] Phase 4 — verify & lock (manual ✓ macOS/Linux/Windows; corpus test pending runner `--self-contained` support)
Scope landed as **macOS + Linux + Windows** (not Linux-first). See the
"Implementation status" section in
[../design/bundled-zig-link-backend-design.md](../design/bundled-zig-link-backend-design.md)
for what refined the original locked decisions.

View File

@@ -0,0 +1,207 @@
# sx `extern` / `export` + `#foreign` retirement — Plan (FFI-linkage stream)
**One stream, two parts.** **Part A** adds `extern`/`export` (the linkage surface);
**Part B** migrates every `#foreign` onto it and purges `foreign` from the tree.
They are *one* plan: Part B can't start until Part A is a behavior-equivalent
superset of `#foreign`, and Part A isn't "done" until Part B reaches the invariant.
**Design rationale:** [design/inline-asm-design.md](../design/inline-asm-design.md) §II.2
(Deviation 6) + §II.10 #4 + the syntax evaluation.
**Decided syntax**
```sx
name :: (sig) -> Ret [callconv(.x)] [extern | export] [LIB] ["csym"] [;|{…}]; // functions
Name :: #objc_class("X") [extern | export] { … }; // aggregates (mirrors `struct #compiler`)
g : Type extern [LIB] ["csym"]; // extern global
```
- `extern` = import (no body, external linkage, C ABI, no sx ctx) — `#foreign`'s role.
- `export` = define **and** expose (body + external linkage + C ABI + no ctx) — **new**.
- `extern`/`export` imply `callconv(.c)`; write `callconv` only to override.
- Optional `LIB` (a `#library` alias) + `"csym"` rename mirror `#foreign LIB "csym"`,
so `extern` is a true `#foreign` **superset** (Gate A→B): carried on
`extern_lib`/`extern_name`. The `#library` declaration + build-flag linking
mechanism stays a separate axis — `extern` *references* a lib, it doesn't fold
in `#library` itself. (Revises the original "library fully separate" decision 4.)
> **END-STATE INVARIANT (hard requirement).** After this stream, `foreign` appears
> **nowhere** in the live tree — not the `#foreign` surface, and **not** internal
> identifiers. The extern AST is **not** named `foreign_expr`. Enforced by the
> Phase 9.4 grep gate. Scope today: 643 `foreign` lines / ~57 identifiers in `src/`
> + 28 in live docs — most of it the objc/jni **runtime-class** machinery.
**Naming constraint (so we can actually reach the invariant):** introduce
`extern`-named representations only — do **not** reuse or extend
`ForeignExpr`/`foreign_expr`/`VarDecl.is_foreign`. Carry extern/export on a new
`FnDecl.extern_export` modifier with a `;`/`{…}` body (so there is **no** `*_expr`
node for it) + `FnDecl.extern_lib`/`extern_name`; add `VarDecl.is_extern`/
`extern_lib`/`extern_name`. The IR is already extern-named (`Function.is_extern`,
`Builder.declareExtern`).
**Key finding (scopes Part A):** the IR + LLVM emit **already support everything**
`Function.linkage` (`.external/.internal/.private`), `is_extern`, `call_conv`, and a
raw un-mangled symbol name are all emitted by `declareFunction`
(`emit_llvm.zig:1225-1300`). Part A is a **parser + lowering** job, no codegen change.
## Cadence (IMPASSIBLE)
No commit may both add a test AND make it pass (xfail-then-green, or a behavior-lock).
`zig build && zig build test` after every step. Never regenerate snapshots while red.
---
# PART A — add `extern` / `export` (alongside `#foreign`)
## Phase 0 — tokens + parser plumbing
| Step | Commit | What | Files |
|---|---|---|---|
| 0.0 | lock | add `kw_extern`, `kw_export` (Tag enum + `StaticStringMap`, beside `kw_callconv` at `token.zig:45,282`); unit lex test | `src/token.zig` |
| 0.1 | lock | `parseOptionalExternExport()` (mirror `parseOptionalCallConv`, `parser.zig:3669`) + `ast.ExternExportModifier` enum + `FnDecl.extern_export` + `VarDecl.is_extern`/`extern_name` fields; **not yet consumed**; unit AST test | `src/parser.zig`, `src/ast.zig` |
## Phase 1 — `extern` (import; equivalent to lib-less `#foreign`)
| Step | Commit | What | Files |
|---|---|---|---|
| 1.0 | xfail | accept postfix `extern` after the callconv slot (`parser.zig:1950`); `examples/12xx-ffi-extern-fn.sx` extern-binds a libc symbol — red (lowering not wired) | `src/parser.zig` |
| 1.1 | green | lowering: `extern``is_extern`, `.external`, `callconv(.c)`, no ctx — route through `declareExtern` like a lib-less `#foreign` (anchors `decl.zig:1123,387,2110,2113`). Example green | `src/ir/lower/decl.zig` |
| 1.2 | green | optional `extern "csym"` rename + extern-global form `g : T extern;` (`parser.zig:425` path) | `src/parser.zig`, `src/ir/lower/decl.zig` |
## Phase 2 — `export` (define + expose; the NEW capability)
Fills the four export-gap conditions (all in `src/ir/lower/decl.zig`):
| Gap | Anchor | Fix |
|---|---|---|
| (i) linkage forced `.internal` | `:2382`, `:2514` | also `.external` when `extern_export == .export` |
| (ii) C ABI not promoted | `:2110` | also `.c` when `== .export` |
| (iii) no symbol-name override | `emit_llvm.zig:1226` raw name | parse optional `export "csym"`; map in the name map |
| (iv) ctx param not suppressed | `:387` `funcWantsImplicitCtx` | also suppress when `== .export` |
| Step | Commit | What | Files |
|---|---|---|---|
| 2.0 | xfail | multi-file test: an `export fn` called from a companion `.c` caller (same `XXXX-` prefix) — red (still internal) | `examples/12xx-ffi-export-fn.{sx,c}` + `expected/` |
| 2.1 | green | gaps (i),(ii),(iv): `export` ⇒ external + C-ABI + no-ctx on a **defined** fn (uses `beginFunction`, not `declareExtern`) | `src/ir/lower/decl.zig` |
| 2.2 | green | gap (iii): `export "csym"` symbol-name override | `src/parser.zig`, `src/ir/lower/decl.zig` |
## Phase 3 — aggregates (objc / jni runtime classes)
| Step | Commit | What | Files |
|---|---|---|---|
| 3.0 | xfail | `#objc_class("X") extern { … }` (import) + `… export { … }` (define) parse alongside legacy `#foreign #objc_class` | `src/parser.zig` (`tryParseForeignClassPrefix` :1305, `parseForeignClassDecl` :1369) |
| 3.1 | green | map postfix `extern`→reference, `export`→define+register; per-runtime tests (objc, jni) | `src/parser.zig`, `src/ir/lower/decl.zig`, `src/ir/lower/objc_class.zig` |
## Phase 4 — interplay, diagnostics, docs
`extern`+`callconv` stacking/redundancy; reject `extern`+`export` together;
`specs.md` documents `extern`/`export` (the three axes); `#foreign` still documented
until Part B cutover.
> **GATE A→B.** `extern`/`export` are a behavior-equivalent **superset** of
> `#foreign`. Lock with a unit test asserting `#foreign` and `extern` lower to
> identical IR for a sample fn / global / class. Do not start Part B before this.
---
# PART B — migrate `#foreign` → `extern`/`export`, then purge `foreign`
**Inventory (drives the batches):** `#foreign` = 466 uses. ~391 sx-code (308 fns
[207 lib / 196 rename], 75 classes [39 objc / 31 jni], 8 globals) + ~145 example
snapshots. 6 libs (`sqlib`98 `libc`61 `objc`22 `tlib`12 `raylib`7 `clib/pcaplib`3).
Hotspots: `vendors/sqlite`(98), `platform/{android,uikit,android_jni,sdl3}`,
`std/{socket,thread,fs,time}`, `ffi/{objc,raylib}`.
## Phase 5 — `#foreign` becomes an alias for `extern`
| Step | Commit | What | Files |
|---|---|---|---|
| 5.0 | lock | route the `#foreign` parser paths (`parser.zig:316,425,1305,1970`) to build the *same extern-named* AST as `extern`/`export`. Suite green, snapshots unchanged | `src/parser.zig` |
| 5.1 | lock | unit test: `#foreign` and `extern` produce identical IR (fn/global/class) | `src/ir/lower/decl.test.zig` |
## Phase 6 — migrate stdlib (behavior-preserving; snapshot diff must be EMPTY)
One commit per batch; rewrite `#foreign``extern` (fns/globals),
`#foreign #objc_class``#objc_class … extern`, defined classes → `… export`.
| Step | Batch | ~sites |
|---|---|---|
| 6.1 | `library/vendors/sqlite/` | 98 |
| 6.2 | `library/modules/platform/` (uikit/android/android_jni/sdl3) | ~95 |
| 6.3 | `library/modules/std/` (socket/thread/fs/time/process/…) | ~60 |
| 6.4 | `library/modules/ffi/` (objc/raylib/objc_block/…) | ~50 |
| 6.5 | remaining `library/` + `vendors/` | remainder |
## Phase 7 — migrate examples + issues (empty snapshot diff; review every diff)
| Step | Batch |
|---|---|
| 7.1 | `examples/12xx-ffi-*` (plain C) |
| 7.2 | `examples/13xx-ffi-objc-*` |
| 7.3 | `examples/14xx-ffi-jni-*` |
| 7.4 | `issues/*` repros + stragglers |
A non-empty diff ⇒ the alias wasn't behavior-equivalent — stop, fix Phase 5.
## Phase 8 — cutover
| Step | Commit | What |
|---|---|---|
| 8.0 | xfail | `examples/11xx-diagnostics-foreign-removed.sx` expects a "`#foreign` removed; use `extern`/`export`" diagnostic — still accepted (red) |
| 8.1 | green | parser hard-rejects `#foreign` (mirrors the variadic `name: ..T` cutover); `specs.md` drops `#foreign`, documents `extern`/`export` |
## Phase 9 — total `foreign` purge (the invariant)
`foreign` must not appear anywhere in the live tree, surface *or* internal. Each step
a mechanical, behavior-preserving rename commit (snapshots unchanged), small
per-file/subsystem commits — not one sweep.
| Step | What | Identifiers (count → new) |
|---|---|---|
| 9.0 | delete the surface | `hash_foreign`(11) + lexer entry + the 4 parse paths + the alias |
| 9.1 | rename **linkage**`extern*` | `foreign_expr`(25) **eliminated** (folds into modifier) · `is_foreign`(39)→`is_extern` · `foreign_lib`/`foreign_name``extern_*` · `foreign_name_map``extern_name_map` · `callForeign`(8)→`callExtern` · `marshalForeignArg``marshalExternArg` · `is_foreign_c_api`(5)→`is_extern_c_api` · `dedupeForeignSymbol``dedupeExternSymbol` |
| 9.2 | rename **runtime-class** machinery → `runtime*` (decision 5) | `ForeignClassDecl`(65) · `ForeignMethodDecl`(31) · `ForeignClassMember`(20) · `ForeignFieldDecl`(15) · `foreign_class_map`(44) · `current_foreign_class`(34)/`_method` · `foreign_path`(62) · `ForeignRuntime` · `parse/tryParseForeignClass*` · `lowerForeign{Method,Static}Call` · `findForeign{Method,Property}InChain` · `resolveForeign*` · `register*ForeignClass*` · `foreignClass*Type` · `*ForeignRefs` |
| 9.3 | purge **live docs** (28 lines) | `specs.md`/`readme.md`/`CLAUDE.md`: drop `#foreign`, document `extern`/`export`; fix file-roles + FFI/bundling notes |
| 9.4 | **acceptance gate** | `grep -rniE 'foreign' src/ library/ examples/ specs.md readme.md CLAUDE.md`**0** |
---
## Open decisions
*Part A (ratified — recommendations stand):* 1. bare keywords (not `#extern`).
2. aggregate position postfix (`#objc_class(…) extern`, like `struct #compiler`).
3. `extern ⇒ callconv(.c)`. 4. **REVISED** (user, 2026-06-14): `extern` carries an
optional `LIB`+`"csym"` axis (`extern_lib`/`extern_name`), mirroring `#foreign LIB
"csym"`, so it's a true `#foreign` superset (Gate A→B). The `#library` declaration +
build-flag linking mechanism stays separate — `extern` references a lib, doesn't
fold in `#library`. (Was: "library fully separate / not on `extern`".)
*Part B:* 5. runtime-class rename target — **RATIFIED `Runtime*Class*`** (user, 2026-06-14;
it's the object-model axis, not linkage). 6. historical carve-out — **STILL OPEN** (user did
not confirm at the Part A milestone): keep `issues/*.md` (+ design-doc prose) as provenance &
gate only the live tree (recommended) vs purge everything. Confirm 6 before Phase 9.
## Relationship to ASM
`PLAN-ASM.md` Phase F (global asm) consumes `extern` (import the asm symbol) and
`export` (let asm call back into sx) — do it after **Part A Phase 2**.
---
## Kickoff prompt (paste into a fresh session to start Part A)
> Work the FFI-linkage stream per `current/PLAN-EXTERN-EXPORT.md` (+ checkpoint
> `current/CHECKPOINT-EXTERN-EXPORT.md`). First read the plan's header (Decided
> syntax, Naming constraint, Key finding) and Part A; rationale is in
> `design/inline-asm-design.md` §II.2 (Deviation 6) + §II.10 #4.
>
> **This session = Part A, Phases 0 and 1 only** (`extern` works as a bare postfix
> keyword equivalent to a lib-less `#foreign` fn/global binding; `#foreign` stays
> untouched). Do NOT start Phase 2 (`export`) or Part B (migration).
>
> **Cadence (IMPASSIBLE):** no commit may both add a test and make it pass — lock
> behavior with a passing test, or land an xfail the next commit turns green.
> `zig build && zig build test` after every step.
>
> **Naming constraint (hard):** introduce only `extern`-named AST — do NOT reuse or
> extend `ForeignExpr`/`foreign_expr`/`VarDecl.is_foreign`. Use a new
> `FnDecl.extern_export` modifier (body `;` or `{…}`) and `VarDecl.is_extern`/
> `extern_name`. IR is already extern-named (`Function.is_extern`, `declareExtern`).
>
> Steps (commit after each; update the checkpoint each time):
> - 0.0 lock: `kw_extern`/`kw_export` tokens + map entries beside `kw_callconv`
> (`src/token.zig:45,282`) + unit lex test.
> - 0.1 lock: `parseOptionalExternExport()` (mirror `parseOptionalCallConv`,
> `parser.zig:3669`) + `ast.ExternExportModifier` + `FnDecl.extern_export` +
> `VarDecl.is_extern`/`extern_name` (parsed, unconsumed) + unit AST test.
> - 1.0 xfail: accept postfix `extern` after the callconv slot (`parser.zig:1950`);
> add `examples/12xx-ffi-extern-fn.sx` that extern-binds a libc symbol (red).
> - 1.1 green: in `src/ir/lower/decl.zig`, lower `extern` like a lib-less `#foreign`
> import — `is_extern`, `.external`, `callconv(.c)`, no ctx, via `declareExtern`
> (anchors :1123, :387, :2110, :2113). Example goes green.
> - 1.2 green: optional `extern "csym"` rename + extern-global `g : T extern;`
> (`parser.zig:425`).
>
> Stop at end of Phase 1. Verify: suite green; the `extern` libc binding runs;
> `#foreign` still works with no snapshot diffs. If you hit an unrelated compiler
> bug, follow the CLAUDE.md IMPASSIBLE RULE (file an issue, stop).

125
current/PLAN-REIFY.md Normal file
View File

@@ -0,0 +1,125 @@
# PLAN-REIFY — comptime type reflection + construction (`type_info` / `reify`)
## Goal
Add the two comptime metaprogramming builtins — **`type_info($T) -> TypeInfo`**
(reflect a type → data) and **`reify(info: TypeInfo) -> Type`** (construct a *new
nominal type* from data) — plus the sx-lib helpers (`make_enum`, `field_type`,
`RecvResult`/`TryResult`) built over them. This is **step 3 of the async-first
sequence** ([../design/execution-evolution-roadmap.md](../design/execution-evolution-roadmap.md)
§7); it gates channel result types (`RecvResult($T)`) and `race`'s synthesized
tagged-union, and **replaces** a would-be `enum($T)` generic-enum language feature.
> Rationale + the five validated contracts: design doc §7 step 3 + §8.1. The approach
> was grounded by three codebase reviewers — it is a **small extension reusing existing
> machinery**, not net-new architecture.
## Locked design (the five reify contracts — all codebase-validated)
1. **Nominal identity via type-fn memoization.** `RecvResult(i64)` is one `TypeId`
because type-fns dedup by mangled `(fn,args)` name (`generic.zig:1620-1629`) +
reify `findByName`. NOT structural dedup — enums are nominal (`types.zig:1110`).
2. **Functional through codegen.** A reify'd enum has **no backing AST decl**, and
every enum stage is type-table-driven (layout, construct, match+exhaustiveness,
`toLLVMType`, `type_name`/format) — so it flows through **unmodified**.
3. **Validate loudly** at the `intern`/`internNominal` choke point (`types.zig:411-439`).
4. **Comptime-only, JIT-free** — a type-table op in the interpreter; no S1 dependency.
5. **Reference-based self-reference** (`*Self`/`[]Self`) via reserve-placeholder→
complete (`nominal.zig:86/108/120`, `types.zig:442`); **by-value recursion rejected**.
Surface follows the **`#builtin`** pattern of the existing reflection builtins
(`type_of`/`field_count`/`field_name` in `library/modules/std/core.sx`,
`specs.md:2594-2600`) — NOT the BuildOptions compiler-hook registry.
## Key code anchors (verified by review)
- Type minting: `TypeTable.intern` / `internNominal``src/ir/types.zig:411-439`.
- Type-fn instantiation + mangled-name cache — `src/ir/lower/generic.zig:1575-1689`
(cache check `:1620-1629`; register inline-struct result `:1663-1689`).
- Forward-declare reserve (recursive types) — `src/ir/lower/nominal.zig:86/108/120`;
complete a forward-declared type — `src/ir/types.zig:442`.
- Enum codegen (all type-table-driven, the reify target shape): size `types.zig:633-636`;
`resolveVariantIndex` `lower/expr.zig:1159-1177`; match `lower/control_flow.zig:748-945`;
`toLLVMType` `backend/llvm/types.zig:111-154`; `type_name` `types.zig:846-882`.
- Existing reflection builtins to mirror — `core.sx` (`#builtin`) + their interp/lower
handlers (`src/ir/interp.zig` `type_name`/reflection at ~`:1911`).
- Match form — `specs.md:408-424`.
## Cadence (IMPASSIBLE)
No commit may both add a test AND make it pass (xfail-then-green, or a behavior-lock).
`zig build && zig build test` after every step. Never regenerate snapshots while red.
Examples: `06xx` (comptime, deterministic), `11xx` (diagnostics for loud failures).
---
## Phases
### Phase 0 — `reify` of a flat enum (the core)
| Step | Commit | What | Files |
|---|---|---|---|
| 0.0 | lock | `TypeInfo`/`EnumInfo`/`EnumVariant` lib types in `core.sx` (data only); `reify`/`type_info`/`field_type` as bodyless `#builtin` decls (parsed, unimplemented → loud bail). Unit: decls parse. | `library/modules/std/core.sx`, `src/ir/interp.zig` |
| 0.1 | xfail | `examples/06xx-comptime-reify-enum.sx``reify(.enum_(.{variants=[.{name="value",payload=i64},.{name="closed",payload=void}]}))`, construct `.value(3)`, match it. Red (reify unimplemented). | `examples/06xx-*` |
| 0.2 | green | implement `reify(.enum_)` → build `EnumInfo`/`TaggedUnionInfo` `TypeInfo`, `internNominal(info, fresh_nominal_id)`, return `TypeId`. Example green; construct + match work unmodified (Contract 2). | `src/ir/interp.zig`, (`src/ir/types.zig` if a helper is wanted) |
### Phase 1 — type-fn → reify identity
| Step | Commit | What | Files |
|---|---|---|---|
| 1.0 | xfail | `examples/06xx-comptime-reify-typefn-identity.sx``R :: ($T)->Type { reify(...) }`; assert `R(i64)` from two sites is ONE type (assignable/matchable across sites). Red if reify-result not registered by mangled name. | `examples/06xx-*` |
| 1.1 | green | register a reify-returning type-fn's result under the instantiation mangled name (mirror the inline-struct path `generic.zig:1663-1689`). Identity holds (Contract 1). | `src/ir/lower/generic.zig` |
### Phase 2 — `type_info` (reflect) + `field_type`
| Step | Commit | What | Files |
|---|---|---|---|
| 2.0 | xfail | reflect a struct/tuple → read variant/field names + **types** (`field_type($T,i)`). Red. | `examples/06xx-*` |
| 2.1 | green | implement `type_info`/`field_type` over the type table (reuse the `field_count`/`field_name` reflection path). | `src/ir/interp.zig` |
### Phase 3 — `make_enum` + `RecvResult`/`TryResult` (sx lib)
| Step | Commit | What | Files |
|---|---|---|---|
| 3.0 | lock | `make_enum(variants) -> Type` (sx lib over `reify`); `RecvResult($T)`/`TryResult($T)` as type-fns. Behavior-lock: `RecvResult(i64)` constructs + matches. | `library/modules/std/*` |
### Phase 4 — reference-based self-reference
| Step | Commit | What | Files |
|---|---|---|---|
| 4.0 | xfail | recursive enum via `*Self` (tree/list): `reify_rec((self)=> .enum_(... payload = ptr_to(self) ...))`. Red. | `examples/06xx-*` |
| 4.1 | green | `reify_rec` reserve-placeholder→complete (reuse `nominal.zig:86`/`types.zig:442` recursive path). | `src/ir/interp.zig`, `src/ir/types.zig` |
### Phase 5 — validation + loud diagnostics
| Step | Commit | What | Files |
|---|---|---|---|
| 5.0 | xfail | `examples/11xx-diagnostics-reify-*` — dup variant names, non-integer backing, **by-value self-reference** ("infinite size; use `*Self`"). Pin the messages. | `examples/11xx-*` |
| 5.1 | green | validate `TypeInfo` at the `intern`/`internNominal` choke point; emit diagnostics, never a broken type (Contract 3). | `src/ir/interp.zig` / `src/ir/types.zig` |
> `RaceResult` (tuple→tagged-union synthesis) is **not** in this stream — it lands with
> `race` (async cluster), but it consumes exactly the `type_info`+`field_type`+`reify`
> primitives built here.
## Risks / watch
- **Mangled-name plumbing (Phase 1)** is the one real unknown — confirm the type-fn
path registers a *reify-returned* result (not just inline `struct {…}` literals).
Fallback: have `reify` itself name the type by the instantiation key + `findByName`.
- **Self-ref completion (Phase 4)** must reuse the existing recursive-type
reserve→complete path; do not invent a new mutate-after-intern mechanism.
- Keep `reify` **comptime-only**: a `reify` reached at runtime is a hard error.
## Status
- [ ] Phase 0 — `reify` flat enum
- [ ] Phase 1 — type-fn identity
- [ ] Phase 2 — `type_info` + `field_type`
- [ ] Phase 3 — `make_enum` + `RecvResult`/`TryResult`
- [ ] Phase 4 — reference self-reference
- [ ] Phase 5 — validation + diagnostics
## Kickoff prompt (paste into a fresh session)
> Work the REIFY stream per `current/PLAN-REIFY.md` (+ checkpoint
> `current/CHECKPOINT-REIFY.md`). Read the plan header (goal, five locked contracts,
> key anchors) first; rationale is in `design/execution-evolution-roadmap.md` §7 step 3
> + §8.1. **This session = Phase 0 only** (`TypeInfo` lib types + `reify` of a flat
> enum: construct + match). Cadence (IMPASSIBLE): no commit both adds a test and makes
> it pass — lock, then xfail→green. `zig build && zig build test` after every step. If
> you hit an unrelated compiler bug, follow the CLAUDE.md IMPASSIBLE RULE (file an
> issue, stop). Stop at the end of Phase 0; update the checkpoint.

View File

@@ -0,0 +1,384 @@
# Bundled `zig` Link Backend for sx — Design Doc & Proposal
> Status: **core landed (macOS / Linux / Windows).** This is the
> design-of-record for how a distributed sx links native binaries
> hermetically. The phased plan lives in
> [../current/PLAN-DIST.md](../current/PLAN-DIST.md); keep the two in sync.
> User-facing surface is documented in `readme.md` (Cross-Compilation §).
---
## Implementation status (landed)
The core backend is implemented and verified on a macOS host:
| Target | Result | Notes |
|--------|--------|-------|
| `--target linux-musl` | static ELF | `zig cc -target x86_64-linux-musl -static` |
| `--target windows-gnu` | PE32+ | `zig cc -target x86_64-windows-gnu` |
| `--target macos` | Mach-O (runs) | `zig cc -target <arch>-macos`, no `-static` |
What shipped, and where it **refined** the original locked decisions:
- **Scope = macOS + Linux + Windows** (not Linux-first). iOS/Android/wasm keep
their specialized toolchains. (`TargetConfig.zigBackendInScope`.)
- **Auto-activation = a *bundled* zig is found** (a real distribution, or a
pinned `$SX_ZIG`). A `PATH`-only zig is the dev fallback and engages **only**
under `--self-contained` — so native dev/CI builds are never silently
rerouted, across all three OSes. This is the precise meaning of the §5.5
"zig found (B)" column: **B = bundled**. *(Refinement of "auto when zig
found": PATH-zig does not auto-engage; the musl-only auto gating considered
mid-design was dropped in favor of bundled-vs-PATH, which is OS-agnostic.)*
- **No translation table** (per the triple-scheme decision): sx triples are
passed straight to `zig cc`, and `emit_llvm` runs them through
`LLVMNormalizeTargetTriple` so vendor-less zig triples (e.g.
`x86_64-windows-gnu`) land their OS/env in LLVM's canonical positions —
otherwise "windows" sits in the vendor slot and the object silently falls
back to ELF. The one unavoidable exception is **macOS**: the object must be
emitted from Apple's `apple-darwin` triple (LLVM needs it for Mach-O), but
zig's `-target` parser rejects that scheme, so the *linker* triple alone is
the vendor-less `<arch>-macos`. One OS-specific line, not a table.
- **New shorthands:** `linux-musl`, `linux-musl-arm`, `windows-gnu` (zig
scheme). The existing `linux`/`linux-arm` shorthands were also de-vendored
(`x86_64-linux-gnu`, matching the corpus runner's own expander).
Files: `src/zig_backend.zig` (discovery), `src/target.zig`
(`selectZigLinker` / `emitZigLinkArgv` / `zigTargetTriple` / dispatch in
`link`), `src/ir/emit_llvm.zig` (triple normalization), `src/main.zig`
(`--self-contained` / `--no-self-contained` + shorthands).
Not yet done: distribution packaging (Phase 3 — vendoring `zig` into
`libexec/`), and a corpus regression test (needs the runner to thread
`--self-contained`; manual verification only so far).
The sections below are the original proposal; where they say "Linux-first" or
"follow-up" for macOS/Windows, the table above supersedes them.
---
## 0. TL;DR + feasibility
**Problem.** A distributed `sx` compiler can run on a Linux box (static-LLVM
binary + relocatable `library/`), but it cannot *finish a build*: the final
link step shells out to the host's `cc`, and relies on the host's libc + CRT
objects. No `cc`/glibc/SDK on the box → no binary. That is the gap between
"sx runs here" and "sx is a toolchain here."
**Proposal.** Bundle a pinned `zig` binary inside the sx distribution and use
`zig cc` as the link backend for `sx build`. `zig cc` brings its own lld,
CRT objects, and libc (musl or glibc) for the chosen target. Default Linux
output is **statically-linked musl**, which runs on any Linux with zero
dependencies — the property that makes Zig's own output portable.
**Feasibility: high.** The change is contained:
- The linker is selected through a single hook —
`TargetConfig.getLinker()` at `src/target.zig:194-196` — and the final
link argv is built in one place, the Unix `cc`-style branch at
`src/target.zig:524-564`.
- `zig cc` is a clang-compatible driver, so `-o` / `-L` / `-l` / extra
objects pass through that branch unchanged. The backend only has to
prepend `zig cc` and add `-target …` / `-static`.
- Exe-relative resolution (for finding the bundled zig) is already solved
for the stdlib in `src/imports.zig:204-227` and can be mirrored.
- `sx run` is JIT and never links, so it is wholly unaffected.
The cost is a ~5060 MB vendored `zig` (binary + its `lib/`) in the
distribution, and version-pinning discipline.
---
## 1. Motivation & background
### 1.1 Current state
| Concern | Today | File |
|---------|-------|------|
| Compiler binary | Self-containable via `-Dstatic-llvm` (no system LLVM) | `build.zig:9-10,156-162` |
| Stdlib | Relocatable, found relative to the exe | `src/imports.zig:204-227` |
| **Linking** | **Shells to system `cc`** | `src/target.zig:524-564` |
| **libc / CRT** | **Provided by the host `cc` driver implicitly** | (no `-lc`/crt passed) |
So two of three legs of a portable toolchain already stand. The third — the
linker and the libc/CRT it pulls in — is the host dependency this design
removes.
### 1.2 Why this matters for distribution
The goal is to hand someone a tarball and have `sx build app.sx` produce a
working binary on a stock Linux machine — a fresh container, a minimal CI
image, a box without `build-essential`. Today that fails at the link step.
Zig solved exactly this problem for its own users; since sx is *built with*
Zig, the cleanest fix is to stand on Zig's hermetic toolchain rather than
re-implement it.
---
## 2. Goals & non-goals
### Goals
- `sx build` produces a native Linux binary with **no host `cc`/ld/libc/SDK**.
- Default Linux output is **portable** (static musl): runs on any Linux.
- **Zero-config in the common case**: a bundled or PATH `zig` is detected and
used automatically; the operator sets nothing.
- A fully-specified, documented configuration surface (this document) for the
cases that *do* need tuning.
- No regression for existing users: system `cc` remains a fallback, and any
explicit `--linker` still wins.
### Non-goals (this iteration)
- Reimplementing lld in-process or building libc from source (see §7 —
Zig already does both; we reuse it).
- First-class Windows/macOS cross-compilation (nearly free as a follow-up,
but unverified — §11).
- Routing C-import compilation (`src/c_import.zig`, which also shells `cc`)
through the backend.
- Glibc-floor version pinning (`…-gnu.2.28`); exposed only if needed.
---
## 3. How Zig achieves hermetic builds (the model we're borrowing)
Zig's turnkey cross-compilation rests on bundling the two things sx borrows
from the host:
1. **In-process lld.** Zig embeds LLVM's lld (ELF/COFF/Mach-O/wasm) and links
without spawning an external linker.
2. **libc as data.** Zig ships musl *source* (builds `libc.a` + `crt*.o` on
demand, cached → static, no dynamic linker → portable output) and glibc
stubs generated from `.abilist` per version. For Windows it ships mingw
`.def` files and synthesizes import libraries.
`zig cc` exposes all of this behind a clang-compatible driver: `zig cc
-target x86_64-linux-musl -static foo.o -o foo` yields a portable binary on
any host, with nothing installed. **This design consumes that driver rather
than rebuilding its internals** — the whole second column above arrives for
free by vendoring the `zig` binary.
---
## 4. Design overview
`sx build` gains a **link backend** abstraction with two implementations:
- `system_cc` — today's behavior (shell `cc`, host libc).
- `bundled_zig` — shell `<zig> cc -target <triple> [-static] …`.
Selection is automatic (§5.5): if a usable `zig` is discovered and the user
gave no explicit `--linker`, `bundled_zig` is used; otherwise `system_cc`.
The backend plugs into the existing Unix link branch — it contributes the
leading `zig cc` tokens and the `-target`/`-static` flags; the rest of the
argv assembly is unchanged because `zig cc` is clang-compatible.
One supporting change: when `bundled_zig` is active, the triple handed to
LLVM in `src/ir/emit_llvm.zig` is aligned to the link target (`x86_64-linux`)
so the emitted object links cleanly against the selected musl CRT.
---
## 5. Detailed design (the configuration surface)
### 5.1 zig discovery — resolution order
`discoverZig()` (new `src/zig_backend.zig`) returns the first hit:
1. `$SX_ZIG` — explicit override.
2. `<exe_dir>/../libexec/zig/zig`**install layout** (§6).
3. `<exe_dir>/../../zig-bundle/zig`**dev vendored layout** (§6).
4. `zig` on `PATH`**dev fallback** (the only one active today).
`<exe_dir>` is resolved exactly as `src/imports.zig` resolves the stdlib.
If none resolve, behavior depends on activation (§5.5): auto-mode silently
falls back to `system_cc`; `--self-contained` errors.
### 5.2 Environment variables
| Var | Effect | Default |
|-----|--------|---------|
| `SX_ZIG` | Absolute path to the `zig` used as the link backend. Highest-priority discovery source. | unset |
| `ZIG_LIB_DIR` | Path to the bundled zig's `lib/`. Needed **only** if `zig` was relocated away from its `lib/`. In the supported layout (§6) they ship together and zig self-locates — leave unset. | unset |
| `SX_DEBUG_ZIG` | Trace discovery: each candidate path and the chosen one (or "none → cc"). Mirrors `SX_DEBUG_STDLIB`. | unset |
| `SX_DEBUG_LINK` | **Existing.** Prints the full link argv — shows the exact `zig cc …` invocation. | unset |
| `SX_STDLIB_PATH` | **Existing.** Stdlib override; unrelated to linking but noted because a full distribution sets neither and relies on exe-relative discovery for both. | unset |
### 5.3 CLI flags (`sx build`)
| Flag | Effect |
|------|--------|
| `--self-contained` | Force `bundled_zig` ON. If no usable zig is found, **error** — do not silently fall back. |
| `--no-self-contained` | Force `system_cc`. |
| `--linker <cmd>` | **Existing.** Explicit linker; supplying it **disables** auto-activation (user's choice wins). To pin a specific zig, prefer `SX_ZIG` + `--self-contained`. |
| `--target <triple\|shorthand>` | **Existing.** Selects target + ABI (§5.4). With `bundled_zig` active and target unspecified on a Linux host → `x86_64-linux-musl` static. |
| `--sysroot <path>` | **Existing.** Forwarded to the linker; rarely needed with `bundled_zig` (zig brings its own sysroot). |
### 5.4 Target → ABI mapping
The default (no `--target`) deliberately differs from the legacy `linux`
shorthand, because portable static output is the entire point.
| `sx` invocation | zig `-target` | Link mode | Portable? |
|-----------------|---------------|-----------|-----------|
| *(no `--target`, Linux host)* | `x86_64-linux-musl` | `-static` | ✅ any Linux |
| `--target linux-musl` *(new)* | `x86_64-linux-musl` | `-static` | ✅ |
| `--target linux` / `linux-x86` | `x86_64-linux-gnu` | dynamic | ❌ host glibc, versioned |
| `--target linux-arm` | `aarch64-linux-musl` | `-static` | ✅ |
| `--target windows` | `x86_64-windows-gnu` | per zig | follow-up (§11) |
| `--target macos` / `macos-arm` | `aarch64-macos` | per zig | follow-up (§11) |
- A **new** `linux-musl` shorthand is added; the existing `linux` shorthand
keeps its current gnu/dynamic meaning for back-compat.
- The LLVM emit triple is aligned to the link target so the `.o` links
cleanly against the selected libc/CRT (§4).
### 5.5 Activation truth table
`B` = a usable zig was discovered (§5.1). Subcommand = `sx build`.
| `--self-contained` | `--no-self-contained` | `--linker` | zig found (B) | Result |
|:---:|:---:|:---:|:---:|--------|
| — | — | no | yes | **bundled_zig** (auto) |
| — | — | no | no | system `cc` (silent fallback) |
| — | — | yes | * | user's `--linker` |
| yes | — | * | yes | **bundled_zig** (forced) |
| yes | — | * | no | **error**: `--self-contained` but no zig |
| — | yes | * | * | system `cc` (forced off) |
- `--self-contained` + `--linker` together: backend choice goes to
`--self-contained`; treat the literal combination as a usage error
(document, don't guess).
- `sx run` / `sx ir` / `sx asm` never link → backend not consulted.
### 5.6 Emit-triple alignment
`src/ir/emit_llvm.zig` (`LLVMSetTarget`, ~L246-284) currently uses the host
default triple when `--target` is unspecified (on Linux,
`x86_64-unknown-linux-gnu`). When `bundled_zig` is active, set the module
triple to match the link target (`x86_64-linux`) so codegen and the musl CRT
agree. Pure codegen objects are ABI-compatible across gnu/musl; aligning the
triple removes the edge-case risk (TLS model, stack protector) up front.
---
## 6. Distribution layout (packaging)
A relocatable tree; everything resolves relative to `bin/sx`, so the whole
directory moves/untars anywhere with no env vars set:
```
sx-<os>-<arch>/
├── bin/
│ └── sx # built -Dstatic-llvm (no system LLVM dep)
├── libexec/
│ └── zig/
│ ├── zig # pinned zig binary
│ └── lib/ # zig's lib/ (musl/glibc sources, lld data, …)
└── library/ # sx stdlib (existing discovery)
└── modules/…
```
Rules:
- `zig` and its `lib/` **must** ship together under `libexec/zig/` so zig
self-locates `lib/`; splitting them forces `ZIG_LIB_DIR`.
- Pinned zig version: **0.16.0** (matches the build toolchain). Record the
exact version in the release manifest — a mismatched `zig cc` CLI is the
likeliest future breakage.
- Vendor the matching zig release per host os/arch from ziglang.org at
package time.
---
## 7. Alternatives considered
| Alternative | Why not (now) |
|-------------|---------------|
| **In-process lld + bundled musl sysroot** (sx owns the pipeline; no zig) | Requires a custom LLVM build *with* lld — the Homebrew `llvm@19` here ships none (`liblld*.a`, headers, `ld.lld` all absent) — plus a C++ lld shim and per-arch prebuilt musl. Strictly more work for the same user-visible result. The right *eventual* target if we want zero foreign binaries; tracked as a follow-up. |
| **Full Zig-style: build libc from source on demand** | Most flexible (any arch/libc version, no prebuilt blobs) but the most work; only worth it after the in-process-lld path exists. |
| **Document a hard dependency on system `cc`** | Zero engineering, but defeats the goal — the box still needs `build-essential`. Acceptable only as the current fallback, not the distribution story. |
| **Bundle just `ld.lld` + a musl sysroot (no full zig)** | Smaller than a whole zig, but we'd hand-manage crt object selection, dynamic-linker paths, and import libs — i.e. re-derive what `zig cc` already encapsulates. Bundle-size saving doesn't justify the fragility. |
Vendoring `zig` wins on effort-to-result because sx already builds with Zig:
it's a first-party dependency, not a foreign toolchain, and it unlocks
Windows/macOS targets later for nearly free.
---
## 8. Phasing
Detail in [../current/PLAN-DIST.md](../current/PLAN-DIST.md). Summary:
0. **Resolve zig**`discoverZig()` + `SX_DEBUG_ZIG`; PATH fallback only.
1. **Link backend** — generalize the linker to a driver argv; emit
`zig cc -target … -static`; align the emit triple.
2. **Auto activation** — wire the §5.5 truth table; `cc` fallback intact.
3. **Packaging**`build.zig` `dist` step assembling the §6 tree.
4. **Verify & lock**`file`/`ldd` shows "statically linked"; host/arch-gated
corpus test honoring the snapshot-integrity + FFI-cadence rules.
The minimum end-to-end proof is Phases 0+1 against PATH zig.
---
## 9. Open decisions
**Locked:**
- Default Linux ABI = **static musl** (portable output).
- Activation = **auto** when a usable zig is found and no `--linker`.
- Dev uses **PATH zig**; vendoring deferred to Phase 3.
**Still open:**
- Exact spelling of the force flags (`--self-contained` vs e.g.
`--bundled-linker`); name chosen here pending review.
- Whether auto-mode should *warn* on silent `cc` fallback or stay quiet
(leaning quiet, with `SX_DEBUG_ZIG` for diagnosis).
- Whether to gate the Phase-4 corpus test behind a `.build` `target`
sidecar or keep it manual until a Linux CI runner exists.
---
## 10. Risks
- **Bundle size** ≈ 5060 MB (zig + `lib/`). Acceptable for a toolchain;
call it out in release notes.
- **zig CLI drift** across versions — pin hard, record in the manifest;
the most likely future breakage.
- **gnu vs musl ABI** for the emitted object — covered by the emit-triple
alignment (§5.6); TLS/stack-protector are the only realistic friction.
- **Operator confusion**: default-no-target (musl) diverging from the
`linux` shorthand (gnu). Mitigated by the new `linux-musl` shorthand and
explicit documentation (§5.4).
---
## 11. Out of scope / follow-ups
- **Windows / macOS targets** via the same `zig cc -target`: nearly free
after the Linux path, but Apple-SDK and Windows specifics need their own
verification — not documented as supported until tested.
- **`src/c_import.zig`** still shells system `cc` for C imports in JIT mode;
route through the backend later.
- **In-process lld** (alternative in §7) as the eventual zero-foreign-binary
endgame.
---
## Appendix — quick recipes (once implemented)
```sh
# Portable static Linux binary (default when a bundled zig is present):
sx build app.sx -o app
file app # → "ELF 64-bit … statically linked"
# Force the backend; fail loudly if no zig is bundled:
sx build app.sx --self-contained
# Use a specific zig:
SX_ZIG=/opt/zig-0.16.0/zig sx build app.sx --self-contained
# Opt out, use the system toolchain:
sx build app.sx --no-self-contained
# Dynamic glibc instead of static musl:
sx build app.sx --target linux
# Debug discovery + the exact link invocation:
SX_DEBUG_ZIG=1 SX_DEBUG_LINK=1 sx build app.sx
```

View File

@@ -0,0 +1,625 @@
# Execution-Model Evolution — Roadmap (comptime JIT · async · concurrency · hot-reload)
> Status: **exploratory design-of-record.** Captures the forward plan for sx's
> execution model across five interlocking threads. Not yet an active
> `PLAN-*`/`CHECKPOINT-*` stream — this is the shared design the streams would be
> carved from. Cross-platform shipping (the bundled-zig backend + the sx bundler)
> is **already landed**; see [bundled-zig-link-backend-design.md](bundled-zig-link-backend-design.md)
> and [../current/PLAN-DIST.md](../current/PLAN-DIST.md).
---
## 0. The thesis
sx's compiler stays small by pushing capability into **library sx + three general
primitives** (`inline asm`, `extern`/`export`, `atomics`) rather than baking
features into codegen. Concretely:
- **Async is a library, not a language feature** — colorblind, stackful fibers
behind an `Io` interface (Zig-inspired). No function coloring, no
async→state-machine transform. The implementation is pure sx down to a per-arch
inline-asm context switch.
- **Comptime gains a JIT escape hatch** — the interpreter stays the default
(debuggable, portable), but drops to a host-JIT for the one thing it can't
walk (inline asm) and, later, for whole fragments (the bundler).
- **One shared substrate** — a persistent ORC LLJIT + host-target emitter — serves
comptime-asm, the bundler, and JIT-resident hot-reload.
The honest trade is **small *surface*, but each primitive is *deep*** — not "small
compiler." The net-new **compiler** obligations this plan adds (all verified absent
today): **atomics lowering** (N1), **generic enums** `enum($T)`, **`type_info` +
`reify` + `field_type`** (comptime type construction), **`callconv(.naked)`**,
**repointable-`context` codegen** (+ per-fiber stack-limit), the **S1 persistent JIT
spine**, **C1 thunk synthesis**, **comptime-asm lifting** (C3), and (later) the **S2
ORC C++ shim**. Async itself is genuinely a library; the *enabling primitives* are a
major codegen/runtime investment. Already landed: `inline asm` (in flight),
`extern`/`export`, the `!`/`try`/`catch`/`onfail`/`raise` ERR stream, value-level
reflection, the `sx run` ORC LLJIT, and the host-FFI trampolines.
---
## 1. The spine (shared substrate)
| ID | Piece | What | Size |
|----|-------|------|------|
| **S1** | Persistent JIT executor | A long-lived ORC LLJIT + a host-triple `LLVMEmitter` + a compiled-fragment cache, plumbed into the interpreter. Today the LLJIT exists only for `sx run`'s `main` ([target.zig:319](../src/target.zig#L319)); the emitter carries one target machine ([emit_llvm.zig:274](../src/ir/emit_llvm.zig#L274)). | L |
| **S2** | ORC C++ shim | `MachOPlatform::Create` + redirectable/lazy-reexport symbols. The bare `LLVMOrcCreateLLJIT` can't do thread-locals, C constructors, or symbol redefinition — the wall the C-with-sx JIT spike hit (`_Thread_local` SIGABRT; `errors-*` examples crashed). Required by any non-trivial JIT or symbol repoint. | M |
S1/S2 are the spine: built once, consumed by **C1** (the FFI thunks — the main
near-term consumer), **C3**, and (later) **R2**. S1 alone suffices for C1/C3 (bare
calling/asm thunks — no TLS/ctors); S2 is only needed for R2 and JIT-ing C-with-sx.
---
## 2. Comptime / build layer
| ID | Piece | Unblocks | Depends | Size |
|----|-------|----------|---------|------|
| **C1** | **Real comptime FFI — JIT calling-thunks (LLVM = single ABI authority).** Trivial calls (scalar/ptr/string args, single-reg return) keep the existing `host_ffi.zig` trampoline fast-path; everything else (floats, structs-by-value, aggregate returns, >8 args, varargs) synthesizes a per-signature thunk, JIT-compiles it via **S1**, and calls it with an args buffer the interpreter fills by known layout (`type_info`). **LLVM emits the ABI-correct call — the same lowering as runtime codegen — so comptime and runtime FFI share ONE ABI implementation.** Rejected: libffi (foreign 2nd ABI impl), hand-rolled sx+asm (3rd impl + drift risk + needs C3 to run its own asm leaf anyway). | struct/string/slice/float signatures at comptime; full C interop in `#run`; lifts the bundler's API straightjacket; unifies comptime+runtime FFI | S1 (fast-path: none) | L |
| **C2** | **`#compiler``extern` collapse** — BuildOptions hooks become real exported C symbols resolved through C1; `*BuildConfig` threaded via global/handle; delete `.compiler_expr`/`compiler_call`/Registry. | one FFI mechanism, not two | C1 (`extern`/`export` already shipped) | M |
| **C3** | **Comptime asm via host-JIT** — stop bailing on `inline_asm` ([interp.zig:1019](../src/ir/interp.zig#L1019)); lift the block (operand model at [inst.zig:354](../src/ir/inst.zig#L354): inputs/`out_value`/`out_place`/`out_ty`/clobbers) to a host-arch thunk via `LLVMGetInlineAsm`, JIT, call through C1, cache by template+sig. | running asm-containing code at comptime | S1, C1 (+S2 non-trivial) | M |
| **C4** *(DROPPED)* | **JIT-the-bundler****not built** (Decision 6). Interp+C1 is the shipping bundler (I/O-bound, so native speed is moot; C1 closes the only capability gap). Remains an always-available S1 optimization if profiling ever shows the bundler's *own logic* is a hotspot. | — | — | — |
**Residue:** cross-arch comptime asm (C3) can't run on the host — narrows the bail
to the cross-compile case; needs a sharp diagnostic ("asm targets `<arch>`, host
is `<host>`").
---
## 3. Concurrency primitives (atomics + threads)
> **Why this is its own section:** we are doing **multiple OS threads**, so the
> async runtime and any lock-free structure need real atomics. OS threads already
> exist; atomics do not.
| ID | Piece | State | Size |
|----|-------|-------|------|
| **N1** | **Atomics — NET-NEW compiler feature.** Atomic load/store/RMW (`add/sub/and/or/xor/swap` + `fetch_min`/`fetch_max`; no `nand`), `compare_exchange`/`_weak` (→ `?T`, **null = success**), and fences, with orderings (relaxed/acquire/release/acq_rel/seq_cst). LLVM provides all — an **emit** feature, not a runtime library. **Surface LOCKED = `Atomic($T)` wrapper + `Ordering` enum** (not `@atomic_*``@` is address-of in sx). | **lowering absent** — zero LLVM `atomicrmw`/`cmpxchg`/`fence` emission today; some IR/inference scaffolding exists | M |
| **N2** | **OS threads + pthread Mutex/Cond + worker Pool** | **landed** — [std/thread.sx](../library/modules/std/thread.sx) (`pthread_create`/`join`/`detach`, in-place `Mutex`/`Cond`, bounded `Pool`). NOTE: pthread mutex **blocks the OS thread** — it is *not* fiber-aware (it would park every fiber on that thread); fiber-aware sync is N3, built on N1. | — |
| **N3** | **Fiber-aware sync** — mutex / channel / waitgroup that **suspend the fiber**, not the OS thread. Hybrid: atomic fast-path (N1) + fiber-suspend slow-path (A2/A5). Distinct from the pthread primitives in N2. | new library | M |
**Compiler obligation for N1:** the emit must map sx orderings to LLVM's and **not
reorder across atomics/fences**. Comptime is single-threaded, so the interpreter
can treat atomic ops as ordinary ops (seq_cst is trivially satisfied with one
thread) — no interp atomics machinery needed.
**N1 is a prerequisite for M:N scheduling (A5) and N3, and is broadly useful**
(lock-free queues, refcounts, the allocator). It is the load-bearing new primitive
this revision adds.
---
## 4. Async — colorblind, stackful, pure-sx
**Commitment:** no function coloring, no async→state-machine transform. Async is a
capability carried in `context` (like `context.allocator`), not a property of a
function's signature. A function does I/O through `context.io`; whether the call
suspends is decided by the `Io` *implementation*, transparently.
| ID | Piece | Notes | Size |
|----|-------|-------|------|
| **A1** | **`Io` interface + `context.io`** — a protocol/vtable threaded like `Allocator`. `io.async(fn,args) → Future`, `future.await`, cancellation. | leverages protocols + context | M |
| **A2** | **Stackful coroutine runtime — in sx lib, NOT a compiler builtin.** The context-switch is a `callconv(.naked)` sx fn with an inline-asm body (save callee-saved + SP/LR into `*from`, load from `*to`, `ret`); fiber bootstrap + stack alloc (`mmap`+guard via `extern`) also sx. The **compiler's** job is only (a) the general primitives — inline asm, `callconv(.naked)`, atomics — and (b) **fiber-safe codegen**: `context` lowered as a *repointable indirection* (never raw TLS) so the switch can repoint it, and stack-limit guards (if emitted) read from a swappable per-fiber location. Most arch-delicate sx in the tree (must match the platform callee-saved set + the compiler ABI), but it's inspectable sx, not a black box. | per-arch, arch-gated; co-validate vs codegen | M |
| **A3** | **Event-loop `Io` impls** — kqueue / epoll / io_uring drive readiness, then the (now-ready) syscall via C1. Plus a trivial **blocking `Io`**. | pure sx around syscall `extern`s | L |
| **A4** | **Stdlib I/O rework** — fs/socket/process take/use `context.io` instead of raw blocking syscalls, so existing calls participate in async. | mirrors the allocator-threading rule | M |
| **A5** | **Schedulers — M:1 → N×(M:1) → M:N, all sx std-lib `Io` vtables (committed; M:N last, not deferred).** M:1 first (minimal vehicle to validate the colorblind stack; covers I/O-bound). N×(M:1) = first parallel step (per-thread M:1 loops + `std/thread.sx` spawn; shared state uses N1 atomics — expected under parallelism, not a wart). M:N work-stealing last (most machinery: thread-safe steal queues + migration + errno/TLS discipline). All over N1 atomics + the A2 asm context-switch + `extern` syscalls. **pinning** API for thread-affine work (UI main thread, GL context). | see §4.3 | M (M:1) / M (N×M:1) / L (M:N) |
### 4.1 How control enters sx (the colorblind model)
- **sx→sx is ordinary.** The whole call chain lives on the fiber stack; a suspend
at a leaf `io.*` freezes the native stack verbatim. No frame knows it suspended.
**Zero special handling at call boundaries** — that's the point.
- **Three inbound boundaries** where the runtime enters sx:
1. **Task entry** (`io.async(fn)`) — a trampoline starts `fn` on a fresh fiber
stack via the normal calling convention.
2. **Resumption** — a context-switch (asm), *not* a call; sx continues mid-stack.
3. **C callback → sx** — must be `export`/`callconv(.c)`; runs on the event-loop
stack (not a fiber) so it **cannot itself suspend** — it may resume/enqueue a
fiber or run a non-suspending sx fn to completion (leaf-only).
### 4.2 `context` is fiber-local (the key obligation)
`context.io`/`context.allocator`/the `push Context` stack are dynamically scoped.
Fibers time-share OS threads (and **migrate** under M:N), so `context` must travel
**with the fiber** — saved/restored on every context-switch — **never a raw TLS
read.** A spawned task snapshots the spawner's context, then evolves its own
`push Context` stack. This is the CLAUDE.md "capture your owning allocator" rule one
level up: ambient state that outlives a suspension point must be carried by the
fiber.
### 4.3 Threads & the two hazard classes (why atomics)
| Model | Parallelism | Migration | Hazards |
|-------|-------------|-----------|---------|
| **M:1** (1 OS thread) | none | none | cooperative, race-free — simplest |
| **N×(M:1)** (per-thread schedulers, no migration) | yes | none | **data races** on shared state → atomics/locks |
| **M:N** (work-stealing) | yes | yes | data races **+** TLS-migration hazards |
- **Parallelism hazard** (any N>1): shared mutable state races → needs **N1
atomics** + N3 fiber-aware sync. The M:1 "no locks" simplicity is gone.
- **Migration hazard** (M:N only): a fiber that moves threads across a suspend
reads the *wrong* thread's TLS. **`errno` must be captured immediately** after
each syscall; **`context` must be fiber-local** (§4.2) — non-negotiable under M:N.
- **Pinning** (`io.pinToThread()`): some work must stay put — the **UI main
thread** (UIKit/macOS/Android — directly the app targets in §6), OpenGL
current-context, TLS-using FFI. M:N needs a "don't migrate / main-thread-only"
fiber attribute (Go's `LockOSThread`).
### 4.4 Pure-sx boundary
Everything is sx except the irreducible FFI floor: the **asm context-switch**
(per-arch, in `.sx`), **syscall `extern`s** (kernel-implemented, like any libc
binding), and **raw stack memory** (`mmap`). The schedulers, event loops, futures,
cancellation, and sync primitives are ordinary sx. Payoff: **swappable `Io`
vtables** — blocking, io_uring, kqueue, a **mock `Io`** for tests, a
**deterministic-simulation `Io`** (fake clock, scripted readiness) for reproducible
concurrency tests — all libraries.
### 4.5 Comptime async = blocking `Io`
At comptime install the **blocking `Io`**: `io.*` just blocks; no fibers, no
scheduler, no suspend. Same source, different vtable. The interpreter never needs
suspend/resume, and the FFI (C1) needs no async awareness. This is *why* the
colorblind model resolves comptime async for free.
### 4.6 Syntax surface (grounded against the grammar)
All of the concurrency/atomics surface lands on **existing** sx grammar — `enum`
tagged unions + `if x == { case … }` match ([specs.md:364,408](../specs.md#L408)),
first-class **tuples** with named fields ([specs.md:815-852](../specs.md#L815)),
`=>` closures, `struct($T)` generics, `callconv(...)`, and the ERR keywords
(`try`/`catch`/`onfail`/`raise`/`error`). `race`/`async`/`await`/`atomic` are **not
reserved words** ([specs.md:168](../specs.md#L168)), so they stay library
types/methods — no keyword additions. One genuinely-new compiler capability is
required (see end).
**Atomics (N1) — generic wrapper type.**
```sx
Ordering :: enum { relaxed; acquire; release; acq_rel; seq_cst; }
Atomic :: ($T: Type) -> Type #builtin; // atomicity carried by the type
counter : Atomic(i64) = .init(0);
counter.store(0, .relaxed);
n := counter.load(.acquire);
prev := counter.fetch_add(1, .seq_cst); // + fetch_sub/and/or/xor (min/max: open)
old := counter.swap(42, .acq_rel);
got := counter.compare_exchange(old, new, .acq_rel, .acquire); // strong → ?T (null = success)
got2 := counter.compare_exchange_weak(old, new, .acq_rel, .acquire); // may fail spuriously; for retry loops
fence(.seq_cst);
```
- CAS takes **two orderings** (success, failure); failure ordering may not be
`release`/`acq_rel` nor stronger than success — enforce in the compiler.
- Weak vs strong matters on **aarch64** (LL/SC) — weak in a loop is the idiom;
both compile identically on x86.
**Channels (N3) — methods only (no `<-`); `recv` returns a tagged union (not `(v, ok)`).**
```sx
RecvResult :: enum($T: Type) { value: T; closed; } // ordinary generic enum (not the race-synthesized union)
TryResult :: enum($T: Type) { value: T; empty; closed; } // non-blocking: 3 states a bool can't express
ch := Channel(i64).make(16); // capacity; .make() unbuffered
ch.send(v);
if ch.recv() == { case .value: (v) { use(v); } case .closed: { /* drained */ } }
ch.close();
// ergonomic layer: `for ch (v) { … }` consumes until closed, hiding RecvResult
```
**Fiber-aware locks (N3) — explicit lock + `defer` (no guard sugar).**
```sx
m : Mutex;
m.lock(); defer m.unlock();
```
**Futures & spawn (A1).**
```sx
f := context.io.async(worker, arg); // Future(R)
r := f.await(); // suspends this fiber
f.cancel();
d := context.io.timeout(5000); // a Future too — raceable like any other
```
**Pinning (A5) — spawn attribute, accepts a thread handle.**
```sx
PinTarget :: enum { any; main; on: Thread; } // default = .any (may migrate)
f := context.io.async(render, pin = .main);
f := context.io.async(worker, pin = .on(some_thread));
```
**`race` (Zig model — over futures, named tuple in → synthesized tagged-union out).**
The input is a **named tuple** (positional also allowed → `.0`/`.1` tags); the
result is an anonymous tagged union whose variants mirror the tuple's labels, each
payload = that field's `Future(T)` projected to `T`. Losers are **cancelled and
joined** before `race` returns (structured).
```sx
fa := context.io.async(read_a, conn); // Future(A)
fb := context.io.async(read_b, conn); // Future(B)
winner := context.io.race((a: fa, b: fb)); // RaceResult = enum { a: A; b: B }
if winner == {
case .a: (v) { handle_a(v); } // v : A
case .b: (v) { handle_b(v); } // v : B
}
// positional form: race((fa, fb)) → tags .0 / .1
```
The Go-style handler-map and the map literal that propped it up are **dropped**
`race` over futures subsumes select, and cancellation handles the losers.
**Cancellation rides ERR.** A cancelled `io.*` **raises**; the fiber unwinds
through `defer`/`onfail` (`try`/`catch`/`raise` are real keywords). Cancellation is
**cooperative** (observed only at suspend points — every `io.*` is a cancellation
point) and **structured** (`race` joins losers' teardown before returning). No
parallel unwind path — it reuses the error channel.
**Context switch (A2).**
```sx
swap_context :: (from: *Fiber, to: *Fiber) callconv(.naked) {
asm { /* save callee-saved + SP into *from; load from *to; ret */ };
}
```
`callconv(.naked)``callconv(.c)`: **no prologue/epilogue/frame** — required
because a context switch deliberately makes SP-in ≠ SP-out (a `.c` epilogue would
restore from the wrong stack). Body is a single `asm` block; you emit your own
`ret`. Args arrive in ABI registers, read directly from asm.
**One new compiler capability (gates `race`):** *comptime tuple→tagged-union
synthesis.* Reflection today only **reads** types (`field_count`/`field_name`/
`type_of`); `RaceResult(T)` must **construct** an anonymous `enum` from a tuple's
`(label, payload-type)` pairs. Supporting pieces: a `field_type($T, i) -> Type`
reflection accessor (we have value-level `field_value` + `type_of`, but type-only
field projection is missing) and `Future(T) → T` projection (falls out of
generics). This is the generic "derive a sum from a product" — useful beyond
`race`.
---
## 5. Dev loop / hot-reload
| ID | Piece | Notes | Depends | Size |
|----|-------|-------|---------|------|
| **R1** | **Hot-reload (dylib swap)** — host owns `State`+allocator; reloadable module is a `.dylib` with a fixed `export` interface; watch→rebuild→`dlopen`→rebind→`dlclose`. State survives (host-owned). | leans on `export` (shipped); sidesteps S2; native | — | M |
| **R2** | **Hot-reload (JIT-resident)** — program runs under S1's LLJIT; reloadable calls route through ORC indirection stubs, repointed on change. Finer granularity; same spine. | | S1, S2 | L |
| **R3** | **Incremental compilation** — dependency tracking + recompile-only-changed. Perf enabler; coarse per-file v1 suffices first. | | — | L |
**Core rule:** the data that must survive a reload cannot be owned by the code that
reloads. Code/state separation — the CLAUDE.md owning-allocator discipline, one
level up.
**Residue — state migration on layout change:** body-only changes hot-swap;
layout/signature/global-type changes are **detected** (compare new vs running
`State` layout via `types.zig`) and trigger **rebuild+restart**. Migration hooks
(`on_reload(old)→new`) are a hard later item. Design against *silent* corruption.
---
## 6. Cross-platform (mostly landed) — from a macOS laptop
### 6.1 Landed
| Capability | State | Reach from a mac |
|---|---|---|
| `extern`/`export` C linkage | done (replaced `#foreign`) | all targets |
| Bundled-`zig cc` cross-link backend | Phases 02 done; packaging pending | **macOS, Linux(-musl/static), Windows(-gnu)** verified |
| sx-side bundler (`.app`/`.apk`) | done | macOS, iOS sim/device, Android |
| JIT `sx run` (ORC LLJIT) | done | host |
| Target shorthands | done | `macos[-arm]`, `linux[-musl[-arm]]`, `windows[-gnu]`, `ios[-arm]`, `ios-sim[-arm/-x86]`, `android[-arm64/-x86_64]`, `wasm` |
### 6.2 Workflows
```sh
# macOS (native): inner loop is JIT; ship is Mach-O / .app
sx run app.sx
sx build app.sx -o app
sx build app.sx --bundle MyApp.app
# Linux (cross, landed killer feature): static, zero-dep ELF
sx build app.sx --target linux-musl -o app # scp anywhere, runs
# Windows (cross, landed, MinGW path): PE32+
sx build app.sx --target windows-gnu -o app.exe # cf. example 1660 (win32)
# iOS simulator (mac-only host)
sx build app.sx --target ios-sim --bundle App.app
# iOS device — signing threaded via the build program (BuildOptions setters)
# #run { o := build_options(); o.set_bundle_id(...); o.set_codesign_identity(...);
# o.set_provisioning_profile(...); }
sx build build.sx --target ios --bundle App.app
# Android (cross + bundle): javac → d8 → aapt2 → zipalign → apksigner, then adb
sx build app.sx --target android --apk app.apk
```
### 6.3 Where the roadmap lights up cross-platform
- **C1 + C4** → the iOS/Android **bundlers** (orchestrate ~a dozen host tools at
comptime; biggest win; always host-arch so no cross-arch risk).
- **R1/R2 + A1A5** → the **inner dev loop for non-host targets**: push-a-dylib +
remote-trigger-reload over an async laptop↔device channel — a capability that
*doesn't exist today* short of full rebuild+reinstall.
- **A1/A2 colorblind `Io`** → the dev tooling is itself async, and the **same
networking code runs blocking inside the bundler** (`adb push`) and async in the
live session — no coloring.
- **Pinning (A5)** → the UI render fiber pins to the main OS thread on every app
target.
**The single hard constraint the matrix exposes:** cross builds mean target arch ≠
host arch, so **C3's residue bites** — comptime/`#run` code reaching *target-arch*
inline asm can't execute on the mac. Native macOS dev never hits it; every cross
target must gate comptime asm to host-arch (`when host_arch == …`) or get a loud
diagnostic.
---
## 7. Linear build sequence (async-first — no parallel streams)
Single ordered list; deps satisfied at every step. **Async-first** (user-chosen): the
async story needs no JIT spine (syscalls use the existing trampoline FFI; comptime
async = blocking `Io`), so the FFI/JIT cluster comes *after*. C4 is omitted (dropped —
an S1 optimization if ever profiled). Net-new compiler prereqs (per the codebase
grounding) are explicit steps, not buried.
**Foundations — compiler primitives the async story needs (all net-new):**
1. **N1 — Atomics lowering.** IR/inference scaffolding exists; add LLVM
`atomicrmw`/`cmpxchg`/`fence` emission + orderings. Surface = `Atomic($T)` wrapper.
Gates channels/N3 + parallel schedulers.
2. ~~**Generic enums** `enum($T)`~~ **DROPPED.** `RecvResult($T)`/`TryResult($T)` are
**type-fns over `reify`** (step 3), not a new `enum($T)` language feature — and
type-fns (user `($T)->Type` in type position) **already work** (e.g.
[`Make`](../examples/0208-generics-value-param-type-function.sx),
[`Complex`](../examples/0201-generics-generic-struct.sx)). A declarative `enum($T)`
surface, if ever wanted, is later *sugar* desugaring to a type-fn-over-`reify`.
3. **`type_info` + `reify` + `field_type`** — comptime metaprogramming floor. Gates
`race` synthesis **and** channel `RecvResult`/`TryResult` (all type-fns over
`reify`; **generic-enum syntax dropped**). **Validated against the codebase (3
reviewers): a small extension reusing existing machinery throughout — not net-new
architecture.** Five contracts:
1. **Nominal identity via type-fn memoization** — type-fns dedup by mangled
`(fn,args)` name (generic.zig:1620-1629) + reify `findByName`, so `RecvResult(i64)`
is one `TypeId` and the body runs once. (NOT structural dedup — enums are
nominal via `nominal_id`, types.zig:1110.)
2. **Functional through codegen** — layout / construct / match+exhaustiveness /
`toLLVMType` / `type_name`+format are **all type-table-driven, zero AST
coupling**, so a backing-decl-less reify'd enum flows through unmodified.
3. **Validate loudly** at the single `intern`/`internNominal` choke point
(types.zig:411-439): reject dup variants / bad backing / unresolved payloads.
4. **Comptime-only, JIT-free** — a type-table op in the interp; no S1 dependency
(keeps reify, hence channels + `race`, off the JIT critical path).
5. **Reference-based self-reference (v1)**`*Self`/`[]Self` payloads via the
reserve-placeholder→complete path recursive *source* types already use
(nominal.zig:86/108/120, types.zig:442); **by-value recursion rejected** (loud,
infinite size). reify gains a `reify_rec((self) => …)` builder form.
- **Type-minting precedents (7):** monomorphization, protocol vtables, tuples,
vector/array, ptr/slice ctors, FFI stubs, **type-fn instantiation** — all
construct `TypeInfo` programmatically + `intern()`. **Residual = plumbing, not
capability:** name reify-results by the instantiation's mangled name (done for
inline-struct bodies — extend to reify-results) + reify input validation.
4. **`callconv(.naked)`** — extend `CallConv {default, c}` (types.zig:169) + skip
prologue/epilogue lowering. Gates A2.
5. **Repointable-`context` codegen** — lower `context` as a swappable indirection
(never raw TLS) + per-fiber stack-limit. Compiler obligation; gates A2 *and*
cross-fiber `context.io` correctness. (Reviewer note: this is a **prerequisite**
of A2, not a successor.)
**Async runtime — sx lib over the primitives:**
6. **A1 — `Io` interface + `context.io` + `Future` + `cancel()` API.**
7. **A2 — fiber runtime** (naked context-switch asm, bootstrap, `mmap` stacks).
8. **A3 — blocking `Io` → deterministic-sim `Io` (keystone, calibrated) → event-loop `Io`.**
9. **A5·M:1 — single-thread scheduler.**
10. **N3 — fiber-aware sync** (channels/mutex/waitgroup; `recv → RecvResult`).
11. **A6 — Cancellation.** `.canceled` in the `!` channel (model a); per-fiber atomic
flag (N1); every `io.*` a cancellation point; structured cancel-and-join; **masked
during cleanup**.
12. **A4 — stdlib I/O rework** (fs/socket/process onto `context.io`).
13. **A5·N×(M:1)** — first parallel (errno-capture + `context`-fiber-local discipline).
14. **A5·M:N** — work-stealing (steal queues + migration + pinning).
**Then comptime / FFI / JIT cluster:**
15. **S1 — persistent JIT spine** → 16. **C1 — real FFI (LLVM = ABI authority, on S1)**
→ 17. **C2 — `#compiler`→`extern`** → 18. **C3 — comptime asm** (S1 + C1; +S2 if
TLS/ctors).
**Deferred tail:**
19. **S2 — ORC C++ shim** (highest-risk — see §8; macOS `MachOPlatform`; ELF/COFF
unplanned) → 20. **R1 — dylib reload** (shipped `export`) → 21. **R2 —
JIT-resident reload** (S1 + S2; **↔ async live-fiber coupling**, §8) → 22. **R3 —
incremental compilation**.
Hard edges to remember: **C1 depends on S1** (the non-trivial FFI cases); **C3 depends
on C1** (calls through its thunk path); **R1/R2 couple to the async runtime** (can't
hot-swap code with live suspended fibers — runtime + long-lived fibers stay
persistent, only leaf logic reloads).
---
## 8. Irreducible hard problems (detect-and-degrade, don't pretend)
1. **State migration across layout change** (R1/R2) → v1 detects + rebuild/restart;
migration hooks later.
2. **Cross-arch comptime asm** (C3) → can't run on host; narrows the bail + loud
diagnostic; gate to host-arch.
3. **M:N migration hazards** (A5) → errno-capture discipline + fiber-local context
(mandatory), pinning for thread-affine work.
### 8.1 Highest technical risks (from review — ranked, async-first lens)
1. **A2 context-switch correctness** (in the async critical path). Silent stack
corruption, per-arch, **untestable by the deterministic-`Io` harness** (it tests
*scheduling*, not the *switch*); a one-register slip is invisible until it crashes
on the right arch. Couples *library asm* to the *compiler ABI* — ABI drift breaks
it silently later. → needs a dedicated **switch-stress test** (§10).
2. **`reify` → anonymous-tagged-union → match-codegen** (gates `race` + channels).
**DE-RISKED by review** (§7 step 3): all enum stages are type-table-driven with
zero AST coupling, identity is handled by existing type-fn mangled-name memoization,
and forward-declaration for self-ref already exists. Residual is *plumbing*
(name reify-results by mangled name + input validation), not new architecture.
3. **Deterministic-`Io` is the test keystone yet itself uncalibrated** — a buggy
deterministic scheduler yields deterministic-*wrong* stdout that snapshots lock in.
→ calibrate against the blocking `Io` / property-test fixed order (§10).
4. **`context`-fiber-local + errno discipline** (A5 M:N). "Non-negotiable" but
enforced by manual rule, not the compiler; M:1 can't even exercise migration.
5. **S2 ORC shim** (deferred, but highest-risk when reached): only C++ in the tree,
**already failed a spike** (`_Thread_local` SIGABRT), `MachOPlatform` is
macOS-specific — **Linux/Windows JIT-resident reload + non-Mac TLS/ctor JIT have no
named plan**. One "M" box hides a per-OS effort.
6. **C1 args-buffer layout-vs-ABI** — "LLVM emits the call" covers the *call*, not the
interpreter's *buffer pack* from `type_info`. Disagreement on edge layouts
(over-aligned/empty structs, aarch64 small-struct register splitting, `bool`) =
silent comptime corruption. → adversarial layout cases (§10).
---
## 9. Decisions log (all resolved)
**Sequencing — locked:** **async-first** (§7). The async cluster (steps 114)
precedes the FFI/JIT cluster (1518) because async needs no JIT spine. **Cancellation
(A6) = model (a)** — a `.canceled` variant in the **existing `!` error channel** that
`io.*` already returns (I/O is inherently fallible, so `io.*` is already `!`-typed —
the "keep calls clean" argument for the non-local-`raise` model is moot). Reuses
`!`/`try`/`catch`/`onfail`; no new unwind primitive. **Net-new prereq surfaced by
grounding:** `callconv(.naked)` (only `.default`/`.c` today). **Generic enums dropped**
`RecvResult($T)`/`TryResult($T)` are **type-fns over `reify`** (type-fns already work
in type position, e.g. `Make`/`Complex`), so no `enum($T)` feature is needed; `reify`
gains two contracts (deterministic identity + functional-enum output, §7 step 3).
**Locked (see §4.6 for the grounded surface):**
- **N1 atomics surface = generic wrapper `Atomic($T)`** + `Ordering` enum, `.init`,
`compare_exchange`/`_weak` returning `?T` (**null = success** — pinned, opposite of
most priors). (Not `@atomic_*` builtins — `@` is address-of in sx.) **RMW set** =
`add/sub/and/or/xor/swap` + `fetch_min`/`fetch_max` (free from LLVM); **no `nand`**.
- **`race` = over futures** (Zig model), **single named-tuple in** (`race((a: fa, b:
fb))`) → synthesized tagged-union out; Go-style handler-map + map literal
**dropped**. **No `async` spawn-sugar** — always `context.io.async(...)`.
- **Channels** = `send`/`recv` methods (no `<-`); **`recv` returns a tagged union**
`RecvResult($T){ value; closed }` (not `(v, ok)`), `try_recv` → `{ value; empty;
closed }`; optional `for ch (v) {…}` iteration sugar. **locks** = `lock()` + `defer
unlock()` (no guard sugar). `race`/`async`/`await` stay library, not keywords.
- **Comptime type metaprogramming = `type_info` + `reify` builtins only** (Zig
`@typeInfo`/`@Type` model). **Everything else is sx lib** — `make_enum`,
`field_type`, `RaceResult`. `reify` coverage starts at **enum/struct/tuple**, grows
later. `Future($T)` exposes `Value :: T` so `Future(X)→X` is plain member access
(no `type_arg` builtin).
- **C1 FFI engine = LLVM as single ABI authority** — per-signature JIT calling-thunks
via S1 (LLVM emits the ABI-correct call, same as runtime codegen); trampoline
fast-path for trivial calls. **libffi/dyncall + hand-rolled-sx rejected** (2nd/3rd
ABI impl; hand-rolled needs C3 for its own asm leaf anyway). Promotes **S1 to
foundational** (shared by C1, C3).
**Scheduler (Decision 5) — locked:** **M:1 → N×(M:1) → M:N**, all **sx std-lib `Io`
vtables** (compiler only provides N1 atomics + the A2 asm context-switch + `extern`
syscalls). M:1 ships first (validates the colorblind stack, covers I/O-bound);
N×(M:1) is the first parallel step; **M:N is last in sequence but committed — not
deferred.** Data races under parallelism are expected and handled with atomics +
fiber-aware sync — that *is* parallelism, not a wart; M:1's lock-freedom is just a
property of the single-threaded case.
**Deferred, orthogonal additions (Decisions 67) — both addable later without
revisiting anything locked:**
- **C4 (Decision 6) — fully orthogonal; not built now.** Pure deferred optimization
riding S1 (already present for C1/C3): JIT the bundler subgraph instead of
interpreting it. Zero coupling — same bundler sx, same C1 FFI. Apply only if
profiling ever shows the bundler's *own logic* is a hotspot (it's I/O-bound, so
unlikely). Interp+C1 is the shipping bundler.
- **Hot-reload (Decision 7) — deferred; mechanism additive.** Substrate ready: R1
(dylib-swap) needs only shipped `export`; R2 (JIT-resident) needs S1 + the S2 ORC
shim. **R1-vs-R2 chosen at pickup.** One coupling (a design constraint, not a
decision change): you can't hot-swap code with **live suspended fibers** pointing
into the old module — so the async runtime + long-lived fibers stay on the
*persistent* side, only transient **leaf logic** is reloadable (or quiesce fibers
before swap).
---
## 10. Testing & gates
Inherits the project cadence (CLAUDE.md): `zig build && zig build test` after every
step; **xfail-then-green or behavior-lock — no commit both adds a test AND makes it
pass**; never regenerate snapshots while red; corpus = `examples/` + `issues/` with
`.exit`/`.stdout`/`.stderr`/`.ir` snapshots. Per-*step* gates live in the eventual
`PLAN-*` streams; this section is the design-level verification strategy that those
streams must implement.
### 10.1 The async test harness = the deterministic-simulation `Io` (the keystone)
Concurrency is nondeterministic (scheduling/readiness order), which **breaks snapshot
testing** outright. So the **deterministic-sim `Io`** (fixed clock, scripted
readiness, deterministic single-stepping scheduler) is not merely a feature — it is
**the test harness for everything async**. Every concurrency example runs under it →
reproducible stdout → snapshottable. Consequence for sequencing: **build the
deterministic `Io` right after the blocking `Io`** (it's the simplest scheduler after
blocking and it *gates the ability to test* fibers/channels/race/schedulers at all).
The 10 patterns in §4.6-adjacent examples become corpus tests only because they run
under it.
### 10.2 What is NOT snapshot-testable
True parallel **data races** (N×M:1 / M:N) are nondeterministic by construction. They
run under the deterministic `Io` for *correctness* repro, but race-detection needs a
separate **stress harness** (run-N-times / TSan-style), **not** the corpus. Any such
coverage bound must be stated loudly (a `log()`-style note in the harness), never
silently skipped — per the REJECTED-PATTERNS rule against silent gaps.
### 10.3 Arch-sensitive lowering — atomics + context-switch
Atomic orderings lower differently per arch (x86 `lock`-prefix / plain MOV vs aarch64
LL/SC / `ldar`/`stlr`), and the A2 context-switch is per-arch asm. Lock both with the
**existing inline-asm cross-arch sibling pattern**: a `.build` `{"target": "…"}`
sidecar runs **ir-only** on a non-matching host (asserts `.ir` + `.exit` + `.stderr`
from `sx ir --target`) and **end-to-end** on a matching CI runner. So `Atomic`
lowering carries **x86_64 + aarch64 `.ir`** snapshots; the context-switch gets
per-arch run tests on matching runners.
### 10.4 New corpus categories
`17xx` atomics · `18xx` concurrency (fibers/channels/race/async, all under the
deterministic `Io`). Comptime metaprogramming (`type_info`/`reify`) + comptime-asm
extend `06xx`; C1 FFI extends `12xx`; the cross-arch comptime-asm **loud bail** and
the cancellation diagnostics are `11xx`.
### 10.5 Per-piece gates (design level)
| Piece | Locks via |
|---|---|
| **N1 atomics** | unit `emit_llvm.test.zig` (LLVM `atomicrmw`/`cmpxchg`/`fence` + ordering emission); corpus `17xx` single-thread (deterministic); arch-gated `.ir` (x86_64 + aarch64) |
| **type_info / reify** | unit (reflect round-trips; reify'd enum has correct layout/match codegen); corpus `06xx` comptime (deterministic) |
| **C1 FFI** | **behavior-lock** existing trampoline cases first; then xfail→green `12xx` comptime extern with floats / structs-by-value / aggregate (`{ptr,len}`) returns; unit for thunk-synth + args-buffer marshal |
| **S1 spine** | infra — exercised transitively via C1/C3 examples; unit for LLJIT lifecycle + thunk cache |
| **C3 comptime asm** | corpus `06xx` host-arch `#run` asm computes a value; `11xx` diagnostic asserts the cross-arch loud bail |
| **A1/A2 fibers** | unit (scheduler step, fiber bootstrap); context-switch arch-gated run tests; corpus `18xx` under deterministic `Io` |
| **A3/A5 schedulers, channels, race, cancel** | corpus `18xx` (the 10 patterns) under deterministic `Io` → deterministic snapshots; cancellation cleanup (`onfail`/`defer`) asserted via stdout ordering |
### 10.6 Cadence example (atomics, N1)
1. **xfail** — add `examples/17xx-atomics-fetch-add.sx` using `Atomic(i64).fetch_add`; seed the `.exit` marker → **red** (codegen missing). *(test added, not yet passing)*
2. **green** — emit LLVM `atomicrmw add` + ordering; example passes; capture `.stdout` + x86_64/aarch64 `.ir` snapshots; review the diff. *(makes it pass, no new test)*
This satisfies "no commit both adds a test and makes it pass," and every other piece
follows the same xfail→green (or behavior-lock→extend) shape.
### 10.7 Review-surfaced gaps (the high-corruption-risk pieces need *correctness*, not existence, tests)
The §10.5 gates prove things *run*; the §8.1 risks are silent-corruption modes a
run/snapshot test won't catch. Each needs an explicit adversarial gate:
- **A2 context-switch — switch-stress test.** Scribble *every* callee-saved register
+ a stack-canary before suspend; deep/recursive fiber chains; verify all survive
post-resume. Run/snapshot tests don't prove register preservation. (The single
highest-corruption-risk piece, §8.1.1.)
- **Deterministic-`Io` — calibrate the oracle.** Cross-check a handful of cases
against the blocking `Io` and property-test that scheduling order is actually fixed,
*before* trusting it to gate everything async (a deterministic-but-wrong scheduler
snapshots garbage).
- **`context`-fiber-local invariant — named test at the N×M:1/M:N step.** M:1 can't
exercise migration; add a test that forces a fiber to migrate and asserts it reads
*its* `context`/`errno`, not the new thread's.
- **N1 ordering *semantics* are out of snapshot scope — state it loudly.** `.ir`
snapshots prove the *keyword emitted*, not weak-memory correctness (e.g. `relaxed`
where `acquire` was needed ships green). Declare this out-of-scope parallel to
§10.2's race carve-out; lock-free structures need the stress harness.
- **C1 args-buffer — adversarial layout cases.** Over-aligned structs, empty structs,
aarch64 small-struct register splitting, `bool` — a wrong layout that happens to
print right passes a stdout test. Call these out explicitly, not just
"structs-by-value."
- **S2 — has no gate today despite a prior spike failure.** When reached, add a TLS +
C-constructor JIT test (the exact `_Thread_local` SIGABRT case), per host OS.
- **Hot-reload — no row today.** When picked up: state-survival test + the
live-suspended-fiber-into-stale-module hazard (R1/R2).

1031
design/inline-asm-design.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@ context:
`sx_trace_push` call emitted through the normal call lowering.
- **`interp`:** yields the packed `(func_id, span.start)` from its own
execution context as the op's value. The separate `sx_trace_push` call
op consuming it is executed by the interp as a foreign call (via
op consuming it is executed by the interp as an extern call (via
`host_ffi`/dlsym, the same path as any extern), storing the packed value
in the buffer; the comptime `.trace_resolve` resolver later recovers
`file:line:col` from it.
@@ -257,7 +257,7 @@ both the trace path and the DWARF path. Items marked ✅ exist today;
| [`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/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 an extern 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_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` |
@@ -301,8 +301,8 @@ traces and DWARF can never disagree:
declared lazily by `getTraceFids()` (which sets `needs_trace_runtime`).
3. **Interpreter** (`interp.zig`, same op): pack `(current_func_id,
span.start)` into a `u64` and return it as the op's value. The separate
`sx_trace_push` call op is then executed by the interp as a foreign call
(`callForeign` → `host_ffi.lookupSymbol`/dlsym, the same path as any
`sx_trace_push` call op is then executed by the interp as an extern call
(`callExtern` → `host_ffi.lookupSymbol`/dlsym, the same path as any
extern), storing the packed value in the buffer. The comptime
`.trace_resolve` resolver later turns each packed value back into
`file:line:col` via the IR/source tables.

View File

@@ -12,14 +12,14 @@ wrapped around them. A function that can fail adds a trailing `!` to its
return type:
```sx
parse_digit :: (s: string) -> (s32, !) {
parse_digit :: (s: string) -> (i32, !) {
if s.len == 0 raise error.Empty;
if !is_digit(s[0]) raise error.BadDigit;
return s[0] - '0';
}
```
The `(s32, !)` says "returns an `s32` on success, or an error." The `!`
The `(i32, !)` says "returns an `i32` on success, or an error." The `!`
is one more slot in sx's normal multi-return — the error rides
alongside the values, it doesn't replace them.
@@ -61,7 +61,7 @@ in the signature:
```sx
ParseErr :: error { Empty, BadDigit, Overflow };
parse_int :: (s: string) -> (s32, !ParseErr) {
parse_int :: (s: string) -> (i32, !ParseErr) {
if s.len == 0 raise error.Empty;
if overflowed raise error.Overflow;
...
@@ -109,7 +109,7 @@ When you call a failable function and want its error to bubble up to
*your* caller, prefix the call with `try`:
```sx
two_digits :: (s: string) -> (s32, !) {
two_digits :: (s: string) -> (i32, !) {
a := try parse_digit(s); // if this fails, two_digits fails
b := try parse_digit(s[1..]);
return a * 10 + b;
@@ -153,7 +153,7 @@ attempt.
port := parse_port(s) or 8080; // if parsing fails, port = 8080
```
The error is absorbed; `port` is a plain `s32`.
The error is absorbed; `port` is a plain `i32`.
### Chain attempts — first success wins
@@ -347,7 +347,7 @@ For human-readable context, use `log` on the error path — the tag tells
you *what* failed, the log tells you the *details*:
```sx
parse :: (s: string) -> (s32, !) {
parse :: (s: string) -> (i32, !) {
onfail e { log.warn("parsing {}: {}", s, e); }
...
}

60
docs/fork-c/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Fork C — setup contract (S0)
Zero-legacy name-resolution redesign (S0→S6), replacing E6cK. This directory is the
**committed S0 contract** the remaining 26 steps (S1→S6) execute against. It is pure
setup/documentation — **S0 introduces no production code change and no behavior
change**; single-author output is byte-identical to `wt-stdlib-base` by construction.
**Authority:** `runs/stdlib/design/fork-c-deepdive/reconciled.md` (§5 by-construction
audit, §6 staged roadmap + deletion lists, §8 fold-in) and
`runs/stdlib/design/fork-c-plan/planspec-r3.json` (the S0.1/S0.2/S0.3 intent +
acceptance, Adi-confirmed — they govern).
## Doc-area decision
Deliverables live under **`docs/fork-c/`**, NOT `current/fork-c/`. Reason: `current/`
is **gitignored** in this repo (`.gitignore``current/`), so a new `current/fork-c/`
tree would not be committed; the S0 contract must be committed. `docs/` is tracked.
## Baseline
- **Base:** `wt-stdlib-base @ 1f755284d98c6e8ebba953045c06e35d8cbe6278` (AE6a merged).
- **E6b:** `flow/stdlib/E6b @ af737b0` — PAUSED, **unmerged**, all transitional, destined
for S3/S6 deletion. Its semantics goldens are harvested; its src is never merged.
- **This branch:** `flow/stdlib/S0` (branched from the base). **Production/compiler
behavior is base-equivalent** — zero `src/` changes, single-author output
byte-identical to base by construction — but S0 HEAD is a distinct commit carrying
the docs/examples/tests diff (it does **not** equal base).
## Contents
| file | sub-step | what |
|---|---|---|
| `S0.1-byte-baseline-and-commit-discipline.md` | S0.1 | the byte-identity reference + the zero-diff reproduction command + resolver-target exclusion + the `mirror \| consumer-cutover \| deletion` commit-classification discipline |
| `S0.2-e6b-disposition-and-two-corpus-partition.md` | S0.2 | E6b src not merged (grep-clean) + the harvested corpus partitioned baseline-green vs resolver-target + 0811/0829 placement + the E6BR-5 re-file + the mirror/flip statement |
| `S0.3-reuse-delete-ledger.md` | S0.3 | every load-bearing AE6 artifact mapped REUSED (Fork C home) or DELETED/TRANSITIONAL (S3/S6 phase); E6c/d/e dropped, F/H/I/K absorbed/superseded |
| `../../tests/resolver-target/` | S0.2 | the listed resolver-target harness: `manifest.md` (18 cases), `expected/` TARGET goldens, the E6BR-5 reproducer under `cases/`, and `run_resolver_target.sh` (xfail runner — NOT part of the gate) |
## The two-corpus law (the one thing the next 26 steps must never conflate)
1. **BASELINE-GREEN / mirror-equivalence corpus** — tests where the old selector is
already correct today (AE6a merged + the 6 harvested baseline-green cases + FFI
12xx14xx + the LSP smoke). Stays **green and single-author byte-identical at every
step S0→S6**, and is the S2 assert-only Debug mirror's **only** oracle.
2. **RESOLVER-TARGET corpus** — harvested goldens that encode **known-wrong old
behavior** (silent resolve / under-diagnose / wrong author / own-wins-fails) + the
E6BR-5 regression. Held **inactive/xfail** (listed, never silently dropped) from S0
through S3.8, then **flips to active + green at S3.9** against its TARGET output —
never against the old selectors (a wrong oracle for it on the E6b-unmerged base).
## Gate (this branch)
```sh
export PATH="$HOME/.zvm/bin:$PATH"
zig build && zig build test && bash tests/run_examples.sh # exit 0 over the baseline-green corpus
```
Since S0 changes no production code, single-author output is byte-identical by
construction. The resolver-target xfail diagnostic
(`bash tests/resolver-target/run_resolver_target.sh`) is separate and not part of the
gate.

View File

@@ -0,0 +1,102 @@
# S0.1 — Byte-baseline (baseline-green only) + commit-classification discipline
Authority: `runs/stdlib/design/fork-c-plan/planspec-r3.json` (S0.1) +
`runs/stdlib/design/fork-c-deepdive/reconciled.md` (§6 green-lock). Base:
`wt-stdlib-base @ 1f755284d98c6e8ebba953045c06e35d8cbe6278` (AE6a merged). This is
documentation only — no production code change, no behavior change.
## 1. The byte-identity reference
The single-author byte-identity reference that **every later Fork C commit is checked
against** is the committed `examples/expected/*` snapshot set on the baseline commit
above. We do **not** copy every file into this doc; the snapshots ARE the reference,
and the reproduction is a documented zero-diff command (§2). Single-author byte
identity is held structurally by `nominal_id == 0 ≡ structural intern`
(`src/ir/types.zig` `internNominal`), which S1S2 keep additive and S3 preserves
through ordinal-0 materialization.
The baseline-green corpus that the reference covers:
| segment | what | how it is exercised | count @ S0 |
|---|---|---|---|
| baseline-green examples | every `examples/<name>.sx` with an active `examples/expected/<name>.exit` marker (incl. the 6 harvested baseline-green cases `07950798`, `0823`, `0828`) | `bash tests/run_examples.sh` | **540** active markers |
| FFI corpus | `examples/12xx14xx` (96 entry trees; 95 with active markers; ~418 files incl. module/`.c`/`.h`/`.m` companions) | same runner (markers) | 95 active markers |
| LSP completion/hover smoke | LSP unit tests under `zig build test``analyzeDocument` flat/namespaced import + the `lsp corpus sweep: every examples/*.sx analyzes without panicking` sweep + definition/references/inlayHint | `zig build test` | — |
> Count note: `reconciled.md §1` cites "116 files in `examples/12xx14xx`". The live
> tree has **96 entry `.sx` trees** (95 with active markers); the "116" is a stale
> historical figure. What is load-bearing is the invariant — *all FFI 12xx14xx
> examples stay byte-stable* — which `run_examples.sh` enforces via their markers,
> not the exact historical count.
## 2. The zero-diff reproduction method
`tests/run_examples.sh` runs `sx run <entry>` for every `<name>.sx` that has an
`expected/<name>.exit` marker, normalizes stdout/stderr identically for expected and
actual (address hashing → `0xADDR`; absolute `…/examples|issues/` paths → repo-relative),
and diffs exit + stdout + stderr (+ optional `.ir`). A **zero-diff** run is the
byte-identity check:
```sh
export PATH="$HOME/.zvm/bin:$PATH"
zig build # build the compiler under test
zig build test # unit tests incl. the LSP completion/hover smoke + corpus sweep
bash tests/run_examples.sh # must print "<N> passed, 0 failed, 0 skipped*, 0 timed out"
```
Any later commit whose single-author output drifts shows up as a `FAIL` with a printed
diff. `tests/run_examples.sh --update` regenerates the snapshots — it must **only** be
run intentionally when a commit's classification (§3) authorizes an output change, and
the diff reviewed. (* `skipped` counts `.exit` markers whose `.sx` is absent; it is 0
on a clean tree.)
## 3. Resolver-target EXCLUSION (recorded, not silently absent)
The **resolver-target set** is **excluded from both** the byte-baseline AND the active
`run_examples.sh` set, because it encodes known-wrong old behavior (silent resolve /
exit 0 / under-diagnosis / wrong author) — a stale old-selector verdict must never
enter the baseline as if it were correct. The exclusion is explicit and **listed**, not
absent:
- the 17 harvested `08xx` resolver-target cases (`0811, 0812, 0813, 0814, 0815, 0816,
0817, 0818, 0819, 0820, 0821, 0822, 0824, 0825, 0826, 0827, 0829`) + the re-filed
**E6BR-5** regression;
- enumerated in `tests/resolver-target/manifest.md`, with TARGET goldens in
`tests/resolver-target/expected/`, held inactive (no `examples/expected/` marker) and
asserted currently-failing by `tests/resolver-target/run_resolver_target.sh`;
- full disposition in `S0.2-e6b-disposition-and-two-corpus-partition.md`.
They flip to active + green at **S3.9** and only then join the baseline.
## 4. Commit-classification discipline
Every future Fork C migration commit (S1→S6) is tagged with exactly one of three
classes, stated in the commit subject/body so a reviewer knows what byte-effect to
expect:
| tag | meaning | expected byte-effect on the baseline-green corpus |
|---|---|---|
| **`mirror`** | builds new facts / a new resolver path **in parallel**, while lowering still consumes the old path (S1S2; the assert-only Debug mirror) | **zero** — single-author output byte-identical; provably zero byte-risk |
| **`consumer-cutover`** | switches a consumer from the old path to the resolved facts (S3 materializer / calls / consts / protocol-registration / `#using`; S5 LSP) | **zero on baseline-green** — byte-identical by ordinal-0 materialization + payload-preserving facts; the only commits that may change resolver-target (the S3.9 flip is a cutover) |
| **`deletion`** | removes a now-dead artifact (old name selectors, `*_by_source` mirrors, `type_bridge`, `findByName`, the grep gate, the S2 mirror) | **zero** — the deleted code had no live readers after its cutover; a surviving reader fails to compile |
Rules:
- A commit is exactly one class; a cutover that also deletes its now-dead source is
still a `consumer-cutover` if the delete is the same atomic cutover (e.g. S3.10
removes the last old selector **and** the S2 mirror in the cutover commit).
- `mirror` and `deletion` commits MUST be byte-zero on baseline-green; if a `mirror`
commit changes a byte, it was not actually parallel — stop.
- Only `consumer-cutover` may legitimately change output, and only the **resolver-target**
corpus (never baseline-green) — that is the S3.9 flip.
## 5. Acceptance (S0.1) — self-check
- ✅ Byte-baseline of all baseline-green examples + FFI 12xx14xx + LSP smoke captured
and reproducible via the documented zero-diff command (§1§2); the reference is the
committed `examples/expected/*` at the baseline commit, re-checked by a zero-`FAIL`
`run_examples.sh`.
- ✅ Resolver-target set explicitly excluded from the byte-baseline AND the active
`run_examples.sh` set, and recorded/listed (§3) — not silently absent.
- ✅ The `mirror | consumer-cutover | deletion` classification rule is written (§4).
- ✅ `zig build && zig build test && bash tests/run_examples.sh` green over the
baseline-green corpus; no behavior change (S0 adds no production code).

View File

@@ -0,0 +1,143 @@
# S0.2 — E6b disposition, the two-corpus partition, and the E6BR-5 re-file
Authority: `runs/stdlib/design/fork-c-plan/planspec-r3.json` (S0.2) +
`runs/stdlib/design/fork-c-deepdive/reconciled.md` (§3 one-resolver-two-timings, §6
roadmap, §8 fold-in). Base: `wt-stdlib-base @ 1f75528` (AE6a merged). E6b:
`flow/stdlib/E6b @ af737b0` (PAUSED, unmerged). This is documentation only — no
production code change.
## 1. E6b transitional src is NOT merged
The transitional E6b source on `flow/stdlib/E6b @ af737b0` is **not** brought onto the
Fork C baseline. All of it is destined for **S3/S6 deletion** under Fork C:
- the **route-all engine** + the type-reference **choke-point**
(`resolveRegistrationSigTypeInSource` / `sig_registration_mode`) — `lower.zig`,
`protocols.zig`;
- the executable **grep gate** `src/ir/e6br_gate.test.zig` (+ its `ir.zig` import);
- the supporting `calls` / `lower` / `protocols` / `type_bridge` / `type_resolver` /
`types` changes.
Grounded reasons it is transitional, not load-bearing: the whole-AST resolver walks
**every** reference position, so it closes the protocol/param-impl signature surface
the choke-point policed (reconciled §3/§4 T4); and the grep gate is unnecessary once
the leaf it polices no longer exists (reconciled §5). AE6a stays merged purely as
**proof-of-semantics + per-decl nominal infra** (`internNominal` / `nominal_id==0`
byte-identity rule / `RawDeclRef` facts → the `DeclId` seed) — see
`S0.3-reuse-delete-ledger.md`.
**Grep-clean (verified on this branch — `src/` is base-equivalent, zero src changes):**
| check | base / S0 | E6b |
|---|---|---|
| `resolveRegistrationSigTypeInSource` in `src/` | **absent** | present (`lower.zig`) |
| `sig_registration_mode` in `src/` | **absent** | present (`lower.zig`, `protocols.zig`) |
| `src/ir/e6br_gate.test.zig` | **absent** | present |
| `walkConcreteSigArgs` in `src/ir/lower.zig` | **absent** | present (the E6BR-5 site) |
Because S0 introduces **no production code change**, these remain absent on the Fork C
baseline by construction.
## 2. Harvested resolver-acceptance corpus + the two-corpus partition
Harvested by copying the example **trees + their expected goldens ONLY** (never the
transitional src):
- `examples/08110829` (19 trees) from `flow/stdlib/E6b @ af737b0` — error-set /
protocol / param-impl / route-all same-name ambiguity & own-wins surfaces;
- `examples/07950798` (already merged on the base) — the enum/union ambiguous/own-wins
pair.
The corpus is **partitioned by empirically running each harvested case through the
current base compiler and comparing its output to the E6b TARGET golden** (the exact
method: build, then `sx run examples/<name>.sx`, normalize with the
`run_examples.sh` rules, diff against the E6b golden):
### (a) BASELINE-GREEN — the mirror-equivalence corpus
Harvested cases whose **old selector is already CORRECT on `wt-stdlib-base` today**
(actual == E6b target, byte-for-byte). They get standard
`examples/expected/<name>.{exit,stdout,stderr}` markers, are run by
`tests/run_examples.sh`, and are **locked into the S0 baseline**. This set is the S2
assert-only Debug mirror's **ONLY** oracle.
| case | exit | proof it is already-correct on base |
|---|---|---|
| `0795-modules-same-name-enum-ambiguous` | 1 | already merged + green (E6a) |
| `0796-modules-same-name-enum-own-wins` | 0 | already merged + green |
| `0797-modules-same-name-union-ambiguous` | 1 | already merged + green |
| `0798-modules-same-name-union-own-wins` | 0 | already merged + green |
| `0823-route-all-own-wins-subform-wrappers` | 0 | base output == E6b target (`opt=1 arr=2 sl=3 dep=99`) |
| `0828-protocols-param-impl-arg-wrapped-own-wins` | 0 | base output == E6b target (`tag=7 dep=9`) |
### (b) RESOLVER-TARGET — known-wrong old behavior
Harvested cases (and the re-filed E6BR-5) where the **old selector is wrong on the
E6b-unmerged base** (silently resolves last-wins / under-diagnoses / picks the wrong
author / fails own-author resolution). They are held in a **documented, listed,
separate harness** at `tests/resolver-target/` with **NO active `examples/expected/`
marker** (so `run_examples.sh` does not run them), but **WITH** their TARGET output
recorded and an **enumerated manifest** (`tests/resolver-target/manifest.md`, 18
cases). The sibling xfail runner `run_resolver_target.sh` runs each and asserts it
currently FAILS — so the set is **never silently dropped**. It flips to active + green
at **S3.9**.
The 17 harvested `08xx` resolver-target cases: `0811, 0812, 0813, 0814, 0815, 0816,
0817, 0818, 0819, 0820, 0821, 0822, 0824, 0825, 0826, 0827, 0829`. Per-case class,
base-now behavior, and target are in the manifest.
**0811 and 0829 placement (required one-liners):**
- **0811 → resolver-target.** *Old selector is wrong here on the E6b-unmerged base:*
the five bare error-set forms (`size_of` / annotation / type-as-value / match-arm /
`!IoErr` channel) each silently resolved one global last-wins `IoErr` via the
`type_bridge.resolveInlineErrorSet` `findByName` short-circuit and the program exited
0; the target is five loud "type 'IoErr' is ambiguous" diagnostics + exit 1.
- **0829 → resolver-target.** *Old selector is wrong here on the E6b-unmerged base:*
the concrete `*Box` prefix of the mixed pack-closure source fell to the no-author
`type_bridge.resolveTemplateSignatureType` wrapper (global last-wins) and registered
silently (exit 0); the target is a loud `Box` ambiguity + exit 1.
## 3. E6BR-5 re-filed into resolver-target
**E6BR-5** (the open nested-pattern ambiguity hole that paused E6b) is re-filed into
the resolver-target set as a regression — **explicitly NOT an E6b attempt-6.**
- *What it is:* `walkConcreteSigArgs` (E6b `lower.zig:14686`) **skips** any direct arg
that has an unbound part instead of **recursing** into it, so a nested concrete leaf
(`*Box` inside `Closure(Closure(*Box, ..$inner) -> $IR, ..$args) -> $R`) is never
ambiguity-checked; the impl compiled rc=0. On `wt-stdlib-base` (E6BR-4 unmerged) both
the direct and nested concrete leaf silently resolve via the pre-E6BR-4 no-author
wrapper path, so the reproducer exits 0 with no diagnostic.
- *Subsumption (one line):* the whole-AST resolver walks **every** reference position —
including nested parameterized-pattern leaves — so the nested `Box` is
ambiguity-checked by construction and the "skip a nested arg" hole cannot exist.
- *Form:* an authored reproducer lives at
`tests/resolver-target/cases/e6br5-nested-pack-source-ambiguous.sx` (self-contained;
verified to exit 0 silently on the base — the fail-before). Its target is a **spec**
(exit 1 + `Box` ambiguous), with exact bytes produced by the resolver at S3.9
(`expected/e6br5-…target.md`).
## 4. The mirror / flip statement (locked)
- **S2's mirror asserts `resolver == old-selector` over the BASELINE-GREEN corpus
ONLY.** It never asserts new == old over the resolver-target corpus (the old selector
is a wrong oracle there), and it never selects for lowering (assert-only, Debug-only,
deleted in the S3.10 commit that completes the cutover).
- **S3.9 flips the resolver-target corpus to active + green**, validated against each
case's TARGET output (not against the old selectors). After the flip, the goldens
move from `tests/resolver-target/expected/` to `examples/expected/`, the harness goes
empty, and these cases stay green through S6.
## 5. Acceptance (S0.2) — self-check
- ✅ Written disposition: E6b transitional src not merged (§1, grep-clean table).
- ✅ Harvested corpus recorded + partitioned: baseline-green (markers, locked,
mirror-equivalence) vs resolver-target (enumerated manifest, separate listed harness,
no active marker, TARGET goldens present, currently xfail, never dropped) — §2.
- ✅ 0811 and 0829 each in resolver-target with the "old selector is wrong on the
E6b-unmerged base" one-liner — §2.
- ✅ E6BR-5 re-filed into resolver-target with the subsumption one-liner, NOT an E6b
attempt-6 — §3.
- ✅ Mirror/flip statement recorded — §4.
- ✅ No transitional E6b src on the Fork C baseline (grep-clean) — §1.

View File

@@ -0,0 +1,72 @@
# S0.3 — AE6 reuse / delete ledger
Authority: `runs/stdlib/design/fork-c-deepdive/reconciled.md` (§6 roadmap + per-phase
deletion lists, §8 fold-in) + `runs/stdlib/design/migration.md` ("what happens to
already-merged AE6 work") + `planspec-r3.json` (S0.3). Base: `wt-stdlib-base @
1f75528`. Symbol/file refs are grounded against the base tree. This is documentation
only — no code change.
This ledger is the contract the later phases execute against: every load-bearing AE6
artifact is mapped to **REUSED** (with its Fork C home) or **DELETED/TRANSITIONAL**
(with the S3/S6 phase that removes it). AE6a stays merged; the transitional E6b src
is never merged (see `S0.2-…`).
## A. REUSED — AE6 work that becomes Fork C infrastructure
| AE6 artifact (base location) | Fork C home | phase |
|---|---|---|
| **Phase A import facts**`RawDeclRef` / `RawAuthor` / `ModuleDecls` / `NamespaceEdges` (`src/imports.zig`), built in `resolveImports` (`core.zig`) | **seed `DeclId` construction**`DeclTable` keys every `RawDeclRef` into a stable `DeclId` (source + name + AST ptr + `DeclKind`); namespace members get ids | S1 |
| **Phase B visibility**`collectVisibleAuthors` / `collectNamespaceAuthors` (`src/ir/resolver.zig`), "the one graph iterator" over `flat_import_graph`/`namespace_edges` | **resolver internals** — become the resolver's visibility walk, with own-wins / single-flat-visible / ≥2-ambiguous **verdicts above them**, producing `ResolvedRef` | S2 |
| **Phase C callable selection** | **`ResolvedRef.function` / `.type_function`** keyed by `DeclId` | S2 (select) → S3 (consume) |
| **Phase D nominal identity**`internNominal` / `updatePreservingKey` + the **`nominal_id == 0 ≡ structural intern` ordinal-0 byte-identity rule** (`src/ir/types.zig`, `lower.zig`) | **reused inside materialization**`materializeType(ResolvedTypeNode)` interns in old scan order with ordinal 0 for non-colliding decls ⇒ byte-identical single-author output | S3 (+ green-lock every phase) |
| **E-series selection rules** — own-wins / not-visible / ambiguity / direct-flat (the E1E6a behaviors) | **resolver behavior + regression tests** (the baseline-green corpus is the mirror oracle) | S2 behavior; regressions locked S0 |
| **CP rule** — body-author == layout-author | **keyed by `InstantiationId{template_decl, resolved_args}`** in the fact store | S4 |
| **E6BR routed-signature cases** (the E6BR-1…4 behavioral cells) | **resolver-signature regressions** — the resolver walks every signature reference position; cases live in the resolver-target corpus, flip at S3.9 | S3.9 |
| **FFI `runtime_class_map` consumers + FFI corpus (96 entry trees / 95 active markers)** | parallel `DeclId`s land at S1 (map still the consumer); runtime classes keyed by `DeclId` at S4; runtime names stay **payload strings on facts** | S1 → S4 |
## B. DELETED / TRANSITIONAL — removed in S3/S6
| artifact (base location) | why it goes | removal phase |
|---|---|---|
| **Stateless `type_bridge` leaves**`resolveAstType` / `resolveTypeName` / `resolveInline{Enum,Union,Struct,ErrorSet}` / `resolveParameterizedType` / `resolveErrorType` (`src/ir/type_bridge.zig`); the whole `type_bridge.zig` (E6b renamed the entry leaf `resolveAstTypeNoAuthorSelection`) | no-author `findByName` leaves — replaced by `materializeType(ResolvedTypeNode)` which cannot take a name | selectors S3; file S6 |
| **`TypeResolver.resolveName` / `resolveNamed`** (`src/ir/type_resolver.zig`) (E6b: `resolveNamedGlobalNoAuthorSelection`) | name→global resolution; superseded by the resolver | S3 (name-selection) / S6 (`resolveName*`) |
| **Old name selectors**`selectNominalLeaf`, `resolveNominalLeaf`, `moduleTypeAuthor`, `namedRefTid`, `flatTypeAuthorCount`, `nameAuthoredAsTypeAnywhere`, `selectModuleConst` (+ const-source pins), `selectGenericStructHead`, `headTypeGate`, `headFnLeak`, `flatFnAuthor*`, the name-selection in `resolveTypeCallWithBindings`/`resolveParameterizedWithBindings` (`src/ir/lower.zig`) | the duality leak — replaced by `ResolvedRef`/`ResolvedTypeNode` consumed in lowering | S3 |
| **`*_by_source` mirrors + source pins** (`src/ir/program_index.zig`) + their writers + the `lower.zig` unified writers | dual-write mirrors of the global maps — superseded by the `DeclId`-keyed fact store | S4 |
| **`type_decl_tids`** (`src/ir/types.zig`, `lower.zig`) | name→TypeId mirror — superseded by `DeclId` facts | S4/S6 |
| **`ShadowTypeDecl` / shadow-slot reservation helpers** (`src/ir/lower.zig`) + lower-side nominal selectors | shadow reservation is a name-keyed pre-pass artifact — subsumed by `DeclId` pre-pass | S3/S4 |
| **`TypeTable.findByName` / `findUniqueByName`** (`src/ir/types.zig`) | the global name table — deleted **last** (after the ~15 category-(b) stdlib lookups are re-homed to resolved-once `DeclId`s, per the §6 critical ordering constraint) | S6 |
| **the type-reference choke-point + route-all engine** (`resolveRegistrationSigTypeInSource` / `sig_registration_mode`) | **transitional E6b src — never merged**; destined for deletion under Fork C | S3/S6 (already off-baseline) |
| **the grep gate `e6br_gate.test.zig`** (+ its `ir.zig` import) | **transitional E6b src — never merged**; unnecessary once the leaf it polices is gone | S6 (already off-baseline) |
| **the S2→S3 assert-only Debug mirror** | a test oracle, not a code path — must be deleted in the **same S3.10 commit** that removes the last old selector | S3.10 |
## C. Dropped / absorbed / superseded plan items
- **E6c / E6d / E6e** (protocol / runtime-class / type-fn per-kind identity): **DROPPED as
steps.** They become resolver behavior + regression tests — a whole-AST resolver
walks every reference position (annotation, `size_of`, dispatch head, `Self`,
vtable), closing the protocol surface the per-kind patch structurally could not
(reconciled §4 T4).
- **F** (namespace resolver + 0104): **ABSORBED** into S2 as resolver internals
(`namespace_edges``ResolvedRef.namespace` / member).
- **H** (constructor heads): **ABSORBED** into S3 `materializeType` over resolved
generic/protocol/type-fn heads.
- **I** (protocol + runtime-class selection, loud-on-≥2): **ABSORBED** into S2 selection + S4
`DeclId` facts.
- **K** (delete dead readers): **SUPERSEDED** by the S4 `DeclId`-keyed fact store + the
S6 deletions — "just delete the maps" is upgraded to "replace with `DeclId` facts."
- **the per-kind taxonomy**: **REIFIED** as the `ResolvedRef` union itself; the
exhaustive `switch` is the live taxonomy check (the grep gate is gone because the leaf
it policed no longer exists).
## D. Acceptance (S0.3) — self-check
- ✅ Every load-bearing AE6 artifact mapped to REUSED (with Fork C home) or
DELETED/TRANSITIONAL (with the S3/S6 phase that removes it) — tables A & B; covers
Phase A import facts → `DeclId` seed; B `collectVisibleAuthors` → resolver internals;
C callable selection → `ResolvedRef.function/type_function`; D `internNominal` +
ordinal-0 byte-identity → materialization; E-series rules → resolver behavior +
regressions; CP → `InstantiationId`; the stateless `type_bridge`/`type_resolver`
leaves, `*_by_source` mirrors + source pins, `type_decl_tids`/shadow helpers +
lower-side selectors, the choke-point + route-all + grep gate → S3/S6 deletion.
- ✅ States E6c/d/e dropped and F/H/I/K absorbed/superseded — table C.
- ✅ No code change.

437
docs/inline-assembly.md Normal file
View File

@@ -0,0 +1,437 @@
# Inline Assembly in sx
A guide to writing inline assembly in sx — emitting raw target
instructions, wiring values in and out, writing through memory, and
defining whole routines in assembly.
> Looking for the *why* behind the design (how it maps to LLVM, the
> Zig comparison, the emit algorithm)? That lives in
> [inline-asm-design.md](../design/inline-asm-design.md). This page is the
> user-facing how-to.
---
## The mental model
`asm` is an **expression**. It drops to the machine: you write a
template of real instructions, declare which sx values feed registers
going in and which come back out, and the block evaluates to the
output value (or a tuple of them).
```sx
add :: (a: i64, b: i64) -> i64 {
return asm { "add %[out], %[a], %[b]", [out] "=r" -> i64, [a] "r" = a, [b] "r" = b };
}
```
Three things to know up front:
1. **The body is a brace block of comma-separated parts:** the template
string first, then operands, then an optional `clobbers(.…)` clause.
2. **Each operand is tagged by role**, not by position: `-> Type` is a
value output, `= expr` is an input, `-> @place` writes through to
existing storage. The list is flat and order-independent — there are
no positional `:` sections.
3. **The outputs decide the result.** Zero outputs → `void` (and the
block must be `volatile`); one → that type; many → a tuple.
Templates are **AT&T syntax** (lowered through LLVM), **target-specific**,
and **never run at compile time** — see [When it runs](#when-it-runs).
---
## Operands
An operand is `[name]? "constraint" <role>`. The constraint string is
the LLVM/GCC-style constraint; the role marker says what the operand
does.
### Inputs — `= expr`
`= expr` feeds a value in. The constraint picks where it lands:
```sx
[a] "r" = a // any general register
"{rdi}" = fd // pinned to a specific register (x86_64 rdi)
```
### Symbol inputs — `"s" = fn`
A `"s"` input feeds a **function or global symbol** (not a runtime value).
In the template, `%[name]` expands to the symbol's **platform-mangled
name**, so you can branch or call straight to it:
```sx
cb :: (n: i64) -> i64 export "cb" { return n + 1; }
trampoline :: (n: i64) -> i64 {
return asm volatile {
#string ASM
mov x0, %[arg]
bl %[fn] // DIRECT call — `bl _cb` on macOS, `bl cb` on Linux
mov %[res], x0
ASM,
[res] "=r" -> i64,
[arg] "r" = n,
[fn] "s" = cb, // symbol operand
clobbers(.x0, .x30, .memory),
};
}
```
The same `%[fn]` works on **x86_64** — just the branch mnemonic differs:
```sx
return asm volatile {
"call %[fn]", // x86_64 — same portable %[fn]
[ret] "={rax}" -> i64,
"{rdi}" = n,
[fn] "s" = cb,
clobbers(.rcx, .rdx, .rsi, .r8, .r9, .r10, .r11, .memory),
};
```
Two reasons to prefer this over passing a function *pointer* in a plain
`"r"` register and using an indirect `blr`/`call *`:
- **One fewer indirection** — a direct PC-relative branch, no pointer
load into a register, and a predictable (non-indirect) branch.
- **Portable** — `%[fn]` is the same on every target; the backend emits
the correctly-mangled name, so you never hardcode the macOS leading
underscore *or* a per-arch operand modifier.
**How the portability works.** A bare `%[fn]` would render differently
per target — on x86 the symbol prints as `$cb` (an immediate `$`-prefix
that `call` rejects), while aarch64 prints it bare. So for a symbol (`"s"`)
operand the compiler **auto-injects LLVM's `:c` operand modifier** (`%[fn]`
`${N:c}`, "print the constant with no punctuation"). `:c` prints the
plain symbol on every target — equivalent to the GCC `:P`/`%P0` call-target
idiom on x86 (both emit the same `R_X86_64_PLT32` relocation) and a no-op
on aarch64. You can still override it with an explicit `%[fn:X]` if you
ever need a different rendering, but for a call/branch you never should.
The callee needs a stable, externally-linked symbol — i.e. `export`
(which also gives it the C ABI). A plain or `callconv(.c)`-only function
is `internal` and gets dead-code-eliminated, so the symbol won't link.
(A global-scope `asm { … }` routine has no operand list, so it can't use
a symbol operand — it references the literal symbol in its text.)
### Value outputs — `-> Type`
`-> Type` produces a value that becomes (part of) the block's result:
```sx
[out] "=r" -> i64 // result in any register
"={rax}" -> i64 // result pinned to rax
```
### Naming and `%[name]`
Inside the template, `%[name]` refers to an operand by its **effective
name**. An operand pinned to a register is **auto-named after that
register** — `"{rdi}"` is reachable as `%[rdi]`, `"={rax}"` as `%[rax]`
— so an explicit `[name]` is only needed:
- for a register-**class** operand (`"=r"`, `"r"`), which has no register
to name it; or
- to give a pinned operand a name *different* from its register.
Two labels are rejected so names stay unambiguous:
- the **echo form** `[rax] "={rax}"` — the label just repeats the pin, so
drop it (the operand is already `%[rax]`); and
- **duplicate** operand names.
In the template, `%%` is a literal `%`, and `%=` expands to a unique id
(handy for a local label that must differ across inlinings).
### The result type
The number of **value** outputs (`-> Type`) decides the block's type:
| `-> Type` outputs | result | example |
|---|---|---|
| 0 | `void` — must be `volatile` | `asm volatile { "dmb ish" }` |
| 1 | that type `T` | `x := asm { …, "=r" -> i64 }` |
| N | a **tuple**, fields named by each operand's name | `lo, hi := asm { … }` |
With multiple outputs you get real multiple return values — a named
operand becomes a named tuple field:
```sx
// aarch64 — split a value into low/high bytes
split :: (x: u64) -> (lo: u64, hi: u64) {
return asm {
#string ASM
and %[l], %[x], #0xff
lsr %[h], %[x], #8
ASM,
[l] "=r" -> u64, // → .lo (operand 0)
[h] "=r" -> u64, // → .hi (operand 1)
[x] "r" = x,
};
}
lo, hi := split(0x1234); // (0x34, 0x12) = (52, 18)
```
---
## `volatile`
`asm volatile { … }` marks the block as having side effects, so the
optimizer won't move or delete it. It is **required whenever there are
no value outputs** — a result-less, non-volatile asm would be dead code.
```sx
barrier :: () { asm volatile { "dmb ish" }; } // aarch64 full barrier
```
A block with outputs may still be `volatile` when its effects matter
beyond the returned value (e.g. a syscall).
---
## `clobbers(.…)`
`clobbers(.…)` is a dot-name list of registers and flags the asm trashes
that aren't already operands — so the register allocator keeps clear of
them:
```sx
clobbers(.rcx, .r11, .memory) // x86_64 syscall trashes rcx, r11, and memory
clobbers(.cc) // condition flags
```
`.memory` means "this asm reads or writes memory the compiler can't see,"
and `.cc` means "the condition flags are modified."
---
## Writing through memory — `-> @place`
Sometimes the asm should write into existing storage (a local, a struct
field) rather than *return* a value. `-> @place` does that: the place
output does **not** join the result tuple. There are three forms,
distinguished by the constraint.
### Write-through — `= …` constraint
The asm computes a value into a register; sx stores it through the
place's address afterward.
```sx
compute :: () -> i64 {
other : i64 = 0;
main_val := asm volatile {
#string ASM
mov %[m], #5
mov %[o], #37
ASM,
[m] "=r" -> i64, // value output → returned into main_val
[o] "=r" -> @other, // place output → stored through @other
};
return main_val + other; // 5 + 37 = 42
}
```
A value output and one or more place outputs can mix freely; only the
value outputs build the returned tuple.
### Read-write — `+` constraint
A `+` operand is read **and** written: the place's current value is fed
in, the asm updates it in place, and the result is stored back.
```sx
// increment-in-place: x is loaded, the asm adds 1, the result is stored back
bump :: () -> i64 {
x : i64 = 41;
asm volatile { "add %[v], %[v], #1", [v] "+r" -> @x };
return x; // 42
}
```
### Indirect memory — `=*m` constraint
An `=*m` operand passes the place's **address** to the asm, which writes
through it directly (no register round-trip, no return slot):
```sx
// store 42 straight into x's storage
poke :: () -> i64 {
x : i64 = 0;
asm volatile {
#string ASM
mov x9, #42
str x9, %[out]
ASM,
[out] "=*m" -> @x,
clobbers(.x9),
};
return x; // 42
}
```
**The place must be mutable storage.** Taking the address of a scalar
`::` constant has no meaning — a scalar constant folds to its value and
has no storage — so `-> @SOME_CONST` is a compile error:
```
cannot take the address of constant 'SOME_CONST' — a scalar '::'
constant has no storage (use a '=' variable or a local copy)
```
---
## Multi-instruction templates
A single `"…"` string is one fragment. For several instructions, use a
multi-line string literal or sx's **`#string` heredoc**, which is
delivered **verbatim** — no escape processing — so you write assembly
exactly as it should appear:
```sx
serialize :: () {
asm volatile {
#string ASM
mfence
lfence
ASM,
};
}
```
---
## Global (module-scope) assembly
A top-level `asm { … }` block is **global assembly** — template only
(no operands, no `volatile`), emitted as module-level assembly. It is
the place to define a whole routine in assembly. Symbols it defines are
reached from sx with a **lib-less `extern`** declaration:
```sx
asm {
#string ASM
.global _my_add
_my_add:
add x0, x0, x1
ret
ASM,
};
my_add :: (a: i64, b: i64) -> i64 extern;
main :: () -> i64 {
return my_add(40, 2); // 42 — computed by the global-asm routine
}
```
Multiple global blocks concatenate in source order. (Symbol naming
follows the platform convention — a leading underscore on macOS, none
on Linux.)
---
## When it runs
Inline assembly is emitted into the program and runs at **runtime**,
under both execution paths:
- **`sx run` (JIT)** — the module is compiled to an in-memory object
(the integrated assembler assembles your asm, including global blocks),
then run. Both inline and global asm work.
- **`sx build` (AOT)** — same, into a native binary.
It does **not** run at **compile time**. A `#run` (comptime) call into a
global-asm symbol fails loudly:
```sx
COMPUTED :: #run my_add(40, 2); // error: the symbol isn't linked yet at comptime
```
```
comptime extern call: symbol not found via dlsym
```
The comptime interpreter resolves `extern` calls against the host
process; a module-asm symbol only exists once the program is
assembled and linked, so call it at runtime, not in a `#run`.
---
## Cookbook
**Read a register** (no inputs):
```sx
stack_ptr :: () -> u64 {
return asm { "mov %[out], sp", [out] "=r" -> u64 }; // aarch64
}
```
**x86_64 syscall**`write(2)`, with pinned registers and clobbers:
```sx
sys_write :: (fd: i64, buf: *u8, count: i64) -> i64 {
return asm volatile {
"syscall",
[ret] "={rax}" -> i64, // bytes written, in rax
"{rax}" = 1, // SYS_write
"{rdi}" = fd,
"{rsi}" = buf,
"{rdx}" = count,
clobbers(.rcx, .r11, .memory),
};
}
```
**x86_64 divmod** — one instruction, two outputs, returned as a tuple:
```sx
divmod :: (n: u64, d: u64) -> (quot: u64, rem: u64) {
return asm {
"divq %[d]",
[quot] "={rax}" -> u64,
[rem] "={rdx}" -> u64,
"{rax}" = n, "{rdx}" = 0, [d] "r" = d,
clobbers(.cc),
};
}
q, r := divmod(17, 5); // (3, 2)
```
---
## Rules of thumb
- **`asm` yields a value.** Bind it (`x := asm { … }`), `return` it, or
destructure a multi-output tuple (`a, b := asm { … }`). A block with no
value outputs must be `volatile`.
- **Pinned operands name themselves.** `"{rdi}"` is `%[rdi]`; only add
`[name]` for register-class operands or to rename. Don't echo a pin
(`[rax] "={rax}"`).
- **`%%` for a literal percent; `%[name]` for an operand.** Templates are
AT&T.
- **List everything you trash** in `clobbers(.…)` — scratch registers,
`.cc`, and `.memory` if the asm touches memory the compiler can't see.
- **`-> @place` writes storage; pick the form:** `=` (compute then
store), `+` (read-modify-write), `=*m` (write through the address).
The place must be mutable — not a scalar `::` constant.
- **Global `asm { … }`** defines symbols; import them with a lib-less
`extern`. They run under JIT and AOT, but **not** in a `#run`.
- **It's target-specific.** Gate or pick instructions per architecture;
there is no portable instruction set.
---
## See also
- [inline-asm-design.md](../design/inline-asm-design.md) — the design rationale and
LLVM mapping.
- `examples/16xx-platform-asm-*` — the full, runnable example matrix
(basic in/out, tuples, the three `-> @place` forms, global asm, the
x86_64 syscall, and the comptime-boundary guard).
- The "Inline Assembly" section of [readme.md](../readme.md) for a
one-screen overview.
```

21
editors/vscode/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 agra
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
editors/vscode/README.md Normal file
View File

@@ -0,0 +1,25 @@
![sx](cover.png)
# sx for Visual Studio Code
Language support for the [sx programming language](https://git.swipelab.com/lab/sx).
## Features
- **Syntax highlighting** for `.sx` files, including embedded GLSL, SQL, HTML, and JSON blocks.
- **Language server integration** — the extension launches the `sx` binary's language server (`sx lsp`) to provide editor intelligence.
- **Breakpoints** registered for the `sx` language.
## Requirements
The `sx` compiler must be installed and on your `PATH` (or point the extension at it via the setting below). The extension shells out to it for the language server.
## Settings
| Setting | Default | Description |
|---------|---------|-------------|
| `sx.lspPath` | `sx` | Path to the `sx` binary used to start the language server (`sx lsp`). |
## License
[MIT](LICENSE) © agra

BIN
editors/vscode/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
editors/vscode/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,26 @@
"description": "Language support for the sx programming language",
"version": "0.0.1",
"publisher": "swipelab",
"icon": "icon.png",
"galleryBanner": {
"color": "#000000",
"theme": "dark"
},
"repository": {
"type": "git",
"url": "https://git.swipelab.com/lab/sx.git"
},
"homepage": "https://git.swipelab.com/lab/sx",
"bugs": {
"url": "https://git.swipelab.com/lab/sx/issues"
},
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Programming Languages"
],
"license": "MIT",
"activationEvents": [
"onLanguage:sx"
],
@@ -73,13 +87,16 @@
},
"scripts": {
"build": "tsc -p .",
"watch": "tsc -watch -p ."
"watch": "tsc -watch -p .",
"vscode:prepublish": "npm run build",
"package": "vsce package --baseContentUrl https://git.swipelab.com/lab/sx/src/branch/master/editors/vscode --baseImagesUrl https://git.swipelab.com/lab/sx/raw/branch/master/editors/vscode"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/vscode": "^1.75.0",
"@vscode/vsce": "^3.9.2",
"typescript": "^5.0.0"
}
}

Binary file not shown.

View File

@@ -179,7 +179,7 @@
"patterns": [
{
"name": "keyword.other.directive.sx",
"match": "#(?:run|import|insert|builtin|foreign|library)\\b"
"match": "#(?:run|import|insert|builtin|library)\\b"
}
]
},
@@ -193,6 +193,10 @@
"name": "keyword.other.sx",
"match": "\\b(enum|struct)\\b"
},
{
"name": "storage.modifier.sx",
"match": "\\b(extern|export)\\b"
},
{
"name": "keyword.operator.cast.sx",
"match": "\\bxx\\b"

View File

@@ -1,5 +1,5 @@
#import "modules/std.sx";
main :: () -> s32 {
main :: () -> i32 {
if false then 40 else 42
}

View File

@@ -1,5 +1,5 @@
#import "modules/std.sx";
main :: () -> s32 {
main :: () -> i32 {
x := 42;
{
print("scope opened\n");

View File

@@ -1,5 +1,5 @@
#import "modules/std.sx";
main :: () -> s32 {
main :: () -> i32 {
defer print("still here\n");
return 42;
}

View File

@@ -1,6 +1,6 @@
#import "modules/std.sx";
sumOf10 :: () -> s32 {
sumOf10 :: () -> i32 {
i:= 1;
s:=0;
while i <= 10 {

View File

@@ -1,7 +1,7 @@
#import "modules/std.sx";
quick_sort :: (items: []$T) {
partition :: (items: []T, lo: s64, hi: s64) -> s64 {
partition :: (items: []T, lo: i64, hi: i64) -> i64 {
pivot := items[hi];
i := lo - 1;
j := lo;
@@ -17,7 +17,7 @@ quick_sort :: (items: []$T) {
i
}
sort :: (items: []T, lo: s64, hi: s64) {
sort :: (items: []T, lo: i64, hi: i64) {
if lo < hi {
pi := partition(items, lo, hi);
sort(items, lo, pi - 1);
@@ -29,7 +29,7 @@ quick_sort :: (items: []$T) {
}
main :: () {
arr : []s64 = .[333, 2, 3, 5, 2, 2, 3, 4, 5, 6, 6, 1];
arr : []i64 = .[333, 2, 3, 5, 2, 2, 3, 4, 5, 6, 6, 1];
quick_sort(arr);
print("{}\n", arr);
}

View File

@@ -24,7 +24,7 @@ Vec2 :: struct {
unit_x :: () -> Vec2 { Vec2.{ x = 1.0, y = 0.0 } }
add :: (a: Vec2, b: Vec2) -> Vec2 { Vec2.{ x = a.x + b.x, y = a.y + b.y } }
scale :: (v: Vec2, s: f32) -> Vec2 { Vec2.{ x = v.x * s, y = v.y * s } }
len :: (v: Vec2) -> s32 { xx (v.x + v.y) }
len :: (v: Vec2) -> i32 { xx (v.x + v.y) }
}
EdgeInsets :: struct {
@@ -39,16 +39,16 @@ EdgeInsets :: struct {
}
Trio :: struct {
a: s32;
b: s32;
c: s32;
a: i32;
b: i32;
c: i32;
make :: (a: s32, b: s32, c: s32) -> Trio { Trio.{ a = a, b = b, c = c } }
sum :: (t: Trio) -> s32 { t.a + t.b + t.c }
make :: (a: i32, b: i32, c: i32) -> Trio { Trio.{ a = a, b = b, c = c } }
sum :: (t: Trio) -> i32 { t.a + t.b + t.c }
}
Result :: enum {
ok: s32;
ok: i32;
err: string;
}
@@ -121,7 +121,7 @@ main :: () {
// T8: .variant(payload) as function argument (match-as-expression)
{
describe :: (sh: Shape) -> s32 {
describe :: (sh: Shape) -> i32 {
if sh == {
case .circle: 10;
case .rect: 20;
@@ -145,7 +145,7 @@ main :: () {
// T10: Match as expression returning tagged enum
{
select :: (n: s32) -> Shape {
select :: (n: i32) -> Shape {
if n == {
case 0: .none;
case 1: .circle(1.0);
@@ -243,7 +243,7 @@ main :: () {
// E2: Function taking both types — each resolves correctly
{
use_both :: (sh: Shape, v: Vec2) {
ms : s32 = 0;
ms : i32 = 0;
if sh == { case .circle: (r) { ms = xx r; } else: {} }
print("E2: {} {} {}\n", ms, v.x, v.y);
}
@@ -252,7 +252,7 @@ main :: () {
// E3: Bare .variant (no parens) as function arg
{
check_none :: (sh: Shape) -> s32 {
check_none :: (sh: Shape) -> i32 {
if sh == { case .none: 1; else: 0; }
}
print("E3: {}\n", check_none(.none));
@@ -268,7 +268,7 @@ main :: () {
// E5: Tagged enum .variant(payload) in match-as-expression
{
sh : Shape = .circle(42.0);
r : s32 = 0;
r : i32 = 0;
if sh == {
case .circle: (v) { r = xx v; }
case .rect: (sz) { r = xx sz.w; }
@@ -280,7 +280,7 @@ main :: () {
// E6: Color enum (plain, not tagged) still works with bare .variant
{
c : Color = .green;
ci : s32 = xx c;
ci : i32 = xx c;
print("E6: {}\n", ci);
}

View File

@@ -4,13 +4,13 @@
// block (only one terminator per block).
#import "modules/std.sx";
#import "modules/compiler.sx";
#import "modules/build.sx";
do_it :: () -> bool {
inline if OS != .ios { return false; }
true
}
main :: () -> s32 {
main :: () -> i32 {
if do_it() then 0 else 1
}

View File

@@ -9,21 +9,21 @@
#import "modules/std.sx";
double :: (x: s32) -> s32 => x * 2;
double :: (x: i32) -> i32 => x * 2;
sum :: (a: s32, b: s32) -> s32 => a + b;
sum :: (a: i32, b: i32) -> i32 => a + b;
answer :: () -> s32 => 42;
answer :: () -> i32 => 42;
Point :: struct {
x: s32;
y: s32;
x: i32;
y: i32;
total :: (self: *Point) -> s32 => self.x + self.y;
scaled :: (self: *Point, by: s32) -> s32 => (self.x + self.y) * by;
total :: (self: *Point) -> i32 => self.x + self.y;
scaled :: (self: *Point, by: i32) -> i32 => (self.x + self.y) * by;
}
main :: () -> s32 {
main :: () -> i32 {
print("double: {}\n", double(7));
print("sum: {}\n", sum(3, 4));
print("answer: {}\n", answer());

View File

@@ -1,5 +1,5 @@
// Range-based for loops: `for start..end: (i) { }` (cursor optional, `end`
// exclusive) is a runtime counting loop; `inline for start..end: (i) { }`
// Range-based for loops: `for start..end (i) { }` (cursor optional, `end`
// exclusive) is a runtime counting loop; `inline for start..end (i) { }`
// is comptime-unrolled — the cursor is a compile-time constant each
// iteration, so `xs[i]` over a heterogeneous pack substitutes the concrete
// per-position element (this is what drives pack iteration).
@@ -9,21 +9,21 @@
Show :: protocol {
show :: () -> string;
}
A :: struct { x: s64; }
A :: struct { x: i64; }
B :: struct { s: string; }
impl Show for A { show :: (self: *A) -> string => "A"; }
impl Show for B { show :: (self: *B) -> string => "B"; }
// Comptime-unrolled iteration over a pack; cursor `i` indexes the pack.
each :: (..xs: Show) -> void {
inline for 0..xs.len: (i) {
inline for 0..xs.len (i) {
print("[{}]={}\n", i, xs[i].show());
}
}
main :: () -> s32 {
main :: () -> i32 {
// Runtime range, cursor used.
for 0..3: (i) { print("i={}\n", i); }
for 0..3 (i) { print("i={}\n", i); }
// Runtime range, no cursor — body runs `end - start` times.
n := 0;
@@ -31,7 +31,7 @@ main :: () -> s32 {
print("n={}\n", n);
// Non-zero start.
for 2..5: (j) { print("j={}\n", j); }
for 2..5 (j) { print("j={}\n", j); }
// Inline unroll over a heterogeneous pack.
each(A.{ x = 1 }, B.{ s = "hi" }, A.{ x = 3 });

View File

@@ -1,4 +1,4 @@
// `for xs: (*x)` binds each element by pointer — no per-element copy.
// `for xs (*x)` binds each element by pointer — no per-element copy.
// Mutations write back, and a pointer subject matches through the deref.
#import "modules/std.sx";
@@ -7,15 +7,15 @@ Shape :: enum {
none;
}
main :: () -> s32 {
main :: () -> i32 {
// By-ref mutation writes back into the array (impossible with a value copy).
xs : [3]s64 = .[1, 2, 3];
for xs: (*x) { x.* = x + 100; }
xs : [3]i64 = .[1, 2, 3];
for xs (*x) { x.* = x + 100; }
print("{} {} {}\n", xs[0], xs[1], xs[2]);
// Pointer subject matches through the deref; payload reads through the ref.
shapes : [2]Shape = .[.circle(2.0), .none];
for shapes: (*s) {
for shapes (*s) {
if s == {
case .circle: (r) { print("circle {}\n", r); }
case .none: { print("none\n"); }

View File

@@ -4,37 +4,37 @@
#import "modules/std.sx";
Box :: struct {
v: s64;
boxed :: (self: Box) -> s64 { self.v } // value receiver
v: i64;
boxed :: (self: Box) -> i64 { self.v } // value receiver
}
sum_ptr :: (xs: *List(s64)) -> s64 {
total : s64 = 0;
for xs: (n) { total = total + n; } // iterate through a *List
sum_ptr :: (xs: *List(i64)) -> i64 {
total : i64 = 0;
for xs (n) { total = total + n; } // iterate through a *List
total
}
main :: () -> s32 {
xs := List(s64).{};
main :: () -> i32 {
xs := List(i64).{};
xs.append(10);
xs.append(20);
xs.append(30);
s : s64 = 0;
for xs: (n) { s = s + n; } // value capture
s : i64 = 0;
for xs (n) { s = s + n; } // value capture
print("sum {}\n", s); // 60
for xs: (*n) { n.* = n + 100; } // by-ref: writes back
for xs (*n) { n.* = n + 100; } // by-ref: writes back
s = 0;
for xs: (n) { s = s + n; }
for xs (n) { s = s + n; }
print("sum2 {}\n", s); // 360
print("via ptr {}\n", sum_ptr(@xs)); // 360
bs := List(Box).{};
bs.append(.{ v = 7 });
bt : s64 = 0;
for bs: (*b) { bt = bt + b.boxed(); } // *Box receiver, value-self method
bt : i64 = 0;
for bs (*b) { bt = bt + b.boxed(); } // *Box receiver, value-self method
print("boxes {}\n", bt); // 7
0
}

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Color :: enum { red; green; blue; }
@@ -45,7 +45,7 @@ END;
print("heredoc: {}\n", hd);
// Undefined with type
undef_val : s32 = ---;
undef_val : i32 = ---;
undef_val = 77;
print("undef-then-set: {}\n", undef_val);
@@ -54,7 +54,7 @@ END;
print("enum-lit: {}\n", c);
// Null pointer
np : *s32 = null;
np : *i32 = null;
print("null-ptr: {}\n", np);
// String .len

View File

@@ -1,20 +1,20 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
add :: (a: s32, b: s32) -> s32 { a + b }
add :: (a: i32, b: i32) -> i32 { a + b }
mul :: (a: s32, b: s32) -> s32 { a * b }
mul :: (a: i32, b: i32) -> i32 { a * b }
// P4 edge: Chained default→default calls
Chained :: protocol {
base :: (msg: string) -> s32;
wrap :: (msg: string) -> s32 {
base :: (msg: string) -> i32;
wrap :: (msg: string) -> i32 {
self.base(msg) + 1
}
double_wrap :: (msg: string) -> s32 {
double_wrap :: (msg: string) -> i32 {
self.wrap(msg) + self.wrap(msg)
}
}
@@ -136,31 +136,31 @@ main :: () {
// Implicit widening conversions
wu : u8 = 200;
ws : s64 = wu;
print("widen-u8-s64: {}\n", ws);
ws : i64 = wu;
print("widen-u8-i64: {}\n", ws);
wi3 : s32 = 42;
wi3 : i32 = 42;
wf : f64 = wi3;
print("widen-s32-f64: {}\n", wf);
print("widen-i32-f64: {}\n", wf);
wf32 : f32 = 1.5;
wf64 : f64 = wf32;
print("widen-f32-f64: {}\n", wf64);
wu2 : u8 = 100;
ws2 : s16 = wu2;
print("widen-u8-s16: {}\n", ws2);
ws2 : i16 = wu2;
print("widen-u8-i16: {}\n", ws2);
// More xx narrowing
xl : s64 = 12345;
xs : s32 = xx xl;
print("xx-s64-s32: {}\n", xs);
xl : i64 = 12345;
xs : i32 = xx xl;
print("xx-i64-i32: {}\n", xs);
xd : f64 = 1.5;
xf : f32 = xx xd;
print("xx-f64-f32: {}\n", xf);
xdf : f64 = 7.9;
xdi : s32 = xx xdf;
print("xx-f64-s32: {}\n", xdi);
xdi : i32 = xx xdf;
print("xx-f64-i32: {}\n", xdi);
}

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
main :: () {
@@ -116,9 +116,9 @@ main :: () {
print("nested-break: {} {}\n", nb_outer, nb_icount);
// For loop basic
farr : [4]s32 = .[10, 20, 30, 40];
farr : [4]i32 = .[10, 20, 30, 40];
out("for:");
for farr: (it) {
for farr (it) {
out(" ");
out(int_to_string(it));
}
@@ -126,14 +126,14 @@ main :: () {
// For with print
out("for-print:");
for farr: (it) {
for farr (it) {
print(" {}", it);
}
out("\n");
// For with index
out("for-idx:");
for farr: (_, ix) {
for farr, 0.. (_, ix) {
out(" ");
out(int_to_string(ix));
}
@@ -141,14 +141,14 @@ main :: () {
// For with print two args
out("for-2arg:");
for farr: (it, ix) {
for farr, 0.. (it, ix) {
print(" {}@{}", it, ix);
}
out("\n");
// For with break
out("for-break:");
for farr: (it) {
for farr (it) {
if it == 30 { break; }
print(" {}", it);
}
@@ -156,42 +156,42 @@ main :: () {
// For with continue
out("for-continue:");
for farr: (it) {
for farr (it) {
if it == 20 { continue; }
print(" {}", it);
}
out("\n");
// For on slice
fsl : []s32 = .[10, 20, 30];
fsl : []i32 = .[10, 20, 30];
out("for-slice:");
for fsl: (it) {
for fsl (it) {
print(" {}", it);
}
out("\n");
// For on slice with index
out("for-slice-idx:");
for fsl: (it, ix) {
for fsl, 0.. (it, ix) {
print(" {}:{}", ix, it);
}
out("\n");
// Nested for
nf_a : [2]s32 = .[0, 1];
nf_b : [2]s32 = .[0, 1];
nf_a : [2]i32 = .[0, 1];
nf_b : [2]i32 = .[0, 1];
out("for-nested:");
for nf_a: (oa) {
for nf_b: (ob) {
for nf_a (oa) {
for nf_b (ob) {
print(" ({},{})", oa, ob);
}
}
out("\n");
// For with break preserving index
fbi : [5]s32 = .[10, 20, 30, 40, 50];
fbi : [5]i32 = .[10, 20, 30, 40, 50];
fbi_idx := 0;
for fbi: (it, ix) {
for fbi, 0.. (it, ix) {
if it == 30 { fbi_idx = ix; break; }
}
print("for-break-idx: {}\n", fbi_idx);

View File

@@ -1,26 +1,26 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
add :: (a: s32, b: s32) -> s32 { a + b }
add :: (a: i32, b: i32) -> i32 { a + b }
mul :: (a: s32, b: s32) -> s32 { a * b }
mul :: (a: i32, b: i32) -> i32 { a * b }
identity :: (x: $T) -> T { x }
pair_add :: (a: $T, b: $U) -> s64 {
cast(s64) a + cast(s64) b
pair_add :: (a: $T, b: $U) -> i64 {
cast(i64) a + cast(i64) b
}
typed_sum :: (..args: []s32) -> s32 {
typed_sum :: (..args: []i32) -> i32 {
result := 0;
for args: (it) { result = result + it; }
for args (it) { result = result + it; }
result
}
apply :: (f: (s32, s32) -> s32, x: s32, y: s32) -> s32 {
apply :: (f: (i32, i32) -> i32, x: i32, y: i32) -> i32 {
f(x, y)
}
@@ -28,11 +28,11 @@ void_return :: () {
return;
}
implicit_return :: (x: s32) -> s32 {
implicit_return :: (x: i32) -> i32 {
x * 2
}
early_return :: (x: s32) -> s32 {
early_return :: (x: i32) -> i32 {
if x > 10 { return 99; }
x
}
@@ -53,7 +53,7 @@ main :: () {
print("typed-const: {}\n", TYPED_PI);
// Variable with default init
di : s32;
di : i32;
print("default-init: {}\n", di);
// Implicit return
@@ -68,7 +68,7 @@ main :: () {
print("void-return: ok\n");
// Generic — single param
print("generic-s32: {}\n", identity(42));
print("generic-i32: {}\n", identity(42));
print("generic-f32: {}\n", identity(1.5));
print("generic-bool: {}\n", identity(true));
@@ -76,7 +76,7 @@ main :: () {
print("generic-multi: {}\n", pair_add(10, 20));
// Lambda
double :: (x: s32) => x * 2;
double :: (x: i32) => x * 2;
print("lambda: {}\n", double(7));
// Lambda with return type
@@ -84,7 +84,7 @@ main :: () {
print("lambda-ret: {}\n", halve(10.0));
// Local function (non-lambda)
local_add :: (a: s32, b: s32) -> s32 { a + b }
local_add :: (a: i32, b: i32) -> i32 { a + b }
print("local-fn: {}\n", local_add(3, 4));
// Nested function calls
@@ -94,11 +94,11 @@ main :: () {
print("varargs: {}\n", typed_sum(1, 2, 3, 4, 5));
// Spread
spread_arr : [3]s32 = .[10, 20, 30];
spread_arr : [3]i32 = .[10, 20, 30];
print("spread: {}\n", typed_sum(..spread_arr));
// Function pointers
fp : (s32, s32) -> s32 = add;
fp : (i32, i32) -> i32 = add;
print("fp: {}\n", fp(3, 4));
fp = mul;
print("fp-reassign: {}\n", fp(3, 4));

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
main :: () {

View File

@@ -1,10 +1,10 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Point :: struct { x, y: s32; }
Point :: struct { x, y: i32; }
Color :: enum { red; green; blue; }
@@ -29,14 +29,14 @@ main :: () {
print("sqrt-f64: {}\n", sqrt(16.0));
// size_of
print("sizeof-s32: {}\n", size_of(s32));
print("sizeof-i32: {}\n", size_of(i32));
print("sizeof-f64: {}\n", size_of(f64));
print("sizeof-struct: {}\n", size_of(Point));
// align_of
print("alignof-u8: {}\n", align_of(u8));
print("alignof-s32: {}\n", align_of(s32));
print("alignof-s64: {}\n", align_of(s64));
print("alignof-i32: {}\n", align_of(i32));
print("alignof-i64: {}\n", align_of(i64));
print("alignof-struct: {}\n", align_of(Point));
// type_of + category matching
@@ -121,7 +121,7 @@ main :: () {
// cast
cval : f64 = 3.7;
print("cast: {}\n", cast(s32) cval);
cv2 : s32 = 42;
print("cast: {}\n", cast(i32) cval);
cv2 : i32 = 42;
print("cast-int-f64: {}\n", cast(f64) cv2);
}

View File

@@ -1,10 +1,10 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Point :: struct { x, y: s32; }
Point :: struct { x, y: i32; }
Shape :: enum {
circle: f32;

View File

@@ -1,12 +1,12 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Point :: struct { x, y: s32; }
Point :: struct { x, y: i32; }
point_sum :: (p: Point) -> s32 { p.x + p.y }
point_sum :: (p: Point) -> i32 { p.x + p.y }
// #run compile-time constants

View File

@@ -1,10 +1,10 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Point :: struct { x, y: s32; }
Point :: struct { x, y: i32; }
main :: () {

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
main :: () {

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
main :: () {
@@ -11,7 +11,7 @@ main :: () {
// ========================================================
print("=== 25. Array Loop Mutation ===\n");
{
arr : [4]s32 = .[0, 0, 0, 0];
arr : [4]i32 = .[0, 0, 0, 0];
i := 0;
while i < 4 {
arr[i] = xx (i + 1);

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
main :: () {
@@ -10,7 +10,7 @@ main :: () {
{
print("=== UFCS Aliases ===\n");
num_sum :: (a: s64, b: s64) -> s64 { a + b }
num_sum :: (a: i64, b: i64) -> i64 { a + b }
sum :: ufcs num_sum;
print("{}\n", num_sum(40, 2)); // 42 — direct call
@@ -20,7 +20,7 @@ main :: () {
print("{}\n", num_sum(40, 2)); // 42 — direct (was tuple full-splat)
print("{}\n", 40 |> sum(2)); // 42 — pipe (was tuple partial-splat)
compute :: (a: s64, b: s64, c: s64, d: s64) -> s64 { a + b * c - d }
compute :: (a: i64, b: i64, c: i64, d: i64) -> i64 { a + b * c - d }
calc :: ufcs compute;
print("{}\n", compute(1, 2, 3, 4)); // 1+2*3-4 = 3 (was tuple full-splat)
@@ -28,14 +28,14 @@ main :: () {
print("{}\n", 1 |> calc(2, 3, 4)); // same = 3 — pipe UFCS
// Tuple return type
swap :: (a: s64, b: s64) -> (s64, s64) { (b, a) }
swap :: (a: i64, b: i64) -> (i64, i64) { (b, a) }
s := swap(1, 2);
a := s.0;
b := s.1;
print("{}\n", a); // 2
print("{}\n", b); // 1
wrap :: (x: s64) -> (s64) { (x,) }
wrap :: (x: i64) -> (i64) { (x,) }
t := wrap(99);
print("{}\n", t.0); // 99
}

View File

@@ -1,10 +1,10 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
add :: (a: s32, b: s32) -> s32 { a + b }
add :: (a: i32, b: i32) -> i32 { a + b }
main :: () {
@@ -23,12 +23,12 @@ main :: () {
assert(v.w == 4.0);
// Function call with trailing comma
add :: (a: s64, b: s64) -> s64 { return a + b; }
add :: (a: i64, b: i64) -> i64 { return a + b; }
r := add(10, 20,);
assert(r == 30);
// Array literal with trailing comma
arr := s64.[1, 2, 3,];
arr := i64.[1, 2, 3,];
assert(arr[2] == 3);
print("trailing commas ok\n");

View File

@@ -14,17 +14,17 @@
E :: error { Neg }
// dead `return 99;` after an unconditional return
const_one :: () -> s64 { return 1; return 99; }
const_one :: () -> i64 { return 1; return 99; }
// dead `return x;` after an unconditional raise (the failable closure shape)
always_raise :: (x: s64) -> (s64, !E) { raise error.Neg; return x; }
always_raise :: (x: i64) -> (i64, !E) { raise error.Neg; return x; }
// guard: a conditional return must still fall through to the trailing return
clamp :: (x: s64) -> s64 { if x > 10 { return 10; } return x; }
clamp :: (x: i64) -> i64 { if x > 10 { return 10; } return x; }
main :: () -> s32 {
main :: () -> i32 {
print("const_one={}\n", const_one()); // 1
print("raised={}\n", always_raise(5) catch e 0); // 0
print("raised={}\n", always_raise(5) catch (e) 0); // 0
print("clamp_hi={}\n", clamp(42)); // 10
print("clamp_lo={}\n", clamp(7)); // 7

View File

@@ -7,14 +7,14 @@
#import "modules/std.sx";
Counter :: struct { n: s32; }
Counter :: struct { n: i32; }
// FREE functions (defined outside the struct), pointer first param.
bump :: (c: *Counter) -> s32 { c.n += 1; return c.n; }
bump :: ufcs (c: *Counter) -> i32 { c.n += 1; return c.n; }
// reached ONLY via UFCS — must still be emitted.
reset :: (c: *Counter) { c.n = 0; }
reset :: ufcs (c: *Counter) { c.n = 0; }
main :: () -> s32 {
main :: () -> i32 {
c := Counter.{ n = 10 };
a := c.bump(); // 11, mutates c
b := c.bump(); // 12

View File

@@ -12,22 +12,22 @@
#import "modules/std.sx";
// Implicit return: trailing expression, no `;`.
double :: (n: s32) -> s32 { n * 2 }
double :: (n: i32) -> i32 { n * 2 }
// if/else as a value — each branch's last expression has no `;`.
sign :: (n: s32) -> s32 {
sign :: (n: i32) -> i32 {
if n < 0 { -1 } else if n > 0 { 1 } else { 0 }
}
// A value-producing block bound to a name.
sum3 :: (a: s32, b: s32, c: s32) -> s32 {
sum3 :: (a: i32, b: i32, c: i32) -> i32 {
t := { x := a + b; x + c }; // block value is `x + c`
t
}
// Match arms keep their `;` (exempt): the arm `;` is an arm terminator, so each
// arm still yields its expression as the match value.
classify :: (n: s32) -> s32 {
classify :: (n: i32) -> i32 {
if n == {
case 0: 100;
case 1: 10;
@@ -35,8 +35,8 @@ classify :: (n: s32) -> s32 {
}
}
main :: () -> s32 {
total : s32 = 0;
main :: () -> i32 {
total : i32 = 0;
total = total + double(10); // 20
total = total + sign(-7); // -1
total = total + sum3(1, 2, 3); // 6

View File

@@ -6,8 +6,8 @@
#import "modules/std.sx";
// `n * 2;` discards the value → the function returns nothing.
double :: (n: s32) -> s32 {
double :: (n: i32) -> i32 {
n * 2;
}
main :: () -> s32 { double(5) }
main :: () -> i32 { double(5) }

View File

@@ -8,9 +8,9 @@
#import "modules/std.sx";
pair :: () -> (s32, s32) { (5, 7) }
pair :: () -> (i32, i32) { (5, 7) }
main :: () -> s32 {
main :: () -> i32 {
// destructure decl inside a value-bound block
sum := {
a, b := pair();

View File

@@ -9,14 +9,14 @@
#import "modules/std.sx";
sign :: (n: s32) -> s32 {
sign :: (n: i32) -> i32 {
if n == {
case 0: 0;
else: if n > 0 then 1 else -1;
}
}
classify :: (n: s32) -> s32 {
classify :: (n: i32) -> i32 {
if n == {
case 0: 100;
case 1: 10;
@@ -24,7 +24,7 @@ classify :: (n: s32) -> s32 {
}
}
main :: () -> s32 {
main :: () -> i32 {
print("sign: {} {} {}\n", sign(-9), sign(0), sign(9)); // -1 0 1
print("classify: {} {} {}\n", classify(0), classify(1), classify(5)); // 100 10 -1
0

View File

@@ -6,9 +6,9 @@
#import "modules/std.sx";
scale :: (n: s32, factor: s32 = 2) -> s32 { n * factor }
scale :: (n: i32, factor: i32 = 2) -> i32 { n * factor }
label :: (n: s32, prefix: string = "v", suffix: string = "!") -> s32 {
label :: (n: i32, prefix: string = "v", suffix: string = "!") -> i32 {
print("{}{}{}\n", prefix, n, suffix);
n
}

View File

@@ -11,7 +11,7 @@
Json :: enum {
str: string;
int_: s64;
int_: i64;
null_;
}

View File

@@ -1,9 +1,9 @@
// Integer `{}` formatting across the full signed/unsigned range.
//
// Regression (issue 0090): the `{}` formatter was s64-based — it negated
// the value to print the sign (so s64::MIN, whose magnitude is
// unrepresentable as a positive s64, rendered as a bare "-"), and it had
// no unsigned-aware path (so a u64 all-ones value printed as the s64
// Regression (issue 0090): the `{}` formatter was i64-based — it negated
// the value to print the sign (so i64::MIN, whose magnitude is
// unrepresentable as a positive i64, rendered as a bare "-"), and it had
// no unsigned-aware path (so a u64 all-ones value printed as the i64
// reinterpretation, "-1"). Both extremes now render correctly: signed
// MIN prints all its digits, and unsigned integers print as unsigned
// decimal across all 64 bits.
@@ -12,16 +12,16 @@
main :: () {
// Signed extreme: magnitude is never negated, so MIN survives.
print("s64.min={}\n", s64.min);
print("s64.max={}\n", s64.max);
print("i64.min={}\n", i64.min);
print("i64.max={}\n", i64.max);
// Unsigned extreme: all 64 bits as unsigned decimal, not -1.
print("u64.max={}\n", u64.max);
// Spread across widths — signed.
print("s8.min={} s8.max={}\n", s8.min, s8.max);
print("s16.min={} s16.max={}\n", s16.min, s16.max);
print("s32.min={} s32.max={}\n", s32.min, s32.max);
print("i8.min={} i8.max={}\n", i8.min, i8.max);
print("i16.min={} i16.max={}\n", i16.min, i16.max);
print("i32.min={} i32.max={}\n", i32.min, i32.max);
// Spread across widths — unsigned (max is all-ones for that width).
print("u8.max={} u16.max={}\n", u8.max, u16.max);
@@ -31,7 +31,7 @@ main :: () {
print("u8.min={} u64.min={} zero={}\n", u8.min, u64.min, 0);
// Ordinary signed/unsigned values still print correctly.
neg : s32 = -42;
neg : i32 = -42;
pos : u32 = 4000000000;
print("neg={} pos={}\n", neg, pos);
}

View File

@@ -0,0 +1,26 @@
// Loop-body locals reuse one stack slot per frame: a body-declared local
// (and every compiler temp) must not grow the stack per iteration, so
// million-iteration loops run in constant stack. Covers body locals,
// nested loops (the inner loop's hidden index slot), and element reads.
// Regression (issue 0109): allocas were emitted at their use site, so each
// iteration re-executed them — LLVM only reclaims allocas at `ret`, and
// these loops segfaulted on stack exhaustion.
#import "modules/std.sx";
main :: () -> i32 {
sum := 0;
for 0..1000000 (i) {
buf : [128]i64 = ---;
buf[0] = i;
sum += buf[0];
}
print("sum={}\n", sum);
n := 0;
for 0..3000000 (i) {
for 0..1 (j) { n += 1; }
}
print("n={}\n", n);
0
}

View File

@@ -0,0 +1,24 @@
// Collection-form `for` over an array, by-value capture: each iteration
// reads ONE element from the array's storage (GEP + load), and the capture
// stays a copy — mutating it never writes back to the array.
// Regression (issue 0110): the element fetch was `index_get` on the array
// VALUE, spilling a full copy of the array to a stack temp per iteration —
// O(N²) bytes copied, and (pre-0109) per-iteration stack growth that made
// this 4096-element loop segfault.
#import "modules/std.sx";
main :: () -> i32 {
arr : [4096]i64 = ---;
i := 0;
while i < 4096 { arr[i] = i; i += 1; }
sum := 0;
for arr (x) { sum += x; }
print("sum={}\n", sum);
// By-value capture is a copy: mutating it leaves the array untouched.
small : [3]i64 = .[10, 20, 30];
for small (x) { x += 100; }
print("copy-guard: {} {} {}\n", small[0], small[1], small[2]);
0
}

View File

@@ -0,0 +1,46 @@
// `defer` runs on EVERY exit from the loop body's scope — fall-through,
// `break`, and `continue` alike (LIFO, including entries from nested blocks
// between the loop and the jump). Covers `for` ranges and `while`.
// Regression (issue 0108): break/continue emitted a bare branch and the
// breaking iteration's defers were silently skipped.
#import "modules/std.sx";
main :: () -> i32 {
for 0..3 (i) {
defer print("cleanup {}\n", i);
if i == 1 { break; }
print("body {}\n", i);
}
print("after break loop\n");
for 0..3 (i) {
defer print("c2 {}\n", i);
if i == 1 { continue; }
print("b2 {}\n", i);
}
print("done\n");
i := 0;
while i < 3 {
defer print("w{}\n", i);
i += 1;
if i == 2 { continue; }
if i == 3 { break; }
print("wbody{}\n", i);
}
print("while done\n");
// A break inside a nested block drains the nested block's defers AND the
// loop body's, in LIFO order.
for 0..2 (j) {
defer print("outer {}\n", j);
{
defer print("inner {}\n", j);
if j == 0 { break; }
}
print("unreached\n");
}
print("nested done\n");
0
}

View File

@@ -0,0 +1,54 @@
#import "modules/std.sx";
pair_sum :: (xs: []i64, ys: []i64) -> i64 {
total := 0;
for xs, ys (x, y) { total += x * y; }
total
}
make :: () -> [3]i64 {
r : [3]i64 = .[7, 8, 9];
r
}
main :: () -> i32 {
// Agra's example: a 1..5 inclusive, b open-ended following along.
for 1..=5, 0.. (a, b) { print("{}:{} ", a, b); }
print("\n");
// Index idiom replacing the old (x, i) form.
xs : [3]i64 = .[10, 20, 30];
for xs, 0.. (x, i) { print("[{}]={} ", i, x); }
print("\n");
// Parallel slices.
a4 : [4]i64 = .[1, 2, 3, 4];
b4 : [4]i64 = .[10, 20, 30, 40];
print("dot={}\n", pair_sum(a4, b4));
// Arrow bodies.
s := 0;
for 0..4 (i) => s += i;
print("arrow-range s={}\n", s);
t := 0;
for xs (x) => t += x;
print("arrow-coll t={}\n", t);
// Call iterable + capture (first group = args, last group = capture).
for make() (v) { print("v{} ", v); }
print("\n");
// No-capture call iterable via leading-group escape.
n := 0;
for (make()) { n += 1; }
print("escape n={}\n", n);
// Three-way zip: two collections + cursor.
for a4, b4, 100.. (p, q, k) { print("{}/{}/{} ", p, q, k); }
print("\n");
// By-ref capture in a multi-iterable header.
for a4, 0.. (*p, i) { p.* += i; }
print("after ref: {} {} {} {}\n", a4[0], a4[1], a4[2], a4[3]);
0
}

View File

@@ -0,0 +1,63 @@
// Range bound markers: each side of `..` takes `=` (inclusive) or `<`
// (exclusive); defaults are start-inclusive, end-exclusive (`a..b` == `a=..<b`).
// Covers the full matrix, open ranges with start markers, comptime unrolling,
// runtime bounds, arbitrary expressions at EITHER end (expression parsing
// stops at the range token), and that `<` / `<<` comparisons still lex
// normally.
#import "modules/std.sx";
main :: () -> i32 {
for 0<..<5 (i) { print("{} ", i); }
print("| 0<..<5\n");
for 0=..=5 (i) { print("{} ", i); }
print("| 0=..=5\n");
for 0<..=5 (i) { print("{} ", i); }
print("| 0<..=5\n");
for 0=..<5 (i) { print("{} ", i); }
print("| 0=..<5\n");
for 0..<5 (i) { print("{} ", i); }
print("| 0..<5\n");
for 0..=5 (i) { print("{} ", i); }
print("| 0..=5\n");
// Exclusive-start open range following a bounded first iterable.
xs : [3]i64 = .[10, 20, 30];
for xs, 2<.. (x, i) { print("{}@{} ", x, i); }
print("| xs, 2<..\n");
// Explicit inclusive-start open form (synonym of `5..`).
for xs, 5=.. (x, i) { print("{}@{} ", x, i); }
print("| xs, 5=..\n");
// Comptime-unrolled with markers.
s := 0;
inline for 0<..=3 (i) { s += i; }
print("inline 0<..=3 sum={}\n", s);
// Runtime bounds with markers.
lo := 1;
hi := 4;
for lo<..=hi (i) { print("{} ", i); }
print("| lo<..=hi\n");
// Arbitrary expressions at either end of the range token.
x := 2;
n := 0;
sum := 0;
for x+2..=42 (e) { n += 1; sum += e; } // expression start: 4 .. 42
print("x+2..=42: n={} sum={}\n", n, sum);
n2 := 0;
for x+2<..<x*21 (e) => n2 += 1; // both ends: 5 .. 41
print("x+2<..<x*21: n2={}\n", n2);
n3 := 0;
for 0..x*3 (i) => n3 += 1; // expression end: 0 .. 5
print("0..x*3: n3={}\n", n3);
// Comparison operators still lex normally.
a := 3;
if a < 5 { print("cmp ok\n"); }
b := a << 1;
print("shl={}\n", b);
0
}

View File

@@ -0,0 +1,32 @@
// Slice range bound markers — same matrix as for-header ranges: each side
// of `..` takes `=` (inclusive) or `<` (exclusive), defaults 0-inclusive
// start / exclusive end. Prefix form takes markers too ([..=2], [<..3]);
// [..] is the whole slice; bounds are arbitrary expressions; strings slice
// through the same path.
#import "modules/std.sx";
dump :: (s: []i64, tag: string) {
print("{}: ", tag);
for s (v) { print("{} ", v); }
print("(len {})\n", s.len);
}
main :: () -> i32 {
xs : [6]i64 = .[10, 11, 12, 13, 14, 15];
full : []i64 = xs[0..6];
dump(full[1..=3], "1..=3"); // 11 12 13
dump(full[0<..<4], "0<..<4"); // 11 12 13
dump(full[..=2], "..=2"); // 10 11 12
dump(full[<..3], "<..3"); // 11 12
dump(full[2<..], "2<.."); // 13 14 15
dump(full[..], ".."); // all six
x := 3;
dump(full[x-1..=x+1], "x-1..=x+1"); // 12 13 14
s := "abcdef";
print("str 1..=3: {}\n", s[1..=3]); // bcd
print("str 0<..<4: {}\n", s[0<..<4]); // bcd
0
}

View File

@@ -0,0 +1,35 @@
// Free-function dot-calls are OPT-IN. Two opt-in spellings:
// name :: ufcs (params) { body } — the fn itself is dot-callable
// name :: ufcs target; — dot-callable (renaming) alias
// A plain fn is callable directly or via `|>` only (see 1166 for the
// rejection). Generic ufcs fns dispatch through normal monomorphization,
// and the plan-side return type binds from the receiver (structured
// params like `[]$T` included).
#import "modules/std.sx";
bump :: (x: i64) -> i64 { x + 1 }
bump2 :: ufcs (x: i64) -> i64 { x + 2 }
bump3 :: ufcs bump;
Counter :: struct { n: i64; }
inc :: ufcs (c: *Counter, by: i64) -> i64 { c.n += by; c.n }
gfirst :: ufcs (xs: []$T) -> T { xs[0] }
main :: () {
f : i64 = 40;
print("marked: {}\n", f.bump2()); // 42
print("alias: {}\n", f.bump3()); // 41
print("direct: {}\n", bump(f)); // 41 — plain fn, direct
print("pipe: {}\n", f |> bump()); // 41 — plain fn, pipe
print("marked-direct: {}\n", bump2(f)); // 42 — marked fn callable directly
c := Counter.{ n = 10 };
print("ptr-recv: {}\n", c.inc(5)); // 15 — auto address-of receiver
arr := .[7, 8, 9];
xs : []i64 = arr;
print("generic-dot: {}\n", xs.gfirst()); // 7
print("generic-direct: {}\n", gfirst(xs)); // 7 — plan types it i64, not a T stub
}

View File

@@ -0,0 +1,33 @@
// Trailing parameter defaults fill on method and ufcs dot-calls (the
// receiver-prepending dispatch paths), matching bare-call expansion (0044);
// a `#caller_location` default and a slice variadic keep their flexible
// arity under the call-arity check.
// Regression (issue 0123).
#import "modules/std.sx";
Point :: struct {
x: i64;
scaled :: (self: Point, k: i64 = 2) -> i64 { return self.x * k; }
}
bump :: ufcs (p: Point, by: i64 = 10) -> i64 { return p.x + by; }
sum_var :: (..xs: []i64) -> i64 {
t := 0;
for xs (x) { t = t + x; }
return t;
}
here :: (loc: Source_Location = #caller_location) -> i64 { return loc.line; }
main :: () {
p := Point.{ x = 5 };
print("{}\n", p.scaled()); // default filled on method dispatch
print("{}\n", p.scaled(3)); // explicit overrides
print("{}\n", p.bump()); // default filled on ufcs dispatch
print("{}\n", p.bump(1));
print("{}\n", sum_var()); // variadic: zero args
print("{}\n", sum_var(1, 2, 3)); // variadic: many args
print("{}\n", here() > 0); // #caller_location default
}

View File

@@ -0,0 +1,45 @@
// Large (64KB+) stack arrays compile and are accessed in place: `---`
// emits no initializer store, and element reads GEP the array's storage
// instead of loading the whole array as a value.
//
// Regression (issue 0124): both whole-aggregate shapes — the undef
// store from `---` and `index_get` on the loaded array value —
// scalarized into one SelectionDAG node per element and segfaulted
// `sx build` at [65536]u8.
//
// Results print via out/int_to_string: `{}` formatting would pull the
// any_to_string dispatcher, whose array arms materialize every interned
// array type BY VALUE — the separate issue 0125.
#import "modules/std.sx";
checksum :: () -> i64 {
buf : [65536]u8 = ---;
i := 0;
while i < 65536 {
buf[i] = xx (i % 251);
i += 1;
}
sum := 0;
i = 0;
while i < 65536 {
sum += xx buf[i];
i += 1;
}
return sum;
}
big :: () -> i64 {
buf : [131072]i64 = ---;
buf[0] = 11;
buf[131071] = 31;
return buf[0] + buf[131071];
}
main :: () -> i32 {
out(int_to_string(checksum()));
out("\n");
out(int_to_string(big()));
out("\n");
return 0;
}

View File

@@ -5,7 +5,7 @@ Vec4 :: struct {
Complex :: struct {
foo : enum {
S: s32;
S: i32;
B: struct {
val: string;
};

View File

@@ -1,7 +1,7 @@
#import "modules/std.sx";
SPECIAL_VALUE :u8: 42;
resolve :: (x: u8) -> s32 {
resolve :: (x: u8) -> i32 {
return 12 + x;
}
@@ -9,7 +9,7 @@ Foo :: struct {
a : u2; // this will have 0 as default
b : u8 = SPECIAL_VALUE;
c : u8 = ---; // default for c is undefined
d : u8 = #run xx resolve(5); // converts s32 to u8
d : u8 = #run xx resolve(5); // converts i32 to u8
}
main :: () {
@@ -38,6 +38,6 @@ Pack :: struct {
c: u8;
d: u32;
f: u64;
v: s32;
v: i32;
x: f32;
}

View File

@@ -1,11 +1,11 @@
#import "modules/std.sx";
Point :: struct {
x, y: s32;
x, y: i32;
}
Color :: struct {
r, g, b: s32;
r, g, b: i32;
}
main :: () {

View File

@@ -2,7 +2,7 @@
Overlay :: union {
f: f32;
i: s32;
i: i32;
}
Vec2 :: union {

View File

@@ -54,12 +54,12 @@ main :: () {
// Explicit values
w :WindowFlags = .vsync | .resizable;
print("\nwindow: {}\n", w);
print("raw value: {}\n", cast(s64) w);
print("raw value: {}\n", cast(i64) w);
// Backing type on plain enums
c :Color = .blue;
print("\ncolor: {}\n", c);
print("raw: {}\n", cast(s64) c);
print("raw: {}\n", cast(i64) c);
// Bitwise ops work on plain integers too
x := 0xFF & 0x0F;

View File

@@ -3,16 +3,16 @@
#import "modules/std.sx";
g_add : s64 = 10;
g_sub : s64 = 10;
g_mul : s64 = 10;
g_div : s64 = 100;
g_mod : s64 = 10;
g_and : s64 = 0xff;
g_or : s64 = 0x0f;
g_xor : s64 = 0xff;
g_shl : s64 = 1;
g_shr : s64 = 256;
g_add : i64 = 10;
g_sub : i64 = 10;
g_mul : i64 = 10;
g_div : i64 = 100;
g_mod : i64 = 10;
g_and : i64 = 0xff;
g_or : i64 = 0x0f;
g_xor : i64 = 0xff;
g_shl : i64 = 1;
g_shr : i64 = 256;
main :: () -> void {
// += repeated: should accumulate, not reset

View File

@@ -5,7 +5,7 @@
#import "modules/std.sx";
main :: () -> void {
x : s64 = 42;
x : i64 = 42;
// OK: comparison in statement context
if x != 0 { out("ok\n"); }

View File

@@ -5,19 +5,19 @@
#import "modules/std.sx";
// 40-byte struct — triggers the bug.
// Shrink to [4]s64 (32 bytes) and the bug goes away.
// Shrink to [4]i64 (32 bytes) and the bug goes away.
BigNode :: struct {
data: [5]s64; // 40 bytes
data: [5]i64; // 40 bytes
}
Tree :: struct {
nodes: List(BigNode); // items(8) + len(8) + cap(8) = 24 bytes
generation: s64; // 8 bytes — total 32 bytes
generation: i64; // 8 bytes — total 32 bytes
}
Container :: struct {
tree: Tree;
sentinel: s64;
sentinel: i64;
do_work :: (self: *Container) {
self.tree.nodes.items = xx 0; // BUG: corrupts self.sentinel
@@ -25,7 +25,7 @@ Container :: struct {
}
main :: () -> void {
obj : *Container = xx context.allocator.alloc(size_of(Container));
obj : *Container = xx context.allocator.alloc_bytes(size_of(Container));
memset(obj, 0, size_of(Container));
obj.sentinel = 0xDEADBEEF;

View File

@@ -3,7 +3,7 @@
#import "modules/std.sx";
g_counter : s64 = 0;
g_counter : i64 = 0;
tick :: () {
g_counter += 1;
@@ -14,7 +14,7 @@ main :: () -> void {
// Test 1: += always produces 1 (BUG)
out("--- Test 1: += (broken) ---\n");
out("Expected: 1, 2, 3\n");
i : s64 = 0;
i : i64 = 0;
while i < 3 {
tick();
i += 1;

View File

@@ -3,7 +3,7 @@
#import "modules/std.sx";
VALS : [4]s32 = .[-2, -1, 42, 99];
VALS : [4]i32 = .[-2, -1, 42, 99];
main :: () {
out("VALS: ");

View File

@@ -5,7 +5,7 @@
Foo :: struct {
running: bool = true;
x: s32 = 42;
x: i32 = 42;
name: string = "default";
}

View File

@@ -10,7 +10,7 @@ ok :: () -> Handle { 0 }
g : Handle = 0;
main :: () -> s32 {
main :: () -> i32 {
g = ok();
if g == 0 then 0 else 1
}

View File

@@ -1,7 +1,7 @@
// Pre-fix: `resolveType(null)` silently returned `.s64`, so a top-level
// var without a type annotation got typed as `s64` regardless of what
// Pre-fix: `resolveType(null)` silently returned `.i64`, so a top-level
// var without a type annotation got typed as `i64` regardless of what
// the initializer was. For `g_pi := 3.14;` this meant the float literal
// was assigned to an s64 slot, producing a wrong value at runtime or
// was assigned to an i64 slot, producing a wrong value at runtime or
// the wrong codegen shape.
//
// After the fix `lowerVarDecl` at the top level mirrors the local-scope
@@ -9,11 +9,11 @@
// the initializer's type. Mirrors how `:=` already worked for locals.
#import "modules/std.sx";
g_count := 42; // inferred s64
g_pi := 3.14; // inferred f64 — used to silently become s64
g_count := 42; // inferred i64
g_pi := 3.14; // inferred f64 — used to silently become i64
g_flag := true; // inferred bool
main :: () -> s32 {
main :: () -> i32 {
print("count = {}\n", g_count);
print("pi = {}\n", g_pi);
print("flag = {}\n", g_flag);

View File

@@ -7,28 +7,28 @@
//
// This test exercises the builder directly (no `#insert`, no impl
// wiring) — three pack shapes through the same `void`-returning
// wrapper plus one non-void `s32`-returning wrapper to pin the
// wrapper plus one non-void `i32`-returning wrapper to pin the
// `return typed_fn(...)` branch. The expected output captures the
// generated source verbatim so any formatting drift surfaces here
// rather than as a downstream compile error inside the eventual
// step-5.2 impl.
#import "modules/std.sx";
#import "modules/std/objc_block.sx";
#import "modules/ffi/objc_block.sx";
preview_void :: (..$args) -> string {
return build_block_convert($args, void);
}
preview_s32 :: (..$args) -> string {
return build_block_convert($args, s32);
preview_i32 :: (..$args) -> string {
return build_block_convert($args, i32);
}
run_all :: () {
print("--- void / 0 args ---\n{}\n", preview_void());
print("--- void / bool ---\n{}\n", preview_void(true));
print("--- void / s64, string ---\n{}\n", preview_void(42, "hi"));
print("--- s32 / f64 ---\n{}\n", preview_s32(3.14));
print("--- void / i64, string ---\n{}\n", preview_void(42, "hi"));
print("--- i32 / f64 ---\n{}\n", preview_i32(3.14));
}
#run run_all();

View File

@@ -2,7 +2,7 @@
// `align_of` accept pointer (`*T`), optional (`?T`), array (`[N]T`),
// function (`(A) -> B`), and tuple (`(A, B)`) types directly. Also
// const-decl RHS aliases through the same forms (`Ptr :: *u8;` etc).
// Same shape as the existing `size_of(s32)` baseline path.
// Same shape as the existing `size_of(i32)` baseline path.
#import "modules/std.sx";
@@ -10,9 +10,9 @@
Ptr :: *u8;
Maybe :: ?u8;
Arr :: [3]u8;
Cb :: (s32) -> s32;
Cb :: (i32) -> i32;
main :: () -> s32 {
main :: () -> i32 {
// Direct: parser fix for *T, ?T + existing [N]T path.
print("size_of(*u8) = {}\n", size_of(*u8));
print("align_of(*u8) = {}\n", align_of(*u8));
@@ -20,10 +20,10 @@ main :: () -> s32 {
print("size_of([3]u8) = {}\n", size_of([3]u8));
// Function-type literal in expression position.
print("size_of((s32)->s32) = {}\n", size_of((s32) -> s32));
print("size_of((i32)->i32) = {}\n", size_of((i32) -> i32));
// Tuple literal reinterpreted as tuple type at the type-demanding site.
print("size_of((s32, s32)) = {}\n", size_of((s32, s32)));
print("size_of((i32, i32)) = {}\n", size_of((i32, i32)));
// Aliases.
print("size_of(Ptr) = {}\n", size_of(Ptr));

View File

@@ -1,19 +1,19 @@
// Type alias resolution through `size_of` / `align_of` — a const-decl
// alias (`MyInt :: s32;`, `MyChain :: MyInt;`, `WideAlias :: Wide;`)
// alias (`MyInt :: i32;`, `MyChain :: MyInt;`, `WideAlias :: Wide;`)
// resolves through `type_alias_map` when used as a `$T: Type` argument.
// Covers chains and struct-name aliases, not just structural-type
// aliases (those land in `examples/182-compound-type-in-expression.sx`).
#import "modules/std.sx";
MyInt :: s32;
MyInt :: i32;
MyChain :: MyInt;
Wide :: struct { a: s64; b: s64; }
Wide :: struct { a: i64; b: i64; }
WideAlias :: Wide;
main :: () -> s32 {
print("direct s32: {}\n", size_of(s32));
print("alias s32: {}\n", size_of(MyInt));
print("chain s32: {}\n", size_of(MyChain));
main :: () -> i32 {
print("direct i32: {}\n", size_of(i32));
print("alias i32: {}\n", size_of(MyInt));
print("chain i32: {}\n", size_of(MyChain));
print("align alias: {}\n", align_of(MyInt));
print("align chain: {}\n", align_of(MyChain));
print("size struct-alias: {}\n", size_of(WideAlias));

View File

@@ -7,17 +7,17 @@
// trampoline's first read.
//
// The fix lives in `abiCoerceParamTypeEx`: the `string`/`slice` →
// `ptr` collapse only applies to `is_extern` foreign decls (libc
// `ptr` collapse only applies to `is_extern` extern decls (libc
// interop). sx-internal `callconv(.c)` keeps the full slice
// shape, which lands as `[2 x i64]` at the LLVM signature site
// and matches the caller's two-register pass on AArch64.
#import "modules/std.sx";
#import "modules/std/objc_block.sx";
#import "modules/ffi/objc_block.sx";
g_s: string = "";
main :: () -> s32 {
main :: () -> i32 {
cl := (s: string) => { g_s = s; };
b : Block = xx cl;
invoke_fn : (*Block, string) -> void callconv(.c) = xx b.invoke;

View File

@@ -12,7 +12,7 @@
// ── Test fixtures ─────────────────────────────────────────────
Point :: struct { x: s32; y: s32; }
Point :: struct { x: i32; y: i32; }
Color :: enum { red; green; blue; }
@@ -24,7 +24,7 @@ identity :: ($T: Type, val: T) -> T => val;
// time fold via type_eq → const_bool → inline-if folds the
// branch away).
describe :: ($T: Type) -> string {
inline if type_eq(T, s64) { return "int64"; }
inline if type_eq(T, i64) { return "int64"; }
inline if type_eq(T, string) { return "text"; }
inline if type_eq(T, bool) { return "boolean"; }
return "other";
@@ -34,7 +34,7 @@ describe :: ($T: Type) -> string {
type_list :: (..$args) -> string {
list := $args;
s := "[";
i : s64 = 0;
i : i64 = 0;
while i < list.len {
if i > 0 { s = concat(s, ", "); }
s = concat(s, type_name(list[i]));
@@ -46,21 +46,21 @@ type_list :: (..$args) -> string {
// Type stored in a struct field.
TypeHolder :: struct { t: Type; }
main :: () -> s32 {
main :: () -> i32 {
// ── 1. Type literal equality ────────────────────────────
print("=== 1. literal == ===\n");
print("s64 == s64: {}\n", s64 == s64);
print("s64 == string: {}\n", s64 == string);
print("i64 == i64: {}\n", i64 == i64);
print("i64 == string: {}\n", i64 == string);
print("*u8 == *u8: {}\n", *u8 == *u8);
print("?s64 == ?s64: {}\n", ?s64 == ?s64);
print("?s64 == ?s32: {}\n", ?s64 == ?s32);
print("?i64 == ?i64: {}\n", ?i64 == ?i64);
print("?i64 == ?i32: {}\n", ?i64 == ?i32);
// ── 2. type_of(value) ───────────────────────────────────
print("=== 2. type_of(value) == T ===\n");
a : s64 = 42;
a : i64 = 42;
b : f64 = 3.14;
s : string = "hi";
print("type_of(a) == s64: {}\n", type_of(a) == s64);
print("type_of(a) == i64: {}\n", type_of(a) == i64);
print("type_of(b) == f64: {}\n", type_of(b) == f64);
print("type_of(s) == string: {}\n", type_of(s) == string);
print("type_of(a) == f64: {}\n", type_of(a) == f64);
@@ -77,7 +77,7 @@ main :: () -> s32 {
// ── 4. type_name on literals + variables ────────────────
print("=== 4. type_name ===\n");
print("type_name(s64): {}\n", type_name(s64));
print("type_name(i64): {}\n", type_name(i64));
print("type_name(*u8): {}\n", type_name(*u8));
print("type_name(Point): {}\n", type_name(Point));
print("type_name(Color): {}\n", type_name(Color));
@@ -86,39 +86,39 @@ main :: () -> s32 {
// ── 5. Print Type values directly ───────────────────────
print("=== 5. print Type values ===\n");
print("literal: {}\n", s64);
print("literal: {}\n", i64);
t = string;
print("var: {}\n", t);
print("type_of(b): {}\n", type_of(b));
// ── 6. Generic dispatch via $T: Type ────────────────────
print("=== 6. generic dispatch ===\n");
print("describe(s64): {}\n", describe(s64));
print("describe(i64): {}\n", describe(i64));
print("describe(string): {}\n", describe(string));
print("describe(bool): {}\n", describe(bool));
print("describe(f64): {}\n", describe(f64));
// ── 7. identity(T, val) ─────────────────────────────────
print("=== 7. identity($T, val) ===\n");
print("identity(s64, 7): {}\n", identity(s64, 7));
print("identity(i64, 7): {}\n", identity(i64, 7));
print("identity(string, hi): {}\n", identity(string, "hi"));
print("identity(bool, true): {}\n", identity(bool, true));
// ── 8. Comptime-generated struct (Wrap($T)) ─────────────
print("=== 8. Wrap($T) ===\n");
w_int := Wrap(s64).{ v = 42 };
w_int := Wrap(i64).{ v = 42 };
w_str := Wrap(string).{ v = "wrapped" };
print("Wrap(s64).v: {}\n", w_int.v);
print("Wrap(i64).v: {}\n", w_int.v);
print("Wrap(string).v: {}\n", w_str.v);
// ── 9. Reflection builtins on Types ─────────────────────
print("=== 9. reflection on Type ===\n");
print("size_of(s64): {}\n", size_of(s64));
print("size_of(i64): {}\n", size_of(i64));
print("size_of(*u8): {}\n", size_of(*u8));
print("align_of(f64): {}\n", align_of(f64));
print("field_count(Point): {}\n", field_count(Point));
print("type_eq(s64, s64): {}\n", type_eq(s64, s64));
print("type_eq(s64, string): {}\n", type_eq(s64, string));
print("type_eq(i64, i64): {}\n", type_eq(i64, i64));
print("type_eq(i64, string): {}\n", type_eq(i64, string));
// ── 10. Type pack (..$args) walking ─────────────────────
print("=== 10. ..$args walking ===\n");
@@ -129,15 +129,15 @@ main :: () -> s32 {
// ── 11. Type in struct field ────────────────────────────
print("=== 11. Type in struct field ===\n");
h := TypeHolder.{ t = s64 };
print("h.t == s64: {}\n", h.t == s64);
h := TypeHolder.{ t = i64 };
print("h.t == i64: {}\n", h.t == i64);
print("h.t == string: {}\n", h.t == string);
print("type_name(h.t): {}\n", type_name(h.t));
// ── 12. Compound type literals ──────────────────────────
print("=== 12. compound literals ===\n");
print("type_name(*Point): {}\n", type_name(*Point));
print("type_name([4]s32): {}\n", type_name([4]s32));
print("type_name([4]i32): {}\n", type_name([4]i32));
print("type_name([]bool): {}\n", type_name([]bool));
print("type_name(?f64): {}\n", type_name(?f64));
@@ -146,13 +146,13 @@ main :: () -> s32 {
// ** stdout **
// === 1. literal == ===
// s64 == s64: true
// s64 == string: false
// i64 == i64: true
// i64 == string: false
// *u8 == *u8: true
// ?s64 == ?s64: true
// ?s64 == ?s32: false
// ?i64 == ?i64: true
// ?i64 == ?i32: false
// === 2. type_of(value) == T ===
// type_of(a) == s64: true
// type_of(a) == i64: true
// type_of(b) == f64: true
// type_of(s) == string: true
// type_of(a) == f64: false
@@ -162,45 +162,45 @@ main :: () -> s32 {
// after reassign t == string: true
// t == bool: true
// === 4. type_name ===
// type_name(s64): s64
// type_name(i64): i64
// type_name(*u8): *u8
// type_name(Point): Point
// type_name(Color): Color
// type_name(t): f64
// === 5. print Type values ===
// literal: s64
// literal: i64
// var: string
// type_of(b): f64
// === 6. generic dispatch ===
// describe(s64): int64
// describe(i64): int64
// describe(string): text
// describe(bool): boolean
// describe(f64): other
// === 7. identity($T, val) ===
// identity(s64, 7): 7
// identity(i64, 7): 7
// identity(string, hi): hi
// identity(bool, true): true
// === 8. Wrap($T) ===
// Wrap(s64).v: 42
// Wrap(i64).v: 42
// Wrap(string).v: wrapped
// === 9. reflection on Type ===
// size_of(s64): 8
// size_of(i64): 8
// size_of(*u8): 8
// align_of(f64): 8
// field_count(Point): 2
// type_eq(s64, s64): true
// type_eq(s64, string): false
// type_eq(i64, i64): true
// type_eq(i64, string): false
// === 10. ..$args walking ===
// type_list(): []
// type_list(1): [s64]
// type_list(1, "x"): [s64, string]
// type_list(1): [i64]
// type_list(1, "x"): [i64, string]
// type_list(true, 3.14): [bool, f64]
// === 11. Type in struct field ===
// h.t == s64: true
// h.t == i64: true
// h.t == string: false
// type_name(h.t): s64
// type_name(h.t): i64
// === 12. compound literals ===
// type_name(*Point): *Point
// type_name([4]s32): [4]s32
// type_name([4]i32): [4]i32
// type_name([]bool): []bool
// type_name(?f64): ?f64

View File

@@ -2,16 +2,16 @@
// return, and operators. Regression for the tuple-construction bug where
// an inferred `:=` tuple literal lowered its element values under the
// enclosing fn's (narrower) return `target_type`, mismatching the
// independently-inferred s64 field types and yielding garbage on read.
// independently-inferred i64 field types and yielding garbage on read.
#import "modules/std.sx";
Box :: struct { xs: (s32, s32); }
Box :: struct { xs: (i32, i32); }
swap :: (a: s64, b: s64) -> (s64, s64) { (b, a) }
fst :: (t: (s64, s64)) -> s64 { t.0 }
swap :: (a: i64, b: i64) -> (i64, i64) { (b, a) }
fst :: (t: (i64, i64)) -> i64 { t.0 }
main :: () -> s32 {
main :: () -> i32 {
// Inferred positional tuple + numeric field access.
pair := (40, 2);
print("pair {} {}\n", pair.0, pair.1);
@@ -21,8 +21,8 @@ main :: () -> s32 {
print("named {} {} {}\n", named.x, named.0, named.1);
// Element into a typed local (access path, not just print).
a : s64 = pair.0;
b : s64 = pair.1;
a : i64 = pair.0;
b : i64 = pair.1;
print("locals {} {}\n", a, b);
// Tuple-typed struct field: store a tuple value, read both elements.
@@ -46,9 +46,9 @@ main :: () -> s32 {
print("mem {}\n", 3 in (1, 2, 3));
print("lex {}\n", (1, 2) < (1, 3));
// Mixed-size fields: a tuple with both an s64 and a string (16-byte fat
// Mixed-size fields: a tuple with both an i64 and a string (16-byte fat
// pointer). Field types are tracked per-position, so reading each back is
// typed correctly (s64 prints as a number, string as text).
// typed correctly (i64 prints as a number, string as text).
mixed := (42, "hi");
print("mixed {} {}\n", mixed.0, mixed.1);
0

View File

@@ -7,15 +7,15 @@
#import "modules/std.sx";
main :: () -> s32 {
main :: () -> i32 {
// Positional element assignment.
a : (s32, string) = ---;
a : (i32, string) = ---;
a.0 = 11;
a.1 = "x";
print("a: {} {}\n", a.0, a.1);
// Named tuple: write + read by name, and read by position.
p : (x: s32, y: string) = ---;
p : (x: i32, y: string) = ---;
p.x = 22;
p.y = "y";
print("p: x={} y={} .0={}\n", p.x, p.y, p.0);

View File

@@ -1,10 +1,10 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Point :: struct { x, y: s32; }
Point :: struct { x, y: i32; }
Color :: enum { red; green; blue; }
@@ -16,7 +16,7 @@ Shape :: enum {
Overlay :: union {
f: f32;
i: s32;
i: i32;
}
Vec2 :: union {
@@ -25,27 +25,27 @@ Vec2 :: union {
}
Defaults :: struct {
a: s32;
b: s32 = 99;
c: s32 = ---;
a: i32;
b: i32 = 99;
c: i32 = ---;
}
MyFloat :: f64;
Status :: enum u8 { ok; err; timeout; }
add :: (a: s32, b: s32) -> s32 { a + b }
add :: (a: i32, b: i32) -> i32 { a + b }
mul :: (a: s32, b: s32) -> s32 { a * b }
mul :: (a: i32, b: i32) -> i32 { a * b }
vec3 :: (x: f32, y: f32, z: f32) -> Vector(3, f32) {
.[x, y, z]
}
// Global variable for address-of test
g_smoke_val : s32 = 42;
g_smoke_val : i32 = 42;
write_to_ptr :: (p: *s32) {
write_to_ptr :: (p: *i32) {
p.* = 99;
}
@@ -57,15 +57,15 @@ main :: () {
print("=== 3. Types ===\n");
// Primitive types
v_s8 : s8 = 127;
v_s16 : s16 = 32000;
v_s32 : s32 = 100000;
v_i8 : i8 = 127;
v_i16 : i16 = 32000;
v_i32 : i32 = 100000;
v_u8 : u8 = 255;
v_u16 : u16 = 65000;
v_u32 : u32 = 4000000;
print("s8: {}\n", v_s8);
print("s16: {}\n", v_s16);
print("s32: {}\n", v_s32);
print("i8: {}\n", v_i8);
print("i16: {}\n", v_i16);
print("i32: {}\n", v_i32);
print("u8: {}\n", v_u8);
print("u16: {}\n", v_u16);
print("u32: {}\n", v_u32);
@@ -88,8 +88,8 @@ main :: () {
print("struct-named: {}\n", p3);
// Shorthand (variable name = field name)
x : s32 = 5;
y : s32 = 6;
x : i32 = 5;
y : i32 = 6;
p4 := Point.{ x, y };
print("struct-shorthand: {}\n", p4);
@@ -226,7 +226,7 @@ main :: () {
o : Overlay = ---;
o.f = 3.14;
print("union-f: {}\n", o.f);
// Type punning — read same bits as s32
// Type punning — read same bits as i32
print("union-i: {}\n", o.i);
// Union member promotion
@@ -237,22 +237,22 @@ main :: () {
print("promoted-data0: {}\n", uv.data[0]);
// --- Arrays ---
arr : [5]s32 = .[10, 20, 30, 40, 50];
arr : [5]i32 = .[10, 20, 30, 40, 50];
print("arr[2]: {}\n", arr[2]);
print("arr.len: {}\n", arr.len);
// Array element assignment
aa : [3]s32 = .[1, 2, 3];
aa : [3]i32 = .[1, 2, 3];
aa[1] = 99;
print("arr-assign: {}\n", aa);
// --- Slices ---
sl : []s32 = .[1, 2, 3, 4, 5];
sl : []i32 = .[1, 2, 3, 4, 5];
print("sl[0]: {}\n", sl[0]);
print("sl.len: {}\n", sl.len);
// Slice element write
sla : []s32 = .[10, 20, 30];
sla : []i32 = .[10, 20, 30];
sla[1] = 55;
print("sl-assign: {}\n", sla);
@@ -265,7 +265,7 @@ main :: () {
print("tail: {}\n", tail);
// Slice of slice
sos : []s32 = .[10, 20, 30, 40, 50];
sos : []i32 = .[10, 20, 30, 40, 50];
mid := sos[1..4];
inner := mid[0..2];
print("slice-of-slice: {}\n", inner);
@@ -289,18 +289,18 @@ main :: () {
print("auto-deref: {}\n", ptr.x);
// Many-pointer
mp : [*]s32 = @arr[0];
mp : [*]i32 = @arr[0];
print("mp[0]: {}\n", mp[0]);
print("mp[3]: {}\n", mp[3]);
// Many-pointer write
mpw : [5]s32 = .[10, 20, 30, 40, 50];
mpw_ptr : [*]s32 = @mpw[0];
mpw : [5]i32 = .[10, 20, 30, 40, 50];
mpw_ptr : [*]i32 = @mpw[0];
mpw_ptr[2] = 99;
print("mp-write: {}\n", mpw[2]);
// Pointer-null comparison
np : *s32 = null;
np : *i32 = null;
print("ptr==null: {}\n", np == null);
print("ptr!=null: {}\n", np != null);
np2 := @pv.x;
@@ -309,13 +309,13 @@ main :: () {
// Pointer to nested struct field
Inner3 :: struct { a: f32; b: f32; c: f32; }
Outer3 :: struct { key: s32; inner: Inner3; }
Outer3 :: struct { key: i32; inner: Inner3; }
out3 := Outer3.{ key = 42, inner = Inner3.{ a = 1.0, b = 2.0, c = 3.0 } };
ip3 := @out3.inner;
print("ptr-nested-field: {} {} {}\n", ip3.a, ip3.b, ip3.c);
// Store to many-pointer field must not corrupt adjacent memory
MpHolder :: struct { items: [*]s64; sentinel: s64; }
MpHolder :: struct { items: [*]i64; sentinel: i64; }
mph := MpHolder.{ items = xx 0, sentinel = 42 };
mph.items = xx 0;
print("mp-store-sentinel: {}\n", mph.sentinel);

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Perms :: enum flags { read; write; execute; }
@@ -42,12 +42,12 @@ main :: () {
print("flags-all: {}\n", pall);
// Cast to int
print("flags-raw: {}\n", cast(s64) perm);
print("flags-raw: {}\n", cast(i64) perm);
// Flags with explicit values
wf : WindowFlags = .vsync | .resizable;
print("flags-explicit: {}\n", wf);
print("flags-explicit-raw: {}\n", cast(s64) wf);
print("flags-explicit-raw: {}\n", cast(i64) wf);
// --- Multi-target assignment (swap) ---
print("--- swap ---\n");
@@ -62,7 +62,7 @@ main :: () {
// Array element swap
{
sarr : [3]s64 = .[1, 2, 3];
sarr : [3]i64 = .[1, 2, 3];
sarr[0], sarr[2] = sarr[2], sarr[0];
print("arr swap: {} {}\n", sarr[0], sarr[2]);
}
@@ -87,7 +87,7 @@ main :: () {
// Destructure from function return
{
dswap :: (a: s64, b: s64) -> (s64, s64) { (b, a) }
dswap :: (a: i64, b: i64) -> (i64, i64) { (b, a) }
dx, dy := dswap(1, 2);
print("fn: {} {}\n", dx, dy);
}

View File

@@ -1,8 +1,8 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
main :: () {
@@ -16,9 +16,9 @@ main :: () {
ca_a += ca_b;
print("f64+=f32: {}\n", ca_a);
ca_c : s64 = 100;
ca_d : s32 = 7;
ca_c : i64 = 100;
ca_d : i32 = 7;
ca_c -= ca_d;
print("s64-=s32: {}\n", ca_c);
print("i64-=i32: {}\n", ca_c);
}
}

View File

@@ -1,10 +1,10 @@
#import "modules/std.sx";
#import "modules/math/math.sx";
#import "modules/compiler.sx";
#import "modules/test.sx";
pkg :: #import "modules/testpkg";
#import "modules/math";
#import "modules/build.sx";
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
Point :: struct { x, y: s32; }
Point :: struct { x, y: i32; }
main :: () {
@@ -16,7 +16,7 @@ main :: () {
spts : [2]Point = .[Point.{1, 2}, Point.{3, 4}];
spt2 := spts[1];
print("arr-struct-x: {}\n", spt2.x);
for spts: (it) {
for spts (it) {
print("for-struct: {}\n", it);
}
}

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