diff --git a/.dev/debt.yaml b/.dev/debt.yaml
index 547c36fbf..17decb763 100644
--- a/.dev/debt.yaml
+++ b/.dev/debt.yaml
@@ -24,50 +24,6 @@
# md-table-align no longer pads it to ~800 KB of whitespace.
active:
- - id: "D-587"
- status: |-
- open (2026-09-09). USE-AFTER-FREE, user-reachable panic. Re-registering a
- deftype/defrecord name frees a TypeDescriptor that live instances still
- point at. `type_descriptor.zig registerType` keys `rt.types` by the SIMPLE
- name, and on re-registration does `fetchRemove(name)` then frees the old
- descriptor (each field_layout entry name, the layout slice, fqcn,
- defining_ns, method_table entries, protocol_impls, then
- `gpa.destroy(old.value)`). Every constructed instance holds
- `inst.descriptor` as a raw pointer to it, so `print.zig
- printTypedInstance` then reads `field_layout` out of freed memory: the
- symptom is `thread panic: integer overflow` (a garbage field index or
- layout length), the defect is the free.
- Repro (ReleaseSafe v1.14.3): `(ns aaa) (defrecord Point [x y]) (def a
- (->Point 1 2)) (ns bbb) (defrecord Point [x y]) (def b (->Point 1 2))
- (prn (pr-str aaa/a))` panics; printing `bbb/b` is fine. Variants: two ns
- with DIFFERENT names is fine; two ns SAME name panics; SAME ns redefining
- the same name panics (this is the ordinary REPL / `require :reload` path
- `dev/user.clj` depends on); same name with a different field count panics;
- the deftype form raises `WriteFailed` instead, same root.
- TWO separable defects: (1) the registry is keyed by simple name, so
- `aaa/Point` and `bbb/Point` collide with no redefinition at all (clj gives
- each ns its own class); (2) re-registration frees a descriptor live
- instances reference.
- ADR-0019 forbids a panic on a user-reachable path, and this one is reached
- by re-evaluating a defrecord form. It also caps the native-suite migration:
- `test/clj/suites/` shares ONE image, so no two suites may define the same
- type name (worked around 2026-09-09 by renaming defrecord_test's record to
- DPoint; 45 type definitions across 103 suites, no other duplicate today).
- Found while migrating phase7_defrecord.sh to a native suite.
- Card `[CLJW-TD-UAF]` 20260909211032-6471bc49.
- category: "correctness"
- quality_floor: "correctness: a user-reachable panic from an ordinary redefine"
- barrier: |-
- Needs an ADR: the free is deliberate (comments cite row 7.7 cycle 5 and
- D-190 / ADR-0068) and exists to avoid leaking on redefine, so removing it
- trades the leak back. Candidate shapes: key `rt.types` by the qualified
- name when `defining_ns` is present and RETIRE rather than free a replaced
- descriptor (a leak bounded by redefinition count, which is what clj does
- with the old class), or make descriptors GC-managed so a descriptor dies
- only when no instance references it. Devil's-advocate fork is mandatory at
- this depth. Not blocked on anything external: the repro is one file and the
- owning function is ~70 lines.
- last_reviewed: "2026-09-09"
- id: "D-586"
status: |-
open (2026-09-05, ADR-0195 trigger). UPSTREAM: zwasm/D-584. zwasm's JIT
@@ -7583,6 +7539,17 @@ discharged:
refs: |-
src/runtime/collection/literal.zig (the one literal construction); src/eval/analyzer/analyzer.zig (the constant fold); src/eval/backend/vm/compiler.zig and vm.zig (the extend opcodes); docs/spec/formats/chunk-v12.edn (bytecode format v12).
discharged_at: "2026-10-02"
+ - id: "D-587"
+ status: |-
+ DISCHARGED 2026-10-14 by ADR-0198 plus ADR-0201. Both defects the row named are fixed. (1) rt.types is keyed by ., so aaa.Point and bbb.Point are two classes (ADR-0198), and a bare constructor binds the class of the namespace its form was analyzed in ([CLJW-CTOR-ANALYZE-TIME]: a ctor site carries definer_ns, bytecode v13), so `aaa/->Point` builds aaa.Point even after bbb registered a namesake. (2) A displaced descriptor is retired, never freed, until Runtime.deinit (ADR-0198; ADR-0201 Part B makes that the one lifetime rule for every user descriptor). ADR-0201 also made membership descriptor identity: instance?, .isInstance, isa? and multimethod dispatch agree with (= (class x) C) for a redefined record, a namesake in another namespace and a reify class. The original repro prints both records and the narrowed redefine on both backends.
+ Was: open (2026-09-09), USE-AFTER-FREE, user-reachable panic. Re-registering a deftype/defrecord name freed a TypeDescriptor that live instances still pointed at, and the registry was keyed by the simple name, so two namespaces defining Point collided with no redefinition at all. Symptom `thread panic: integer overflow` from printTypedInstance, or `WriteFailed` for a deftype.
+ category: "correctness"
+ barrier: |-
+ none, discharged. Pinned by test/clj/suites/type_redefine_test.clj (the six scenarios of the former type_descriptor_redefinition e2e, incl. 3000 redefinitions interleaved with reify under a forced collection and the defining-namespace ctor binding), test/clj/suites/user_class_identity_test.clj, the type_redefinition diff corpus, and the class_name.zig / analyzer.zig unit tests. Still open and separate: rt.types_by_simple still exists for the bare-name paths that are not ctor sites (card 20260909231443-1ba4303c), and D-574 (reify mints a descriptor per evaluation).
+ last_reviewed: "2026-10-14"
+ refs: |-
+ ADR-0198, ADR-0201; src/runtime/type_descriptor.zig (registerType retires), src/runtime/class_name.zig (isInstanceOfClass, classIsaOf, descriptorIsa), src/eval/analyzer/special_forms.zig (constructInstance definer_ns lookup), docs/spec/formats/chunk-v13.edn.
+ discharged_at: "2026-10-14"
conventions: |-
- Category vocabulary is a closed set (guarded by scripts/check_debt_category.sh; synonyms were normalized 2026-10-08: structural->structure, tests/test->testing, numerics->numeric, performance->perf). Allowed: app backend bench bootstrap build clj-parity concurrency correctness debt-hygiene demo discipline distribution docs docs-onboarding ecosystem error-system eval eval-semantics feature gc governance host-surface infra interop isolation license macro meta-reorg missing-ns namespace numeric perf polymorphism process project-flow reader refactor regex robustness runtime scope security structure testing tooling wasm. A new category is added here first, in the same commit as the row that needs it.
- Structure: two top-level lists — `active:` and `discharged:`. Active entries carry id / status / category / barrier (+ optional quality_floor / last_reviewed); discharged entries carry id / discharged_at / resolution. Edit as YAML (no Markdown table to align).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5bc55917..084d6f929 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,51 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+## [1.15.1] - 2026-10-21
+
+### Added
+
+- **`wasm/exports` lists a core module's exports.** `(wasm/exports m)` returns
+ a vector in export order: a function as `{:name "sum_f64" :kind :func
+ :params [:i32 :i32] :results [:f64]}`, a memory, global or table as
+ `{:name "memory" :kind :memory}`. Signatures come from the same runtime probe
+ `wasm/call` uses (ADR-0203).
+
+- **Dot interop on a Wasm module handle.** `(.sum_f64 m buf 4)`,
+ `(. m sum_f64 buf 4)` and `(. m (sum_f64 buf 4))` call the export named by
+ the method symbol, verbatim, through `wasm/call`'s own path, so results,
+ fuel and errors are identical. A C function compiled to Wasm is called like a
+ host method (ADR-0203).
+
+### Changed
+
+- **zwasm pinned to the fork's `main` (d74e63b4, upstream main of
+ 2026-10-21).** Upstream now carries the component re-export, wasip2
+ `metadata-hash` and `PreparedWasi` fixes the fork's `cljw` branch existed
+ for.
+
+- **A shorter README** led by calling C from Clojure, with the architecture,
+ size and startup charts kept.
+
+### Fixed
+
+- **Redefining a deftype or defrecord is safe, and a user class is matched by
+ identity, not by name.** Re-registering a type no longer frees a descriptor
+ live instances still use (the `thread panic: integer overflow` on printing
+ an old instance is gone). `aaa.Point` and `bbb.Point` are two classes, and
+ `instance?`, `.isInstance`, class `isa?` and multimethod dispatch agree with
+ `(= (class x) C)` for a redefined record, a namesake in another namespace
+ and a reify class. A bare `(Point. x y)` or `->Point` builds the class of the
+ namespace the form was written in, even after another namespace registered a
+ namesake of a different shape. Compiled chunks move to format v13.
+
+- **The VM reports errors at their form.** `set!` on an unbound var, an
+ unresolved or unsupported `.member` call, a static method call, a
+ constructor arity failure, `import` and `require`/`refer` failures raised on
+ the VM with no source position (`0:0`) while TreeWalk reported line and
+ column. Both backends now report the same position, and the dual-backend
+ test oracle checks it for every failing case.
+
## [1.15.0] - 2026-10-13
### Added
diff --git a/README.md b/README.md
index 144c7d29e..dd7e248a4 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
-
+
+
+
ClojureWasm
@@ -13,133 +15,97 @@
-
-ClojureWasm (`cljw`) runs Clojure without a JVM. It is written in Zig and
-Clojure, ships as one static binary, starts in milliseconds, and embeds a
-WebAssembly engine so a `.wasm` compiled from Rust, Go, C or Zig can be
-required like a namespace and called like a function, inside a sandbox whose
-fuel and memory the caller sets.
-
-## News
-
-**2026-10: the project has a new maintainer.**
-[@chaploud](https://github.com/chaploud), who created ClojureWasm and released
-it through v1.10.1, has handed the [clojurewasm](https://github.com/clojurewasm)
-organization over to [@BuddhiLW](https://github.com/BuddhiLW). The fork's work
-(v1.11 through v1.15.0) is merged back into
-[clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm), so the
-original repository is active again. Releases and the Homebrew tap keep
-publishing from [BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm)
-for now; existing install commands and version pins keep working.
-
-[Issues](https://github.com/BuddhiLW/ClojureWasm/issues) and PRs are open, and
-the most useful report is Clojure code that behaves differently here than on
-the JVM (there is an issue template for it).
-
-## Where it shines
-
-**Sandboxed plugins and tools.** A module instantiates with an empty import
-object, so a guest reaches nothing the host did not grant, and every load takes
-a budget. A tool that spins burns its fuel and returns an error; the host
-process keeps running. That makes `cljw` a fit for hosts where untrusted code
-runs in-process: plugin systems, MCP tool servers, request handlers that
-evaluate user code.
+
+ Install ·
+ Call C from Clojure ·
+ How it works ·
+ Numbers ·
+ Where it fits ·
+ What runs ·
+ Docs
+
-```clojure
-(def zk (wasm/load-component "zk_groth16.wasm" {:fuel 5000000 :max-memory-pages 64}))
-(wasm/component-call zk "verify" proof 35) ;=> [:ok true]
-(wasm/component-call zk "spin") ; throws: exhausted its fuel budget
-```
+`cljw` runs Clojure without a JVM. It ships as one static binary, starts in
+milliseconds, and embeds a WebAssembly engine: a `.wasm` built from C, Rust,
+Go or Zig loads like a library and runs inside a sandbox whose fuel and memory
+you set.
-**Other languages' libraries, from Clojure.** JVM Clojure calls Java;
-ClojureScript calls JavaScript; here the host is a language-neutral `.wasm`.
-A WIT-typed component is a namespace: records arrive as maps, lists as
-vectors, `result` as `[:ok v]` / `[:err e]`.
+## Install
-```clojure
-(ns my.app
- (:require ["./typed_payload.wasm" :as tp])) ; Rust + wit-bindgen, 51 KB
+```sh
+brew install buddhilw/tap/cljw # macOS arm64, Linux x86_64
+zig build -Dwasm -Doptimize=ReleaseSafe # or from source (Zig 0.16)
+```
-(tp/process {:xs [3 4 5] :label "data"}) ;; => [:ok {:xs [3 4 5 12], :label "data!"}]
-(tp/process {:xs [] :label "fail"}) ;; => [:err "boom: fail"]
-(:arglists (meta #'tp/process)) ;; => ([input])
+Prebuilt binaries are on [Releases](https://github.com/BuddhiLW/ClojureWasm/releases).
+They are not code-signed; on macOS, `xattr -d com.apple.quarantine "$(which cljw)"`
+once.
+
+```sh
+cljw -e '(->> (range) (filter even?) (take 5))' # (0 2 4 6 8)
+cljw script.clj # run a file
+cljw # REPL
+cljw nrepl --port 7888 # nREPL, CIDER-compatible
+cljw build script.clj -o app # self-contained native binary
```
-Each guest language has a natural shape, and cljw has an entry point for each:
+## Call C from Clojure
-| guest | built with | crosses the boundary as | entry point |
-|---|---|---|---|
-| C | `zig cc -target wasm32-freestanding` | scalars, arrays in a guest-owned buffer | `wasm/load`, `wasm/call`, `wasm/mem-*` |
-| Zig | `zig build-exe -target wasm32-freestanding` | scalars | `wasm/load`, `wasm/call` |
-| Rust, `no_std` | `rustc --target wasm32-unknown-unknown` | scalars | `wasm/load`, `wasm/call` |
-| Rust, wit-bindgen | `cargo build --target wasm32-wasip2` | records, lists, strings, results | `(:require ["x.wasm" :as x])` |
-| Go | `GOOS=wasip1 GOARCH=wasm go build` | argv, env, stdin, stdout, exit code | `wasm/run` |
+A C function, compiled to Wasm, called with ordinary Clojure interop.
-All five run in [`docs/examples/polyglot`](./docs/examples/polyglot/README.md),
-sources and build lines included; CI rebuilds them from source and runs them on
-every push.
+```c
+// docs/examples/polyglot/c/kernel.c
+EXPORT("sum_f64") double sum_f64(int off, int n) {
+ const double *p = (const double *)(long)off;
+ double s = 0.0;
+ for (int i = 0; i < n; i++) s += p[i];
+ return s;
+}
+```
-**Native tools, unchanged.** When the tool already exists as a native binary,
-nothing needs recompiling to Wasm and nothing needs bindings: its seam is its
-command line. `clojure.java.shell/sh` runs any program by argv (never through a
-shell) and returns `{:exit :out :err}`, with a non-zero exit as data. The same
-call runs on the JVM and on clojurust, so the client that wraps the tool is one
-`.cljc`.
+```sh
+zig cc -target wasm32-freestanding -O2 -nostdlib \
+ -Wl,--no-entry -Wl,--export-memory -Wl,-z,stack-size=65536 kernel.c -o kernel.wasm
+```
```clojure
-(require '[clojure.java.shell :refer [sh]])
-(sh "autopdf" "build" "invoice.tex" "invoice.json" "clean" :dir "examples")
-;; => {:exit 0, :out "...", :err "..."} ; a Go binary, driven as is
-```
+(def k (wasm/load "docs/examples/polyglot/c/kernel.wasm" {:fuel 1000000 :max-memory-pages 16}))
-[AutoPDF's Clojure client](https://github.com/BuddhiLW/AutoPDF/tree/main/clients/clojure)
-drives the unmodified Go `autopdf` CLI this way and renders the same PDF,
-byte for byte, on cljw, clojurust and the JVM. A child process escapes every
-in-process containment, so `sh` is refused under a filesystem root
-(`CLJW_FS_ROOT`) or inside `cljw.eval/with-budget`: pick Wasm when the guest
-must be confined, the process seam when the tool is trusted.
-
-**Short-lived processes.** 8.1 MB on disk, about 6 ms from process start to
-first eval, and `cljw build app.clj -o app` produces a self-contained
-executable. CLI tools, serverless handlers, scripts that run a thousand times
-a day.
-
-**One program, two runtimes.** `cljw` reads `.cljc` under the feature set
-`{:cljw :clj :default}`, and its sibling [clojurust](https://github.com/BuddhiLW/clojurust)
-(`cljrs`, Rust-hosted, Cranelift JIT) reads `{:rust}`. A host-free `.cljc`
-runs unchanged on the JVM, on `cljw` and on `cljrs`
-([`hosts.cljc`](./docs/examples/polyglot/hosts.cljc) prints the same map on
-all three, ratios and bigints included, and CI checks that), and the same Rust
-crate can serve both: as a zero-import Wasm component on `cljw`, as a native
-`cdylib` on `cljrs`. The program picks confinement or speed without a rewrite.
+(mapv :name (wasm/exports k)) ;=> ["memory" "buf_addr" "buf_cap" "sum_f64" "scale_f64"]
-```mermaid
-flowchart LR
- P["demo.cljc
one Clojure program"] --> A["cljw
Wasm component, zero imports"]
- P --> B["cljrs
native cdylib"]
- A --> L[("arkworks Groth16
the same Rust crate")]
- B --> L
+(def buf (.buf_addr k))
+(wasm/mem-write! k :f64 buf [1.0 2.0 3.0 4.0])
+(.sum_f64 k buf 4) ;=> 10.0
```
-Measured on that exact shape (a Groth16 prover over BN254, 2026-09-12, same
-crate and feature set on both sides):
+`wasm/exports` and the dot form need v1.15.1 or later; `(wasm/call k "sum_f64"
+buf 4)` works on every version. A guest fault is a catchable
+exception, never a host crash, and a guest that runs out of fuel stops with an
+error while your process keeps going.
+
+A WIT component goes further: it is required like a namespace, and records,
+lists and `result` cross as plain Clojure data.
+
+```clojure
+(ns my.app (:require ["./typed_payload.wasm" :as tp])) ; Rust + wit-bindgen
-| | cljrs (native) | cljw (Wasm) |
+(tp/process {:xs [3 4 5] :label "data"}) ;=> [:ok {:xs [3 4 5 12], :label "data!"}]
+(tp/process {:xs [] :label "fail"}) ;=> [:err "boom: fail"]
+```
+
+| guest | crosses the boundary as | entry point |
|---|---|---|
-| proof | 128 bytes | 128 bytes |
-| verify, correct / wrong statement | true / false | true / false |
-| wall time | 1.29 s (debug build) | 9.17 s |
-| env vars visible to the prover | 212 | 0 |
-| file read | granted | denied |
-| wall clock | granted | trap |
+| C, Zig, Rust `no_std` | scalars, arrays in guest memory | `wasm/load`, `wasm/call`, `(.f m args)`, `wasm/mem-*` |
+| Rust + wit-bindgen | records, lists, strings, results | `(:require ["x.wasm" :as x])` |
+| Go (`wasip1`) | argv, env, stdin, stdout, exit code | `wasm/run` |
-Same proof bytes; the only thing that differs is how much authority the prover
-holds while it runs.
+All of them build and run in [`docs/examples/polyglot`](./docs/examples/polyglot/README.md),
+rebuilt from source by CI on every push.
-## How it is put together
+## How it works
```mermaid
flowchart LR
@@ -149,77 +115,15 @@ flowchart LR
TW --> RT["runtime: NaN-boxed values, mark-sweep GC,
persistent collections, STM, agents, nREPL"]
VM --> RT
RT --> Z["zwasm engine
interpreter + JIT, fuel and memory budgets"]
- Z --> W[".wasm modules and WIT components
from Rust, Go, C, Zig"]
+ Z --> W[".wasm modules and WIT components
from C, Rust, Go, Zig"]
```
-Both backends evaluate the same analyzer output, and the test suite runs every
-case on both and fails on a mismatch. That differential oracle is how a
-from-scratch runtime keeps Clojure semantics honest without a reference
-implementation in the process. [`docs/architecture.md`](./docs/architecture.md)
+Two backends evaluate the same analyzer output, and every test runs on both:
+a mismatch fails the build. [`docs/architecture.md`](./docs/architecture.md)
has the map.
-## Install
-
-```sh
-brew install buddhilw/tap/cljw # macOS arm64, Linux x86_64
-```
-
-Binaries are also on the [Releases](https://github.com/BuddhiLW/ClojureWasm/releases)
-page. The binary is not code-signed; if macOS blocks it once, clear the
-quarantine flag: `xattr -d com.apple.quarantine "$(which cljw)"`.
-
-From source (Zig 0.16; `direnv allow` or `nix develop` provides it):
-
-```sh
-zig build -Dwasm -Doptimize=ReleaseSafe # → ./zig-out/bin/cljw
-```
-
-## Quickstart
-
-```sh
-cljw -e '(->> (range) (filter even?) (take 5))' # (0 2 4 6 8)
-cljw -e '(wasm/call (wasm/load "docs/examples/wasm/add.wasm") "add" 40 2)' # 42
-cljw script.clj # run a file
-cljw # REPL
-cljw nrepl --port 7888 # nREPL, CIDER-compatible
-cljw build script.clj -o app # one self-contained native binary
-```
-
-## The Wasm FFI in one screen
-
-```clojure
-;; A core module (docs/examples/polyglot/c/kernel.c): numbers in, numbers
-;; out, arrays in a buffer the guest owns and hands out by address.
-(def m (wasm/load "docs/examples/polyglot/c/kernel.wasm" {:fuel 1000000 :max-memory-pages 16}))
-(def buf (wasm/call m "buf_addr"))
-(wasm/mem-write! m :f64 buf [1.0 2.0 3.0 4.0])
-(wasm/call m "sum_f64" buf 4) ;=> 10.0
-(wasm/mem-read m :f64 buf 4) ;=> [1.0 2.0 3.0 4.0]
-
-;; A WIT component: typed, so arguments and results are plain Clojure data.
-(def g "test/e2e/fixtures/wasm/greet_component.wasm")
-(wasm/component-exports g) ;=> ({:name "greet", ...})
-(def c (wasm/load-component g {:fuel 100000}))
-(wasm/component-call c "greet" "zwasm") ;=> "Hello, zwasm!"
-
-;; A fault in the guest is a catchable Clojure exception, never a host crash.
-(try (wasm/call (wasm/load "docs/examples/wasm/trap.wasm") "boom")
- (catch Exception e (ex-message e))) ;=> "wasm/call: 'boom' ... trapped ..."
-```
-
-Budgets: a missing axis keeps the engine's finite default (1e9 fuel, 4096
-pages), `0` removes a cap for a trusted guest, and fuel is per instance, so an
-exhausted handle stays exhausted until you load again. The engine is JIT by
-default for modules (`:engine :interp` to force the interpreter); components
-run on the interpreter. Element types for memory access are the guest's
-layout: `:i8 :u8 :i16 :u16 :i32 :u32 :i64 :f32 :f64`.
-
## Numbers
-Every figure below is generated from a committed measurement file under
-[`bench/`](./bench/README.md), not typed by hand, and the release size is
-checked against the built binary by a gate on every push.
-
```mermaid
xychart-beta
title "Binary size, MB (cljw 8.14 MB, others measured 2026-07-16)"
@@ -236,83 +140,82 @@ xychart-beta
bar [1.4, 3.2, 3.2, 10.0, 13.6, 18.8, 28.0, 33.7, 46.4, 231.9]
```
-Persistent collections trade wins with JVM Clojure op by op (20 000 ops at
-n = 16 000, Clojure 1.12.4): `assoc` on a map is 3.9× faster on cljw, `conj`
-on a vector 4.4× slower; `seq` / `rest` / `next` over a vector are O(1) views
-on both and cljw's are about 2× faster. The interpreter is where the JVM wins:
-an empty loop iteration costs ~45 ns here against ~2 ns under the JVM JIT, so
-tight numeric Clojure loops belong in a Wasm guest or on the JVM.
-[Measured, both sides.](./docs/works/collection_performance.md)
+- Persistent collections trade wins with JVM Clojure op by op: map `assoc`
+ 3.9× faster, vector `conj` 4.4× slower ([measured](./docs/works/collection_performance.md)).
+- Tight numeric loops are the JVM's: about 45 ns per empty iteration here
+ against 2 ns under its JIT. Put a hot kernel in a Wasm guest.
+- In a guest, the engine runs 2× to 3× behind wasmtime, and one call costs
+ about 1 µs ([bench](./bench/README.md)).
+
+The release size is checked against the built binary on every push.
+
+## Where it fits
+
+- **Untrusted code in-process.** Plugins, MCP tool servers, handlers that run
+ user code: a module sees only what you pass it, under a fuel and memory budget.
+- **Other languages' libraries.** Reach Rust, C, Go or Zig code through Wasm
+ instead of a JVM.
+- **Existing native tools.** `clojure.java.shell/sh` runs a binary by argv and
+ returns `{:exit :out :err}`, as on the JVM and babashka.
+- **Short-lived processes.** CLIs, scripts and serverless handlers that pay
+ for startup thousands of times a day.
+- **One program, two runtimes.** A host-free `.cljc` runs unchanged on the
+ JVM, on `cljw` and on its Rust-hosted sibling
+ [clojurust](https://github.com/BuddhiLW/clojurust) (`cljrs`).
-Inside a Wasm guest the embedded engine runs 2× to 3× behind wasmtime on
-compute-bound loops (fib: 1.29 s vs 0.49 s, same module, 2026-09-04) and one
-`wasm/call` crossing costs about 1 µs. The FFI is for keeping a hot kernel
-native, not for beating a standalone runtime;
-[the comparison is published as measured](./bench/README.md).
+```mermaid
+flowchart LR
+ P["demo.cljc
one Clojure program"] --> A["cljw
Wasm component, zero imports"]
+ P --> B["cljrs
native cdylib"]
+ A --> L[("arkworks Groth16
the same Rust crate")]
+ B --> L
+```
-## What runs
+| Groth16 over BN254, same crate | cljrs (native) | cljw (Wasm) |
+|---|---|---|
+| proof, verify | 128 bytes, true / false | 128 bytes, true / false |
+| wall time | 1.29 s (debug build) | 9.17 s |
+| env vars / file read / clock | 212 / granted / granted | 0 / denied / trap |
-`clojure.core` and the everyday standard library: `string`, `set`, `walk`,
-`zip`, `edn`, `data.json`, `data.csv`, `math`, `pprint`, `test`, `repl`,
-`template`, `tools.cli`, `spec`. STM, atoms, agents, futures, promises,
-watches, real threads. Lazy and chunked seqs, transducers, protocols, records,
-multimethods, `deftype` / `reify`, the full numeric tower (`Ratio`, `BigInt`,
-`BigDecimal`). A `deps.edn`-aware classpath with `:local/root` and `:git`
-coordinates, and a CIDER-compatible nREPL.
-
-Real libraries that load and run today (medley, tools.cli, data.json,
-core.cache, malli, hiccup, honeysql, test.check, ...) are tracked in
-[`docs/works/ladder.md`](./docs/works/ladder.md).
-
-Maven `:mvn/version` coordinates resolve Clojure source entries in cached JARs
-from `$M2_REPO` (default `~/.m2/repository`). Missing artifacts are downloaded
-from Clojars, then Maven Central via HTTPS (`curl`); extracted source roots are
-cached under `$CLJW_HOME/mvn` (default `~/.cljw/mvn`). Compile/runtime POM
-coordinates are traversed; `org.clojure/clojure` and JARs with no Clojure
-sources are omitted. Remote JARs and POMs must match their repository's SHA-1
-sidecar before installation; a missing or mismatched checksum fails closed.
-`CLJW_MVN_REPOS` overrides the ordered repository bases as comma-separated URLs
-(default Clojars, then Maven Central; `file://` is useful for hermetic tests).
-Artifacts already in `$M2_REPO` are trusted. `curl` and `unzip` are required on the host. POM traversal
-supports direct compile/runtime dependencies with literal versions or local
-properties; it does not implement BOM imports, inherited dependencyManagement,
-classifiers, exclusions, or Maven version mediation. JVM-only jars provide no
-source path (native Java classes remain unavailable). For example:
+Confinement or speed, chosen per deployment, without a rewrite.
-```clojure
-{:deps {metosin/malli {:mvn/version "0.20.2"}}}
-```
+## What runs
+
+`clojure.core` and the everyday library (`string`, `set`, `walk`, `zip`,
+`edn`, `data.json`, `data.csv`, `pprint`, `test`, `spec`, `tools.cli` and more),
+STM, agents, futures, real threads, transducers, protocols, records,
+multimethods, `deftype` / `reify` and the full numeric tower. `deps.edn` with
+`:local/root`, `:git` and `:mvn/version` coordinates; libraries such as malli,
+hiccup, honeysql and test.check load today ([ladder](./docs/works/ladder.md)).
-What does not: `core.async` is not bundled, and JVM
-interop that exists only to reach the JVM (`gen-class`, `proxy` over arbitrary
-classes, reflection, `import` of arbitrary Java classes) is out of scope by
-design. The full picture, including the intentional divergences, is
+Not yet: `core.async`, `java.time.format`, and a strict `clojure.edn` reader.
+JVM-only interop (`gen-class`, `proxy` over arbitrary classes, reflection) is
+out of scope by design. Details:
[`docs/clojure_vs_clojurewasm.md`](./docs/clojure_vs_clojurewasm.md).
## Documentation
-[`docs/`](./docs/README.md) is the index. The pieces most people want:
+[`docs/`](./docs/README.md) ·
+[CHANGELOG](./CHANGELOG.md) ·
+[vs JVM Clojure](./docs/clojure_vs_clojurewasm.md) ·
+[landscape](./docs/landscape.md) ·
+[testing](./docs/testing.md) ·
+[demos](./docs/works/demos.md) ·
+[roadmap](./.dev/ROADMAP.md)
-- [`CHANGELOG.md`](./CHANGELOG.md), every release, newest first.
-- [`docs/clojure_vs_clojurewasm.md`](./docs/clojure_vs_clojurewasm.md), what
- matches JVM Clojure, what deliberately does not, what is missing.
-- [`docs/landscape.md`](./docs/landscape.md), where cljw sits among Clojure
- runtimes, `cljrs` included.
-- [`docs/testing.md`](./docs/testing.md), the eight test layers and where a
- new test belongs. Read it before sending a patch.
-- [`docs/works/demos.md`](./docs/works/demos.md), two real applications
- (a browser playground and a bookshelf with SQLite as `sqlite3.wasm`) whose
- source still builds.
-- [`.dev/ROADMAP.md`](./.dev/ROADMAP.md), the plan.
+Issues and PRs are welcome at
+[BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm/issues); the most
+useful report is Clojure code that behaves differently here than on the JVM.
## License and credit
-Eclipse Public License 2.0, see [LICENSE](./LICENSE) and
-[NOTICE](./legal/NOTICE).
+Eclipse Public License 2.0 ([LICENSE](./LICENSE), [NOTICE](./legal/NOTICE)).
-ClojureWasm was created by [Shota Kudo (@chaploud)](https://github.com/chaploud),
-who took it from nothing to a working JVM-free Clojure runtime with a Wasm FFI
-through v1.10, and in 2026-10 passed the project on to
-[@BuddhiLW](https://github.com/BuddhiLW). Everything shipped since is built on
-that work. The embedded
-engine is [zwasm](https://github.com/zwasm/zwasm).
+Created by [Shota Kudo (@chaploud)](https://github.com/chaploud), who built it
+from nothing to a working JVM-free Clojure with a Wasm FFI through v1.10.1. In
+2026-10 the project passed to [@BuddhiLW](https://github.com/BuddhiLW); the
+fork's work (v1.11 to v1.15.0) is merged back into
+[clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm), while
+releases and the Homebrew tap publish from
+[BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm). The engine is
+[zwasm](https://github.com/zwasm/zwasm).
diff --git a/build.zig.zon b/build.zig.zon
index 9b041366a..8bea727f0 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.15.0",
+ .version = "1.15.1",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
@@ -14,7 +14,15 @@
// zwasm v2 embedding API (F-001 / D-037). `lazy` so the DEFAULT build +
// gate never resolve it — only `-Dwasm` (b.lazyDependency) does.
//
- // PINNED to the `cljw` branch of THIS FORK, BuddhiLW/zwasm
+ // 2026-10-21: PINNED to `main` of THIS FORK, BuddhiLW/zwasm, at
+ // d74e63b4 (fork main fast-forwarded to upstream zwasm/zwasm main,
+ // version string still 2.7.0, no newer tag). Upstream now carries all
+ // three cljw fixes listed below (851cd0e7 component re-export,
+ // f3067d03 metadata-hash, 767b808a PreparedWasi), so the `cljw`
+ // branch's patches are no longer needed. The pin returns to a
+ // zwasm/zwasm tag when the next one is cut.
+ //
+ // Before 2026-10-21: PINNED to the `cljw` branch of THIS FORK, BuddhiLW/zwasm
// (2026-09-14, user direction: "use our zwasm for now"). That branch
// is upstream v2.7.0 (d09d9248) plus two fixes found from cljw that
// upstream does not carry yet: every exported interface of a
@@ -60,8 +68,8 @@
// moment anything else claims that name. The `.hash` is
// content-addressed, so the host of the URL never changes it.
.zwasm = .{
- .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=cljw#70c8b7ee60e66115e3bf1030e1d657f772e7b99f",
- .hash = "zwasm-2.7.0-FT1FvzKOnADpIAKlkOhKgEpJcG6LFoKrWsUAjd-jSCid",
+ .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=main#d74e63b4aac1c7d392fc7e562467232945788dfe",
+ .hash = "zwasm-2.7.0-FT1Fv-zingBTL0it1Jtud_E9LvRZ6jkeZWH66zgwYpsw",
.lazy = true,
},
},
diff --git a/data/placement.yaml b/data/placement.yaml
index baf8a5848..04d946bb4 100644
--- a/data/placement.yaml
+++ b/data/placement.yaml
@@ -24,8 +24,8 @@
schema_version: "2.0"
summary:
namespaces_required: 36
- total: 1455
- zig: 423
+ total: 1456
+ zig: 424
clj: 806
value: 220
unbound: 6
@@ -1478,6 +1478,7 @@ vars:
"wasm/component-exports": { impl: zig, flags: "-" }
"wasm/component-invoke": { impl: zig, flags: "-" }
"wasm/engine": { impl: zig, flags: "-" }
+ "wasm/exports": { impl: zig, flags: "-" }
"wasm/load": { impl: zig, flags: "-" }
"wasm/load-component": { impl: zig, flags: "-" }
"wasm/mem-read": { impl: zig, flags: "-" }
diff --git a/docs/assets/buddhilw_logo.png b/docs/assets/buddhilw_logo.png
new file mode 100644
index 000000000..51e078de3
Binary files /dev/null and b/docs/assets/buddhilw_logo.png differ
diff --git a/docs/examples/wasm/README.md b/docs/examples/wasm/README.md
index 4cd63e458..6d8caa80d 100644
--- a/docs/examples/wasm/README.md
+++ b/docs/examples/wasm/README.md
@@ -14,6 +14,22 @@ sandboxed `.wasm` and invokes its exports.
name, marshalling arguments and results from the export's signature
(`i32`/`i64`/`f32`/`f64` today).
+A module handle also answers Clojure's host-interop method syntax (ADR-0203):
+the method name is the export name, verbatim, and the call is `wasm/call`'s
+own path, so results, fuel limits and traps behave the same.
+
+```clojure
+(.add m 40 2) ;=> 42
+(. m add 40 2) ;=> 42
+(. m (add 40 2)) ;=> 42
+
+;; A C kernel (../polyglot/c/kernel.c): C names work as written.
+(def k (wasm/load "docs/examples/polyglot/c/kernel.wasm"))
+(def buf (.buf_addr k))
+(wasm/mem-write! k :f64 buf [1.0 2.0 3.0 4.0])
+(.sum_f64 k buf 4) ;=> 10.0
+```
+
## Run it
The **released binaries** (GitHub releases, `brew install
diff --git a/docs/spec/formats/chunk-v13.edn b/docs/spec/formats/chunk-v13.edn
new file mode 100644
index 000000000..e19d50431
--- /dev/null
+++ b/docs/spec/formats/chunk-v13.edn
@@ -0,0 +1,17 @@
+;; Add-only bytecode archive amendment, 2026-10-14 (ADR-0034 D11, ADR-0198,
+;; ADR-0201, [CLJW-CTOR-ANALYZE-TIME]). Earlier records remain unchanged.
+;; v13 keeps the chunk-body framing of chunk-v12.edn except for one field
+;; added to each ctor_sites entry, so a decoder of an older version must
+;; reject the chunk.
+{:chunk-format
+ {:version 13
+ :magic "CLJW"
+ :base-layout "chunk-v12.edn"
+ :semantic-history
+ [{:version 13
+ :change "A ctor site carries definer_ns, the namespace its `(Name. args)` form was analyzed in, so a bare user-type name resolves to . before any reading the calling namespace could give it (ADR-0198, ADR-0201)."}]
+ :ctor-site
+ {:fields [{:name :type_name :body "u32 length, then bytes"}
+ {:name :definer_ns :body "u32 length, then bytes; empty when the name was qualified at analysis or no namespace was current"}
+ {:name :arg_count :body "u16"}]}
+ :compatibility "The runtime rejects a chunk version unequal to 13; rebuild older payloads."}}
diff --git a/src/eval/analyzer/analyzer.zig b/src/eval/analyzer/analyzer.zig
index 6a29ac4b3..5723828b2 100644
--- a/src/eval/analyzer/analyzer.zig
+++ b/src/eval/analyzer/analyzer.zig
@@ -2444,6 +2444,27 @@ test "ctor call (Foo. ...) analyses into interop_call_node .constructor" {
try testing.expect(n.interop_call_node.kind == .constructor);
try testing.expectEqualStrings("Foo", n.interop_call_node.type_name);
try testing.expectEqual(@as(usize, 2), n.interop_call_node.args.len);
+ // [CLJW-CTOR-ANALYZE-TIME]: a bare type name carries the namespace it was
+ // analyzed in, so eval-time lookup qualifies it lexically.
+ try testing.expectEqualStrings(fix.env.current_ns.?.name, n.interop_call_node.definer_ns);
+}
+
+test "constructInstance resolves . before the last-wins simple name" {
+ const td_mod = @import("../../runtime/type_descriptor.zig");
+ var fix: TestFixture = undefined;
+ try fix.init(testing.allocator);
+ defer fix.deinit();
+ // aaa.Point has two fields; bbb.Point, registered later, has one, so a
+ // simple-name lookup would pick bbb's and reject two arguments.
+ const aaa = try td_mod.registerType(&fix.rt, "Point", "aaa", &.{ "x", "y" }, &.{}, .defrecord);
+ const bbb = try td_mod.registerType(&fix.rt, "Point", "bbb", &.{"q"}, &.{}, .defrecord);
+ const two = [_]Value{ Value.initInteger(1), Value.initInteger(2) };
+ const a = try special_forms.constructInstance(&fix.rt, &fix.env, "Point", "aaa", &two, .{});
+ try testing.expect(a.tag() == .typed_instance);
+ try testing.expect(a.decodePtr(*const td_mod.TypedInstance).descriptor == aaa);
+ const one = [_]Value{Value.initInteger(9)};
+ const b = try special_forms.constructInstance(&fix.rt, &fix.env, "Point", "bbb", &one, .{});
+ try testing.expect(b.decodePtr(*const td_mod.TypedInstance).descriptor == bbb);
}
test "member access (.x inst) analyses into interop_call_node .instance_member" {
diff --git a/src/eval/analyzer/special_forms.zig b/src/eval/analyzer/special_forms.zig
index ffccaa65c..27681b7d3 100644
--- a/src/eval/analyzer/special_forms.zig
+++ b/src/eval/analyzer/special_forms.zig
@@ -104,9 +104,22 @@ pub fn constructInstance(
rt: *Runtime,
env: *Env,
type_name: []const u8,
+ /// The namespace the ctor form was analyzed in ("" when none). A user
+ /// type registered as `.` wins over every other
+ /// reading of the bare name, as a class name clj compiled there would.
+ definer_ns: []const u8,
args: []const Value,
loc: error_catalog.SourceLocation,
) anyerror!Value {
+ if (definer_ns.len != 0) {
+ var qbuf: [512]u8 = undefined;
+ if (std.fmt.bufPrint(&qbuf, "{s}.{s}", .{ definer_ns, type_name })) |qualified| {
+ if (rt.types.get(qualified)) |td| return constructFromDescriptor(rt, env, td, type_name, args, loc);
+ } else |_| {
+ // Namespace + name longer than the buffer: no registered key
+ // could match it either, so fall through to the steps below.
+ }
+ }
// D-284: `(MapEntry. k v)` / `(new clojure.lang.MapEntry k v)` constructs an
// entry, so collection libs that build entries explicitly (priority-map,
// ordered, …) work. It used to return a plain 2-vector, from when a map entry
@@ -151,9 +164,23 @@ pub fn constructInstance(
}
const td = resolveJavaSurface(rt, env, type_name) orelse
return error_catalog.raise(.symbol_unresolved, loc, .{ .sym = type_name });
+ return constructFromDescriptor(rt, env, td, type_name, args, loc);
+}
+
+/// The descriptor-driven half of `constructInstance`: a field layout
+/// (deftype / record) allocates an instance, a `` entry (java surface)
+/// is called through the runtime vtable, anything else takes no constructor.
+fn constructFromDescriptor(
+ rt: *Runtime,
+ env: *Env,
+ td: *const type_descriptor_mod.TypeDescriptor,
+ type_name: []const u8,
+ args: []const Value,
+ loc: error_catalog.SourceLocation,
+) anyerror!Value {
if (td.field_layout) |fl| {
if (args.len != fl.len)
- return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = type_name, .expected = fl.len });
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = td.fqcn orelse type_name, .expected = fl.len });
return type_descriptor_mod.allocInstance(rt, td, args);
}
if (td.lookupMethod(null, "")) |me| {
@@ -261,14 +288,20 @@ pub fn analyzeCtorCall(
// lexically correct one. Eval-time resolution would consult the CALLER's
// ns and miss it (hiccup.util's `(URI. s)` inside to-uri, called from
// user code). Guarded on the FQCN actually resolving as a registered
- // surface. A USER deftype keeps the bare spelling here and resolves on
- // the eval-time path (which also allows forward refs to later deftypes);
- // since ADR-0198 that path finds it by qualifying the bare name with the
- // current ns inside `host_class_resolve.resolve`, rather than by the
- // simple-name registry hit this comment used to describe.
+ // surface.
+ //
+ // A USER type keeps the bare spelling and resolves at eval time, which is
+ // what lets a `(Name. …)` inside the defrecord's own expansion refer to a
+ // type registered a moment later. The namespace of THIS analysis rides
+ // along as `definer_ns` so that eval-time lookup qualifies the name
+ // lexically (ADR-0198): the current ns at eval time is the CALLER's, and
+ // a bare `Point` there fell to the last-wins simple-name index, so
+ // `aaa/->Point` built whichever `Point` any namespace registered last.
var resolved_name = type_name;
+ var definer_ns: []const u8 = "";
if (std.mem.findScalar(u8, type_name, '.') == null) {
if (env.current_ns) |cur_ns| {
+ definer_ns = try arena.dupe(u8, cur_ns.name);
if (cur_ns.imports.get(type_name)) |fqcn| {
if (rt.types.get(fqcn) != null) {
resolved_name = fqcn;
@@ -288,6 +321,7 @@ pub fn analyzeCtorCall(
n.* = .{ .interop_call_node = .{
.kind = .constructor,
.type_name = resolved_name,
+ .definer_ns = if (resolved_name.ptr == type_name.ptr) definer_ns else "",
.args = args,
.loc = form.location,
} };
diff --git a/src/eval/backend/tree_walk.zig b/src/eval/backend/tree_walk.zig
index ba3632d82..3430533c3 100644
--- a/src/eval/backend/tree_walk.zig
+++ b/src/eval/backend/tree_walk.zig
@@ -722,7 +722,7 @@ fn evalConstructorCall(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.Int
buf[i] = try eval(rt, env, locals, &arg_node);
af.publish(i + 1);
}
- return special_forms.constructInstance(rt, env, n.type_name, buf[0..n.args.len], n.loc);
+ return special_forms.constructInstance(rt, env, n.type_name, n.definer_ns, buf[0..n.args.len], n.loc);
}
/// `(.member recv args...)` / `(.-field recv)` unified instance member
@@ -777,6 +777,9 @@ fn evalInstanceMember(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.Inte
}
const me = td.lookupMethod(null, n.name) orelse {
+ // ADR-0203: a core wasm module handle answers `(.export h …)` through
+ // wasm/call's path (hook installed only under -Dwasm).
+ if (receiver.tag() == .wasm_module) if (rt.wasm_member_call) |call| return call(rt, receiver, n.name, args_buf[1..], n.loc);
// Universal java.lang.Object method fallback (D-207): str/=/hash/class.
if (try object_method.tryObjectMethod(rt, env, receiver, td, n.name, args_buf[1..])) |r| return r;
// clojure.lang read/op methods on a NATIVE collection (D-371): `.valAt`/
diff --git a/src/eval/backend/vm.zig b/src/eval/backend/vm.zig
index b8a0551f0..0199de64f 100644
--- a/src/eval/backend/vm.zig
+++ b/src/eval/backend/vm.zig
@@ -527,7 +527,7 @@ inline fn stepOnce(
},
.op_load_local => {
if (instr.operand >= locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = instr.operand, .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = instr.operand, .max = locals.len });
if (sp >= stack.len)
return operandOverflow(instr_loc);
stack[sp] = locals[instr.operand];
@@ -535,7 +535,7 @@ inline fn stepOnce(
},
.op_store_local => {
if (instr.operand >= locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "let*", .index = instr.operand, .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "let*", .index = instr.operand, .max = locals.len });
if (sp == 0) return raiseInternal("vm: op_store_local on empty stack");
sp -= 1;
locals[instr.operand] = stack[sp];
@@ -600,7 +600,7 @@ inline fn stepOnce(
return raiseInternal("vm: op_ns_import site index out of range");
const imp = chunk.import_sites[instr.operand];
const here = env.current_ns orelse
- return error_catalog.raise(.current_namespace_missing, .{}, .{ .sym = imp.simple });
+ return error_catalog.raise(.current_namespace_missing, instr_loc, .{ .sym = imp.simple });
try here.addImport(env.alloc, imp.simple, imp.fqcn);
if (sp >= stack.len)
return operandOverflow(instr_loc);
@@ -618,7 +618,7 @@ inline fn stepOnce(
if (!env_mod.setBinding(var_ptr, val)) {
const full = try std.fmt.allocPrint(rt.gpa, "{s}/{s}", .{ var_ptr.ns.name, var_ptr.name });
defer rt.gpa.free(full);
- return error_catalog.raise(.var_set_not_bound, .{}, .{ .@"var" = full });
+ return error_catalog.raise(.var_set_not_bound, instr_loc, .{ .@"var" = full });
}
},
.op_set_field => {
@@ -644,7 +644,7 @@ inline fn stepOnce(
break;
}
}
- if (!wrote) return error_catalog.raise(.symbol_unresolved, .{}, .{ .sym = field_name });
+ if (!wrote) return error_catalog.raise(.symbol_unresolved, instr_loc, .{ .sym = field_name });
sp -= 2;
stack[sp] = value;
sp += 1;
@@ -860,7 +860,7 @@ inline fn stepOnce(
const lslot: usize = instr.operand >> 8;
const cidx: usize = instr.operand & 0xFF;
if (lslot >= locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(lslot)), .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(lslot)), .max = locals.len });
if (cidx >= chunk.constants.len)
return raiseInternal("vm: local_const constant index out of range");
const a = locals[lslot];
@@ -893,7 +893,7 @@ inline fn stepOnce(
const sa: usize = instr.operand >> 8;
const sb: usize = instr.operand & 0xFF;
if (sa >= locals.len or sb >= locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(if (sa >= locals.len) sa else sb)), .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(if (sa >= locals.len) sa else sb)), .max = locals.len });
const a = locals[sa];
const b = locals[sb];
const aop = intrinsic.fromLocalsOpcode(instr_op).?;
@@ -931,7 +931,7 @@ inline fn stepOnce(
const offset: i16 = @bitCast(chunk.instructions[ip].operand);
ip += 1;
if (sa >= locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(sa)), .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(sa)), .max = locals.len });
const a = locals[sa];
const b = if (info.b_is_const) bblk: {
if (sb >= chunk.constants.len)
@@ -939,7 +939,7 @@ inline fn stepOnce(
break :bblk chunk.constants[sb];
} else bblk: {
if (sb >= locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(sb)), .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(sb)), .max = locals.len });
break :bblk locals[sb];
};
const fast: ?Value = if (rt.core_arith_pristine)
@@ -975,7 +975,7 @@ inline fn stepOnce(
ip += 1;
if (sp < nb) return raiseInternal("vm: op_recur_loop underflow");
if (base + nb > locals.len)
- return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "loop*", .index = @as(u16, @intCast(base + nb)), .max = locals.len });
+ return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "loop*", .index = @as(u16, @intCast(base + nb)), .max = locals.len });
const top: u16 = sp - @as(u16, @intCast(nb));
var k: usize = 0;
while (k < nb) : (k += 1) {
@@ -1096,7 +1096,7 @@ inline fn stepOnce(
// through `op_call` + vtable). Per
// `no_op_stub_forbidden.md`, raise rather than fall
// through silently.
- return error_catalog.raise(.feature_not_supported, .{}, .{ .name = "op_invoke_builtin" });
+ return error_catalog.raise(.feature_not_supported, instr_loc, .{ .name = "op_invoke_builtin" });
},
.op_push_handler => {
const offset: i16 = @bitCast(instr.operand);
@@ -1303,7 +1303,7 @@ inline fn stepOnce(
// lib_not_found renders the caret (tree_walk parity).
const target_ns = try loader.loadOrFindNs(rt, env, spec.ns_name, instr_loc);
const here = env.current_ns orelse
- return error_catalog.raise(.current_namespace_missing, .{}, .{ .sym = spec.ns_name });
+ return error_catalog.raise(.current_namespace_missing, instr_loc, .{ .sym = spec.ns_name });
if (spec.alias) |alias_name| {
try env.setAlias(here, alias_name, target_ns);
}
@@ -1319,7 +1319,7 @@ inline fn stepOnce(
.private_blocked => {
const full = try std.fmt.allocPrint(rt.gpa, "{s}/{s}", .{ spec.ns_name, refer_name });
defer rt.gpa.free(full);
- return error_catalog.raise(.private_access_error, .{}, .{
+ return error_catalog.raise(.private_access_error, instr_loc, .{
.sym = full,
.ns = spec.ns_name,
});
@@ -1327,7 +1327,7 @@ inline fn stepOnce(
.not_found => {
const full = try std.fmt.allocPrint(rt.gpa, "{s}/{s}", .{ spec.ns_name, refer_name });
defer rt.gpa.free(full);
- return error_catalog.raise(.symbol_unresolved, .{}, .{ .sym = full });
+ return error_catalog.raise(.symbol_unresolved, instr_loc, .{ .sym = full });
},
}
}
@@ -1373,7 +1373,7 @@ inline fn stepOnce(
// Shared resolver/dispatcher (deftype/record + java-surface
// ``) — identical to TreeWalk's evalConstructorCall so a
// `(java.io.File. …)` ctor works on both backends.
- const new_val = try special_forms.constructInstance(rt, env, type_name, args_slice, .{});
+ const new_val = try special_forms.constructInstance(rt, env, type_name, ctor.definer_ns, args_slice, instr_loc);
sp -= arg_count;
stack[sp] = new_val;
sp += 1;
@@ -1419,11 +1419,11 @@ inline fn stepOnce(
stack[sp] = fv;
sp += 1;
} else if (cs_entry.field_only) {
- return error_catalog.raise(.symbol_unresolved, .{}, .{ .sym = cs_entry.method_name });
+ return error_catalog.raise(.symbol_unresolved, instr_loc, .{ .sym = cs_entry.method_name });
} else {
if (cs_entry.cache.lookupWithCache(td, null, cs_entry.method_name, rt.protocol_generation)) |me| {
if (me.method_val.tag() == .nil)
- return error_catalog.raise(.feature_not_supported, .{}, .{ .name = "method declared but not implemented" });
+ return error_catalog.raise(.feature_not_supported, instr_loc, .{ .name = "method declared but not implemented" });
const args_slice = stack[sp - arg_count .. sp];
const vt = rt.vtable orelse return error.NoVTable;
// Frame the user `/` so the interop form
@@ -1435,6 +1435,13 @@ inline fn stepOnce(
sp -= arg_count;
stack[sp] = result;
sp += 1;
+ } else if (receiver.tag() == .wasm_module and rt.wasm_member_call != null) {
+ // ADR-0203: `(.export h …)` on a core wasm module handle is
+ // wasm/call's path — mirrors TreeWalk's evalInstanceMember.
+ const result = try rt.wasm_member_call.?(rt, receiver, cs_entry.method_name, stack[sp - arg_count + 1 .. sp], instr_loc);
+ sp -= arg_count;
+ stack[sp] = result;
+ sp += 1;
} else if (try object_method.tryObjectMethod(rt, env, receiver, td, cs_entry.method_name, stack[sp - arg_count + 1 .. sp])) |r| {
// Universal java.lang.Object method fallback:
// str/=/hash/class — mirrors TreeWalk's evalInstanceMember.
@@ -1448,7 +1455,7 @@ inline fn stepOnce(
stack[sp] = r;
sp += 1;
} else {
- return error_catalog.raise(.protocol_no_satisfies, .{}, .{
+ return error_catalog.raise(.protocol_no_satisfies, instr_loc, .{
.protocol = "<.member>",
.method = cs_entry.method_name,
.type_name = td.fqcn orelse "",
@@ -1470,17 +1477,17 @@ inline fn stepOnce(
const td = cs_entry.descriptor orelse
return raiseInternal("vm: op_static_method_call missing descriptor (compiler bug)");
const me = td.lookupMethod(null, cs_entry.method_name) orelse {
- return error_catalog.raise(.protocol_no_satisfies, .{}, .{
+ return error_catalog.raise(.protocol_no_satisfies, instr_loc, .{
.protocol = "",
.method = cs_entry.method_name,
.type_name = td.fqcn orelse "",
});
};
if (me.method_val.tag() == .nil)
- return error_catalog.raise(.feature_not_supported, .{}, .{ .name = "static method declared but not implemented" });
+ return error_catalog.raise(.feature_not_supported, instr_loc, .{ .name = "static method declared but not implemented" });
const args_slice = stack[sp - arg_count .. sp];
const vt = rt.vtable orelse return error.NoVTable;
- const result = try vt.callFn(rt, env, me.method_val, args_slice, .{});
+ const result = try vt.callFn(rt, env, me.method_val, args_slice, instr_loc);
sp -= arg_count;
stack[sp] = result;
// Result loc = this call form (pops below sp_entry, so the sweep misses it).
diff --git a/src/eval/backend/vm/compiler.zig b/src/eval/backend/vm/compiler.zig
index d9f2dda8f..8569bf20c 100644
--- a/src/eval/backend/vm/compiler.zig
+++ b/src/eval/backend/vm/compiler.zig
@@ -193,6 +193,9 @@ const Compiler = struct {
fn compileSet(self: *Compiler, n: node_mod.SetNode) Error!void {
try self.compileNode(n.value_expr);
const idx = try self.addConstant(Value.encodeHeapPtr(.var_ref, n.var_ptr));
+ // The value expr re-stamped `current_*`; op_set_var raises at the
+ // set! form itself (TreeWalk evalSet's n.loc, ADR-0036 error half).
+ self.stampLoc(n.loc);
try self.emit(.op_set_var, idx);
}
@@ -205,6 +208,7 @@ const Compiler = struct {
try self.compileNode(n.value_expr);
const name_val = try string_mod.alloc(self.rt, n.field_name);
const idx = try self.addConstant(name_val);
+ self.stampLoc(n.loc);
try self.emit(.op_set_field, idx);
}
@@ -738,8 +742,12 @@ const Compiler = struct {
const name_dup = try self.arena.dupe(u8, n.type_name);
try self.ctor_sites.append(self.arena, .{
.type_name = name_dup,
+ .definer_ns = try self.arena.dupe(u8, n.definer_ns),
.arg_count = @intCast(n.args.len),
});
+ // Raise at the ctor form, not its last argument
+ // (TreeWalk evalConstructorCall's n.loc, ADR-0036 error half).
+ self.stampLoc(n.loc);
try self.emit(.op_ctor_call, ctor_idx);
},
.instance_member => {
@@ -757,6 +765,9 @@ const Compiler = struct {
.arg_count = total_args,
.field_only = n.field_only,
});
+ // Raise at the interop form, not its last argument
+ // (TreeWalk evalInstanceMember's n.loc, ADR-0036 error half).
+ self.stampLoc(n.loc);
try self.emit(.op_method_call, cs_idx);
},
.static_method => {
@@ -773,6 +784,7 @@ const Compiler = struct {
.arg_count = arg_count,
.descriptor = td,
});
+ self.stampLoc(n.loc);
try self.emit(.op_static_method_call, cs_idx);
},
}
diff --git a/src/eval/backend/vm/opcode.zig b/src/eval/backend/vm/opcode.zig
index 88d7337d1..a1526d73a 100644
--- a/src/eval/backend/vm/opcode.zig
+++ b/src/eval/backend/vm/opcode.zig
@@ -682,9 +682,12 @@ pub const LibspecEntry = struct {
/// here (not packed into the operand) so it carries full width — the old
/// `(name_idx << 8) | arg_count` packing truncated name_idx to 8 bits and
/// corrupted ctor calls in chunks with > 255 constants. `type_name` is
-/// analyzer-arena-owned, chunk lifetime.
+/// analyzer-arena-owned, chunk lifetime. `definer_ns` is the namespace the
+/// form was analyzed in (empty when none), which qualifies a bare user-type
+/// name at run time, where the current namespace is the caller's.
pub const CtorEntry = struct {
type_name: []const u8,
+ definer_ns: []const u8 = "",
arg_count: u16,
};
diff --git a/src/eval/bytecode/serialize.zig b/src/eval/bytecode/serialize.zig
index 336c1c476..7e607f8da 100644
--- a/src/eval/bytecode/serialize.zig
+++ b/src/eval/bytecode/serialize.zig
@@ -29,7 +29,8 @@
//! + has_only:u8 + (only_count:u32
//! + each: only_len:u32 + bytes)?
//! [...] ctor_sites_count (u32 LE)
-//! for each entry: type_name_len:u32 + bytes + arg_count:u16
+//! for each entry: type_name_len:u32 + bytes
+//! + definer_ns_len:u32 + bytes + arg_count:u16
//! [...] import_sites_count (u32 LE)
//! for each entry: simple_len:u32 + bytes + fqcn_len:u32 + bytes
//!
@@ -78,6 +79,10 @@ const tree_walk = @import("../backend/tree_walk.zig");
const Function = tree_walk.Function;
pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' };
+// v13: a ctor site carries `definer_ns`, the namespace its form was analyzed
+// in, so a bare user-type name is qualified lexically instead of by the
+// caller's namespace (ADR-0198, ADR-0201). A v12 ctor site has no such field,
+// so v12 is rejected.
// v12 (D-346): the op_*_extend opcodes build a large literal in bounded steps,
// and a folded constant literal may be a hash_map (new wire tag) or a set past
// the array-map threshold. A v11 decoder cannot read either, so v11 is rejected.
@@ -89,7 +94,7 @@ pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' };
// v10: def operands capture Var constants instead of caller-relative names.
// v9 added op_var_meta; v8 unified host-class FQCNs; v7 compacted WireInstr
// and interned the name pool; v6 merged rt into clojure.core.
-pub const VERSION: u16 = 12;
+pub const VERSION: u16 = 13;
pub const SerializeError = error{
OutOfMemory,
@@ -852,6 +857,7 @@ fn writeChunkBody(ctx: *WriteCtx, w: *std.Io.Writer, chunk: BytecodeChunk) Seria
try writeU32(w, @intCast(chunk.ctor_sites.len));
for (chunk.ctor_sites) |ct| {
try writeLenPrefixed(w, ct.type_name);
+ try writeLenPrefixed(w, ct.definer_ns);
try writeU16(w, ct.arg_count);
}
try writeU32(w, @intCast(chunk.import_sites.len));
@@ -1039,8 +1045,10 @@ fn readChunkBody(ctx: *ReadCtx, r: *ByteReader) DeserializeError!BytecodeChunk {
while (i < ctor_count) : (i += 1) {
const tn_bytes = try r.readLenPrefixed();
const tn_dup = try allocator.dupe(u8, tn_bytes);
+ const dn_bytes = try r.readLenPrefixed();
+ const dn_dup = try allocator.dupe(u8, dn_bytes);
const arg_count = try r.readU16();
- ctor_sites[i] = .{ .type_name = tn_dup, .arg_count = arg_count };
+ ctor_sites[i] = .{ .type_name = tn_dup, .definer_ns = dn_dup, .arg_count = arg_count };
}
const import_count = try r.readU32();
@@ -1132,7 +1140,10 @@ pub fn freeChunk(allocator: std.mem.Allocator, chunk: BytecodeChunk) void {
if (nf.doc) |d| allocator.free(d);
}
allocator.free(chunk.ns_filters);
- for (chunk.ctor_sites) |ct| allocator.free(ct.type_name);
+ for (chunk.ctor_sites) |ct| {
+ allocator.free(ct.type_name);
+ allocator.free(ct.definer_ns);
+ }
allocator.free(chunk.ctor_sites);
for (chunk.import_sites) |ip| {
allocator.free(ip.simple);
@@ -2077,7 +2088,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
}
inline for (std.meta.fields(CtorEntry)) |f| {
const classified: bool = switch (@field(std.meta.FieldEnum(CtorEntry), f.name)) {
- .type_name, .arg_count => true,
+ .type_name, .definer_ns, .arg_count => true,
};
try testing.expect(classified);
}
@@ -2119,6 +2130,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
};
const ctor_sites = [_]CtorEntry{
.{ .type_name = "java.io.File", .arg_count = 1 },
+ .{ .type_name = "Point", .definer_ns = "app.core", .arg_count = 2 },
};
const import_sites = [_]ImportPair{
.{ .simple = "File", .fqcn = "java.io.File" },
@@ -2179,9 +2191,13 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
try testing.expectEqual(@as(u32, 7), round.ns_filters[0].attr_const);
// ctor_sites
- try testing.expectEqual(@as(usize, 1), round.ctor_sites.len);
+ try testing.expectEqual(@as(usize, 2), round.ctor_sites.len);
try testing.expectEqualStrings("java.io.File", round.ctor_sites[0].type_name);
+ try testing.expectEqualStrings("", round.ctor_sites[0].definer_ns);
try testing.expectEqual(@as(u16, 1), round.ctor_sites[0].arg_count);
+ try testing.expectEqualStrings("Point", round.ctor_sites[1].type_name);
+ try testing.expectEqualStrings("app.core", round.ctor_sites[1].definer_ns);
+ try testing.expectEqual(@as(u16, 2), round.ctor_sites[1].arg_count);
// import_sites
try testing.expectEqual(@as(usize, 1), round.import_sites.len);
diff --git a/src/eval/evaluator.zig b/src/eval/evaluator.zig
index 98f07e7a8..58c84cfeb 100644
--- a/src/eval/evaluator.zig
+++ b/src/eval/evaluator.zig
@@ -32,6 +32,7 @@ const tree_walk = @import("backend/tree_walk.zig");
const vm = @import("backend/vm.zig");
const vm_compiler = @import("backend/vm/compiler.zig");
const driver = @import("driver.zig");
+const error_info = @import("../runtime/error/info.zig");
pub const CompareResult = struct {
tree_walk: anyerror!Value,
@@ -39,8 +40,40 @@ pub const CompareResult = struct {
/// `true` when both backends produced the same NaN-boxed bit pattern, or
/// failed with the same Zig error.
equal: bool,
+ /// Source position of the error each backend raised; null when that
+ /// backend returned a value. Only `line`/`column` are captured: the
+ /// `file` field of a fixture-analysed form is "unknown" on both
+ /// backends and carries no signal.
+ tree_walk_loc: ?ErrorPos = null,
+ vm_loc: ?ErrorPos = null,
+ /// `true` when both backends agree on WHERE the failure is: neither
+ /// raised, or both raised at the same line:column. The error half of
+ /// the dual-backend contract (ADR-0036). A backend that raises the
+ /// same Kind at 0:0 while the other reports 3:11 is a parity break,
+ /// invisible to `equal` (which only compares returned Values).
+ loc_equal: bool = true,
};
+/// The comparable part of a raised error's `SourceLocation`.
+pub const ErrorPos = struct {
+ line: u32,
+ column: u16,
+};
+
+/// The position of the error a just-finished backend run raised, or null
+/// when the run returned a value. Reads the threadlocal payload without
+/// clearing it, so the caller's own error handling still sees it; must be
+/// called before the next run overwrites the slot. A run that failed
+/// without publishing a payload (a user throw, OOM) reports 0:0, the same
+/// position a dropped location has, rather than null ("did not raise").
+fn errorPos(result: anyerror!Value) ?ErrorPos {
+ _ = result catch {
+ const info = error_info.peekLastError() orelse return .{ .line = 0, .column = 0 };
+ return .{ .line = info.location.line, .column = info.location.column };
+ };
+ return null;
+}
+
pub fn compare(
rt: *Runtime,
env: *Env,
@@ -48,11 +81,20 @@ pub fn compare(
arena: std.mem.Allocator,
source: []const u8,
) CompareResult {
+ // runOnce does not reset the threadlocal error slot, so clear it before
+ // each run: a stale payload from an earlier raise must not be read as
+ // this run's position.
+ error_info.clearLastError();
tree_walk.installVTable(rt);
const tw_value = runOnce(rt, env, table, arena, source, .tree_walk);
+ // Read the raise position NOW: the threadlocal payload is a single slot
+ // that the VM run below overwrites.
+ const tw_loc = errorPos(tw_value);
+ error_info.clearLastError();
vm.installVTable(rt);
const vm_value = runOnce(rt, env, table, arena, source, .vm);
+ const vm_loc = errorPos(vm_value);
const equal = if (tw_value) |tw| blk: {
const vm_v = vm_value catch break :blk false;
@@ -61,7 +103,18 @@ pub fn compare(
_ = vm_value catch |vm_err| break :blk tw_err == vm_err;
break :blk false;
};
- return .{ .tree_walk = tw_value, .vm = vm_value, .equal = equal };
+ const loc_equal = if (tw_loc) |a|
+ if (vm_loc) |b| a.line == b.line and a.column == b.column else false
+ else
+ vm_loc == null;
+ return .{
+ .tree_walk = tw_value,
+ .vm = vm_value,
+ .equal = equal,
+ .tree_walk_loc = tw_loc,
+ .vm_loc = vm_loc,
+ .loc_equal = loc_equal,
+ };
}
pub const BackendChoice = enum { tree_walk, vm };
diff --git a/src/eval/node.zig b/src/eval/node.zig
index a3c8d5c0a..b9a63bd61 100644
--- a/src/eval/node.zig
+++ b/src/eval/node.zig
@@ -425,6 +425,12 @@ pub const InteropCallNode = struct {
/// `.constructor`: type name string for eval-time `resolveJavaSurface`
/// lookup. Other kinds: empty string.
type_name: []const u8 = "",
+ /// `.constructor` with a dot-free `type_name`: the namespace the form was
+ /// ANALYZED in. A user type is keyed `.` (ADR-0198) and clj
+ /// binds a class name lexically, so the eval-time lookup tries
+ /// `.` before anything the caller's namespace
+ /// could answer. Empty when there is none.
+ definer_ns: []const u8 = "",
/// `.static_method` / `.instance_member`: method or field name.
/// `.constructor`: ignored.
name: []const u8 = "",
diff --git a/src/lang/diff_test.zig b/src/lang/diff_test.zig
index 1af1c9fea..9906b16db 100644
--- a/src/lang/diff_test.zig
+++ b/src/lang/diff_test.zig
@@ -74,20 +74,47 @@ const Fixture = struct {
fn check(self: *Fixture, source: []const u8, expected: i64) !void {
const r = evaluator.compare(&self.rt, &self.env, &self.table, self.arena.allocator(), source);
+ try expectLocParity(r, source);
try testing.expect(r.equal);
try testing.expectEqual(expected, (try r.tree_walk).asInteger());
}
+ /// Both backends raise the same error at the same source position
+ /// (ADR-0036 error half). For cases whose contract is the failure itself.
+ fn checkRaises(self: *Fixture, source: []const u8, expected: anyerror) !void {
+ const r = evaluator.compare(&self.rt, &self.env, &self.table, self.arena.allocator(), source);
+ try testing.expectError(expected, r.tree_walk);
+ try testing.expectError(expected, r.vm);
+ try expectLocParity(r, source);
+ }
+
/// Assert only that both backends agree (VM == TreeWalk) — for results whose
/// type is not an inline integer (bigint / float / string), where
/// `asInteger()` cannot be used. `r.equal` is the dual-backend parity oracle.
fn checkEqual(self: *Fixture, source: []const u8) !void {
const r = evaluator.compare(&self.rt, &self.env, &self.table, self.arena.allocator(), source);
+ try expectLocParity(r, source);
_ = try r.tree_walk; // surface an eval error rather than a silent mismatch
try testing.expect(r.equal);
}
};
+/// The shared error-location assertion every fixture check runs (ADR-0036
+/// error half): whenever either backend raises, both must report the same
+/// line:column. `loc_equal` is true when neither raised, so value cases pass
+/// through; a raise on one backend only, or at a different position, fails
+/// here with both positions printed.
+fn expectLocParity(r: evaluator.CompareResult, source: []const u8) !void {
+ if (r.loc_equal) return;
+ const tw = r.tree_walk_loc orelse evaluator.ErrorPos{ .line = 0, .column = 0 };
+ const v = r.vm_loc orelse evaluator.ErrorPos{ .line = 0, .column = 0 };
+ std.debug.print(
+ "error-location parity broken for: {s}\n tree_walk {d}:{d} (raised: {})\n vm {d}:{d} (raised: {})\n",
+ .{ source, tw.line, tw.column, r.tree_walk_loc != null, v.line, v.column, r.vm_loc != null },
+ );
+ return error.TestUnexpectedResult;
+}
+
test "diff: D-346 chunked collection literals preserve order and nested evaluation" {
var f: Fixture = undefined;
try Fixture.init(&f, testing.allocator);
@@ -120,9 +147,7 @@ test "diff: D-413 unresolved symbol → clean symbol_unresolved, not a panic" {
var f: Fixture = undefined;
try Fixture.init(&f, testing.allocator);
defer f.deinit();
- const r = evaluator.compare(&f.rt, &f.env, &f.table, f.arena.allocator(), "(re-matcher 1)");
- try testing.expectError(error.NameError, r.tree_walk);
- try testing.expectError(error.NameError, r.vm);
+ try f.checkRaises("(re-matcher 1)", error.NameError);
}
test "diff: D-563b def meta — user ^meta + compiler :line land on the Var in BOTH backends (op_var_meta ≡ evalDef meta_expr)" {
@@ -1800,3 +1825,48 @@ test "diff: op_get / op_nth collection intrinsics (O-043, both backends)" {
// fixture's literal-arg quirk in TreeWalk; the value path is what matters).
try f.check("(let [v [[1 2] [3 4]]] (count (nth v 0 nil)))", 2);
}
+
+/// Assert the error-location half of the dual-backend contract (ADR-0036):
+/// both backends must raise, and both must report the SAME source position.
+/// A raise at line 0 is rejected on either side: "both agree on nothing" is
+/// vacuous parity, and 0:0 is exactly what a dropped location looks like.
+fn checkErrorLoc(f: *Fixture, source: []const u8) !void {
+ const r = evaluator.compare(&f.rt, &f.env, &f.table, f.arena.allocator(), source);
+ const tw = r.tree_walk_loc orelse {
+ std.debug.print("tree_walk did not raise for: {s}\n", .{source});
+ return error.TestExpectedRaise;
+ };
+ const vm_pos = r.vm_loc orelse {
+ std.debug.print("vm did not raise for: {s}\n", .{source});
+ return error.TestExpectedRaise;
+ };
+ if (tw.line == 0 or vm_pos.line == 0 or !r.loc_equal) {
+ std.debug.print(
+ "error-location parity broken for: {s}\n tree_walk {d}:{d}\n vm {d}:{d}\n",
+ .{ source, tw.line, tw.column, vm_pos.line, vm_pos.column },
+ );
+ return error.TestUnexpectedResult;
+ }
+}
+
+test "diff: error source location survives BOTH backends (ADR-0036 error half)" {
+ var f: Fixture = undefined;
+ try Fixture.init(&f, testing.allocator);
+ defer f.deinit();
+ // Control arm: a builtin raising through op_call already threads the call
+ // form's loc into `vt.callFn`, so this one held before the fix.
+ try checkErrorLoc(&f, "(/ 1 0)");
+ // `set!` on a var with no active thread binding → var_set_not_bound, raised
+ // by the VM's op_set_var directly rather than through `vt.callFn`.
+ try checkErrorLoc(&f, "(do (def svl 1) (set! svl 2))");
+ // Interop member on a receiver whose type has no such method → raised by
+ // the VM's op_method_call directly.
+ try checkErrorLoc(&f, "(.noSuchMember \"s\")");
+ // Constructor arity failure → raised inside constructInstance with the
+ // loc the backend hands it (TreeWalk n.loc, VM op_ctor_call instr_loc).
+ try checkErrorLoc(&f, "(MapEntry. 1)");
+ // Multi-line source: the position must be the inner form's own line and
+ // column, not the enclosing form's and not the last argument's.
+ try checkErrorLoc(&f, "(do\n (def svl2 1)\n (set! svl2\n 2))");
+ try checkErrorLoc(&f, "(let [x 1]\n (MapEntry. x))");
+}
diff --git a/src/lang/primitive/core.zig b/src/lang/primitive/core.zig
index 5c3954a68..96f2a0c7a 100644
--- a/src/lang/primitive/core.zig
+++ b/src/lang/primitive/core.zig
@@ -51,36 +51,38 @@ const big_int = @import("../../runtime/numeric/big_int.zig");
/// `(map (partial instance? C) …)`, works exactly as in clj). Returns true iff
/// `x` is an instance of `c`.
///
-/// `runtime/class_name.zig::isInstance` is the COMPLETE membership oracle
-/// (native exact / interface multi-tag / Throwable hierarchy / Object-universal /
-/// user TypeDescriptor walk; an opaque collapsed class — Integer / BigInteger —
-/// is in no table so it returns false, clj-faithful). So this primitive is just
-/// `fqcn → isInstance` with no opaque/Object/Number special-case (those dissolved
-/// into the oracle per ADR-0128). A non-class `c` raises (clj: ClassCastException).
+/// `runtime/class_name.zig` is the COMPLETE membership oracle (native exact /
+/// interface multi-tag / Throwable hierarchy / Object-universal / descriptor
+/// identity for a user class; an opaque collapsed class, Integer / BigInteger,
+/// is in no table so it returns false, clj-faithful). So this primitive only
+/// picks the entry point, with no opaque/Object/Number special-case (those
+/// dissolved into the oracle per ADR-0128). A non-class `c` raises (clj:
+/// ClassCastException).
pub fn instanceOf(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
_ = rt;
_ = env;
try error_catalog.checkArity("instance?", args, 2, loc);
- // The class arg is either a class VALUE (`.type_descriptor` — String / Number /
- // Object / a user deftype / an exception class / `clojure.lang.IFn` FQCN) OR a
- // cljw interface-marker PROTOCOL (`.protocol` — bare `IFn`/`ISeq`/`IPersistentMap`/
- // `IDeref`/… resolve to the defprotocol Var, the cljw representation of the clj
- // interface). Both name a class in the membership oracle, so dispatch by fqcn.
- const fqcn = switch (args[0].tag()) {
- .type_descriptor => td_mod.asTypeDescriptorRef(args[0]).fqcn orelse return .false_val,
- .protocol => protocol_mod.asProtocol(args[0]).fqcn(),
+ // The class arg is either a class VALUE (`.type_descriptor`: String / Number /
+ // Object / a user deftype / an exception class / a `clojure.lang.IFn` FQCN),
+ // answered through its descriptor so a user class is matched by identity
+ // (ADR-0201), OR a cljw interface-marker PROTOCOL (`.protocol`: bare `IFn` /
+ // `ISeq` / `IPersistentMap` / `IDeref` resolve to the defprotocol Var, the
+ // cljw representation of the clj interface), which names an interface.
+ const member = switch (args[0].tag()) {
+ .type_descriptor => class_name.isInstanceOfClass(args[1], td_mod.asTypeDescriptorRef(args[0])),
+ .protocol => class_name.isInstance(args[1], protocol_mod.asProtocol(args[0]).fqcn()),
else => return error_catalog.raise(.type_arg_invalid, loc, .{
.fn_name = "instance?",
.expected = "class",
.actual = @tagName(args[0].tag()),
}),
};
- return if (class_name.isInstance(args[1], fqcn)) .true_val else .false_val;
+ return Value.initBoolean(member);
}
/// `(-class-isa? child parent)` — true iff both args are class values
/// (TypeDescriptor) and `child`'s class is `parent` or a subclass of it, via the
-/// shared `class_name.classIsa` (Object/Number/IFn + interface markers + the host
+/// shared `class_name.classIsaOf` (Object/Number/IFn + interface markers + the host
/// exception/parent hierarchy — shared with multimethod dispatch so the two
/// cannot drift, D-464). The class-hierarchy arm of `isa?` (clj's
/// `Class.isAssignableFrom` step); equality + the ad-hoc `derive` hierarchy stay
@@ -91,13 +93,16 @@ pub fn classIsaPrim(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLoc
try error_catalog.checkArity("__class-isa?", args, 2, loc);
if (args[0].tag() != .type_descriptor or args[1].tag() != .type_descriptor)
return .false_val;
- const child = td_mod.asTypeDescriptorRef(args[0]).fqcn orelse return .false_val;
- const parent = td_mod.asTypeDescriptorRef(args[1]).fqcn orelse return .false_val;
- // The class-vs-class hierarchy step (ADR-0109 Object/Number/IFn + D-293
- // interface markers + the exception/parent hierarchy) lives in the shared
- // `class_name.classIsa` so `isa?` and multimethod dispatch cannot drift
- // (D-464). The ad-hoc `derive` hierarchy + equality stay in the `.clj` isa?.
- return if (class_name.classIsa(child, parent)) .true_val else .false_val;
+ // The class-vs-class hierarchy step lives in the shared
+ // `class_name.classIsaOf` so `isa?` and multimethod dispatch cannot drift
+ // (D-464): a user class by descriptor identity and by what it declares,
+ // never by its name (ADR-0201); a native class by name (ADR-0109
+ // Object/Number/IFn, D-293 interface markers, the exception/parent
+ // hierarchy). The ad-hoc `derive` hierarchy + equality stay in the `.clj`
+ // isa?.
+ const child = td_mod.asTypeDescriptorRef(args[0]);
+ const parent = td_mod.asTypeDescriptorRef(args[1]);
+ return Value.initBoolean(class_name.classIsaOf(child, parent));
}
/// Java String's immediate interfaces plus Object. JVM's `Class.getInterfaces`
diff --git a/src/runtime/class_name.zig b/src/runtime/class_name.zig
index bbb736102..30f5df0f3 100644
--- a/src/runtime/class_name.zig
+++ b/src/runtime/class_name.zig
@@ -28,9 +28,13 @@
//! - **Throwable hierarchy**: delegates to `host_class.matches`
//! (covers `Throwable` / `Exception` / `RuntimeException` /
//! `ExceptionInfo` etc.).
-//! - **User types** (`defrecord` / `deftype` / `reify`): walks the
-//! `TypeDescriptor.parent` chain, comparing `fqcn` against the
-//! class name.
+//! - **User classes** (`defrecord` / `deftype` / `reify`) are NOT matched
+//! by name (ADR-0201). A user class is its TypeDescriptor, so membership
+//! in one is descriptor identity: `isInstanceOfClass` / `classIsaOf`,
+//! which take the class VALUE's descriptor. The name oracle below only
+//! answers which host interfaces a user type declares (its
+//! `protocol_impls` / `method_table`), and matches a descriptor's own
+//! fqcn only for a native host-surface descriptor.
//!
//! ## DIVERGENCE from cw v0
//!
@@ -308,13 +312,14 @@ fn isInterfaceName(simple: []const u8) bool {
/// 1. Throwable hierarchy (host_class.matches walks the parent chain).
/// 2. Native exact-tag table (`String`, `Long`, etc.).
/// 3. Interface-shaped multi-tag set (`IFn`, `Number`, `IPersistent*`).
-/// 4. User TypeDescriptor walk for `.typed_instance` / `.reified_instance`
-/// receivers — comparing the simple name against each ancestor's
-/// `fqcn`.
+/// 4. Descriptor walk for `.typed_instance` / `.reified_instance` /
+/// `.host_instance` receivers: the interfaces a type declares, plus the
+/// own fqcn of a native host-surface descriptor (see `matchUserType`).
///
-/// Unknown class names return false here; callers (= the
-/// `__instance?` primitive) should pre-check with `isKnown` and raise
-/// loud rather than relying on silent false.
+/// This is the NAME oracle, for host / native classes. A user class is
+/// never answered by name; callers holding a class VALUE go through
+/// `isInstanceOfClass`, which routes a user class to descriptor identity.
+/// Unknown class names return false here.
pub fn isInstance(v: Value, class_name: []const u8) bool {
const simple = normalizeClassName(class_name);
@@ -339,6 +344,59 @@ pub fn isInstance(v: Value, class_name: []const u8) bool {
return false;
}
+/// `(instance? C v)` for a class VALUE, where `class_td` is the descriptor `C`
+/// evaluates to. The entry point for every caller that holds a class rather
+/// than a name (`instance?`, `Class.isInstance`). A user class is answered by
+/// identity (ADR-0201): `v` is a member iff `class_td` is `v`'s descriptor or on
+/// its parent chain, so a redefined `R` and its previous vintage, or `aaa.Point`
+/// and `bbb.Point`, never answer for each other. A host / native class has no
+/// user descriptor and goes to the name oracle `isInstance`.
+pub fn isInstanceOfClass(v: Value, class_td: *const TypeDescriptor) bool {
+ if (class_td.isUserClass()) {
+ const own = userInstanceDescriptor(v) orelse return false;
+ return descendsFrom(own, class_td);
+ }
+ const name = class_td.fqcn orelse return false;
+ return isInstance(v, name);
+}
+
+/// Class-vs-class `isa?` over class VALUES, shared by the `isa?` primitive and
+/// multimethod dispatch so the two cannot drift (D-464). Identity first. A
+/// user-class PARENT is reached only along `child`'s parent chain. A user-class
+/// CHILD under a host parent is a subclass of Object and of what it declares
+/// (`descriptorIsa`, the relation `instance?` reads), never of a host class that
+/// merely shares its simple name: a `(deftype BigInt [])` is not a Number. Only
+/// a native child keeps the name relation `classIsa` (ADR-0201).
+pub fn classIsaOf(child: *const TypeDescriptor, parent: *const TypeDescriptor) bool {
+ if (child == parent) return true;
+ if (parent.isUserClass()) return descendsFrom(child, parent);
+ const parent_name = parent.fqcn orelse return false;
+ if (child.isUserClass()) {
+ const simple = normalizeClassName(parent_name);
+ return host_class.isUniversalClass(simple) or descriptorIsa(child, simple);
+ }
+ return classIsa(child.fqcn orelse "", parent_name);
+}
+
+/// The descriptor a user-class instance carries, or null for any other value
+/// (a host_instance's descriptor is a native surface, never a user class).
+fn userInstanceDescriptor(v: Value) ?*const TypeDescriptor {
+ return switch (v.tag()) {
+ .typed_instance => v.decodePtr(*const TypedInstance).descriptor,
+ .reified_instance => v.decodePtr(*const ReifiedInstance).descriptor,
+ else => null,
+ };
+}
+
+/// True iff `ancestor` is `td` itself or on `td`'s parent chain, by pointer.
+fn descendsFrom(td: *const TypeDescriptor, ancestor: *const TypeDescriptor) bool {
+ var cursor: ?*const TypeDescriptor = td;
+ while (cursor) |t| : (cursor = t.parent) {
+ if (t == ancestor) return true;
+ }
+ return false;
+}
+
fn isThrowableTag(t: Tag) bool {
return switch (t) {
.ex_info => true,
@@ -408,15 +466,15 @@ fn displayClassTag(name: []const u8) ?Tag {
return M.get(name);
}
-/// Class-vs-class `isa?` (the `(isa? )` hierarchy step), by fqcn
-/// name. SHARED by `core.classIsaPrim` (the `isa?` primitive) and
-/// `multimethod.isaCheck` (method dispatch) so the two cannot drift (D-464 — a
-/// `(defmethod f java.lang.Number)` must be found for a `Long` dispatch value
-/// exactly as `(isa? Long Number)` is true). Order mirrors clj's isa? class
-/// step: identity, universal Object, narrow Number, narrow IFn, interface-marker
-/// membership, then the host exception/parent hierarchy. The ad-hoc `derive`
-/// hierarchy and non-class isa? stay with the callers.
-pub fn classIsa(child: []const u8, parent: []const u8) bool {
+/// Class-vs-class `isa?` BY NAME, for a host / native parent: the name half of
+/// `classIsaOf`, which is the entry both `core.classIsaPrim` (the `isa?`
+/// primitive) and `multimethod.isaCheck` (method dispatch) call so the two
+/// cannot drift (D-464: a `(defmethod f java.lang.Number)` must be found for a
+/// `Long` dispatch value exactly as `(isa? Long Number)` is true). Order mirrors
+/// clj's isa? class step: identity, universal Object, narrow Number, narrow IFn,
+/// interface-marker membership, then the host exception/parent hierarchy. The
+/// ad-hoc `derive` hierarchy and non-class isa? stay with the callers.
+fn classIsa(child: []const u8, parent: []const u8) bool {
if (std.mem.eql(u8, child, parent)) return true;
if (host_class.isUniversalClass(parent)) return true;
if (host_class.isNumberClass(parent)) return host_class.isNumericClass(child);
@@ -442,11 +500,27 @@ fn matchUserType(v: Value, simple: []const u8) bool {
.host_instance => @import("host_instance.zig").asHostInstance(v).descriptor,
else => return false,
};
+ return descriptorIsa(td, simple);
+}
+
+/// True iff a value whose descriptor is `td` is an instance of the host class
+/// named `simple` (normalised). The descriptor-level half of `matchUserType`,
+/// shared with `classIsaOf` so `(instance? C x)` and `(isa? (class x) C)` read
+/// ONE relation. A user class answers only from what it DECLARES (its
+/// interfaces), never from its own name; a native host-surface descriptor also
+/// answers to its own fqcn (ADR-0201).
+fn descriptorIsa(td: *const TypeDescriptor, simple: []const u8) bool {
var cursor: ?*const TypeDescriptor = td;
while (cursor) |t| {
- if (t.fqcn) |fqcn| {
- if (std.mem.eql(u8, fqcn, simple)) return true;
- if (std.mem.eql(u8, normalizeClassName(fqcn), simple)) return true;
+ // Only a native host-surface descriptor is its NAME. A user class's
+ // fqcn is a display string it may share with a host interface (a
+ // `(deftype Sorted [])` is not `clojure.lang.Sorted`) or with another
+ // user class, so it never answers here (ADR-0201).
+ if (!t.isUserClass()) {
+ if (t.fqcn) |fqcn| {
+ if (std.mem.eql(u8, fqcn, simple)) return true;
+ if (std.mem.eql(u8, normalizeClassName(fqcn), simple)) return true;
+ }
}
// The declared interfaces (ADR-0116 Decision B). `protocol_impls` holds
// what the type DECLARED; the method table is not consulted, because a
@@ -676,3 +750,54 @@ test "isCallableClassName derives from the IFn tag set" {
try testing.expect(isClassInterfaceMember("TransientArrayMap", "Counted"));
try testing.expect(!isClassInterfaceMember("Cons", "Counted"));
}
+
+test "ADR-0201: a user class is matched by descriptor identity, never by its simple name" {
+ const Runtime = @import("runtime.zig").Runtime;
+ const td_mod = @import("type_descriptor.zig");
+ var threaded = std.Io.Threaded.init(testing.allocator, .{});
+ defer threaded.deinit();
+ var rt = Runtime.init(threaded.io(), testing.allocator);
+ defer rt.deinit();
+
+ // Two vintages of user.R, plus a namesake in another namespace.
+ const old = try td_mod.registerType(&rt, "R", "user", &.{"a"}, &.{}, .defrecord);
+ const old_inst = try td_mod.allocInstance(&rt, old, &.{Value.initInteger(1)});
+ const new = try td_mod.registerType(&rt, "R", "user", &.{ "a", "b" }, &.{}, .defrecord);
+ const namesake = try td_mod.registerType(&rt, "R", "other", &.{"a"}, &.{}, .defrecord);
+ try testing.expect(old != new);
+
+ try testing.expect(isInstanceOfClass(old_inst, old));
+ try testing.expect(!isInstanceOfClass(old_inst, new));
+ try testing.expect(!isInstanceOfClass(old_inst, namesake));
+ try testing.expect(!isInstanceOfClass(Value.initInteger(1), old));
+ try testing.expect(classIsaOf(old, old));
+ try testing.expect(!classIsaOf(old, new));
+ try testing.expect(!classIsaOf(new, old));
+ try testing.expect(!classIsaOf(namesake, old));
+ // The name oracle no longer answers for a user class by its display name.
+ try testing.expect(!isInstance(old_inst, "R"));
+
+ // Under a host parent a user class is an Object, and is NOT the subclass of
+ // a host class that merely shares its simple name.
+ const object = try rt.classDescriptor("Object");
+ try testing.expect(classIsaOf(old, object));
+ try testing.expect(isInstanceOfClass(old_inst, object));
+ const user_big_int = try td_mod.registerType(&rt, "BigInt", "user", &.{}, &.{}, .deftype);
+ try testing.expect(!classIsaOf(user_big_int, try rt.classDescriptor("Number")));
+ try testing.expect(classIsaOf(user_big_int, object));
+
+ // A reify descriptor has no name at all and is still its own class.
+ const reify_td: TypeDescriptor = .{
+ .fqcn = null,
+ .kind = .reify_anon,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+ };
+ const reified = try td_mod.allocReifiedInstance(&rt, &reify_td);
+ try testing.expect(isInstanceOfClass(reified, &reify_td));
+ try testing.expect(!isInstanceOfClass(reified, old));
+ try testing.expect(classIsaOf(&reify_td, object));
+}
diff --git a/src/runtime/cljw/wasm/engine.zig b/src/runtime/cljw/wasm/engine.zig
index 84cc0e593..29b52429e 100644
--- a/src/runtime/cljw/wasm/engine.zig
+++ b/src/runtime/cljw/wasm/engine.zig
@@ -25,6 +25,8 @@ pub const Value = zwasm.Value;
pub const FuncType = zwasm.ir.zir.FuncType;
pub const ValType = zwasm.ir.zir.ValType;
pub const Memory = zwasm.Memory;
+/// Owned export-descriptor list (zwasm `Module.exports`), ADR-0203.
+pub const ModuleExports = @typeInfo(@typeInfo(@TypeOf(zwasm.Module.exports)).@"fn".return_type.?).error_union.payload;
/// Per-axis runtime budget for a loaded module (re-exported from zwasm's
/// `Module.Budget`, ADR-0179). `.unmetered` lifts the cap (trusted modules
@@ -80,6 +82,10 @@ pub const Loaded = struct {
/// Keys are owned copies; values are slices into the instance's own
/// signature storage, valid for the instance's lifetime.
sig_cache: std.StringHashMapUnmanaged(FuncType),
+ /// Export descriptors (name + kind, export order) decoded at `load`, while
+ /// the module bytes are still alive: zwasm's native section view borrows
+ /// them, so a later `Module.exports` would decode freed memory (ADR-0203).
+ export_list: ModuleExports,
/// Runtime signature of an exported function (`null` if absent / not a
/// func). Drives the marshal: caller sizes its `[]Value` buffers from
@@ -120,6 +126,7 @@ pub const Loaded = struct {
var keys = self.sig_cache.keyIterator();
while (keys.next()) |k| self.alloc.free(k.*);
self.sig_cache.deinit(self.alloc);
+ self.export_list.deinit();
self.instance.deinit();
self.module.deinit();
self.engine.deinit();
@@ -295,6 +302,8 @@ pub fn load(alloc: std.mem.Allocator, bytes: []const u8, opts: LoadOpts) !*Loade
if (opts.fuel) |b| inst_opts.fuel = b;
if (opts.max_memory_pages) |b| inst_opts.max_memory_pages = b;
inst_opts.engine = opts.engine;
+ self.export_list = try self.module.exports(alloc);
+ errdefer self.export_list.deinit();
self.instance = try self.module.instantiate(inst_opts);
self.engine_kind = opts.engine;
self.alloc = alloc;
diff --git a/src/runtime/cljw/wasm/exports.zig b/src/runtime/cljw/wasm/exports.zig
new file mode 100644
index 000000000..322061da5
--- /dev/null
+++ b/src/runtime/cljw/wasm/exports.zig
@@ -0,0 +1,160 @@
+// SPDX-License-Identifier: EPL-2.0
+//! `(wasm/exports h)` — list a core module's exports in export-section order
+//! (ADR-0203 decision 1). A function answers
+//! `{:name "add" :kind :func :params [:i32 :i32] :results [:i32]}`; a memory,
+//! global or table answers `{:name "memory" :kind :memory}`. The function
+//! signature comes from `Loaded.exportSig`, the same runtime probe `wasm/call`
+//! marshals through, so nothing here is declared by hand and a listed function
+//! is callable at exactly the listed arity. Compiled ONLY under `-Dwasm`.
+//!
+//! Backend: impl-only
+//! Impl deps: none
+//! Clojure peer: wasm/exports
+const std = @import("std");
+const zwasm = @import("zwasm");
+const engine = @import("engine.zig");
+const wasm_handle = @import("wasm_handle.zig");
+const Runtime = @import("../../runtime.zig").Runtime;
+const Env = @import("../../env.zig").Env;
+const Value = @import("../../value/value.zig").Value;
+const error_catalog = @import("../../error/catalog.zig");
+const SourceLocation = @import("../../error/info.zig").SourceLocation;
+const string_mod = @import("../../collection/string.zig");
+const vector_mod = @import("../../collection/vector.zig");
+const map_mod = @import("../../collection/map.zig");
+const keyword_mod = @import("../../keyword.zig");
+
+/// `(wasm/exports handle)` — a vector of export maps, in export order.
+pub fn wasmExportsFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("wasm/exports", args, 1, loc);
+ if (!wasm_handle.isHandle(args[0]))
+ return error_catalog.raise(.wasm_handle_invalid, loc, .{ .fn_name = "wasm/exports" });
+ const loaded = wasm_handle.unwrap(args[0]);
+
+ const name_kw = try keyword_mod.intern(rt, null, "name");
+ const kind_kw = try keyword_mod.intern(rt, null, "kind");
+ const params_kw = try keyword_mod.intern(rt, null, "params");
+ const results_kw = try keyword_mod.intern(rt, null, "results");
+
+ // The partial `out` vector and each `m` live only in Zig locals until
+ // return, so no collection may run while they are built.
+ rt.gc.enterFabrication();
+ defer rt.gc.exitFabrication();
+
+ var out = vector_mod.empty();
+ for (loaded.export_list.items) |item| {
+ var m = map_mod.empty();
+ m = try map_mod.assoc(rt, m, name_kw, try string_mod.alloc(rt, item.name));
+ m = try map_mod.assoc(rt, m, kind_kw, try keyword_mod.intern(rt, null, kindName(item.kind)));
+ if (item.kind == .func) {
+ // A func export always has a signature; a miss would mean the
+ // instance and the export section disagree, so the map simply
+ // carries no signature rather than inventing one.
+ if (loaded.exportSig(item.name)) |sig| {
+ m = try map_mod.assoc(rt, m, params_kw, try typeVector(rt, sig.params));
+ m = try map_mod.assoc(rt, m, results_kw, try typeVector(rt, sig.results));
+ }
+ }
+ out = try vector_mod.conj(rt, out, m);
+ }
+ return out;
+}
+
+fn typeVector(rt: *Runtime, types: []const engine.ValType) anyerror!Value {
+ var v = vector_mod.empty();
+ for (types) |t| v = try vector_mod.conj(rt, v, try keyword_mod.intern(rt, null, valTypeName(t)));
+ return v;
+}
+
+/// The `:kind` keyword name of an export.
+pub fn kindName(kind: zwasm.ExternKind) []const u8 {
+ return switch (kind) {
+ .func => "func",
+ .table => "table",
+ .memory => "memory",
+ .global => "global",
+ // The binary format has no tag export; zwasm's enum shares the import side.
+ .tag => "tag",
+ };
+}
+
+/// The keyword name of a value type: `i32 i64 f32 f64 v128`, and for a
+/// reference its abstract heap type's text-format shorthand (`funcref`,
+/// `externref`, ...). A concrete typed reference `(ref $t)` names as `ref`.
+pub fn valTypeName(t: engine.ValType) []const u8 {
+ return switch (t) {
+ .i32 => "i32",
+ .i64 => "i64",
+ .f32 => "f32",
+ .f64 => "f64",
+ .v128 => "v128",
+ .ref => |r| switch (r.heap_type) {
+ .concrete => "ref",
+ .abstract => |a| switch (a) {
+ .func => "funcref",
+ .extern_ => "externref",
+ .any => "anyref",
+ .eq => "eqref",
+ .i31 => "i31ref",
+ .struct_ => "structref",
+ .array => "arrayref",
+ .none => "nullref",
+ .noextern => "nullexternref",
+ .nofunc => "nullfuncref",
+ .exn => "exnref",
+ .noexn => "nullexnref",
+ },
+ },
+ };
+}
+
+test "valTypeName names scalars and the abstract references" {
+ const VT = engine.ValType;
+ try std.testing.expectEqualStrings("i32", valTypeName(.i32));
+ try std.testing.expectEqualStrings("f64", valTypeName(.f64));
+ try std.testing.expectEqualStrings("v128", valTypeName(.v128));
+ try std.testing.expectEqualStrings("funcref", valTypeName(VT.funcref));
+ try std.testing.expectEqualStrings("externref", valTypeName(VT.externref));
+}
+
+test "kindName names every export kind" {
+ try std.testing.expectEqualStrings("func", kindName(.func));
+ try std.testing.expectEqualStrings("memory", kindName(.memory));
+ try std.testing.expectEqualStrings("global", kindName(.global));
+ try std.testing.expectEqualStrings("table", kindName(.table));
+}
+
+// (module (func (export "add") (param i32 i32) (result i32)
+// local.get 0 local.get 1 i32.add) (memory (export "memory") 1))
+const add_mem_wasm = [_]u8{
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60,
+ 0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x05, 0x03, 0x01,
+ 0x00, 0x01, 0x07, 0x10, 0x02, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x06,
+ 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0a, 0x09, 0x01, 0x07,
+ 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b,
+};
+
+test "the export list outlives the module bytes and pairs with exportSig" {
+ const alloc = std.testing.allocator;
+ inline for (.{ engine.EngineKind.interp, engine.EngineKind.jit }) |kind| {
+ // `wasm/load` frees the file bytes right after `load`; mirror that.
+ const bytes = try alloc.dupe(u8, &add_mem_wasm);
+ const loaded = try engine.load(alloc, bytes, .{ .engine = kind });
+ @memset(bytes, 0);
+ alloc.free(bytes);
+ defer {
+ loaded.deinit();
+ alloc.destroy(loaded);
+ }
+ const items = loaded.export_list.items;
+ try std.testing.expectEqual(@as(usize, 2), items.len);
+ try std.testing.expectEqualStrings("add", items[0].name);
+ try std.testing.expectEqualStrings("func", kindName(items[0].kind));
+ try std.testing.expectEqualStrings("memory", items[1].name);
+ try std.testing.expectEqualStrings("memory", kindName(items[1].kind));
+ const sig = loaded.exportSig("add").?;
+ try std.testing.expectEqual(@as(usize, 2), sig.params.len);
+ try std.testing.expectEqualStrings("i32", valTypeName(sig.results[0]));
+ }
+}
diff --git a/src/runtime/cljw/wasm/surface.zig b/src/runtime/cljw/wasm/surface.zig
index 58c1afc30..ae00e73a9 100644
--- a/src/runtime/cljw/wasm/surface.zig
+++ b/src/runtime/cljw/wasm/surface.zig
@@ -12,7 +12,7 @@
//!
//! Backend: impl-only
//! Impl deps: none
-//! Clojure peer: wasm/load, wasm/call, wasm/engine, wasm/mem-size, wasm/mem-read, wasm/mem-write!
+//! Clojure peer: wasm/load, wasm/call, wasm/exports, wasm/engine, wasm/mem-size, wasm/mem-read, wasm/mem-write!
const std = @import("std");
const engine = @import("engine.zig");
const run_cache = @import("run_cache.zig");
@@ -138,10 +138,19 @@ pub fn wasmCallFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
if (!args[1].isString())
return error_catalog.raise(.wasm_export_name_invalid, loc, .{});
- const loaded = wasm_handle.unwrap(args[0]);
- const name = string_mod.asString(args[1]);
- const call_args = args[2..];
+ return callExport(rt, wasm_handle.unwrap(args[0]), string_mod.asString(args[1]), args[2..], loc);
+}
+
+/// `(.export h args…)` on a `.wasm_module` receiver (ADR-0203 decision 2):
+/// the method name is the export name verbatim, and the call is `wasm/call`'s
+/// own path. Installed on `rt.wasm_member_call` by `register`.
+fn memberCallFn(rt: *Runtime, receiver: Value, name: []const u8, args: []const Value, loc: SourceLocation) anyerror!Value {
+ return callExport(rt, wasm_handle.unwrap(receiver), name, args, loc);
+}
+/// The one export-invoke path shared by `wasm/call` and dot-interop: signature
+/// probe, arity check, marshalling, fuel and trap mapping.
+fn callExport(rt: *Runtime, loaded: *engine.Loaded, name: []const u8, call_args: []const Value, loc: SourceLocation) anyerror!Value {
const sig = loaded.exportSig(name) orelse
return error_catalog.raise(.wasm_export_not_found, loc, .{ .name = name });
if (call_args.len != sig.params.len)
@@ -600,10 +609,13 @@ pub fn register(env: *Env) !void {
// Register the `.wasm_module` GC finaliser so a swept `(wasm/load …)` handle
// tears down its zwasm triple instead of leaking (D-259 (b)).
wasm_handle.registerGcHooks();
+ // ADR-0203: `.member` dispatch on a handle routes to `callExport`.
+ env.rt.wasm_member_call = &memberCallFn;
const ns = try env.findOrCreateNs("wasm");
_ = try env.intern(ns, "load", Value.initBuiltinFn(&wasmLoadFn), null);
_ = try env.intern(ns, "call", Value.initBuiltinFn(&wasmCallFn), null);
_ = try env.intern(ns, "engine", Value.initBuiltinFn(&wasmEngineFn), null);
+ _ = try env.intern(ns, "exports", Value.initBuiltinFn(&@import("exports.zig").wasmExportsFn), null);
_ = try env.intern(ns, "run", Value.initBuiltinFn(&wasmRunFn), null);
_ = try env.intern(ns, "clear-cache!", Value.initBuiltinFn(&wasmClearCacheFn), null);
// ADR-0192: the (ptr,len) half of the FFI — `wasm/call` passes the pointer,
diff --git a/src/runtime/java/lang/Class.zig b/src/runtime/java/lang/Class.zig
index 8279e9ee2..b064def83 100644
--- a/src/runtime/java/lang/Class.zig
+++ b/src/runtime/java/lang/Class.zig
@@ -60,16 +60,16 @@ fn getSimpleName(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocati
}
/// `(.isInstance klass obj)` — true iff `obj` is an instance of `klass`. Routes
-/// through the shared `class_name.isInstance` predicate (native tags + interface
-/// sets + Throwable hierarchy + user TypeDescriptor walk). JVM reference:
+/// through the same `class_name.isInstanceOfClass` entry `instance?` uses
+/// (native tags + interface sets + Throwable hierarchy by name, descriptor
+/// identity for a user class per ADR-0201). JVM reference:
/// java.lang.Class#isInstance.
fn isInstance(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
_ = rt;
_ = env;
try error_catalog.checkArity(".isInstance", args, 2, loc);
const td = type_descriptor.asTypeDescriptorRef(args[0]);
- const name = td.fqcn orelse return .false_val;
- return Value.initBoolean(class_name.isInstance(args[1], name));
+ return Value.initBoolean(class_name.isInstanceOfClass(args[1], td));
}
/// Populate the per-Runtime `.type_descriptor` native descriptor's method table
diff --git a/src/runtime/multimethod.zig b/src/runtime/multimethod.zig
index 70dd1d373..1c6a7daf2 100644
--- a/src/runtime/multimethod.zig
+++ b/src/runtime/multimethod.zig
@@ -153,13 +153,11 @@ pub fn isPreferred(prefer_table: Value, x: Value, y: Value) !bool {
return try set_mod.contains(preferred_over, y);
}
-/// cw v1 `isa?` check: equality + hierarchy ancestors-map lookup +
-/// TypeDescriptor parent walk. Mirrors `clojure.core/isa?` steps
-/// 1 + 3. Step 2 (`Class.isAssignableFrom`) and step 4 (`supers`
-/// walk) have no JVM-direct equivalent in cw v1 per ADR-0007
-/// Option β + `.claude/rules/no_jvm_specific_assumption.md` — the
-/// `typeDescriptorIsa` walk is the cw-side replacement (DIVERGENCE
-/// from JVM class hierarchy).
+/// cw v1 `isa?` check for multimethod dispatch, mirroring `clojure.core/isa?`:
+/// equality, element-wise vectors, the class-vs-class step through the shared
+/// `class_name.classIsaOf` (clj's `Class.isAssignableFrom` step, the same
+/// function the `isa?` primitive calls), then the `derive` hierarchy's
+/// ancestors map.
///
/// `hierarchy_ancestors` is the `:ancestors` sub-map of a
/// hierarchy struct: `{child #{ancestor1 ancestor2 ...}, ...}`.
@@ -186,38 +184,22 @@ pub fn isaCheck(hierarchy_ancestors: Value, child: Value, parent: Value) !bool {
// D-464: a class VALUE child isa? a class VALUE parent via the class hierarchy
// (Long isa? Number, vector-class isa? Sequential), through the SAME shared
- // `class_name.classIsa` the `isa?` primitive uses. Only short-circuits on a
- // true match; a class can ALSO sit in an ad-hoc `derive` hierarchy, so a
+ // `class_name.classIsaOf` the `isa?` primitive uses, so a method keyed by a
+ // user class catches only that class, never a same-named redefinition
+ // vintage or another namespace's namesake (ADR-0201). Only short-circuits on
+ // a true match; a class can ALSO sit in an ad-hoc `derive` hierarchy, so a
// non-match falls through to the ancestors lookup below.
if (child.tag() == .type_descriptor and parent.tag() == .type_descriptor) {
- const child_fqcn = td_mod.asTypeDescriptorRef(child).fqcn orelse "";
- const parent_fqcn = td_mod.asTypeDescriptorRef(parent).fqcn orelse "";
- if (class_name_mod.classIsa(child_fqcn, parent_fqcn)) return true;
- }
-
- // Row 7.5 cycle 6 — D-082 discharge: typed_instance + reified_instance
- // children walk their descriptor chain. JVM `clojure.core/isa?` step 2
- // (`Class.isAssignableFrom`) + step 4 (`supers` walk) map onto:
- // - `descriptor.fqcn` ≡ JVM Class.name
- // - `descriptor.protocol_impls` ≡ JVM implementedInterfaces walk
- // - `descriptor.parent` ≡ JVM superclass chain
- // Parent symbol/keyword name is matched against each. Triggered only
- // when child is a TypedInstance / ReifiedInstance and parent is
- // a Named (symbol or keyword) — the JVM analogue's input shape.
- if ((child.tag() == .typed_instance or child.tag() == .reified_instance) and
- (parent.tag() == .symbol or parent.tag() == .keyword))
- {
- const parent_name: []const u8 = if (parent.tag() == .symbol)
- symbol.asSymbol(parent).name
- else
- keyword.asKeyword(parent).name;
- const child_desc: *const td_mod.TypeDescriptor = if (child.tag() == .typed_instance)
- child.decodePtr(*const td_mod.TypedInstance).descriptor
- else
- child.decodePtr(*const td_mod.ReifiedInstance).descriptor;
- if (typeDescriptorIsa(child_desc, parent_name)) return true;
+ const child_td = td_mod.asTypeDescriptorRef(child);
+ const parent_td = td_mod.asTypeDescriptorRef(parent);
+ if (class_name_mod.classIsaOf(child_td, parent_td)) return true;
}
+ // An INSTANCE child (a `(defmulti m identity)` dispatch value) is related
+ // to a symbol or keyword parent only through the `derive` hierarchy below,
+ // never by its class's simple name or a protocol its type declares:
+ // clj's isa? has no such step, and `(isa? (->R 1) 'R)` is false here too
+ // (ADR-0201).
if (hierarchy_ancestors.tag() == .nil) return false;
const child_ancestors = try map_mod.get(hierarchy_ancestors, child);
@@ -225,22 +207,6 @@ pub fn isaCheck(hierarchy_ancestors: Value, child: Value, parent: Value) !bool {
return try set_mod.contains(child_ancestors, parent);
}
-/// Walk a descriptor's `fqcn` + `protocol_impls` + `parent` chain
-/// looking for a match against `parent_name`. Row 7.5 cycle 6 D-082
-/// discharge — replaces JVM `Class.isAssignableFrom` + `supers` walk
-/// per `.claude/rules/no_jvm_specific_assumption.md` (cw-side
-/// equivalent of class hierarchy via TypeDescriptor).
-fn typeDescriptorIsa(td: *const td_mod.TypeDescriptor, parent_name: []const u8) bool {
- if (td.fqcn) |fqcn| {
- if (std.mem.eql(u8, fqcn, parent_name)) return true;
- }
- for (td.protocol_impls) |impl| {
- if (std.mem.eql(u8, impl, parent_name)) return true;
- }
- if (td.parent) |p| return typeDescriptorIsa(p, parent_name);
- return false;
-}
-
/// Dereference an IRef-shape hierarchy reference to its held map.
/// `defmulti` threads the `-global-hierarchy` atom (D-161), so an
/// atom ref is deref'd to its current value — and crucially each
@@ -545,74 +511,31 @@ test "isaCheck: nil hierarchy falls back to equality only" {
try testing.expect(!(try isaCheck(Value.nil_val, a, b)));
}
-test "isaCheck: typed_instance child matches descriptor.fqcn against Symbol parent (D-082)" {
+test "isaCheck: an instance child is not isa? a Named parent spelled like its class (ADR-0201)" {
var fix: TestFixture = undefined;
fix.init(testing.allocator);
defer fix.deinit();
- // Synthetic descriptor with fqcn "Foo".
+ // clj's isa? relates an INSTANCE only by equality (derive rejects one as a
+ // child), so neither its class's simple name nor a protocol its type
+ // declares makes `(defmulti m identity)` catch it under that name.
+ const impls = [_][]const u8{"Drawable"};
const td = try testing.allocator.create(td_mod.TypeDescriptor);
defer testing.allocator.destroy(td);
td.* = .{
.fqcn = "Foo",
.kind = .defrecord,
.field_layout = null,
- .protocol_impls = &.{},
- .method_table = &.{},
- .parent = null,
- .meta = Value.nil_val,
- };
- const inst_val = try td_mod.allocInstance(&fix.rt, td, &.{});
- const foo_sym = try symbol.intern(&fix.rt, null, "Foo");
- const bar_sym = try symbol.intern(&fix.rt, null, "Bar");
- try testing.expect(try isaCheck(Value.nil_val, inst_val, foo_sym));
- try testing.expect(!(try isaCheck(Value.nil_val, inst_val, bar_sym)));
-}
-
-test "isaCheck: typed_instance child walks protocol_impls list (D-082)" {
- var fix: TestFixture = undefined;
- fix.init(testing.allocator);
- defer fix.deinit();
-
- const impls = [_][]const u8{ "Drawable", "Serializable" };
- const td = try testing.allocator.create(td_mod.TypeDescriptor);
- defer testing.allocator.destroy(td);
- td.* = .{
- .fqcn = "Shape",
- .kind = .defrecord,
- .field_layout = null,
.protocol_impls = &impls,
.method_table = &.{},
.parent = null,
.meta = Value.nil_val,
};
const inst_val = try td_mod.allocInstance(&fix.rt, td, &.{});
- const drawable_sym = try symbol.intern(&fix.rt, null, "Drawable");
- const unknown_sym = try symbol.intern(&fix.rt, null, "Unknown");
- try testing.expect(try isaCheck(Value.nil_val, inst_val, drawable_sym));
- try testing.expect(!(try isaCheck(Value.nil_val, inst_val, unknown_sym)));
-}
-
-test "isaCheck: reified_instance child walks descriptor like typed_instance (D-082)" {
- var fix: TestFixture = undefined;
- fix.init(testing.allocator);
- defer fix.deinit();
-
- const impls = [_][]const u8{"IProto"};
- const td = try testing.allocator.create(td_mod.TypeDescriptor);
- defer testing.allocator.destroy(td);
- td.* = .{
- .fqcn = null,
- .kind = .reify_anon,
- .field_layout = null,
- .protocol_impls = &impls,
- .method_table = &.{},
- .parent = null,
- .meta = Value.nil_val,
- };
- const inst_val = try td_mod.allocReifiedInstance(&fix.rt, td);
- const iproto_sym = try symbol.intern(&fix.rt, null, "IProto");
- try testing.expect(try isaCheck(Value.nil_val, inst_val, iproto_sym));
+ try testing.expect(!(try isaCheck(Value.nil_val, inst_val, try symbol.intern(&fix.rt, null, "Foo"))));
+ try testing.expect(!(try isaCheck(Value.nil_val, inst_val, try keyword.intern(&fix.rt, null, "Foo"))));
+ try testing.expect(!(try isaCheck(Value.nil_val, inst_val, try symbol.intern(&fix.rt, null, "Drawable"))));
+ try testing.expect(try isaCheck(Value.nil_val, inst_val, inst_val));
}
test "getMethod: hierarchy walk returns ancestor's method on isa? match" {
diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig
index 32aa1aa67..a508c7c0e 100644
--- a/src/runtime/runtime.zig
+++ b/src/runtime/runtime.zig
@@ -393,6 +393,13 @@ pub const Runtime = struct {
/// the zone direction legal (lang installs into runtime).
source_resolver: ?*const fn (*Runtime, []const u8) ?SourceContext = null,
+ /// ADR-0203 decision 2: `(.export h args…)` on a `.wasm_module` receiver.
+ /// Both backends' `.member` dispatch call this on a method-table miss; the
+ /// wasm surface (`runtime/cljw/wasm/surface.zig`) installs it at `register`,
+ /// so it is set only under `-Dwasm` (F-001) and the eval zone never imports
+ /// a surface. `args` EXCLUDES the receiver; `name` is the export, verbatim.
+ wasm_member_call: ?*const fn (rt: *Runtime, receiver: @import("value/value.zig").Value, name: []const u8, args: []const @import("value/value.zig").Value, loc: @import("error/info.zig").SourceLocation) anyerror!@import("value/value.zig").Value = null,
+
/// Swappable resolver fn for `require`. ADR-0035 D8. `null` at
/// `init`; bootstrap installs the embedded resolver. Phase 12+
/// can swap for classpath / build-artifact resolvers; Phase 16+
diff --git a/src/runtime/type_descriptor.zig b/src/runtime/type_descriptor.zig
index 8fc5b5389..de2e8a663 100644
--- a/src/runtime/type_descriptor.zig
+++ b/src/runtime/type_descriptor.zig
@@ -262,6 +262,15 @@ pub const TypeDescriptor = struct {
big_decimal: i64,
};
+ /// True iff this descriptor is a class the USER defined (`deftype`,
+ /// `defrecord`, `reify`), as opposed to a native / host-surface class.
+ /// A user class is identified by the descriptor itself, never by its
+ /// display name (ADR-0201): two vintages of a redefined `R`, or `aaa.Point`
+ /// and `bbb.Point`, share a simple name and are still different classes.
+ pub fn isUserClass(self: *const TypeDescriptor) bool {
+ return self.kind != .native;
+ }
+
/// Find a static field by name (ADR-0061). Linear — field tables are
/// tiny (≤ 2 today). Parallel to `lookupMethod`; walks the parent
/// chain for symmetry though host surfaces have no parent.
@@ -662,17 +671,6 @@ pub fn finaliseReifiedInstance(gc_ptr: *anyopaque, header: *HeapHeader) void {
_ = header;
}
-/// Allocate a TypeDescriptor on `rt.gpa` with the declared field
-/// layout and register it in `rt.types` under `name`. Re-registering
-/// the same name frees the previous descriptor (keeps the REPL
-/// re-`def` path clean). `field_names` is borrowed; each entry is
-/// `dupe`'d into the descriptor.
-///
-/// Shared by the `__deftype!` and `__defrecord!` Layer-2 primitives —
-/// both lower to the same Layer-0 surface per F-009 (feature-impl
-/// neutrality), differing only in the `kind` argument. Since ADR-0066
-/// both `deftype` and `defrecord` are macros emitting their respective
-/// `rt/__…!` registration call (no analyzer Node fork, no backend op).
/// Build a defrecord instance from map `m` — clj's `Record/create`:
/// declared fields pulled by keyword (nil when absent), every remaining
/// key held in the extmap (nil when empty, so the result `=`/hashes as a
@@ -700,6 +698,19 @@ pub fn recordFromMap(rt: *Runtime, td: *const TypeDescriptor, m: Value) !Value {
return allocInstanceFull(rt, td, fields, Value.nil_val, ext);
}
+/// Allocate a TypeDescriptor on `rt.gpa` with the declared field layout and
+/// register it in `rt.types` under its qualified key `.`
+/// (ADR-0198). Re-registering the same key RETIRES the previous descriptor to
+/// `rt.retired_types` rather than freeing it: instances, boxed class values
+/// and CallSite caches still hold it, and the retired descriptor stays a
+/// distinct class from its replacement (ADR-0201). `field_names` is borrowed;
+/// each entry is `dupe`'d into the descriptor.
+///
+/// Shared by the `__deftype!` and `__defrecord!` Layer-2 primitives, which
+/// lower to the same Layer-0 surface per F-009 (feature-impl neutrality) and
+/// differ only in the `kind` argument. Since ADR-0066 both `deftype` and
+/// `defrecord` are macros emitting their `__deftype!` / `__defrecord!`
+/// registration call (no analyzer Node fork, no backend op).
pub fn registerType(
rt: *Runtime,
name: []const u8,
diff --git a/test/clj/suites/class_type_test.clj b/test/clj/suites/class_type_test.clj
index 885e603e3..c8cb9aa9c 100644
--- a/test/clj/suites/class_type_test.clj
+++ b/test/clj/suites/class_type_test.clj
@@ -135,3 +135,55 @@
(is (= "TransientVector" (pr-str (class (transient []))))))
(testing "class-level isa? still holds after the rename (isCallableClassName)"
(is (= true (isa? (class +) clojure.lang.IFn)))))
+
+;; --- ADR-0194: the five seq class symbols tools.reader's `inspect*`
+;; dispatches on. A defmethod on a class symbol resolves it at ANALYZE time,
+;; so these forms loading at all is the first assertion. LazySeq and Cons
+;; name types cljw has, so they are native exact-tag classes and their
+;; methods are reached. The three JVM inner seq classes name types cljw has
+;; no values of (its vector seq is ArraySeq, its map seq is PersistentList),
+;; so they resolve as OPAQUE class values whose `instance?` is uniformly
+;; false and whose methods are never reached (AD-066 / AD-067).
+(defmulti inspect-shape
+ (fn [x] (cond (vector? x) :vector (map? x) :map :else (class x))))
+(defmethod inspect-shape clojure.lang.PersistentVector$ChunkedSeq [_] :vec-seq)
+(defmethod inspect-shape clojure.lang.PersistentArrayMap$Seq [_] :map-seq)
+(defmethod inspect-shape clojure.lang.PersistentHashMap$NodeSeq [_] :map-seq)
+(defmethod inspect-shape clojure.lang.Cons [_] :cons)
+(defmethod inspect-shape clojure.lang.LazySeq [_] :lazy-seq)
+(defmethod inspect-shape :default [_] :other)
+
+(deftest native-seq-classes
+ (testing "LazySeq resolves and is the class of a lazy seq"
+ (is (= true (class? clojure.lang.LazySeq)))
+ (is (= true (= clojure.lang.LazySeq (class (map inc [1])))))
+ (is (= true (instance? clojure.lang.LazySeq (map inc [1]))))
+ (is (= false (instance? clojure.lang.LazySeq [1])))
+ (is (= false (instance? clojure.lang.LazySeq (range 3)))))
+ (testing "Cons is a native exact-tag class, as on the JVM"
+ (is (= true (class? clojure.lang.Cons)))
+ (is (= true (= clojure.lang.Cons (class (cons 1 [2 3])))))
+ (is (= true (instance? clojure.lang.Cons (cons 1 [2 3]))))))
+
+(deftest opaque-jvm-seq-classes
+ (testing "each resolves as a class value distinct from the others"
+ (is (= true (class? clojure.lang.PersistentVector$ChunkedSeq)))
+ (is (= true (class? clojure.lang.PersistentArrayMap$Seq)))
+ (is (= true (class? clojure.lang.PersistentHashMap$NodeSeq)))
+ (is (= false (= clojure.lang.Cons clojure.lang.PersistentVector$ChunkedSeq)))
+ (is (= false (= clojure.lang.PersistentArrayMap$Seq
+ clojure.lang.PersistentHashMap$NodeSeq))))
+ (testing "no cljw value is an instance of one (AD-066 / AD-067)"
+ (is (= false (instance? clojure.lang.PersistentVector$ChunkedSeq (seq [1 2 3]))))
+ (is (= false (instance? clojure.lang.PersistentArrayMap$Seq (seq {:a 1}))))
+ (is (= false (instance? clojure.lang.PersistentHashMap$NodeSeq
+ (seq (zipmap (range 20) (range 20))))))))
+
+(deftest inspect-multimethod-dispatches
+ (testing "the native methods are reached"
+ (is (= :lazy-seq (inspect-shape (map inc [1 2]))))
+ (is (= :cons (inspect-shape (cons 1 [2 3])))))
+ (testing "the opaque methods are dead: cljw seqs fall to :default (AD-066 / AD-067)"
+ (is (= :other (inspect-shape (seq [1 2 3]))))
+ (is (= :other (inspect-shape (seq (range 3)))))
+ (is (= :other (inspect-shape (seq {:a 1}))))))
diff --git a/test/clj/suites/type_redefine_test.clj b/test/clj/suites/type_redefine_test.clj
index 8fd4b771c..28b75b858 100644
--- a/test/clj/suites/type_redefine_test.clj
+++ b/test/clj/suites/type_redefine_test.clj
@@ -19,6 +19,26 @@
(ns suites.type-redefine-test
(:require [clojure.test :refer [deftest is]]))
+;; --- two namespaces defining the SAME simple name must retain distinct
+;; descriptors. Unlike re-evaluation, this is not a redefine: the registry
+;; key must include the namespace. The CLI reproduction is the
+;; `ns-collision-then-narrowing-redefine` row at the end of this file.
+(ns suites.tduafa)
+(defrecord Point [x y])
+(def a (->Point 1 2))
+
+(ns suites.tduafb)
+(defrecord Point [x y])
+(def b (->Point 3 4))
+
+(ns suites.type-redefine-test
+ (:require [clojure.test :refer [deftest is]]))
+
+(deftest equal-simple-names-in-distinct-namespaces
+ (is (= "#suites.tduafa.Point{:x 1, :y 2}" (pr-str suites.tduafa/a)))
+ (is (= "#suites.tduafb.Point{:x 3, :y 4}" (pr-str suites.tduafb/b)))
+ (is (not (= (class suites.tduafa/a) (class suites.tduafb/b)))))
+
;; --- same name, same shape, redefined in one namespace (the REPL re-eval
;; and `require :reload` path) ---
@@ -115,3 +135,116 @@
(dotimes [_ 200] (vec (range 64)))
(is (some? captured-class))
(is (string? (pr-str captured-class))))
+
+;; --- scenarios migrated from the former test/e2e/type_descriptor_redefinition.sh.
+;; Every expected value is the JVM's (clojure -M). The printed forms use
+;; hyphen-free namespaces because clj munges `-` to `_` in a record class name
+;; and cljw does not yet (ADR-0201 Consequences). ---
+
+;; Same simple name in two namespaces, then a narrowing redefine in the first.
+(ns suites.tdra)
+(defrecord Point [x y])
+(def a (->Point 1 2))
+
+(ns suites.tdrb)
+(defrecord Point [x y])
+(def b (->Point 3 4))
+
+(ns suites.tdra)
+(def a-repr-after-namesake (pr-str a))
+(defrecord Point [x])
+(def a-after (->Point 5))
+(deftype Box [v])
+(def old-box (Box. 7))
+(deftype Box [v])
+
+;; defrecord R [a] -> R [a b], each vintage extended separately; a deftype
+;; with an inline impl redefined without it; then registration churn under GC.
+(ns suites.tdrc)
+(defprotocol Area (area [s]))
+(defrecord R [a])
+(extend-protocol Area R (area [s] (:a s)))
+(def r (->R 1))
+(def rc (class r))
+(defrecord R [a b])
+(extend-protocol Area R (area [s] (+ (:a s) (:b s))))
+(def r-new (->R 2 3))
+(def r-nil (->R 1 nil))
+
+(deftype T [x] Area (area [_] x))
+(def t (T. 5))
+(deftype T [x y])
+
+(def last-area (atom nil))
+(dotimes [i 3000]
+ (eval '(defrecord Churn [v]))
+ (reset! last-area (area (reify Area (area [_] i)))))
+(def cs (vec (for [i (range 50)]
+ (do (eval '(defrecord Churn [v w]))
+ ((ns-resolve 'suites.tdrc '->Churn) i i)))))
+(System/gc)
+
+;; A constructor form binds the class of the namespace it was WRITTEN in
+;; ([CLJW-CTOR-ANALYZE-TIME]). The factories, a `(Point. x y)` in a later fn
+;; and one inside the record's own method body all build suites.tdrd.Point when
+;; called from another namespace after suites.tdre registered a namesake with a
+;; different shape.
+(ns suites.tdrd)
+(defprotocol Grow (grow [p]))
+(defrecord Point [x y] Grow (grow [_] (Point. (inc x) y)))
+(deftype Box [v])
+(defn mk [] (Point. 1 2))
+
+(ns suites.tdre)
+(defrecord Point [q])
+(deftype Box [v w])
+
+(ns suites.type-redefine-test
+ (:require [clojure.test :refer [deftest is]]))
+
+(deftest ns-collision-then-narrowing-redefine
+ (is (= "#suites.tdra.Point{:x 1, :y 2}" (pr-str suites.tdra/a)))
+ (is (= "#suites.tdra.Point{:x 1, :y 2}" suites.tdra/a-repr-after-namesake))
+ (is (= "#suites.tdrb.Point{:x 3, :y 4}" (pr-str suites.tdrb/b)))
+ (is (= "#suites.tdra.Point{:x 5}" (pr-str suites.tdra/a-after)))
+ (is (= 7 (.-v suites.tdra/old-box))))
+
+(deftest widening-redefine-keeps-the-old-vintage-whole
+ (let [r suites.tdrc/r rc suites.tdrc/rc]
+ (is (= "#suites.tdrc.R{:a 1}" (pr-str r)))
+ (is (= 1 (:a r)))
+ (is (= 1 (.-a r)))
+ (is (true? (instance? rc r)))
+ (is (true? (= rc (class r))))
+ (is (false? (= (class r) suites.tdrc.R)))
+ (is (true? (= r r)))
+ (is (false? (= r suites.tdrc/r-nil)))
+ (is (= (hash r) (hash r)))
+ (is (true? (map? r)))
+ (is (= 1 (suites.tdrc/area r)))
+ (is (= 5 (suites.tdrc/area suites.tdrc/r-new)))))
+
+(deftest deftype-redefine-keeps-the-old-method-table
+ (let [t suites.tdrc/t]
+ (is (= 5 (.-x t)))
+ (is (= 5 (suites.tdrc/area t)))
+ (is (= t t))
+ (is (integer? (hash t)))))
+
+(deftest redefine-churn-under-gc
+ (let [cs suites.tdrc/cs]
+ (is (= 1225 (reduce + (map :v cs))))
+ (is (= "#suites.tdrc.Churn{:v 0, :w 0}" (pr-str (first cs))))
+ (is (= "#suites.tdrc.Churn{:v 49, :w 49}" (pr-str (last cs))))
+ (is (= 2999 @suites.tdrc/last-area))))
+
+(deftest ctor-binds-its-defining-namespace
+ (is (= "#suites.tdrd.Point{:x 1, :y 2}" (pr-str (suites.tdrd/->Point 1 2))))
+ (is (= "#suites.tdrd.Point{:x 1, :y 2}" (pr-str (suites.tdrd/mk))))
+ (is (= "#suites.tdrd.Point{:x 2, :y 2}" (pr-str (suites.tdrd/grow (suites.tdrd/->Point 1 2)))))
+ (is (= "#suites.tdre.Point{:q 9}" (pr-str (suites.tdre/->Point 9))))
+ (is (= "#suites.tdrd.Point{:x 1, :y 2}" (pr-str (suites.tdrd/map->Point {:x 1 :y 2}))))
+ (is (true? (instance? suites.tdrd.Point (suites.tdrd/mk))))
+ (is (false? (instance? suites.tdre.Point (suites.tdrd/mk))))
+ (is (= 7 (.-v (suites.tdrd/->Box 7))))
+ (is (= 8 (.-w (suites.tdre/->Box 7 8)))))
diff --git a/test/clj/suites/user_class_identity_test.clj b/test/clj/suites/user_class_identity_test.clj
new file mode 100644
index 000000000..fb4b61a21
--- /dev/null
+++ b/test/clj/suites/user_class_identity_test.clj
@@ -0,0 +1,169 @@
+;; ADR-0201: a user-defined class (deftype / defrecord / reify) IS its
+;; TypeDescriptor, so membership in it is decided by descriptor identity,
+;; never by comparing simple-name strings.
+;;
+;; The simple-name oracle survived ADR-0198 (which gave every other question,
+;; `(class x)`, `=` on classes, printing, the registry key, a per-descriptor
+;; answer) and so answered `instance?` and `isa?` differently from `=`:
+;; after `(defrecord R [a b])` replaced `(defrecord R [a])`, an OLD instance
+;; was still `(instance? R old)`, a record from another namespace with the same
+;; simple name was an instance of this one, and a deftype named `Sorted` was
+;; `sorted?`. Every expected value below is the JVM's (`clojure -M`).
+(ns suites.user-class-identity-test
+ (:require [clojure.test :refer [deftest is testing]]))
+
+(defprotocol Area (area [s]))
+
+;; --- same-namespace redefinition: the new class is a DIFFERENT class ---
+
+(defrecord R [a])
+(def ^:private old-r (->R 1))
+(def ^:private old-r-class (class old-r))
+(defrecord R [a b])
+(def ^:private new-r (->R 1 2))
+
+(deftest instance-of-redefined-record
+ (is (false? (instance? R old-r)))
+ (is (true? (instance? old-r-class old-r)))
+ (is (true? (instance? R new-r)))
+ (is (false? (instance? old-r-class new-r))))
+
+(deftest class-isInstance-agrees-with-instance?
+ (is (false? (.isInstance R old-r)))
+ (is (true? (.isInstance old-r-class old-r))))
+
+(deftest isa?-between-record-vintages
+ (is (false? (isa? old-r-class R)))
+ (is (false? (isa? R old-r-class)))
+ (is (true? (isa? old-r-class old-r-class)))
+ (is (true? (isa? R R)))
+ (testing "a user class still sits under Object"
+ (is (true? (isa? old-r-class Object)))
+ (is (true? (instance? Object old-r)))))
+
+(defmulti ^:private by-class class)
+(defmethod by-class :default [_] :default)
+(defmethod by-class R [_] :new-r)
+
+(defmulti ^:private by-old-class class)
+(defmethod by-old-class old-r-class [_] :old-r)
+(defmethod by-old-class :default [_] :default)
+
+(deftest multimethod-dispatch-on-class-vintage
+ (testing "a method keyed by the NEW class does not catch an old instance"
+ (is (= :default (by-class old-r)))
+ (is (= :new-r (by-class new-r))))
+ (testing "a method keyed by the OLD class catches only the old instance"
+ (is (= :old-r (by-old-class old-r)))
+ (is (= :default (by-old-class new-r)))))
+
+(deftype T [x])
+(def ^:private old-t (T. 1))
+(def ^:private old-t-class (class old-t))
+(deftype T [x])
+
+(deftest instance-of-redefined-deftype
+ (is (false? (instance? T old-t)))
+ (is (true? (instance? old-t-class old-t)))
+ (is (true? (instance? T (T. 2))))
+ (is (false? (= old-t-class T))))
+
+;; --- reify: its class value is a class like any other ---
+
+(defn- mk-area [i] (reify Area (area [_] i)))
+
+(deftest reify-instance-of-its-own-class
+ (let [a (mk-area 1)]
+ (is (true? (instance? (class a) a)))
+ (is (true? (.isInstance (class a) a)))
+ (is (true? (isa? (class a) (class a))))
+ (is (true? (instance? Object a)))
+ (is (= 1 (area a)))))
+
+;; --- a user type named like a host interface is not that interface ---
+
+(deftype Sorted [])
+
+(deftest user-type-named-like-an-interface
+ (is (false? (sorted? (Sorted.))))
+ (is (true? (instance? Sorted (Sorted.))))
+ (is (false? (instance? clojure.lang.Sorted (Sorted.)))))
+
+;; --- class-level isa? of a user class against a host class: what the user
+;; class DECLARES decides, never its simple name. These names are each a host
+;; class's simple name, which the name oracle used to match. ---
+
+(deftype BigInt [])
+(deftype Keyword [])
+(deftype PersistentVector [])
+(deftype LazySeq [])
+(deftype ExceptionInfo [])
+(deftype Ratio [])
+(deftype Deref [] clojure.lang.IDeref (deref [_] :derefed))
+
+(deftest user-class-named-like-a-host-class-is-not-a-subclass-of-it
+ (is (false? (isa? BigInt Number)))
+ (is (false? (isa? Ratio Number)))
+ (is (false? (isa? Keyword clojure.lang.IFn)))
+ (is (false? (isa? PersistentVector clojure.lang.IFn)))
+ (is (false? (isa? PersistentVector clojure.lang.IPersistentVector)))
+ (is (false? (isa? LazySeq clojure.lang.Seqable)))
+ (is (false? (isa? ExceptionInfo Exception)))
+ (is (false? (isa? Sorted clojure.lang.Sorted)))
+ (is (false? (number? (BigInt.))))
+ (is (false? (ifn? (Keyword.))))
+ (testing "Object and the declared interfaces still answer"
+ (is (true? (isa? BigInt Object)))
+ (is (true? (isa? Deref clojure.lang.IDeref)))
+ (is (= :derefed @(Deref.)))))
+
+(defmulti ^:private by-host-class class)
+(defmethod by-host-class Number [_] :number)
+(defmethod by-host-class clojure.lang.IFn [_] :ifn)
+(defmethod by-host-class :default [_] :default)
+
+(deftest multimethod-dispatch-on-a-user-class-named-like-a-host-class
+ (is (= :default (by-host-class (BigInt.))))
+ (is (= :default (by-host-class (Keyword.))))
+ (is (= :number (by-host-class 1)))
+ (is (= :ifn (by-host-class :k))))
+
+;; --- two namespaces, one simple name: two classes ---
+
+(ns suites.ucia)
+(defrecord Point [x y])
+(def a (->Point 1 2))
+
+(ns suites.ucib)
+(defrecord Point [q])
+(def b (->Point 9))
+
+(ns suites.user-class-identity-test)
+
+(deftest same-simple-name-in-two-namespaces
+ (is (false? (instance? suites.ucia.Point suites.ucib/b)))
+ (is (true? (instance? suites.ucib.Point suites.ucib/b)))
+ (is (true? (instance? suites.ucia.Point suites.ucia/a)))
+ (is (false? (= (class suites.ucia/a) (class suites.ucib/b))))
+ (is (false? (isa? (class suites.ucib/b) suites.ucia.Point)))
+ ;; a factory builds ITS namespace's class, also when called from another
+ ;; namespace after a namesake was registered later
+ (is (= [5 6] ((juxt :x :y) (suites.ucia/->Point 5 6))))
+ (is (true? (instance? suites.ucia.Point (suites.ucia/->Point 5 6))))
+ (is (= {:x 7 :y 8} (into {} (suites.ucia/map->Point {:x 7 :y 8}))))
+ (is (true? (instance? suites.ucib.Point (suites.ucib/->Point 1)))))
+
+;; --- an INSTANCE dispatch value is not isa? its class's name, nor a protocol
+;; its type declares: clj relates an instance only by equality or derive ---
+
+(defmulti ^:private by-identity identity)
+(defmethod by-identity 'R [_] :sym)
+(defmethod by-identity :R [_] :kw)
+(defmethod by-identity 'Area [_] :protocol)
+(defmethod by-identity :default [_] :default)
+
+(deftest instance-dispatch-value-is-not-its-class-name
+ (is (= :default (by-identity new-r)))
+ (is (= :default (by-identity (mk-area 1))))
+ (is (false? (isa? new-r 'R)))
+ (is (false? (isa? new-r :R))))
diff --git a/test/clj/suites/wasm_dot_interop_test.clj b/test/clj/suites/wasm_dot_interop_test.clj
new file mode 100644
index 000000000..964de3867
--- /dev/null
+++ b/test/clj/suites/wasm_dot_interop_test.clj
@@ -0,0 +1,48 @@
+;; Dot-interop on a core wasm module handle (ADR-0203 decision 2).
+;; `(.add h 40 2)`, `(. h add 40 2)` and `(. h (add 40 2))` are
+;; `(wasm/call h "add" 40 2)`: the method symbol's name is the export name
+;; verbatim, and the call takes the same path as wasm/call, so a missing
+;; export, an arity mismatch and a trap raise the same catalogued errors.
+;; Both backends carry the case (VM op_method_call, TreeWalk
+;; evalInstanceMember). Needs the -Dwasm binary (the gate configuration).
+(ns suites.wasm-dot-interop-test
+ (:require [clojure.test :refer [deftest is testing]]))
+
+(def ^:private add-wasm "docs/examples/wasm/add.wasm")
+(def ^:private kernel-wasm "docs/examples/polyglot/c/kernel.wasm")
+(def ^:private trap-wasm "docs/examples/wasm/trap.wasm")
+
+(defn- msg [f]
+ (try (f) :not-raised (catch Throwable e (.getMessage e))))
+
+(deftest three-spellings
+ (let [h (wasm/load add-wasm)]
+ (is (= 42 (.add h 40 2)))
+ (is (= 42 (. h add 40 2)))
+ (is (= 42 (. h (add 40 2))))
+ (is (= (wasm/call h "add" 40 2) (.add h 40 2)))))
+
+(deftest c-export-names-verbatim
+ (let [m (wasm/load kernel-wasm)
+ buf (.buf_addr m)]
+ (wasm/mem-write! m :f64 buf [1.0 2.0 3.0 4.0])
+ (is (= 10.0 (.sum_f64 m buf 4)))
+ (is (= 4 (.scale_f64 m buf 4 2.0)))
+ (is (= 20.0 (.sum_f64 m buf 4)))))
+
+(deftest errors-are-wasm-call-errors
+ (let [h (wasm/load add-wasm)
+ t (wasm/load trap-wasm)]
+ (testing "missing export"
+ (is (= (msg #(wasm/call h "nope" 1)) (msg #(.nope h 1))))
+ (is (= "wasm/call: no exported function named 'nope'" (msg #(.nope h 1)))))
+ (testing "arity"
+ (is (= "wasm/call: 'add' expects 2 argument(s), got 1" (msg #(.add h 1)))))
+ (testing "trap"
+ (is (= (msg #(wasm/call t "boom")) (msg #(.boom t))))
+ (is (re-find #"^wasm/call: 'boom' \(\) -> \(i32\) trapped" (msg #(.boom t)))))))
+
+(deftest non-wasm-receivers-unchanged
+ (is (= "ABC" (.toUpperCase "abc")))
+ (is (re-find #"No implementation of method 'add' on protocol '<\.member>'"
+ (str (msg #(.add 42 1))))))
diff --git a/test/clj/suites/wasm_exports_test.clj b/test/clj/suites/wasm_exports_test.clj
new file mode 100644
index 000000000..8cdfb6b8a
--- /dev/null
+++ b/test/clj/suites/wasm_exports_test.clj
@@ -0,0 +1,42 @@
+;; `(wasm/exports h)` lists a core module's exports in export order with
+;; runtime-probed signatures (ADR-0203 decision 1). A function answers
+;; {:name :kind :func :params [...] :results [...]}, any other export
+;; {:name :kind :memory|:global|:table}. The signature comes from the probe
+;; `wasm/call` marshals through, so a listed function is callable at exactly
+;; the listed arity. Needs the -Dwasm binary (the gate configuration).
+(ns suites.wasm-exports-test
+ (:require [clojure.test :refer [deftest is testing]]))
+
+(def ^:private add-wasm "docs/examples/wasm/add.wasm")
+(def ^:private kernel-wasm "docs/examples/polyglot/c/kernel.wasm")
+
+(deftest single-function-module
+ (is (= [{:name "add" :kind :func :params [:i32 :i32] :results [:i32]}]
+ (wasm/exports (wasm/load add-wasm)))))
+
+(deftest c-kernel-exports
+ (let [es (wasm/exports (wasm/load kernel-wasm))]
+ (testing "the linear memory and four functions, in export order"
+ (is (= [["memory" :memory] ["buf_addr" :func] ["buf_cap" :func]
+ ["sum_f64" :func] ["scale_f64" :func]]
+ (mapv (juxt :name :kind) es))))
+ (testing "function signatures"
+ (is (= [["buf_addr" [] [:i32]]
+ ["buf_cap" [] [:i32]]
+ ["sum_f64" [:i32 :i32] [:f64]]
+ ["scale_f64" [:i32 :i32 :f64] [:i32]]]
+ (vec (for [e es :when (= :func (:kind e))]
+ [(:name e) (:params e) (:results e)])))))
+ (testing "a non-function export carries no :params / :results"
+ (is (= {:name "memory" :kind :memory}
+ (first (filter #(= :memory (:kind %)) es)))))))
+
+(deftest exports-drive-call
+ (let [h (wasm/load add-wasm)
+ e (first (wasm/exports h))]
+ (is (= 42 (apply wasm/call h (:name e) (take (count (:params e)) [40 2 99]))))))
+
+(deftest bad-handle-is-catalogued
+ (is (= "wasm/exports: the first argument must be a loaded wasm module"
+ (try (wasm/exports 42) nil
+ (catch Exception e (ex-message e))))))
diff --git a/test/diff/clj_corpus/type_redefinition.txt b/test/diff/clj_corpus/type_redefinition.txt
new file mode 100644
index 000000000..cb7d2cedf
--- /dev/null
+++ b/test/diff/clj_corpus/type_redefinition.txt
@@ -0,0 +1,254 @@
+(do (defprotocol Area (area [s])) :ok)
+;;=> :ok
+(do (defrecord R [a] Area (area [_] :old-area)) :ok)
+;;=> :ok
+(do (def r (->R 1)) (def rc (class r)) :ok)
+;;=> :ok
+(do (defrecord R [a b] Area (area [_] :new-area)) :ok)
+;;=> :ok
+(:a r)
+;;=> 1
+(.-a r)
+;;=> 1
+(get r :b :missing)
+;;=> :missing
+(pr-str r)
+;;=> "#user.R{:a 1}"
+(pr-str (->R 1 2))
+;;=> "#user.R{:a 1, :b 2}"
+(instance? R r)
+;;=> false
+(instance? rc r)
+;;=> true
+(instance? user.R r)
+;;=> false
+(instance? R (->R 1 2))
+;;=> true
+(instance? rc (->R 1 2))
+;;=> false
+(.isInstance R r)
+;;=> false
+(.isInstance rc r)
+;;=> true
+(= rc R)
+;;=> false
+(identical? rc (class r))
+;;=> true
+(= r r)
+;;=> true
+(= r (->R 1 nil))
+;;=> false
+(= (->R 1 2) (->R 1 2))
+;;=> true
+(= (hash r) (hash r))
+;;=> true
+(integer? (hash r))
+;;=> true
+(area r)
+;;=> :old-area
+(area (->R 1 2))
+;;=> :new-area
+(satisfies? Area r)
+;;=> true
+(extends? Area rc)
+;;=> true
+(map? r)
+;;=> true
+(record? r)
+;;=> true
+(pr-str (assoc r :c 3))
+;;=> "#user.R{:a 1, :c 3}"
+(pr-str (assoc r :a 9))
+;;=> "#user.R{:a 9}"
+(pr-str (dissoc r :a))
+;;=> "{}"
+(keys r)
+;;=> (:a)
+(vals r)
+;;=> (1)
+(count r)
+;;=> 1
+(isa? rc R)
+;;=> false
+(isa? R rc)
+;;=> false
+(isa? rc rc)
+;;=> true
+(isa? rc Object)
+;;=> true
+(instance? Object r)
+;;=> true
+(do (defmulti f class) (defmethod f :default [_] :default) (defmethod f R [_] :new) :ok)
+;;=> :ok
+(f r)
+;;=> :default
+(f (->R 1 2))
+;;=> :new
+(do (defmethod f rc [_] :old) :ok)
+;;=> :ok
+(f r)
+;;=> :old
+(f (->R 1 2))
+;;=> :new
+(do (defprotocol P2 (p2 [x])) (extend-protocol P2 R (p2 [_] :new-p2)) :ok)
+;;=> :ok
+(satisfies? P2 r)
+;;=> false
+(satisfies? P2 (->R 1 2))
+;;=> true
+(extends? P2 rc)
+;;=> false
+(p2 (->R 1 2))
+;;=> :new-p2
+(do (derive rc ::old-kind) :ok)
+;;=> :ok
+(isa? rc ::old-kind)
+;;=> true
+(isa? R ::old-kind)
+;;=> false
+(do (defrecord S [a]) :ok)
+;;=> :ok
+(do (def s1 (->S 1)) :ok)
+;;=> :ok
+(do (defrecord S [a]) :ok)
+;;=> :ok
+(= s1 (->S 1))
+;;=> false
+(= (hash s1) (hash (->S 1)))
+;;=> true
+(instance? S s1)
+;;=> false
+(pr-str s1)
+;;=> "#user.S{:a 1}"
+(do (deftype T [x] Area (area [_] :old-t)) :ok)
+;;=> :ok
+(do (def t (T. 1)) (def tc (class t)) :ok)
+;;=> :ok
+(do (deftype T [x y]) :ok)
+;;=> :ok
+(.-x t)
+;;=> 1
+(.-x (T. 1 2))
+;;=> 1
+(.-y (T. 1 2))
+;;=> 2
+(instance? T t)
+;;=> false
+(instance? tc t)
+;;=> true
+(instance? T (T. 1 2))
+;;=> true
+(.isInstance T t)
+;;=> false
+(= tc T)
+;;=> false
+(= t t)
+;;=> true
+(= t (T. 1 2))
+;;=> false
+(= (hash t) (hash t))
+;;=> true
+(area t)
+;;=> :old-t
+(satisfies? Area t)
+;;=> true
+(satisfies? Area (T. 1 2))
+;;=> false
+(isa? tc T)
+;;=> false
+(isa? tc Object)
+;;=> true
+(f t)
+;;=> :default
+(do (defn mk [i] (reify Area (area [_] i))) :ok)
+;;=> :ok
+(reduce + (map area (map mk (range 1000))))
+;;=> 499500
+(let [xs (doall (map mk (range 200)))] (System/gc) (reduce + (map area xs)))
+;;=> 19900
+(let [a (mk 1)] [(instance? (class a) a) (.isInstance (class a) a) (isa? (class a) (class a)) (instance? Object a) (isa? (class a) Object)])
+;;=> [true true true true true]
+(do (dotimes [_ 300] (eval '(defrecord Churn [v]))) :ok)
+;;=> :ok
+(pr-str (->Churn 7))
+;;=> "#user.Churn{:v 7}"
+(let [olds (doall (for [i (range 50)] (do (eval '(defrecord Churn2 [v])) (eval (list '->Churn2 i)))))] (System/gc) [(reduce + (map :v olds)) (pr-str (first olds)) (pr-str (last olds)) (count (set (map class olds)))])
+;;=> [1225 "#user.Churn2{:v 0}" "#user.Churn2{:v 49}" 50]
+(let [olds (doall (for [i (range 20)] (do (eval '(defrecord Churn3 [v])) (eval (list '->Churn3 i)))))] (count (filter #(= (class %) (class (last olds))) olds)))
+;;=> 1
+(do (deftype Sorted []) :ok)
+;;=> :ok
+(sorted? (Sorted.))
+;;=> false
+(instance? clojure.lang.Sorted (Sorted.))
+;;=> false
+(isa? Sorted clojure.lang.Sorted)
+;;=> false
+(do (deftype Keyword []) :ok)
+;;=> :ok
+(ifn? (Keyword.))
+;;=> false
+(instance? clojure.lang.IFn (Keyword.))
+;;=> false
+(isa? Keyword clojure.lang.IFn)
+;;=> false
+(keyword? (Keyword.))
+;;=> false
+(do (deftype PersistentVector []) :ok)
+;;=> :ok
+(vector? (PersistentVector.))
+;;=> false
+(isa? PersistentVector clojure.lang.IFn)
+;;=> false
+(isa? PersistentVector clojure.lang.IPersistentVector)
+;;=> false
+(do (deftype BigInt []) :ok)
+;;=> :ok
+(number? (BigInt.))
+;;=> false
+(instance? Number (BigInt.))
+;;=> false
+(isa? BigInt Number)
+;;=> false
+(do (deftype Ratio []) :ok)
+;;=> :ok
+(isa? Ratio Number)
+;;=> false
+(rational? (Ratio.))
+;;=> false
+(do (deftype ExceptionInfo []) :ok)
+;;=> :ok
+(isa? ExceptionInfo Exception)
+;;=> false
+(instance? Throwable (ExceptionInfo.))
+;;=> false
+(do (deftype LazySeq []) :ok)
+;;=> :ok
+(isa? LazySeq clojure.lang.Seqable)
+;;=> false
+(seq? (LazySeq.))
+;;=> false
+(do (defmulti g class) (defmethod g Number [_] :number) (defmethod g clojure.lang.IFn [_] :ifn) (defmethod g :default [_] :default) :ok)
+;;=> :ok
+(g (BigInt.))
+;;=> :default
+(g (Keyword.))
+;;=> :default
+(g 1)
+;;=> :number
+(g :k)
+;;=> :ifn
+(do (defmulti mi identity) (defmethod mi 'R [_] :sym) (defmethod mi :R [_] :kw) (defmethod mi 'Area [_] :protocol) (defmethod mi :default [_] :default) :ok)
+;;=> :ok
+(mi (->R 1 2))
+;;=> :default
+(mi (mk 1))
+;;=> :default
+(mi r)
+;;=> :default
+(isa? (->R 1 2) 'R)
+;;=> false
+(isa? (->R 1 2) :R)
+;;=> false
+(isa? (mk 1) 'Area)
+;;=> false