Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.

Commit 908665a

Browse files
committed
0029: the compiler is the nREPL server, and (def x 5) survives to the next eval
bb repl starts cljwit.repl: the default nrepl middleware stack with "eval" intercepted ahead of interruptible-eval — piggieback's shape, after the review showed a hand-rolled four-op handler hangs CIDER on its first lookup, since clone is session-middleware behavior and unknown ops must answer unknown-op+done. Evals analyze in a persistent session (open-session/analyze-in-session!, a separate API whose docstring bans the corpus from using it — the oracle's isolation depends on analyze-forms' discard), emit 0028's linked form modules, and run in one long-lived node child that owns the runtime instance, the var ledger, and the engine-side assembler (0026). Protocol pins from the review: child stdout is protocol-only, exit on stdin EOF. A def prints #'user/name — a presentation of analysis state; the internal namespace is never shown. The gate now round-trips the whole pipe with a real nREPL client, and 0026's recorded promise lands on its named trigger: binaryen-js is in tools.json with a node-side probe, and bb check materializes node_modules with a guarded npm ci, so CI runs the REPL test instead of inheriting a named hole that two notes pointed at each other about. S2's skeleton is now cljwit.edn + this server; the watcher waits for namespace loading. Entering S4 starts with its survey. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent d2e572a commit 908665a

12 files changed

Lines changed: 493 additions & 26 deletions

File tree

‎bb.edn‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@
4747
check-tools {:doc "Verify the local toolchain against tools.json"
4848
:task (run "bin/check-tools")}
4949

50+
ensure-npm {:doc "Materialize node_modules (the dev-loop assembler, 0026/0029) when absent"
51+
:task (when (and (fs/exists? "package.json")
52+
(not (fs/exists? "node_modules/binaryen")))
53+
(run "npm" "ci" "--no-audit" "--no-fund"))}
54+
55+
repl {:doc "The ClojureWit nREPL server (0029): evals compile to Wasm, run in the engine child"
56+
:task (run "clojure" "-M:repl")}
57+
5058
test {:doc "Unit tests (JVM)"
5159
:task (if (and (fs/exists? "test") (seq (fs/glob "test" "**.clj")))
5260
(run "clojure" "-X:test")
@@ -195,6 +203,6 @@
195203
;; push-time hook stops being a good trade — and this is how that becomes
196204
;; visible, without a rule anyone has to remember to read.
197205
check {:doc "The gate. Everything a change must pass; CI runs exactly this."
198-
:depends [check-tools lint fmt-check reflection test]
206+
:depends [check-tools ensure-npm lint fmt-check reflection test]
199207
:task (println (format "\n✓ gate green (%.1fs)"
200208
(/ (- (System/currentTimeMillis) start-ms) 1000.0)))}}}

‎bin/check-tools‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
"wasm-tools" ["wasm-tools" "--version"]
1313
"wasmtime" ["wasmtime" "--version"]
1414
"binaryen" ["wasm-opt" "--version"]
15-
"node" ["node" "--version"]})
15+
"node" ["node" "--version"]
16+
;; npm-managed, resolved from the repo root's node_modules — the
17+
;; require of a package.json is CJS-safe where importing the ESM
18+
;; entry from -e is not.
19+
"binaryen-js" ["node" "-p" "require('binaryen/package.json').version"]})
1620

