errors: user-readable messages and stack traces (jolt-2o7 rounds 1+2)

Before: (+ 1 "a") printed 'could not find method :+ for 1 or :r+ for "a"'
followed by three janet frames pointing at jolt internals. After:

    Error: Cannot add 1 and "a" — + expects numbers
      at app.deep/level3

Round 1 — compiled fns carry their Clojure identity:
- The analyzer's recur target (which doubles as the compiled janet fn's
  name) is now ns/fn-name (_r$app.deep/level3--N), so janet stack traces
  name the user's fns; defn passes the self-name through to fn.
- eval-toplevel re-raises with propagate instead of protect+error — the
  failing fiber's stack was being discarded, which is why every trace began
  at eval-toplevel.
- require/maybe-require-ns route loaded namespaces through the loader's
  compile-or-interpret eval-toplevel via a ctx hook (the evaluator can't
  import the loader). Previously REQUIRED namespaces always ran interpreted:
  slower, and their fns were anonymous in traces.

Round 2 — report-error presents for users (rephrase-inspired):
- The full trace text is stashed at the innermost eval-toplevel boundary
  (janet's debug/stacktrace walks the fiber propagation chain; debug/stack
  cannot), then filtered: _r$ frames demangled to ns/fn-name, jolt-internal
  and [eval] frames dropped. JOLT_DEBUG=1 restores the raw janet trace.
- Message rewrites: janet arithmetic dispatch -> 'Cannot add X and Y — +
  expects numbers'; compiled arity -> Clojure's 'Wrong number of args (N)
  passed to: ns/fn'; nil-call gets an undefined-symbol hint (round 3 will
  fix resolution properly).

6 cli-test rows assert the exact user-visible output. Gate green, suite
4718 steady, bench within noise.
This commit is contained in:
Yogthos 2026-06-12 08:58:08 -04:00
parent a40c715ade
commit e0442f84e4
7 changed files with 162 additions and 12 deletions

View file

@ -342,7 +342,9 @@
(let [body (if (and (seq body) (string? (first body))) (rest body) body) (let [body (if (and (seq body) (string? (first body))) (rest body) body)
body (if (and (seq body) (map? (first body)) (not (symbol? (first body)))) body (if (and (seq body) (map? (first body)) (not (symbol? (first body))))
(rest body) body)] (rest body) body)]
`(def ~fn-name (fn ~@body)))) ;; pass the name through to fn: the compiled fn's janet name carries it,
;; so stack traces read app.deep/level3 instead of a gensym (jolt-2o7.1)
`(def ~fn-name (fn ~fn-name ~@body))))
;; Jolt doesn't enforce privacy, so defn- is just defn (matching how Clojure's own ;; Jolt doesn't enforce privacy, so defn- is just defn (matching how Clojure's own
;; defn- delegates to defn with :private metadata). ;; defn- delegates to defn with :private metadata).

View file

