From 582baf5bfdc6d42e089f8ef1250ef449c9266c4c Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 14 May 2026 19:08:34 +0000 Subject: [PATCH] erlang: code:which/is_loaded/all_loaded introspection (+10 eval tests) --- lib/erlang/scoreboard.json | 6 +++--- lib/erlang/scoreboard.md | 4 ++-- lib/erlang/tests/eval.sx | 43 ++++++++++++++++++++++++++++++++++++++ lib/erlang/transpile.sx | 42 +++++++++++++++++++++++++++++++++++++ plans/erlang-on-sx.md | 4 +++- 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 9ae4f811..d836a6c6 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 561, - "total": 561, + "total_pass": 571, + "total": 571, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":364,"total":364,"status":"ok"}, + {"name":"eval","pass":374,"total":374,"status":"ok"}, {"name":"runtime","pass":52,"total":52,"status":"ok"}, {"name":"ring","pass":4,"total":4,"status":"ok"}, {"name":"ping-pong","pass":4,"total":4,"status":"ok"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 16b0c7d1..43f8d776 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 561 / 561 tests passing** +**Total: 571 / 571 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 364 | 364 | +| ✅ | eval | 374 | 374 | | ✅ | runtime | 52 | 52 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index 18d06fe3..59eb0ff2 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -1209,6 +1209,49 @@ (er-eval-test "code:soft_purge badarg" (nm (ev "try code:soft_purge(123) catch error:badarg -> ok end")) "ok") + +;; ── Phase 7: code:which/1 + code:is_loaded/1 + code:all_loaded/0 ── +(er-modules-reset!) + +(er-eval-test "code:which non_existing" + (nm (ev "code:which(nope)")) "non_existing") + +(er-eval-test "code:which after load" + (nm (ev "code:load_binary(wh1, \"wh1\", \"-module(wh1). v() -> 1.\"), code:which(wh1)")) + "loaded") + +(er-eval-test "code:is_loaded missing" + (nm (ev "code:is_loaded(nope)")) "false") + +(er-eval-test "code:is_loaded tag" + (nm (ev "code:load_binary(il1, \"il1\", \"-module(il1). v() -> 1.\"), element(1, code:is_loaded(il1))")) + "file") + +(er-eval-test "code:is_loaded value" + (nm (ev "code:load_binary(il2, \"il2\", \"-module(il2). v() -> 1.\"), element(2, code:is_loaded(il2))")) + "loaded") + +(er-modules-reset!) +(er-eval-test "code:all_loaded empty" + (ev "length(code:all_loaded())") 0) + +(er-modules-reset!) +(er-eval-test "code:all_loaded count" + (ev "code:load_binary(al1, \"al1\", \"-module(al1). v() -> 1.\"), + code:load_binary(al2, \"al2\", \"-module(al2). v() -> 1.\"), + length(code:all_loaded())") + 2) + +(er-eval-test "code:all_loaded first entry tag" + (nm (ev "code:load_binary(al3, \"al3\", \"-module(al3). v() -> 1.\"), + element(2, hd(code:all_loaded()))")) + "loaded") + +(er-eval-test "code:which badarg" + (nm (ev "try code:which(\"str\") catch error:badarg -> ok end")) "ok") +(er-eval-test "code:is_loaded badarg" + (nm (ev "try code:is_loaded(123) catch error:badarg -> ok end")) "ok") + (define er-eval-test-summary (str "eval " er-eval-test-pass "/" er-eval-test-count)) diff --git a/lib/erlang/transpile.sx b/lib/erlang/transpile.sx index 04d95e56..3ac63d54 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -2032,6 +2032,42 @@ (er-module-version slot))) (er-mk-atom "true")))))))))))) +(define er-bif-code-which + (fn (vs) + (let ((mod-arg (nth vs 0))) + (cond + (not (er-atom? mod-arg)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (dict-has? (er-modules-get) (get mod-arg :name)) + (er-mk-atom "loaded") + :else (er-mk-atom "non_existing"))))) + +(define er-bif-code-is-loaded + (fn (vs) + (let ((mod-arg (nth vs 0))) + (cond + (not (er-atom? mod-arg)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (dict-has? (er-modules-get) (get mod-arg :name)) + (er-mk-tuple (list (er-mk-atom "file") (er-mk-atom "loaded"))) + :else (er-mk-atom "false"))))) + +(define er-bif-code-all-loaded + (fn (vs) + (let ((registry (er-modules-get)) + (ks (keys (er-modules-get))) + (out (er-mk-nil))) + (for-each + (fn (i) + (let ((k (nth ks (- (- (len ks) 1) i)))) + (set! out + (er-mk-cons + (er-mk-tuple + (list (er-mk-atom k) (er-mk-atom "loaded"))) + out)))) + (range 0 (len ks))) + out))) + (define er-apply-code-bif (fn (name vs) (cond @@ -2041,6 +2077,12 @@ (er-bif-code-purge vs) (and (= name "soft_purge") (= (len vs) 1)) (er-bif-code-soft-purge vs) + (and (= name "which") (= (len vs) 1)) + (er-bif-code-which vs) + (and (= name "is_loaded") (= (len vs) 1)) + (er-bif-code-is-loaded vs) + (and (= name "all_loaded") (= (len vs) 0)) + (er-bif-code-all-loaded vs) :else (error (str "Erlang: undefined function 'code:" name "/" (len vs) "'"))))) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 50ec8236..e85febf1 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -104,7 +104,7 @@ Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules mu - [x] Module version slot: `er-modules` entry becomes `{:current MOD-ENV :old MOD-ENV-or-nil :version INT}`; bump version on each load — **13 new runtime tests** (543/543 total) - [x] `code:load_binary/3` (the canonical reload BIF) — re-parses module source, swaps `:current` → `:old`, installs new env as `:current`; returns `{module, Name}` or `{error, Reason}` (badarg / badfile / module_name_mismatch). **+8 eval tests** (551/551 total). `code:load_file/1` is a thin filesystem wrapper around this and lands once `file:read_file/1` is in (Phase 8). - [x] `code:purge/1` + `code:soft_purge/1` — purge clears `:old` slot and kills any process whose `:initial-fun` env identity matches the old env (returns `true` if there was old code, `false` if there wasn't). soft_purge: refuses (returns `false`, leaves `:old` intact) if any process is still pinned to the old env; otherwise clears and returns `true`. **+10 eval tests** (561/561 total). Caveat: a true "lingering on old code" test needs `spawn/3` (still stubbed) or `fun M:F/A` syntax (not parsed) — anonymous `fun () -> M:F() end` closures capture the caller's env, not the module's, and cross-module calls always resolve to `:current`. Current tests therefore exercise the return-value matrix but not the kill path. -- [ ] `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` — introspection +- [x] `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` — introspection. **+10 eval tests** (571/571 total). Return-value contract: `which` → `loaded` / `non_existing` (since we have no filesystem path); `is_loaded` → `{file, loaded}` / `false`; `all_loaded` → list of `{Module, loaded}` tuples. Non-atom Mod raises `error:badarg`. - [ ] Cross-module call `M:F(...)` dispatches to `:current`; local calls inside a module body keep using the env they closed over so a running process finishes its current function with the version it started with - [ ] Tests: load v1 → spawn → load v2 → cross-module call hits v2 → local call inside v1 process keeps v1 semantics until function returns → purge kills v1 procs → soft_purge refuses while v1 procs alive @@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **2026-05-14 code introspection BIFs green** — `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` added to `er-apply-code-bif` dispatch with three small implementations in `transpile.sx`. `which` and `is_loaded` are dict-lookups on the module registry returning the loaded-marker (atom `loaded`) or the missing-marker (atom `non_existing` for which, atom `false` for is_loaded). Since we don't have a filesystem path representation, the standard `{file, Path}` shape for `is_loaded` becomes `{file, loaded}` — same tuple arity so destructuring code stays portable. `all_loaded` iterates `(keys (er-modules-get))` in reverse (so the result list preserves insertion order after the cons-prepend loop), wrapping each name in a `{Module, loaded}` tuple. **10 new eval tests**: non_existing for absent / loaded after load for which; missing / file-tag / loaded-value for is_loaded; empty / count-after-2-loads / first-entry-tag for all_loaded; badarg for both single-arg BIFs. Two of the all_loaded tests needed an explicit `(er-modules-reset!)` before the measurement because prior tests in the suite leave modules registered (the registry is process-global across the whole epoch session). Total **571/571** (+10 eval). + - **2026-05-14 code:purge/1 + code:soft_purge/1 green** — Two new BIFs in `transpile.sx`: `er-bif-code-purge` and `er-bif-code-soft-purge`, both dispatched through the existing `er-apply-code-bif` cond chain. Shared helper `er-procs-on-env` walks `(er-sched-processes)` and collects pids whose `:initial-fun` is a fun whose `:env` is identical (dict-identity, not structural) to a given env, filtering out already-dead procs. `er-bif-code-purge` looks up the module slot, returns `false` if either the module isn't registered or `:old` is nil; otherwise calls `er-cascade-exit!` on every matching pid with reason `killed`, replaces the slot with a fresh `er-mk-module-slot` that has `:old nil` (current + version preserved), returns `true`. `er-bif-code-soft-purge` returns `true` (treating "no module" / "no old version" as already-purged), else checks for lingering procs and returns `false` (leaving the slot untouched) if any, else clears `:old` and returns `true`. Non-atom Mod raises `error:badarg` from both. **10 new eval tests**: unknown / no-old / after-reload / idempotent for purge; unknown / no-old / clean for soft_purge; badarg for both; one "purge after spawn" test verifying return value (does NOT exercise the kill path — see caveat in plan). Total **561/561** (+10 eval). Implementation cost: 1 dispatch entry, 3 small BIFs, no scheduler changes. - **2026-05-14 code:load_binary/3 green** — Canonical hot-reload entry point. Adds a `"code"` module branch to `er-apply-remote-bif`'s dispatch; new helpers `er-source-walk-bytes!` and `er-source-to-string` coerce any of {SX string, Erlang binary `<<...>>`, Erlang char-code cons list} to an SX source string before parsing. `er-bif-code-load-binary` is the BIF itself: validates `Mod` is an atom (`{error, badarg}` else), coerces source (`{error, badarg}` on unrecognised shape), wraps `erlang-load-module` in `guard` to convert parse failures into `{error, badfile}`, checks the parsed `-module(Name).` matches the BIF's first arg (`{error, module_name_mismatch}` else), returns `{module, Mod}`. Reload reuses the Phase-7 slot logic from the previous iteration so calling `code:load_binary(m, _, v2_source)` after `code:load_binary(m, _, v1_source)` bumps the slot to version 2 with v1 sitting in `:old`. 8 new eval tests: ok-tag/ok-name on first load, immediate cross-module call hits new env, reload-and-call returns v2 result, name-mismatch errors with both tag and reason, garbage source yields badfile, non-atom Mod is badarg. Total **551/551** (+8 eval). `code:load_file/1` deferred until `file:read_file/1` lands in Phase 8 (it's just a wrapper that reads bytes from disk then calls `load_binary`).