Files
sx/current/CHECKPOINT-EXTERN-EXPORT.md
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

64 KiB
Raw Permalink Blame History

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_pathruntime_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.md0, 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 ForeignClassDeclRuntimeClassDecl etc.; fns parseForeignClassDeclparseRuntimeClassDecl, lowerForeignMethodCalllowerRuntimeMethodCall, …; state foreign_class_mapruntime_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_pathruntime_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 — callForeigncallExtern, marshalForeignArgmarshalExternArg, dedupeForeignSymboldedupeExternSymbol, foreign_name_mapextern_name_map, is_foreign_c_apiis_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 refactors 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 #foreignextern (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/ #foreignextern; 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/ #foreignextern (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/ #foreignextern (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/ #foreignextern/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 #foreignextern (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 #foreignextern/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 #foreignextern 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): externis_foreign_eff = true (reference an existing runtime class, == legacy #foreign); exportis_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/getcounter: 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 (#foreignextern/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.)

  1. FIRST: commit the staged examples/ comment purge (a classifier outage blocked the commit; changes are git added). Message: "refactor(ffi-linkage): Phase 9.3-examples — purge 'foreign' from example .sx comments".
  2. 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").
  3. issues/*.md (~20) — rewrite writeup prose #foreign/foreignextern/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.
  4. 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.
  5. 9.4 gategrep -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 foreignextern/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 gategrep -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-issuesissues/*.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 gategrep -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_effis_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 equivalenceDONE (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_pathruntime_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_exportconst. 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) #foreignextern; 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) #foreignextern; 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_absabs); 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 COMPLETEextern 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>.aotsx 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 COMPLETEexport 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: #foreignextern/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.sxextern 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.