1721
(defn version-of [cmd]
1822
(let [{:keys [out err exit]} (apply sh cmd)]

‎deps.edn‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
:deps {org.clojure/clojure {:mvn/version "1.12.4"}
33
;; The S3 front end: tools.analyzer core plus our own host layer
44
;; (doc/design/0022 E decided it, 0023 grows it into src/).
5-
org.clojure/tools.analyzer {:mvn/version "1.2.2"}}
5+
org.clojure/tools.analyzer {:mvn/version "1.2.2"}
6+
;; The S2 entry point: the compiler is itself the nREPL server
7+
;; (doc/design/0029) — the same trade shadow-cljs makes.
8+
nrepl/nrepl {:mvn/version "1.4.0"}
9+
;; The engine-child protocol is JSON lines (0029).
10+
org.clojure/data.json {:mvn/version "2.5.1"}}
611

712
:aliases
813
{;; --- development -------------------------------------------------------
@@ -23,6 +28,10 @@
2328
;; session as the Wasm lanes for the comparison to mean anything.
2429
:bench {:extra-paths ["bench"]}
2530

31+
;; `clj -M:repl` — the ClojureWit REPL server (doc/design/0029):
32+
;; evals compile to Wasm and run in the engine child, not on the JVM.
33+
:repl {:main-opts ["-m" "cljwit.repl"]}
34+
2635
;; `clj -M:nrepl` — editor connection (CIDER / Calva / any nREPL client)
2736
:nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.4.0"}
2837
cider/cider-nrepl {:mvn/version "0.57.0"}}

‎dev/repl_engine.mjs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// The REPL's engine child (doc/design/0029): one long-lived node
2+
// process holding one session — runtime instance, var ledger, and the
3+
// engine-side assembler (0026: binaryen.js, explicit features, never
4+
// Features.All). Speaks one JSON object per line on stdio:
5+
//
6+
// in: {"op":"init","runtime":"<wat>"}
7+
// {"op":"eval","wat":"<wat>","statement":true|false}
8+
// out: {"tag":"ok"} (init)
9+
// {"tag":"result","value":"<printed>"} (expression form)
10+
// {"tag":"done"} (statement form, e.g. def)
11+
// {"tag":"exn","class":<n>} (uncaught Clojure throw)
12+
// {"tag":"trap","message":"<engine>"} (native trap)
13+
// {"tag":"error","message":"..."} (broken pipe-side invariant)
14+
//
15+
// corpus/session.mjs is this loop unrolled for one batch; keep their
16+
// classification identical.
17+
18+
import { createInterface } from "node:readline";
19+
import binaryen from "binaryen";
20+
21+
const F = binaryen.Features;
22+
const FEATS = F.GC | F.ReferenceTypes | F.TailCall | F.ExceptionHandling |
23+
F.BulkMemory | F.Multivalue | F.SignExt | F.NontrappingFPToInt |
24+
F.MutableGlobals;
25+
26+
const assemble = (wat) => {
27+
const mod = binaryen.parseText(wat);
28+
mod.setFeatures(FEATS);
29+
const bin = mod.emitBinary();
30+
mod.dispose();
31+
return bin;
32+
};
33+
34+
let rt = null;
35+
const vars = {};
36+
37+
const reply = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
38+
39+
const handle = (msg) => {
40+
if (msg.op === "init") {
41+
rt = new WebAssembly.Instance(new WebAssembly.Module(assemble(msg.runtime)), {});
42+
return { tag: "ok" };
43+
}
44+
if (msg.op !== "eval") return { tag: "error", message: `unknown op ${msg.op}` };
45+
if (!rt) return { tag: "error", message: "eval before init" };
46+
const inst = new WebAssembly.Instance(
47+
new WebAssembly.Module(assemble(msg.wat)),
48+
{ rt: rt.exports, vars },
49+
);
50+
for (const [name, val] of Object.entries(inst.exports)) {
51+
if (name === "entry") continue;
52+
// 0028 §2: the first definer owns the canonical global forever; a
53+
// duplicate export is a compiler bug made loud.
54+
if (name in vars) return { tag: "error", message: `duplicate var export: ${name}` };
55+
vars[name] = val;
56+
}
57+
try {
58+
const v = inst.exports.entry();
59+
return msg.statement ? { tag: "done" } : { tag: "result", value: String(v) };
60+
} catch (e) {
61+
const tag = rt.exports["clj-exn"];
62+
if (e instanceof WebAssembly.Exception && e.is(tag))
63+
return { tag: "exn", class: Number(rt.exports.exn_class(e.getArg(tag, 0))) };
64+
if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
65+
return { tag: "trap", message: e.message };
66+
throw e;
67+
}
68+
};
69+
70+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
71+
rl.on("line", (line) => {
72+
if (!line.trim()) return;
73+
let out;
74+
try {
75+
out = handle(JSON.parse(line));
76+
} catch (e) {
77+
out = { tag: "error", message: String(e && e.message ? e.message : e) };
78+
}
79+
reply(out);
80+
});
81+
rl.on("close", () => process.exit(0));

‎doc/design/0026-dev-loop-assembler.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ TeaVM's WasmGC binary writer. This note examines them and decides.
122122
part of "done" for emitted-grammar changes.
123123
- **The version skew biting**: npm binaryen.js and the flake's binaryen
124124
are different builds of one project (131 vs 129 today). `tools.json`
125-
gains the dev assembler's version when the nREPL unit lands; until
126-
then the committed differential is also the skew watcher — the same
127-
interim rule, named once *(review)*.
125+
gains the dev assembler's version when the nREPL unit lands *(done —
126+
`0029`'s commit added `binaryen-js` to `tools.json` with a node-side
127+
probe, and `bb check` materializes `node_modules` via a guarded
128+
`npm ci`)*; the committed differential remains the behavioural skew
129+
watcher.
128130
- **The margin is a function of the runtime preamble** *(review)*:
129131
every per-form module re-parses the whole preamble, and assembly cost
130132
is linear in it — a 100 KB core-library preamble puts the per-form

