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

Commit 8f716cd

Browse files
committed
0022 E decided the same day it deferred: tools.analyzer, spike run in-repo
The survey the deferral named ran against the clones, not recall. tools.analyzer's core is 827 maintained lines whose host contract is four dynamic vars, whose -parse already handles every S3 form with the arity and def-ordering rules D requires, and whose pass scheduler is where closure conversion and tail marking want to live. Its JVM host layer, read as the worked example, is 58% reflection machinery a Wasm target does not have. The measured surprise is the macro layer: with :inline off, clojure.core's own macros expand S3's forms into pure special forms and core-var calls -- the leak table is two entries (map destructuring's kwargs branch, chunked doseq) -- so the cljs fork's day-one cost of owning destructure/let/loop/fn/defn buys nothing here, and the host ops double as mechanical leak detectors. bb spike-analyzer commits the executable half: (defn fib ...) through bare tools.analyzer 1.2.2, zero host ops, recursion resolved. TeaVM, read as the third WasmGC emitter, independently lands on 0004's three-loads-plus-call_ref vtable shape. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 1612520 commit 8f716cd

5 files changed

Lines changed: 160 additions & 17 deletions

File tree

‎bb.edn‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@
146146
"-o" "target/imp.component.wasm")
147147
(run "clojure" "-M:dev" "-m" "cljwit.spike.host-import"))}
148148

