# 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 0–9) 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.1–7.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 0–9 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 6–7** (`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 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~~ — **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.**