‎doc/design/0029-nrepl-entry.md‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# 0029 — The nREPL entry point: the compiler is the server, the engine is a child
2+
3+
**Status:** proposed · 2026-07-30 · adversarially reviewed the same day,
4+
before implementation. The review inspected the actual nrepl 1.4.0
5+
stack and changed five things, marked *(review)*: the handler is the
6+
default middleware stack with eval intercepted, not four hand-rolled
7+
ops; the session analysis API is a separate pair the corpus never
8+
imports; the child protocol's two framing pins; the `def` printing
9+
decision (a stable `user` presentation); and §6's hole is closed in
10+
this unit's own commit, because `0026`'s recorded trigger for closing
11+
it *is* this unit.
12+
13+
## The question
14+
15+
S2's remaining shape (`doc/roadmap.md`): the compiler runs on the JVM
16+
and *is itself* the nREPL server, so CIDER/Calva connect with no extra
17+
machinery. Every prerequisite is now decided — the assembler sits next
18+
to the engine (`0026`), forms link over one runtime (`0028`), throws
19+
classify (`0027`) — but nothing serves the protocol. What does the v0
20+
server do on `eval`, who holds which half of the session, and what is
21+
honestly out of scope?
22+
23+
## The decision
24+
25+
1. **One JVM process, `cljwit.repl`, serving nREPL as the default
26+
middleware stack with `eval` intercepted** *(review — the first
27+
draft said "a minimal clone/describe/eval/close handler", which
28+
hand-rolls exactly the parts that break: `clone` is session
29+
middleware behavior, not an op you implement, and a handler that
30+
does not answer unknown ops with `unknown-op`+`done` hangs CIDER on
31+
its first `lookup`)*. A `wrap-cljwit-eval` middleware declares it
32+
handles `"eval"` ahead of `interruptible-eval` — piggieback's exact
33+
shape — so session bookkeeping, `describe`, and unknown-op
34+
semantics come from `nrepl/nrepl` protocol-correct and for free.
35+
`clj -M:repl` starts it; the `-X` coordinate `0021` called
36+
provisional is superseded by this. Verified with the nrepl client;
37+
an editor connection is a manual check until someone performs it
38+
*(review)*.
39+
2. **One long-lived node child, `dev/repl_engine.mjs`** — started by
40+
the server, speaking one JSON line per message on stdio, strict
41+
request→response alternation. Two framing pins *(review)*: **the
42+
child's stdout carries protocol lines exclusively** — diagnostics go
43+
to stderr, which the server inherits — and **the child exits on
44+
stdin EOF**, which is also the whole orphaning story when the JVM
45+
dies. It loads binaryen.js once (~170 ms, `0026`'s measured session
46+
cost), instantiates the session runtime once, holds the var ledger
47+
(`0028`'s instantiator half), and per form: assemble (explicit
48+
features, never `Features.All`), instantiate, merge exports —
49+
rejecting duplicate var exports loudly — call `entry`, answer
50+
`{"tag":"result"|"exn"|"trap"|"error", …}` in the corpus vocabulary.
51+
Crash recovery beyond EOF-exit is a named v0 hole.
52+
3. **The compiler half of the session lives in a separate session API**
53+
*(review)*: `open-session`/`analyze-in-session!`/`close-session!`
54+
keep one analysis namespace alive across eval messages, so form 2's
55+
analysis resolves form 1's vars. **`analyze-forms` keeps its
56+
create-and-discard semantics unchanged, and the corpus lanes use
57+
only it** — the oracle's isolation rule (`0022` B.2) depends on the
58+
discard, and routing corpus entries through the session API would
59+
be the analysis-side version of the leak `0028`'s ledger makes
60+
loud. The contract is in both docstrings.
61+
4. **What the REPL prints, v0**: an expression's value is a scalar and
62+
prints as its number (`0022` B.3's boundary, surfaced honestly); a
63+
`def` prints **`#'user/name`** — a *presentation* *(review)*: the
64+
session's analysis namespace is internal and gensym'd, the user
65+
never sees it, and the printed var is analysis state, not a JVM var
66+
anyone can deref. Namespaces are global across nREPL sessions,
67+
Clojure's own answer and the cheap parity choice. A classified
68+
`exn`/`trap` prints as an error with the trap-table row's class
69+
name. Anything the slice cannot express is the loud out-of-slice
70+
compile error it already is.
71+
5. **Scope, named**: no watcher, no browser transport, no interrupt, no
72+
pretty-printing, no wasmtime session lane (deferred in `0028` §5).
73+
The engine child is node-only v0; the browser session reuses the
74+
same protocol the day a transport exists for it.
75+
6. **The dev assembler enters the toolchain in this unit's commit**
76+
*(review — `0026` recorded exactly this trigger: "`tools.json`
77+
gains the dev assembler's version when the nREPL unit lands", and
78+
this is that unit; deferring again would be two notes pointing at
79+
each other forever)*. `tools.json` gains `binaryen-js` with a
80+
node-side probe, and `bb check` materializes `node_modules` with a
81+
guarded `npm ci` when it is absent — a network step only on first
82+
run or lock change, the same class of need as `bb ref`. The
83+
integration test still skips with a printed line if binaryen is
84+
somehow absent (the FFM tests' trade), but on CI it runs: server
85+
in-process, real nREPL client, eval through the whole pipe.
86+
87+
## Why
88+
89+
- The server/child split is `0026`'s decision made operational:
90+
assembly *must* live engine-side for the browser anyway, so the node
91+
child doing its own assembly keeps one code path for both targets.
92+
- The JSON-line protocol is the session runner (`corpus/session.mjs`)
93+
turned resident — same instantiation, same ledger, same
94+
classification, one loop around it.
95+
- nREPL rather than a bespoke socket because the roadmap's whole point
96+
for S2 is that editors connect with zero new machinery.
97+
98+
## Alternatives rejected
99+
100+
- **Evaluating on the JVM and cross-compiling later** (a normal Clojure
101+
nREPL now, wasm someday). Rejected because it is not this project: a
102+
REPL whose semantics are the JVM's is `0002`'s incident waiting to
103+
recur at the DX layer — every difference would be invisible until
104+
batch compilation.
105+
- **piggieback middleware inside a host JVM REPL.** Right shape for
106+
nesting, heavier dependency surface, and nothing nests yet; the
107+
minimal handler serves the same editors today and does not preclude
108+
it.
109+
- **JVM-side assembly (wasm-tools spawn) for the v0 child.** Works
110+
everywhere the gate runs, but contradicts `0026`'s decided placement
111+
and forks the code path the browser target needs; the 23 ms spawn is
112+
also above the budget `0026` bought for 1.3 ms.
113+
- **A bespoke wire protocol to the editor.** nREPL exists; the child's
114+
JSON lines are internal plumbing, not an editor surface.
115+
116+
## What would falsify this
117+
118+
- **An editor round-trip measurably dominated by something other than
119+
the measured pieces** (analysis, 1.3 ms assembly, instantiation) —
120+
the v0 makes no latency claim, but the first complaint gets measured
121+
against `0026`'s numbers before anything is redesigned.
122+
- **Session growth**: hundreds of forms → hundreds of live instances
123+
and an ever-longer ledger (`0028`'s named soak question). The nREPL
124+
server is where a soak test becomes runnable at all.
125+
- **The CI hole biting** — a protocol regression landing while CI
126+
cannot run the pipe. If that happens once, the assembler's
127+
`tools.json` entry is due immediately, not eventually.

‎doc/status.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,23 @@ _Short by design, and printed at every session start — so findings live in
8080
cross-form fn values, so linked K is a session constant, 20 —
8181
Clojure's own ceiling (`0028` §2a, `0024` amended). The wasmtime
8282
linked lane is deferred on cost, not capability (`--preload` works;
83-
recorded). Next unit: **S2's nREPL entry point** — the shadow-cljs
84-
shape from `doc/roadmap.md`, every prerequisite now decided and
85-
the session runner already the shape the server drives.
83+
recorded). **S2's nREPL entry point is landed** (`0029`): `bb repl`
84+
starts a JVM server — the default nrepl stack with eval
85+
intercepted, piggieback's shape — whose evals analyze in a
86+
persistent session, emit linked form modules, and run in a
87+
long-lived node engine child holding the runtime and the var
88+
ledger. A real client round-trips the whole pipe in the gate:
89+
`(def x 5)` then `(+ x 37)` → 42 across evals, `(defn fib …)` and
90+
`(fib 20)` → 6765, fn redefinition takes effect, overflow and traps
91+
classify as errors, out-of-slice stays loud. The dev assembler
92+
entered `tools.json` and `bb check` materializes it (guarded
93+
`npm ci`) — `0026`'s promise kept on its named trigger. An editor
94+
connection (CIDER/Calva) remains a manual check. Next: **entering
95+
S4 starts with its survey** (`/next`'s stage rule; `0007` sized the
96+
stage — lift/lower over linear memory, `cabi_realloc`, resource
97+
tables — and `/survey`'s Kotlin sample is the closest prior art);
98+
S2's watcher waits for namespace loading, post-S3 compiler work
99+
either way.
86100

87101
Done since the last update: `0016` `own<T>` handles, `0017` host imports
88102
(A–F), `0018` host-defined resources, `0012`'s `ex-data` contract shrunk to

‎src/cljwit/analyze.clj‎

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,57 @@
4040
form))))
4141
form))
4242