149+
spike-analyzer {:doc "Can bare tools.analyzer analyze S3's forms without host-op leaks? See 0022 E."
150+
:task (run "clojure" "-M:dev" "-m" "cljwit.spike.analyzer")}
151+
149152
spike-reflect {:doc "Can a component describe itself at run time? See 0014."
150153
:task (do (babashka.fs/create-dirs "target")
151154
(run "wasm-tools" "parse" "dev/resources/echo.wat" "-o" "target/echo.core.wasm")

‎deps.edn‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
{;; --- development -------------------------------------------------------
66
:dev {:extra-paths ["test" "dev"]
77
:extra-deps {io.github.cognitect-labs/test-runner
8-
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}}
8+
{:git/tag "v0.5.1" :git/sha "dfb30dd"}
9+
;; bb spike-analyzer (0022 E): the S3 front-end survey's
10+
;; executable half.
11+
org.clojure/tools.analyzer {:mvn/version "1.2.2"}}}
912

1013
;; `clj -M:test` — the unit lane
1114
:test {:extra-paths ["test"]

‎dev/cljwit/spike/analyzer.clj‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
(ns cljwit.spike.analyzer
2+
"Can bare tools.analyzer, with a minimal host layer, analyze S3's forms
3+
without leaking JVM host ops? (`0022` E's survey ran this and the answer
4+
decided the front end.)
5+
6+
The host layer is four dynamic vars, modeled on tools.analyzer's own
7+
core-test. The one decision embedded here: `macroexpand-1` applies only
8+
`:macro` vars, never `:inline` — with inlining on, `(+ x 1)` becomes
9+
`(. clojure.lang.Numbers (add x 1))` and `+`-vs-`+'` semantics leave
10+
cljwit's hands. Without it, `+` stays an `:invoke` of `#'clojure.core/+`,
11+
which the emitter maps to the overflow-checking runtime (`0022` C).
12+
13+
Success is a `:def` AST for fib whose distinct op set contains no
14+
`:host-call`/`:host-field`/`:host-interop`/`:maybe-host-form`/`:new` —
15+
the host ops double as leak detectors."
16+
(:refer-clojure :exclude [macroexpand-1])
17+
(:require [clojure.pprint :as pp]
18+
[clojure.tools.analyzer :as ana]
19+
[clojure.tools.analyzer.ast :as ast]
20+
[clojure.tools.analyzer.env :refer [with-env]]
21+
[clojure.tools.analyzer.utils :refer [resolve-sym]]))
22+
23+
(set! *warn-on-reflection* true)
24+
25+
(defn macroexpand-1
26+
"Only `:macro` vars expand; specials and locals shield; no `:inline`."
27+
[form env]
28+
(if (seq? form)
29+
(let [op (first form)]
30+
(if (ana/specials op)
31+
form
32+
(let [v (resolve-sym op env)]
33+
(if (and (not (-> env :locals (get op)))
34+
(:macro (meta v)))
35+
(apply v form env (rest form))
36+
form))))
37+
form))
38+
39+
(def ^:private HOST-OPS #{:host-call :host-field :host-interop :maybe-host-form :new})
40+
41+
(def ^:private fib-form
42+
'(defn fib [n]
43+
(if (< n 2)
44+
n
45+
(+ (fib (- n 1)) (fib (- n 2))))))
46+
47+
(defn -main [& _]
48+
(let [env {:context :ctx/expr :locals {} :ns 'user}
49+
genv (atom {:namespaces
50+
{'user {:mappings (ns-map 'clojure.core)
51+
:aliases {} :ns 'user}
52+
'clojure.core {:mappings (ns-map 'clojure.core)
53+
:aliases {} :ns 'clojure.core}}})
54+
result (binding [ana/macroexpand-1 macroexpand-1
55+
ana/create-var (fn [sym env]
56+
(doto (intern (:ns env) sym)
57+
(reset-meta! (meta sym))))
58+
ana/parse ana/-parse
59+
ana/var? var?]
60+
(with-env genv
61+
(ana/analyze fib-form env)))
62+
ops (sort (distinct (map :op (ast/nodes result))))
63+
leaks (filter HOST-OPS ops)]
64+
(println "top :op =>" (:op result))
65+
(println "via :raw-forms =>" (pr-str (map first (:raw-forms result))))
66+
(println "distinct ops =>" (pr-str ops))
67+
(println "host-op leaks =>" (if (seq leaks) (pr-str leaks) "none"))
68+
(println "recursive fib resolved =>"
69+
(-> result :init :expr :methods first :body :ret :else
70+
:args first :fn :var pr-str))
71+
(when (seq leaks)
72+
(pp/pprint result)
73+
(System/exit 1))))

‎doc/design/0022-s3-compiler-shape.md‎

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
**Status:** proposed · 2026-07-30 · the entry survey for S3, run against the
44
sibling projects' recorded failures, the ClojureScript compiler's source, and
5-
the toolchain as it exists today. Three decisions are evidenced enough to
6-
make; the analyzer choice is not, and saying so is the point.
5+
the toolchain as it exists today. The first draft made three decisions and
6+
deferred the analyzer to a survey; the survey ran the same day (E, amended)
7+
and decided it — tools.analyzer core plus our own host layer, validated by
8+
`bb spike-analyzer`. An adversarial review of the whole note is in flight;
9+
`proposed` until it lands.
710

811
## The question
912

@@ -111,9 +114,63 @@ baseline.
111114
builds an open S3 decision (`0010`). The coverage report is a
112115
deliverable: it is the number S0's verdict rests on.
113116

114-
### E. Deferred, with its survey named: the analyzer
115-
116-
The front end is the load-bearing choice and the evidence is not in yet.
117+
### E. The analyzer: tools.analyzer core, plus our own host layer
118+
119+
**Amended 2026-07-30, same day: the survey ran, and it decides.** Everything
120+
below the rule is the original deferral, kept because its reasoning shaped
121+
the survey. What the survey found (all read from source or executed;
122+
`bb spike-analyzer` is the committed, re-runnable half):
123+
124+
- **tools.analyzer core is 827 lines, maintained (4466d93, 2026-02-15), and
125+
host-agnostic by contract**: four dynamic vars (`macroexpand-1`, `parse`,
126+
`create-var`, `var?` — `analyzer.clj:129-148`). Its `-parse` natively
127+
handles every S3 form, already enforces the three JVM arity rules and the
128+
declare-before-init `def` ordering that D requires, and its pass
129+
scheduler (`passes.clj`: `:walk`/`:depends` metadata, same-walk fusion)
130+
is the natural home for closure conversion, tail marking and boxing —
131+
`collect-closed-overs` already exists.
132+
- **The host layer's cost is measured by example**: tools.analyzer.jvm is
133+
3,172 lines, of which **58% is JVM reflection and class resolution that a
134+
Wasm target simply does not have**; ~450 lines are the generic-host
135+
pattern to adapt (ns map, macroexpansion, the Gilardi `do`-unrolling) and
136+
~680 are passes cljwit would write under *any* route.
137+
- **clojure.core's own macros are reusable directly — measured, with a
138+
two-entry leak table.** With `:inline` **off** (the one deliberate
139+
divergence from t.a.jvm's `macroexpand-1`), `defn`, `and`, `or`, `when`,
140+
`->`, `loop` and vector destructuring expand into pure special forms and
141+
core-var calls; `(+ x 1)` stays an `:invoke` of `#'clojure.core/+`, which
142+
keeps `+`-vs-`+'` in cljwit's hands (C). The leaks: map destructuring's
143+
kwargs branch touches `PersistentArrayMap` (needs a shim), and chunked
144+
`doseq` (out of S3 scope). The host ops (`:host-call` etc.) double as
145+
**free leak detectors**: any future macro that expands into interop fails
146+
analysis mechanically.
147+
- **The spike ran, in this repo**: `bb spike-analyzer` analyzes
148+
`(defn fib [n] …)` through bare tools.analyzer 1.2.2 with a ~40-line
149+
host binding — top op `:def`, recursive `fib` resolves mid-parse, and
150+
the distinct op set contains **zero host ops**.
151+
- The cljs fork's real cost is now counted: `core.cljc` defines 166
152+
macros, and the S3-relevant ones it *re-implements* — `destructure`
153+
(~140 lines), `let`, `loop`, `fn`, `defn`, `and`, `or` — are exactly
154+
what the tools.analyzer route defers until a corpus line actually leaks.
155+
TeaVM, read as the third emitter, independently confirms `0004`'s
156+
three-loads-plus-`call_ref` vtable shape
157+
(`WasmGCVirtualCallGenerator.java:56-79`).
158+
159+
**Falsified by:** map-based pseudo-vars failing in passes (the spike
160+
interned real JVM vars; the next spike swaps in a map-returning
161+
`create-var` — the contract allows it, but it was not run); the leak table
162+
growing past shim scale once the corpus passes S3 (`case*`'s JVM hashing,
163+
`binding`, `lazy-seq` — if cljwit ends up owning ~20+ core macros, the
164+
cljs fork's macro layer stops being a differentiator and this reopens); a
165+
macro capturing a non-EDN JVM object into an expansion (mechanically
166+
detectable: a `:const` whose `:val` does not print); the scheduler proving
167+
unable to express the closure-conversion ordering.
168+
169+
---
170+
171+
*The original deferral, for the record:*
172+
173+
The front end is the load-bearing choice and the evidence was not in yet.
117174
What is known (read from source, 2026-07-30, paths in `.ref/clojurescript`):
118175

119176
- **cljs.analyzer is a fork, not a dependency.** The pipeline is
@@ -132,11 +189,9 @@ What is known (read from source, 2026-07-30, paths in `.ref/clojurescript`):
132189
- clj.wasm's author — the one person who tried this before — picked the
133190
cljs side (`.ref/clj.wasm/plan.md`).
134191

135-
**The next unit is that survey**: add `tools.analyzer` (and TeaVM, the
136-
production JVM-hosted WasmGC emitter, reported browser-ready as of late
137-
2025 — secondary source only) to `refs.json`, read both, and decide in an
138-
amendment here. Deciding today on a recalled API would be `0013`'s failure
139-
shape — a claim about the tool, never run.
192+
**The next unit is that survey** — which is the amendment above: read
193+
both, run the spike, decide. Deciding on a recalled API would have been
194+
`0013`'s failure shape — a claim about the tool, never run.
140195

141196
## Alternatives rejected
142197

@@ -145,8 +200,14 @@ shape — a claim about the tool, never run.
145200
a measured optimisation.
146201
- **An opt-in differential oracle.** The sibling measured what opt-in
147202
costs: weeks of silent drift.
148-
- **Deciding the analyzer now.** The strongest candidate's advantages are
149-
recalled rather than read; see E.
203+
- **Deciding the analyzer on recall** (the note's own first draft, for a
204+
few hours). The survey replaced recall with reads and a spike; see E.
205+
- **Forking cljs.analyzer.** Owning `destructure`/`let`/`loop`/`fn`/`defn`
206+
from day one, for parse methods tools.analyzer also has; its 71 `js*`
207+
bottoms in `core.cljc` are the JS target's, not ours.
208+
- **A purpose-built analyzer for the S3 subset.** Re-implements 827 lines
209+
of subtle, maintained validation against the standing prefer-the-
210+
ecosystem constraint.
150211
- **`--closed-world` in dev mode, or anywhere heap-sharing units exist** —
151212
already measured as a trap (`0009`, the `wat` skill).
152213

‎doc/status.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@ _Short by design, and printed at every session start — so findings live in
2020
the watcher — waits for a compiler to serve, so:
2121
3. **S3 is entered** (`0022`, proposed): WAT-text emission through the
2222
pinned toolchain, a CI-mandatory differential oracle against `clojure`
23-
itself, i31-fixnum numerics with a throwing boxed i64. **The analyzer
24-
choice is deliberately deferred** — the next unit is its survey: add
25-
`tools.analyzer` and TeaVM to `refs.json`, read them, amend `0022`.
26-
The sibling failures that shaped all of this are cited in the note.
23+
itself, i31-fixnum numerics with a throwing boxed i64, and — the survey
24+
ran same-day — **tools.analyzer core plus our own host layer** as the
25+
front end, with clojure.core's macros reused directly (`:inline` off, a
26+
two-entry leak table, host ops as free leak detectors).
27+
`bb spike-analyzer` is the executable half. Next unit: fold the in-flight
28+
adversarial review of `0022`, then the first vertical slice — corpus
29+
harness + `(def x 42)` through analyze → emit → both engines.
2730

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

0 commit comments

Comments
 (0)