self-host: gate the analyzer build on the kernel tier; move case/cond-> to the overlay

The real bug behind the case/cond-> fixpoint regression wasn't gensym — it was
build ordering. A top-level defn in a pre-kernel tier (the fresh-sym helper these
macros need) gets compiled in compile mode, and that lazily builds the self-hosted
analyzer via ensure-analyzer. 00-syntax loads before 00-kernel, so the analyzer
was built against a core missing mapv/second/peek/...: its references to them were
interned as forward-ref nil cells in jolt.analyzer. Those nil cells then shadowed
the real clojure.core defs when the analyzer rebuilt itself (stage2), so the
analyzer's own mapv calls went to nil — 'Cannot call nil as a function'. Only
variadic-heavy paths (juxt+mapv) surfaced it.

build-compiler! already documented that the kernel must be loaded first; nothing
enforced it. Gate ensure-analyzer on a :kernel-ready? flag set after the kernel
tier loads. A pre-kernel compile now falls back to the interpreter (compile-and-eval
already handles that) instead of building the analyzer too early. fresh-sym ends up
interpreted during 00-syntax load, which is fine.

With ordering fixed, case and cond-> move to 00-syntax (they need a real gensym, so
syntax-quote auto-gensym alone won't do). conformance 228x3, fixpoint stage1==2==3,
clojure-test-suite 3930, full suite green, bench flat.
This commit is contained in:
Yogthos 2026-06-07 16:29:50 -04:00
parent d0d48f0ebd
commit 646744ca1d
4 changed files with 58 additions and 58 deletions

View file

@ -59,3 +59,39 @@
;; Forward declaration is a no-op on Jolt — the compiler resolves forward refs via
;; pending cells (matching the prior Janet macro).
(defmacro declare [& syms] `(do))
;; A fresh jolt symbol inside a macro body (a bare (gensym) returns a Janet symbol
;; the destructurer rejects). This defn compiles fine: by the time a tier triggers
;; the analyzer build the kernel is in place (the build is gated until then).
(defn- fresh-sym [] (symbol (str (gensym))))
;; cond->: thread expr through each (test form) pair, only when the test is truthy.
;; Linear nested let*, a distinct fresh symbol per step.
(defmacro cond-> [expr & clauses]
(let [step (fn step [prev cls]
(if (empty? cls)
prev
(let [t (first cls)
f (nth cls 1)
gn (fresh-sym)
call (if (seq? f) `(~(first f) ~prev ~@(rest f)) `(~f ~prev))]
`(let* [~gn (if ~t ~call ~prev)] ~(step gn (drop 2 cls))))))
g0 (fresh-sym)]
`(let* [~g0 ~expr] ~(step g0 clauses))))
;; case: nested =/or tests (no jump table). Test constants are NOT evaluated —
;; symbols and list constants are quoted; a list in test position is a set (or).
(defmacro case [expr & clauses]
(let [g (fresh-sym)
mk-const (fn [c] (if (or (symbol? c) (seq? c)) `(quote ~c) c))
mk-test (fn [c]
(if (seq? c)
`(or ~@(map (fn [v] `(= ~g ~(mk-const v))) c))
`(= ~g ~(mk-const c))))
build (fn build [cls]
(if (empty? cls)
nil
(if (empty? (rest cls))
(first cls)
`(if ~(mk-test (first cls)) ~(nth cls 1) ~(build (drop 2 cls))))))]
`(let* [~g ~expr] ~(build clauses))))

View file

@ -72,12 +72,21 @@
(def core-dl (get env :aot-core?))
(def saved (ctx-current-ns ctx))
(ctx-set-current-ns ctx "clojure.core")
# Gate the analyzer build until the kernel tier loads (see ensure-analyzer):
# present-and-false here means pre-kernel compiles fall back to the interpreter.
(put env :kernel-ready? false)
(each tier core-tiers
(when-let [src (get stdlib-embed/sources (tier :ns))]
(put env :direct-linking? core-dl)
(if (and compile? (tier :kernel))
(backend/bootstrap-load-source ctx "clojure.core" src)
(eval-overlay-source ctx src))))
(eval-overlay-source ctx src))
# The self-hosted compiler depends on the kernel tier (second/peek/mapv/...).
# Mark it ready once that tier is in place so the analyzer can be built; a
# pre-kernel tier that triggers a compile (e.g. a defn in 00-syntax) instead
# falls back to the interpreter rather than building the analyzer against a
# half-loaded core (which would forward-ref the missing kernel fns to nil).
(when (tier :kernel) (put env :kernel-ready? true))))
(put env :direct-linking? user-dl)
(ctx-set-current-ns ctx saved))

View file

@ -306,7 +306,17 @@
(compile-load ctx "jolt.analyzer"))
(defn- ensure-analyzer [ctx]
(when (= 0 (length ((ctx-find-ns ctx "jolt.analyzer") :mappings)))
# Don't build until the kernel tier is loaded (see api/load-core-overlay! and
# build-compiler!). Before then a compile request — e.g. a defn in a pre-kernel
# tier — must fall back to the interpreter, not build the analyzer against a
# core missing the fns it references (which would intern them as nil cells that
# then shadow the real definitions on the self-rebuild). The flag is absent in
# bare/test contexts that never load core; treat that as ready so those keep
# building the analyzer lazily as before.
(def env (ctx :env))
(def gated (and (has-key? env :kernel-ready?) (not (get env :kernel-ready?))))
(when (and (not gated)
(= 0 (length ((ctx-find-ns ctx "jolt.analyzer") :mappings))))
(build-compiler! ctx)))
(defn rebuild-compiler!

View file

@ -2062,38 +2062,6 @@
(put gensym_counter :val (+ n 1))
{:jolt/type :symbol :ns nil :name (string prefix-string n)})
(defn core-case
"Macro: (case expr val1 result1 ... default)
Supports single values, lists of values (one-of-many), and symbols."
[expr & clauses]
(def g (gensym))
(defn make-const [c]
# case constants are literals, never evaluated: quote symbols and list
# literals (read as arrays) so e.g. `sym` and a wrapped list `(a b c)` match
# by value rather than resolving/calling.
(if (or (and (struct? c) (= :symbol (c :jolt/type))) (array? c))
@[{:jolt/type :symbol :ns nil :name "quote"} c]
c))
(defn make-test [c]
(if (array? c)
(let [or-args @[{:jolt/type :symbol :ns nil :name "or"}]]
(each v c
(array/push or-args @[{:jolt/type :symbol :ns nil :name "="} g (make-const v)]))
or-args)
@[{:jolt/type :symbol :ns nil :name "="} g (make-const c)]))
(defn build [cls]
(if (= 0 (length cls))
nil
(if (= 1 (length cls))
(first cls)
(let [c (first cls)
r (first (tuple/slice cls 1))]
@[{:jolt/type :symbol :ns nil :name "if"}
(make-test c)
r
(build (tuple/slice cls 2))]))))
@[{:jolt/type :symbol :ns nil :name "let*"} @[g expr] (build clauses)])
# if-let / when-let / if-some / when-some bind the name ONLY in the then/body
# branch — the else branch must see the surrounding scope, not the binding (so
@ -2170,27 +2138,6 @@
(build 0 (parse-groups bindings))
body))
(defn core-cond->
"Macro: (cond-> expr test form ...) — thread first only when test is true"
[expr & clauses]
(def g (gensym))
(defn build [cls result-form]
(if (= 0 (length cls))
result-form
(let [t (first cls)
f (in cls 1)
f-call (if (array? f)
(let [arr (array/slice f)]
(array/insert arr 1 result-form)
arr)
@[f result-form])]
(build (tuple/slice cls 2)
@[{:jolt/type :symbol :ns nil :name "if"}
t
f-call
result-form]))))
@[{:jolt/type :symbol :ns nil :name "let*"} @[g expr] (build clauses g)])
(defn core-push-thread-bindings [b] (push-thread-bindings b))
(defn core-pop-thread-bindings [] (pop-thread-bindings))
@ -3534,10 +3481,8 @@
"add-watch" core-add-watch
"remove-watch" core-remove-watch
"not" core-not
"case" core-case
"for" core-for
"when-let" core-when-let
"cond->" core-cond->
"defn" core-defn
"defn-" core-defn-
"derive" core-derive
@ -3617,7 +3562,7 @@
(defn core-macro-names
"Set of core binding names that are macros."
[]
@{"case" true "for" true "when-let" true "defn" true "defn-" true "fn" true "let" true "loop" true "defrecord" true "defprotocol" true "extend-type" true "extend-protocol" true "extend" true "reify" true "proxy" true "definterface" true "lazy-seq" true "lazy-cat" true "cond->" true "doseq" true})
@{"for" true "when-let" true "defn" true "defn-" true "fn" true "let" true "loop" true "defrecord" true "defprotocol" true "extend-type" true "extend-protocol" true "extend" true "reify" true "proxy" true "definterface" true "lazy-seq" true "lazy-cat" true "doseq" true})
(def init-core!
(fn [& args]