@ -81,7 +81,12 @@
;; Always a recur target, variadic included: the back end gives the rest ;; Always a recur target, variadic included: the back end gives the rest
;; param an ordinary positional slot (holding the collected seq), so recur ;; param an ordinary positional slot (holding the collected seq), so recur
;; is a self-call carrying the rest seq directly — Clojure semantics. ;; is a self-call carrying the rest seq directly — Clojure semantics.
rname (gen-name "arity") ;; The recur target doubles as the COMPILED FN'S NAME, which is what a
;; janet stack trace shows — so carry the Clojure ns/fn-name (jolt-2o7.1):
;; an error inside app.deep/level3 traces as _r$app.deep/level3--N
;; (report-error demangles the _r$/--N wrapper). gen-name's counter
;; keeps recur targets unique per compilation unit.
rname (gen-name (str (compile-ns ctx) "/" (or fn-name "fn") "--"))
names (cond-> (vec fixed) rst (conj rst) fn-name (conj fn-name)) names (cond-> (vec fixed) rst (conj rst) fn-name (conj fn-name))
env* (-> (add-locals env names) (with-recur rname)) env* (-> (add-locals env names) (with-recur rname))
arity {:params fixed :recur-name rname arity {:params fixed :recur-name rname

View file

@ -154,6 +154,11 @@
(install-async! ctx) (install-async! ctx)
# Host contract (ns jolt.host): the seam the portable jolt-core compiler calls. # Host contract (ns jolt.host): the seam the portable jolt-core compiler calls.
(host/install! ctx) (host/install! ctx)
# require/maybe-require-ns route loaded namespaces through the loader's
# compile-or-interpret eval-toplevel (the evaluator can't import the loader
# — that would be circular — so it reads this hook). Without it, required
# namespaces ran interpreted-only.
(put (ctx :env) :toplevel-eval eval-toplevel)
# Stateful primitives as ctx-capturing clojure.core fns (protocol-dispatch, # Stateful primitives as ctx-capturing clojure.core fns (protocol-dispatch,
# register-method, …) — so the protocol macros compile to plain invokes. Must # register-method, …) — so the protocol macros compile to plain invokes. Must
# precede the overlay (its defprotocol/extend-type expansions call these). # precede the overlay (its defprotocol/extend-type expansions call these).

View file

@ -353,13 +353,19 @@
found)) found))
(defn- load-ns-source (defn- load-ns-source
"Parse and evaluate every form of a namespace's source in the given context." "Parse and evaluate every form of a namespace's source in the given context.
Routes through the loader's eval-toplevel when the api has installed it
(the :toplevel-eval hook) so REQUIRED namespaces compile like everything
else — without it they ran interpreted-only: slower, and their fns were
anonymous closures in stack traces (jolt-2o7.1)."
[ctx src] [ctx src]
(def toplevel (get (ctx :env) :toplevel-eval))
(var s src) (var s src)
(while (> (length (string/trim s)) 0) (while (> (length (string/trim s)) 0)
(def [f r] (parse-next s)) (def [f r] (parse-next s))
(set s r) (set s r)
(when (not (nil? f)) (eval-form ctx @{} f)))) (when (not (nil? f))
(if toplevel (toplevel ctx f) (eval-form ctx @{} f)))))
(defn- maybe-require-ns (defn- maybe-require-ns
"If namespace ns-name isn't populated yet, load its source — from a file on the "If namespace ns-name isn't populated yet, load its source — from a file on the

View file

@ -52,11 +52,22 @@
(try-compile) (try-compile)
(eval-form ctx @{} form))) (eval-form ctx @{} form)))
(eval-form ctx @{} form))) (eval-form ctx @{} form)))
(def res (protect (run))) (try
(if (res 0) (run)
(res 1) ([err fib]
(do (ctx-set-current-ns ctx entry-ns) (ctx-set-current-ns ctx entry-ns)
(error (res 1))))) # Stash the full trace TEXT at this innermost boundary: janet's
# debug/stacktrace walks the propagation chain (fiber->child, no public
# accessor), so this is the only place the user's compiled frames
# (in _r$ns/fn--N ...) are reachable. Innermost capture wins; the CLI's
# report-error filters + demangles it. (jolt-2o7.1/2)
(when (nil? (get (ctx :env) :error-trace))
(def buf @"")
(with-dyns [:err buf] (debug/stacktrace fib err ""))
(put (ctx :env) :error-trace (string buf)))
# propagate (not error): re-raising with `error` discards the failing
# fiber's stack
(propagate err fib))))
(defn load-ns (defn load-ns
"Load a Clojure namespace from a .clj file. Per-form routing (compile-or- "Load a Clojure namespace from a .clj file. Per-form routing (compile-or-

View file

@ -226,10 +226,100 @@
(string? err) err (string? err) err
(string/format "%q" err))) (string/format "%q" err)))
# --- error presentation (jolt-2o7.2, rephrase-inspired) ----------------------
# Host messages rewritten into Clojure-shaped ones; stack frames filtered to
# the USER'S code (compiled jolt fns carry _r$ns/name--N janet names — round
# 2o7.1). JOLT_DEBUG=1 restores the raw janet trace for jolt development.
(defn- demangle
"_r$app.deep/level3--105 -> app.deep/level3 (nil for non-jolt names)."
[nm]
(when (string/has-prefix? "_r$" nm)
(def s (string/slice nm 3))
# the counter suffix is the LAST --N run
(var cut (length s))
(var i (string/find "--" s))
(while (not (nil? i))
(set cut i)
(set i (string/find "--" s (+ i 1))))
(string/slice s 0 cut)))
(defn- fmt-val [x]
(cond
(string? x) (string `"` x `"`)
(nil? x) "nil"
(string x)))
(def- op-words
{"+" "add" "-" "subtract" "*" "multiply" "/" "divide"
"<" "compare" ">" "compare" "<=" "compare" ">=" "compare"})
(defn- rewrite-message
"Host/janet error text -> a user-facing message. Unknown messages verbatim."
[msg]
(def msg (string msg))
(cond
# janet polymorphic arithmetic: could not find method :+ for 1 or :r+ for "a"
(string/has-prefix? "could not find method :" msg)
(let [rest* (string/slice msg (length "could not find method :"))
sp (string/find " " rest*)
op (string/slice rest* 0 sp)
tail (string/slice rest* (+ sp (length " for ")))
orpos (string/find " or :r" tail)
a (string/slice tail 0 orpos)
forpos (string/find " for " tail (+ orpos 1))
b (string/slice tail (+ forpos 5))]
(string "Cannot " (get op-words op op) " " a " and " b
" — " op " expects numbers"))
# janet fixed-arity: <function _r$ns/f--N> called with 2 arguments, expected 1
(and (string/has-prefix? "<function " msg) (string/find "> called with " msg))
(let [nm-end (string/find ">" msg)
nm (string/slice msg (length "<function ") nm-end)
pretty (or (demangle nm) nm)
tail (string/slice msg (+ nm-end (length "> called with ")))
n-end (string/find " " tail)
n (string/slice tail 0 n-end)
exp (if-let [i (string/find "expected " tail)]
(string " (expected " (string/slice tail (+ i 9)) ")") "")]
(string "Wrong number of args (" n ") passed to: " pretty exp))
# a typo'd symbol compiles to nil today (round 2o7.3 will fix resolution)
(= msg "Cannot call nil as a function")
(string msg " — often an undefined (misspelled?) symbol")
msg))
(defn- print-user-trace
"Filter the stashed janet trace down to frames a jolt user can act on:
compiled jolt fns (the _r$ns/name--N janet names, demangled) and frames
from non-jolt source files. Internal janet/jolt frames are dropped."
[trace]
(var shown 0)
(each line (string/split "\n" trace)
(def t (string/trim line))
(when (string/has-prefix? "in " t)
(def rest* (string/slice t 3))
(def sp (or (string/find " " rest*) (length rest*)))
(def nm (string/slice rest* 0 sp))
(cond
(string/has-prefix? "_r$" nm)
(do (eprint " at " (demangle nm)) (++ shown))
# a frame from a real source file outside jolt internals
(and (string/find "[" rest*)
(not (string/find "src/jolt/" rest*))
(not (string/find "boot.janet" rest*))
(not (string/find "[eval]" rest*)))
(do (eprint " " t) (++ shown))
nil)))
shown)
(defn- report-error [err fib] (defn- report-error [err fib]
(eprint "Error: " (err-message err)) (eprint "Error: " (rewrite-message (err-message err)))
# Janet-level stack trace of where evaluation failed (def stashed (get (ctx :env) :error-trace))
(when fib (debug/stacktrace fib ""))) (put (ctx :env) :error-trace nil)
(cond
(os/getenv "JOLT_DEBUG")
(if stashed (eprin stashed) (when fib (debug/stacktrace fib "")))
stashed (print-user-trace stashed)
fib (when fib nil)))
(defn- run-repl [] (defn- run-repl []
(print "Jolt — Clojure on Janet") (print "Jolt — Clojure on Janet")

View file

@ -7,6 +7,12 @@
(os/proc-wait p) (os/proc-wait p)
(string (or out ""))) (string (or out "")))
(defn- run-err [& args]
(def p (os/spawn ["janet" "src/jolt/main.janet" ;args] :p {:out :pipe :err :pipe}))
(def err (:read (p :err) :all))
(os/proc-wait p)
(string (or err "")))
(var fails 0) (var fails 0)
(defn check [label got pred] (defn check [label got pred]
(if (pred got) (print " ok " label) (if (pred got) (print " ok " label)
@ -34,3 +40,28 @@
(if (> fails 0) (if (> fails 0)
(error (string "cli-test: " fails " failing check(s)")) (error (string "cli-test: " fails " failing check(s)"))
(print "\nAll CLI tests passed!")) (print "\nAll CLI tests passed!"))
# --- user-facing error output (jolt-2o7 rounds 1+2) --------------------------
# Messages are Clojure-shaped; traces show the USER'S fns (compiled fn names
# carry ns/fn-name, demangled by report-error) and never jolt internals.
(check "arith error message rewritten"
(run-err "-e" `(+ 1 "a")`)
(has `Cannot add 1 and "a"`))
(check "arity error names the fn"
(run-err "-e" "(defn afn [x] x) (afn 1 2)")
(has "Wrong number of args (2) passed to: user/afn"))
(check "nil-call hints at undefined symbol"
(run-err "-e" "(undefined-fn 1)")
(has "undefined (misspelled?) symbol"))
(check "trace shows the user's call chain"
(run-err "-e" "(defn inner [x] (let [r (+ x :k)] r)) (defn outer [x] (let [v (inner x)] v)) (outer 1)")
(fn [s] (and (string/find "at user/inner" s) (string/find "at user/outer" s))))
(check "no jolt-internal frames in user errors"
(run-err "-e" `(+ 1 "a")`)
(fn [s] (nil? (string/find "src/jolt/" s))))
(check "JOLT_DEBUG restores the raw trace"
(do (os/setenv "JOLT_DEBUG" "1")
(def r (run-err "-e" `(+ 1 "a")`))
(os/setenv "JOLT_DEBUG" nil)
r)
(has "could not find method"))