16 KiB
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 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
lazyLowerFunctionpromotes the body into it viaforeign_name_map. Examples 1226 (bare export, C callssx_square→ 37/82) + 1227 (export "triple_c", C callstriple_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
PART B — Phase 5 (#foreign becomes an alias for extern). Part A is complete and
the A→B gate is locked, so migration can begin. Phase 5.0 (lock): route the four
#foreign parser paths (parser.zig:316,425,1305,1970) to build the same extern-named
AST as extern/export — suite green, all snapshots unchanged. Phase 5.1 (lock): unit
test that #foreign and extern produce identical IR (the gate already covers this — extend
or reuse lowerSrcToIr). Then Phases 6–7 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 — bare extern (no extern_lib)
is currently unconditionally visible via the c_import_bare gate
(decl.zig:~2241, fd.body.data != .foreign_expr → return true), whereas a lib-less
#foreign is policed by visibleOverEdges. Single-file examples don't exercise this;
verify/align it at the A→B gate (a bare-extern lib-less fn should be policed like its
#foreign twin). Adding it now would be untested — needs a cross-module example.
Open decisions
Part A ratified (bare / postfix / ⇒ callconv(.c) / lib-separate). Part B (confirm
before Phase 9): runtime-class rename target — Runtime*Class* (recommended);
historical carve-out — keep issues/*.md provenance, gate the live tree only.
Log
- (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_exporttokens + keyword-map entries + LSP keyword classification +lex linkage keywordstest. Suite green; no identifier collisions in the corpus.lockcommit. - (0.1) Added
ast.ExternExportModifier+FnDecl.extern_export+VarDecl.is_extern/extern_name+parseOptionalExternExport()(unconsumed) + 2 parser unit tests. Suite green (443/633).lockcommit. - (1.0a) Wired fn-path extern parsing (
parseFnDecl+ both lookahead predicates) + addedFnDecl.extern_lib/extern_name+VarDecl.extern_libper user feedback (decision 4 revised: extern carries an optional lib axis). Unconsumed by lowering. Suite green (443/633).lockcommit. - (1.0b) Added
examples/1223-ffi-extern-fn.sx+ hand-authored success snapshots. RED (634 ran, 1 failed — semabody produces no value).xfailcommit; 1.1 greens it. - (1.1) Wired extern fn lowering (6 edits in
decl.zig, all declare-only routing mirroringforeign_expr):funcWantsImplicitCtx+declareFunctioncc +lazyLowerFunction/lowerFunction/lowerFunctionBodyIntoguards. 1223 green;declare i32 @abs(i32)(C ABI, no ctx). Suite green (634/443).greencommit. - (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"afterexternnot yet accepted).xfail; 1.2b greens it. (Also recovered a formatter-clobberedparser.zig— see Known issues.) - (1.2b)
parseFnDeclparses the optional[LIB] ["csym"]tail intoextern_lib/extern_name;declareFunctionunifies 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).greencommit. extern_lib parsed+stored (lib linking stays the#libraryaxis). - (1.2c) Added
examples/1225-ffi-extern-global.sx(__stdinp : *void extern;, mirrors#foreignglobal 1205) + success snapshot. RED (636 ran, 1 failed — parse error: var-declexternnot accepted).xfail; 1.2d greens it. - (1.2d) Parser
kw_externbranch in the var-decl path ([LIB] ["csym"]→is_extern/extern_lib/extern_name) +registerTopLevelGlobal/globalInitValueconsumeis_extern. 1225 green (@__stdinp = external global ptr). Suite green (636/443).greencommit. PHASE 1 COMPLETE —externfns + globals fully work. - (JIT spike) User-requested feasibility investigation of C→sx-by-name in
sx run(JIT). Verdict: feasible viaLLVMOrcLLJITAddObjectFile(C objects into the ORC JITDylib) — proven by a throwaway spike — but blocked by JITLink MachO TLV handling (sx_trace.c's_Thread_localSIGABRTs without the ORCMachOPlatform). Own future milestone (see Next step). Spike reverted; no commit. - (2.0) Added the AOT corpus mode (
expected/<name>.aot→sx build+ execute) tocorpus_run.test.zig+ retiredtests/run_examples.sh(verify-step.sh/CLAUDE.md updated) +examples/1226-ffi-export-fn.{sx,c,h}(C callssx_squareback). RED (AOT link fails:_sx_squareundefined — export not lowered).xfail; 2.1 greens it. - (2.1) Filled export gaps i/ii/iv in
decl.zig(.externallinkage +.ccc on both define paths;funcWantsImplicitCtxfalse for any non-.nonemodifier) + force-lower export fns as roots inlowerMainAndComptime. 1226 green via AOT (37/82). Suite green (637/443).greencommit. - (2.2a) Added
examples/1227-ffi-export-fn-rename.sx(export "triple_c", C callstriple_c). RED (define path emits@sx_triple, ignoresextern_name→ C ref undefined).xfail; 2.2b greens it. - (2.2b)
declareFunctionrename branch fires forexport(stub under C name + sx→C inforeign_name_map);lazyLowerFunctionresolves 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).greencommit. PHASE 2 COMPLETE —exportfully works. - (3.0) Added
examples/1348-ffi-objc-extern-class.sx(postfixexternon#objc_class, new spelling of#foreign #objc_class). RED (parser:expected '{'after the directive). Hand-authored green snapshots.xfailcommit; 3.1 greens it. - (3.1a) Wired the postfix
extern/exportaggregate slot inparseForeignClassDecl(optional modifier between("X")and{;var is_foreign_effoverrides the passedis_foreign, threaded into theforeign_class_declnode). No lowering change — reuses the existingis_foreignreference-vs-define path. 1348 green. Suite green (639/443).greencommit. PHASE 3 COMPLETE. - (3.1b) Behavior-lock: added
examples/1426-ffi-jni-extern-class.sx(jniextern, parse-only) +examples/1349-ffi-objc-export-class.sx(objcexportdefined class,counter: 2). Both pass against the 3.1a parser change (locked in their own commit per the cadence rule). Suite green (641/443).lockcommit. (Note:-Dupdate-goldensnewline-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
lowerSrcToIrhelper + "GATE A→B" test tolower.test.zig:#foreign≡extern/exportbyte-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 withsx ir(LLVM IR byte-identical for all three). Suite green (641/444).testcommit. - (4.diag1) Added
examples/1174-diagnostics-foreign-postfix-conflict.sx— prefix#foreign- postfix
exporton an aggregate previously surfaced a confusing internal "emitObjcDefinedClassAllocImp … compiler bug".xfail(golden = clean message) →green:parseForeignClassDeclrejects the combo at the postfix keyword (failFmt). Suite green.
- postfix
- (4.docs)
specs.md(new "extern/exportlinkage keywords" subsection after the#foreignFFI docs) +readme.md(C Interop section) document the three axes.docscommit. - (4.diag2) Added
examples/1175-diagnostics-extern-export-conflict.sx—extern exporton one fn decl previously gave bare "expected ';'".xfail(golden = clean message) →green:parseFnDeclrejects a second linkage keyword afterparseOptionalExternExport. Suite green (643/444). PHASE 4 COMPLETE → PART A DONE. (Workflow note:-Dupdate-goldenskeeps newline-normalizing the 5 empty-stderr files [1226/1227/1348/1349/1426] — revert that churn after each regen; a flaky0712-sha256-streamingtimeout appears only under concurrentzig buildload, not a real failure.)
Known issues
- Workflow hazard (1.2): an editor format-on-save (or
zig fmt) clobbered the working-treesrc/parser.zigbetween commits — it reformatted one-liners AND silently dropped myhasFnBodyAfterArrowextern edit, reverting 1223 to a parse error. Recovered withgit 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.