43+
(defn- fresh-genv [ns-sym]
44+
(atom {:namespaces
45+
{ns-sym {:mappings (ns-map 'clojure.core)
46+
:aliases {} :ns ns-sym}
47+
'clojure.core {:mappings (ns-map 'clojure.core)
48+
:aliases {} :ns 'clojure.core}}}))
49+
50+
(defmacro ^:private with-host [& body]
51+
`(binding [ana/macroexpand-1 macroexpand-1
52+
ana/create-var (fn [sym# var-env#]
53+
(doto (intern (:ns var-env#) sym#)
54+
(reset-meta! (meta sym#))))
55+
ana/parse ana/-parse
56+
ana/var? var?]
57+
~@body))
58+
59+
(defn open-session
60+
"A persistent analysis session (`0029`): one kept namespace — internal
61+
and gensym'd, presented to the user as `user` — so a later form's
62+
analysis resolves an earlier form's vars. The corpus lanes must NOT
63+
use this: the oracle's isolation rule (`0022` B.2) depends on
64+
`analyze-forms`' per-call discard, which is unchanged."
65+
[]
66+
(let [ns-sym (gensym "cljwit.session")]
67+
(create-ns ns-sym)
68+
{:ns ns-sym :genv (fresh-genv ns-sym)}))
69+
70+
(defn analyze-in-session!
71+
"Analyzes one form in the session's kept environment; returns its AST."
72+
[{:keys [ns genv]} form]
73+
(with-host
74+
(with-env genv
75+
(run-passes (ana/analyze form {:context :ctx/expr :locals {} :ns ns})))))
76+
77+
(defn close-session!
78+
"Discards the session's analysis namespace."
79+
[{:keys [ns]}]
80+
(remove-ns ns))
81+
4382
(defn analyze-forms
4483
"Analyzes top-level `forms` in order as one program — later forms see
45-
earlier `def`s. Returns a vector of ASTs."
84+
earlier `def`s. Returns a vector of ASTs. The namespace is created
85+
and discarded per call — the corpus oracle's isolation rule
86+
(`0022` B.2); sessions use `open-session` instead."
4687
[forms]
4788
(let [ns-sym (gensym "cljwit.analysis")]
4889
(create-ns ns-sym)
4990
(try
5091
(let [env {:context :ctx/expr :locals {} :ns ns-sym}
51-
genv (atom {:namespaces
52-
{ns-sym {:mappings (ns-map 'clojure.core)
53-
:aliases {} :ns ns-sym}
54-
'clojure.core {:mappings (ns-map 'clojure.core)
55-
:aliases {} :ns 'clojure.core}}})]
56-
(binding [ana/macroexpand-1 macroexpand-1
57-
ana/create-var (fn [sym var-env]
58-
(doto (intern (:ns var-env) sym)
59-
(reset-meta! (meta sym))))
60-
ana/parse ana/-parse
61-
ana/var? var?]
92+
genv (fresh-genv ns-sym)]
93+
(with-host
6294
(with-env genv
6395
(mapv #(run-passes (ana/analyze % env)) forms))))
6496
(finally (remove-ns ns-sym)))))

0 commit comments

Comments
 (0)