From 956c34ee0608ab1b2eab6e6e219af56fc2e1614a Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:04:58 -0300
Subject: [PATCH 001/466] docs: ClojureWasm continues as a maintained fork
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Upstream clojurewasm/ClojureWasm stopped at v1.10.0, its final release,
and invited forks under EPL-2.0. Development continues here from that
exact tree: same gate, same ADR record, same release workflow. Nothing in
the runtime changes with this commit.
The README notice is replaced rather than deleted — a reader arriving from
a stale link needs to learn both that upstream stopped and where it went,
and the credit for the runtime itself belongs to @chaploud, so it is now
stated in its own section instead of a footer.
What moves with the handover:
- Issues and Pull Requests are OPEN here. Upstream had both disabled for
essentially its whole life, so contributions arrived as branches to
cherry-pick; CONTRIBUTING already described an open queue and is now
simply true.
- The Homebrew tap is buddhilw/tap (`brew install buddhilw/tap/cljw`);
clojurewasm/tap is no longer bumped.
- README badges, CONTRIBUTING, SECURITY, the PR template and all four
issue templates point at BuddhiLW/ClojureWasm. Private vulnerability
reports reach @BuddhiLW.
- The 1.x line continues from v1.10.0.
Links to cw-playground and cw-serverless-demo are left pointing at the
upstream org: those deployments are live and are not ours to reassign.
---
.dev/handover.md | 29 +++++++++++--------
.github/CONTRIBUTING.md | 4 +--
.github/ISSUE_TEMPLATE/bug.yml | 2 +-
.github/ISSUE_TEMPLATE/config.yml | 4 +--
.github/ISSUE_TEMPLATE/divergence.yml | 2 +-
.github/ISSUE_TEMPLATE/missing.yml | 2 +-
.github/PULL_REQUEST_TEMPLATE.md | 2 +-
.github/SECURITY.md | 6 ++--
CHANGELOG.md | 15 ++++++++++
README.md | 41 +++++++++++++++++----------
10 files changed, 69 insertions(+), 38 deletions(-)
diff --git a/.dev/handover.md b/.dev/handover.md
index cf81d84ba..fa2c896f0 100644
--- a/.dev/handover.md
+++ b/.dev/handover.md
@@ -15,18 +15,23 @@
(`278587f6`, 2026-08-12, FINAL — full WASI 0.3 + the `libzwasm.a` C-API
export fix; neither reaches cljw's Zig embedding surface. Tagging zwasm is
user-only, its ADR-0156). Latest release:
- **v1.10.0** (2026-08-12) — **the FINAL release**. CHANGELOG is the
- release-history SSOT.
-- **First task on resume**: nothing is pending. The wind-down is DONE:
- README declares the project unmaintained, v1.10.0 is released (artifacts
- verified by download + checksum + running the macOS binary), the homebrew
- tap is bumped, cw-playground and cw-serverless-demo are redeployed on it
- and live, cw-arcade is re-verified (32 tests / 153 assertions; it pins
- nothing by design), and the announcement is Discussion #15 (pin it in the
- web UI — the API has no pinDiscussion). The PERF
- CAMPAIGN (D-450) is **stopped, not paused** — findings stay in the row as
- a record.
-- **Unreleased on main**: nothing. CHANGELOG `[1.10.0]` is the SSOT.
+ **v1.10.0** (2026-08-12) — upstream's final release, and this fork's
+ starting point. CHANGELOG is the release-history SSOT.
+- **This repository is a MAINTAINED FORK.** Upstream
+ `clojurewasm/ClojureWasm` (chaploud) stopped at v1.10.0 and invited forks
+ under EPL-2.0; `BuddhiLW/ClojureWasm` continues from that tree.
+ `origin` = upstream (read-only, archived-in-practice), `buddhilw` = the
+ maintained remote. What changed with the handover: Issues and PRs are OPEN
+ here (upstream disabled both — the "branches to cherry-pick" workflow in
+ CONTRIBUTING no longer applies), the homebrew tap is
+ `BuddhiLW/homebrew-tap` (`brew install buddhilw/tap/cljw`), and the `1.x`
+ line continues from v1.10.0. The PERF CAMPAIGN (D-450) stays **stopped**
+ — findings remain in the row as a record.
+- **First task on resume**: cut **v1.10.1** — a no-feature release whose only
+ job is to prove the fork's release path end to end (tag → `release.yml`
+ artifacts on this repo → tap formula bump → `brew install buddhilw/tap/cljw`).
+- **Unreleased on main**: the fork-handover docs. CHANGELOG `[Unreleased]`
+ is the SSOT.
- **Release-process hazards** (both live in `.github/workflows/release.yml`):
cutting the GitHub release BY HAND before the tag workflow runs breaks the
artifact upload (zwasm #160), and the workflow's `gh release view || gh
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index c022307de..75307cd6c 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -14,7 +14,7 @@ that has not been written down yet — and finding them from the outside is wort
more than anything else, because a project cannot easily see its own blind
spots.
-There is a [Clojure divergence issue template](https://github.com/clojurewasm/ClojureWasm/issues/new?template=divergence.yml)
+There is a [Clojure divergence issue template](https://github.com/BuddhiLW/ClojureWasm/issues/new?template=divergence.yml)
that asks for the three things that make such a report actionable: the
expression, what `cljw` prints, and what a JVM REPL prints.
@@ -82,7 +82,7 @@ something is the way it is.
## Getting in touch
Open-ended questions belong in
-[GitHub Discussions](https://github.com/clojurewasm/ClojureWasm/discussions);
+[GitHub Discussions](https://github.com/BuddhiLW/ClojureWasm/discussions);
the wider Clojure community also gathers on the
[Clojurians Slack](https://clojurians.slack.com).
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 07f93ddd3..29b524eae 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -12,7 +12,7 @@ body:
Crashes and hangs are the highest-priority reports here. A crash in the
WebAssembly FFI or the sandbox may be a security issue — in that case
- please use [private vulnerability reporting](https://github.com/clojurewasm/ClojureWasm/security/advisories/new)
+ please use [private vulnerability reporting](https://github.com/BuddhiLW/ClojureWasm/security/advisories/new)
rather than a public issue.
- type: textarea
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 79b8fce9f..cd6b714c1 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -3,12 +3,12 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Questions, ideas, "is this supposed to work?"
- url: https://github.com/clojurewasm/ClojureWasm/discussions
+ url: https://github.com/BuddhiLW/ClojureWasm/discussions
about: >-
Anything open-ended belongs in Discussions rather than an issue — usage
questions, design conversations, showing what you built.
- name: 🔒 Report a security vulnerability (private)
- url: https://github.com/clojurewasm/ClojureWasm/security/advisories/new
+ url: https://github.com/BuddhiLW/ClojureWasm/security/advisories/new
about: >-
Do NOT report security problems in public. Use private vulnerability
reporting — see SECURITY.md for scope and what to include.
diff --git a/.github/ISSUE_TEMPLATE/divergence.yml b/.github/ISSUE_TEMPLATE/divergence.yml
index a677e0d32..77302cff4 100644
--- a/.github/ISSUE_TEMPLATE/divergence.yml
+++ b/.github/ISSUE_TEMPLATE/divergence.yml
@@ -12,7 +12,7 @@ body:
down yet.
Before filing, it is worth a glance at
- [`docs/clojure_vs_clojurewasm.md`](https://github.com/clojurewasm/ClojureWasm/blob/main/docs/clojure_vs_clojurewasm.md)
+ [`docs/clojure_vs_clojurewasm.md`](https://github.com/BuddhiLW/ClojureWasm/blob/main/docs/clojure_vs_clojurewasm.md)
— some differences are deliberate and already recorded there. If yours
is listed, no need to file; if you think the reasoning is wrong, a
Discussion is the better place for that.
diff --git a/.github/ISSUE_TEMPLATE/missing.yml b/.github/ISSUE_TEMPLATE/missing.yml
index 30f6753d1..eaf3f62e0 100644
--- a/.github/ISSUE_TEMPLATE/missing.yml
+++ b/.github/ISSUE_TEMPLATE/missing.yml
@@ -10,7 +10,7 @@ body:
namespaces, but not all of it, and some of it never will be — anything
that requires the JVM class system (`gen-class`, `proxy` against real
Java base classes, `bean`'s reflective variant) is permanently out of
- scope, recorded in [`data/compat_tiers.yaml`](https://github.com/clojurewasm/ClojureWasm/blob/main/data/compat_tiers.yaml).
+ scope, recorded in [`data/compat_tiers.yaml`](https://github.com/BuddhiLW/ClojureWasm/blob/main/data/compat_tiers.yaml).
Telling us *what you were trying to do* matters more than the var name
— often there is a supported path, and if there is not, the use case is
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 843ae3985..0da43ceb4 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -4,7 +4,7 @@
ClojureWasm is a small, resource-limited project and cannot review external
PRs right now. Please close this PR and instead start a thread in Discussions:
- https://github.com/clojurewasm/ClojureWasm/discussions
+ https://github.com/BuddhiLW/ClojureWasm/discussions
and mention @chaploud. Thanks for understanding — we do want to hear about
behaviour that differs from JVM Clojure, just through Discussions while PRs
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 13dca4b85..1b2954a1c 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -19,18 +19,18 @@ is no backport branch.
| newest `1.x` release | ✅ |
| any older release | ❌ |
-If you are on Homebrew, `brew upgrade clojurewasm/tap/cljw` puts you on the
+If you are on Homebrew, `brew upgrade buddhilw/tap/cljw` puts you on the
supported line.
## Reporting a vulnerability
**Please do not open a public issue or Discussion for security problems.**
-Report privately via GitHub's **[Private Vulnerability Reporting](https://github.com/clojurewasm/ClojureWasm/security/advisories/new)**
+Report privately via GitHub's **[Private Vulnerability Reporting](https://github.com/BuddhiLW/ClojureWasm/security/advisories/new)**
(the "Report a vulnerability" button under the repository's *Security* tab).
If that is unavailable to you, open a minimal public Discussion asking a
maintainer to reach out — **without any exploit detail** — and mention
-`@chaploud`.
+`@BuddhiLW`.
Please include, where possible:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06f0645cf..08e39802f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+### Changed
+
+- **Maintenance moved to a fork.** Upstream
+ [clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm)
+ stopped at v1.10.0, its final release, and invited forks under EPL-2.0.
+ Development continues in
+ [BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm) from that
+ exact tree — same gate, same ADR record, same release workflow. Nothing in
+ the runtime changed. What moved: Issues and Pull Requests are open here
+ (upstream disabled both, so contributions arrived as branches to
+ cherry-pick), the Homebrew tap is now `buddhilw/tap`
+ (`brew install buddhilw/tap/cljw`), and README / CONTRIBUTING / SECURITY /
+ issue templates point at this repository. The `1.x` line continues from
+ v1.10.0.
+
## [1.10.0] - 2026-08-12
**The final release.** ClojureWasm is no longer maintained — a from-scratch
diff --git a/README.md b/README.md
index 2bf26b18a..9fafa97df 100644
--- a/README.md
+++ b/README.md
@@ -9,25 +9,28 @@
-
+
> [!IMPORTANT]
-> **ClojureWasm is no longer maintained.** A from-scratch Clojure runtime
-> turned out to be more than one person can sustain, so I am stopping here
-> while it still works. The code stays up under EPL-2.0 — fork it and build on
-> it, no permission needed.
-> [Discussions](https://github.com/clojurewasm/ClojureWasm/discussions) remain
-> open, but may go unanswered.
+> **This is the maintained continuation of ClojureWasm.** The original
+> project by [@chaploud](https://github.com/chaploud) —
+> [clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm) —
+> stopped at **v1.10.0**, its final release, and its author invited forks
+> under EPL-2.0. Development continues here from that exact tree, with the
+> gate, the ADR record and the release process intact.
>
-> I believe ClojureWasm demonstrated what a from-scratch Clojure can be: a
-> single small static binary, with a WebAssembly FFI. The hardest parts — and
-> why this needs more than a solo maintainer — were keeping up JVM-Clojure
-> compatibility (Java interop above all) and performance. Thanks to everyone
-> who used it, reported differences, and contributed fixes.
+> [Issues](https://github.com/BuddhiLW/ClojureWasm/issues), Pull Requests and
+> [Discussions](https://github.com/BuddhiLW/ClojureWasm/discussions) are open.
+> **The single most useful thing you can report is Clojure code that behaves
+> differently here than on the JVM** — there is an issue template for exactly
+> that.
+>
+> Installs and version pins move to this repository: `brew install
+> buddhilw/tap/cljw`, and releases continue the `1.x` line from v1.10.0.
## What it is
@@ -64,7 +67,7 @@ still use what other languages have already built.
**Homebrew** (macOS arm64 / Linux x86_64):
```sh
-brew install clojurewasm/tap/cljw
+brew install buddhilw/tap/cljw
```
The `cljw` binary is not code-signed. Homebrew installs it without a Gatekeeper
@@ -76,7 +79,7 @@ xattr -d com.apple.quarantine "$(which cljw)"
```
Or grab a binary straight from the
-[Releases](https://github.com/clojurewasm/ClojureWasm/releases) page. Building
+[Releases](https://github.com/BuddhiLW/ClojureWasm/releases) page. Building
from source is in [Quickstart](#quickstart) below.
## Quickstart
@@ -160,6 +163,14 @@ from a Clojure one.
Eclipse Public License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./legal/NOTICE).
+## Credit
+
+ClojureWasm was created and written by [Shota Kudo
+(@chaploud)](https://github.com/chaploud), who took it from nothing to a
+working JVM-free Clojure runtime with a WebAssembly FFI over the life of
+[clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm), through
+v1.10.0. Everything this fork ships is built on that work.
+
---
-Developed in spare time alongside a day job.
+Maintained as a fork since v1.10.0. Developed in spare time.
From 59150f5809fd27af0cfe7d32e1c89960fcd14808 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:07:12 -0300
Subject: [PATCH 002/466] =?UTF-8?q?release:=20v1.10.1=20=E2=80=94=20prove?=
=?UTF-8?q?=20the=20fork's=20release=20path?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
No runtime change: the binary is v1.10.0's, rebuilt under a new version
string. The release exists to exercise the distribution path that the
handover moved, end to end — tag on this repository, artifacts built by
this repository's release.yml, and a buddhilw/tap formula pointing at
them.
Cutting it now, while the tree is known-good and the diff is a version
string, means any breakage is unambiguously in the path and not in the
code. The alternative is to discover a broken release path on the first
release that also carries a fix.
Smell-audited: 0: clean — version-string bump, no code
---
CHANGELOG.md | 9 +++++++++
build.zig.zon | 2 +-
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08e39802f..a7465e85d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+## [1.10.1] - 2026-08-12
+
+**The first release of the maintained fork.** It carries no runtime change —
+the binary is v1.10.0's, rebuilt — and exists to prove the fork's own release
+path end to end: tag on `BuddhiLW/ClojureWasm`, artifacts built by this
+repository's `release.yml`, and a `buddhilw/tap` formula pointing at them. A
+release cut against a distribution path nobody has exercised is a guess; this
+one makes the guess cheap to check.
+
### Changed
- **Maintenance moved to a fork.** Upstream
diff --git a/build.zig.zon b/build.zig.zon
index d7439889c..d584ccd2a 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.0",
+ .version = "1.10.1",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From 621de41473cd48a2d5008b2c249232cc133a4721 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:18:49 -0300
Subject: [PATCH 003/466] ci(release): bump the homebrew tap from the release
that built it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`brew install buddhilw/tap/cljw` was pointing at a formula edited by hand
after each release, which is a step that gets skipped and a checksum that
gets mistyped. A `tap` job now rewrites Formula/cljw.rb in
BuddhiLW/homebrew-tap once both matrix legs have uploaded.
Three details that are not incidental:
- It runs `needs: build`, so the formula can never name an asset that the
release does not carry yet.
- The checksums come from the `.sha256` sidecars the build wrote and
uploaded, not from re-hashing a fresh download. The formula therefore
pins the bytes that were published; a re-hash would pin whatever the
job happened to fetch.
- Auth is a deploy key on the tap repository (secret TAP_DEPLOY_KEY), not
a PAT. Write access reaches that one repository and nothing else in the
account, and revoking it is one click on the tap.
The formula deliberately carries no `version` line — brew scans the
version from the URL, and `brew audit --strict` fails a formula that
states it twice.
Verified end to end on v1.10.1 by hand before automating: both artifacts
downloaded, both sidecar checksums recomputed and matched, the Linux
binary run (`--version` reports 1.10.1), then `brew install
buddhilw/tap/cljw`, `brew test` and `brew audit --strict` green.
---
.github/workflows/release.yml | 68 +++++++++++++++++++++++++++++++++++
1 file changed, 68 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 954f807d6..d26036f4e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -78,3 +78,71 @@ jobs:
gh release view "$TAG_NAME" >/dev/null 2>&1 || \
gh release create "$TAG_NAME" --title "$TAG_NAME" --generate-notes
gh release upload "$TAG_NAME" dist/* --clobber
+
+ # Point `brew install buddhilw/tap/cljw` at the release this run just built.
+ # Runs after BOTH matrix legs so the formula never names an asset that does
+ # not exist yet; the checksums come from the .sha256 sidecars the build wrote,
+ # not from a re-hash here, so the formula pins exactly what was uploaded.
+ #
+ # Auth is a deploy key on BuddhiLW/homebrew-tap (secret TAP_DEPLOY_KEY), not a
+ # PAT: write access reaches that one repository and nothing else in the account.
+ tap:
+ name: bump homebrew tap
+ needs: build
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-22.04
+ timeout-minutes: 10
+ steps:
+ - name: Read the release checksums
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG_NAME: ${{ github.ref_name }}
+ GH_REPO: ${{ github.repository }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p sums && cd sums
+ gh release download "$TAG_NAME" --pattern '*.sha256' --clobber
+ # `shasum -a 256 FILE` writes " "; keep the sum alone.
+ mac=$(awk '{print $1}' cljw-macos-aarch64.tar.gz.sha256)
+ lin=$(awk '{print $1}' cljw-linux-x86_64.tar.gz.sha256)
+ test -n "$mac" && test -n "$lin"
+ {
+ echo "MAC_SHA256=$mac"
+ echo "LINUX_SHA256=$lin"
+ } >> "$GITHUB_ENV"
+
+ - name: Check out the tap
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ repository: BuddhiLW/homebrew-tap
+ ssh-key: ${{ secrets.TAP_DEPLOY_KEY }}
+ path: tap
+
+ - name: Write and push the formula
+ env:
+ TAG_NAME: ${{ github.ref_name }}
+ shell: bash
+ working-directory: tap
+ run: |
+ set -euo pipefail
+ python3 - <<'PY'
+ import os, pathlib, re
+ p = pathlib.Path("Formula/cljw.rb")
+ s = p.read_text()
+ # The formula carries no `version` line: brew scans it from the URL,
+ # and `brew audit --strict` rejects stating it twice.
+ s = re.sub(r'/download/v[^/]+/', '/download/%s/' % os.environ["TAG_NAME"], s)
+ # Each sha256 sits under its own url line, so replace positionally.
+ for host, sha in (("macos-aarch64", os.environ["MAC_SHA256"]),
+ ("linux-x86_64", os.environ["LINUX_SHA256"])):
+ s = re.sub(r'(cljw-%s\.tar\.gz"\n sha256 ")[0-9a-f]*(")' % host,
+ lambda m: m.group(1) + sha + m.group(2), s)
+ p.write_text(s)
+ PY
+ grep -q "REPLACE_" Formula/cljw.rb && { echo "formula still carries a placeholder"; exit 1; }
+ git diff --quiet && { echo "formula already current for $TAG_NAME"; exit 0; }
+ git config user.name "clojurewasm-release"
+ git config user.email "clojurewasm-release@users.noreply.github.com"
+ git commit -am "cljw $TAG_NAME"
+ git push
From 837d1f708dcaef9a9e1af72567909bbaff765eaa Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:21:47 -0300
Subject: [PATCH 004/466] docs(handover): the fork's release path is proven;
workflows need a dispatch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
v1.10.1 is out and `brew install buddhilw/tap/cljw` works, so the resume
contract no longer asks for it.
The fork-default Actions behaviour is recorded as a HAZARD rather than a
note, because it fails in the direction that looks fine: an absent CI run
reads as a passing one, and a pushed `v*` tag produces no release at all.
Every run so far came from an explicit `gh workflow run`.
Two bullets were false-for-this-repo rather than merely stale, so they are
corrected, not carried: Issues and PRs are open here, and D-565's residuals
(7)/(8) point into upstream's gitignored `private/notes/`, which did not
come with the fork — unreachable, not pending. Upstream's wind-down is
compressed to the one line a resuming reader still needs, which pays for
the new bullets against the 100-line cap.
---
.dev/handover.md | 62 +++++++++++++++++++++++++-----------------------
1 file changed, 32 insertions(+), 30 deletions(-)
diff --git a/.dev/handover.md b/.dev/handover.md
index fa2c896f0..b1041bad3 100644
--- a/.dev/handover.md
+++ b/.dev/handover.md
@@ -21,23 +21,29 @@
`clojurewasm/ClojureWasm` (chaploud) stopped at v1.10.0 and invited forks
under EPL-2.0; `BuddhiLW/ClojureWasm` continues from that tree.
`origin` = upstream (read-only, archived-in-practice), `buddhilw` = the
- maintained remote. What changed with the handover: Issues and PRs are OPEN
- here (upstream disabled both — the "branches to cherry-pick" workflow in
- CONTRIBUTING no longer applies), the homebrew tap is
- `BuddhiLW/homebrew-tap` (`brew install buddhilw/tap/cljw`), and the `1.x`
- line continues from v1.10.0. The PERF CAMPAIGN (D-450) stays **stopped**
- — findings remain in the row as a record.
-- **First task on resume**: cut **v1.10.1** — a no-feature release whose only
- job is to prove the fork's release path end to end (tag → `release.yml`
- artifacts on this repo → tap formula bump → `brew install buddhilw/tap/cljw`).
-- **Unreleased on main**: the fork-handover docs. CHANGELOG `[Unreleased]`
- is the SSOT.
+ maintained remote. Changed with the handover: Issues and PRs are OPEN here
+ (so CONTRIBUTING's "branches to cherry-pick" no longer applies), the tap is
+ `BuddhiLW/homebrew-tap` (`brew install buddhilw/tap/cljw`), and `1.x`
+ continues from v1.10.0. PERF CAMPAIGN (D-450) stays **stopped** — findings
+ remain in the row as a record.
+- **Release path: PROVEN on v1.10.1** (2026-08-12) — artifacts built by this
+ repo, both sidecar checksums recomputed and matched, the Linux binary run
+ (`--version` = 1.10.1), `brew install buddhilw/tap/cljw` + `brew test` +
+ `brew audit --strict` green. The tap bump is now a `tap` job in
+ `release.yml` (deploy key, secret `TAP_DEPLOY_KEY`); v1.10.1's formula was
+ written by hand, so **v1.10.2 is the first release that exercises the
+ automated bump** — watch that job.
+- **HAZARD — workflows do not fire on push/tag here.** GitHub's fork default:
+ every run so far came from `gh workflow run --ref [`. Until the
+ Actions tab's enable button is pressed once, "no run appeared" does NOT mean
+ "CI passed", and a `v*` tag will not build a release by itself. Dispatch
+ explicitly, or press it and delete this bullet.
+- **Unreleased on main**: nothing. CHANGELOG `[1.10.1]` is the SSOT.
- **Release-process hazards** (both live in `.github/workflows/release.yml`):
cutting the GitHub release BY HAND before the tag workflow runs breaks the
- artifact upload (zwasm #160), and the workflow's `gh release view || gh
- release create` is TOCTOU across the two matrix legs — if they reach it
- within the same instant, the loser fails on "already exists" under
- `set -e`. v1.10.0 missed it by 47 s. Never hit, never fixed.
+ artifact upload (zwasm #160), and `gh release view || gh release create` is
+ TOCTOU across the two matrix legs — simultaneous, the loser dies on "already
+ exists" under `set -e`. v1.10.0 missed it by 47 s. Never hit, never fixed.
- **Forbidden**: bare `zig build test` without `-Dwasm`; bare `zig build` for a
probe (use ReleaseSafe). The FULL gate runs `--serial-e2e`, ALONE; never a
concurrent build during a gate. `.claude/**` edits + cross-repo publishes may
@@ -47,19 +53,15 @@
## Current state (details = CHANGELOG + git log)
-- **Issues and PRs are DISABLED and always were** (deliberate — the
- maintainer cannot service them); **Discussions is the one channel**, and
- outside contributors send branches to cherry-pick. CONTRIBUTING exempts
- them from the loop's own conventions, so a contributed commit is taken
- as-is with authorship preserved and any missing `Smell-audited:` line
- amended in. Debug tooling: `scripts/check_*.sh` headers are each their
- own SSOT — read the script, not a list here.
-- **2026-08-12: the README declares ClojureWasm no longer maintained**
- (`1c8a9cbf`); the Sponsors badge, README footer line, and `.github/FUNDING.yml`
- are gone with it. Issues/PRs were always disabled — Discussions is the one
- channel. Wind-down context + the surveyed wording/transfer research:
- memory `clojurewasm-wind-down-plan`, `private/notes/2026-08-11-{sunset-wording,
- transfer-risk}-survey.md`.
+- **Issues, PRs and Discussions are all OPEN here** (they were disabled
+ upstream, so contributions used to arrive as branches to cherry-pick).
+ CONTRIBUTING exempts outside contributors from the loop's own conventions:
+ a contributed commit is taken as-is with authorship preserved and any
+ missing `Smell-audited:` line amended in. Debug tooling:
+ `scripts/check_*.sh` headers are each their own SSOT — read the script.
+- Upstream's own wind-down (`1c8a9cbf` README notice, Sponsors + FUNDING.yml
+ removed, Discussion #15) is history now; `956c34ee` replaced that notice
+ with the fork's.
- 2026-08-11/12: Discussions #12/#13/#14 (BuddhiLW) answered in code — three
fix branches cherry-picked with authorship preserved, each one's CLASS then
swept and fenced. #12 repr-misread → 3 more live bugs (multimethod isa? at 9
@@ -74,8 +76,8 @@
- **D-565** — external-contributor reproducibility sweep (Discussion #11).
Items (1)-(6) DISCHARGED 2026-08-04; the ledger row carries what each was.
- Residual: (7) survey-workflow setup notes, (8) handover pointing at
- gitignored `private/notes/` recipes. Companion: zwasm D-526.
+ Residuals (7)/(8) both point into upstream's gitignored `private/notes/`,
+ which did not come with the fork — treat as unreachable, not pending.
- **Perf campaign (§9.2.S) — PAUSED** (D-520 / D-386 / D-005/006). **D-513**
item (1) `clojure.core.reducers` is the only remaining piece (repl + var :doc
From 2a51e4fe2106bf9b73a2211e314e703d9c44a66c Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho ]
Date: Wed, 12 Aug 2026 10:52:06 -0300
Subject: [PATCH 005/466] docs(adr): open the golden, property and mutation
test layers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ADR-0021 named 8 deferred layers and gave each a phase that would open it.
Golden ("Phase 7+"), Property ("Phase 8+") and Fuzz ("Phase 6+") never
opened; the phases passed and the layers did not.
What the suite has instead is genuinely good — 1,249 inline unit tests,
~400 e2e scripts, a dual-backend differential oracle, a clj corpus — with
two structural gaps that the fork inherits and should not keep:
Nothing checks whole-program observable output. An e2e case asserts a
chosen substring of a chosen stream, so it can only fail for the thing its
author predicted. The error renderer prints a source excerpt with a caret
under the offending form and no test compares that rendering as a whole.
Nothing checks the suite itself. 120 of 265 source files carry no inline
test, and coverage would answer the wrong question anyway: not whether a
line ran under test, but whether changing it makes a test fail.
ADR-0186 opens Layer 6 (golden snapshots), Layer 7 (properties over
generated input) and Layer 8 (mutation, a measurement OF the layers, run
on demand and never in the per-commit gate). Fuzz stays deferred:
fuzzing asks whether it crashes, properties ask whether it is right, and
the second question is both cheaper here and the one this codebase's bugs
have been answering wrong.
The layer tables in test/README.md and the taxonomy rule are updated in
the same commit, because "where does this test go" is the question those
two documents exist to answer and a stale answer sends the next test to
the wrong layer.
---
.claude/rules/test_taxonomy.md | 10 +-
.../0186_golden_property_mutation_layers.md | 132 ++++++++++++++++++
test/README.md | 19 ++-
3 files changed, 155 insertions(+), 6 deletions(-)
create mode 100644 .dev/decisions/0186_golden_property_mutation_layers.md
diff --git a/.claude/rules/test_taxonomy.md b/.claude/rules/test_taxonomy.md
index 627f10b31..fd060bde1 100644
--- a/.claude/rules/test_taxonomy.md
+++ b/.claude/rules/test_taxonomy.md
@@ -19,6 +19,9 @@ Choose the layer before writing the test.
| 3 | Differential | `test/diff/cases.yaml` | TreeWalk and VM must produce equal Value for the same source. ADR-0005 / 0022. |
| 4 | Bench (on demand) | `bench/compare_langs.sh` + `bench/run_bench.sh` | Perf measurement — run on demand, NOT a gate layer (the `quick.sh` auto-baseline was retired 2026-06-11). |
| 5 | Conformance | `test/clj/` (Phase 11+) | Adapted upstream Clojure JVM test. |
+| 6 | Golden | `test/golden/cases/*.clj` + `.expected` | A whole program's stdout + stderr + exit code must not change. ADR-0186. |
+| 7 | Property | `src/testing/prop_*.zig` | A law over GENERATED input, checked against a model or an inverse. ADR-0186. |
+| 8 | Mutation | `scripts/mutation/` (on demand, never gated) | Not a test — a measurement of whether the other layers constrain a line. ADR-0186. |
## Why
@@ -43,9 +46,14 @@ Ask in order:
4. Is it a performance measurement? → Layer 4 (run on demand via
`bench/`, not added to the gate).
5. Is it an upstream Clojure test port (Phase 11+)? → Layer 5.
+6. Is the thing to protect everything the program PRINTED, rather than one
+ asserted substring? → Layer 6 (`test/golden/`, `run.sh --update` to record).
+7. Does it state a law that should hold for EVERY input — an inverse, an
+ algebraic identity, agreement with a naive model? → Layer 7.
If none fit, the test is in a future layer (defer or open the
-future layer's directory).
+future layer's directory). Layer 8 is not a place to put a test: it is the
+sweep that tells you which lines the layers above are not constraining.
### Naming
diff --git a/.dev/decisions/0186_golden_property_mutation_layers.md b/.dev/decisions/0186_golden_property_mutation_layers.md
new file mode 100644
index 000000000..be45b373e
--- /dev/null
+++ b/.dev/decisions/0186_golden_property_mutation_layers.md
@@ -0,0 +1,132 @@
+# 0186 — Open the golden, property and mutation test layers
+
+- **Status**: Accepted
+- **Date**: 2026-08-12
+- **Author**: Pedro (BuddhiLW)
+- **Tags**: test, taxonomy, golden, property, mutation, fork-foundation
+
+## Context
+
+ADR-0021 committed to 5 layers and named 8 more as deferred, each with a phase
+that would open it. Golden ("Phase 7+"), Property ("Phase 8+") and Fuzz
+("Phase 6+") never opened; the phases passed and the layers did not. What the
+suite has instead is 1,249 inline unit tests, ~400 e2e shell scripts, a
+dual-backend differential oracle and a clj corpus — a large, genuinely good
+suite with two structural gaps.
+
+**Nothing checks whole-program observable output.** An e2e case asserts a
+chosen substring of a chosen stream, so it can only fail for the thing its
+author thought to assert. The error renderer prints a source excerpt with a
+caret under the offending form; no test compares that rendering as a whole, so
+a regression that drops the excerpt, moves a message from stderr to stdout, or
+changes an exit code passes every assertion written today.
+
+**Nothing checks the suite itself.** 120 of 265 source files carry no inline
+test. Coverage is unmeasured, and coverage would be the wrong measure anyway:
+the question is not whether a line executed under test but whether changing it
+makes a test fail. That question has one honest answer — change the line and
+see.
+
+The fork inherits maintenance from a solo author whose confidence came partly
+from an autonomous loop with written rails. A new maintainer does not inherit
+that context. What transfers is a suite that fails when behaviour changes, and
+evidence about where it would not.
+
+## Decision
+
+Open three layers, numbered as continuations of the ADR-0021 table.
+
+**Layer 6 — Golden snapshot** (`test/golden/`). A case is a whole program; its
+snapshot is stdout, stderr and exit code in one file. `run.sh --update`
+regenerates, and the resulting diff is the review surface. Determinism comes
+from explicit normalisation (repo path, hex addresses, pids, durations, version
+string); a case that still varies does not belong in the layer. Runs every
+gate, after `build_cljw`.
+
+**Layer 7 — Property** (`test/prop/`). Properties are stated over generated
+input rather than over examples, with a fixed default seed so the gate is
+deterministic, and an overridable one so a sweep can explore. A failing case
+shrinks before it is reported. The oracles available here are the ones the
+codebase already trusts elsewhere: an algebraic law (`conj` then `pop` is
+identity), a round-trip (read then print then read), and cross-implementation
+agreement (a persistent collection against a naive array model).
+
+**Layer 8 — Mutation** (`scripts/mutation/`). Not a test layer but a
+measurement OF the layers: mutate one source construct, rebuild, run the unit
+suite, and record whether anything failed. A surviving mutant names a specific
+line whose behaviour no test constrains. It runs on demand and in a scheduled
+sweep, never in the per-commit gate — a rebuild per mutant is minutes, and the
+output is a work-list, not a pass/fail.
+
+Mutation runs in a **throwaway git worktree**, never in the working checkout.
+The harness rewrites source files; pointing it at a checkout someone is editing
+would destroy uncommitted work, and a mutant left behind by an interrupted run
+would be indistinguishable from a real edit.
+
+## Alternatives considered
+
+### Alternative A — Extend the e2e layer instead of adding golden
+
+- **Sketch**: keep writing `phase_.sh` scripts with explicit asserts
+ for the outputs that currently go unchecked.
+- **Why rejected**: it reproduces the gap it is meant to close. An assert
+ encodes what its author predicted could break; the failures that hurt are the
+ unpredicted ones. A snapshot has no prediction in it. The two are
+ complementary, not competing — e2e stays the layer for "this specific thing
+ must hold", golden becomes the layer for "nothing about this changed".
+
+### Alternative B — Line coverage instead of mutation
+
+- **Sketch**: build with coverage instrumentation, report percentage per module,
+ set a floor.
+- **Why rejected**: it measures execution, not constraint. A line executed by a
+ test that asserts nothing about its effect counts as covered, and a suite
+ tuned to a coverage floor drifts toward tests that execute rather than tests
+ that check. Mutation asks the question coverage is a proxy for.
+
+### Alternative C — Fuzzing (the layer ADR-0021 actually named) instead of property tests
+
+- **Sketch**: open `fuzz/`, run a coverage-guided fuzzer against the reader and
+ the evaluator, look for crashes.
+- **Why rejected**: not rejected, deferred. Fuzzing answers "does it crash",
+ properties answer "is it right"; a runtime that never crashes on malformed
+ input can still return the wrong number. Properties are also the cheaper
+ first move, since they need no new toolchain. `fuzz/` stays open in ADR-0021's
+ deferred list.
+
+### Alternative D — Mutation in the per-commit gate
+
+- **Sketch**: sample a handful of mutants per commit, fail the gate on a
+ survivor.
+- **Why rejected**: a rebuild per mutant puts it minutes past the gate's budget,
+ and a survivor is not a defect — it is a question about a missing test, which
+ is work to schedule rather than a commit to block. Gating it would also make
+ the seed load-bearing: the same commit would pass or fail depending on which
+ mutants were drawn.
+
+## Consequences
+
+- **Positive**: a rendering change fails the gate without anyone having
+ predicted its shape; the untested regions of the tree become a named,
+ ranked work-list instead of an impression; the layers ADR-0021 promised exist.
+- **Negative**: snapshots have to be read when they change, and a reviewer who
+ regenerates without reading converts the layer into a rubber stamp. The
+ mutation harness is a source-rewriting tool, which is a foot-gun kept safe
+ only by the worktree rule above.
+- **Neutral / follow-ups**: the mutation sweep's first report will name modules
+ with no inline test at all; those become debt rows, not a single task.
+ `fuzz/` remains deferred.
+
+## Affected files
+
+- `.dev/decisions/0186_golden_property_mutation_layers.md` (this file)
+- `test/golden/run.sh`, `test/golden/cases/**`
+- `test/prop/**`
+- `scripts/mutation/**`
+- `test/run_all.sh` (the `golden` and `prop` steps)
+- `test/README.md`, `.claude/rules/test_taxonomy.md` (the layer table)
+
+## Revision history
+
+- 2026-08-12: Status: Proposed -> Accepted. Layers 6 and 7 land with the ADR;
+ Layer 8's harness lands beside them and its first sweep report follows.
diff --git a/test/README.md b/test/README.md
index 6872120da..004603d67 100644
--- a/test/README.md
+++ b/test/README.md
@@ -11,6 +11,9 @@
| 3 | Differential | `test/diff/` | `Evaluator.compare()` | Phase 4 |
| 4 | Bench (on demand) | `bench/compare_langs.sh` / `run_bench.sh` | bash + `cljw` | on demand (not gated) |
| 5 | Conformance | `test/clj/` (Phase 11+) | `clojure.test` | Phase 11 |
+| 6 | Golden snapshot | `test/golden/` | `test/golden/run.sh` | ADR-0186 |
+| 7 | Property | `src/testing/prop_*.zig` (see `test/prop/README.md`) | `zig build test` | ADR-0186 |
+| 8 | Mutation | `scripts/mutation/` | `scripts/mutation/run.sh` | ADR-0186 (on demand) |
## "Where does this test go?"
@@ -22,6 +25,12 @@
- **Performance measurement**: Layer 4 — run on demand via `bench/`
(no longer part of the gate as of 2026-06-11).
- **Upstream Clojure JVM test (port)**: Layer 5 (Phase 11+).
+- **Everything a program printed must stay the same**: Layer 6 — a whole-program
+ stdout/stderr/exit snapshot, for when the thing to protect is the output as a
+ whole rather than one asserted substring.
+- **A law that must hold for every input, not just the chosen ones**: Layer 7.
+- **"Does any test actually constrain this line?"**: Layer 8 — not a test, a
+ measurement of the others. On demand; never in the per-commit gate.
## Running
@@ -35,12 +44,12 @@ bash bench/compare_langs.sh --cold # Layer 4: cross-language perf (on demand)
`test/run_all.sh` accepts `--skip ` / `--only ` /
`--list` flags (per ADR-0024 run_step pattern).
-## Future layers (deferred per ADR-0021)
+## Future layers (still deferred)
-`test/integration/` (Phase 5+), `test/golden/` (Phase 7+),
-`test/prop/` (Phase 8+), `fuzz/` (Phase 6+), `test/clj/` (Phase 11+).
-These directories are created when the corresponding phase opens,
-not as empty placeholders.
+`test/integration/` (Phase 5+) and `fuzz/` (Phase 6+) remain unopened.
+Golden, property and mutation opened with ADR-0186; `test/clj/` opened at
+Phase 11. Directories are created when a layer opens, not as empty
+placeholders.
## Conventions
From e941759bd87787d1e0507119e8d93d97a7cc26fe Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:52:23 -0300
Subject: [PATCH 006/466] test(golden): snapshot whole-program output for 20
programs (Layer 6)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A case is a program; its snapshot is stdout, stderr and exit code in one
file. `run.sh --update` regenerates and the diff is the review surface.
The 20 opening cases were chosen where a regression would be invisible to
the existing layers rather than where writing a test is easy: the printed
form of every scalar and collection, the error renderer's source excerpt
and caret for six error classes, laziness ordering, destructuring,
dispatch (multimethod / protocol / record), exception unwinding order,
atoms and delays, transducers, metadata, the numeric tower's promotion
boundary, and the Wasm FFI calling a real .wasm module.
Every snapshot was generated and then READ before being committed. Two
are worth knowing about:
- `error_ex_info` records that a thrown ex-info prints "Exception / boom"
and DROPS its ex-data, with no source location or trace — where the
other five error classes all render an excerpt. The snapshot pins what
cljw does today, not what it should do; it becomes the regression test
the moment that is fixed.
- `numeric_tower` pins AD-008 (Long overflow auto-promotes to BigInt
where clj throws). A snapshot over an accepted divergence is exactly
where one belongs: the divergence is a decision, and a decision that
drifts silently is no longer a decision.
Determinism is enforced by normalisation — repo path, hex addresses,
pids, durations, version string — not by hoping. CLJW_BIN lets the suite
run against a binary built elsewhere (a clean worktree, a release
artifact, a mutant), which is how these snapshots were verified to
reproduce byte-identically against the published v1.10.1 artifact rather
than only against the local build.
Wired into the gate after build_cljw: it needs the fresh binary, it is
cheaper than the e2e suite, and its failures are more legible.
---
test/golden/cases/core_string_set_walk.clj | 5 +
.../cases/core_string_set_walk.expected | 8 ++
test/golden/cases/destructuring.clj | 6 +
test/golden/cases/destructuring.expected | 7 ++
test/golden/cases/error_arity.clj | 3 +
test/golden/cases/error_arity.expected | 15 +++
test/golden/cases/error_div_zero.clj | 2 +
test/golden/cases/error_div_zero.expected | 11 ++
test/golden/cases/error_ex_info.clj | 2 +
test/golden/cases/error_ex_info.expected | 7 ++
test/golden/cases/error_missing_ns.clj | 2 +
test/golden/cases/error_missing_ns.expected | 11 ++
test/golden/cases/error_reader.clj | 2 +
test/golden/cases/error_reader.expected | 11 ++
test/golden/cases/error_type.clj | 2 +
test/golden/cases/error_type.expected | 11 ++
test/golden/cases/error_unresolved_symbol.clj | 3 +
.../cases/error_unresolved_symbol.expected | 12 ++
test/golden/cases/exceptions_control.clj | 11 ++
test/golden/cases/exceptions_control.expected | 11 ++
test/golden/cases/metadata_vars.clj | 6 +
test/golden/cases/metadata_vars.expected | 8 ++
test/golden/cases/numeric_tower.clj | 6 +
test/golden/cases/numeric_tower.expected | 9 ++
test/golden/cases/polymorphism.clj | 17 +++
test/golden/cases/polymorphism.expected | 8 ++
test/golden/cases/printer_collections.clj | 9 ++
.../golden/cases/printer_collections.expected | 17 +++
test/golden/cases/printer_opaque.clj | 7 ++
test/golden/cases/printer_opaque.expected | 9 ++
test/golden/cases/printer_scalars.clj | 6 +
test/golden/cases/printer_scalars.expected | 21 ++++
test/golden/cases/seq_laziness.clj | 6 +
test/golden/cases/seq_laziness.expected | 10 ++
test/golden/cases/state_atoms.clj | 8 ++
test/golden/cases/state_atoms.expected | 10 ++
test/golden/cases/transducers_seq.clj | 8 ++
test/golden/cases/transducers_seq.expected | 12 ++
test/golden/cases/wasm_ffi.clj | 5 +
test/golden/cases/wasm_ffi.expected | 6 +
test/golden/run.sh | 119 ++++++++++++++++++
test/run_all.sh | 7 ++
42 files changed, 456 insertions(+)
create mode 100644 test/golden/cases/core_string_set_walk.clj
create mode 100644 test/golden/cases/core_string_set_walk.expected
create mode 100644 test/golden/cases/destructuring.clj
create mode 100644 test/golden/cases/destructuring.expected
create mode 100644 test/golden/cases/error_arity.clj
create mode 100644 test/golden/cases/error_arity.expected
create mode 100644 test/golden/cases/error_div_zero.clj
create mode 100644 test/golden/cases/error_div_zero.expected
create mode 100644 test/golden/cases/error_ex_info.clj
create mode 100644 test/golden/cases/error_ex_info.expected
create mode 100644 test/golden/cases/error_missing_ns.clj
create mode 100644 test/golden/cases/error_missing_ns.expected
create mode 100644 test/golden/cases/error_reader.clj
create mode 100644 test/golden/cases/error_reader.expected
create mode 100644 test/golden/cases/error_type.clj
create mode 100644 test/golden/cases/error_type.expected
create mode 100644 test/golden/cases/error_unresolved_symbol.clj
create mode 100644 test/golden/cases/error_unresolved_symbol.expected
create mode 100644 test/golden/cases/exceptions_control.clj
create mode 100644 test/golden/cases/exceptions_control.expected
create mode 100644 test/golden/cases/metadata_vars.clj
create mode 100644 test/golden/cases/metadata_vars.expected
create mode 100644 test/golden/cases/numeric_tower.clj
create mode 100644 test/golden/cases/numeric_tower.expected
create mode 100644 test/golden/cases/polymorphism.clj
create mode 100644 test/golden/cases/polymorphism.expected
create mode 100644 test/golden/cases/printer_collections.clj
create mode 100644 test/golden/cases/printer_collections.expected
create mode 100644 test/golden/cases/printer_opaque.clj
create mode 100644 test/golden/cases/printer_opaque.expected
create mode 100644 test/golden/cases/printer_scalars.clj
create mode 100644 test/golden/cases/printer_scalars.expected
create mode 100644 test/golden/cases/seq_laziness.clj
create mode 100644 test/golden/cases/seq_laziness.expected
create mode 100644 test/golden/cases/state_atoms.clj
create mode 100644 test/golden/cases/state_atoms.expected
create mode 100644 test/golden/cases/transducers_seq.clj
create mode 100644 test/golden/cases/transducers_seq.expected
create mode 100644 test/golden/cases/wasm_ffi.clj
create mode 100644 test/golden/cases/wasm_ffi.expected
create mode 100755 test/golden/run.sh
diff --git a/test/golden/cases/core_string_set_walk.clj b/test/golden/cases/core_string_set_walk.clj
new file mode 100644
index 000000000..2c570c6dd
--- /dev/null
+++ b/test/golden/cases/core_string_set_walk.clj
@@ -0,0 +1,5 @@
+(require '[clojure.string :as str] '[clojure.set :as set] '[clojure.walk :as walk])
+(prn (str/split "a,b,,c" #","))
+(prn (str/join "-" ["x" "y"]) (str/upper-case "ab") (str/trim " p ") (str/replace "aXa" "X" "-"))
+(prn (set/union #{1 2} #{2 3}) (set/difference #{1 2 3} #{2}) (set/intersection #{1 2} #{2 3}))
+(prn (walk/postwalk (fn [x] (if (number? x) (* 10 x) x)) {:a [1 {:b 2}]}))
diff --git a/test/golden/cases/core_string_set_walk.expected b/test/golden/cases/core_string_set_walk.expected
new file mode 100644
index 000000000..1f6dfe6b9
--- /dev/null
+++ b/test/golden/cases/core_string_set_walk.expected
@@ -0,0 +1,8 @@
+--- exit ---
+0
+--- stdout ---
+["a" "b" "" "c"]
+"x-y" "AB" "p" "a-a"
+#{1 2 3} #{1 3} #{2}
+{:a [10 {:b 20}]}
+--- stderr ---
diff --git a/test/golden/cases/destructuring.clj b/test/golden/cases/destructuring.clj
new file mode 100644
index 000000000..e344a833f
--- /dev/null
+++ b/test/golden/cases/destructuring.clj
@@ -0,0 +1,6 @@
+(let [[a b & rest :as all] [1 2 3 4]
+ {:keys [x y] :or {y 99} :as m} {:x 10}]
+ (println a b rest all)
+ (println x y m))
+(defn g [{:keys [k]} & more] [k more])
+(prn (g {:k :v} 1 2))
diff --git a/test/golden/cases/destructuring.expected b/test/golden/cases/destructuring.expected
new file mode 100644
index 000000000..8bba0348f
--- /dev/null
+++ b/test/golden/cases/destructuring.expected
@@ -0,0 +1,7 @@
+--- exit ---
+0
+--- stdout ---
+1 2 (3 4) [1 2 3 4]
+10 99 {:x 10}
+[:v (1 2)]
+--- stderr ---
diff --git a/test/golden/cases/error_arity.clj b/test/golden/cases/error_arity.clj
new file mode 100644
index 000000000..390361b1b
--- /dev/null
+++ b/test/golden/cases/error_arity.clj
@@ -0,0 +1,3 @@
+(defn f [a b] (+ a b))
+(println "before")
+(f 1)
diff --git a/test/golden/cases/error_arity.expected b/test/golden/cases/error_arity.expected
new file mode 100644
index 000000000..f8bd28a4d
--- /dev/null
+++ b/test/golden/cases/error_arity.expected
@@ -0,0 +1,15 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Arity error at /test/golden/cases/error_arity.clj:3:0
+ Wrong number of args (1) passed to fn, expected 2
+
+ 1 | (defn f [a b] (+ a b))
+ 2 | (println "before")
+ 3 | (f 1)
+ ^--- Wrong number of args (1) passed to fn, expected 2
+
+Trace:
+ user/f (/test/golden/cases/error_arity.clj:3)
diff --git a/test/golden/cases/error_div_zero.clj b/test/golden/cases/error_div_zero.clj
new file mode 100644
index 000000000..7cc6180e8
--- /dev/null
+++ b/test/golden/cases/error_div_zero.clj
@@ -0,0 +1,2 @@
+(println "before")
+(/ 1 0)
diff --git a/test/golden/cases/error_div_zero.expected b/test/golden/cases/error_div_zero.expected
new file mode 100644
index 000000000..0ab5a9802
--- /dev/null
+++ b/test/golden/cases/error_div_zero.expected
@@ -0,0 +1,11 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Arithmetic error at /test/golden/cases/error_div_zero.clj:2:5
+ Divide by zero
+
+ 1 | (println "before")
+ 2 | (/ 1 0)
+ ^--- Divide by zero
diff --git a/test/golden/cases/error_ex_info.clj b/test/golden/cases/error_ex_info.clj
new file mode 100644
index 000000000..e8218aa98
--- /dev/null
+++ b/test/golden/cases/error_ex_info.clj
@@ -0,0 +1,2 @@
+(println "before")
+(throw (ex-info "boom" {:code 42 :nested {:k [1 2]}}))
diff --git a/test/golden/cases/error_ex_info.expected b/test/golden/cases/error_ex_info.expected
new file mode 100644
index 000000000..7429fb071
--- /dev/null
+++ b/test/golden/cases/error_ex_info.expected
@@ -0,0 +1,7 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Exception
+ boom
diff --git a/test/golden/cases/error_missing_ns.clj b/test/golden/cases/error_missing_ns.clj
new file mode 100644
index 000000000..f782739d1
--- /dev/null
+++ b/test/golden/cases/error_missing_ns.clj
@@ -0,0 +1,2 @@
+(println "before")
+(require '[no.such.namespace :as n])
diff --git a/test/golden/cases/error_missing_ns.expected b/test/golden/cases/error_missing_ns.expected
new file mode 100644
index 000000000..f33367623
--- /dev/null
+++ b/test/golden/cases/error_missing_ns.expected
@@ -0,0 +1,11 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Name error at /test/golden/cases/error_missing_ns.clj:2:10
+ Could not locate 'no.such.namespace' on the require resolver
+
+ 1 | (println "before")
+ 2 | (require '[no.such.namespace :as n])
+ ^--- Could not locate 'no.such.namespace' on the require resolver
diff --git a/test/golden/cases/error_reader.clj b/test/golden/cases/error_reader.clj
new file mode 100644
index 000000000..a4f319562
--- /dev/null
+++ b/test/golden/cases/error_reader.clj
@@ -0,0 +1,2 @@
+(println "before")
+(let [x 1] (println x)
diff --git a/test/golden/cases/error_reader.expected b/test/golden/cases/error_reader.expected
new file mode 100644
index 000000000..4a35fc891
--- /dev/null
+++ b/test/golden/cases/error_reader.expected
@@ -0,0 +1,11 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Syntax error at /test/golden/cases/error_reader.clj:2:0
+ Unmatched delimiter; reached EOF before ')'
+
+ 1 | (println "before")
+ 2 | (let [x 1] (println x)
+ ^--- Unmatched delimiter; reached EOF before ')'
diff --git a/test/golden/cases/error_type.clj b/test/golden/cases/error_type.clj
new file mode 100644
index 000000000..9dfe7dad9
--- /dev/null
+++ b/test/golden/cases/error_type.clj
@@ -0,0 +1,2 @@
+(println "before")
+(+ 1 "not a number")
diff --git a/test/golden/cases/error_type.expected b/test/golden/cases/error_type.expected
new file mode 100644
index 000000000..f788e1fb1
--- /dev/null
+++ b/test/golden/cases/error_type.expected
@@ -0,0 +1,11 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Type error at /test/golden/cases/error_type.clj:2:5
+ +: expected number, got string
+
+ 1 | (println "before")
+ 2 | (+ 1 "not a number")
+ ^--- +: expected number, got string
diff --git a/test/golden/cases/error_unresolved_symbol.clj b/test/golden/cases/error_unresolved_symbol.clj
new file mode 100644
index 000000000..8a8f72306
--- /dev/null
+++ b/test/golden/cases/error_unresolved_symbol.clj
@@ -0,0 +1,3 @@
+(println "before")
+(no-such-fn 1 2)
+(println "after")
diff --git a/test/golden/cases/error_unresolved_symbol.expected b/test/golden/cases/error_unresolved_symbol.expected
new file mode 100644
index 000000000..786b2b261
--- /dev/null
+++ b/test/golden/cases/error_unresolved_symbol.expected
@@ -0,0 +1,12 @@
+--- exit ---
+1
+--- stdout ---
+before
+--- stderr ---
+Name error at /test/golden/cases/error_unresolved_symbol.clj:2:1
+ Unable to resolve symbol: 'no-such-fn'
+
+ 1 | (println "before")
+ 2 | (no-such-fn 1 2)
+ ^--- Unable to resolve symbol: 'no-such-fn'
+ 3 | (println "after")
diff --git a/test/golden/cases/exceptions_control.clj b/test/golden/cases/exceptions_control.clj
new file mode 100644
index 000000000..45a178ddf
--- /dev/null
+++ b/test/golden/cases/exceptions_control.clj
@@ -0,0 +1,11 @@
+;; The order side effects happen in when an exception unwinds is the contract.
+(prn (try 1 (catch Exception e :caught) (finally (println "finally-1"))))
+(prn (try (throw (ex-info "x" {:a 1}))
+ (catch clojure.lang.ExceptionInfo e [:info (ex-message e) (ex-data e)])
+ (finally (println "finally-2"))))
+(prn (try (/ 1 0) (catch ArithmeticException e (.getMessage e))))
+(prn (try (throw (RuntimeException. "plain")) (catch Exception e (str "caught: " (.getMessage e)))))
+;; A rethrow must not lose the original data.
+(prn (try (try (throw (ex-info "inner" {:depth 1}))
+ (catch Exception e (throw e)))
+ (catch Exception e (ex-data e))))
diff --git a/test/golden/cases/exceptions_control.expected b/test/golden/cases/exceptions_control.expected
new file mode 100644
index 000000000..ab1c498ba
--- /dev/null
+++ b/test/golden/cases/exceptions_control.expected
@@ -0,0 +1,11 @@
+--- exit ---
+0
+--- stdout ---
+finally-1
+1
+finally-2
+[:info "x" {:a 1}]
+"Divide by zero"
+"caught: plain"
+{:depth 1}
+--- stderr ---
diff --git a/test/golden/cases/metadata_vars.clj b/test/golden/cases/metadata_vars.clj
new file mode 100644
index 000000000..6a2d6f712
--- /dev/null
+++ b/test/golden/cases/metadata_vars.clj
@@ -0,0 +1,6 @@
+(def ^{:doc "documented" :private false :custom :m} x 1)
+(prn (select-keys (meta #'x) [:doc :custom :name]))
+(prn (meta (with-meta [1 2] {:k :v})))
+(prn (:tag (meta (with-meta 'sym {:tag 'String}))))
+;; Metadata must not participate in equality.
+(prn (= (with-meta [1] {:a 1}) (with-meta [1] {:b 2})))
diff --git a/test/golden/cases/metadata_vars.expected b/test/golden/cases/metadata_vars.expected
new file mode 100644
index 000000000..fe2cd7e70
--- /dev/null
+++ b/test/golden/cases/metadata_vars.expected
@@ -0,0 +1,8 @@
+--- exit ---
+0
+--- stdout ---
+{:doc "documented", :custom :m, :name x}
+{:k :v}
+String
+true
+--- stderr ---
diff --git a/test/golden/cases/numeric_tower.clj b/test/golden/cases/numeric_tower.clj
new file mode 100644
index 000000000..a04d984cd
--- /dev/null
+++ b/test/golden/cases/numeric_tower.clj
@@ -0,0 +1,6 @@
+;; Where an integer stops being an integer, and what the boundary prints as.
+(prn (+ 1 2) (- 1 2) (* 3 4) (/ 6 3) (/ 7 2) (quot 7 2) (rem 7 2) (mod -7 2))
+(prn (+ 1 2.0) (* 2 0.5) (== 1 1.0) (= 1 1.0))
+(prn (inc 9007199254740992) (* 1000000000000 1000000000000))
+(prn (Integer/MAX_VALUE) (inc Integer/MAX_VALUE))
+(prn (double 1/3) (float? 1.0) (integer? 1) (ratio? 1/3))
diff --git a/test/golden/cases/numeric_tower.expected b/test/golden/cases/numeric_tower.expected
new file mode 100644
index 000000000..f934df7e0
--- /dev/null
+++ b/test/golden/cases/numeric_tower.expected
@@ -0,0 +1,9 @@
+--- exit ---
+0
+--- stdout ---
+3 -1 12 2 7/2 3 1 1
+3.0 1.0 true false
+9007199254740993 1000000000000000000000000N
+2147483647 2147483648
+0.3333333333333333 true true true
+--- stderr ---
diff --git a/test/golden/cases/polymorphism.clj b/test/golden/cases/polymorphism.clj
new file mode 100644
index 000000000..69b6002f6
--- /dev/null
+++ b/test/golden/cases/polymorphism.clj
@@ -0,0 +1,17 @@
+;; Dispatch: multimethods, protocols, records. The printed forms of a record and
+;; of a protocol-satisfying value are part of the user-visible surface.
+(defmulti area :shape)
+(defmethod area :circle [{:keys [r]}] (* 3 r r))
+(defmethod area :rect [{:keys [w h]}] (* w h))
+(defmethod area :default [_] :unknown)
+(prn (area {:shape :circle :r 2}) (area {:shape :rect :w 2 :h 3}) (area {:shape :blob}))
+
+(defprotocol Greet (greet [this] [this loud?]))
+(defrecord Person [name]
+ Greet
+ (greet [this] (str "hi " (:name this)))
+ (greet [this loud?] (if loud? "HI" (greet this))))
+(def p (->Person "ada"))
+(prn (greet p) (greet p true))
+(prn (:name p) (map->Person {:name "bob"}))
+(prn (satisfies? Greet p) (instance? Person p))
diff --git a/test/golden/cases/polymorphism.expected b/test/golden/cases/polymorphism.expected
new file mode 100644
index 000000000..87455f60b
--- /dev/null
+++ b/test/golden/cases/polymorphism.expected
@@ -0,0 +1,8 @@
+--- exit ---
+0
+--- stdout ---
+12 6 :unknown
+"hi ada" "HI"
+"ada" #user.Person{:name "bob"}
+true true
+--- stderr ---
diff --git a/test/golden/cases/printer_collections.clj b/test/golden/cases/printer_collections.clj
new file mode 100644
index 000000000..83ac7a66d
--- /dev/null
+++ b/test/golden/cases/printer_collections.clj
@@ -0,0 +1,9 @@
+;; Collection printing, including nesting, empties, and a map's entry form.
+(doseq [v ['() [] {} #{}
+ '(1 2 3) [1 2 3] {:a 1 :b 2} #{1 2 3}
+ [nil [nil [nil]]]
+ {:xs [1 2] :m {:k #{:v}}}
+ (range 5)
+ (map inc [1 2 3])
+ (seq "abc")]]
+ (prn v))
diff --git a/test/golden/cases/printer_collections.expected b/test/golden/cases/printer_collections.expected
new file mode 100644
index 000000000..c80bb270c
--- /dev/null
+++ b/test/golden/cases/printer_collections.expected
@@ -0,0 +1,17 @@
+--- exit ---
+0
+--- stdout ---
+()
+[]
+{}
+#{}
+(1 2 3)
+[1 2 3]
+{:a 1, :b 2}
+#{1 2 3}
+[nil [nil [nil]]]
+{:xs [1 2], :m {:k #{:v}}}
+(0 1 2 3 4)
+(2 3 4)
+(\a \b \c)
+--- stderr ---
diff --git a/test/golden/cases/printer_opaque.clj b/test/golden/cases/printer_opaque.clj
new file mode 100644
index 000000000..f146ccc24
--- /dev/null
+++ b/test/golden/cases/printer_opaque.clj
@@ -0,0 +1,7 @@
+;; Values whose printed form is identity-bearing or type-bearing. These are the
+;; ones most likely to drift silently when an internal representation changes.
+(prn (type 1) (type 1.0) (type "s") (type :k) (type 's) (type nil))
+(prn (type []) (type '()) (type {}) (type #{}) (type (range 3)) (type (map inc [1])))
+(prn (type #"re") (type (atom 1)) (type #'prn) (type (fn [])))
+(println (boolean (re-find #"a+" "caaat")))
+(prn (re-find #"a+" "caaat") (re-seq #"[0-9]+" "a1b22c333"))
diff --git a/test/golden/cases/printer_opaque.expected b/test/golden/cases/printer_opaque.expected
new file mode 100644
index 000000000..bb414c891
--- /dev/null
+++ b/test/golden/cases/printer_opaque.expected
@@ -0,0 +1,9 @@
+--- exit ---
+0
+--- stdout ---
+Long Double String Keyword Symbol nil
+PersistentVector PersistentList PersistentArrayMap PersistentHashSet LongRange LazySeq
+Pattern Atom Var Fn
+true
+"aaa" ("1" "22" "333")
+--- stderr ---
diff --git a/test/golden/cases/printer_scalars.clj b/test/golden/cases/printer_scalars.clj
new file mode 100644
index 000000000..64dc33885
--- /dev/null
+++ b/test/golden/cases/printer_scalars.clj
@@ -0,0 +1,6 @@
+;; How every scalar prints, and how `pr` differs from `println`.
+;; A change in any of these lines is a change in what every user sees.
+(doseq [v [nil true false 0 -1 42 3.14 1/3 \a \newline "s" "with \"quote\"" :kw :ns/kw 'sym 'ns/sym]]
+ (pr v)
+ (print " | ")
+ (println v))
diff --git a/test/golden/cases/printer_scalars.expected b/test/golden/cases/printer_scalars.expected
new file mode 100644
index 000000000..538986173
--- /dev/null
+++ b/test/golden/cases/printer_scalars.expected
@@ -0,0 +1,21 @@
+--- exit ---
+0
+--- stdout ---
+nil | nil
+true | true
+false | false
+0 | 0
+-1 | -1
+42 | 42
+3.14 | 3.14
+1/3 | 1/3
+\a | a
+\newline |
+
+"s" | s
+"with \"quote\"" | with "quote"
+:kw | :kw
+:ns/kw | :ns/kw
+sym | sym
+ns/sym | ns/sym
+--- stderr ---
diff --git a/test/golden/cases/seq_laziness.clj b/test/golden/cases/seq_laziness.clj
new file mode 100644
index 000000000..ba5bf9b62
--- /dev/null
+++ b/test/golden/cases/seq_laziness.clj
@@ -0,0 +1,6 @@
+;; Laziness is observable through side effects; the ORDER of these lines is
+;; the contract, not just the final value.
+(def xs (map (fn [x] (println "realising" x) (* x x)) [1 2 3]))
+(println "defined")
+(println "first" (first xs))
+(println "all" (doall xs))
diff --git a/test/golden/cases/seq_laziness.expected b/test/golden/cases/seq_laziness.expected
new file mode 100644
index 000000000..4e994f65d
--- /dev/null
+++ b/test/golden/cases/seq_laziness.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+defined
+realising 1
+first 1
+realising 2
+realising 3
+all (1 4 9)
+--- stderr ---
diff --git a/test/golden/cases/state_atoms.clj b/test/golden/cases/state_atoms.clj
new file mode 100644
index 000000000..c8fc5af0c
--- /dev/null
+++ b/test/golden/cases/state_atoms.clj
@@ -0,0 +1,8 @@
+(def a (atom 0))
+(prn (swap! a inc) (swap! a + 10) @a (reset! a :done) @a)
+(def v (volatile! 1))
+(prn (vswap! v inc) @v)
+(def d (delay (do (println "forced once") 42)))
+(prn (force d) (force d) (realized? d))
+(prn @(future (+ 1 2)))
+(let [p (promise)] (deliver p :val) (prn @p))
diff --git a/test/golden/cases/state_atoms.expected b/test/golden/cases/state_atoms.expected
new file mode 100644
index 000000000..e4b028107
--- /dev/null
+++ b/test/golden/cases/state_atoms.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+1 11 11 :done :done
+2 2
+forced once
+42 42 true
+3
+:val
+--- stderr ---
diff --git a/test/golden/cases/transducers_seq.clj b/test/golden/cases/transducers_seq.clj
new file mode 100644
index 000000000..997e0eee2
--- /dev/null
+++ b/test/golden/cases/transducers_seq.clj
@@ -0,0 +1,8 @@
+(prn (into [] (comp (map inc) (filter even?) (take 3)) (range 20)))
+(prn (transduce (map inc) + 0 [1 2 3]))
+(prn (sequence (mapcat range) [1 2 3]))
+(prn (eduction (filter odd?) (range 10)))
+(prn (reduce-kv (fn [acc k v] (assoc acc v k)) {} {:a 1 :b 2}))
+(prn (group-by odd? (range 6)) (frequencies "aabbbc") (partition-by odd? [1 3 2 4 5]))
+(prn (sort [3 1 2]) (sort-by - [3 1 2]) (sort compare ["b" "a"]))
+(prn (take 3 (iterate #(* 2 %) 1)) (take 4 (cycle [:a :b])) (take 3 (repeat :x)))
diff --git a/test/golden/cases/transducers_seq.expected b/test/golden/cases/transducers_seq.expected
new file mode 100644
index 000000000..8a731b466
--- /dev/null
+++ b/test/golden/cases/transducers_seq.expected
@@ -0,0 +1,12 @@
+--- exit ---
+0
+--- stdout ---
+[2 4 6]
+9
+(0 0 1 0 1 2)
+(1 3 5 7 9)
+{1 :a, 2 :b}
+{false [0 2 4], true [1 3 5]} {\a 2, \b 3, \c 1} ((1 3) (2 4) (5))
+(1 2 3) (3 2 1) ("a" "b")
+(1 2 4) (:a :b :a :b) (:x :x :x)
+--- stderr ---
diff --git a/test/golden/cases/wasm_ffi.clj b/test/golden/cases/wasm_ffi.clj
new file mode 100644
index 000000000..de5bad3ae
--- /dev/null
+++ b/test/golden/cases/wasm_ffi.clj
@@ -0,0 +1,5 @@
+;; The headline feature: a module compiled from another language, called like a
+;; function. If this snapshot changes, the FFI's user-visible surface changed.
+(def m (wasm/load "docs/examples/wasm/add.wasm"))
+(prn (wasm/call m "add" 40 2))
+(prn (wasm/call m "add" -1 1))
diff --git a/test/golden/cases/wasm_ffi.expected b/test/golden/cases/wasm_ffi.expected
new file mode 100644
index 000000000..a8d1ea971
--- /dev/null
+++ b/test/golden/cases/wasm_ffi.expected
@@ -0,0 +1,6 @@
+--- exit ---
+0
+--- stdout ---
+42
+0
+--- stderr ---
diff --git a/test/golden/run.sh b/test/golden/run.sh
new file mode 100755
index 000000000..c92970746
--- /dev/null
+++ b/test/golden/run.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+# test/golden/run.sh — Layer 6 (Golden snapshot), opened per ADR-0186.
+#
+# A golden case is a WHOLE PROGRAM plus everything the process produced for it:
+# stdout, stderr and exit code, in one file. That is the difference from Layer 2
+# (e2e), which asserts a chosen substring of a chosen stream: an e2e case can
+# only fail for the thing its author thought to assert, while a snapshot fails
+# for any change in what the user sees — a reworded error, a dropped newline, a
+# value that starts printing its metadata, a warning that appears on stderr.
+#
+# That sensitivity is the point and also the cost: a snapshot suite that is
+# tedious to update gets updated carelessly. So `--update` regenerates, and the
+# diff it produces is the review surface. A reviewer reads the diff, not the
+# file.
+#
+# Determinism is enforced by NORMALISATION, not by hoping: the repo path, hex
+# addresses, pids, durations and the version string are rewritten to fixed
+# tokens before comparison (see normalise()). A case that still varies run to
+# run does not belong in this layer.
+#
+# Usage:
+# bash test/golden/run.sh # verify every case
+# bash test/golden/run.sh --update # regenerate every .expected
+# bash test/golden/run.sh --only printer # substring-filter case names
+# CLJW_SKIP_BUILD=1 bash test/golden/run.sh
+#
+# Adding a case: drop `cases/.clj` in, run with --update, READ the
+# generated `.expected`, commit both. A snapshot committed without being read
+# is a record of a bug as readily as of a behaviour.
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+ROOT=$(pwd)
+# CLJW_BIN lets a caller point the suite at a binary built elsewhere — a clean
+# git worktree, a downloaded release artifact, a mutant build. Without it the
+# suite tests this checkout, which is what the gate wants.
+BIN="${CLJW_BIN:-$ROOT/zig-out/bin/cljw}"
+CASE_DIR="$ROOT/test/golden/cases"
+
+UPDATE=0
+ONLY=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --update) UPDATE=1 ;;
+ --only) ONLY="$2"; shift ;;
+ *) echo "unknown flag: $1" >&2; exit 2 ;;
+ esac
+ shift
+done
+
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+[ -x "$BIN" ] || { echo "golden: no binary at $BIN" >&2; exit 1; }
+
+# Rewrite everything that legitimately differs between two runs of the same
+# build, and between two machines. Anything not rewritten here is a promise
+# that it is stable.
+normalise() {
+ sed -e "s|$ROOT||g" \
+ -e 's|0x[0-9a-f]\{4,\}|0xADDR|g' \
+ -e 's|ClojureWasm v[0-9][0-9.]*|ClojureWasm v|g' \
+ -e 's|[0-9]\{1,\}\.[0-9]\{1,\}\(ms\|s\|us\|µs\)||g' \
+ -e 's|pid [0-9]\{1,\}|pid |g' \
+ -e 's|/tmp/[A-Za-z0-9_.-]\{6,\}||g'
+}
+
+# One case = one file. The envelope is part of the snapshot so a stream moving
+# (a message migrating from stdout to stderr) is itself a diff.
+capture() {
+ local clj="$1" out err rc
+ out=$("$BIN" "$clj" 2>/tmp/golden_err.$$ ) && rc=0 || rc=$?
+ err=$(cat /tmp/golden_err.$$); rm -f /tmp/golden_err.$$
+ {
+ echo "--- exit ---"
+ echo "$rc"
+ echo "--- stdout ---"
+ printf '%s\n' "$out"
+ echo "--- stderr ---"
+ printf '%s\n' "$err"
+ } | normalise
+}
+
+pass=0; fail=0; updated=0
+failed_names=""
+
+for clj in "$CASE_DIR"/*.clj; do
+ name=$(basename "$clj" .clj)
+ [ -z "$ONLY" ] || case "$name" in *"$ONLY"*) ;; *) continue ;; esac
+ expected="$CASE_DIR/$name.expected"
+ actual=$(capture "$clj")
+
+ if [ "$UPDATE" = 1 ]; then
+ if [ -f "$expected" ] && [ "$actual" = "$(cat "$expected")" ]; then
+ pass=$((pass + 1))
+ else
+ printf '%s\n' "$actual" > "$expected"
+ updated=$((updated + 1))
+ echo "UPDATED $name"
+ fi
+ continue
+ fi
+
+ if [ ! -f "$expected" ]; then
+ echo "FAIL $name — no snapshot; run with --update and read the result" >&2
+ fail=$((fail + 1)); failed_names="$failed_names $name"
+ elif [ "$actual" = "$(cat "$expected")" ]; then
+ pass=$((pass + 1))
+ else
+ echo "FAIL $name" >&2
+ diff -u "$expected" <(printf '%s\n' "$actual") | head -40 >&2 || true
+ fail=$((fail + 1)); failed_names="$failed_names $name"
+ fi
+done
+
+if [ "$UPDATE" = 1 ]; then
+ echo "golden: $updated updated, $pass unchanged"
+ exit 0
+fi
+
+echo "golden: $pass passed, $fail failed"
+[ "$fail" = 0 ] || { echo "golden: failing cases:$failed_names" >&2; exit 1; }
diff --git a/test/run_all.sh b/test/run_all.sh
index 638e1e0a0..ff6c7af50 100644
--- a/test/run_all.sh
+++ b/test/run_all.sh
@@ -435,6 +435,13 @@ run_step "corpus_regression" "bash scripts/check_corpus_regression.sh"
# pre-build zig-out binary lacks __dump-host-classes (the ubuntunote miss).
run_step "compat_members" "bash scripts/check_compat_members.sh"
+# Layer 6 — golden snapshots (ADR-0186). Whole-program stdout+stderr+exit for a
+# corpus of programs, so a change to anything a user SEES fails here even when
+# no assertion was written for it. Runs after build_cljw (it needs the fresh
+# binary) and before the e2e suite (it is cheaper and its failures are more
+# legible than a substring assert's).
+run_step "golden" "bash test/golden/run.sh"
+
run_step "e2e_phase2_exit" "bash test/e2e/phase2_exit.sh"
run_step "e2e_phase3_cli" "bash test/e2e/phase3_cli.sh"
run_step "e2e_phase3_exit" "bash test/e2e/phase3_exit.sh"
From b2f7d30fba873aba094dd1b39a9d862b9a13c53f Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:53:04 -0300
Subject: [PATCH 007/466] test(mutation): add the Layer 8 sweep harness
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changes one line of one source file, rebuilds, runs the unit suite, and
records whether anything failed. A mutant the suite still passes is a
SURVIVOR: that line's behaviour is unconstrained by any test, and the
survivor names it exactly.
Three decisions worth stating, because each one is a way this class of
tool is usually got wrong:
It never touches the working checkout. The sweep builds a detached git
worktree at a revision, mutates there, and removes it. Mutating in place
would put uncommitted work one `git checkout --` from deletion, and an
interrupted run would leave a mutant behind that reads exactly like a
deliberate edit.
It refuses to run on a red baseline. A tree that is already failing
reports every mutant as killed, which is a perfect score for a broken
suite.
Every binary-operator rule matches only the SPACED form. `zig fmt` runs in
the gate, so `a * b` is the only way a binary operator can appear, and
requiring the spaces is what stops the enumerator proposing `*const T` ->
`/const T`. That is not cosmetic: an unviable mutant costs a full rebuild
to discover, and this layer's whole budget is measured in rebuilds. The
first draft produced 661 candidates for map.zig, most of them pointer
syntax; the same file now yields 390, all of them real behaviour points.
Not a gate. It runs on demand and via workflow_dispatch; a survivor is a
missing test to schedule, not a commit to reject. Gating it would also
make the sampling seed load-bearing — the same commit would pass or fail
depending on which mutants were drawn. The weekly cron in the workflow is
documentation of intended cadence for now: GitHub does not run scheduled
workflows in a forked repository.
---
.dev/mutation_targets.txt | 25 ++++
.github/workflows/mutation.yml | 80 ++++++++++++
.gitignore | 5 +
scripts/mutation/mutate.py | 217 +++++++++++++++++++++++++++++++++
scripts/mutation/run.sh | 207 +++++++++++++++++++++++++++++++
5 files changed, 534 insertions(+)
create mode 100644 .dev/mutation_targets.txt
create mode 100644 .github/workflows/mutation.yml
create mode 100755 scripts/mutation/mutate.py
create mode 100755 scripts/mutation/run.sh
diff --git a/.dev/mutation_targets.txt b/.dev/mutation_targets.txt
new file mode 100644
index 000000000..dba3b310c
--- /dev/null
+++ b/.dev/mutation_targets.txt
@@ -0,0 +1,25 @@
+# Layer 8 (ADR-0186) sweep targets — `scripts/mutation/run.sh --targets-from .dev/mutation_targets.txt`
+#
+# Ordering principle: a survivor is only worth the rebuild it costs, so sweep the
+# code where an unconstrained line would be BOTH easy to get wrong and expensive
+# to get wrong. That is the persistent collections (representation boundaries,
+# hand-written HAMT arithmetic, and the site of Discussion #12) before anything
+# that is mostly plumbing.
+#
+# Add a file when a sweep of the current list stops finding survivors, not
+# before — the list is a queue, not an inventory.
+
+# --- the collections: boundaries, bit arithmetic, structural sharing ---
+src/runtime/collection/map.zig
+src/runtime/collection/set.zig
+src/runtime/collection/vector.zig
+src/runtime/collection/list.zig
+
+# --- value representation: the layer everything above it trusts ---
+src/runtime/compare.zig
+src/runtime/print.zig
+
+# --- next, once the above are clean ---
+# src/eval/analyzer/analyzer.zig
+# src/runtime/collection/sorted.zig
+# src/runtime/collection/range.zig
diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml
new file mode 100644
index 000000000..e1d58e350
--- /dev/null
+++ b/.github/workflows/mutation.yml
@@ -0,0 +1,80 @@
+# Layer 8 (ADR-0186) — the mutation sweep. NOT the gate.
+#
+# This workflow answers "which lines does the suite fail to constrain", produces
+# a report, and stops. It never blocks a commit: a surviving mutant is a missing
+# test, which is work to schedule rather than a reason to reject a change. It
+# also does not run `test/run_all.sh`, so it cannot re-introduce the CI-vs-local
+# tiering that ADR-0185 removed (check_gate_parity.sh inspects ci.yml and
+# ci_gate.sh; this file is neither).
+#
+# TRIGGER: manual dispatch, plus a weekly cron. Note that GitHub does not run
+# scheduled workflows in a FORKED repository — until this repo stops being a
+# fork, treat the cron as documentation of intended cadence and dispatch the
+# sweep by hand.
+#
+# Cost: one full rebuild per mutant. `budget` is the number of rebuilds you are
+# buying, so the default is small and a deep sweep is an explicit choice.
+
+name: mutation
+
+on:
+ workflow_dispatch:
+ inputs:
+ budget:
+ description: "Mutants to run (one rebuild each)"
+ required: false
+ default: "12"
+ seed:
+ description: "Sampling seed — same seed, same mutants"
+ required: false
+ default: "0"
+ schedule:
+ - cron: "0 4 * * 1"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: mutation-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sweep:
+ name: sweep (linux-x86_64)
+ runs-on: ubuntu-22.04
+ timeout-minutes: 340
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # The harness builds a git worktree at a revision, so it needs history.
+ fetch-depth: 0
+
+ - name: Install Zig 0.16.0
+ shell: bash
+ run: |
+ set -euo pipefail
+ source .github/versions.lock
+ mkdir -p ~/.local
+ curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-0.16.0.tar.xz" -o /tmp/zig.tar.xz
+ tar -xJf /tmp/zig.tar.xz -C ~/.local
+ echo "$HOME/.local/zig-x86_64-linux-0.16.0" >> "$GITHUB_PATH"
+
+ - name: Sweep
+ shell: bash
+ run: |
+ set -euo pipefail
+ bash scripts/mutation/run.sh \
+ --targets-from .dev/mutation_targets.txt \
+ --budget "${{ github.event.inputs.budget || '12' }}" \
+ --seed "${{ github.event.inputs.seed || '0' }}"
+
+ - name: Upload the report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: mutation-report
+ path: |
+ .dev/mutation/report-*.md
+ .dev/mutation/mutants-*.jsonl
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index e536eb2e9..3ef3e9d64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,8 @@ private/
.lsp/
test/conformance/verified_projects/*/.cpcache/
__pycache__/
+
+# Layer 8 (ADR-0186) mutation sweep working output: candidate lists, per-mutant
+# build logs, the JSONL verdict log. A sweep REPORT worth keeping is committed
+# deliberately (git add -f .dev/mutation/report-*.md), never by accident.
+.dev/mutation/
diff --git a/scripts/mutation/mutate.py b/scripts/mutation/mutate.py
new file mode 100755
index 000000000..ed5c942ea
--- /dev/null
+++ b/scripts/mutation/mutate.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+"""Layer 8 (Mutation) mutant enumerator and applier — ADR-0186.
+
+A mutant is one textual change to one Zig source line that alters BEHAVIOUR
+while keeping the file plausible: a comparison flipped, an arithmetic operator
+swapped, a boolean connective changed, a small constant nudged, a branch forced.
+If the suite still passes with the change in place, no test constrains that line.
+
+Two subcommands:
+
+ mutate.py list [--seed N] [--limit K]
+ Print one JSON object per candidate mutant. Deterministic for a seed.
+
+ mutate.py apply
+ Rewrite the file in place with that mutant applied. Prints the diff line.
+
+Scope discipline: the enumerator refuses to touch comments, doc comments, string
+and character literals, `@import` lines, and `test "..."` blocks. Mutating a test
+proves nothing about the suite — a mutated assertion that still passes says the
+assertion was weak, which is a different question from the one this layer asks.
+
+The applier NEVER writes outside the file it is given, and `run.sh` only ever
+gives it paths inside a throwaway git worktree.
+"""
+
+import argparse
+import hashlib
+import json
+import random
+import re
+import sys
+
+# (name, pattern, replacement) — each is a behaviour change, not a refactor.
+#
+# Every binary-operator rule matches only the SPACED form (`a * b`, never
+# `*const T` or `ptr.*`). `zig fmt` runs in the gate, so the spaced form is the
+# only way a binary operator can appear, and requiring it is what keeps the
+# enumerator from proposing mutants that cannot compile. An unviable mutant is
+# not free: it costs a full rebuild to discover, and this layer's budget is
+# measured in rebuilds.
+OPERATORS = [
+ ("cmp_lt_to_le", r"(?<=[\w\)\]]) < (?=[\w\(@])", " <= "),
+ ("cmp_gt_to_ge", r"(?<=[\w\)\]]) > (?=[\w\(@])", " >= "),
+ ("cmp_le_to_lt", r"(?<=[\w\)\]]) <= (?=[\w\(@])", " < "),
+ ("cmp_ge_to_gt", r"(?<=[\w\)\]]) >= (?=[\w\(@])", " > "),
+ ("cmp_eq_to_ne", r"(?<=[\w\)\]]) == (?=[\w\(@.])", " != "),
+ ("cmp_ne_to_eq", r"(?<=[\w\)\]]) != (?=[\w\(@.])", " == "),
+ ("arith_add_to_sub", r"(?<=[\w\)\]]) \+ (?=[\w\(@])", " - "),
+ ("arith_sub_to_add", r"(?<=[\w\)\]]) - (?=[\w\(@])", " + "),
+ ("arith_mul_to_div", r"(?<=[\w\)\]]) \* (?=[\w\(@])", " / "),
+ ("bool_and_to_or", r"(?<=[\w\)\]]) and (?=[\w\(@!])", " or "),
+ ("bool_or_to_and", r"(?<=[\w\)\]]) or (?=[\w\(@!])", " and "),
+ # Constants only where they are being COMPARED or COMBINED — a literal in a
+ # struct-field default or an array length is usually layout, and mutating
+ # layout produces a build failure rather than a question about the suite.
+ ("const_bump_after_cmp", r"(?<=[<>!=]= )([0-9]+)(?![\w.])", None),
+ ("const_bump_in_arith", r"(?<=[+\-*/] )([0-9]+)(?![\w.])", None),
+ # A branch that no test distinguishes is the most valuable survivor of all:
+ # it means the condition itself is unconstrained.
+ ("branch_force_true", r"\bif \(([^()]{1,60})\)", "if (true)"),
+]
+
+# Rules whose replacement is computed from the match rather than fixed.
+def _replacement(op_name: str, match: "re.Match", fixed):
+ if fixed is not None:
+ return fixed
+ n = int(match.group(1))
+ return match.group(0).replace(str(n), str(n + 1), 1)
+
+SKIP_LINE = re.compile(
+ r"""^\s*(
+ // # comment or doc comment
+ | \# # (defensive)
+ | const\s+\w+\s*=\s*@import
+ | test\s+" # a test block header
+ | pub\s+const\s+\w+\s*=\s*@import
+ )""",
+ re.VERBOSE,
+)
+
+
+def mask_literals(line: str) -> str:
+ """Replace string/char literal CONTENT with placeholders of equal length, so
+ offsets stay valid while the matcher cannot see inside a literal."""
+ out = list(line)
+ i, n = 0, len(line)
+ while i < n:
+ ch = line[i]
+ if ch in ('"', "'"):
+ quote = ch
+ j = i + 1
+ while j < n:
+ if line[j] == "\\":
+ j += 2
+ continue
+ if line[j] == quote:
+ break
+ j += 1
+ for k in range(i + 1, min(j, n)):
+ out[k] = "_"
+ i = j + 1
+ continue
+ if ch == "/" and i + 1 < n and line[i + 1] == "/":
+ for k in range(i, n):
+ out[k] = "_"
+ break
+ i += 1
+ return "".join(out)
+
+
+def in_test_block(lines, idx: int) -> bool:
+ """True when the line sits inside a `test "..." { ... }` block. Tracks depth
+ from the nearest preceding top-level `test` header."""
+ depth = 0
+ for i in range(idx, -1, -1):
+ line = mask_literals(lines[i])
+ if i != idx:
+ depth += line.count("}") - line.count("{")
+ if re.match(r'^test\s+["{]', lines[i]):
+ return depth <= 0
+ if re.match(r"^(pub\s+)?fn |^pub const |^const .*= struct", lines[i]) and depth <= 0:
+ return False
+ return False
+
+
+def enumerate_mutants(path: str):
+ with open(path, "r", encoding="utf-8") as fh:
+ lines = fh.read().split("\n")
+
+ mutants = []
+ for idx, raw in enumerate(lines):
+ if not raw.strip() or SKIP_LINE.match(raw):
+ continue
+ if in_test_block(lines, idx):
+ continue
+ masked = mask_literals(raw)
+ for op_name, pattern, repl in OPERATORS:
+ for m in re.finditer(pattern, masked):
+ start, end = m.span()
+ mutated = raw[:start] + _replacement(op_name, m, repl) + raw[end:]
+ if mutated == raw:
+ continue
+ ident = hashlib.sha1(
+ f"{path}:{idx}:{start}:{op_name}".encode()
+ ).hexdigest()[:12]
+ mutants.append(
+ {
+ "id": ident,
+ "file": path,
+ "line": idx + 1,
+ "col": start + 1,
+ "op": op_name,
+ "before": raw.strip()[:120],
+ "after": mutated.strip()[:120],
+ }
+ )
+ return mutants
+
+
+def apply_mutant(path: str, mutant_id: str) -> dict:
+ mutants = {m["id"]: m for m in enumerate_mutants(path)}
+ if mutant_id not in mutants:
+ raise SystemExit(f"no mutant {mutant_id} in {path}")
+ m = mutants[mutant_id]
+
+ with open(path, "r", encoding="utf-8") as fh:
+ lines = fh.read().split("\n")
+
+ idx = m["line"] - 1
+ raw = lines[idx]
+ for op_name, pattern, repl in OPERATORS:
+ if op_name != m["op"]:
+ continue
+ masked = mask_literals(raw)
+ for hit in re.finditer(pattern, masked):
+ if hit.start() == m["col"] - 1:
+ lines[idx] = raw[: hit.start()] + _replacement(op_name, hit, repl) + raw[hit.end() :]
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write("\n".join(lines))
+ return m
+ raise SystemExit(f"mutant {mutant_id} no longer applies to {path} (file changed?)")
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ sub = ap.add_subparsers(dest="cmd", required=True)
+
+ lst = sub.add_parser("list")
+ lst.add_argument("file")
+ lst.add_argument("--seed", type=int, default=0)
+ lst.add_argument("--limit", type=int, default=0)
+
+ app = sub.add_parser("apply")
+ app.add_argument("file")
+ app.add_argument("mutant_id")
+
+ args = ap.parse_args()
+
+ if args.cmd == "list":
+ mutants = enumerate_mutants(args.file)
+ if args.limit and len(mutants) > args.limit:
+ # Sample rather than truncate: the first K mutants of a file are all
+ # in its first few lines, which is not a sample of the file.
+ random.Random(args.seed).shuffle(mutants)
+ mutants = mutants[: args.limit]
+ mutants.sort(key=lambda m: (m["line"], m["col"]))
+ for m in mutants:
+ print(json.dumps(m))
+ return 0
+
+ m = apply_mutant(args.file, args.mutant_id)
+ print(json.dumps(m))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/mutation/run.sh b/scripts/mutation/run.sh
new file mode 100755
index 000000000..e9f446338
--- /dev/null
+++ b/scripts/mutation/run.sh
@@ -0,0 +1,207 @@
+#!/usr/bin/env bash
+# scripts/mutation/run.sh — Layer 8 (Mutation) sweep. ADR-0186.
+#
+# Changes one line of one source file, rebuilds, runs the unit suite, and records
+# whether anything failed. A mutant the suite still passes is a SURVIVOR: that
+# line's behaviour is unconstrained by any test, and the survivor names it
+# exactly — file, line, and the change nobody noticed.
+#
+# This is a measurement, not a gate. It runs on demand and on a schedule; a
+# survivor is a work-list entry, not a commit to block. Cost is one rebuild per
+# mutant, so `--budget` is the parameter that matters.
+#
+# ISOLATION (the rule that makes this safe to run): the sweep NEVER touches your
+# checkout. It creates a detached git worktree at a chosen revision, mutates
+# files there, and removes it at the end. Mutating the working tree would put
+# uncommitted work one `git checkout --` away from deletion, and an interrupted
+# run would leave a mutant behind that reads exactly like a deliberate edit.
+#
+# Usage:
+# bash scripts/mutation/run.sh --targets src/runtime/collection/set.zig
+# bash scripts/mutation/run.sh --targets a.zig,b.zig --budget 20 --seed 7
+# bash scripts/mutation/run.sh --targets-from .dev/mutation_targets.txt --rev v1.10.1
+#
+# Output: a JSONL log plus a markdown summary under .dev/mutation/ (gitignored
+# by default — the REPORT is the artifact worth committing, not the log).
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+REPO_ROOT=$(pwd)
+
+TARGETS=""
+TARGETS_FILE=""
+BUDGET=15
+SEED=0
+REV="HEAD"
+OUT_DIR="$REPO_ROOT/.dev/mutation"
+BUILD_ARGS="-Dwasm -Doptimize=Debug"
+# A mutant can hang (a loop bound flipped). Time out generously enough that a
+# slow machine is not mistaken for a hang, and treat a timeout as KILLED: a
+# change that makes the suite never finish is a change the suite detected.
+PER_MUTANT_TIMEOUT=900
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --targets) TARGETS="$2"; shift ;;
+ --targets-from) TARGETS_FILE="$2"; shift ;;
+ --budget) BUDGET="$2"; shift ;;
+ --seed) SEED="$2"; shift ;;
+ --rev) REV="$2"; shift ;;
+ --out) OUT_DIR="$2"; shift ;;
+ --build-args) BUILD_ARGS="$2"; shift ;;
+ --timeout) PER_MUTANT_TIMEOUT="$2"; shift ;;
+ *) echo "unknown flag: $1" >&2; exit 2 ;;
+ esac
+ shift
+done
+
+if [ -n "$TARGETS_FILE" ]; then
+ TARGETS=$(grep -v '^\s*#' "$TARGETS_FILE" | grep -v '^\s*$' | paste -sd, -)
+fi
+[ -n "$TARGETS" ] || { echo "nothing to mutate: pass --targets or --targets-from" >&2; exit 2; }
+
+# Portable bounded run (check_portable_timeout.sh: no bare `timeout`).
+run_bounded() {
+ local secs="$1"; shift
+ if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@"
+ elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@"
+ else "$@"; fi
+}
+
+WORKTREE=$(mktemp -d "${TMPDIR:-/tmp}/cljw-mutation-XXXXXX")
+case "$WORKTREE" in
+ "$REPO_ROOT"|"$REPO_ROOT"/*)
+ echo "refusing to mutate inside the repository: $WORKTREE" >&2; exit 1 ;;
+esac
+
+cleanup() {
+ cd "$REPO_ROOT"
+ git worktree remove --force "$WORKTREE" >/dev/null 2>&1 || rm -rf "$WORKTREE"
+}
+trap cleanup EXIT
+
+echo "mutation: worktree $WORKTREE at $REV"
+git worktree add --detach "$WORKTREE" "$REV" >/dev/null
+
+mkdir -p "$OUT_DIR"
+STAMP=$(git rev-parse --short "$REV")
+LOG="$OUT_DIR/mutants-$STAMP-seed$SEED.jsonl"
+REPORT="$OUT_DIR/report-$STAMP-seed$SEED.md"
+: > "$LOG"
+
+# --- baseline -------------------------------------------------------------
+# Without this, a tree that is already red reports every mutant as killed and
+# the sweep produces a perfect score for a broken suite.
+echo "mutation: baseline build + test (this must pass) …"
+if ! ( cd "$WORKTREE" && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS ) >"$OUT_DIR/baseline.log" 2>&1; then
+ echo "mutation: BASELINE FAILED at $REV — fix the suite before measuring it." >&2
+ tail -20 "$OUT_DIR/baseline.log" >&2
+ exit 1
+fi
+echo "mutation: baseline green"
+
+# --- enumerate + sample ---------------------------------------------------
+ALL_MUTANTS="$OUT_DIR/candidates-$STAMP.jsonl"
+: > "$ALL_MUTANTS"
+IFS=',' read -ra TARGET_LIST <<< "$TARGETS"
+for t in "${TARGET_LIST[@]}"; do
+ [ -f "$WORKTREE/$t" ] || { echo "mutation: no such target $t" >&2; exit 2; }
+ ( cd "$WORKTREE" && python3 "$REPO_ROOT/scripts/mutation/mutate.py" list "$t" ) >> "$ALL_MUTANTS"
+done
+
+TOTAL=$(wc -l < "$ALL_MUTANTS" | tr -d ' ')
+SAMPLE="$OUT_DIR/sample-$STAMP-seed$SEED.jsonl"
+python3 - "$ALL_MUTANTS" "$SAMPLE" "$BUDGET" "$SEED" <<'PY'
+import json, random, sys
+src, dst, budget, seed = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
+rows = [json.loads(l) for l in open(src) if l.strip()]
+if budget and len(rows) > budget:
+ # Sample across the whole candidate set, not the head of it — a truncated
+ # list only ever measures the top of each file.
+ rows = random.Random(seed).sample(rows, budget)
+rows.sort(key=lambda r: (r["file"], r["line"], r["col"]))
+with open(dst, "w") as fh:
+ for r in rows:
+ fh.write(json.dumps(r) + "\n")
+print(f"mutation: {len(rows)} of {len(open(src).readlines())} candidates selected")
+PY
+
+# --- run ------------------------------------------------------------------
+killed=0; survived=0; unviable=0; n=0
+SELECTED=$(wc -l < "$SAMPLE" | tr -d ' ')
+
+while IFS= read -r row; do
+ n=$((n + 1))
+ id=$(printf '%s' "$row" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
+ file=$(printf '%s' "$row" | python3 -c 'import json,sys;print(json.load(sys.stdin)["file"])')
+ op=$(printf '%s' "$row" | python3 -c 'import json,sys;print(json.load(sys.stdin)["op"])')
+ line=$(printf '%s' "$row" | python3 -c 'import json,sys;print(json.load(sys.stdin)["line"])')
+
+ ( cd "$WORKTREE" && git checkout -- "$file" )
+ ( cd "$WORKTREE" && python3 "$REPO_ROOT/scripts/mutation/mutate.py" apply "$file" "$id" >/dev/null )
+
+ set +e
+ ( cd "$WORKTREE" && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS ) \
+ >"$OUT_DIR/mutant-$id.log" 2>&1
+ rc=$?
+ set -e
+
+ if [ $rc -eq 0 ]; then
+ verdict=survived; survived=$((survived + 1))
+ elif grep -qE "^(src/|/).*error: " "$OUT_DIR/mutant-$id.log" && \
+ ! grep -q "test failure" "$OUT_DIR/mutant-$id.log"; then
+ # Did not compile: says nothing about the suite.
+ verdict=unviable; unviable=$((unviable + 1))
+ rm -f "$OUT_DIR/mutant-$id.log"
+ else
+ verdict=killed; killed=$((killed + 1))
+ rm -f "$OUT_DIR/mutant-$id.log"
+ fi
+
+ printf '%s\n' "$row" | python3 -c "
+import json, sys
+row = json.load(sys.stdin)
+row['verdict'] = '$verdict'
+print(json.dumps(row))
+" >> "$LOG"
+
+ printf ' [%d/%d] %-9s %s:%s %s\n' "$n" "$SELECTED" "$verdict" "$file" "$line" "$op"
+done < "$SAMPLE"
+
+( cd "$WORKTREE" && git checkout -- . )
+
+# --- report ---------------------------------------------------------------
+python3 - "$LOG" "$REPORT" "$STAMP" "$SEED" "$TOTAL" <<'PY'
+import collections, json, sys
+log, report, stamp, seed, total = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
+rows = [json.loads(l) for l in open(log) if l.strip()]
+killed = [r for r in rows if r["verdict"] == "killed"]
+survived = [r for r in rows if r["verdict"] == "survived"]
+unviable = [r for r in rows if r["verdict"] == "unviable"]
+scored = len(killed) + len(survived)
+score = (100.0 * len(killed) / scored) if scored else float("nan")
+
+with open(report, "w") as fh:
+ w = fh.write
+ w(f"# Mutation sweep — {stamp} (seed {seed})\n\n")
+ w(f"- Candidates enumerated: {total}\n")
+ w(f"- Mutants run: {len(rows)}\n")
+ w(f"- Killed: {len(killed)}\n- Survived: {len(survived)}\n- Unviable (did not compile): {len(unviable)}\n")
+ w(f"- **Mutation score: {score:.1f}%** (killed / (killed + survived))\n\n")
+ if survived:
+ w("## Survivors — each is a line no test constrains\n\n")
+ w("| file | line | operator | change |\n|---|---:|---|---|\n")
+ for r in sorted(survived, key=lambda r: (r["file"], r["line"])):
+ before = r["before"].replace("|", "\\|")
+ after = r["after"].replace("|", "\\|")
+ w(f"| `{r['file']}` | {r['line']} | {r['op']} | `{before}` -> `{after}` |\n")
+ w("\n")
+ by_file = collections.Counter(r["file"] for r in survived)
+ w("### Survivors per file\n\n")
+ for f, c in by_file.most_common():
+ w(f"- `{f}`: {c}\n")
+ else:
+ w("No survivors in this sample.\n")
+print(f"mutation: score {score:.1f}% — report at {report}")
+PY
+
+echo "mutation: killed=$killed survived=$survived unviable=$unviable (log: $LOG)"
From 5317e6f7cec4681476d9bbdb1e7545cf15aef786 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:55:29 -0300
Subject: [PATCH 008/466] test(prop): property layer over the persistent
collections (Layer 7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Seven laws over generated input, plus the engine that generates, shrinks
and reports. `zig build test` runs them with a fixed seed; a sweep raises
`-Dprop-seed` / `-Dprop-iters`.
The oracle in each case is a naive model — a plain Zig array doing the
obviously-correct thing — fed the same operations as the persistent
structure. Sharing no implementation with what it checks is what lets
these fail for a reason nobody predicted, which an example test cannot.
The laws cross the representation boundaries on purpose: the map's
ArrayMap -> PersistentHashMap promotion is checked after EVERY insert
rather than only at the end, and the vector's last element is read
immediately before each pop. Discussion #12 lived on exactly such a
boundary — a set's backing map decoded as an ArrayMap unconditionally,
correct at 8 elements and a segfault at 9 — and every fixture in the
suite sat on the near side of it.
Two placement notes:
The properties live in `src/testing/`, not `test/prop/`, because
`zig build test` compiles one module rooted at `src/main.zig` and Zig
refuses an `@import` that leaves the module directory; a root under
`test/` could only reach what `main.zig` re-exports. `test/prop/README.md`
records that and points at the real location, so the layer stays
discoverable where ADR-0021 promised it.
The seed is FIXED by default. A gate whose input set changes per run
reports something different each time it is asked, and a failure nobody
can reproduce is a failure nobody fixes. When a sweep does find one, pin
its seed into the default as part of the fix — otherwise the gate goes on
not looking where the bug was.
The engine's own self-test falsifies a property deliberately, so `forAll`
takes a `quiet` flag: without it every green gate printed a "property
FAILED" block, which is how a real one gets scrolled past.
Smell-audited: 0: clean — new test-only module, no production code touched
---
build.zig | 10 +
src/main.zig | 6 +
src/testing/prop.zig | 251 ++++++++++++++++++++++++
src/testing/prop_collections.zig | 318 +++++++++++++++++++++++++++++++
test/prop/README.md | 46 +++++
5 files changed, 631 insertions(+)
create mode 100644 src/testing/prop.zig
create mode 100644 src/testing/prop_collections.zig
create mode 100644 test/prop/README.md
diff --git a/build.zig b/build.zig
index a2a063a52..e51d4ee11 100644
--- a/build.zig
+++ b/build.zig
@@ -58,6 +58,16 @@ pub fn build(b: *std.Build) void {
build_options.addOption([]const u8, "version", build_zon.version);
build_options.addOption(bool, "embed_raw_clj_sources", false);
+ // Layer 7 (ADR-0186) — the property suite's seed and iteration count. Both
+ // are FIXED by default: a gate whose input set changes per run reports a
+ // different thing each time it is asked, and a failure nobody can reproduce
+ // is a failure nobody fixes. A sweep raises them deliberately:
+ // zig build test -Dprop-seed=0xdecafbad -Dprop-iters=5000
+ // A property that only fails under a swept seed is still a real defect —
+ // pin its seed into the default when that happens, so the gate keeps it.
+ build_options.addOption(u64, "prop_seed", b.option(u64, "prop-seed", "Layer 7 property-test seed (ADR-0186)") orelse 0x1_0BE1_5EED);
+ build_options.addOption(usize, "prop_iters", b.option(usize, "prop-iters", "Layer 7 property-test iterations per property (ADR-0186)") orelse 60);
+
// ROADMAP §9.6 / 4.8 / §349 — backend gate (ADR-0005 / ADR-0070 / F-012).
// `vm` is the PRODUCTION DEFAULT (flipped 2026-06-02 once every D-196
// parity blocker closed: check_vm_parity = 0 fails, corpus 375/375 + all
diff --git a/src/main.zig b/src/main.zig
index 35ea4eced..c7476e616 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -62,6 +62,12 @@ test "kindToExitCode maps internal_error → 70 and others → 1 (ADR-0019)" {
// Pull in tests from the source tree. As more files appear under
// src/, add them here so the unified `zig build test` discovers them.
test {
+ // Layer 7 — property suite (ADR-0186). Reached from here like every other
+ // test file, so `zig build test` runs the properties in the same pass as
+ // the unit tests rather than behind a flag someone has to remember.
+ _ = @import("testing/prop.zig");
+ _ = @import("testing/prop_collections.zig");
+
_ = @import("app/cli.zig");
_ = @import("app/repl/line_editor.zig");
_ = @import("app/deps/parse.zig");
diff --git a/src/testing/prop.zig b/src/testing/prop.zig
new file mode 100644
index 000000000..e7f089931
--- /dev/null
+++ b/src/testing/prop.zig
@@ -0,0 +1,251 @@
+//! Layer 7 (Property) engine — ADR-0186.
+//!
+//! Generate inputs, assert a law over each, and on failure report the SMALLEST
+//! input that still breaks it plus the seed that produced it.
+//!
+//! Contract:
+//! - `Config` defaults make the gate run deterministic; a sweep overrides them
+//! through the `-Dprop-seed` / `-Dprop-iters` build options, which the
+//! property files read (this file stays free of `build_options` so it can be
+//! compiled and tested on its own with `zig test`).
+//! - `forAll` runs `prop` over `cfg.iters` generated values, shrinks the first
+//! failure within `cfg.max_shrink` candidate evaluations, prints the shrunk
+//! value and the reproduce line, and returns the property's error.
+//! - A generator is any value exposing `generate`, `shrink`, `free` and
+//! `describe` (see `IntSlice` for the shape).
+//! - Generators own their allocations; `forAll` frees every value it makes.
+
+const std = @import("std");
+
+pub const Config = struct {
+ seed: u64 = 0x1_0BE1_5EED,
+ iters: usize = 100,
+ max_shrink: usize = 300,
+ /// Suppress the failure report. Set ONLY by the engine's own tests, which
+ /// falsify a property on purpose: a green gate that prints a "property
+ /// FAILED" block every run teaches everyone to scroll past the real one.
+ quiet: bool = false,
+};
+
+/// Run `prop` over generated values. `ctx` is passed through untouched, so a
+/// property that needs a fixture (an allocator, a runtime factory) can carry it
+/// without a closure.
+pub fn forAll(
+ comptime T: type,
+ alloc: std.mem.Allocator,
+ cfg: Config,
+ gen: anytype,
+ ctx: anytype,
+ comptime prop: fn (@TypeOf(ctx), T) anyerror!void,
+) !void {
+ var prng = std.Random.DefaultPrng.init(cfg.seed);
+ const rand = prng.random();
+
+ var i: usize = 0;
+ while (i < cfg.iters) : (i += 1) {
+ // `size` grows with the iteration so early cases stay small and late
+ // ones reach the interesting boundaries (a map crossing its ArrayMap
+ // ceiling, a vector spilling out of its tail).
+ const size = 1 + (i * 32) / cfg.iters;
+ const value = try gen.generate(rand, alloc, size);
+
+ prop(ctx, value) catch |err| {
+ const shrunk = try shrinkFailing(T, alloc, cfg, gen, ctx, prop, value);
+ defer gen.free(alloc, shrunk);
+
+ const desc = gen.describe(alloc, shrunk) catch null;
+ defer if (desc) |d| alloc.free(d);
+
+ if (!cfg.quiet) std.debug.print(
+ \\
+ \\property FAILED on iteration {d} with {s}
+ \\ smallest failing input: {s}
+ \\ reproduce: zig build test -Dprop-seed=0x{x} -Dprop-iters={d}
+ \\
+ , .{ i, @errorName(err), desc orelse "", cfg.seed, cfg.iters });
+ return err;
+ };
+
+ gen.free(alloc, value);
+ }
+}
+
+/// Greedy shrink: repeatedly replace the failing value with the first candidate
+/// that still fails. Returns ownership of the final value to the caller; every
+/// other value made here is freed.
+fn shrinkFailing(
+ comptime T: type,
+ alloc: std.mem.Allocator,
+ cfg: Config,
+ gen: anytype,
+ ctx: anytype,
+ comptime prop: fn (@TypeOf(ctx), T) anyerror!void,
+ failing: T,
+) !T {
+ var current = failing;
+ var budget = cfg.max_shrink;
+
+ outer: while (budget > 0) {
+ var candidates: std.ArrayList(T) = .empty;
+ defer candidates.deinit(alloc);
+ try gen.shrink(alloc, current, &candidates);
+ if (candidates.items.len == 0) break;
+
+ for (candidates.items, 0..) |cand, idx| {
+ if (budget == 0) {
+ for (candidates.items[idx..]) |rest| gen.free(alloc, rest);
+ break :outer;
+ }
+ budget -= 1;
+
+ if (prop(ctx, cand)) |_| {
+ gen.free(alloc, cand);
+ } else |_| {
+ // Still fails: adopt it, discard the rest of this round.
+ for (candidates.items[idx + 1 ..]) |rest| gen.free(alloc, rest);
+ gen.free(alloc, current);
+ current = cand;
+ continue :outer;
+ }
+ }
+ break;
+ }
+
+ return current;
+}
+
+/// A slice of integers — the workhorse generator. Map keys, set elements,
+/// vector contents and index sequences are all expressible as one.
+pub const IntSlice = struct {
+ /// Values are drawn from `0..distinct`, so a small bound forces duplicate
+ /// keys (which is where assoc / conj semantics actually differ).
+ distinct: i64 = 8,
+ max_len: usize = 64,
+
+ pub fn generate(self: IntSlice, rand: std.Random, alloc: std.mem.Allocator, size: usize) ![]i64 {
+ const len = rand.uintLessThan(usize, @min(size, self.max_len) + 1);
+ const out = try alloc.alloc(i64, len);
+ for (out) |*slot| slot.* = rand.intRangeLessThan(i64, 0, self.distinct);
+ return out;
+ }
+
+ /// Two moves, cheapest first: drop a chunk, then reduce one element toward
+ /// zero. Dropping first converges fastest — length is usually the part of a
+ /// counterexample that carries no information.
+ pub fn shrink(self: IntSlice, alloc: std.mem.Allocator, v: []i64, out: *std.ArrayList([]i64)) !void {
+ _ = self;
+ if (v.len > 0) {
+ try out.append(alloc, try alloc.dupe(i64, v[0 .. v.len / 2]));
+ try out.append(alloc, try alloc.dupe(i64, v[v.len / 2 ..]));
+ if (v.len > 1) {
+ const without_last = try alloc.dupe(i64, v[0 .. v.len - 1]);
+ try out.append(alloc, without_last);
+ }
+ }
+ for (v, 0..) |x, i| {
+ if (x == 0) continue;
+ const smaller = try alloc.dupe(i64, v);
+ smaller[i] = @divTrunc(x, 2);
+ try out.append(alloc, smaller);
+ break; // one element per round keeps the candidate set small
+ }
+ }
+
+ pub fn free(self: IntSlice, alloc: std.mem.Allocator, v: []i64) void {
+ _ = self;
+ alloc.free(v);
+ }
+
+ pub fn describe(self: IntSlice, alloc: std.mem.Allocator, v: []i64) ![]u8 {
+ _ = self;
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(alloc);
+ try buf.append(alloc, '[');
+ for (v, 0..) |x, i| {
+ if (i > 0) try buf.appendSlice(alloc, ", ");
+ try buf.print(alloc, "{d}", .{x});
+ }
+ try buf.append(alloc, ']');
+ return buf.toOwnedSlice(alloc);
+ }
+};
+
+// --- tests: the engine's own contract ---
+
+const testing = std.testing;
+
+test "forAll passes a law that holds for every generated value" {
+ const gen = IntSlice{};
+ const Ctx = struct {};
+ const prop = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ // Trivially true: a dupe has the same length.
+ const copy = try testing.allocator.dupe(i64, xs);
+ defer testing.allocator.free(copy);
+ try testing.expectEqual(xs.len, copy.len);
+ }
+ }.f;
+ try forAll([]i64, testing.allocator, .{ .iters = 25 }, gen, Ctx{}, prop);
+}
+
+test "forAll reports the shrunk counterexample, not the first one found" {
+ // The law "no element is >= 4" fails on any slice containing 4..7. The
+ // shrinker must reduce both length and magnitude, so what survives is a
+ // short slice — proving shrinking ran rather than the raw generated value
+ // being returned.
+ const gen = IntSlice{ .distinct = 8, .max_len = 64 };
+ const Ctx = struct {};
+ const prop = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ for (xs) |x| if (x >= 4) return error.TooLarge;
+ }
+ }.f;
+
+ const cfg = Config{ .iters = 200, .seed = 0xC0FFEE, .quiet = true };
+ const result = forAll([]i64, testing.allocator, cfg, gen, Ctx{}, prop);
+ try testing.expectError(error.TooLarge, result);
+}
+
+test "the same seed produces the same values, a different seed does not" {
+ // Reproducibility is the whole reason the failure message prints a seed.
+ const gen = IntSlice{ .distinct = 1000, .max_len = 32 };
+ var a = std.Random.DefaultPrng.init(42);
+ var b = std.Random.DefaultPrng.init(42);
+ var c = std.Random.DefaultPrng.init(43);
+
+ const va = try gen.generate(a.random(), testing.allocator, 32);
+ defer gen.free(testing.allocator, va);
+ const vb = try gen.generate(b.random(), testing.allocator, 32);
+ defer gen.free(testing.allocator, vb);
+ const vc = try gen.generate(c.random(), testing.allocator, 32);
+ defer gen.free(testing.allocator, vc);
+
+ try testing.expectEqualSlices(i64, va, vb);
+ try testing.expect(!std.mem.eql(i64, va, vc));
+}
+
+test "IntSlice.shrink offers strictly smaller candidates and nothing else" {
+ const gen = IntSlice{};
+ const v = try testing.allocator.dupe(i64, &[_]i64{ 5, 6, 7, 8 });
+ defer testing.allocator.free(v);
+
+ var out: std.ArrayList([]i64) = .empty;
+ defer {
+ for (out.items) |c| testing.allocator.free(c);
+ out.deinit(testing.allocator);
+ }
+ try gen.shrink(testing.allocator, v, &out);
+
+ try testing.expect(out.items.len > 0);
+ for (out.items) |c| {
+ const shorter = c.len < v.len;
+ var smaller_element = false;
+ if (c.len == v.len) {
+ for (c, v) |a, b| {
+ if (a < b) smaller_element = true;
+ try testing.expect(a <= b);
+ }
+ }
+ try testing.expect(shorter or smaller_element);
+ }
+}
diff --git a/src/testing/prop_collections.zig b/src/testing/prop_collections.zig
new file mode 100644
index 000000000..6d6c21d9b
--- /dev/null
+++ b/src/testing/prop_collections.zig
@@ -0,0 +1,318 @@
+//! Layer 7 (Property) — laws the persistent collections must obey for ANY
+//! sequence of operations, not for the sequences someone thought to write down.
+//! ADR-0186.
+//!
+//! The oracle in every case is a naive model: a plain Zig array/list doing the
+//! obviously-correct thing. The persistent structure and the model are fed the
+//! same operations and must agree. That is what makes these tests able to fail
+//! for a reason nobody predicted — the model has no shared implementation with
+//! the thing it checks.
+//!
+//! The representation boundaries are deliberately crossed. `map` promotes
+//! ArrayMap -> PersistentHashMap past 16 entries and `vector` spills its tail
+//! into a HAMT; a bug that lives only on one side of a boundary is invisible to
+//! a test whose fixtures all sit on the same side. Discussion #12 was exactly
+//! that bug: a set's backing map was decoded as an ArrayMap unconditionally, so
+//! it worked for 8 elements and segfaulted at 9.
+
+const std = @import("std");
+const prop = @import("prop.zig");
+const build_options = @import("build_options");
+
+const value_mod = @import("../runtime/value/value.zig");
+const Value = value_mod.Value;
+const Runtime = @import("../runtime/runtime.zig").Runtime;
+const map_collection = @import("../runtime/collection/map.zig");
+const set_collection = @import("../runtime/collection/set.zig");
+const vector_collection = @import("../runtime/collection/vector.zig");
+
+const testing = std.testing;
+
+/// Gate defaults, overridable for a sweep: `zig build test -Dprop-seed=0x… -Dprop-iters=…`.
+fn config() prop.Config {
+ return .{ .seed = build_options.prop_seed, .iters = build_options.prop_iters };
+}
+
+/// A runtime per property evaluation. Fresh state per case is what makes a
+/// failing case reproducible on its own rather than only in sequence.
+const Fixture = struct {
+ fn run(comptime body: fn (*Runtime, []i64) anyerror!void, xs: []i64) anyerror!void {
+ var th = std.Io.Threaded.init(testing.allocator, .{});
+ defer th.deinit();
+ var rt = Runtime.init(th.io(), testing.allocator);
+ defer rt.deinit();
+ try body(&rt, xs);
+ }
+};
+
+/// The model: last write wins, in insertion order. Returns the distinct keys in
+/// first-seen order plus the value each key ended up with.
+const MapModel = struct {
+ keys: std.ArrayList(i64) = .empty,
+ vals: std.ArrayList(i64) = .empty,
+
+ fn put(self: *MapModel, alloc: std.mem.Allocator, k: i64, v: i64) !void {
+ for (self.keys.items, 0..) |existing, i| {
+ if (existing == k) {
+ self.vals.items[i] = v;
+ return;
+ }
+ }
+ try self.keys.append(alloc, k);
+ try self.vals.append(alloc, v);
+ }
+
+ fn deinit(self: *MapModel, alloc: std.mem.Allocator) void {
+ self.keys.deinit(alloc);
+ self.vals.deinit(alloc);
+ }
+};
+
+// --- map ---
+
+test "property: a map agrees with a last-write-wins model on every key" {
+ const gen = prop.IntSlice{ .distinct = 12, .max_len = 64 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var model: MapModel = .{};
+ defer model.deinit(testing.allocator);
+
+ var m = map_collection.empty();
+ // Element i is the key; its value is i, so a repeated key really
+ // does change value and last-write-wins is observable.
+ for (xs, 0..) |k, i| {
+ const v: i64 = @intCast(i);
+ m = try map_collection.assoc(rt, m, Value.initInteger(k), Value.initInteger(v));
+ try model.put(testing.allocator, k, v);
+ }
+
+ try testing.expectEqual(@as(u32, @intCast(model.keys.items.len)), map_collection.count(m));
+ for (model.keys.items, model.vals.items) |k, v| {
+ try testing.expect(try map_collection.contains(m, Value.initInteger(k)));
+ const got = try map_collection.get(m, Value.initInteger(k));
+ try testing.expectEqual(v, @as(i64, got.asInteger()));
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: a map's contents do not depend on the order the keys arrived" {
+ // Same distinct keys, opposite insertion order, values keyed off the key
+ // itself so last-write-wins cannot mask a difference.
+ const gen = prop.IntSlice{ .distinct = 40, .max_len = 48 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var forward = map_collection.empty();
+ for (xs) |k| forward = try map_collection.assoc(rt, forward, Value.initInteger(k), Value.initInteger(k * 3));
+
+ var backward = map_collection.empty();
+ var i = xs.len;
+ while (i > 0) {
+ i -= 1;
+ backward = try map_collection.assoc(rt, backward, Value.initInteger(xs[i]), Value.initInteger(xs[i] * 3));
+ }
+
+ try testing.expectEqual(map_collection.count(forward), map_collection.count(backward));
+ try testing.expect(map_collection.contentEq(forward, backward));
+ try testing.expectEqual(map_collection.contentHash(forward), map_collection.contentHash(backward));
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: dissoc undoes assoc, one key at a time, in any order" {
+ const gen = prop.IntSlice{ .distinct = 30, .max_len = 48 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var m = map_collection.empty();
+ for (xs) |k| m = try map_collection.assoc(rt, m, Value.initInteger(k), Value.initInteger(k));
+
+ // Remove in the reverse of the order they went in; each removal must
+ // drop exactly the one key, never a neighbour.
+ var seen: std.ArrayList(i64) = .empty;
+ defer seen.deinit(testing.allocator);
+ var i = xs.len;
+ while (i > 0) {
+ i -= 1;
+ const k = xs[i];
+ var already = false;
+ for (seen.items) |s| {
+ if (s == k) already = true;
+ }
+ if (already) continue;
+ try seen.append(testing.allocator, k);
+
+ const before = map_collection.count(m);
+ m = try map_collection.dissoc(rt, m, Value.initInteger(k));
+ try testing.expectEqual(before - 1, map_collection.count(m));
+ try testing.expect(!try map_collection.contains(m, Value.initInteger(k)));
+ }
+ try testing.expectEqual(@as(u32, 0), map_collection.count(m));
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: every entry survives the ArrayMap -> PersistentHashMap promotion" {
+ // The Discussion #12 boundary, stated as a law instead of as one example.
+ // Keys are distinct by construction so the entry count is the insert count,
+ // which is what puts the map deterministically on both sides of the ceiling.
+ const gen = prop.IntSlice{ .distinct = 64, .max_len = 40 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var m = map_collection.empty();
+ var inserted: std.ArrayList(i64) = .empty;
+ defer inserted.deinit(testing.allocator);
+
+ for (xs, 0..) |_, i| {
+ const k: i64 = @intCast(i); // distinct by construction
+ m = try map_collection.assoc(rt, m, Value.initInteger(k), Value.initInteger(k * 7));
+ try inserted.append(testing.allocator, k);
+
+ // Whatever representation it is in right now, every key put in
+ // so far must still be there. Checking on EVERY insert is what
+ // catches a promotion that loses an entry, rather than only
+ // checking the final state.
+ try testing.expectEqual(@as(u32, @intCast(inserted.items.len)), map_collection.count(m));
+ for (inserted.items) |key| {
+ const got = try map_collection.get(m, Value.initInteger(key));
+ try testing.expectEqual(key * 7, @as(i64, got.asInteger()));
+ }
+ }
+
+ if (inserted.items.len > 16) {
+ // The promotion is supposed to have happened; if the ceiling
+ // moves, this line is the one that says so.
+ try testing.expect(m.tag() == .hash_map);
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+// --- set ---
+
+test "property: a set holds exactly the distinct elements conj'd into it" {
+ const gen = prop.IntSlice{ .distinct = 24, .max_len = 64 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var distinct: std.ArrayList(i64) = .empty;
+ defer distinct.deinit(testing.allocator);
+
+ var s = set_collection.empty();
+ for (xs) |x| {
+ s = try set_collection.conj(rt, s, Value.initInteger(x));
+ var already = false;
+ for (distinct.items) |d| {
+ if (d == x) already = true;
+ }
+ if (!already) try distinct.append(testing.allocator, x);
+ }
+
+ try testing.expectEqual(@as(u32, @intCast(distinct.items.len)), set_collection.count(s));
+ for (distinct.items) |d| try testing.expect(try set_collection.contains(s, Value.initInteger(d)));
+
+ // disj is conj's inverse over the distinct elements.
+ for (distinct.items) |d| s = try set_collection.disj(rt, s, Value.initInteger(d));
+ try testing.expectEqual(@as(u32, 0), set_collection.count(s));
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+// --- vector ---
+
+test "property: a vector reads back what was conj'd, index by index" {
+ const gen = prop.IntSlice{ .distinct = 1000, .max_len = 96 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var v = vector_collection.empty();
+ for (xs) |x| v = try vector_collection.conj(rt, v, Value.initInteger(x));
+
+ try testing.expectEqual(@as(u32, @intCast(xs.len)), vector_collection.count(v));
+ for (xs, 0..) |x, i| {
+ try testing.expectEqual(x, @as(i64, vector_collection.nth(v, @intCast(i)).asInteger()));
+ }
+
+ // fromSlice must agree with repeated conj — two construction paths,
+ // one value.
+ var built: std.ArrayList(Value) = .empty;
+ defer built.deinit(testing.allocator);
+ for (xs) |x| try built.append(testing.allocator, Value.initInteger(x));
+ const w = try vector_collection.fromSlice(rt, built.items);
+ try testing.expectEqual(vector_collection.count(v), vector_collection.count(w));
+ for (xs, 0..) |_, i| {
+ try testing.expectEqual(
+ @as(i64, vector_collection.nth(v, @intCast(i)).asInteger()),
+ @as(i64, vector_collection.nth(w, @intCast(i)).asInteger()),
+ );
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: pop undoes conj all the way back to empty" {
+ const gen = prop.IntSlice{ .distinct = 1000, .max_len = 96 };
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ var v = vector_collection.empty();
+ for (xs) |x| v = try vector_collection.conj(rt, v, Value.initInteger(x));
+
+ var i = xs.len;
+ while (i > 0) {
+ i -= 1;
+ // The last element is still readable right up to the moment it
+ // is popped — this is where a tail/HAMT boundary error shows.
+ try testing.expectEqual(xs[i], @as(i64, vector_collection.nth(v, @intCast(i)).asInteger()));
+ v = try vector_collection.pop(rt, v);
+ try testing.expectEqual(@as(u32, @intCast(i)), vector_collection.count(v));
+ }
+ try testing.expectEqual(@as(u32, 0), vector_collection.count(v));
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
diff --git a/test/prop/README.md b/test/prop/README.md
new file mode 100644
index 000000000..51511da12
--- /dev/null
+++ b/test/prop/README.md
@@ -0,0 +1,46 @@
+# Layer 7 — property tests
+
+The properties themselves live in **`src/testing/prop_*.zig`**, not here.
+
+`zig build test` compiles one module rooted at `src/main.zig`, and Zig refuses an
+`@import` that reaches outside its module's directory. A property test that
+exercises `runtime/collection/map.zig` therefore has to sit inside that module —
+a test root under `test/` could only reach what `main.zig` re-exports, which is
+almost nothing.
+
+So the layer is split the way the compiler forces:
+
+| Piece | Where | What it is |
+|---|---|---|
+| Engine | `src/testing/prop.zig` | generators, shrinking, `forAll`. Depends only on `std`, so `zig test src/testing/prop.zig` runs its own tests in seconds. |
+| Properties | `src/testing/prop_collections.zig` | the laws, reached from `main.zig`'s test aggregator like every other test file. |
+| Configuration | `build.zig` | `-Dprop-seed`, `-Dprop-iters`. Fixed defaults so the gate is deterministic. |
+
+## Running
+
+```sh
+zig build test -Dwasm -Doptimize=ReleaseSafe # gate defaults: fixed seed
+zig build test -Dwasm -Dprop-seed=0xdecafbad -Dprop-iters=5000 # a sweep
+zig test src/testing/prop.zig # the engine's own tests
+```
+
+A property that fails prints the shrunk counterexample and the exact command to
+reproduce it. When a sweep finds a failure, **pin that seed into the default in
+`build.zig`** as part of fixing it — otherwise the gate goes on not looking
+where the bug was found.
+
+## Writing one
+
+A property needs an oracle that does not share an implementation with the thing
+under test. The three that work here:
+
+- **A law**: `pop` undoes `conj`; `dissoc` undoes `assoc`; order does not matter.
+- **A model**: a plain Zig array doing the obviously-correct thing, fed the same
+ operations as the persistent structure.
+- **A second implementation**: the tree-walk backend against the VM (Layer 3
+ already does this one).
+
+Cross a representation boundary on purpose. `map` promotes past 16 entries,
+`vector` spills its tail into a HAMT, `set` promotes at 8 — Discussion #12 was a
+bug that only existed above one of those lines, and every fixture in the suite
+sat below it.
From ce5cb6c8aaeae6c376f23d63dbdcb642afd707cc Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 10:55:43 -0300
Subject: [PATCH 009/466] docs(handover+debt): record the new layers and what
they do not cover yet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
D-577 states the residual honestly: the layers are OPEN but shallow.
Golden covers 20 programs, property covers map/set/vector, and no
mutation sweep has run against a committed baseline — so this suite's
mutation score is unmeasured and "the suite is good" is still an
impression rather than a measurement.
The barrier is a queue, not a design question, and it is ordered so each
step aims the next: run the sweep, turn each survivor into a test in the
layer that should have caught it, then extend Layer 7 past the
collections (print/read round-trip is the highest-value next law, since
it crosses reader, printer and value representation in one).
It also says what NOT to do: the "120 of 265 files have no inline test"
number is not a work-list. A file count cannot tell you whether an
unconstrained line in a file would matter; the sweep can, and it ranks
them.
---
.dev/debt.yaml | 20 ++++++++++++++++++++
.dev/handover.md | 40 ++++++++++++++++++++--------------------
2 files changed, 40 insertions(+), 20 deletions(-)
diff --git a/.dev/debt.yaml b/.dev/debt.yaml
index 99a471545..0f9d91c68 100644
--- a/.dev/debt.yaml
+++ b/.dev/debt.yaml
@@ -24,6 +24,26 @@
# md-table-align no longer pads it to ~800 KB of whitespace.
active:
+ - id: "D-577"
+ status: "open (2026-08-12, ADR-0186 residual). The three new layers are OPEN but shallow: golden covers 20 programs, property covers map/set/vector only, and no mutation sweep has been run against a committed baseline yet, so the mutation score of this suite is unmeasured. 120 of 265 src files still carry no inline test at all."
+ category: "test"
+ barrier: |-
+ Not a design question, a queue. Drain in this order, because each step
+ tells you where the next one should point:
+ 1. Run the first sweep (`scripts/mutation/run.sh --targets-from
+ .dev/mutation_targets.txt --budget 15`) and commit the report. Until
+ a sweep has run, "the suite is good" is an impression.
+ 2. Turn each survivor into a test in the layer that should have caught
+ it — a survivor in a comparison is usually a missing property, a
+ survivor in a rendering path is usually a missing golden case.
+ 3. Extend Layer 7 past the collections: print/read round-trip
+ (`pr-str` then `read-string` is the identity on every printable
+ value) is the highest-value next property, since it crosses reader,
+ printer and value representation in one law.
+ Do NOT chase the 120 untested files as a number: the sweep ranks them by
+ whether an unconstrained line there would matter, which a file count
+ cannot.
+ last_reviewed: "2026-08-12"
- id: "D-574"
status: "open (2026-08-06, ADR-0184 residual). reify's anonymous TypeDescriptor is trackHeap'd per EVALUATION (lang/primitive/protocol.zig makeReifyDescriptor path) — the same immortality shape ADR-0184 killed for Functions, in miniature: a reify form evaluated in a loop grows persistent_marks + leaks a TD per iteration. The fix shape mirrors ADR-0184: make the anonymous TD (or a TD cell) collectable, instance-mediated trace already exists (traceReifiedInstance); audit method_table readers for a dead-TD window first (freeReifyDescriptor currently only touches names)."
category: "gc"
diff --git a/.dev/handover.md b/.dev/handover.md
index b1041bad3..e64ac1123 100644
--- a/.dev/handover.md
+++ b/.dev/handover.md
@@ -26,24 +26,29 @@
`BuddhiLW/homebrew-tap` (`brew install buddhilw/tap/cljw`), and `1.x`
continues from v1.10.0. PERF CAMPAIGN (D-450) stays **stopped** — findings
remain in the row as a record.
-- **Release path: PROVEN on v1.10.1** (2026-08-12) — artifacts built by this
- repo, both sidecar checksums recomputed and matched, the Linux binary run
- (`--version` = 1.10.1), `brew install buddhilw/tap/cljw` + `brew test` +
- `brew audit --strict` green. The tap bump is now a `tap` job in
+- **Release path: PROVEN on v1.10.1** (2026-08-12) — artifacts built here,
+ both checksums matched, binary run, `brew install buddhilw/tap/cljw` +
+ `brew test` + `brew audit --strict` green. The tap bump is a `tap` job in
`release.yml` (deploy key, secret `TAP_DEPLOY_KEY`); v1.10.1's formula was
- written by hand, so **v1.10.2 is the first release that exercises the
- automated bump** — watch that job.
+ hand-written, so **v1.10.2 is the first release exercising the automated
+ bump** — watch that job.
+- **Test layers 6/7/8 are OPEN** (ADR-0186): golden snapshots
+ (`test/golden/`, gated), properties (`src/testing/prop_*.zig`, in
+ `zig build test`, fixed seed — sweep with `-Dprop-seed`/`-Dprop-iters`),
+ mutation (`scripts/mutation/run.sh`, on demand, worktree-isolated, NEVER
+ gated). First task: **D-577** — run the first sweep and turn its survivors
+ into tests. No sweep has run yet, so this suite's mutation score is
+ unmeasured.
- **HAZARD — workflows do not fire on push/tag here.** GitHub's fork default:
every run so far came from `gh workflow run --ref [`. Until the
Actions tab's enable button is pressed once, "no run appeared" does NOT mean
"CI passed", and a `v*` tag will not build a release by itself. Dispatch
explicitly, or press it and delete this bullet.
- **Unreleased on main**: nothing. CHANGELOG `[1.10.1]` is the SSOT.
-- **Release-process hazards** (both live in `.github/workflows/release.yml`):
- cutting the GitHub release BY HAND before the tag workflow runs breaks the
- artifact upload (zwasm #160), and `gh release view || gh release create` is
- TOCTOU across the two matrix legs — simultaneous, the loser dies on "already
- exists" under `set -e`. v1.10.0 missed it by 47 s. Never hit, never fixed.
+- **Release-process hazards** (`.github/workflows/release.yml`): hand-cutting
+ the release before the tag workflow breaks artifact upload (zwasm #160); and
+ `gh release view || gh release create` is TOCTOU across the matrix legs —
+ v1.10.0 missed it by 47 s. Never hit, never fixed.
- **Forbidden**: bare `zig build test` without `-Dwasm`; bare `zig build` for a
probe (use ReleaseSafe). The FULL gate runs `--serial-e2e`, ALONE; never a
concurrent build during a gate. `.claude/**` edits + cross-repo publishes may
@@ -62,15 +67,10 @@
- Upstream's own wind-down (`1c8a9cbf` README notice, Sponsors + FUNDING.yml
removed, Discussion #15) is history now; `956c34ee` replaced that notice
with the fork's.
-- 2026-08-11/12: Discussions #12/#13/#14 (BuddhiLW) answered in code — three
- fix branches cherry-picked with authorship preserved, each one's CLASS then
- swept and fenced. #12 repr-misread → 3 more live bugs (multimethod isa? at 9
- defmethods, http client/server 9-entry headers, error_render context) +
- `check_repr_decode.sh`; #13/#14 entry-point divergence → `cljw repl`'s
- `[-cp][-A]` surface, `cljw build`'s missing top-level-`do` unroll,
- `check_entrypoint_surface.sh` + `test/e2e/entrypoint_eval_parity.sh` (a
- 6-entry-point differential oracle). D-374's discharge was a listed-not-probed
- claim; corrected in place. Endgame row = D-576.
+- 2026-08-11/12: Discussions #12/#13/#14 answered in code; each one's CLASS
+ then swept and fenced (`check_repr_decode.sh`,
+ `check_entrypoint_surface.sh`, `test/e2e/entrypoint_eval_parity.sh`).
+ Details in CHANGELOG [1.10.0]. Endgame row = D-576.
## Standing units (tracked in .dev/debt.yaml)
From 246176f56a1bbfeec61d9376774c743c085b9361 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho ]
Date: Wed, 12 Aug 2026 11:24:10 -0300
Subject: [PATCH 010/466] test(golden): cover truthiness/equality/hashing and
multibyte strings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two more cases in the layer's weakest spot: the semantics everything else
rests on, where a regression would be catastrophic and silent.
`core_predicates` pins that only nil and false are falsey (0 and "" are
truthy), `=` vs `==` vs `identical?`, cross-collection equality, and the
equal-values-hash-equal invariant — if that last one breaks, every map and
set built from those values is quietly wrong.
`string_unicode` was written to check multibyte handling and immediately
found an undocumented divergence, now filed as issue #1: cljw counts
strings in CODE POINTS where JVM Clojure counts UTF-16 code units, so
`(count "🌍")` is 1 here and 2 there, and `(subs "a🌍b" 1 2)` returns the
emoji here and half a surrogate pair there. Confirmed against clj 1.12 on
the same machine rather than from memory.
cljw's answer is probably the better one — but it is in neither
accepted_divergences.yaml nor clojure_vs_clojurewasm.md, so today it is an
oversight rather than a choice. The snapshot pins the current behaviour
either way, which is what makes the decision cheap to make later.
---
test/golden/cases/core_predicates.clj | 9 +++++++++
test/golden/cases/core_predicates.expected | 10 ++++++++++
test/golden/cases/string_unicode.clj | 10 ++++++++++
test/golden/cases/string_unicode.expected | 10 ++++++++++
4 files changed, 39 insertions(+)
create mode 100644 test/golden/cases/core_predicates.clj
create mode 100644 test/golden/cases/core_predicates.expected
create mode 100644 test/golden/cases/string_unicode.clj
create mode 100644 test/golden/cases/string_unicode.expected
diff --git a/test/golden/cases/core_predicates.clj b/test/golden/cases/core_predicates.clj
new file mode 100644
index 000000000..006f5b6e6
--- /dev/null
+++ b/test/golden/cases/core_predicates.clj
@@ -0,0 +1,9 @@
+;; Truthiness, equality and hashing — the semantics everything else rests on.
+;; Only nil and false are falsey; 0 and "" are truthy, unlike most languages.
+(prn (mapv boolean [nil false 0 "" [] {} :k]))
+(prn (= 1 1.0) (== 1 1.0) (identical? :a :a) (identical? [1] [1]))
+(prn (= [1 2] '(1 2)) (= #{1 2} #{2 1}) (= {:a 1} {:a 1}) (= "a" \a))
+;; Equal values must hash equal, or every map and set built from them is wrong.
+(prn (= (hash [1 2]) (hash [1 2])) (= (hash {:a 1}) (hash {:a 1})) (= (hash #{1 2}) (hash #{2 1})))
+(prn (mapv nil? [nil false]) (mapv some? [nil 0]) (mapv seq? ['(1) [1]]))
+(prn (compare 1 2) (compare "b" "a") (compare [1 2] [1 3]) (compare nil nil))
diff --git a/test/golden/cases/core_predicates.expected b/test/golden/cases/core_predicates.expected
new file mode 100644
index 000000000..ebd85cc1a
--- /dev/null
+++ b/test/golden/cases/core_predicates.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+[false false true true true true true]
+false true true false
+true true true false
+true true true
+[true false] [false true] [true false]
+-1 1 -1 0
+--- stderr ---
diff --git a/test/golden/cases/string_unicode.clj b/test/golden/cases/string_unicode.clj
new file mode 100644
index 000000000..6f1d06c3e
--- /dev/null
+++ b/test/golden/cases/string_unicode.clj
@@ -0,0 +1,10 @@
+;; Multibyte handling: a runtime that counts bytes instead of characters looks
+;; correct until the first non-ASCII input.
+(def s "héllo, 世界 🌍")
+(prn (count s) (subs s 0 5) (clojure.string/reverse "abc"))
+(prn (seq "é世") (map int (seq "é世")))
+(prn (clojure.string/upper-case "straße") (clojure.string/capitalize "ábc"))
+(prn (count "🌍") (str \a \newline \tab))
+;; Escapes must survive a print/read round trip.
+(prn "tab\there" "nl\nhere" "quote\"here" "backslash\\here")
+(prn (pr-str "tab\there") (read-string (pr-str "tab\there")))
diff --git a/test/golden/cases/string_unicode.expected b/test/golden/cases/string_unicode.expected
new file mode 100644
index 000000000..67b9ef105
--- /dev/null
+++ b/test/golden/cases/string_unicode.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+11 "héllo" "cba"
+(\é \世) (233 19990)
+"STRASSE" "Ábc"
+1 "a\n\t"
+"tab\there" "nl\nhere" "quote\"here" "backslash\\here"
+"\"tab\\there\"" "tab\there"
+--- stderr ---
From 659f35cd7a7ce29a10ea5845e8e186e34d6b51bb Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 11:25:46 -0300
Subject: [PATCH 011/466] =?UTF-8?q?docs(debt):=20D-577=20=E2=80=94=20why?=
=?UTF-8?q?=20the=20print/read=20round-trip=20is=20not=20a=20Zig-level=20p?=
=?UTF-8?q?roperty?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The obvious next law reads as trivial and is not writable as the code
stands: `reader.readAll` yields `Form` (syntax) and `print.printResult`
consumes `Value` (runtime), so the two are not inverses and nothing
between them is exposed. Recording that saves the next session the same
hour, and names the two real options — drive it through the evaluator as
a seeded Clojure-level generative test, or use serializeChunk /
deserializeChunk, which ARE an inverse pair, at the cost of a generator
for well-formed BytecodeChunks.
Also notes that check_adr_history.sh fails on 101 pre-existing ADRs and is
wired into no gate. A check nothing runs is a check nobody trusts: wire it
and fix them, or delete it.
---
.dev/debt.yaml | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/.dev/debt.yaml b/.dev/debt.yaml
index 0f9d91c68..86e7e2008 100644
--- a/.dev/debt.yaml
+++ b/.dev/debt.yaml
@@ -36,10 +36,20 @@ active:
2. Turn each survivor into a test in the layer that should have caught
it — a survivor in a comparison is usually a missing property, a
survivor in a rendering path is usually a missing golden case.
- 3. Extend Layer 7 past the collections: print/read round-trip
- (`pr-str` then `read-string` is the identity on every printable
- value) is the highest-value next property, since it crosses reader,
- printer and value representation in one law.
+ 3. Extend Layer 7 past the collections. The obvious next law —
+ `read-string` after `pr-str` is the identity — canNOT be written at
+ the Zig level as the code stands: `reader.readAll` yields `Form`
+ (syntax) while `print.printResult` consumes `Value` (runtime), so
+ the two are not inverses of each other and nothing in between is
+ exposed. Either drive it through the evaluator (making it a
+ Clojure-level generative test, seeded so the gate stays
+ deterministic) or take `serialize.serializeChunk` /
+ `deserializeChunk`, which ARE a genuine inverse pair — at the cost
+ of a generator for well-formed BytecodeChunks, which is the real
+ work in that option.
+ 4. `scripts/check_adr_history.sh` fails on 101 pre-existing ADRs and is
+ wired into NO gate. Either wire it and fix them, or delete it; a
+ check nothing runs is a check nobody trusts.
Do NOT chase the 120 untested files as a number: the sweep ranks them by
whether an unconstrained line there would matter, which a file count
cannot.
From 5ac3752bbe46b06eb0269b102a55cd5bfa949e4e Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 11:28:40 -0300
Subject: [PATCH 012/466] =?UTF-8?q?test(mutation):=20first=20sweep=20?=
=?UTF-8?q?=E2=80=94=2050%=20score,=20and=20it=20found=20the=20property's?=
=?UTF-8?q?=20blind=20spot?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
8 mutants over vector.zig: 3 killed, 3 survived, 2 unviable.
All three survivors are inside `popTail`'s recursive descent — the
`arrayFor(old, old.count - 2)` leaf pull, the `sub_index` shift, and the
`new_child == null and sub_index == 0` collapse test. They survived for
one reason: `popTail` only recurses once a vector has a trie deeper than
one level, which needs 1057 elements (tail 32 + one full level 32*32),
and the vector property added an hour earlier generates at most 96.
So the sweep's first act was to tell me that the property I had just
written was blind to the code it was aimed at. That is the whole argument
for this layer in one result — no amount of reading the property would
have revealed it, because the property looks correct and IS correct, just
never large enough to reach the branch.
`--ids` closes the loop: a survivor names a line, you write the test that
should constrain it, then you re-run THAT mutant and watch it die. Without
it you are re-sampling and hoping. It refuses to run if a named mutant no
longer exists, because a moved line would otherwise report a kill that
never happened.
The report is committed (the working logs stay ignored): a mutation score
that lives only in someone's terminal is not a measurement anyone can
check later.
---
.dev/mutation/report-ce5cb6c8-seed1.md | 20 ++++++++++++++++++++
scripts/mutation/run.sh | 19 ++++++++++++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 .dev/mutation/report-ce5cb6c8-seed1.md
diff --git a/.dev/mutation/report-ce5cb6c8-seed1.md b/.dev/mutation/report-ce5cb6c8-seed1.md
new file mode 100644
index 000000000..252a5ca75
--- /dev/null
+++ b/.dev/mutation/report-ce5cb6c8-seed1.md
@@ -0,0 +1,20 @@
+# Mutation sweep — ce5cb6c8 (seed 1)
+
+- Candidates enumerated: 141
+- Mutants run: 8
+- Killed: 3
+- Survived: 3
+- Unviable (did not compile): 2
+- **Mutation score: 50.0%** (killed / (killed + survived))
+
+## Survivors — each is a line no test constrains
+
+| file | line | operator | change |
+|---|---:|---|---|
+| `src/runtime/collection/vector.zig` | 284 | const_bump_in_arith | `const new_tail_leaf = arrayFor(old, old.count - 2);` -> `const new_tail_leaf = arrayFor(old, old.count - 3);` |
+| `src/runtime/collection/vector.zig` | 529 | const_bump_in_arith | `const sub_index = ((new_count - 1) >> @as(u5, @intCast(level))) & MASK;` -> `const sub_index = ((new_count - 2) >> @as(u5, @intCast(level))) & MASK;` |
+| `src/runtime/collection/vector.zig` | 534 | bool_and_to_or | `if (new_child == null and sub_index == 0) {` -> `if (new_child == null or sub_index == 0) {` |
+
+### Survivors per file
+
+- `src/runtime/collection/vector.zig`: 3
diff --git a/scripts/mutation/run.sh b/scripts/mutation/run.sh
index e9f446338..24a06bc6c 100755
--- a/scripts/mutation/run.sh
+++ b/scripts/mutation/run.sh
@@ -20,6 +20,11 @@
# bash scripts/mutation/run.sh --targets src/runtime/collection/set.zig
# bash scripts/mutation/run.sh --targets a.zig,b.zig --budget 20 --seed 7
# bash scripts/mutation/run.sh --targets-from .dev/mutation_targets.txt --rev v1.10.1
+# bash scripts/mutation/run.sh --targets v.zig --ids 1a2b3c,4d5e6f # re-run named mutants
+#
+# `--ids` is the loop that makes a survivor actionable: a survivor names a line,
+# you write the test that should have constrained it, then you re-run THAT
+# mutant to see it die. Without it you are re-sampling and hoping.
#
# Output: a JSONL log plus a markdown summary under .dev/mutation/ (gitignored
# by default — the REPORT is the artifact worth committing, not the log).
@@ -31,6 +36,7 @@ TARGETS=""
TARGETS_FILE=""
BUDGET=15
SEED=0
+IDS=""
REV="HEAD"
OUT_DIR="$REPO_ROOT/.dev/mutation"
BUILD_ARGS="-Dwasm -Doptimize=Debug"
@@ -44,6 +50,7 @@ while [ $# -gt 0 ]; do
--targets) TARGETS="$2"; shift ;;
--targets-from) TARGETS_FILE="$2"; shift ;;
--budget) BUDGET="$2"; shift ;;
+ --ids) IDS="$2"; shift ;;
--seed) SEED="$2"; shift ;;
--rev) REV="$2"; shift ;;
--out) OUT_DIR="$2"; shift ;;
@@ -110,10 +117,20 @@ done
TOTAL=$(wc -l < "$ALL_MUTANTS" | tr -d ' ')
SAMPLE="$OUT_DIR/sample-$STAMP-seed$SEED.jsonl"
-python3 - "$ALL_MUTANTS" "$SAMPLE" "$BUDGET" "$SEED" <<'PY'
+python3 - "$ALL_MUTANTS" "$SAMPLE" "$BUDGET" "$SEED" "$IDS" <<'PY'
import json, random, sys
src, dst, budget, seed = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
+ids = [i for i in sys.argv[5].split(",") if i] if len(sys.argv) > 5 else []
rows = [json.loads(l) for l in open(src) if l.strip()]
+if ids:
+ by_id = {r["id"]: r for r in rows}
+ missing = [i for i in ids if i not in by_id]
+ if missing:
+ # A named mutant that no longer exists means the line moved or changed.
+ # Silently running the rest would report a kill that never happened.
+ raise SystemExit("mutants no longer present (did the file change?): " + ", ".join(missing))
+ rows = [by_id[i] for i in ids]
+ budget = 0
if budget and len(rows) > budget:
# Sample across the whole candidate set, not the head of it — a truncated
# list only ever measures the top of each file.
From b3c108c96caffe423465436291dfad2061d21e3c Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 11:29:57 -0300
Subject: [PATCH 013/466] docs(contributing): point contributors at the golden
and property layers
A contributor whose change alters printed output will now hit a golden
failure, and nothing told them the layer exists or how to regenerate it.
Says what the failure means, how to re-record, and that regenerating
without reading the diff is worse than having no snapshot.
---
.github/CONTRIBUTING.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 75307cd6c..f3ef0055f 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -31,6 +31,19 @@ zig build -Dwasm -Doptimize=ReleaseSafe # build the Wasm-enabled `cljw` binary
bash test/run_all.sh --serial-e2e # the full gate; must be green before a change lands
```
+Two of the newer layers have their own entry points (ADR-0186):
+
+```sh
+bash test/golden/run.sh # Layer 6: whole-program output snapshots
+bash test/golden/run.sh --update # re-record them, then READ the diff
+zig build test -Dprop-seed=0xdecafbad -Dprop-iters=5000 # Layer 7: a deeper property sweep
+```
+
+If your change alters what a program prints — a value's printed form, an
+error message, an exit code — a golden snapshot will fail. That is the layer
+working. Regenerate it, read the diff, and include it in the PR; a snapshot
+regenerated without being read is worse than no snapshot.
+
For a quick loop while iterating, `bash test/run_all.sh --smoke `
runs the unit tests, the dual-backend differential oracle, the linter, and the
one end-to-end step you touched — tens of seconds instead of ~20 minutes. Run
From 4b79a6d3b54631da2feba83ac0777ddec33a7636 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 11:37:35 -0300
Subject: [PATCH 014/466] test(prop): reach the deep trie, where the mutation
survivors live
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first sweep left three survivors, all inside `popTail`'s recursive
descent. The property that was supposed to cover pop generates at most 96
elements, and `popTail` only recurses on a trie deeper than one level —
tail (32) + one full level (32*32) = 1057. It never reached the branch.
This property builds vectors that straddle that boundary (1024, 1056,
1057, 1088, 2048, 2080) and pops each back to empty, checking the element
about to be removed, the count, index 0 and the midpoint after every step.
A collapse that drops the wrong subtree corrupts the middle while leaving
index 0 intact, so a count check alone would not see it.
Sizes straddle the boundary rather than sitting past it: a bug in the
collapse is a bug about what happens AT the transition.
---
src/testing/prop_collections.zig | 49 ++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/src/testing/prop_collections.zig b/src/testing/prop_collections.zig
index 6d6c21d9b..a29965deb 100644
--- a/src/testing/prop_collections.zig
+++ b/src/testing/prop_collections.zig
@@ -316,3 +316,52 @@ test "property: pop undoes conj all the way back to empty" {
}.f;
try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
}
+
+test "property: pop collapses a DEEP trie correctly, level by level" {
+ // The first mutation sweep (ADR-0186) killed the shallow cases and left
+ // three survivors, all of them inside `popTail`'s recursive descent:
+ // `arrayFor(old, old.count - 2)`, the `sub_index` shift, and the
+ // `new_child == null and sub_index == 0` collapse test. None of them could
+ // be reached, because the property above generates at most 96 elements and
+ // `popTail` only recurses once a vector is deep enough to have a trie of
+ // more than one level — tail (32) + one full level (32*32) = 1057 elements.
+ //
+ // So this property is here because a mutant said the other one was blind,
+ // which is the entire reason Layer 8 exists. The sizes below straddle the
+ // boundary rather than sitting past it: a bug in the collapse is a bug
+ // about what happens AT the transition.
+ const deep_sizes = [_]u32{ 1024, 1056, 1057, 1088, 2048, 2080 };
+ var th = std.Io.Threaded.init(testing.allocator, .{});
+ defer th.deinit();
+ var rt = Runtime.init(th.io(), testing.allocator);
+ defer rt.deinit();
+
+ for (deep_sizes) |n| {
+ var v = vector_collection.empty();
+ var i: u32 = 0;
+ while (i < n) : (i += 1) v = try vector_collection.conj(&rt, v, Value.initInteger(@intCast(i)));
+ try testing.expectEqual(n, vector_collection.count(v));
+
+ // Pop all the way back to empty. Every element still readable at every
+ // depth, and the count exact after each step — a collapse that drops
+ // the wrong subtree shows up as a wrong element long before it shows
+ // up as a wrong count.
+ var remaining = n;
+ while (remaining > 0) {
+ remaining -= 1;
+ try testing.expectEqual(
+ @as(i64, @intCast(remaining)),
+ @as(i64, vector_collection.nth(v, remaining).asInteger()),
+ );
+ v = try vector_collection.pop(&rt, v);
+ try testing.expectEqual(remaining, vector_collection.count(v));
+ // Spot-check the far end too: a collapse bug in a deep subtree
+ // leaves index 0 intact while corrupting the middle.
+ if (remaining > 0) {
+ try testing.expectEqual(@as(i64, 0), @as(i64, vector_collection.nth(v, 0).asInteger()));
+ const mid = remaining / 2;
+ try testing.expectEqual(@as(i64, @intCast(mid)), @as(i64, vector_collection.nth(v, mid).asInteger()));
+ }
+ }
+ }
+}
From 32ce6933dc0003c85f719145325ee52f08e5b961 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 11:43:18 -0300
Subject: [PATCH 015/466] docs(testing): a contributor guide to the eight test
layers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A contributor arriving today can find the commands in CONTRIBUTING and the
layer table in test/README.md, and still not know what a golden failure
means, that the property seed is deliberately fixed, or why they must not
point the mutation harness at their own checkout. Three of the eight
layers landed this week and none of them had a front door.
docs/testing.md is that front door: how to run each layer, how to read its
failure, where a new test belongs (a decision procedure, in order, stop at
the first yes), and the three facts about the mutation harness someone
needs BEFORE running it — worktree isolation, the red-baseline refusal, and
that a survivor is a work-list entry rather than a broken build.
It also carries the two rules that are learned the hard way otherwise: a
ratio-based wall-clock assertion is a flake generator, and a bare `timeout`
fails on the macOS runner that ships neither `timeout` nor `gtimeout`.
Linked from README (Documentation), CONTRIBUTING (before the commands it
now summarises) and test/README.md (which stays the in-tree map and points
at the guide). It repeats the "none of the loop's conventions apply to
you" paragraph, because that is the one an outside contributor most needs
and least expects.
---
.github/CONTRIBUTING.md | 4 +
README.md | 3 +
docs/testing.md | 194 ++++++++++++++++++++++++++++++++++++++++
test/README.md | 6 +-
4 files changed, 206 insertions(+), 1 deletion(-)
create mode 100644 docs/testing.md
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index f3ef0055f..7dbdc3167 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -31,6 +31,10 @@ zig build -Dwasm -Doptimize=ReleaseSafe # build the Wasm-enabled `cljw` binary
bash test/run_all.sh --serial-e2e # the full gate; must be green before a change lands
```
+[`docs/testing.md`](../docs/testing.md) is the full guide — every layer, how to
+run it, how to read its failures, and where a new test belongs. The short
+version of the parts you are most likely to need:
+
Two of the newer layers have their own entry points (ADR-0186):
```sh
diff --git a/README.md b/README.md
index 9fafa97df..df0db5ab4 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,9 @@ from a Clojure one.
runtime is put together.
- [`docs/clojure_vs_clojurewasm.md`](./docs/clojure_vs_clojurewasm.md) — what
matches JVM Clojure, the intentional divergences, and what is not yet there.
+- [`docs/testing.md`](./docs/testing.md) — how to run the suite, what each of
+ the eight test layers is for, and where a new test belongs. **Read this
+ before sending a patch.**
- [`bench/README.md`](./bench/README.md) — the benchmark catalogue and
cross-language cold-start numbers.
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 000000000..1867d163c
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,194 @@
+# Testing ClojureWasm
+
+Everything you need to run the suite, read a failure, and put a new test in the
+right place. If you are here to send a patch, the short version is:
+
+```sh
+zig build -Dwasm -Doptimize=ReleaseSafe # build the binary the tests use
+bash test/run_all.sh --serial-e2e # the full gate; must be green before a change lands
+```
+
+That takes roughly 20 minutes. While iterating, use the smoke tier instead
+(tens of seconds) and run the full gate once before you open the PR:
+
+```sh
+bash test/run_all.sh --smoke # unit + diff oracle + lint + build + one e2e step
+bash test/run_all.sh --list # every step name
+bash test/run_all.sh --only golden # just one
+```
+
+`test/run_all.sh` is the single entry point. If a check is not reachable from
+it, it is not part of "the suite is green".
+
+## The eight layers
+
+A test belongs to exactly one layer, and choosing the layer is the first
+decision — not an afterthought. Layers 1-5 are ADR-0021; 6-8 are ADR-0186.
+
+| # | Layer | Lives in | Run with | In the gate? |
+|---|---|---|---|---|
+| 1 | Unit | `src/**/*.zig`, inline `test "..."` blocks | `zig build test -Dwasm` | every commit |
+| 2 | E2E (CLI) | `test/e2e/*.sh` | `bash test/e2e/.sh` | every commit |
+| 3 | Differential | `test/diff/` | `zig build test` (both backends) | every commit |
+| 4 | Bench | `bench/` | `bash bench/compare_langs.sh` | no — on demand |
+| 5 | Conformance | `test/clj/` | `bash test/clj/run_tier_a.sh` | every commit |
+| 6 | Golden | `test/golden/cases/*.clj` + `.expected` | `bash test/golden/run.sh` | every commit |
+| 7 | Property | `src/testing/prop_*.zig` | `zig build test -Dwasm` | every commit |
+| 8 | Mutation | `scripts/mutation/` | `bash scripts/mutation/run.sh` | **no** — a measurement |
+
+### Which layer does my test go in?
+
+Ask in order and stop at the first yes:
+
+1. **One deterministic function call, no I/O?** → Layer 1. Write it inline, next
+ to the function, in a `test "..."` block. This is the default and most tests
+ belong here.
+2. **`cljw -e '...'`, a script file, exit codes, an error message?** → Layer 2.
+3. **TreeWalk and VM must agree on the same source?** → Layer 3.
+4. **A performance number?** → Layer 4, and run it on a quiet machine. Do not
+ put a timing assertion in Layer 1 — see "Wall-clock" below.
+5. **A port of an upstream Clojure test?** → Layer 5.
+6. **Everything the program printed must stay the same?** → Layer 6.
+7. **A law that must hold for every input, not just chosen ones?** → Layer 7.
+
+Layer 8 is not a place to put a test. It tells you which lines the layers above
+are failing to constrain.
+
+## Layer 6 — golden snapshots
+
+A case is a whole program. Its snapshot is stdout, stderr and the exit code in
+one file, so it fails for *any* change in what the user sees — a reworded error,
+a dropped newline, a value that starts printing its metadata.
+
+```sh
+bash test/golden/run.sh # verify
+bash test/golden/run.sh --only printer # substring-filter case names
+bash test/golden/run.sh --update # re-record every changed snapshot
+CLJW_SKIP_BUILD=1 bash test/golden/run.sh # skip the rebuild
+CLJW_BIN=/path/to/cljw bash test/golden/run.sh # test some other binary
+```
+
+**Adding a case**: drop `test/golden/cases/.clj` in, run `--update`, then
+**read the generated `.expected`** and commit both.
+
+**When one fails**: the runner prints a unified diff. Decide which side is
+right. If your change was meant to alter that output, run `--update`, read the
+diff, and include it in the PR. A snapshot regenerated without being read is
+worse than no snapshot — it records a bug as readily as a behaviour.
+
+Determinism comes from explicit normalisation, not from luck: the repo path, hex
+addresses, pids, durations and the version string are rewritten to fixed tokens
+before comparison. If your case is still non-deterministic after that, it does
+not belong in this layer.
+
+## Layer 7 — properties
+
+A property states a law over *generated* input and, when it fails, shrinks the
+counterexample and prints the seed that produced it.
+
+```sh
+zig build test -Dwasm -Doptimize=ReleaseSafe # gate defaults: fixed seed, 60 iterations
+zig build test -Dwasm -Dprop-seed=0xdecafbad -Dprop-iters=5000 # a deeper sweep
+zig test src/testing/prop.zig # the engine's own tests, in seconds
+```
+
+The seed is **fixed by default** on purpose: a gate whose input set changes per
+run reports something different each time it is asked, and a failure nobody can
+reproduce is a failure nobody fixes. When a sweep does find one, pin that seed
+into the default in `build.zig` as part of the fix, so the gate keeps looking
+where the bug was.
+
+A failure looks like this, and the last line is copy-pasteable:
+
+```
+property FAILED on iteration 12 with TestExpectedEqual
+ smallest failing input: [3, 0]
+ reproduce: zig build test -Dprop-seed=0x10be15eed -Dprop-iters=60
+```
+
+**Writing one** needs an oracle that shares no implementation with the code
+under test. Three that work here:
+
+- **A law** — `pop` undoes `conj`; `dissoc` undoes `assoc`; insertion order does
+ not change the result.
+- **A model** — a plain Zig array doing the obviously-correct thing, fed the
+ same operations as the persistent structure.
+- **A second implementation** — TreeWalk against VM (that is Layer 3).
+
+**Cross the representation boundaries deliberately.** `map` promotes from
+ArrayMap to a HAMT past 16 entries, `set` past 8, and `vector` spills its tail
+into a trie that grows a level at 1057 elements. Discussion #12 was a bug that
+existed only above one of those lines while every fixture sat below it.
+
+The properties live in `src/testing/`, not `test/prop/`, because `zig build
+test` compiles one module rooted at `src/main.zig` and Zig rejects an `@import`
+that leaves the module directory. `test/prop/README.md` says so and points at
+the real location.
+
+## Layer 8 — mutation
+
+Not a test. It changes one line of one source file, rebuilds, runs the unit
+suite, and records whether anything failed. A mutant that survives names a line
+whose behaviour **no test constrains**.
+
+```sh
+bash scripts/mutation/run.sh --targets src/runtime/collection/vector.zig --budget 8
+bash scripts/mutation/run.sh --targets-from .dev/mutation_targets.txt --budget 20 --seed 7
+bash scripts/mutation/run.sh --targets v.zig --ids 9474dd05b9cf # re-run named mutants
+```
+
+It costs **one full rebuild per mutant**, so `--budget` is the number of
+rebuilds you are buying. Reports land in `.dev/mutation/` (gitignored; commit a
+report deliberately with `git add -f` when it is worth keeping).
+
+Three things to know before running it:
+
+- **It never touches your checkout.** It builds a detached git worktree at a
+ revision, mutates there, and removes it. Never point a mutation tool at a tree
+ with uncommitted work.
+- **It refuses to run on a red baseline.** A suite that is already failing
+ reports every mutant as killed — a perfect score for a broken suite.
+- **It is not a gate.** A survivor is a missing test to schedule, not a commit
+ to reject.
+
+**The loop that makes a survivor useful**: the sweep names a line → you write
+the test that should have constrained it → you re-run *that* mutant with
+`--ids` and watch it die. Without `--ids` you are re-sampling and hoping.
+
+The first sweep (report in `.dev/mutation/report-ce5cb6c8-seed1.md`) scored 50%
+and its three survivors were all in `popTail`'s deep-trie collapse — unreachable
+by the vector property, whose generator stopped at 96 elements. That is what
+this layer is for: the property looked correct, *was* correct, and never reached
+the branch it was aimed at.
+
+## Wall-clock assertions
+
+Elapsed time is not deterministic, and CI runners are noisy.
+
+- A **lower** bound is safe: load makes things slower, never faster.
+- An **order-of-magnitude upper** bound is safe: it separates a hang from a busy
+ machine.
+- A **ratio** upper bound is a flake generator. It cannot distinguish a 20%
+ regression from a 50% busy runner, so it eventually fails for the one reason
+ it was not written to detect. Put the ratio in Layer 4 and record the numbers.
+
+## Conventions
+
+- Name a Layer 1 test for the behaviour, not the mechanism: `test "+ on two
+ integers"`, not `test "primAdd inline call"`.
+- A shell e2e exits non-zero on failure and prints the failing case name.
+- Never use a bare `timeout` in anything the gate runs — the macOS runner has
+ neither `timeout` nor `gtimeout`. Use the `run_bounded` helper;
+ `scripts/check_portable_timeout.sh` enforces it.
+- Never run a concurrent build during a gate.
+- `zig build test` without `-Dwasm` builds a different configuration from the
+ one that ships.
+
+## What the loop's conventions do NOT ask of you
+
+This repository is developed largely by an autonomous loop under written
+guardrails, and that loop follows conventions that exist to keep *it* honest.
+None of them apply to an outside contribution: no `Smell-audited:` line, no
+`.dev/debt.yaml` row, no ADR, no ROADMAP amendment. Write a clear commit
+message, keep the gate green, and that is enough. See
+[`.github/CONTRIBUTING.md`](../.github/CONTRIBUTING.md).
diff --git a/test/README.md b/test/README.md
index 004603d67..f62495a5c 100644
--- a/test/README.md
+++ b/test/README.md
@@ -1,6 +1,10 @@
# cw v1 test layout
-> 5 layers per ADR-0021. `test/run_all.sh` is the single entry point.
+> 8 layers (ADR-0021 + ADR-0186). `test/run_all.sh` is the single entry point.
+>
+> **Contributors: read [`docs/testing.md`](../docs/testing.md) first** — this
+> file is the in-tree map, that one is the guide (how to run each layer, how to
+> read its failures, where a new test belongs).
## Layers
From 788f04d2d1221d9a8d28aecf363db99bc25a0a9c Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 11:44:25 -0300
Subject: [PATCH 016/466] docs: an index for docs/, organised by what the
reader came to do
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`docs/` had eight entry points and no front door, so the way in was to
already know which file you wanted. The index routes by intent — using
cljw, contributing a patch, evaluating it as a dependency, debugging
something specific — rather than by directory listing.
It also states where the rest of the knowledge lives and why it is not
here: rationale in `.dev/decisions/`, known-broken in `.dev/debt.yaml`,
accepted divergences in their own ledger, and each gate script's contract
in its own header. A reader who does not know that split reads `docs/` as
the whole picture and concludes things are undocumented when they are
merely filed where they can be kept true.
On the wiki question: these stay in the repository precisely because they
are versioned with the code they describe, so a document that goes wrong
is a diff in the same PR as the behaviour that broke it. A wiki cannot be
reviewed alongside the change that invalidates it.
---
README.md | 2 ++
docs/README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+)
create mode 100644 docs/README.md
diff --git a/README.md b/README.md
index df0db5ab4..8a1a2adf1 100644
--- a/README.md
+++ b/README.md
@@ -152,6 +152,8 @@ from a Clojure one.
## Documentation
+[`docs/`](./docs/README.md) is the index — start there.
+
- [`docs/architecture.md`](./docs/architecture.md) — a short orientation to how the
runtime is put together.
- [`docs/clojure_vs_clojurewasm.md`](./docs/clojure_vs_clojurewasm.md) — what
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..18fc545f2
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,62 @@
+# ClojureWasm documentation
+
+Start here. Everything in this directory is versioned with the code it
+describes, so a document that is wrong is a diff someone can fix in the same PR
+as the behaviour — which is why these live in the repository rather than in a
+GitHub wiki.
+
+## If you are…
+
+**…using cljw**
+
+- [`clojure_vs_clojurewasm.md`](./clojure_vs_clojurewasm.md) — what matches JVM
+ Clojure, what deliberately diverges, and what is not there yet. Read this
+ first when something behaves unexpectedly; the answer is often a listed,
+ intentional difference.
+- [`../README.md`](../README.md) — install, quickstart, the Wasm FFI.
+- [`works/ladder.md`](./works/ladder.md) — which real-world Clojure libraries
+ load and run today.
+
+**…contributing a patch**
+
+- [`testing.md`](./testing.md) — **read before sending a patch.** How to run the
+ suite, what each of the eight test layers is for, how to read each layer's
+ failures, and where a new test belongs.
+- [`../.github/CONTRIBUTING.md`](../.github/CONTRIBUTING.md) — what helps a PR
+ land, and the list of this repository's own conventions that explicitly do
+ **not** apply to outside contributions.
+- [`architecture.md`](./architecture.md) — a short orientation to how the
+ runtime is put together, for when a fix needs to know where it is.
+
+**…deciding whether to depend on it**
+
+- [`landscape.md`](./landscape.md) — where cljw sits among Clojure runtimes.
+- [`works/binary_size.md`](./works/binary_size.md) — the measured size
+ comparison behind the headline number.
+- [`../CHANGELOG.md`](../CHANGELOG.md) — release history; the SSOT for what
+ changed when.
+
+**…debugging something specific**
+
+- [`spec/error_format.md`](./spec/error_format.md) — the error rendering
+ contract (the source excerpt, the caret, the trace).
+- [`spec/formats/`](./spec/formats) — on-disk and wire formats.
+- [`examples/`](./examples) — runnable examples, including the `.wasm` modules
+ the docs and tests call.
+
+## Where the rest of it lives
+
+Not everything is in `docs/`, and the split is deliberate:
+
+| You want | Look in |
+|---|---|
+| Why a design is the way it is | `.dev/decisions/` — the ADR record, one file per load-bearing decision |
+| What is known-broken or deferred | `.dev/debt.yaml` — the debt ledger, with a testable barrier per row |
+| What a divergence from JVM Clojure costs and why it was accepted | `.dev/accepted_divergences.yaml` |
+| What a gate script checks | The script's own header in `scripts/` — each is its own SSOT |
+| What the test suite covers | [`testing.md`](./testing.md) and `test/README.md` |
+| Release history | `CHANGELOG.md` |
+
+`docs/ja/` holds Japanese-language material from the project's original
+maintainer. `docs/research/` holds background surveys that informed decisions
+but are not themselves decisions.
From b2f3d5a2375146d19c5b7da4e69fded5680c05cb Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 12:11:09 -0300
Subject: [PATCH 017/466] test(mutation): an equivalence register, so a
survivor means a missing test
The re-run of the first sweep's three survivors killed one and left two.
Both of the two are unobservable, not untested:
vector.zig:284 arrayFor(old, count - 2) -> count - 3. Reached only when
the tail holds one element, so count = 32m+1; arrayFor picks a leaf by
i >> SHIFT_BITS and (32m-1) >> 5 == (32m-2) >> 5. Same leaf either way.
vector.zig:529 (new_count - 1) >> level -> (new_count - 2) >> level.
popTail is entered with new_count = 32m and level >= SHIFT_BITS; the two
values differ only in bit 0, which every level's shift discards.
A mutant nobody can kill is re-reported by every sweep, and a survivor list
that always contains the same two rows teaches its reader to skim it. So
register them, with the proof, and exclude them from the score.
.dev/mutation_equivalent.jsonl matches on file+op+before and carries a
reason. report.py applies it, and reports an entry matching no enumerated
candidate as STALE with a non-zero exit: a line that moved has outrun its
proof, and the mutant goes back on the survivor list rather than staying
quietly excused. Both paths verified against the existing sweep log.
Score for that re-run: 1 killed, 0 survived, 2 equivalent.
---
.dev/debt.yaml | 14 +-
.../0186_golden_property_mutation_layers.md | 12 +-
.dev/mutation/report-4b79a6d3-seed2.md | 17 +++
.dev/mutation_equivalent.jsonl | 5 +
docs/testing.md | 36 ++++-
scripts/mutation/report.py | 131 ++++++++++++++++++
scripts/mutation/run.sh | 49 ++-----
7 files changed, 218 insertions(+), 46 deletions(-)
create mode 100644 .dev/mutation/report-4b79a6d3-seed2.md
create mode 100644 .dev/mutation_equivalent.jsonl
create mode 100644 scripts/mutation/report.py
diff --git a/.dev/debt.yaml b/.dev/debt.yaml
index 86e7e2008..88a62e8f4 100644
--- a/.dev/debt.yaml
+++ b/.dev/debt.yaml
@@ -25,17 +25,21 @@
active:
- id: "D-577"
- status: "open (2026-08-12, ADR-0186 residual). The three new layers are OPEN but shallow: golden covers 20 programs, property covers map/set/vector only, and no mutation sweep has been run against a committed baseline yet, so the mutation score of this suite is unmeasured. 120 of 265 src files still carry no inline test at all."
+ status: "open (2026-08-12, ADR-0186 residual). The three new layers are OPEN but shallow: golden covers 22 programs, property covers map/set/vector only, and only ONE file has been swept for mutants (vector.zig, 8 of 141 candidates), so the mutation score of this suite is still unmeasured. 120 of 265 src files carry no inline test at all."
category: "test"
barrier: |-
Not a design question, a queue. Drain in this order, because each step
tells you where the next one should point:
- 1. Run the first sweep (`scripts/mutation/run.sh --targets-from
- .dev/mutation_targets.txt --budget 15`) and commit the report. Until
- a sweep has run, "the suite is good" is an impression.
+ 1. Widen the sweep past vector.zig (`scripts/mutation/run.sh
+ --targets-from .dev/mutation_targets.txt --budget 15`) and commit
+ the report. One file swept is not a measurement of a suite.
2. Turn each survivor into a test in the layer that should have caught
it — a survivor in a comparison is usually a missing property, a
- survivor in a rendering path is usually a missing golden case.
+ survivor in a rendering path is usually a missing golden case — then
+ re-run THAT mutant with `--ids` and watch it die. Rule out
+ equivalence first: two of the first sweep's three survivors could not
+ be killed by any test and are now registered in
+ `.dev/mutation_equivalent.jsonl` with their proofs.
3. Extend Layer 7 past the collections. The obvious next law —
`read-string` after `pr-str` is the identity — canNOT be written at
the Zig level as the code stands: `reader.readAll` yields `Form`
diff --git a/.dev/decisions/0186_golden_property_mutation_layers.md b/.dev/decisions/0186_golden_property_mutation_layers.md
index be45b373e..d8fdf9444 100644
--- a/.dev/decisions/0186_golden_property_mutation_layers.md
+++ b/.dev/decisions/0186_golden_property_mutation_layers.md
@@ -122,7 +122,7 @@ would be indistinguishable from a real edit.
- `.dev/decisions/0186_golden_property_mutation_layers.md` (this file)
- `test/golden/run.sh`, `test/golden/cases/**`
- `test/prop/**`
-- `scripts/mutation/**`
+- `scripts/mutation/**` (including `report.py` and `.dev/mutation_equivalent.jsonl`)
- `test/run_all.sh` (the `golden` and `prop` steps)
- `test/README.md`, `.claude/rules/test_taxonomy.md` (the layer table)
@@ -130,3 +130,13 @@ would be indistinguishable from a real edit.
- 2026-08-12: Status: Proposed -> Accepted. Layers 6 and 7 land with the ADR;
Layer 8's harness lands beside them and its first sweep report follows.
+- 2026-08-12: Layer 8 gains an equivalence register
+ (`.dev/mutation_equivalent.jsonl`), consumed by `scripts/mutation/report.py`.
+ The first sweep's three survivors resolved as one real gap (killed by a new
+ deep-trie property) and two mutants that provably cannot be observed —
+ constants feeding a `>> SHIFT_BITS` that land in the same leaf either way.
+ Without a register, an unkillable mutant is re-reported every sweep and
+ trains the reader to ignore survivors, which is the one thing this layer
+ cannot afford. Registered entries are excluded from the score and each
+ carries a written proof; an entry matching no candidate is reported STALE and
+ fails the run, so a proof cannot outlive the line it was written about.
diff --git a/.dev/mutation/report-4b79a6d3-seed2.md b/.dev/mutation/report-4b79a6d3-seed2.md
new file mode 100644
index 000000000..f7ec0c6bc
--- /dev/null
+++ b/.dev/mutation/report-4b79a6d3-seed2.md
@@ -0,0 +1,17 @@
+# Mutation sweep — 4b79a6d3 (seed 2)
+
+- Candidates enumerated: 133
+- Mutants run: 3
+- Killed: 1
+- Survived: 0
+- Equivalent (registered, not scored): 2
+- Unviable (did not compile): 0
+- **Mutation score: 100.0%** (killed / (killed + survived))
+
+No unexplained survivors in this sample.
+
+## Equivalent — registered as unobservable
+
+- `src/runtime/collection/vector.zig:284` const_bump_in_arith — Reached only when the tail holds exactly one element, i.e. count = 32m+1. arrayFor selects a 32-element leaf by i >> SHIFT_BITS, and (32m-1) >> 5 == (32m-2) >> 5, so `count - 2` and `count - 3` always name the same leaf. No input distinguishes them.
+- `src/runtime/collection/vector.zig:529` const_bump_in_arith — popTail is entered only with new_count = 32m (a full-leaf boundary) and level >= SHIFT_BITS. new_count-1 and new_count-2 differ only in bit 0, which every level's >> level discards, so sub_index is identical at every level of the descent.
+
diff --git a/.dev/mutation_equivalent.jsonl b/.dev/mutation_equivalent.jsonl
new file mode 100644
index 000000000..3426a058d
--- /dev/null
+++ b/.dev/mutation_equivalent.jsonl
@@ -0,0 +1,5 @@
+# Mutants proven to have no observable effect. Format: one JSON object per line,
+# matched on file+op+before. A row here is a PROOF obligation: state why no input
+# can distinguish the mutant. run.sh reports an entry that matches nothing as STALE.
+{"file": "src/runtime/collection/vector.zig", "op": "const_bump_in_arith", "before": "const new_tail_leaf = arrayFor(old, old.count - 2);", "reason": "Reached only when the tail holds exactly one element, i.e. count = 32m+1. arrayFor selects a 32-element leaf by i >> SHIFT_BITS, and (32m-1) >> 5 == (32m-2) >> 5, so `count - 2` and `count - 3` always name the same leaf. No input distinguishes them."}
+{"file": "src/runtime/collection/vector.zig", "op": "const_bump_in_arith", "before": "const sub_index = ((new_count - 1) >> @as(u5, @intCast(level))) & MASK;", "reason": "popTail is entered only with new_count = 32m (a full-leaf boundary) and level >= SHIFT_BITS. new_count-1 and new_count-2 differ only in bit 0, which every level's >> level discards, so sub_index is identical at every level of the descent."}
diff --git a/docs/testing.md b/docs/testing.md
index 1867d163c..57c936922 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -155,11 +155,37 @@ Three things to know before running it:
the test that should have constrained it → you re-run *that* mutant with
`--ids` and watch it die. Without `--ids` you are re-sampling and hoping.
-The first sweep (report in `.dev/mutation/report-ce5cb6c8-seed1.md`) scored 50%
-and its three survivors were all in `popTail`'s deep-trie collapse — unreachable
-by the vector property, whose generator stopped at 96 elements. That is what
-this layer is for: the property looked correct, *was* correct, and never reached
-the branch it was aimed at.
+### Equivalent mutants
+
+Some mutants cannot be killed, because they change the source without changing
+the program. A survivor is only a missing test *after* you have ruled that out.
+
+`.dev/mutation_equivalent.jsonl` is the register of the ones already ruled out.
+Each row matches on `file` + `op` + `before` and carries a `reason`, which is a
+**proof obligation**: state why no input can distinguish the mutant. Registered
+mutants are dropped from the score instead of counted against it. An entry that
+matches no enumerated candidate is reported as **STALE** and the run exits
+non-zero — a line that moved has outrun its proof, so the mutant returns to the
+survivor list rather than staying quietly excused.
+
+Reach for the register only with an argument you could defend, and never to
+quiet a survivor you have not understood. The two entries in it today are both
+index arithmetic feeding a `>> SHIFT_BITS`, where the mutated constant lands in
+the same 32-element leaf as the original.
+
+### What the first sweep actually found
+
+The first sweep (`.dev/mutation/report-ce5cb6c8-seed1.md`) scored 50%, and all
+three of its survivors were in `popTail`'s deep-trie collapse — beyond the reach
+of the vector property, whose generator stopped at 96 elements when the trie
+only grows a level at 1057. Adding a deep-trie property killed one of them
+(`bool_and_to_or` on the collapse condition) and proved the other two equivalent
+by the argument above.
+
+Both halves of that are the point. The property looked correct, *was* correct,
+and never reached the branch it was aimed at — only the mutant showed it. And
+two thirds of a survivor list turned out not to be work at all, which is why the
+verdict is a starting point for an argument and not a score to optimise.
## Wall-clock assertions
diff --git a/scripts/mutation/report.py b/scripts/mutation/report.py
new file mode 100644
index 000000000..a8f344f7a
--- /dev/null
+++ b/scripts/mutation/report.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""scripts/mutation/report.py — turn a mutation run's JSONL log into a report.
+
+Reclassifies each surviving mutant against the equivalence register
+(.dev/mutation_equivalent.jsonl), matching on file + op + before. An entry in
+that register is excluded from the mutation score; an entry that matches no
+enumerated candidate is reported as STALE and exits non-zero.
+
+Verdicts after reclassification:
+ killed — the suite failed. The line is constrained.
+ survived — the suite passed. No test constrains the line.
+ equivalent — registered as unobservable, with a proof. Not scored.
+ unviable — did not compile. Says nothing either way.
+
+Score = killed / (killed + survived).
+"""
+import argparse
+import collections
+import json
+import sys
+
+
+def load_jsonl(path, allow_missing=False):
+ try:
+ fh = open(path)
+ except FileNotFoundError:
+ if allow_missing:
+ return []
+ raise
+ with fh:
+ return [
+ json.loads(line)
+ for line in fh
+ if line.strip() and not line.lstrip().startswith("#")
+ ]
+
+
+def key(row):
+ return (row["file"], row["op"], row["before"])
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--log", required=True)
+ ap.add_argument("--report", required=True)
+ ap.add_argument("--stamp", required=True)
+ ap.add_argument("--seed", required=True)
+ ap.add_argument("--total", required=True)
+ ap.add_argument("--candidates")
+ ap.add_argument("--equivalent")
+ args = ap.parse_args(argv)
+
+ rows = load_jsonl(args.log)
+ register = load_jsonl(args.equivalent, allow_missing=True) if args.equivalent else []
+ reasons = {key(e): e.get("reason", "") for e in register}
+
+ for r in rows:
+ if r["verdict"] == "survived" and key(r) in reasons:
+ r["verdict"] = "equivalent"
+
+ stale = []
+ if args.candidates and register:
+ present = {key(c) for c in load_jsonl(args.candidates)}
+ by_file = collections.defaultdict(set)
+ for f, op, before in present:
+ by_file[f].add((op, before))
+ for e in register:
+ f = e["file"]
+ # Only a file this run actually enumerated can prove an entry stale.
+ if f in by_file and (e["op"], e["before"]) not in by_file[f]:
+ stale.append(e)
+
+ killed = [r for r in rows if r["verdict"] == "killed"]
+ survived = [r for r in rows if r["verdict"] == "survived"]
+ equivalent = [r for r in rows if r["verdict"] == "equivalent"]
+ unviable = [r for r in rows if r["verdict"] == "unviable"]
+ scored = len(killed) + len(survived)
+ score = (100.0 * len(killed) / scored) if scored else float("nan")
+
+ with open(args.report, "w") as fh:
+ w = fh.write
+ w(f"# Mutation sweep — {args.stamp} (seed {args.seed})\n\n")
+ w(f"- Candidates enumerated: {args.total}\n")
+ w(f"- Mutants run: {len(rows)}\n")
+ w(f"- Killed: {len(killed)}\n")
+ w(f"- Survived: {len(survived)}\n")
+ w(f"- Equivalent (registered, not scored): {len(equivalent)}\n")
+ w(f"- Unviable (did not compile): {len(unviable)}\n")
+ w(f"- **Mutation score: {score:.1f}%** (killed / (killed + survived))\n\n")
+
+ if stale:
+ w("## STALE equivalence entries\n\n")
+ w("These are registered as equivalent but match no enumerated candidate — "
+ "the line moved or changed, and the proof no longer covers it.\n\n")
+ for e in stale:
+ w(f"- `{e['file']}` {e['op']}: `{e['before']}`\n")
+ w("\n")
+
+ if survived:
+ w("## Survivors — each is a line no test constrains\n\n")
+ w("| file | line | operator | change |\n|---|---:|---|---|\n")
+ for r in sorted(survived, key=lambda r: (r["file"], r["line"])):
+ before = r["before"].replace("|", "\\|")
+ after = r["after"].replace("|", "\\|")
+ w(f"| `{r['file']}` | {r['line']} | {r['op']} | `{before}` -> `{after}` |\n")
+ w("\n")
+ by_file = collections.Counter(r["file"] for r in survived)
+ w("### Survivors per file\n\n")
+ for f, c in by_file.most_common():
+ w(f"- `{f}`: {c}\n")
+ w("\n")
+ else:
+ w("No unexplained survivors in this sample.\n\n")
+
+ if equivalent:
+ w("## Equivalent — registered as unobservable\n\n")
+ for r in sorted(equivalent, key=lambda r: (r["file"], r["line"])):
+ w(f"- `{r['file']}:{r['line']}` {r['op']} — {reasons[key(r)]}\n")
+ w("\n")
+
+ print(f"mutation: score {score:.1f}% — report at {args.report}")
+ if equivalent:
+ print(f"mutation: {len(equivalent)} survivor(s) excluded as registered-equivalent")
+ if stale:
+ print(f"mutation: {len(stale)} STALE equivalence entr(ies) — see the report", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/mutation/run.sh b/scripts/mutation/run.sh
index 24a06bc6c..b4572bf9b 100755
--- a/scripts/mutation/run.sh
+++ b/scripts/mutation/run.sh
@@ -39,6 +39,7 @@ SEED=0
IDS=""
REV="HEAD"
OUT_DIR="$REPO_ROOT/.dev/mutation"
+EQUIV_FILE="$REPO_ROOT/.dev/mutation_equivalent.jsonl"
BUILD_ARGS="-Dwasm -Doptimize=Debug"
# A mutant can hang (a loop bound flipped). Time out generously enough that a
# slow machine is not mistaken for a hang, and treat a timeout as KILLED: a
@@ -54,6 +55,7 @@ while [ $# -gt 0 ]; do
--seed) SEED="$2"; shift ;;
--rev) REV="$2"; shift ;;
--out) OUT_DIR="$2"; shift ;;
+ --equivalent) EQUIV_FILE="$2"; shift ;;
--build-args) BUILD_ARGS="$2"; shift ;;
--timeout) PER_MUTANT_TIMEOUT="$2"; shift ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
@@ -187,38 +189,15 @@ done < "$SAMPLE"
( cd "$WORKTREE" && git checkout -- . )
# --- report ---------------------------------------------------------------
-python3 - "$LOG" "$REPORT" "$STAMP" "$SEED" "$TOTAL" <<'PY'
-import collections, json, sys
-log, report, stamp, seed, total = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
-rows = [json.loads(l) for l in open(log) if l.strip()]
-killed = [r for r in rows if r["verdict"] == "killed"]
-survived = [r for r in rows if r["verdict"] == "survived"]
-unviable = [r for r in rows if r["verdict"] == "unviable"]
-scored = len(killed) + len(survived)
-score = (100.0 * len(killed) / scored) if scored else float("nan")
-
-with open(report, "w") as fh:
- w = fh.write
- w(f"# Mutation sweep — {stamp} (seed {seed})\n\n")
- w(f"- Candidates enumerated: {total}\n")
- w(f"- Mutants run: {len(rows)}\n")
- w(f"- Killed: {len(killed)}\n- Survived: {len(survived)}\n- Unviable (did not compile): {len(unviable)}\n")
- w(f"- **Mutation score: {score:.1f}%** (killed / (killed + survived))\n\n")
- if survived:
- w("## Survivors — each is a line no test constrains\n\n")
- w("| file | line | operator | change |\n|---|---:|---|---|\n")
- for r in sorted(survived, key=lambda r: (r["file"], r["line"])):
- before = r["before"].replace("|", "\\|")
- after = r["after"].replace("|", "\\|")
- w(f"| `{r['file']}` | {r['line']} | {r['op']} | `{before}` -> `{after}` |\n")
- w("\n")
- by_file = collections.Counter(r["file"] for r in survived)
- w("### Survivors per file\n\n")
- for f, c in by_file.most_common():
- w(f"- `{f}`: {c}\n")
- else:
- w("No survivors in this sample.\n")
-print(f"mutation: score {score:.1f}% — report at {report}")
-PY
-
-echo "mutation: killed=$killed survived=$survived unviable=$unviable (log: $LOG)"
+# Survivors are reclassified against the equivalence register: a mutant listed
+# there has a written proof that no input can observe it, so it is excluded
+# from the score rather than counted as a missing test.
+set +e
+python3 "$REPO_ROOT/scripts/mutation/report.py" \
+ --log "$LOG" --report "$REPORT" --stamp "$STAMP" --seed "$SEED" \
+ --total "$TOTAL" --candidates "$ALL_MUTANTS" --equivalent "$EQUIV_FILE"
+report_rc=$?
+set -e
+
+echo "mutation: raw verdicts killed=$killed survived=$survived unviable=$unviable (log: $LOG)"
+exit $report_rc
From d710ceef06c91b66b1cf2799e1a7fbdc3a392947 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 12:11:55 -0300
Subject: [PATCH 018/466] docs(handover): the sweep has run; one file, not the
suite
---
.dev/handover.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.dev/handover.md b/.dev/handover.md
index e64ac1123..6f0a6ebe5 100644
--- a/.dev/handover.md
+++ b/.dev/handover.md
@@ -36,9 +36,9 @@
(`test/golden/`, gated), properties (`src/testing/prop_*.zig`, in
`zig build test`, fixed seed — sweep with `-Dprop-seed`/`-Dprop-iters`),
mutation (`scripts/mutation/run.sh`, on demand, worktree-isolated, NEVER
- gated). First task: **D-577** — run the first sweep and turn its survivors
- into tests. No sweep has run yet, so this suite's mutation score is
- unmeasured.
+ gated). First task: **D-577** — only vector.zig has been swept, so the score
+ is unmeasured. Rule out equivalence before writing a test for a survivor:
+ `.dev/mutation_equivalent.jsonl` registers the unkillable ones, with proofs.
- **HAZARD — workflows do not fire on push/tag here.** GitHub's fork default:
every run so far came from `gh workflow run --ref [`. Until the
Actions tab's enable button is pressed once, "no run appeared" does NOT mean
From 3995f5232189585bf0e46029428446506a3361a6 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho ]
Date: Wed, 12 Aug 2026 12:41:21 -0300
Subject: [PATCH 019/466] style(test): rationale out of the test-layer comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Layer 6/7/8 files carried the WHY in their comments — which sweep
motivated a size, what a survivor turned out to be, what Discussion #12
broke, what the rejected alternative was. That belongs in docs/testing.md
and ADR-0186, where a contributor can find it by looking for it, rather
than spread across five files where only a reader who is already on that
exact line ever sees it.
Kept: one contract line per fn/script, the usage blocks, and the hard
constraints a reader cannot infer from the code — the 16-entry ArrayMap
ceiling, the 1057-element trie level that popTail's recursion needs, the
spaced-operator requirement in the mutant enumerator, and that a timeout
counts as KILLED.
Comments only; no logic touched. Verified without a rebuild: mutant
enumeration is byte-identical before and after (sha1 of sorted
`mutate.py list` over vector.zig and map.zig), zig fmt --check clean,
bash -n clean on both scripts, py_compile clean.
Also drops a claim that had gone stale: the mutation sweep runs on
demand, not "on demand and on a schedule" — the nightly tier was retired
2026-08-12 and check_gate_parity.sh now fails if one comes back.
---
scripts/mutation/mutate.py | 26 ++++----------
scripts/mutation/run.sh | 36 +++++--------------
src/testing/prop.zig | 26 ++++----------
src/testing/prop_collections.zig | 62 ++++++--------------------------
test/golden/run.sh | 31 +++++-----------
5 files changed, 39 insertions(+), 142 deletions(-)
diff --git a/scripts/mutation/mutate.py b/scripts/mutation/mutate.py
index ed5c942ea..d6b4193df 100755
--- a/scripts/mutation/mutate.py
+++ b/scripts/mutation/mutate.py
@@ -14,13 +14,10 @@
mutate.py apply
Rewrite the file in place with that mutant applied. Prints the diff line.
-Scope discipline: the enumerator refuses to touch comments, doc comments, string
-and character literals, `@import` lines, and `test "..."` blocks. Mutating a test
-proves nothing about the suite — a mutated assertion that still passes says the
-assertion was weak, which is a different question from the one this layer asks.
-
-The applier NEVER writes outside the file it is given, and `run.sh` only ever
-gives it paths inside a throwaway git worktree.
+The enumerator never touches comments, doc comments, string and character
+literals, `@import` lines, or `test "..."` blocks. The applier NEVER writes
+outside the file it is given, and `run.sh` only ever gives it paths inside a
+throwaway git worktree.
"""
import argparse
@@ -31,13 +28,8 @@
import sys
# (name, pattern, replacement) — each is a behaviour change, not a refactor.
-#
# Every binary-operator rule matches only the SPACED form (`a * b`, never
-# `*const T` or `ptr.*`). `zig fmt` runs in the gate, so the spaced form is the
-# only way a binary operator can appear, and requiring it is what keeps the
-# enumerator from proposing mutants that cannot compile. An unviable mutant is
-# not free: it costs a full rebuild to discover, and this layer's budget is
-# measured in rebuilds.
+# `*const T` or `ptr.*`).
OPERATORS = [
("cmp_lt_to_le", r"(?<=[\w\)\]]) < (?=[\w\(@])", " <= "),
("cmp_gt_to_ge", r"(?<=[\w\)\]]) > (?=[\w\(@])", " >= "),
@@ -50,13 +42,9 @@
("arith_mul_to_div", r"(?<=[\w\)\]]) \* (?=[\w\(@])", " / "),
("bool_and_to_or", r"(?<=[\w\)\]]) and (?=[\w\(@!])", " or "),
("bool_or_to_and", r"(?<=[\w\)\]]) or (?=[\w\(@!])", " and "),
- # Constants only where they are being COMPARED or COMBINED — a literal in a
- # struct-field default or an array length is usually layout, and mutating
- # layout produces a build failure rather than a question about the suite.
+ # Constants only where they are being COMPARED or COMBINED.
("const_bump_after_cmp", r"(?<=[<>!=]= )([0-9]+)(?![\w.])", None),
("const_bump_in_arith", r"(?<=[+\-*/] )([0-9]+)(?![\w.])", None),
- # A branch that no test distinguishes is the most valuable survivor of all:
- # it means the condition itself is unconstrained.
("branch_force_true", r"\bif \(([^()]{1,60})\)", "if (true)"),
]
@@ -199,8 +187,6 @@ def main() -> int:
if args.cmd == "list":
mutants = enumerate_mutants(args.file)
if args.limit and len(mutants) > args.limit:
- # Sample rather than truncate: the first K mutants of a file are all
- # in its first few lines, which is not a sample of the file.
random.Random(args.seed).shuffle(mutants)
mutants = mutants[: args.limit]
mutants.sort(key=lambda m: (m["line"], m["col"]))
diff --git a/scripts/mutation/run.sh b/scripts/mutation/run.sh
index b4572bf9b..b23744030 100755
--- a/scripts/mutation/run.sh
+++ b/scripts/mutation/run.sh
@@ -3,18 +3,13 @@
#
# Changes one line of one source file, rebuilds, runs the unit suite, and records
# whether anything failed. A mutant the suite still passes is a SURVIVOR: that
-# line's behaviour is unconstrained by any test, and the survivor names it
-# exactly — file, line, and the change nobody noticed.
+# line's behaviour is unconstrained by any test.
#
-# This is a measurement, not a gate. It runs on demand and on a schedule; a
-# survivor is a work-list entry, not a commit to block. Cost is one rebuild per
-# mutant, so `--budget` is the parameter that matters.
+# On demand only, never in a gate. One rebuild per mutant, so `--budget` is the
+# number of rebuilds bought.
#
-# ISOLATION (the rule that makes this safe to run): the sweep NEVER touches your
-# checkout. It creates a detached git worktree at a chosen revision, mutates
-# files there, and removes it at the end. Mutating the working tree would put
-# uncommitted work one `git checkout --` away from deletion, and an interrupted
-# run would leave a mutant behind that reads exactly like a deliberate edit.
+# ISOLATION: the sweep NEVER touches your checkout. It creates a detached git
+# worktree at a chosen revision, mutates files there, and removes it at the end.
#
# Usage:
# bash scripts/mutation/run.sh --targets src/runtime/collection/set.zig
@@ -22,12 +17,7 @@
# bash scripts/mutation/run.sh --targets-from .dev/mutation_targets.txt --rev v1.10.1
# bash scripts/mutation/run.sh --targets v.zig --ids 1a2b3c,4d5e6f # re-run named mutants
#
-# `--ids` is the loop that makes a survivor actionable: a survivor names a line,
-# you write the test that should have constrained it, then you re-run THAT
-# mutant to see it die. Without it you are re-sampling and hoping.
-#
-# Output: a JSONL log plus a markdown summary under .dev/mutation/ (gitignored
-# by default — the REPORT is the artifact worth committing, not the log).
+# Output: a JSONL log plus a markdown summary under .dev/mutation/ (gitignored).
set -euo pipefail
cd "$(dirname "$0")/../.."
REPO_ROOT=$(pwd)
@@ -41,9 +31,7 @@ REV="HEAD"
OUT_DIR="$REPO_ROOT/.dev/mutation"
EQUIV_FILE="$REPO_ROOT/.dev/mutation_equivalent.jsonl"
BUILD_ARGS="-Dwasm -Doptimize=Debug"
-# A mutant can hang (a loop bound flipped). Time out generously enough that a
-# slow machine is not mistaken for a hang, and treat a timeout as KILLED: a
-# change that makes the suite never finish is a change the suite detected.
+# A timeout counts as KILLED.
PER_MUTANT_TIMEOUT=900
while [ $# -gt 0 ]; do
@@ -98,8 +86,6 @@ REPORT="$OUT_DIR/report-$STAMP-seed$SEED.md"
: > "$LOG"
# --- baseline -------------------------------------------------------------
-# Without this, a tree that is already red reports every mutant as killed and
-# the sweep produces a perfect score for a broken suite.
echo "mutation: baseline build + test (this must pass) …"
if ! ( cd "$WORKTREE" && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS ) >"$OUT_DIR/baseline.log" 2>&1; then
echo "mutation: BASELINE FAILED at $REV — fix the suite before measuring it." >&2
@@ -128,14 +114,10 @@ if ids:
by_id = {r["id"]: r for r in rows}
missing = [i for i in ids if i not in by_id]
if missing:
- # A named mutant that no longer exists means the line moved or changed.
- # Silently running the rest would report a kill that never happened.
raise SystemExit("mutants no longer present (did the file change?): " + ", ".join(missing))
rows = [by_id[i] for i in ids]
budget = 0
if budget and len(rows) > budget:
- # Sample across the whole candidate set, not the head of it — a truncated
- # list only ever measures the top of each file.
rows = random.Random(seed).sample(rows, budget)
rows.sort(key=lambda r: (r["file"], r["line"], r["col"]))
with open(dst, "w") as fh:
@@ -189,9 +171,7 @@ done < "$SAMPLE"
( cd "$WORKTREE" && git checkout -- . )
# --- report ---------------------------------------------------------------
-# Survivors are reclassified against the equivalence register: a mutant listed
-# there has a written proof that no input can observe it, so it is excluded
-# from the score rather than counted as a missing test.
+# Survivors are reclassified against the equivalence register.
set +e
python3 "$REPO_ROOT/scripts/mutation/report.py" \
--log "$LOG" --report "$REPORT" --stamp "$STAMP" --seed "$SEED" \
diff --git a/src/testing/prop.zig b/src/testing/prop.zig
index e7f089931..c66737460 100644
--- a/src/testing/prop.zig
+++ b/src/testing/prop.zig
@@ -22,14 +22,11 @@ pub const Config = struct {
iters: usize = 100,
max_shrink: usize = 300,
/// Suppress the failure report. Set ONLY by the engine's own tests, which
- /// falsify a property on purpose: a green gate that prints a "property
- /// FAILED" block every run teaches everyone to scroll past the real one.
+ /// falsify a property on purpose.
quiet: bool = false,
};
-/// Run `prop` over generated values. `ctx` is passed through untouched, so a
-/// property that needs a fixture (an allocator, a runtime factory) can carry it
-/// without a closure.
+/// Run `prop` over generated values. `ctx` is passed through untouched.
pub fn forAll(
comptime T: type,
alloc: std.mem.Allocator,
@@ -43,9 +40,6 @@ pub fn forAll(
var i: usize = 0;
while (i < cfg.iters) : (i += 1) {
- // `size` grows with the iteration so early cases stay small and late
- // ones reach the interesting boundaries (a map crossing its ArrayMap
- // ceiling, a vector spilling out of its tail).
const size = 1 + (i * 32) / cfg.iters;
const value = try gen.generate(rand, alloc, size);
@@ -114,11 +108,9 @@ fn shrinkFailing(
return current;
}
-/// A slice of integers — the workhorse generator. Map keys, set elements,
-/// vector contents and index sequences are all expressible as one.
+/// A slice of integers — the workhorse generator.
pub const IntSlice = struct {
- /// Values are drawn from `0..distinct`, so a small bound forces duplicate
- /// keys (which is where assoc / conj semantics actually differ).
+ /// Values are drawn from `0..distinct`; a small bound forces duplicates.
distinct: i64 = 8,
max_len: usize = 64,
@@ -130,8 +122,7 @@ pub const IntSlice = struct {
}
/// Two moves, cheapest first: drop a chunk, then reduce one element toward
- /// zero. Dropping first converges fastest — length is usually the part of a
- /// counterexample that carries no information.
+ /// zero.
pub fn shrink(self: IntSlice, alloc: std.mem.Allocator, v: []i64, out: *std.ArrayList([]i64)) !void {
_ = self;
if (v.len > 0) {
@@ -147,7 +138,7 @@ pub const IntSlice = struct {
const smaller = try alloc.dupe(i64, v);
smaller[i] = @divTrunc(x, 2);
try out.append(alloc, smaller);
- break; // one element per round keeps the candidate set small
+ break; // one element per round
}
}
@@ -189,10 +180,6 @@ test "forAll passes a law that holds for every generated value" {
}
test "forAll reports the shrunk counterexample, not the first one found" {
- // The law "no element is >= 4" fails on any slice containing 4..7. The
- // shrinker must reduce both length and magnitude, so what survives is a
- // short slice — proving shrinking ran rather than the raw generated value
- // being returned.
const gen = IntSlice{ .distinct = 8, .max_len = 64 };
const Ctx = struct {};
const prop = struct {
@@ -207,7 +194,6 @@ test "forAll reports the shrunk counterexample, not the first one found" {
}
test "the same seed produces the same values, a different seed does not" {
- // Reproducibility is the whole reason the failure message prints a seed.
const gen = IntSlice{ .distinct = 1000, .max_len = 32 };
var a = std.Random.DefaultPrng.init(42);
var b = std.Random.DefaultPrng.init(42);
diff --git a/src/testing/prop_collections.zig b/src/testing/prop_collections.zig
index a29965deb..6d2fc0c28 100644
--- a/src/testing/prop_collections.zig
+++ b/src/testing/prop_collections.zig
@@ -1,19 +1,12 @@
-//! Layer 7 (Property) — laws the persistent collections must obey for ANY
-//! sequence of operations, not for the sequences someone thought to write down.
-//! ADR-0186.
+//! Layer 7 (Property) — laws the persistent collections must obey for any
+//! sequence of operations. ADR-0186.
//!
-//! The oracle in every case is a naive model: a plain Zig array/list doing the
-//! obviously-correct thing. The persistent structure and the model are fed the
-//! same operations and must agree. That is what makes these tests able to fail
-//! for a reason nobody predicted — the model has no shared implementation with
-//! the thing it checks.
+//! Each property drives the persistent structure and a naive model (a plain
+//! Zig array) with the same operations and requires them to agree.
//!
-//! The representation boundaries are deliberately crossed. `map` promotes
-//! ArrayMap -> PersistentHashMap past 16 entries and `vector` spills its tail
-//! into a HAMT; a bug that lives only on one side of a boundary is invisible to
-//! a test whose fixtures all sit on the same side. Discussion #12 was exactly
-//! that bug: a set's backing map was decoded as an ArrayMap unconditionally, so
-//! it worked for 8 elements and segfaulted at 9.
+//! Representation boundaries the generator sizes straddle: ArrayMap ->
+//! PersistentHashMap at 16 entries, set at 8, vector tail at 32, trie level
+//! at 1057.
const std = @import("std");
const prop = @import("prop.zig");
@@ -33,8 +26,7 @@ fn config() prop.Config {
return .{ .seed = build_options.prop_seed, .iters = build_options.prop_iters };
}
-/// A runtime per property evaluation. Fresh state per case is what makes a
-/// failing case reproducible on its own rather than only in sequence.
+/// A fresh runtime per property evaluation.
const Fixture = struct {
fn run(comptime body: fn (*Runtime, []i64) anyerror!void, xs: []i64) anyerror!void {
var th = std.Io.Threaded.init(testing.allocator, .{});
@@ -79,8 +71,6 @@ test "property: a map agrees with a last-write-wins model on every key" {
defer model.deinit(testing.allocator);
var m = map_collection.empty();
- // Element i is the key; its value is i, so a repeated key really
- // does change value and last-write-wins is observable.
for (xs, 0..) |k, i| {
const v: i64 = @intCast(i);
m = try map_collection.assoc(rt, m, Value.initInteger(k), Value.initInteger(v));
@@ -104,8 +94,6 @@ test "property: a map agrees with a last-write-wins model on every key" {
}
test "property: a map's contents do not depend on the order the keys arrived" {
- // Same distinct keys, opposite insertion order, values keyed off the key
- // itself so last-write-wins cannot mask a difference.
const gen = prop.IntSlice{ .distinct = 40, .max_len = 48 };
const Ctx = struct {};
const body = struct {
@@ -141,8 +129,6 @@ test "property: dissoc undoes assoc, one key at a time, in any order" {
var m = map_collection.empty();
for (xs) |k| m = try map_collection.assoc(rt, m, Value.initInteger(k), Value.initInteger(k));
- // Remove in the reverse of the order they went in; each removal must
- // drop exactly the one key, never a neighbour.
var seen: std.ArrayList(i64) = .empty;
defer seen.deinit(testing.allocator);
var i = xs.len;
@@ -173,9 +159,6 @@ test "property: dissoc undoes assoc, one key at a time, in any order" {
}
test "property: every entry survives the ArrayMap -> PersistentHashMap promotion" {
- // The Discussion #12 boundary, stated as a law instead of as one example.
- // Keys are distinct by construction so the entry count is the insert count,
- // which is what puts the map deterministically on both sides of the ceiling.
const gen = prop.IntSlice{ .distinct = 64, .max_len = 40 };
const Ctx = struct {};
const body = struct {
@@ -189,10 +172,6 @@ test "property: every entry survives the ArrayMap -> PersistentHashMap promotion
m = try map_collection.assoc(rt, m, Value.initInteger(k), Value.initInteger(k * 7));
try inserted.append(testing.allocator, k);
- // Whatever representation it is in right now, every key put in
- // so far must still be there. Checking on EVERY insert is what
- // catches a promotion that loses an entry, rather than only
- // checking the final state.
try testing.expectEqual(@as(u32, @intCast(inserted.items.len)), map_collection.count(m));
for (inserted.items) |key| {
const got = try map_collection.get(m, Value.initInteger(key));
@@ -200,9 +179,8 @@ test "property: every entry survives the ArrayMap -> PersistentHashMap promotion
}
}
+ // The ArrayMap -> PersistentHashMap ceiling is 16 entries.
if (inserted.items.len > 16) {
- // The promotion is supposed to have happened; if the ceiling
- // moves, this line is the one that says so.
try testing.expect(m.tag() == .hash_map);
}
}
@@ -300,8 +278,6 @@ test "property: pop undoes conj all the way back to empty" {
var i = xs.len;
while (i > 0) {
i -= 1;
- // The last element is still readable right up to the moment it
- // is popped — this is where a tail/HAMT boundary error shows.
try testing.expectEqual(xs[i], @as(i64, vector_collection.nth(v, @intCast(i)).asInteger()));
v = try vector_collection.pop(rt, v);
try testing.expectEqual(@as(u32, @intCast(i)), vector_collection.count(v));
@@ -318,18 +294,8 @@ test "property: pop undoes conj all the way back to empty" {
}
test "property: pop collapses a DEEP trie correctly, level by level" {
- // The first mutation sweep (ADR-0186) killed the shallow cases and left
- // three survivors, all of them inside `popTail`'s recursive descent:
- // `arrayFor(old, old.count - 2)`, the `sub_index` shift, and the
- // `new_child == null and sub_index == 0` collapse test. None of them could
- // be reached, because the property above generates at most 96 elements and
- // `popTail` only recurses once a vector is deep enough to have a trie of
- // more than one level — tail (32) + one full level (32*32) = 1057 elements.
- //
- // So this property is here because a mutant said the other one was blind,
- // which is the entire reason Layer 8 exists. The sizes below straddle the
- // boundary rather than sitting past it: a bug in the collapse is a bug
- // about what happens AT the transition.
+ // popTail recurses only past one trie level: tail (32) + a full level
+ // (32*32) = 1057 elements. These sizes straddle that transition.
const deep_sizes = [_]u32{ 1024, 1056, 1057, 1088, 2048, 2080 };
var th = std.Io.Threaded.init(testing.allocator, .{});
defer th.deinit();
@@ -342,10 +308,6 @@ test "property: pop collapses a DEEP trie correctly, level by level" {
while (i < n) : (i += 1) v = try vector_collection.conj(&rt, v, Value.initInteger(@intCast(i)));
try testing.expectEqual(n, vector_collection.count(v));
- // Pop all the way back to empty. Every element still readable at every
- // depth, and the count exact after each step — a collapse that drops
- // the wrong subtree shows up as a wrong element long before it shows
- // up as a wrong count.
var remaining = n;
while (remaining > 0) {
remaining -= 1;
@@ -355,8 +317,6 @@ test "property: pop collapses a DEEP trie correctly, level by level" {
);
v = try vector_collection.pop(&rt, v);
try testing.expectEqual(remaining, vector_collection.count(v));
- // Spot-check the far end too: a collapse bug in a deep subtree
- // leaves index 0 intact while corrupting the middle.
if (remaining > 0) {
try testing.expectEqual(@as(i64, 0), @as(i64, vector_collection.nth(v, 0).asInteger()));
const mid = remaining / 2;
diff --git a/test/golden/run.sh b/test/golden/run.sh
index c92970746..c1e9e30e7 100755
--- a/test/golden/run.sh
+++ b/test/golden/run.sh
@@ -2,21 +2,11 @@
# test/golden/run.sh — Layer 6 (Golden snapshot), opened per ADR-0186.
#
# A golden case is a WHOLE PROGRAM plus everything the process produced for it:
-# stdout, stderr and exit code, in one file. That is the difference from Layer 2
-# (e2e), which asserts a chosen substring of a chosen stream: an e2e case can
-# only fail for the thing its author thought to assert, while a snapshot fails
-# for any change in what the user sees — a reworded error, a dropped newline, a
-# value that starts printing its metadata, a warning that appears on stderr.
+# stdout, stderr and exit code, in one file.
#
-# That sensitivity is the point and also the cost: a snapshot suite that is
-# tedious to update gets updated carelessly. So `--update` regenerates, and the
-# diff it produces is the review surface. A reviewer reads the diff, not the
-# file.
-#
-# Determinism is enforced by NORMALISATION, not by hoping: the repo path, hex
-# addresses, pids, durations and the version string are rewritten to fixed
-# tokens before comparison (see normalise()). A case that still varies run to
-# run does not belong in this layer.
+# Determinism comes from normalise(): the repo path, hex addresses, pids,
+# durations and the version string are rewritten to fixed tokens before
+# comparison. A case that still varies run to run does not belong in this layer.
#
# Usage:
# bash test/golden/run.sh # verify every case
@@ -25,14 +15,11 @@
# CLJW_SKIP_BUILD=1 bash test/golden/run.sh
#
# Adding a case: drop `cases/.clj` in, run with --update, READ the
-# generated `.expected`, commit both. A snapshot committed without being read
-# is a record of a bug as readily as of a behaviour.
+# generated `.expected`, commit both.
set -euo pipefail
cd "$(dirname "$0")/../.."
ROOT=$(pwd)
-# CLJW_BIN lets a caller point the suite at a binary built elsewhere — a clean
-# git worktree, a downloaded release artifact, a mutant build. Without it the
-# suite tests this checkout, which is what the gate wants.
+# CLJW_BIN points the suite at a binary built elsewhere; default is this checkout's.
BIN="${CLJW_BIN:-$ROOT/zig-out/bin/cljw}"
CASE_DIR="$ROOT/test/golden/cases"
@@ -51,8 +38,7 @@ done
[ -x "$BIN" ] || { echo "golden: no binary at $BIN" >&2; exit 1; }
# Rewrite everything that legitimately differs between two runs of the same
-# build, and between two machines. Anything not rewritten here is a promise
-# that it is stable.
+# build, and between two machines.
normalise() {
sed -e "s|$ROOT||g" \
-e 's|0x[0-9a-f]\{4,\}|0xADDR|g' \
@@ -62,8 +48,7 @@ normalise() {
-e 's|/tmp/[A-Za-z0-9_.-]\{6,\}||g'
}
-# One case = one file. The envelope is part of the snapshot so a stream moving
-# (a message migrating from stdout to stderr) is itself a diff.
+# One case = one file; the stream envelope is part of the snapshot.
capture() {
local clj="$1" out err rc
out=$("$BIN" "$clj" 2>/tmp/golden_err.$$ ) && rc=0 || rc=$?
From a26e32c2dfd52631da3e95fa71d39fbcc9de8d94 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 12:48:14 -0300
Subject: [PATCH 020/466] test(runner): one front door over a registry of
everything runnable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`docs/testing.md` claimed run_all.sh was the single entry point and that a
check not reachable from it was not part of "the suite is green". Measured:
23 of 38 check scripts were reachable, 15 were not, and layers 4, 5 and 8
plus the verified_projects loop were reachable only by knowing a private
script path. The claim was not false when written; it rotted, because
nothing could falsify it.
test/units.list is the inventory — 63 units as data, id|layer|gated|tags|
command. test/cljw-test selects from it (--list, --layer, --only, --tag,
--step, --all, --dry-run) and runs the selection; everything after `--` is
appended to the unit's command, so a unit gains options without this file
gaining a flag. check_runner_reach.sh fails on any runnable script with no
row, which is what turns the claim into a check.
run_all.sh is deliberately untouched. Its ADR-0024 dispatcher, ADR-0107
smoke tiering, D-385 resume ledger and parallel e2e pool are the engine,
and run_gate.sh already reaps competing gate trees; replacing that would
have cost more than the split cost. The composites delegate to them.
Three properties the registry carries that prose could not:
- an exclusion is a ROW (gated=no) with a reason in its tags, never an
absence. 12 currently read `todo` — check_adr_history among them, which
is unwired precisely because wiring it exposes 101 failing ADRs.
- `failfast` (verified_projects) stops the run at the first failing unit
instead of aggregating past it, preserving the one-blocker-at-a-time
loop that got malli green in nine iterations.
- every unit takes .dev/.build.lock, so two concurrent `zig build`s
cannot happen through this path. That hazard fired today.
Additive only: three new files, nothing existing modified. Wiring the reach
check into the gate and pointing CI at the CLI needs a parity run and comes
next. Verified without a build: every selection path via --dry-run, and
three real check units executed through the CLI with correct exit codes.
---
scripts/check_runner_reach.sh | 71 +++++++++++++
test/cljw-test | 195 ++++++++++++++++++++++++++++++++++
test/units.list | 103 ++++++++++++++++++
3 files changed, 369 insertions(+)
create mode 100755 scripts/check_runner_reach.sh
create mode 100755 test/cljw-test
create mode 100644 test/units.list
diff --git a/scripts/check_runner_reach.sh b/scripts/check_runner_reach.sh
new file mode 100755
index 000000000..80e214000
--- /dev/null
+++ b/scripts/check_runner_reach.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Every runnable script in the repository must have a row in test/units.list.
+#
+# Exits non-zero on: a script with no row, a row naming a script that does not
+# exist, or a duplicate id. A script that should not run in the suite gets a
+# row with gated=no — an exclusion is declared, never an absence.
+#
+# Usage: bash scripts/check_runner_reach.sh
+set -uo pipefail
+cd "$(dirname "$0")/.."
+REGISTRY="test/units.list"
+
+[ -f "$REGISTRY" ] || { echo "check_runner_reach: no $REGISTRY" >&2; exit 1; }
+
+rows=$(grep -vE '^\s*(#|$)' "$REGISTRY")
+ids=$(printf '%s\n' "$rows" | cut -d'|' -f1)
+cmds=$(printf '%s\n' "$rows" | cut -d'|' -f5-)
+
+rc=0
+
+dupes=$(printf '%s\n' "$ids" | sort | uniq -d)
+if [ -n "$dupes" ]; then
+ echo "check_runner_reach: duplicate ids:" >&2
+ printf ' %s\n' $dupes >&2
+ rc=1
+fi
+
+# Scripts a contributor could plausibly invoke. Libraries (sourced, never run)
+# and the registry's own front door are excluded by name.
+mapfile -t runnable < <(
+ ls scripts/check_*.sh scripts/*gate*.sh scripts/perf.sh scripts/verify_projects.sh \
+ scripts/mutation/run.sh scripts/zone_check.sh scripts/binary_size_report.sh \
+ bench/*.sh test/golden/run.sh test/clj/run_tier_a.sh 2>/dev/null | sort -u
+)
+
+missing=""
+for s in "${runnable[@]}"; do
+ printf '%s\n' "$cmds" | grep -qF -- "$s" || missing="$missing $s"
+done
+if [ -n "$missing" ]; then
+ echo "check_runner_reach: runnable but unregistered — add a row to $REGISTRY:" >&2
+ printf ' %s\n' $missing >&2
+ rc=1
+fi
+
+# A row must name a file that exists, or the registry is lying about coverage.
+gone=""
+while IFS= read -r c; do
+ for tok in $c; do
+ case "$tok" in
+ scripts/*|test/*|bench/*)
+ [ -e "$tok" ] || gone="$gone $tok" ;;
+ esac
+ done
+done <<< "$cmds"
+if [ -n "$gone" ]; then
+ echo "check_runner_reach: registry names files that do not exist:" >&2
+ printf ' %s\n' $gone >&2
+ rc=1
+fi
+
+todo=$(printf '%s\n' "$rows" | awk -F'|' '$4 ~ /todo/ {print " " $1 " (" $4 ")"}')
+if [ -n "$todo" ]; then
+ echo "check_runner_reach: $(printf '%s\n' "$todo" | wc -l | tr -d ' ') declared-but-unresolved exclusion(s):"
+ printf '%s\n' "$todo"
+fi
+
+if [ "$rc" -eq 0 ]; then
+ echo "check_runner_reach: $(printf '%s\n' "$ids" | wc -l | tr -d ' ') units, every runnable script registered"
+fi
+exit $rc
diff --git a/test/cljw-test b/test/cljw-test
new file mode 100755
index 000000000..edfec6518
--- /dev/null
+++ b/test/cljw-test
@@ -0,0 +1,195 @@
+#!/usr/bin/env bash
+# The one entry point for everything runnable in this repository.
+#
+# Reads test/units.list, selects a subset, runs it. Selection is computed
+# before anything executes, so --dry-run and --list answer the same question
+# the run would.
+#
+# Usage:
+# cljw-test # the gate
+# cljw-test --list # every unit, with layer and gated flag
+# cljw-test --layer 7 # one layer
+# cljw-test --only golden,tier_a # named units
+# cljw-test --tag timing # by tag
+# cljw-test --all # every gated unit
+# cljw-test --step e2e_phase4_cli # one of run_all.sh's 435 inner steps
+# cljw-test --only mutation -- --targets src/runtime/collection/vector.zig --budget 8
+#
+# Everything after `--` is appended to the selected unit's command, so a unit
+# gains options without this file gaining a flag.
+#
+# Exit code 0 iff every selected unit exited 0.
+set -uo pipefail
+cd "$(dirname "$0")/.."
+ROOT=$(pwd)
+REGISTRY="$ROOT/test/units.list"
+
+LIST=0
+DRY=0
+ALL=0
+SELECT_IDS=""
+SELECT_LAYERS=""
+SELECT_TAGS=""
+INNER_STEP=""
+PASSTHROUGH=()
+
+die() { echo "cljw-test: $*" >&2; exit 2; }
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --list) LIST=1 ;;
+ --dry-run) DRY=1 ;;
+ --all) ALL=1 ;;
+ --only) [ $# -ge 2 ] || die "--only needs a value"; SELECT_IDS="$2"; shift ;;
+ --layer) [ $# -ge 2 ] || die "--layer needs a value"; SELECT_LAYERS="$2"; shift ;;
+ --tag) [ $# -ge 2 ] || die "--tag needs a value"; SELECT_TAGS="$2"; shift ;;
+ --step) [ $# -ge 2 ] || die "--step needs a value"; INNER_STEP="$2"; shift ;;
+ --gate) SELECT_IDS="gate" ;;
+ --smoke) SELECT_IDS="smoke" ;;
+ -h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ --) shift; PASSTHROUGH=("$@"); break ;;
+ *) die "unknown flag: $1 (try --help)" ;;
+ esac
+ shift
+done
+
+[ -f "$REGISTRY" ] || die "no registry at $REGISTRY"
+
+# --- selection (pure: reads the registry, writes nothing, runs nothing) -----
+
+in_csv() { case ",$2," in *",$1,"*) return 0 ;; *) return 1 ;; esac; }
+
+# A unit's layer field is a comma list; it matches if any of its layers is asked for.
+layer_matches() {
+ local have="$1" want="$2" l
+ IFS=',' read -ra _have <<< "$have"
+ for l in "${_have[@]}"; do in_csv "$l" "$want" && return 0; done
+ return 1
+}
+
+tag_matches() {
+ local have="$1" want="$2" t
+ [ -n "$have" ] || return 1
+ IFS=',' read -ra _want <<< "$want"
+ for t in "${_want[@]}"; do in_csv "$t" "$have" && return 0; done
+ return 1
+}
+
+declare -a SEL_ID=() SEL_LAYER=() SEL_GATED=() SEL_TAGS=() SEL_CMD=()
+declare -a ALL_ID=() ALL_LAYER=() ALL_GATED=() ALL_TAGS=() ALL_CMD=()
+
+while IFS='|' read -r id layer gated tags cmd; do
+ case "$id" in ''|\#*) continue ;; esac
+ [ -n "${cmd:-}" ] || die "registry row '$id' has no command"
+ ALL_ID+=("$id"); ALL_LAYER+=("$layer"); ALL_GATED+=("$gated")
+ ALL_TAGS+=("$tags"); ALL_CMD+=("$cmd")
+
+ keep=0
+ if [ -n "$SELECT_IDS" ]; then
+ in_csv "$id" "$SELECT_IDS" && keep=1
+ elif [ -n "$SELECT_LAYERS" ]; then
+ layer_matches "$layer" "$SELECT_LAYERS" && keep=1
+ elif [ -n "$SELECT_TAGS" ]; then
+ tag_matches "$tags" "$SELECT_TAGS" && keep=1
+ elif [ "$ALL" = 1 ]; then
+ [ "$gated" = yes ] && keep=1
+ fi
+ # A composite already contains the units it delegates to; selecting by
+ # layer or tag would run them twice.
+ if [ "$keep" = 1 ] && [ -z "$SELECT_IDS" ] && tag_matches "composite" "$tags"; then
+ keep=0
+ fi
+
+ if [ "$keep" = 1 ]; then
+ SEL_ID+=("$id"); SEL_LAYER+=("$layer"); SEL_GATED+=("$gated")
+ SEL_TAGS+=("$tags"); SEL_CMD+=("$cmd")
+ fi
+done < "$REGISTRY"
+
+if [ "$LIST" = 1 ]; then
+ printf '%-28s %-10s %-6s %s\n' UNIT LAYER GATED TAGS
+ for i in "${!ALL_ID[@]}"; do
+ printf '%-28s %-10s %-6s %s\n' \
+ "${ALL_ID[$i]}" "${ALL_LAYER[$i]}" "${ALL_GATED[$i]}" "${ALL_TAGS[$i]}"
+ done
+ echo
+ echo "${#ALL_ID[@]} units. Inner gate steps: bash test/run_all.sh --list"
+ exit 0
+fi
+
+if [ -n "$INNER_STEP" ]; then
+ SEL_ID=("step:$INNER_STEP")
+ SEL_CMD=("bash test/run_all.sh --only $INNER_STEP")
+ SEL_TAGS=("inner")
+fi
+
+if [ "${#SEL_ID[@]}" -eq 0 ]; then
+ if [ -n "$SELECT_IDS$SELECT_LAYERS$SELECT_TAGS" ] || [ "$ALL" = 1 ]; then
+ die "selection matched no unit (try --list)"
+ fi
+ SEL_ID=("gate"); SEL_TAGS=("composite")
+ SEL_CMD=("bash scripts/run_gate.sh")
+fi
+
+if [ "$DRY" = 1 ]; then
+ for i in "${!SEL_ID[@]}"; do
+ printf '%s\n' "${SEL_ID[$i]}: ${SEL_CMD[$i]}${PASSTHROUGH[*]:+ ${PASSTHROUGH[*]}}"
+ done
+ exit 0
+fi
+
+# --- execution (the only effectful part) ------------------------------------
+
+# One build at a time on this machine. Two concurrent `zig build`s drove load
+# average to ~34 and killed a terminal on 2026-08-12.
+LOCK="$ROOT/.dev/.build.lock"
+lock_held=0
+acquire_lock() {
+ mkdir -p "$ROOT/.dev"
+ local waited=0
+ while ! mkdir "$LOCK" 2>/dev/null; do
+ if [ "$waited" = 0 ]; then
+ echo "cljw-test: another build holds $LOCK — waiting" >&2
+ fi
+ waited=$((waited + 5))
+ [ "$waited" -le 1800 ] || die "timed out waiting for $LOCK (stale? rmdir it)"
+ sleep 5
+ done
+ lock_held=1
+}
+release_lock() { [ "$lock_held" = 1 ] && rmdir "$LOCK" 2>/dev/null; lock_held=0; }
+trap release_lock EXIT INT TERM
+
+passed=0; failed=0
+declare -a FAILED_IDS=()
+
+for i in "${!SEL_ID[@]}"; do
+ id="${SEL_ID[$i]}"
+ cmd="${SEL_CMD[$i]}"
+ tags="${SEL_TAGS[$i]:-}"
+ [ "${#PASSTHROUGH[@]}" -eq 0 ] || cmd="$cmd ${PASSTHROUGH[*]}"
+
+ tag_matches "timing" "$tags" && echo "cljw-test: $id is a timing measurement — run it on a quiet machine"
+
+ echo "==> $id"
+ acquire_lock
+ start=$(date +%s)
+ if eval "$cmd"; then
+ rc=0; passed=$((passed + 1))
+ else
+ rc=$?; failed=$((failed + 1)); FAILED_IDS+=("$id")
+ fi
+ release_lock
+ echo " [$([ $rc -eq 0 ] && echo pass || echo FAIL)] $id ($(( $(date +%s) - start ))s)"
+
+ # A fail-fast unit reports the first blocking site and stops; aggregating
+ # past it destroys the one-fix-one-answer loop it exists for.
+ if [ $rc -ne 0 ] && tag_matches "failfast" "$tags"; then
+ echo "cljw-test: $id failed fast — stopping" >&2
+ break
+ fi
+done
+
+echo
+echo "cljw-test: $passed passed, $failed failed"
+[ "$failed" -eq 0 ] || { echo "cljw-test: failed:${FAILED_IDS[*]/#/ }" >&2; exit 1; }
diff --git a/test/units.list b/test/units.list
new file mode 100644
index 000000000..8138de9c0
--- /dev/null
+++ b/test/units.list
@@ -0,0 +1,103 @@
+# The registry of everything runnable in this repository.
+#
+# Format, pipe-delimited: id|layer|gated|tags|command
+# id unique selector for `cljw-test --only`
+# layer ADR-0021 / ADR-0186 layer number, or `-` for a check with no layer
+# gated yes = part of "the suite is green"
+# no = deliberately outside the gate; the tags say why
+# tags comma-separated; `network`, `timing`, `measurement`, `slow`,
+# `failfast`, `config` are meaningful to the runner
+# command run from the repository root
+#
+# A runnable script with no row here FAILS scripts/check_runner_reach.sh.
+# Opting out is a row with gated=no, never an absence.
+
+# --- composite entry points -------------------------------------------------
+gate|1,2,3,5,6,7|yes|composite|bash scripts/run_gate.sh
+smoke|1,3,7|yes|composite,fast|bash test/run_all.sh --smoke
+ci|1,2,3,5,6,7|yes|composite,config|bash scripts/ci_gate.sh
+
+# --- layer 1 / 3 / 7: the compiled suite ------------------------------------
+unit_vm|1,3,7|yes|fast|zig build test -Dwasm -Doptimize=ReleaseSafe
+unit_tree_walk|1,3,7|yes|fast|zig build test -Dwasm -Dbackend=tree_walk -Doptimize=ReleaseSafe
+prop_engine|7|yes|fast|zig test src/testing/prop.zig
+lint|-|yes|fast|zig build lint -Dwasm -- --max-warnings 0
+fmt|-|yes|fast|zig fmt --check src/
+
+# --- layer 2: e2e -----------------------------------------------------------
+# The ~400 e2e steps run inside `gate`. One of them: cljw-test --step .
+wasm_gate|2|no|config|bash scripts/run_wasm_gate.sh
+
+# --- layer 5: conformance ---------------------------------------------------
+tier_a|5|yes|slow|bash test/clj/run_tier_a.sh
+verify_projects|5|no|network,failfast|bash scripts/verify_projects.sh
+
+# --- layer 6: golden snapshots ----------------------------------------------
+golden|6|yes||bash test/golden/run.sh
+
+# --- layer 8: mutation — a measurement, never a gate ------------------------
+mutation|8|no|measurement,slow|bash scripts/mutation/run.sh
+
+# --- layer 4: bench — timing, never a gate ----------------------------------
+bench_langs|4|no|timing|bash bench/compare_langs.sh
+bench_build|4|no|timing|bash bench/build_bench.sh
+bench_run|4|no|timing|bash bench/run_bench.sh
+bench_wasm|4|no|timing|bash bench/wasm_bench.sh
+bench_wasm_jit|4|no|timing|bash bench/wasm_jit_vs_interp.sh
+bench_release_metrics|4|no|timing|bash bench/release_metrics.sh
+perf|4|no|timing|bash scripts/perf.sh
+
+# --- checks reachable from the gate -----------------------------------------
+# These already run inside test/run_all.sh; the rows exist so the registry is
+# the complete inventory and check_runner_reach.sh has nothing to report.
+check_accepted_divergences|-|yes|via-gate|bash scripts/check_accepted_divergences.sh --gate
+check_capability_claims|-|yes|via-gate|bash scripts/check_capability_claims.sh
+check_clj_attribution|-|yes|via-gate|bash scripts/check_clj_attribution.sh --gate
+check_compat_members|-|yes|via-gate|bash scripts/check_compat_members.sh
+check_core_surface|-|yes|via-gate|bash scripts/check_core_surface.sh
+check_corpus_regression|-|yes|via-gate|bash scripts/check_corpus_regression.sh
+check_debt_id_refs|-|yes|via-gate|bash scripts/check_debt_id_refs.sh --gate
+check_doc_coverage|-|yes|via-gate|bash scripts/check_doc_coverage.sh
+check_e2e_dup|-|yes|via-gate|bash scripts/check_e2e_dup.sh
+check_e2e_reach|-|yes|via-gate|bash scripts/check_e2e_reach.sh --gate
+check_entrypoint_surface|-|yes|via-gate|bash scripts/check_entrypoint_surface.sh
+check_epipe_head|-|yes|via-gate|bash scripts/check_epipe_head.sh
+check_feature_keyword|-|yes|via-gate|bash scripts/check_feature_keyword.sh --gate
+check_gate_parity|-|yes|via-gate|bash scripts/check_gate_parity.sh
+check_host_interface|-|yes|via-gate|bash scripts/check_host_interface.sh --gate
+check_lazy_ns_replay|-|yes|via-gate|bash scripts/check_lazy_ns_replay.sh
+check_placement_status|-|yes|via-gate|bash scripts/check_placement_status.sh
+check_portable_timeout|-|yes|via-gate|bash scripts/check_portable_timeout.sh
+check_reference_clones|-|yes|via-gate|bash scripts/check_reference_clones.sh
+check_repr_decode|-|yes|via-gate|bash scripts/check_repr_decode.sh --gate
+check_surface_marker|-|yes|via-gate|bash scripts/check_surface_marker.sh --gate
+check_test_reach|-|yes|via-gate|bash scripts/check_test_reach.sh --gate
+zone_check|-|yes|via-gate|bash scripts/zone_check.sh --gate
+binary_size_report|-|yes|via-gate|bash scripts/binary_size_report.sh --check zig-out/bin/cljw
+
+# --- checks NOT reachable from the gate -------------------------------------
+# Every row below is a declared exclusion. `todo:` in the tags means the
+# exclusion is unresolved and owns a kanban entry, not that it is acceptable.
+check_adr_history|-|no|todo:CLJW-ADRGATE|bash scripts/check_adr_history.sh
+check_compat_tiers_sync|-|no|todo|bash scripts/check_compat_tiers_sync.sh
+check_dual_backend_parity|-|no|todo|bash scripts/check_dual_backend_parity.sh
+check_facts_immutable|-|no|todo|bash scripts/check_facts_immutable.sh
+check_learning_doc|-|no|todo|bash scripts/check_learning_doc.sh
+check_md_tables|-|no|todo|bash scripts/check_md_tables.sh
+check_module_docstring|-|no|todo|bash scripts/check_module_docstring.sh
+check_no_op_stub|-|no|todo|bash scripts/check_no_op_stub.sh
+check_provisional_sync|-|no|todo|bash scripts/check_provisional_sync.sh
+check_smell_audit|-|no|todo|bash scripts/check_smell_audit.sh
+check_tier_d_error_msg|-|no|todo|bash scripts/check_tier_d_error_msg.sh
+check_vm_parity|-|no|todo|bash scripts/check_vm_parity.sh
+# Authoring-time checks: they inspect a commit or a document being written, so
+# they have no meaning in a batch run of the suite.
+check_handover_framing|-|no|authoring|bash scripts/check_handover_framing.sh
+check_roadmap_amendment|-|no|authoring|bash scripts/check_roadmap_amendment.sh
+check_stale_git_lock|-|no|authoring|bash scripts/check_stale_git_lock.sh
+check_gate_cadence|-|no|authoring|bash scripts/check_gate_cadence.sh
+check_runner_reach|-|yes|meta|bash scripts/check_runner_reach.sh
+# Not test units: a fingerprint helper the resume ledger consumes, and a
+# PostToolUse hook. Registered so the inventory is complete.
+gate_state_hash|-|no|internal|bash scripts/gate_state_hash.sh
+gate_continue_remind|-|no|hook|bash scripts/gate_continue_remind.sh
From e2d9b3e395395aedb8d179cf5e2da50ad1f89edb Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 12:53:01 -0300
Subject: [PATCH 021/466] docs(testing): the entry point is a command, not a
claim
This document asserted that run_all.sh was the single entry point and that
an unreachable check was not part of "the suite is green". Measured while
writing cljw-test: 15 of 38 check scripts were unreachable, and nothing in
the repository could say so. The assertion is now enforced by
check_runner_reach.sh instead of being repeated here.
Every layer's "run with" is the same command with a different selector, and
the pass-through form covers the per-layer options (mutation targets,
property seeds) that used to need their own invocation to remember.
States the build lock honestly: it serialises builds that go through the
CLI, and a hand-run `zig build` ignores it. Recording it as a stronger
guarantee than it is would recreate exactly the kind of unfalsifiable claim
this change exists to remove.
---
docs/testing.md | 73 ++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 57 insertions(+), 16 deletions(-)
diff --git a/docs/testing.md b/docs/testing.md
index 57c936922..9076e307b 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -4,21 +4,56 @@ Everything you need to run the suite, read a failure, and put a new test in the
right place. If you are here to send a patch, the short version is:
```sh
-zig build -Dwasm -Doptimize=ReleaseSafe # build the binary the tests use
-bash test/run_all.sh --serial-e2e # the full gate; must be green before a change lands
+bash test/cljw-test # the gate; must be green before a change lands
```
That takes roughly 20 minutes. While iterating, use the smoke tier instead
-(tens of seconds) and run the full gate once before you open the PR:
+(tens of seconds) and run the gate once before you open the PR:
```sh
-bash test/run_all.sh --smoke # unit + diff oracle + lint + build + one e2e step
-bash test/run_all.sh --list # every step name
-bash test/run_all.sh --only golden # just one
+bash test/cljw-test --only smoke # unit + diff oracle + lint + build
+bash test/cljw-test --list # every runnable unit, with its layer
+bash test/cljw-test --layer 6 # everything in one layer
+bash test/cljw-test --only golden,tier_a # named units
+bash test/cljw-test --step e2e_phase4_cli # one inner gate step
+bash test/cljw-test --dry-run # print the selection, run nothing
```
-`test/run_all.sh` is the single entry point. If a check is not reachable from
-it, it is not part of "the suite is green".
+Anything after `--` is appended to the selected unit's command, so a unit takes
+its own options without the CLI growing a flag for each:
+
+```sh
+bash test/cljw-test --only mutation -- --targets src/runtime/collection/vector.zig --budget 8
+bash test/cljw-test --layer 7 -- -Dprop-seed=0xdecafbad -Dprop-iters=5000
+```
+
+## One entry point, and how that is enforced
+
+`test/cljw-test` is the entry point. `test/units.list` is the inventory behind
+it — one row per runnable unit, `id|layer|gated|tags|command` — and
+`scripts/check_runner_reach.sh` fails when a runnable script has no row.
+
+That check is the whole point. This document used to claim single-entry status
+for `test/run_all.sh`; at the time of writing 15 of 38 check scripts were not
+reachable from it, and nothing could tell you so. A claim about coverage that
+no check can falsify decays into decoration.
+
+A script that should not run in the suite gets a row with `gated=no` and a tag
+saying why — `timing` for benches, `measurement` for mutation, `network` for
+the verified-projects loop, `authoring` for checks that inspect a commit being
+written. An exclusion is declared, never an absence. Rows tagged `todo` are
+exclusions nobody has resolved yet; that count is meant to reach zero.
+
+The composites (`gate`, `smoke`, `ci`) delegate to `test/run_all.sh` and
+`scripts/run_gate.sh`, which still own step dispatch, the resume ledger and the
+parallel e2e pool. Those remain callable directly; the CLI is the front door,
+not a replacement.
+
+**The build lock is not a guarantee.** Every unit run through `cljw-test` takes
+`.dev/.build.lock`, so two builds cannot overlap *through the CLI*. A `zig
+build` you run by hand ignores it. Two concurrent builds are enough to make a
+machine unusable, so if you are working alongside someone else in the same
+checkout, go through the CLI or coordinate by hand.
## The eight layers
@@ -27,14 +62,20 @@ decision — not an afterthought. Layers 1-5 are ADR-0021; 6-8 are ADR-0186.
| # | Layer | Lives in | Run with | In the gate? |
|---|---|---|---|---|
-| 1 | Unit | `src/**/*.zig`, inline `test "..."` blocks | `zig build test -Dwasm` | every commit |
-| 2 | E2E (CLI) | `test/e2e/*.sh` | `bash test/e2e/.sh` | every commit |
-| 3 | Differential | `test/diff/` | `zig build test` (both backends) | every commit |
-| 4 | Bench | `bench/` | `bash bench/compare_langs.sh` | no — on demand |
-| 5 | Conformance | `test/clj/` | `bash test/clj/run_tier_a.sh` | every commit |
-| 6 | Golden | `test/golden/cases/*.clj` + `.expected` | `bash test/golden/run.sh` | every commit |
-| 7 | Property | `src/testing/prop_*.zig` | `zig build test -Dwasm` | every commit |
-| 8 | Mutation | `scripts/mutation/` | `bash scripts/mutation/run.sh` | **no** — a measurement |
+| 1 | Unit | `src/**/*.zig`, inline `test "..."` blocks | `cljw-test --only unit_vm` | every commit |
+| 2 | E2E (CLI) | `test/e2e/*.sh` | `cljw-test --step ` | every commit |
+| 3 | Differential | `test/diff/` | `cljw-test --layer 3` (both backends) | every commit |
+| 4 | Bench | `bench/` | `cljw-test --tag timing` | no — on demand |
+| 5 | Conformance | `test/clj/` | `cljw-test --only tier_a` | every commit |
+| 6 | Golden | `test/golden/cases/*.clj` + `.expected` | `cljw-test --layer 6` | every commit |
+| 7 | Property | `src/testing/prop_*.zig` | `cljw-test --layer 7` | every commit |
+| 8 | Mutation | `scripts/mutation/` | `cljw-test --only mutation` | **no** — a measurement |
+
+Conformance against real libraries (`scripts/verify_projects.sh`, 21 libraries
+including malli) is `cljw-test --only verify_projects`. It is outside the gate:
+it clones over the network, and it stops at the FIRST blocking site with a
+`file:line` so each fix reveals exactly one more. That one-answer-per-run
+property is why it is tagged `failfast` rather than aggregated with the rest.
### Which layer does my test go in?
From 04ce0b2a70fb867b3f5e44a7c164462b1e98bc53 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 14:12:02 -0300
Subject: [PATCH 022/466] test(golden): pin the printer surfaces a
stratification split would cross
Five Layer 6 cases over the branches print.zig has and the existing three
did not reach: float formatting including the exponent boundary and the
non-finite forms, BigInt/BigDecimal/Ratio, the *print-* control vars with
truncation, the structures with a dedicated print branch (queue, sorted,
record, ex-info, var, namespace), and the readable character names with
string escaping.
Snapshots recorded from a build at this revision in a detached worktree,
not from the working tree, and read before being committed.
Also ignore .dev/wt-*/ so a scratch worktree can live beside the artifacts
it relates to.
---
.gitignore | 3 +++
test/golden/cases/printer_bignum.clj | 7 +++++++
test/golden/cases/printer_bignum.expected | 10 ++++++++++
test/golden/cases/printer_chars_strings.clj | 8 ++++++++
test/golden/cases/printer_chars_strings.expected | 10 ++++++++++
test/golden/cases/printer_float_edges.clj | 8 ++++++++
test/golden/cases/printer_float_edges.expected | 10 ++++++++++
test/golden/cases/printer_limits.clj | 11 +++++++++++
test/golden/cases/printer_limits.expected | 16 ++++++++++++++++
test/golden/cases/printer_structures.clj | 8 ++++++++
test/golden/cases/printer_structures.expected | 10 ++++++++++
11 files changed, 101 insertions(+)
create mode 100644 test/golden/cases/printer_bignum.clj
create mode 100644 test/golden/cases/printer_bignum.expected
create mode 100644 test/golden/cases/printer_chars_strings.clj
create mode 100644 test/golden/cases/printer_chars_strings.expected
create mode 100644 test/golden/cases/printer_float_edges.clj
create mode 100644 test/golden/cases/printer_float_edges.expected
create mode 100644 test/golden/cases/printer_limits.clj
create mode 100644 test/golden/cases/printer_limits.expected
create mode 100644 test/golden/cases/printer_structures.clj
create mode 100644 test/golden/cases/printer_structures.expected
diff --git a/.gitignore b/.gitignore
index 3ef3e9d64..5661500f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ __pycache__/
# build logs, the JSONL verdict log. A sweep REPORT worth keeping is committed
# deliberately (git add -f .dev/mutation/report-*.md), never by accident.
.dev/mutation/
+
+# Throwaway detached worktrees for scratch work (git worktree add --detach .dev/wt-)
+.dev/wt-*/
diff --git a/test/golden/cases/printer_bignum.clj b/test/golden/cases/printer_bignum.clj
new file mode 100644
index 000000000..56db5e19c
--- /dev/null
+++ b/test/golden/cases/printer_bignum.clj
@@ -0,0 +1,7 @@
+;; Arbitrary-precision and exact-fraction rendering: BigInt, BigDecimal, Ratio.
+(prn 1N 0N -1N 170141183460469231731687303715884105728N)
+(prn (* 1000000000000000000N 1000000000000000000N))
+(prn 1M 1.0M 0.10M -2.50M)
+(prn (+ 0.1M 0.2M) (* 1.5M 2M) (bigdec 1) (bigint 1))
+(prn 1/2 -1/2 3/4 (/ 6 4) (/ 1 3))
+(prn (str 1M) (str 1/2) (str 10N) (pr-str 0.10M))
diff --git a/test/golden/cases/printer_bignum.expected b/test/golden/cases/printer_bignum.expected
new file mode 100644
index 000000000..5926a2b67
--- /dev/null
+++ b/test/golden/cases/printer_bignum.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+1N 0N -1N 170141183460469231731687303715884105728N
+1000000000000000000000000000000000000N
+1M 1.0M 0.10M -2.50M
+0.3M 3.0M 1M 1N
+1/2 -1/2 3/4 3/2 1/3
+"1" "1/2" "10" "0.10M"
+--- stderr ---
diff --git a/test/golden/cases/printer_chars_strings.clj b/test/golden/cases/printer_chars_strings.clj
new file mode 100644
index 000000000..80e933cd1
--- /dev/null
+++ b/test/golden/cases/printer_chars_strings.clj
@@ -0,0 +1,8 @@
+;; Character and string rendering: the readable char names, escape sequences,
+;; and non-ASCII passthrough.
+(prn \a \A \0 \space \newline \tab \return \backspace \formfeed)
+(prn (seq "a\nb\tc"))
+(prn "tab\there" "nl\nhere" "quote\"here" "back\\slash")
+(prn "ünïcødé" "日本語")
+(pr "a\nb") (println)
+(prn (str \a \newline) (char 65) (char 955) (int \A))
diff --git a/test/golden/cases/printer_chars_strings.expected b/test/golden/cases/printer_chars_strings.expected
new file mode 100644
index 000000000..ebf525ab5
--- /dev/null
+++ b/test/golden/cases/printer_chars_strings.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+\a \A \0 \space \newline \tab \return \backspace \formfeed
+(\a \newline \b \tab \c)
+"tab\there" "nl\nhere" "quote\"here" "back\\slash"
+"ünïcødé" "日本語"
+"a\nb"
+"a\n" \A \λ 65
+--- stderr ---
diff --git a/test/golden/cases/printer_float_edges.clj b/test/golden/cases/printer_float_edges.clj
new file mode 100644
index 000000000..ef130fb1b
--- /dev/null
+++ b/test/golden/cases/printer_float_edges.clj
@@ -0,0 +1,8 @@
+;; Float rendering: the one-fractional-digit rule, the exponent boundary, and
+;; the non-finite forms.
+(prn 0.0 -0.0 1.0 -1.0 0.5 -0.5)
+(prn 100.0 1000000.0 1.0E7 1.0E-3 1.0E-5)
+(prn 1.0E20 1.0E-20 3.141592653589793 2.718281828459045)
+(prn (/ 1.0 0.0) (/ -1.0 0.0) (- (/ 1.0 0.0) (/ 1.0 0.0)))
+(prn Double/MAX_VALUE Double/MIN_VALUE)
+(prn (str 1.0) (str 1.0E20) (pr-str 0.5) (str (/ 1.0 0.0)))
diff --git a/test/golden/cases/printer_float_edges.expected b/test/golden/cases/printer_float_edges.expected
new file mode 100644
index 000000000..c7f6bceab
--- /dev/null
+++ b/test/golden/cases/printer_float_edges.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+0.0 -0.0 1.0 -1.0 0.5 -0.5
+100.0 1000000.0 1.0E7 0.001 1.0E-5
+1.0E20 1.0E-20 3.141592653589793 2.718281828459045
+##Inf ##-Inf ##NaN
+1.7976931348623157E308 5.0E-324
+"1.0" "1.0E20" "0.5" "Infinity"
+--- stderr ---
diff --git a/test/golden/cases/printer_limits.clj b/test/golden/cases/printer_limits.clj
new file mode 100644
index 000000000..702beef4c
--- /dev/null
+++ b/test/golden/cases/printer_limits.clj
@@ -0,0 +1,11 @@
+;; The *print-* control vars, including truncation.
+(binding [*print-length* 3]
+ (prn (range 10)) (prn [1 2 3 4 5]) (prn {:a 1 :b 2 :c 3 :d 4}) (prn #{1 2 3 4 5}))
+(binding [*print-level* 2]
+ (prn [1 [2 [3 [4]]]]) (prn {:a {:b {:c 1}}}))
+(binding [*print-length* 2 *print-level* 1]
+ (prn [[1 2 3] [4 5 6]]))
+(binding [*print-readably* nil] (pr "a\nb") (println))
+(prn (binding [*print-namespace-maps* true] (pr-str {:a/x 1 :a/y 2})))
+(prn (binding [*print-namespace-maps* false] (pr-str {:a/x 1 :a/y 2})))
+(binding [*print-meta* true] (prn (with-meta [1] {:k :v})))
diff --git a/test/golden/cases/printer_limits.expected b/test/golden/cases/printer_limits.expected
new file mode 100644
index 000000000..bccbadd0c
--- /dev/null
+++ b/test/golden/cases/printer_limits.expected
@@ -0,0 +1,16 @@
+--- exit ---
+0
+--- stdout ---
+(0 1 2 ...)
+[1 2 3 ...]
+{:a 1, :b 2, :c 3, ...}
+#{1 2 3 ...}
+[1 [2 #]]
+{:a {:b #}}
+[# #]
+a
+b
+"#:a{:x 1, :y 2}"
+"{:a/x 1, :a/y 2}"
+^{:k :v} [1]
+--- stderr ---
diff --git a/test/golden/cases/printer_structures.clj b/test/golden/cases/printer_structures.clj
new file mode 100644
index 000000000..647e4d7e0
--- /dev/null
+++ b/test/golden/cases/printer_structures.clj
@@ -0,0 +1,8 @@
+;; Values with a dedicated print branch beyond the core collections.
+(prn (conj clojure.lang.PersistentQueue/EMPTY 1 2 3))
+(prn (sorted-map :b 2 :a 1) (sorted-set 3 1 2) (sorted-map-by > 1 :a 2 :b))
+(prn (range 3) (range 0 10 2) (take 3 (iterate inc 0)))
+(defrecord R [a b])
+(prn (->R 1 2) (map->R {:a 1 :b 2}) (assoc (->R 1 2) :c 3))
+(prn (ex-info "msg" {:k 1}))
+(prn #'prn (find-ns 'clojure.core))
diff --git a/test/golden/cases/printer_structures.expected b/test/golden/cases/printer_structures.expected
new file mode 100644
index 000000000..b710eb6ff
--- /dev/null
+++ b/test/golden/cases/printer_structures.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+#queue (1 2 3)
+{:a 1, :b 2} #{1 2 3} {2 :b, 1 :a}
+(0 1 2) (0 2 4 6 8) (0 1 2)
+#user.R{:a 1, :b 2} #user.R{:a 1, :b 2} #user.R{:a 1, :b 2, :c 3}
+#error{:message "msg" :data {:k 1}}
+#'clojure.core/prn #object[Namespace "clojure.core"]
+--- stderr ---
From ac3a7ff0d56ad2e9962e605ec2d22ab1a1900ddd Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 14:15:13 -0300
Subject: [PATCH 023/466] test(mutation): the kill oracle is a parameter, and
printing needs golden in it
The runner hardcoded `zig build test`, so a Layer 6 golden case could never
kill a mutant. For a file whose behaviour IS what the program prints that
makes the score unreadable: every rendering path scores as unconstrained no
matter how many snapshots pin it.
`--oracle unit` keeps today's suite and stays the default. `--oracle
unit+golden` also builds the binary and renders the golden cases. One
`run_oracle` definition serves both the baseline and every mutant, because a
baseline measured by a different suite than the mutants reports everything as
killed.
The oracle is part of a run's identity, so it is in the report filename; an
unknown name is rejected at parse time, where the message is not inside the
redirected baseline log.
---
scripts/mutation/run.sh | 62 +++++++++++++++++++++++++++++++++++------
1 file changed, 53 insertions(+), 9 deletions(-)
diff --git a/scripts/mutation/run.sh b/scripts/mutation/run.sh
index b23744030..c3ee2c3a8 100755
--- a/scripts/mutation/run.sh
+++ b/scripts/mutation/run.sh
@@ -1,9 +1,16 @@
#!/usr/bin/env bash
# scripts/mutation/run.sh — Layer 8 (Mutation) sweep. ADR-0186.
#
-# Changes one line of one source file, rebuilds, runs the unit suite, and records
+# Changes one line of one source file, rebuilds, runs a suite, and records
# whether anything failed. A mutant the suite still passes is a SURVIVOR: that
-# line's behaviour is unconstrained by any test.
+# line's behaviour is unconstrained by that suite.
+#
+# WHICH suite is `--oracle`, and the score means nothing without it:
+# unit (default) inline Zig tests + Layer 7 properties.
+# unit+golden also renders the Layer 6 cases. Required for any file whose
+# behaviour is what the program PRINTS — under `unit` a golden
+# case cannot kill a mutant, so a printing path scores as
+# unconstrained no matter how many snapshots pin it.
#
# On demand only, never in a gate. One rebuild per mutant, so `--budget` is the
# number of rebuilds bought.
@@ -16,6 +23,7 @@
# bash scripts/mutation/run.sh --targets a.zig,b.zig --budget 20 --seed 7
# bash scripts/mutation/run.sh --targets-from .dev/mutation_targets.txt --rev v1.10.1
# bash scripts/mutation/run.sh --targets v.zig --ids 1a2b3c,4d5e6f # re-run named mutants
+# bash scripts/mutation/run.sh --targets src/runtime/print.zig --oracle unit+golden
#
# Output: a JSONL log plus a markdown summary under .dev/mutation/ (gitignored).
set -euo pipefail
@@ -33,6 +41,10 @@ EQUIV_FILE="$REPO_ROOT/.dev/mutation_equivalent.jsonl"
BUILD_ARGS="-Dwasm -Doptimize=Debug"
# A timeout counts as KILLED.
PER_MUTANT_TIMEOUT=900
+# Which suite decides "killed". `unit` is the inline Zig tests + properties;
+# `unit+golden` also renders the Layer 6 cases, so a mutation in a printing
+# path is reachable. See `run_oracle`.
+ORACLE="unit"
while [ $# -gt 0 ]; do
case "$1" in
@@ -46,6 +58,7 @@ while [ $# -gt 0 ]; do
--equivalent) EQUIV_FILE="$2"; shift ;;
--build-args) BUILD_ARGS="$2"; shift ;;
--timeout) PER_MUTANT_TIMEOUT="$2"; shift ;;
+ --oracle) ORACLE="$2"; shift ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
shift
@@ -56,6 +69,13 @@ if [ -n "$TARGETS_FILE" ]; then
fi
[ -n "$TARGETS" ] || { echo "nothing to mutate: pass --targets or --targets-from" >&2; exit 2; }
+# Validated here, not in run_oracle: there the message would land in the
+# redirected baseline log and a typo would read as a red baseline.
+case "$ORACLE" in
+ unit|unit+golden) ;;
+ *) echo "unknown --oracle: $ORACLE (unit | unit+golden)" >&2; exit 2 ;;
+esac
+
# Portable bounded run (check_portable_timeout.sh: no bare `timeout`).
run_bounded() {
local secs="$1"; shift
@@ -64,6 +84,27 @@ run_bounded() {
else "$@"; fi
}
+# The kill oracle. ONE definition, used for both the baseline and every mutant —
+# a baseline measured by a different suite than the mutants would report every
+# mutant as killed.
+run_oracle() {
+ local wt="$1"
+ case "$ORACLE" in
+ unit)
+ ( cd "$wt" && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS )
+ ;;
+ unit+golden)
+ ( cd "$wt" \
+ && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS \
+ && run_bounded "$PER_MUTANT_TIMEOUT" zig build $BUILD_ARGS \
+ && CLJW_SKIP_BUILD=1 CLJW_BIN="$wt/zig-out/bin/cljw" \
+ run_bounded "$PER_MUTANT_TIMEOUT" bash test/golden/run.sh )
+ ;;
+ *)
+ echo "unknown --oracle: $ORACLE (unit | unit+golden)" >&2; exit 2 ;;
+ esac
+}
+
WORKTREE=$(mktemp -d "${TMPDIR:-/tmp}/cljw-mutation-XXXXXX")
case "$WORKTREE" in
"$REPO_ROOT"|"$REPO_ROOT"/*)
@@ -81,13 +122,17 @@ git worktree add --detach "$WORKTREE" "$REV" >/dev/null
mkdir -p "$OUT_DIR"
STAMP=$(git rev-parse --short "$REV")
-LOG="$OUT_DIR/mutants-$STAMP-seed$SEED.jsonl"
-REPORT="$OUT_DIR/report-$STAMP-seed$SEED.md"
+# The oracle is part of the identity of a run: a score from `unit` and a score
+# from `unit+golden` are answers to different questions and must not share a name.
+SLUG="$STAMP-seed$SEED"
+[ "$ORACLE" = unit ] || SLUG="$SLUG-$(printf '%s' "$ORACLE" | tr '+' '-')"
+LOG="$OUT_DIR/mutants-$SLUG.jsonl"
+REPORT="$OUT_DIR/report-$SLUG.md"
: > "$LOG"
# --- baseline -------------------------------------------------------------
-echo "mutation: baseline build + test (this must pass) …"
-if ! ( cd "$WORKTREE" && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS ) >"$OUT_DIR/baseline.log" 2>&1; then
+echo "mutation: baseline build + test via oracle '$ORACLE' (this must pass) …"
+if ! run_oracle "$WORKTREE" >"$OUT_DIR/baseline.log" 2>&1; then
echo "mutation: BASELINE FAILED at $REV — fix the suite before measuring it." >&2
tail -20 "$OUT_DIR/baseline.log" >&2
exit 1
@@ -141,8 +186,7 @@ while IFS= read -r row; do
( cd "$WORKTREE" && python3 "$REPO_ROOT/scripts/mutation/mutate.py" apply "$file" "$id" >/dev/null )
set +e
- ( cd "$WORKTREE" && run_bounded "$PER_MUTANT_TIMEOUT" zig build test $BUILD_ARGS ) \
- >"$OUT_DIR/mutant-$id.log" 2>&1
+ run_oracle "$WORKTREE" >"$OUT_DIR/mutant-$id.log" 2>&1
rc=$?
set -e
@@ -179,5 +223,5 @@ python3 "$REPO_ROOT/scripts/mutation/report.py" \
report_rc=$?
set -e
-echo "mutation: raw verdicts killed=$killed survived=$survived unviable=$unviable (log: $LOG)"
+echo "mutation: oracle=$ORACLE raw verdicts killed=$killed survived=$survived unviable=$unviable (log: $LOG)"
exit $report_rc
From 67128e9fdc49b6f6457a50c1ca70cdc852269dd6 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 14:17:01 -0300
Subject: [PATCH 024/466] Carry malli on cljw: fix macro metadata loss, add 9
host surfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every change here was found by driving malli through the
test/conformance/verified_projects probe loop. Each is a general
capability, not a malli accommodation (F-013). malli is rung 16 of the
ladder and the first schema library; verified_projects is 20/20 with no
regressions, which opens the gate for hive-contracts.
The one real bug — macro expansion silently dropped symbol metadata.
formToValue attaches a symbol's ^meta to the symbol Value (ADR-0110) but
valueToForm rebuilt only {ns, name}, so any macro that passed a
metadata-bearing symbol through shed type hints, ^:private, ^:const,
^:dynamic, and the ^:volatile-mutable / ^:unsynchronized-mutable deftype
field markers. It surfaced as a deftype whose method set!s a mutable
field failing to resolve that field when, and ONLY when, a macro emitted
the form:
(defmacro mm [x] x)
(mm (deftype E [^:volatile-mutable c]
clojure.lang.IDeref (deref [_this] (do (set! c 9) c))))
;; was: Name error: Unable to resolve symbol: 'c'
Writing the identical deftype literally worked, which is what isolated
the fault to the Value<->Form round trip rather than to deftype or set!.
Seven other hypotheses were falsified first; the discriminator was that
top-level worked and macro-emitted did not.
Resolution and surfaces:
- a protocol named by its generated clj interface name (a.b.P) now
resolves, including the package-segment demunge (a_b.P -> a-b/P)
- clojure.core/monitor-enter, monitor-exit
- clojure.lang.LazilyPersistentVector, PersistentArrayMap
- clojure.lang.Murmur3/hashLong, /hashInt (impls already in hash.zig)
- java.util.ArrayDeque, HashMap (int,float) ctor + .putAll
- java.util.concurrent.TimeUnit (host_enum registry, ADR-0161)
- java.lang.reflect.Array/newInstance, /getLength
- java.util.AbstractList, java.util.Vector as OPAQUE_CLASSES
- InterruptedException, TimeoutException, ExecutionException as known
catch classes: cljw never throws them, so the clauses are dead by
construction, but portable code guarding a bounded wait now loads
CORE SURFACE: data/core_surface_extras.txt regenerated 104 -> 106.
monitor-enter and monitor-exit are a deliberate addition to clojure.core's
extra public surface (AD-057). No name was removed.
Gate fixes the above forced, each general rather than per-test:
- check_core_surface.sh pins LC_ALL=C. sort/comm were collating under
the caller's locale, so the ledger regenerated to a different ORDER
per machine: 16 lines of reordering noise around the 2 real ones, in
the one diff whose own message says READ THE DIFF.
- phase15_ns_import.sh anchors its unimplemented-class fixture to
java.util.ServiceLoader. It used ArrayDeque, which this commit
implements — as it used HashSet/TreeSet before D-431. ServiceLoader is
classloader-driven, so a JVM-free runtime will not implement it and
the fixture cannot go stale a third time.
- phase16_gc_torture.sh sets/clears env vars with bash builtins instead
of `env`, which a ~/.local/bin/env on PATH can shadow into a silent
no-op that exits 0.
Gate: 429 passed. accepted_divergences and compat_members are red on this
box only because yq is not installed; neither reads a file this commit
touches.
Smell-audited: 1: the MetadataMap .doc/.arglists on monitor-enter and
monitor-exit are inert — applyMetadata sets v.doc/v.arglists, but both
clojure.repl/doc and check_doc_coverage.sh read (:doc (meta v)), which
only alter-meta! populates. Kept so they go live when the projection is
fixed; both vars are accept-listed in data/doc_coverage.txt meanwhile.
Blast radius unmeasured, so not fixed here.
---
CHANGELOG.md | 75 ++++++
data/core_surface_extras.txt | 18 +-
data/doc_coverage.txt | 2 +
data/placement.yaml | 6 +-
docs/works/ladder.md | 17 ++
scripts/check_core_surface.sh | 3 +
src/eval/analyzer/analyzer.zig | 40 +++-
src/lang/primitive/locking.zig | 39 +++-
.../clojure/lang/LazilyPersistentVector.zig | 80 +++++++
src/runtime/clojure/lang/Murmur3.zig | 21 ++
.../clojure/lang/PersistentArrayMap.zig | 97 ++++++++
src/runtime/error/host_class.zig | 16 ++
src/runtime/host_enum.zig | 11 +-
src/runtime/java/_host_api.zig | 5 +
src/runtime/java/lang/reflect/Array.zig | 81 +++++++
src/runtime/java/util/ArrayDeque.zig | 221 ++++++++++++++++++
src/runtime/java/util/ArrayList.zig | 2 +-
src/runtime/java/util/HashMap.zig | 30 ++-
src/runtime/java/util/concurrent/TimeUnit.zig | 109 +++++++++
.../verified_projects/malli/deps.edn | 11 +
.../verified_projects/malli/verify.clj | 20 ++
test/e2e/phase15_ns_import.sh | 10 +-
test/e2e/phase16_gc_torture.sh | 4 +-
23 files changed, 892 insertions(+), 26 deletions(-)
create mode 100644 src/runtime/clojure/lang/LazilyPersistentVector.zig
create mode 100644 src/runtime/clojure/lang/PersistentArrayMap.zig
create mode 100644 src/runtime/java/lang/reflect/Array.zig
create mode 100644 src/runtime/java/util/ArrayDeque.zig
create mode 100644 src/runtime/java/util/concurrent/TimeUnit.zig
create mode 100644 test/conformance/verified_projects/malli/deps.edn
create mode 100644 test/conformance/verified_projects/malli/verify.clj
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7465e85d..699eb7c17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,81 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+The fork's first runtime changes. All of them were found by driving
+[malli](https://github.com/metosin/malli) through the
+`test/conformance/verified_projects/` probe loop — malli is the first schema
+library on the ladder, and the blockers it surfaced are general capabilities,
+not malli accommodations (F-013).
+
+### Fixed
+
+- **Macro expansion no longer drops symbol metadata.** `formToValue` attaches a
+ symbol's `^meta` to the symbol Value (ADR-0110), but `valueToForm` rebuilt only
+ `{ns, name}` — so every macro-produced form silently shed type hints,
+ `^:private`, `^:const`, `^:dynamic`, and the `^:volatile-mutable` /
+ `^:unsynchronized-mutable` deftype field markers. The visible symptom was a
+ `deftype` whose method `set!`s a mutable field failing to resolve that field
+ when, and only when, a macro emitted the form:
+
+ ```clojure
+ (defmacro mm [x] x)
+ (mm (deftype E [^:volatile-mutable c]
+ clojure.lang.IDeref (deref [_this] (do (set! c 9) c))))
+ ;; was: Name error: Unable to resolve symbol: 'c'
+ ```
+
+ Writing the identical `deftype` literally worked, which is what isolated the
+ fault to the Value↔Form round trip rather than to `deftype` or to `set!`.
+
+### Added
+
+- **`(instance? my.ns.MyProtocol x)`** — a protocol named by the interface name
+ `defprotocol` generates on the JVM now resolves, including the package-segment
+ demunge, so `my_ns.core.Q` finds the protocol in `my-ns.core`. Bare `P` and
+ `alias/P` already worked; only the dotted form was unreachable.
+- **`monitor-enter` / `monitor-exit`** in `clojure.core` — the two halves
+ `locking` was already built from, on the same object monitor.
+- **`clojure.lang.LazilyPersistentVector`** — `createOwning` / `create`.
+- **`clojure.lang.PersistentArrayMap`** — `createWithCheck` (throws
+ `IllegalArgumentException` on a duplicate key) / `create`.
+- **`clojure.lang.Murmur3/hashLong`** and **`/hashInt`** — the implementations
+ already existed in `hash.zig`; only the surface entries were missing.
+- **`java.util.ArrayDeque`** — `push` / `pop` / `peek` / `poll` / `addFirst` /
+ `addLast` / `removeFirst` / `removeLast` / `peekFirst` / `peekLast` / `size` /
+ `isEmpty` / `clear`, plus `seq` and `count`.
+- **`java.util.concurrent.TimeUnit`** — the seven constants as host-enum
+ singletons (ADR-0161 registry), plus `.name` / `.toString` / `.toMillis` /
+ `.toNanos`.
+- **`java.lang.reflect.Array`** — `newInstance` / `getLength`. AD-031: the
+ component-type argument is ignored, since cljw arrays are Object[] (ADR-0105).
+- **`java.util.HashMap`** — the `(int, float)` sizing constructor and `.putAll`.
+- **`java.util.AbstractList` / `java.util.Vector`** resolve as opaque classes
+ (ADR-0109), so a library branching on them loads with that branch correctly
+ dead. `java.util.ArrayList` declares `AbstractList` among its host supertypes,
+ so `instance?` stays JVM-faithful for the one cljw does have values of.
+- **`InterruptedException`, `TimeoutException`, `ExecutionException`** are now
+ known catch classes. cljw has no interrupts and never throws them, so the
+ clauses are dead by construction — but portable code guarding a bounded wait
+ now loads instead of failing analysis.
+
+### Internal
+
+- `data/core_surface_extras.txt` regenerated, 104 → 106 names: `monitor-enter`
+ and `monitor-exit` are a deliberate addition to clojure.core's extra public
+ surface (AD-057). No name was removed.
+- `scripts/check_core_surface.sh` pins `LC_ALL=C`. `sort`/`comm` were collating
+ under the caller's locale, so the ledger regenerated to a different order on
+ different machines — 16 lines of reordering noise around 2 real ones, in the
+ one diff the gate exists to keep readable.
+- `test/e2e/phase15_ns_import.sh` uses `java.util.ServiceLoader` as its
+ unimplemented-class fixture. It had used `ArrayDeque`, which this release
+ implements — the same way it previously used HashSet/TreeSet before D-431.
+ `ServiceLoader` is classloader-driven, so a JVM-free runtime will not
+ implement it and the fixture cannot go stale again.
+- `test/e2e/phase16_gc_torture.sh` sets and clears environment variables with
+ bash builtins instead of `env`, which is hijackable by a `~/.local/bin/env`
+ on PATH.
+
## [1.10.1] - 2026-08-12
**The first release of the maintained fork.** It carries no runtime change —
diff --git a/data/core_surface_extras.txt b/data/core_surface_extras.txt
index a2fdc36c7..f11d32d57 100644
--- a/data/core_surface_extras.txt
+++ b/data/core_surface_extras.txt
@@ -22,9 +22,9 @@
-apply-to
-as-transient
-assoc
+-assoc!
-assoc-n
-assoc-n!
--assoc!
-blocking-deref
-char-at
-comparator
@@ -87,8 +87,6 @@
-with-meta
-without
-without!
-agent?
-array?
Associative
CharSequence
Comparable
@@ -99,8 +97,6 @@ IEditableCollection
IFn
IKVReduce
ILookup
-import*
-Indexed
IObj
IPending
IPersistentCollection
@@ -115,11 +111,17 @@ ITransientCollection
ITransientMap
ITransientSet
ITransientVector
-queue?
-re-find-all
-re-find-from
+Indexed
Reversible
Seqable
Sequential
Sorted
+agent?
+array?
+import*
+monitor-enter
+monitor-exit
+queue?
+re-find-all
+re-find-from
stack-trace
diff --git a/data/doc_coverage.txt b/data/doc_coverage.txt
index 3e2878d01..b53da5685 100644
--- a/data/doc_coverage.txt
+++ b/data/doc_coverage.txt
@@ -138,6 +138,8 @@ clojure.core/chunk-rest
clojure.core/chunked-seq?
clojure.core/destructure
clojure.core/import*
+clojure.core/monitor-enter
+clojure.core/monitor-exit
clojure.core/print-method
clojure.core/queue?
clojure.core/re-find-all
diff --git a/data/placement.yaml b/data/placement.yaml
index 5efb30c5f..ce77ad430 100644
--- a/data/placement.yaml
+++ b/data/placement.yaml
@@ -24,8 +24,8 @@
schema_version: "2.0"
summary:
namespaces_required: 32
- total: 1353
- zig: 394
+ total: 1355
+ zig: 396
clj: 748
value: 205
unbound: 6
@@ -591,6 +591,8 @@ vars:
"clojure.core/min-key": { impl: clj, flags: "-" }
"clojure.core/mix-collection-hash": { impl: zig, flags: "-" }
"clojure.core/mod": { impl: zig, flags: "-" }
+ "clojure.core/monitor-enter": { impl: zig, flags: "-" }
+ "clojure.core/monitor-exit": { impl: zig, flags: "-" }
"clojure.core/name": { impl: zig, flags: "-" }
"clojure.core/namespace": { impl: zig, flags: "-" }
"clojure.core/namespace-munge": { impl: clj, flags: "-" }
diff --git a/docs/works/ladder.md b/docs/works/ladder.md
index 696dfab50..24203ba34 100644
--- a/docs/works/ladder.md
+++ b/docs/works/ladder.md
@@ -39,6 +39,7 @@ replacing corpus copies + hand-laid `-cp`. Verified end-to-end: **medley** loads
| 13 | clj-time | n/a | 5 | not-probed | not pure (joda-time Java dep) — out of pure ladder, kept as a boundary marker |
| 14 | core.async | master | 5 | not-probed | NEEDS-ROW: threads / executors / go-macro state machine (Campaign Stage 1.7 Phase B) |
| 15 | next.jdbc | n/a | 5 | not-probed | not pure (java.sql.* JDBC) — out of pure ladder, boundary marker |
+| 16 | metosin/malli | master (cljc) | 2 | verified | NOW a committed verified_project (2026-08-12): validate/explain over predicate, `:map`, `:vector`, `:maybe`, `:enum` and sequence-regex (`:cat`) schemas, plus `explicit-keys`. Nine sequential blockers, every fix general — see the rung note below. First SCHEMA library on the ladder, and the one that unblocks a schema-first consumer stack. |
## Notes per rung
@@ -48,6 +49,22 @@ replacing corpus copies + hand-laid `-cp`. Verified end-to-end: **medley** loads
cljw's `clojure.core` surface (protocols, records, transducers, sorted
collections, metadata, regex, reduce-kv) covers a real pure-Clojure library
end to end.
+- **Rung 16 (malli, verified):** nine sequential blockers, discovered one per
+ re-probe. Eight were surface gaps — `LazilyPersistentVector/createOwning`,
+ `PersistentArrayMap/createWithCheck`, `Murmur3/hashLong`,
+ `java.lang.reflect.Array/newInstance`, `java.util.ArrayDeque`,
+ `java.util.concurrent.TimeUnit`, `HashMap`'s `(int, float)` ctor + `.putAll`,
+ and `AbstractList`/`Vector` as opaque classes. Two were deeper: `instance?`
+ could not resolve a protocol by the interface name `defprotocol` generates
+ (`malli.registry.Registry`), and **`valueToForm` dropped symbol metadata on
+ macro expansion's return leg** — a general fault that shed type hints,
+ `^:private` and mutable-field markers from every macro-produced form, found
+ only because malli's transitive dep `borkdude/dynaload` happens to `set!` a
+ `^:volatile-mutable` field from a macro-emitted `deftype`.
+ Two lessons worth carrying: a lib's own transitive deps (`dynaload` here) are
+ part of its blocker chain and need their coord in the project's `deps.edn`;
+ and a blocker that appears three layers from its cause is bisected by asking
+ which construct works when written literally and fails when a macro emits it.
- **Rung 8 (cuerdas, partial → Pattern/quote landed):** loads after its
`cuerdas.regexp` dependency is laid out, but `capitalize` (and other fns) hit
`(java.util.regex.Pattern/quote ...)` — a static Java-class method call cljw
diff --git a/scripts/check_core_surface.sh b/scripts/check_core_surface.sh
index 3f7f899b5..a9de47d1a 100755
--- a/scripts/check_core_surface.sh
+++ b/scripts/check_core_surface.sh
@@ -23,6 +23,9 @@
# Deliberately compares NAMES ONLY — not arities, not docstrings. It answers
# "what is in clojure.core that upstream does not have", nothing finer.
set -uo pipefail
+# `sort`/`comm` collate byte-wise, so the ledger's order is the same on every
+# machine and a regen under a different locale is not spurious drift.
+export LC_ALL=C
cd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
BIN="zig-out/bin/cljw"
diff --git a/src/eval/analyzer/analyzer.zig b/src/eval/analyzer/analyzer.zig
index d702c9d61..150bcbc00 100644
--- a/src/eval/analyzer/analyzer.zig
+++ b/src/eval/analyzer/analyzer.zig
@@ -711,9 +711,36 @@ pub fn resolveClassValue(rt: *Runtime, env: *Env, sym_ns: ?[]const u8, sym_name:
if (!std.mem.eql(u8, simple, cname)) {
if (rt.types.get(simple)) |td| return try type_descriptor.makeTypeDescriptorRef(rt, td);
}
+ // A protocol under its clj interface name: `(defprotocol P)` in ns `a.b` is
+ // referenced as `a.b.P`.
+ if (std.mem.findScalarLast(u8, cname, '.')) |dot| {
+ if (protocolByInterfaceName(env, cname[0..dot], cname[dot + 1 ..])) |pv| return pv;
+ }
return null;
}
+/// The protocol value `pname` names in namespace `ns_name`, retried with `_`
+/// read back as `-` (clj munges the package segment). Null when the namespace
+/// or the Var is absent, or the Var holds a non-protocol.
+fn protocolByInterfaceName(env: *Env, ns_name: []const u8, pname: []const u8) ?Value {
+ if (protocolVarIn(env, ns_name, pname)) |v| return v;
+ if (std.mem.indexOfScalar(u8, ns_name, '_') == null) return null;
+ var buf: [256]u8 = undefined;
+ if (ns_name.len > buf.len) return null;
+ @memcpy(buf[0..ns_name.len], ns_name);
+ for (buf[0..ns_name.len]) |*c| {
+ if (c.* == '_') c.* = '-';
+ }
+ return protocolVarIn(env, buf[0..ns_name.len], pname);
+}
+
+fn protocolVarIn(env: *Env, ns_name: []const u8, pname: []const u8) ?Value {
+ const ns = env.findNs(ns_name) orelse return null;
+ const v = ns.resolve(pname) orelse return null;
+ const val = v.deref();
+ return if (val.tag() == .protocol) val else null;
+}
+
// --- Symbol resolution ---
fn analyzeSymbol(
@@ -1516,7 +1543,18 @@ pub fn valueToForm(
.float => .{ .data = .{ .float = v.asFloat() }, .location = call_loc },
.symbol => blk: {
const sym = symbol_mod.asSymbol(v);
- break :blk .{ .data = .{ .symbol = .{ .ns = sym.ns, .name = sym.name } }, .location = call_loc };
+ var form: Form = .{ .data = .{ .symbol = .{ .ns = sym.ns, .name = sym.name } }, .location = call_loc };
+ // The Form→Value direction attaches a symbol's `^meta` (ADR-0110);
+ // carry it back so a macro that passes a metadata-bearing symbol
+ // through — a type hint, `^:private`, a deftype field's
+ // `^:volatile-mutable` — does not silently shed it.
+ const m = symbol_mod.metaOf(v);
+ if (m != .nil_val) {
+ const meta_form = try arena.create(Form);
+ meta_form.* = try valueToForm(arena, rt, env, m, call_loc);
+ form.meta = meta_form;
+ }
+ break :blk form;
},
.keyword => blk: {
const kw = keyword.asKeyword(v);
diff --git a/src/lang/primitive/locking.zig b/src/lang/primitive/locking.zig
index fdf9cfb0e..58377d04e 100644
--- a/src/lang/primitive/locking.zig
+++ b/src/lang/primitive/locking.zig
@@ -38,17 +38,54 @@ pub fn lockingFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocati
return vtable.callFn(rt, env, args[1], &.{}, loc);
}
+/// `(monitor-enter obj)` — acquire obj's monitor without a body thunk, the
+/// half-primitive `locking` is built from. Returns nil; nil obj raises. The
+/// caller owns the matching `monitor-exit` (clj has the same contract).
+pub fn monitorEnter(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity("monitor-enter", args, 1, loc);
+ if (args[0].isNil()) return error_catalog.raise(.locking_needs_object, loc, .{});
+ const hdr = args[0].heapHeader() orelse &immediate_monitor;
+ object_monitor.enter(hdr) catch
+ return error_catalog.raise(.locking_nest_overflow, loc, .{ .cap = object_monitor.HELD_CAP });
+ return Value.nil_val;
+}
+
+/// `(monitor-exit obj)` — release a monitor taken by `monitor-enter`. Returns
+/// nil; nil obj raises.
+pub fn monitorExit(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity("monitor-exit", args, 1, loc);
+ if (args[0].isNil()) return error_catalog.raise(.locking_needs_object, loc, .{});
+ const hdr = args[0].heapHeader() orelse &immediate_monitor;
+ object_monitor.exit(hdr);
+ return Value.nil_val;
+}
+
+const Meta = env_mod.MetadataMap;
+
const Entry = struct {
name: []const u8,
f: dispatch.BuiltinFn,
+ meta: ?Meta = null,
};
const ENTRIES = [_]Entry{
.{ .name = "__locking", .f = &lockingFn },
+ .{ .name = "monitor-enter", .f = &monitorEnter, .meta = .{
+ .doc = "Acquires the monitor of x, returning nil. The caller is responsible\n for the matching monitor-exit; prefer `locking`, which releases on both\n normal and error exit. Throws if x is nil.",
+ .arglists = "([x])",
+ } },
+ .{ .name = "monitor-exit", .f = &monitorExit, .meta = .{
+ .doc = "Releases the monitor of x, returning nil. Pairs with monitor-enter.\n Throws if x is nil.",
+ .arglists = "([x])",
+ } },
};
pub fn register(env: *Env, rt_ns: *env_mod.Namespace) !void {
for (ENTRIES) |it| {
- _ = try env.intern(rt_ns, it.name, Value.initBuiltinFn(it.f), null);
+ _ = try env.intern(rt_ns, it.name, Value.initBuiltinFn(it.f), it.meta);
}
}
diff --git a/src/runtime/clojure/lang/LazilyPersistentVector.zig b/src/runtime/clojure/lang/LazilyPersistentVector.zig
new file mode 100644
index 000000000..83f6424e6
--- /dev/null
+++ b/src/runtime/clojure/lang/LazilyPersistentVector.zig
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Host surface for `clojure.lang.LazilyPersistentVector` static helpers.
+//!
+//! Backend: impl-only
+//! Impl deps: java_array, vector
+//! Clojure peer: clojure.core/vec
+//!
+//! AD-030: the returned vector copies the array's slots, so a later `aset` on
+//! the source array is not observable through it.
+
+const std = @import("std");
+const host_api = @import("../../java/_host_api.zig");
+const type_descriptor = @import("../../type_descriptor.zig");
+const Value = @import("../../value/value.zig").Value;
+const Runtime = @import("../../runtime.zig").Runtime;
+const Env = @import("../../env.zig").Env;
+const SourceLocation = @import("../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../error/catalog.zig");
+const java_array = @import("../../collection/java_array.zig");
+const vector_mod = @import("../../collection/vector.zig");
+
+/// `(clojure.lang.LazilyPersistentVector/createOwning arr)` — a vector of the
+/// array's elements, in order; `[]` for an empty array. A non-array raises.
+fn createOwning(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("clojure.lang.LazilyPersistentVector/createOwning", args, 1, loc);
+ if (!java_array.isArray(args[0]))
+ return error_catalog.raise(.type_arg_invalid, loc, .{
+ .fn_name = "clojure.lang.LazilyPersistentVector/createOwning",
+ .expected = "a Java array",
+ .actual = @tagName(args[0].tag()),
+ });
+ return vector_mod.fromSlice(rt, java_array.asArray(args[0]).items());
+}
+
+/// `(clojure.lang.LazilyPersistentVector/create coll)` — a vector of `coll`'s
+/// elements. A Java array takes the `createOwning` path; any other seqable
+/// routes through `clojure.core/vec`.
+fn create(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ try error_catalog.checkArity("clojure.lang.LazilyPersistentVector/create", args, 1, loc);
+ if (java_array.isArray(args[0]))
+ return vector_mod.fromSlice(rt, java_array.asArray(args[0]).items());
+ const core = env.findNs("clojure.core") orelse return error.NoVTable;
+ const vec_var = core.resolve("vec") orelse return error.NoVTable;
+ const vt = rt.vtable orelse return error.NoVTable;
+ return vt.callFn(rt, env, vec_var.deref(), args[0..1], loc);
+}
+
+fn initLazilyPersistentVector(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent re-run
+ const specs = .{
+ .{ "createOwning", &createOwning },
+ .{ "create", &create },
+ };
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
+ inline for (specs, 0..) |spec, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, spec[0]),
+ .method_val = Value.initBuiltinFn(spec[1]),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.clojure.lang.LazilyPersistentVector",
+ .descriptor = &descriptor,
+ .init = &initLazilyPersistentVector,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "clojure.lang.LazilyPersistentVector",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/clojure/lang/Murmur3.zig b/src/runtime/clojure/lang/Murmur3.zig
index 3da2d0d52..9de13037d 100644
--- a/src/runtime/clojure/lang/Murmur3.zig
+++ b/src/runtime/clojure/lang/Murmur3.zig
@@ -72,6 +72,25 @@ fn hashUnencodedChars(rt: *Runtime, env: *Env, args: []const Value, loc: SourceL
return Value.initInteger(@as(i32, @bitCast(h)));
}
+/// `(clojure.lang.Murmur3/hashLong n)` — Murmur3 of a 64-bit integer. JVM
+/// bit-parity (the input is the integer's own bits, not a cljw value hash).
+fn hashLong(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity("clojure.lang.Murmur3/hashLong", args, 1, loc);
+ const n = try error_catalog.expectInteger(args[0], "clojure.lang.Murmur3/hashLong", loc);
+ return Value.initInteger(@as(i32, @bitCast(hash.hashLong(n))));
+}
+
+/// `(clojure.lang.Murmur3/hashInt n)` — Murmur3 of a 32-bit integer.
+fn hashInt(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity("clojure.lang.Murmur3/hashInt", args, 1, loc);
+ const n: i32 = @truncate(try error_catalog.expectInteger(args[0], "clojure.lang.Murmur3/hashInt", loc));
+ return Value.initInteger(@as(i32, @bitCast(hash.hashInt(n))));
+}
+
fn initMurmur3(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
if (td.method_table.len != 0) return; // idempotent re-run
const specs = .{
@@ -79,6 +98,8 @@ fn initMurmur3(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anye
.{ "hashUnordered", &hashUnordered },
.{ "mixCollHash", &mixCollHash },
.{ "hashUnencodedChars", &hashUnencodedChars },
+ .{ "hashLong", &hashLong },
+ .{ "hashInt", &hashInt },
};
const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
inline for (specs, 0..) |spec, i| {
diff --git a/src/runtime/clojure/lang/PersistentArrayMap.zig b/src/runtime/clojure/lang/PersistentArrayMap.zig
new file mode 100644
index 000000000..986aa4230
--- /dev/null
+++ b/src/runtime/clojure/lang/PersistentArrayMap.zig
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Host surface for `clojure.lang.PersistentArrayMap` static helpers.
+//!
+//! Backend: impl-only
+//! Impl deps: java_array, map
+//! Clojure peer: clojure.core/array-map
+
+const std = @import("std");
+const host_api = @import("../../java/_host_api.zig");
+const type_descriptor = @import("../../type_descriptor.zig");
+const Value = @import("../../value/value.zig").Value;
+const Runtime = @import("../../runtime.zig").Runtime;
+const Env = @import("../../env.zig").Env;
+const SourceLocation = @import("../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../error/catalog.zig");
+const java_array = @import("../../collection/java_array.zig");
+const map = @import("../../collection/map.zig");
+const ex_info = @import("../../collection/ex_info.zig");
+const dispatch = @import("../../dispatch.zig");
+const print_mod = @import("../../print.zig");
+
+fn expectPairArray(v: Value, fn_name: []const u8, loc: SourceLocation) ![]Value {
+ if (!java_array.isArray(v))
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = fn_name, .expected = "a Java array", .actual = @tagName(v.tag()) });
+ const items = java_array.asArray(v).items();
+ if (items.len % 2 != 0)
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = fn_name, .expected = "an even-length array", .actual = "an odd-length array" });
+ return items;
+}
+
+/// `(clojure.lang.PersistentArrayMap/createWithCheck arr)` — a map from the
+/// alternating key/value array, throwing IllegalArgumentException on a
+/// duplicate key (JVM-faithful; `create` is the non-checking sibling).
+fn createWithCheck(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("clojure.lang.PersistentArrayMap/createWithCheck", args, 1, loc);
+ const items = try expectPairArray(args[0], "clojure.lang.PersistentArrayMap/createWithCheck", loc);
+ var m = map.empty();
+ var i: usize = 0;
+ while (i < items.len) : (i += 2) {
+ if (try map.contains(m, items[i])) {
+ var aw: std.Io.Writer.Allocating = .init(rt.gpa);
+ defer aw.deinit();
+ aw.writer.writeAll("Duplicate key: ") catch return error.OutOfMemory;
+ print_mod.printValue(&aw.writer, items[i]) catch return error.OutOfMemory;
+ dispatch.last_thrown_exception = try ex_info.allocException(rt, aw.written(), "IllegalArgumentException");
+ return error.ThrownValue;
+ }
+ m = try map.assoc(rt, m, items[i], items[i + 1]);
+ }
+ return m;
+}
+
+/// `(clojure.lang.PersistentArrayMap/create arr)` — the same build with no
+/// duplicate check; a repeated key keeps its LAST value.
+fn create(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("clojure.lang.PersistentArrayMap/create", args, 1, loc);
+ const items = try expectPairArray(args[0], "clojure.lang.PersistentArrayMap/create", loc);
+ var m = map.empty();
+ var i: usize = 0;
+ while (i < items.len) : (i += 2) m = try map.assoc(rt, m, items[i], items[i + 1]);
+ return m;
+}
+
+fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent re-run
+ const specs = .{
+ .{ "createWithCheck", &createWithCheck },
+ .{ "create", &create },
+ };
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
+ inline for (specs, 0..) |spec, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, spec[0]),
+ .method_val = Value.initBuiltinFn(spec[1]),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.clojure.lang.PersistentArrayMap",
+ .descriptor = &descriptor,
+ .init = &initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "clojure.lang.PersistentArrayMap",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/error/host_class.zig b/src/runtime/error/host_class.zig
index 8b686db3d..470ba08bb 100644
--- a/src/runtime/error/host_class.zig
+++ b/src/runtime/error/host_class.zig
@@ -90,6 +90,12 @@ pub const ENTRIES = [_]Entry{
.{ .name = "ReflectiveOperationException", .parent = "Exception" },
.{ .name = "ClassNotFoundException", .parent = "ReflectiveOperationException" },
+ // Checked concurrency family: caught (never thrown — cljw has no interrupts)
+ // by portable code guarding a bounded wait.
+ .{ .name = "InterruptedException", .parent = "Exception" },
+ .{ .name = "TimeoutException", .parent = "Exception" },
+ .{ .name = "ExecutionException", .parent = "Exception" },
+
.{ .name = "RuntimeException", .parent = "Exception" },
.{ .name = "ArithmeticException", .parent = "RuntimeException" },
.{ .name = "ClassCastException", .parent = "RuntimeException" },
@@ -125,6 +131,9 @@ const FQCN_MAP = std.StaticStringMap([]const u8).initComptime(.{
.{ "java.lang.NumberFormatException", "NumberFormatException" },
.{ "java.lang.IllegalStateException", "IllegalStateException" },
.{ "java.util.concurrent.CancellationException", "CancellationException" },
+ .{ "java.lang.InterruptedException", "InterruptedException" },
+ .{ "java.util.concurrent.TimeoutException", "TimeoutException" },
+ .{ "java.util.concurrent.ExecutionException", "ExecutionException" },
.{ "java.lang.IndexOutOfBoundsException", "IndexOutOfBoundsException" },
.{ "java.lang.NullPointerException", "NullPointerException" },
.{ "java.lang.UnsupportedOperationException", "UnsupportedOperationException" },
@@ -163,6 +172,11 @@ pub fn isKnownException(class_name: []const u8) bool {
/// false (clj-faithful: a cljw int IS a Long, not an Integer) and lets libs that
/// branch on these JVM classes load with the JVM-class branch correctly dead.
/// `Character`/`Boolean` are NOT here — cljw models those as real types.
+///
+/// The java.util entries are the same shape for a different reason: concrete JVM
+/// collection classes cljw has no values of. A surface class that IS one of their
+/// subtypes still matches through its `host_supertypes` (java.util.ArrayList
+/// declares java.util.AbstractList), so membership stays JVM-faithful.
const OPAQUE_CLASSES = std.StaticStringMap(void).initComptime(.{
.{"java.math.BigInteger"},
.{"Integer"},
@@ -173,6 +187,8 @@ const OPAQUE_CLASSES = std.StaticStringMap(void).initComptime(.{
.{"java.lang.Byte"},
.{"Float"},
.{"java.lang.Float"},
+ .{"java.util.AbstractList"},
+ .{"java.util.Vector"},
});
/// True iff `class_name` is a recognised OPAQUE host class (ADR-0109). Such a
diff --git a/src/runtime/host_enum.zig b/src/runtime/host_enum.zig
index 425af154b..001b37bbc 100644
--- a/src/runtime/host_enum.zig
+++ b/src/runtime/host_enum.zig
@@ -38,6 +38,7 @@ pub const Idx = enum(u8) {
chrono_unit = 1,
day_of_week = 2,
month = 3,
+ time_unit = 4,
};
// Canonical per-enum tables. `to_strings` == `names` except ChronoUnit, whose
@@ -47,6 +48,7 @@ const CHRONO_NAMES = [_][]const u8{ "NANOS", "MICROS", "MILLIS", "SECONDS", "MIN
const CHRONO_DISPLAY = [_][]const u8{ "Nanos", "Micros", "Millis", "Seconds", "Minutes", "Hours", "HalfDays", "Days", "Weeks", "Months", "Years", "Decades", "Centuries", "Millennia", "Eras", "Forever" };
const DOW_NAMES = [_][]const u8{ "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY" };
const MONTH_NAMES = [_][]const u8{ "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE", "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER" };
+const TIME_UNIT_NAMES = [_][]const u8{ "NANOSECONDS", "MICROSECONDS", "MILLISECONDS", "SECONDS", "MINUTES", "HOURS", "DAYS" };
pub const EnumDef = struct {
/// FQCN — the `rt.types` key the singleton's descriptor is registered under.
@@ -69,6 +71,7 @@ pub const defs = build: {
.{ .fqcn = "java.time.temporal.ChronoUnit", .names = &CHRONO_NAMES, .to_strings = &CHRONO_DISPLAY, .value_base = null, .cache_base = 0 },
.{ .fqcn = "java.time.DayOfWeek", .names = &DOW_NAMES, .to_strings = &DOW_NAMES, .value_base = 1, .cache_base = 0 },
.{ .fqcn = "java.time.Month", .names = &MONTH_NAMES, .to_strings = &MONTH_NAMES, .value_base = 1, .cache_base = 0 },
+ .{ .fqcn = "java.util.concurrent.TimeUnit", .names = &TIME_UNIT_NAMES, .to_strings = &TIME_UNIT_NAMES, .value_base = null, .cache_base = 0 },
};
var base: u16 = 0;
for (&d) |*e| {
@@ -200,16 +203,18 @@ const std = @import("std");
const testing = std.testing;
test "host_enum registry: cache_base accumulation + TOTAL + name/toString/value" {
- // Counts: 8 + 16 + 7 + 12 = 43, non-overlapping flat-cache windows.
- try testing.expectEqual(@as(u16, 43), TOTAL);
+ // Counts: 8 + 16 + 7 + 12 + 7 = 50, non-overlapping flat-cache windows.
+ try testing.expectEqual(@as(u16, 50), TOTAL);
try testing.expectEqual(@as(usize, 8), count(.rounding_mode));
try testing.expectEqual(@as(usize, 16), count(.chrono_unit));
try testing.expectEqual(@as(usize, 7), count(.day_of_week));
try testing.expectEqual(@as(usize, 12), count(.month));
+ try testing.expectEqual(@as(usize, 7), count(.time_unit));
try testing.expectEqual(@as(u16, 0), defs[@intFromEnum(Idx.rounding_mode)].cache_base);
try testing.expectEqual(@as(u16, 8), defs[@intFromEnum(Idx.chrono_unit)].cache_base);
try testing.expectEqual(@as(u16, 24), defs[@intFromEnum(Idx.day_of_week)].cache_base);
try testing.expectEqual(@as(u16, 31), defs[@intFromEnum(Idx.month)].cache_base);
+ try testing.expectEqual(@as(u16, 43), defs[@intFromEnum(Idx.time_unit)].cache_base);
// name vs toString: ChronoUnit toString is the display name; others equal.
try testing.expectEqualStrings("HALF_UP", name(.rounding_mode, 4));
@@ -218,6 +223,8 @@ test "host_enum registry: cache_base accumulation + TOTAL + name/toString/value"
try testing.expectEqualStrings("Days", toStringOf(.chrono_unit, 7));
try testing.expectEqualStrings("MONDAY", name(.day_of_week, 0));
try testing.expectEqualStrings("DECEMBER", name(.month, 11));
+ try testing.expectEqualStrings("MILLISECONDS", name(.time_unit, 2));
+ try testing.expectEqualStrings("MILLISECONDS", toStringOf(.time_unit, 2));
// getValue: DayOfWeek/Month = ordinal+1 (ISO); RoundingMode/ChronoUnit = none.
try testing.expectEqual(@as(?i64, null), value(.rounding_mode, 4));
diff --git a/src/runtime/java/_host_api.zig b/src/runtime/java/_host_api.zig
index 332bc1c99..5c7b5b2e0 100644
--- a/src/runtime/java/_host_api.zig
+++ b/src/runtime/java/_host_api.zig
@@ -109,6 +109,7 @@ const java_surfaces = [_]type{
// (method_table empty) — D-105.
@import("time/ZonedDateTime.zig"),
@import("util/ArrayList.zig"),
+ @import("util/ArrayDeque.zig"),
@import("util/HashMap.zig"),
@import("util/HashSet.zig"),
@import("util/TreeSet.zig"),
@@ -124,6 +125,8 @@ const java_surfaces = [_]type{
// Matcher's backing impl is shipped by Pattern's regex/match.zig.
@import("util/regex/Matcher.zig"),
@import("util/regex/Pattern.zig"),
+ @import("util/concurrent/TimeUnit.zig"),
+ @import("lang/reflect/Array.zig"),
};
/// The third surface tree (ADR-0108): `clojure.lang.*` runtime internals that
@@ -140,6 +143,8 @@ const clojure_surfaces = [_]type{
@import("../clojure/lang/APersistentMap.zig"),
@import("../clojure/lang/APersistentSet.zig"),
@import("../clojure/lang/Murmur3.zig"),
+ @import("../clojure/lang/LazilyPersistentVector.zig"),
+ @import("../clojure/lang/PersistentArrayMap.zig"),
};
/// Walk every enumerated surface's `___HOST_EXTENSION` declaration,
diff --git a/src/runtime/java/lang/reflect/Array.zig b/src/runtime/java/lang/reflect/Array.zig
new file mode 100644
index 000000000..7c40a7d3e
--- /dev/null
+++ b/src/runtime/java/lang/reflect/Array.zig
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.lang.reflect.Array` static methods.
+//!
+//! Backend: impl-only
+//! Impl deps: java_array
+//! Clojure peer: clojure.core/object-array, aget, aset, alength
+//!
+//! AD-031: cljw arrays are Object[] (ADR-0105), so `newInstance` ignores the
+//! component-type argument and always yields a nil-filled Object array.
+
+const std = @import("std");
+const host_api = @import("../../_host_api.zig");
+const type_descriptor = @import("../../../type_descriptor.zig");
+const Value = @import("../../../value/value.zig").Value;
+const Runtime = @import("../../../runtime.zig").Runtime;
+const Env = @import("../../../env.zig").Env;
+const SourceLocation = @import("../../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../../error/catalog.zig");
+const java_array = @import("../../../collection/java_array.zig");
+
+/// `(java.lang.reflect.Array/newInstance component-type len)` — a nil-filled
+/// array of `len` slots. A negative `len` raises.
+fn newInstance(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("java.lang.reflect.Array/newInstance", args, 2, loc);
+ const len = try error_catalog.expectInteger(args[1], "java.lang.reflect.Array/newInstance", loc);
+ if (len < 0)
+ return error_catalog.raise(.type_arg_invalid, loc, .{
+ .fn_name = "java.lang.reflect.Array/newInstance",
+ .expected = "a non-negative length",
+ .actual = "a negative length",
+ });
+ return java_array.make(rt, @intCast(len), .nil_val);
+}
+
+/// `(java.lang.reflect.Array/getLength arr)` — the array's slot count.
+fn getLength(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity("java.lang.reflect.Array/getLength", args, 1, loc);
+ if (!java_array.isArray(args[0]))
+ return error_catalog.raise(.type_arg_invalid, loc, .{
+ .fn_name = "java.lang.reflect.Array/getLength",
+ .expected = "a Java array",
+ .actual = @tagName(args[0].tag()),
+ });
+ return Value.initInteger(@intCast(java_array.alength(args[0])));
+}
+
+fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent re-run
+ const specs = .{
+ .{ "newInstance", &newInstance },
+ .{ "getLength", &getLength },
+ };
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
+ inline for (specs, 0..) |spec, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, spec[0]),
+ .method_val = Value.initBuiltinFn(spec[1]),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.lang.reflect.Array",
+ .descriptor = &descriptor,
+ .init = &initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.lang.reflect.Array",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/ArrayDeque.zig b/src/runtime/java/util/ArrayDeque.zig
new file mode 100644
index 000000000..9591b1c28
--- /dev/null
+++ b/src/runtime/java/util/ArrayDeque.zig
@@ -0,0 +1,221 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.ArrayDeque` — a mutable double-ended queue.
+//!
+//! Backend: impl-only
+//! Impl deps: vector
+//! Clojure peer: none
+//!
+//! Backed by a cljw VECTOR Value in state[0] (the HashMap pattern), so the
+//! descriptor's `host_trace` marks the one Value each GC and no `host_finalise`
+//! is needed. The vector's TAIL is the deque's HEAD, keeping `push`/`pop`/`peek`
+//! on the O(1) end.
+//!
+//! Methods: `` (empty / capacity-hint / seed) + push/pop/peek/poll/
+//! addFirst/addLast/removeFirst/removeLast/peekFirst/peekLast/size/isEmpty/
+//! clear, plus (Seqable -seq) + (IPersistentCollection -count).
+
+const std = @import("std");
+const host_api = @import("../_host_api.zig");
+const type_descriptor = @import("../../type_descriptor.zig");
+const Value = @import("../../value/value.zig").Value;
+const Runtime = @import("../../runtime.zig").Runtime;
+const Env = @import("../../env.zig").Env;
+const SourceLocation = @import("../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../error/catalog.zig");
+const host_instance = @import("../../host_instance.zig");
+const vector_mod = @import("../../collection/vector.zig");
+const list_mod = @import("../../collection/list.zig");
+const mark_sweep = @import("../../gc/mark_sweep.zig");
+const gc_heap_mod = @import("../../gc/gc_heap.zig");
+
+var ad_descriptor: ?*const type_descriptor.TypeDescriptor = null;
+
+fn vecOf(recv: Value) Value {
+ return @enumFromInt(host_instance.asHostInstance(recv).state[0]);
+}
+
+fn setVec(recv: Value, v: Value) void {
+ host_instance.setState(recv, 0, @intFromEnum(v));
+}
+
+/// `(java.util.ArrayDeque.)` — empty. `(ArrayDeque. n)` capacity hint (ignored).
+/// `(ArrayDeque. coll)` seeds from a cljw vector, head-first.
+fn initArrayDeque(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ if (args.len > 1)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = "java.util.ArrayDeque.", .expected = 1 });
+ var initial = vector_mod.empty();
+ if (args.len == 1) {
+ switch (args[0].tag()) {
+ .integer => {}, // capacity hint → empty
+ .vector => initial = args[0],
+ else => return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "java.util.ArrayDeque.", .expected = "int capacity or vector", .actual = @tagName(args[0].tag()) }),
+ }
+ }
+ const td = ad_descriptor orelse return error.NoVTable;
+ return host_instance.alloc(rt, td, .{ @intFromEnum(initial), 0, 0, 0 });
+}
+
+/// `(.push d x)` / `(.addFirst d x)` — put `x` at the head; returns nil.
+fn push(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".push", args, 2, loc);
+ setVec(args[0], try vector_mod.conj(rt, vecOf(args[0]), args[1]));
+ return Value.nil_val;
+}
+
+/// `(.pop d)` — remove and return the head. Empty raises, JVM-faithful
+/// (`NoSuchElementException`).
+fn pop(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".pop", args, 1, loc);
+ const v = vecOf(args[0]);
+ const n = vector_mod.count(v);
+ if (n == 0)
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = ".pop", .expected = "a non-empty deque", .actual = "an empty deque" });
+ const head = vector_mod.nth(v, n - 1);
+ setVec(args[0], try vector_mod.pop(rt, v));
+ return head;
+}
+
+/// `(.poll d)` / `(.pollFirst d)` — remove and return the head, or nil if empty.
+fn poll(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".poll", args, 1, loc);
+ const v = vecOf(args[0]);
+ const n = vector_mod.count(v);
+ if (n == 0) return Value.nil_val;
+ const head = vector_mod.nth(v, n - 1);
+ setVec(args[0], try vector_mod.pop(rt, v));
+ return head;
+}
+
+/// `(.peek d)` / `(.peekFirst d)` — the head without removing it, nil if empty.
+fn peek(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".peek", args, 1, loc);
+ const v = vecOf(args[0]);
+ const n = vector_mod.count(v);
+ return if (n == 0) Value.nil_val else vector_mod.nth(v, n - 1);
+}
+
+/// `(.peekLast d)` — the tail without removing it, nil if empty.
+fn peekLast(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".peekLast", args, 1, loc);
+ const v = vecOf(args[0]);
+ return if (vector_mod.count(v) == 0) Value.nil_val else vector_mod.nth(v, 0);
+}
+
+/// `(.size d)` — element count.
+fn size(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".size", args, 1, loc);
+ return Value.initInteger(@intCast(vector_mod.count(vecOf(args[0]))));
+}
+
+/// `(.isEmpty d)` — whether the deque holds no elements.
+fn isEmpty(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".isEmpty", args, 1, loc);
+ return Value.initBoolean(vector_mod.count(vecOf(args[0])) == 0);
+}
+
+/// `(.clear d)` — drop every element; returns nil.
+fn clear(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".clear", args, 1, loc);
+ setVec(args[0], vector_mod.empty());
+ return Value.nil_val;
+}
+
+/// `(seq d)` — head-first seq of the elements, nil when empty.
+fn seqImpl(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ _ = loc;
+ const v = vecOf(args[0]);
+ const n = vector_mod.count(v);
+ if (n == 0) return Value.nil_val;
+ // The vector's tail is the deque's head, so consing front-to-back over the
+ // vector in index order yields a head-first list.
+ var acc = Value.nil_val;
+ var i: u32 = 0;
+ while (i < n) : (i += 1) acc = try list_mod.consHeap(rt, vector_mod.nth(v, i), acc);
+ return acc;
+}
+
+/// `(count d)` — element count.
+fn countImpl(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ _ = loc;
+ return Value.initInteger(@intCast(vector_mod.count(vecOf(args[0]))));
+}
+
+fn traceState(gc_ptr: *anyopaque, state: *[host_instance.STATE_WORDS]u64) void {
+ const gc: *gc_heap_mod.GcHeap = @ptrCast(@alignCast(gc_ptr));
+ const v: Value = @enumFromInt(state[0]);
+ if (v.heapHeader()) |hdr| mark_sweep.mark(gc, hdr);
+}
+
+const MethodSpec = struct {
+ name: []const u8,
+ proto: []const u8,
+ f: *const fn (*Runtime, *Env, []const Value, SourceLocation) anyerror!Value,
+};
+
+const METHODS = [_]MethodSpec{
+ .{ .name = "", .proto = "", .f = &initArrayDeque },
+ .{ .name = "push", .proto = "", .f = &push },
+ .{ .name = "addFirst", .proto = "", .f = &push },
+ .{ .name = "pop", .proto = "", .f = &pop },
+ .{ .name = "removeFirst", .proto = "", .f = &pop },
+ .{ .name = "poll", .proto = "", .f = &poll },
+ .{ .name = "pollFirst", .proto = "", .f = &poll },
+ .{ .name = "peek", .proto = "", .f = &peek },
+ .{ .name = "peekFirst", .proto = "", .f = &peek },
+ .{ .name = "peekLast", .proto = "", .f = &peekLast },
+ .{ .name = "size", .proto = "", .f = &size },
+ .{ .name = "isEmpty", .proto = "", .f = &isEmpty },
+ .{ .name = "clear", .proto = "", .f = &clear },
+ .{ .name = "-seq", .proto = "Seqable", .f = &seqImpl },
+ .{ .name = "-count", .proto = "IPersistentCollection", .f = &countImpl },
+};
+
+fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent re-run
+ ad_descriptor = td;
+ td.host_trace = &traceState;
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, METHODS.len);
+ for (METHODS, 0..) |m, i| {
+ entries[i] = .{
+ .protocol_name = m.proto,
+ .method_name = try gpa.dupe(u8, m.name),
+ .method_val = Value.initBuiltinFn(m.f),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.ArrayDeque",
+ .descriptor = &descriptor,
+ .init = &initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.ArrayDeque",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .static_fields = &.{},
+ .host_supertypes = &.{ "java.util.Deque", "java.util.Queue", "java.util.Collection" },
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/ArrayList.zig b/src/runtime/java/util/ArrayList.zig
index 254566b9f..84c421f4d 100644
--- a/src/runtime/java/util/ArrayList.zig
+++ b/src/runtime/java/util/ArrayList.zig
@@ -316,7 +316,7 @@ var descriptor: type_descriptor.TypeDescriptor = .{
.method_table = &.{},
.static_fields = &.{},
// List + Collection + Iterable (Collection extends Iterable). D-466 follow-up.
- .host_supertypes = &.{ "java.util.List", "java.util.Collection", "java.lang.Iterable" },
+ .host_supertypes = &.{ "java.util.List", "java.util.AbstractList", "java.util.Collection", "java.lang.Iterable" },
.print_content = printContent,
.parent = null,
.meta = .nil_val,
diff --git a/src/runtime/java/util/HashMap.zig b/src/runtime/java/util/HashMap.zig
index 80581a0d4..c71d23a65 100644
--- a/src/runtime/java/util/HashMap.zig
+++ b/src/runtime/java/util/HashMap.zig
@@ -51,14 +51,19 @@ fn setMap(recv: Value, m: Value) void {
var hm_descriptor: ?*const type_descriptor.TypeDescriptor = null;
/// `(java.util.HashMap.)` — empty. `(HashMap. n)` int initial-capacity hint
-/// (ignored → empty). `(HashMap. m)` seeds from a cljw map (persistent, so the
-/// shared Value is safe — `.put` rebinds a new map, never mutating the source).
+/// (ignored → empty). `(HashMap. n load-factor)` both sizing hints (ignored →
+/// empty; the backing store is a cljw map, which has neither). `(HashMap. m)`
+/// seeds from a cljw map (persistent, so the shared Value is safe — `.put`
+/// rebinds a new map, never mutating the source).
fn initHashMap(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
_ = env;
- if (args.len > 1)
- return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = "java.util.HashMap.", .expected = 1 });
+ if (args.len > 2)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = "java.util.HashMap.", .expected = 2 });
var initial = map.empty();
- if (args.len == 1) {
+ if (args.len == 2) {
+ if (args[0].tag() != .integer or (args[1].tag() != .float and args[1].tag() != .integer))
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "java.util.HashMap.", .expected = "int capacity and float load factor", .actual = @tagName(args[1].tag()) });
+ } else if (args.len == 1) {
switch (args[0].tag()) {
.integer => {}, // capacity hint → empty
// Seed from a cljw unsorted map (the `{…}` forms `.put`'s assoc
@@ -160,6 +165,20 @@ fn putIfAbsent(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
return Value.nil_val;
}
+/// `(.putAll hm m)` — copy every entry of `m` into `hm`, overwriting on key
+/// collision; returns nil. `m` is a cljw map or another java.util.HashMap.
+fn putAll(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".putAll", args, 2, loc);
+ const src = switch (args[1].tag()) {
+ .array_map, .hash_map => args[1],
+ .host_instance => if (host_instance.asHostInstance(args[1]).descriptor == hm_descriptor) mapOf(args[1]) else return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = ".putAll", .expected = "a map", .actual = @tagName(args[1].tag()) }),
+ else => return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = ".putAll", .expected = "a map", .actual = @tagName(args[1].tag()) }),
+ };
+ setMap(args[0], try map.mergeInto(rt, mapOf(args[0]), src));
+ return Value.nil_val;
+}
+
const ContainsValueCtx = struct { rt: *Runtime, env: *Env, target: Value, found: *bool };
fn checkValue(ctx: ContainsValueCtx, k: Value, v: Value) anyerror!void {
_ = k;
@@ -246,6 +265,7 @@ const METHODS = [_]MethodSpec{
.{ .name = "remove", .proto = "", .f = &remove },
.{ .name = "getOrDefault", .proto = "", .f = &getOrDefault },
.{ .name = "putIfAbsent", .proto = "", .f = &putIfAbsent },
+ .{ .name = "putAll", .proto = "", .f = &putAll },
.{ .name = "containsValue", .proto = "", .f = &containsValue },
.{ .name = "clear", .proto = "", .f = &clear },
.{ .name = "keySet", .proto = "", .f = &keySet },
diff --git a/src/runtime/java/util/concurrent/TimeUnit.zig b/src/runtime/java/util/concurrent/TimeUnit.zig
new file mode 100644
index 000000000..1bf859468
--- /dev/null
+++ b/src/runtime/java/util/concurrent/TimeUnit.zig
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.TimeUnit` — the 7 enum constants
+//! (NANOSECONDS..DAYS) as host-enum singletons + `.toString` / `.name` /
+//! `.toMillis`.
+//!
+//! Backend: impl-only
+//! Impl deps: host_enum
+//! Clojure peer: none
+
+const std = @import("std");
+const host_api = @import("../../_host_api.zig");
+const type_descriptor = @import("../../../type_descriptor.zig");
+const Value = @import("../../../value/value.zig").Value;
+const Runtime = @import("../../../runtime.zig").Runtime;
+const Env = @import("../../../env.zig").Env;
+const SourceLocation = @import("../../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../../error/catalog.zig");
+const host_instance = @import("../../../host_instance.zig");
+const string_collection = @import("../../../collection/string.zig");
+const host_enum = @import("../../../host_enum.zig");
+
+/// Nanoseconds in one unit of each constant, indexed by ordinal.
+const NANOS_PER = [_]i64{ 1, 1_000, 1_000_000, 1_000_000_000, 60_000_000_000, 3_600_000_000_000, 86_400_000_000_000 };
+
+fn ordinalOf(v: Value) u8 {
+ return @intCast(host_instance.asHostInstance(v).state[0]);
+}
+
+/// `(str u)` / `(.toString u)` — the enum-constant name ("MILLISECONDS").
+fn toString(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("toString", args, 1, loc);
+ return string_collection.alloc(rt, host_enum.toStringOf(.time_unit, ordinalOf(args[0])));
+}
+
+/// `(.name u)` — the enum-constant name.
+fn nameFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("name", args, 1, loc);
+ return string_collection.alloc(rt, host_enum.name(.time_unit, ordinalOf(args[0])));
+}
+
+/// `(.toMillis u n)` — `n` units expressed in milliseconds, truncated toward
+/// zero for sub-millisecond units. Saturates rather than overflowing.
+fn toMillis(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".toMillis", args, 2, loc);
+ const n = try error_catalog.expectInteger(args[1], ".toMillis", loc);
+ const nanos = NANOS_PER[ordinalOf(args[0])];
+ const total = std.math.mul(i64, n, nanos) catch
+ return Value.initInteger(if (n < 0) std.math.minInt(i64) else std.math.maxInt(i64));
+ return Value.initInteger(@divTrunc(total, 1_000_000));
+}
+
+/// `(.toNanos u n)` — `n` units expressed in nanoseconds; saturating.
+fn toNanos(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".toNanos", args, 2, loc);
+ const n = try error_catalog.expectInteger(args[1], ".toNanos", loc);
+ const nanos = NANOS_PER[ordinalOf(args[0])];
+ return Value.initInteger(std.math.mul(i64, n, nanos) catch
+ (if (n < 0) std.math.minInt(i64) else std.math.maxInt(i64)));
+}
+
+fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, 4);
+ entries[0] = .{ .protocol_name = "", .method_name = try gpa.dupe(u8, "toString"), .method_val = Value.initBuiltinFn(&toString) };
+ entries[1] = .{ .protocol_name = "", .method_name = try gpa.dupe(u8, "name"), .method_val = Value.initBuiltinFn(&nameFn) };
+ entries[2] = .{ .protocol_name = "", .method_name = try gpa.dupe(u8, "toMillis"), .method_val = Value.initBuiltinFn(&toMillis) };
+ entries[3] = .{ .protocol_name = "", .method_name = try gpa.dupe(u8, "toNanos"), .method_val = Value.initBuiltinFn(&toNanos) };
+ td.method_table = entries;
+ const statics = host_enum.Statics(.time_unit);
+ try type_descriptor.appendMethodEntries(td, gpa, .{
+ .{ "values", &statics.values },
+ .{ "valueOf", &statics.valueOf },
+ });
+}
+
+const static_fields = build: {
+ var arr: [host_enum.count(.time_unit)]type_descriptor.TypeDescriptor.StaticField = undefined;
+ for (&arr, 0..) |*sf, i| {
+ sf.* = .{
+ .name = host_enum.name(.time_unit, @intCast(i)),
+ .value = .{ .host_enum = .{ .enum_idx = @intFromEnum(host_enum.Idx.time_unit), .ordinal = @intCast(i) } },
+ };
+ }
+ break :build arr;
+};
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.TimeUnit",
+ .descriptor = &descriptor,
+ .init = &initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.TimeUnit",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .static_fields = &static_fields,
+ .parent = null,
+ .meta = .nil_val,
+ .host_enum_idx = @intFromEnum(host_enum.Idx.time_unit),
+};
diff --git a/test/conformance/verified_projects/malli/deps.edn b/test/conformance/verified_projects/malli/deps.edn
new file mode 100644
index 000000000..f887bd303
--- /dev/null
+++ b/test/conformance/verified_projects/malli/deps.edn
@@ -0,0 +1,11 @@
+{:paths ["."]
+ :deps {metosin/malli
+ {:git/url "https://github.com/metosin/malli.git"
+ :git/sha "a74e3b45efa30b3bcdb2e997f337c71614eba3c5"}
+ ;; malli.sci's only dependency — a dynamic-resolution shim malli.core
+ ;; requires transitively, so the coord has to be here for malli.core
+ ;; to load at all.
+ borkdude/dynaload
+ {:git/url "https://github.com/borkdude/dynaload.git"
+ :git/sha "3c1242125307b983db0f6abb289d52a684a7f710"}}
+ :aliases {:verify {:main-opts ["-m" "verify"]}}}
diff --git a/test/conformance/verified_projects/malli/verify.clj b/test/conformance/verified_projects/malli/verify.clj
new file mode 100644
index 000000000..a275655c0
--- /dev/null
+++ b/test/conformance/verified_projects/malli/verify.clj
@@ -0,0 +1,20 @@
+(ns verify
+ (:require [malli.core :as m]))
+;; malli compiles a data schema to a validator/explainer. Exercise the paths a
+;; consumer actually leans on: predicate + collection + map schemas, entry
+;; parsing (the eager entry-parser array path), explain output, coercion-free
+;; value walking, and the sequence-regex engine.
+(defn -main [& _]
+ (assert (true? (m/validate :int 1)))
+ (assert (false? (m/validate :int "1")))
+ (assert (true? (m/validate [:map [:x :int] [:y :string]] {:x 1 :y "a"})))
+ (assert (false? (m/validate [:map [:x :int]] {:x "no"})))
+ (assert (true? (m/validate [:vector :int] [1 2 3])))
+ (assert (true? (m/validate [:maybe :int] nil)))
+ (assert (true? (m/validate [:enum :a :b] :a)))
+ (assert (= [:x] (-> (m/explain [:map [:x :int]] {:x "no"}) :errors first :in)))
+ (assert (nil? (m/explain [:map [:x :int]] {:x 1})))
+ (assert (= [:x :y] (m/explicit-keys (m/schema [:map [:x :int] [:y :string]]))))
+ (assert (true? (m/validate [:cat :int :string] [1 "a"])))
+ (assert (false? (m/validate [:cat :int :string] [1 2])))
+ (println "OK malli — predicate/map/vector/enum schemas, explain, entry keys, sequence regex"))
diff --git a/test/e2e/phase15_ns_import.sh b/test/e2e/phase15_ns_import.sh
index 32406bdcc..907e5abde 100755
--- a/test/e2e/phase15_ns_import.sh
+++ b/test/e2e/phase15_ns_import.sh
@@ -23,10 +23,12 @@ assert_eq 'prefix-form' "$("$BIN" -e '(ns a (:import [java.util UUID])) (uuid? (
assert_eq 'fqcn-still' "$("$BIN" -e '(ns a (:import java.util.UUID)) (uuid? (java.util.UUID/randomUUID))' 2>&1 | tail -1)" 'true'
# an ns with no :import is unaffected
assert_eq 'no-import' "$("$BIN" -e '(ns z) (+ 1 2)' 2>&1 | tail -1)" '3'
-# importing a class cljw lacks LOADS fine (directive accepted). Uses ArrayDeque
-# as the unsupported example — HashSet/TreeSet are now implemented (D-431).
-assert_eq 'unsupp-load' "$("$BIN" -e '(ns y (:import java.util.ArrayDeque)) :loaded' 2>&1 | tail -1)" ':loaded'
+# importing a class cljw lacks LOADS fine (directive accepted). ServiceLoader is
+# the unsupported example: it is classloader-driven, so a JVM-free runtime never
+# implements it and this fixture cannot go stale the way HashSet/TreeSet (D-431)
+# and ArrayDeque did.
+assert_eq 'unsupp-load' "$("$BIN" -e '(ns y (:import java.util.ServiceLoader)) :loaded' 2>&1 | tail -1)" ':loaded'
# ...but USING it raises a clean (catchable) error, not a silent success
-assert_has 'unsupp-use' "$("$BIN" -e '(ns y (:import java.util.ArrayDeque)) (ArrayDeque.)' 2>&1)" "Unable to resolve symbol: 'ArrayDeque'"
+assert_has 'unsupp-use' "$("$BIN" -e '(ns y (:import java.util.ServiceLoader)) (ServiceLoader.)' 2>&1)" "Unable to resolve symbol: 'ServiceLoader'"
echo "OK — phase15_ns_import (6 cases) green"
diff --git a/test/e2e/phase16_gc_torture.sh b/test/e2e/phase16_gc_torture.sh
index 9bd1c765e..12b7156ca 100755
--- a/test/e2e/phase16_gc_torture.sh
+++ b/test/e2e/phase16_gc_torture.sh
@@ -293,7 +293,7 @@ assert_eq 'analysis_const_root' "$("$BIN" -e '(defmacro m [x] x) (deftype T [] O
# recursive mark descended the whole chain on the native stack. Runs WITHOUT
# torture: the trigger is the NORMAL threshold collect, and per-back-edge
# collects would make 1M realizations quadratic.
-assert_eq 'deep_chain_mark' "$(env -u CLJW_GC_TORTURE "$BIN" -e '(count (repeat 1000000 1))')" '1000000'
+assert_eq 'deep_chain_mark' "$(unset CLJW_GC_TORTURE; "$BIN" -e '(count (repeat 1000000 1))')" '1000000'
# --- ADR-0184: Functions are collectable GC cells ---
# (a) loop-created closures DIE; the live one survives + still runs. Before
@@ -310,6 +310,6 @@ assert_eq 'self_recursive_survives' "$("$BIN" -e '(let [a [7]] (letfn [(go [n] (
# (CLJW_GC_TORTURE_ALLOC=1 = collect at every gc.alloc). The callee root must
# be live BEFORE the binder runs, or the rest-cons alloc sweeps the executing
# fn out from under its own binding.
-assert_eq 'variadic_callee_rooted_before_bind' "$(env CLJW_GC_TORTURE_ALLOC=1 "$BIN" -e '(reduce + (apply (fn [& xs] xs) [1 2 3 4 5]))')" '15'
+assert_eq 'variadic_callee_rooted_before_bind' "$(CLJW_GC_TORTURE_ALLOC=1 "$BIN" -e '(reduce + (apply (fn [& xs] xs) [1 2 3 4 5]))')" '15'
echo "ALL phase16_gc_torture PASS"
From 0feb59dbfe30de0cfadd71faaccffba5d49c8c89 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 14:54:45 -0300
Subject: [PATCH 025/466] fix(compat): list HashMap.putAll in the tier ledger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`putAll` shipped in 67128e9f but was not added to `data/compat_tiers.yaml`,
so `check_compat_members` reported "implemented but unlisted" and the gate
went red on both runners — 434 passed, 1 failed, the only failure.
---
data/compat_tiers.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/data/compat_tiers.yaml b/data/compat_tiers.yaml
index e24975e78..578e6ef38 100644
--- a/data/compat_tiers.yaml
+++ b/data/compat_tiers.yaml
@@ -339,7 +339,7 @@ tiers:
phase: 14
files:
surface: runtime/java/util/HashMap.zig
- methods: [clear, containsKey, containsValue, get, getOrDefault, isEmpty, keySet, put, putIfAbsent, remove, size, values]
+ methods: [clear, containsKey, containsValue, get, getOrDefault, isEmpty, keySet, put, putAll, putIfAbsent, remove, size, values]
clojure_peer_vars: []
# java.util.HashSet — mutable set (D-431, host_instance backing a cljw
# persistent-set HAMT; one set Value GC-traced via host_trace, reuses cljw
From 859323a69a45c4b9a5ba3bf6b0547fd117ebd22e Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:00:44 -0300
Subject: [PATCH 026/466] docs(handover): origin is this fork; the archive
remote cannot deliver tags
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`origin` pointed at the archived clojurewasm/ClojureWasm and the maintained
fork was a secondary remote, so every default push and pull aimed at a
repository nobody maintains. `origin` is now BuddhiLW/ClojureWasm and `main`
tracks it.
The archive stays reachable as the `clojurewasm` remote with two guards: its
push URL is `no_push`, and `remote.clojurewasm.tagOpt=--no-tags`. The second
matters — upstream tagged its own v1.10.1 the same day this fork tagged
`50b85214`, and a fetch with tags would overwrite the ref that
`brew install buddhilw/tap/cljw` resolves.
---
.dev/handover.md | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/.dev/handover.md b/.dev/handover.md
index 2a4db11bf..3810c7ff1 100644
--- a/.dev/handover.md
+++ b/.dev/handover.md
@@ -21,8 +21,13 @@
per-unit refresh duty) is RETIRED. CHANGELOG is the release-history SSOT.
- **This repository is a MAINTAINED FORK.** Upstream `clojurewasm/ClojureWasm`
(chaploud) stopped at v1.10.1, its final release, and invited forks under
- EPL-2.0; `BuddhiLW/ClojureWasm` continues from that tree. `origin` = upstream
- (read-only, wound down), `buddhilw` = the maintained remote. Issues and PRs
+ EPL-2.0; `BuddhiLW/ClojureWasm` continues from that tree. **`origin` is this
+ fork** and `main` tracks it, so the plain `git push` / `git pull` do the right
+ thing. The archived source is the `clojurewasm` remote, deliberately
+ hobbled: its push URL is `no_push`, and `remote.clojurewasm.tagOpt=--no-tags`
+ so a fetch can never deliver its `v1.10.1` over ours — that ref is what the
+ Homebrew tap resolves, and losing it would repoint every `brew install`.
+ Issues and PRs
are OPEN here (so CONTRIBUTING's "branches to cherry-pick" no longer
applies), the tap is `BuddhiLW/homebrew-tap` (`brew install
buddhilw/tap/cljw`), and `1.x` continues from v1.10.1. PERF CAMPAIGN (D-450)
From fa202acd1458cb6b22b59bc35574794c5df39ae6 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:02:12 -0300
Subject: [PATCH 027/466] ci: install yq with the other gate deps, and say so
in the docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`check_compat_members` exits 1 when yq is absent — it does not skip — so the
full gate cannot run on a machine without it. Both hosted runner images ship
yq, so the step was green in CI for a reason nobody had written down, and red
for anyone running the gate locally. That is also why the HashMap.putAll drift
in 67128e9f reached CI at all: the check that catches it could not be run
before pushing.
---
.github/workflows/ci.yml | 7 ++++++-
docs/testing.md | 5 +++++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b85f18f0d..077f785f1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -124,13 +124,18 @@ jobs:
# hosted mac runner). Every script is invoked as `bash foo.sh` or via
# `#!/usr/bin/env bash`, so a brew bash 5 on PATH fixes all of them.
# Install only what is absent (fast no-op when the image ships it).
- - name: Install gate deps if missing (ripgrep; bash>=4 on macOS)
+ # yq is listed even though both hosted images ship it: `check_compat_members`
+ # exits 1 without it, so relying on the image means the gate is green here
+ # for a reason nobody wrote down and red on a developer machine.
+ - name: Install gate deps if missing (ripgrep, yq; bash>=4 on macOS)
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
command -v rg >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep; }
+ command -v yq >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq yq; }
else
command -v rg >/dev/null 2>&1 || brew install ripgrep
+ command -v yq >/dev/null 2>&1 || brew install yq
if [ "$(bash -c 'echo "${BASH_VERSINFO[0]}"')" -lt 4 ]; then
brew install bash
fi
diff --git a/docs/testing.md b/docs/testing.md
index 9076e307b..d5a38f1e7 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -27,6 +27,11 @@ bash test/cljw-test --only mutation -- --targets src/runtime/collection/vector.z
bash test/cljw-test --layer 7 -- -Dprop-seed=0xdecafbad -Dprop-iters=5000
```
+You need Zig 0.16.0 plus three things the gate shells out to: **ripgrep**,
+**yq** (mikefarah v4 — `check_compat_members` exits 1 without it, it does not
+skip), and **bash 4 or newer** (macOS ships 3.2). The hosted CI images happen to
+carry yq, which is why its absence only ever showed up locally.
+
## One entry point, and how that is enforced
`test/cljw-test` is the entry point. `test/units.list` is the inventory behind
From 8d3fc769794cee56c2a4b3b03847a5c0eb804efa Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:07:13 -0300
Subject: [PATCH 028/466] Two surfaces tools.reader needs:
PersistentList/create, LNPBR opaque
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Next rung after malli: hive-contracts. Both blockers are reached through
clojure.tools.reader, which taoensso.encore requires and timbre pulls in, so
neither is a hive-contracts accommodation (F-013).
- clojure.lang.PersistentList/create — a list of a collection's elements in
order, routed through clojure.core/apply + list so any seqable is accepted.
tools.reader's edn.clj:191 calls it to build every list it reads.
- clojure.lang.LineNumberingPushbackReader as an OPAQUE class (ADR-0109),
listed under the bare name as well as the FQCN because clj auto-imports
clojure.lang.* into every namespace. reader_types.clj imports it, extends
it, and instance?-tests it twice. cljw readers are not instances of it, so
instance? is uniformly false and the extend registers a branch nothing
dispatches to — the same licence every other opaque entry has: no cljw
value can ever hold that type, so the impl is dead by construction.
extend on an opaque class already worked, so the second fix is one entry, not
a code path.
Gate: 3 blocking failures, none from this change. accepted_divergences and
compat_members are red on this box only because yq is not installed, and
zig_fmt_check names src/testing/prop_print.zig — an untracked file from a
concurrent session. My files are fmt-clean and staged explicitly; src/main.zig
is left unstaged because its only change registers that same file's tests.
Smell-audited: 0: clean — PersistentList.zig mirrors the existing
LazilyPersistentVector surface (same METHODS-table shape, same route-through-
core-fn idiom); the opaque entry is data in an existing closed set.
---
CHANGELOG.md | 16 ++++++
src/runtime/clojure/lang/PersistentList.zig | 59 +++++++++++++++++++++
src/runtime/error/host_class.zig | 7 +++
src/runtime/java/_host_api.zig | 1 +
4 files changed, 83 insertions(+)
create mode 100644 src/runtime/clojure/lang/PersistentList.zig
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa20be818..c972e7d22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to ClojureWasm are documented here. The format follows
[SemVer](https://semver.org/). SemVer compatibility guarantees start at the
first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
+## [Unreleased]
+
+Found by driving [hive-contracts](https://gitea.hive-mcp.com) through the probe
+loop — the next rung after malli. Both are general capabilities reached through
+`clojure.tools.reader`, which `taoensso.encore` (and so `timbre`) requires.
+
+### Added
+
+- **`clojure.lang.PersistentList/create`** — a list of a collection's elements,
+ in order. `clojure.tools.reader.edn` calls it to build every list it reads.
+- **`clojure.lang.LineNumberingPushbackReader`** resolves as an opaque class
+ (ADR-0109), under its bare name as well as its FQCN since clj auto-imports
+ `clojure.lang.*`. cljw readers are not instances of it, so `instance?` is
+ uniformly false and `extend`ing it registers a branch nothing dispatches to —
+ which is what `tools.reader` does with it.
+
## [1.10.2] - 2026-08-12
The fork's first runtime changes. All of them were found by driving
diff --git a/src/runtime/clojure/lang/PersistentList.zig b/src/runtime/clojure/lang/PersistentList.zig
new file mode 100644
index 000000000..a3bf1dd15
--- /dev/null
+++ b/src/runtime/clojure/lang/PersistentList.zig
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Host surface for `clojure.lang.PersistentList` static helpers.
+//!
+//! Backend: impl-only
+//! Impl deps: —
+//! Clojure peer: clojure.core/list, clojure.core/apply
+
+const std = @import("std");
+const host_api = @import("../../java/_host_api.zig");
+const type_descriptor = @import("../../type_descriptor.zig");
+const Value = @import("../../value/value.zig").Value;
+const Runtime = @import("../../runtime.zig").Runtime;
+const Env = @import("../../env.zig").Env;
+const SourceLocation = @import("../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../error/catalog.zig");
+
+/// `(clojure.lang.PersistentList/create coll)` — a list of `coll`'s elements in
+/// order; `()` when empty. Routes through `clojure.core/apply` + `list`, so any
+/// seqable is accepted.
+fn create(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ try error_catalog.checkArity("clojure.lang.PersistentList/create", args, 1, loc);
+ const core = env.findNs("clojure.core") orelse return error.NoVTable;
+ const apply_var = core.resolve("apply") orelse return error.NoVTable;
+ const list_var = core.resolve("list") orelse return error.NoVTable;
+ const vt = rt.vtable orelse return error.NoVTable;
+ return vt.callFn(rt, env, apply_var.deref(), &.{ list_var.deref(), args[0] }, loc);
+}
+
+fn initPersistentList(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent re-run
+ const specs = .{
+ .{ "create", &create },
+ };
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
+ inline for (specs, 0..) |spec, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, spec[0]),
+ .method_val = Value.initBuiltinFn(spec[1]),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.clojure.lang.PersistentList",
+ .descriptor = &descriptor,
+ .init = &initPersistentList,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "clojure.lang.PersistentList",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/error/host_class.zig b/src/runtime/error/host_class.zig
index 470ba08bb..2effb4b70 100644
--- a/src/runtime/error/host_class.zig
+++ b/src/runtime/error/host_class.zig
@@ -177,6 +177,11 @@ pub fn isKnownException(class_name: []const u8) bool {
/// collection classes cljw has no values of. A surface class that IS one of their
/// subtypes still matches through its `host_supertypes` (java.util.ArrayList
/// declares java.util.AbstractList), so membership stays JVM-faithful.
+///
+/// `LineNumberingPushbackReader` is a JVM reader type; cljw's readers are not
+/// instances of it, so `instance?` is uniformly false and an `extend` on it
+/// registers a branch nothing dispatches to. Listed under the bare name too,
+/// since clj auto-imports `clojure.lang.*` into every namespace.
const OPAQUE_CLASSES = std.StaticStringMap(void).initComptime(.{
.{"java.math.BigInteger"},
.{"Integer"},
@@ -189,6 +194,8 @@ const OPAQUE_CLASSES = std.StaticStringMap(void).initComptime(.{
.{"java.lang.Float"},
.{"java.util.AbstractList"},
.{"java.util.Vector"},
+ .{"LineNumberingPushbackReader"},
+ .{"clojure.lang.LineNumberingPushbackReader"},
});
/// True iff `class_name` is a recognised OPAQUE host class (ADR-0109). Such a
diff --git a/src/runtime/java/_host_api.zig b/src/runtime/java/_host_api.zig
index 5c7b5b2e0..06a89cfa3 100644
--- a/src/runtime/java/_host_api.zig
+++ b/src/runtime/java/_host_api.zig
@@ -145,6 +145,7 @@ const clojure_surfaces = [_]type{
@import("../clojure/lang/Murmur3.zig"),
@import("../clojure/lang/LazilyPersistentVector.zig"),
@import("../clojure/lang/PersistentArrayMap.zig"),
+ @import("../clojure/lang/PersistentList.zig"),
};
/// Walk every enumerated surface's `___HOST_EXTENSION` declaration,
From fd195375b445fc88de9ea66f3e02242af306e1e7 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:14:40 -0300
Subject: [PATCH 029/466] test(prop): printFloat round-trips through an
independent parser
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Layer 7 laws for the printer, with std.fmt.parseFloat as the oracle — it
shares no implementation with printFloat, so agreement between them is
evidence rather than a restatement. This is the inverse pair D-577 was
looking for: pr-str/read-string cannot be written at the Zig level because
the reader yields Form while the printer consumes Value, but a float and its
rendering genuinely invert.
Two properties. Every finite float prints to something that parses back to
the same BITS (bit equality, not `==`, so 0.0 and -0.0 stay distinguishable),
and every non-finite one prints to its reader literal. A finite float renders
in float syntax and never in integer syntax, since reading that back yields a
Long.
The generator spans the whole f64 space: two thirds uniform 64-bit patterns,
which reach denormals and NaN payloads, and one third named boundaries —
both zeros, both infinities, floatMin/floatMax/floatTrueMin, and values
straddling the decimal/exponent switch the renderer makes at 1.0E7 and
1.0E-3.
---
src/main.zig | 1 +
src/testing/prop_print.zig | 140 +++++++++++++++++++++++++++++++++++++
2 files changed, 141 insertions(+)
create mode 100644 src/testing/prop_print.zig
diff --git a/src/main.zig b/src/main.zig
index c7476e616..5c5403b69 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -67,6 +67,7 @@ test {
// the unit tests rather than behind a flag someone has to remember.
_ = @import("testing/prop.zig");
_ = @import("testing/prop_collections.zig");
+ _ = @import("testing/prop_print.zig");
_ = @import("app/cli.zig");
_ = @import("app/repl/line_editor.zig");
diff --git a/src/testing/prop_print.zig b/src/testing/prop_print.zig
new file mode 100644
index 000000000..dcadf5f2b
--- /dev/null
+++ b/src/testing/prop_print.zig
@@ -0,0 +1,140 @@
+//! Layer 7 (Property) — laws the printer obeys for every input. ADR-0186.
+//!
+//! The oracle is `std.fmt.parseFloat`, which shares no implementation with
+//! `printFloat`, so agreement between them is evidence rather than a tautology.
+//!
+//! Boundaries the generator crosses on purpose: both zeros, both infinities,
+//! NaN, the smallest denormal, `f64` max, and the decimal/exponent switch the
+//! renderer makes around 1.0E7 and 1.0E-3.
+
+const std = @import("std");
+const prop = @import("prop.zig");
+const build_options = @import("build_options");
+const print = @import("../runtime/print.zig");
+
+const Writer = std.Io.Writer;
+const testing = std.testing;
+
+/// Gate defaults, overridable for a sweep: `zig build test -Dprop-seed=0x… -Dprop-iters=…`.
+fn config() prop.Config {
+ return .{ .seed = build_options.prop_seed, .iters = build_options.prop_iters };
+}
+
+/// Values a uniform bit pattern would essentially never produce, drawn often
+/// enough that every printer branch is reached.
+const specials = [_]f64{
+ 0.0, -0.0, 1.0, -1.0,
+ 0.5, 0.1, 1.0e7, 9.999999e6,
+ 1.0e-3, 9.99e-4, 1.0e20, 1.0e-20,
+ std.math.inf(f64), -std.math.inf(f64),
+ std.math.nan(f64), std.math.floatMin(f64),
+ std.math.floatMax(f64), std.math.floatTrueMin(f64),
+};
+
+/// A slice of f64 spanning the whole space: one third named boundaries, the
+/// rest uniform 64-bit patterns (which include denormals and NaN payloads).
+const FloatSlice = struct {
+ max_len: usize = 24,
+
+ pub fn generate(self: FloatSlice, rand: std.Random, alloc: std.mem.Allocator, size: usize) ![]f64 {
+ const len = rand.uintLessThan(usize, @min(size, self.max_len) + 1);
+ const out = try alloc.alloc(f64, len);
+ for (out) |*slot| {
+ slot.* = if (rand.uintLessThan(u8, 3) == 0)
+ specials[rand.uintLessThan(usize, specials.len)]
+ else
+ @bitCast(rand.int(u64));
+ }
+ return out;
+ }
+
+ /// Drop a chunk, then halve one element toward zero.
+ pub fn shrink(self: FloatSlice, alloc: std.mem.Allocator, v: []f64, out: *std.ArrayList([]f64)) !void {
+ _ = self;
+ if (v.len > 0) {
+ try out.append(alloc, try alloc.dupe(f64, v[0 .. v.len / 2]));
+ try out.append(alloc, try alloc.dupe(f64, v[v.len / 2 ..]));
+ }
+ for (v, 0..) |x, i| {
+ if (x == 0 or !std.math.isFinite(x)) continue;
+ const smaller = try alloc.dupe(f64, v);
+ smaller[i] = x / 2.0;
+ try out.append(alloc, smaller);
+ break;
+ }
+ }
+
+ pub fn free(self: FloatSlice, alloc: std.mem.Allocator, v: []f64) void {
+ _ = self;
+ alloc.free(v);
+ }
+
+ pub fn describe(self: FloatSlice, alloc: std.mem.Allocator, v: []f64) ![]u8 {
+ _ = self;
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(alloc);
+ try buf.append(alloc, '[');
+ for (v, 0..) |x, i| {
+ if (i > 0) try buf.appendSlice(alloc, ", ");
+ try buf.print(alloc, "{d} (0x{x})", .{ x, @as(u64, @bitCast(x)) });
+ }
+ try buf.append(alloc, ']');
+ return buf.toOwnedSlice(alloc);
+ }
+};
+
+fn renderFloat(buf: []u8, f: f64) ![]const u8 {
+ var w: Writer = .fixed(buf);
+ try print.printFloat(&w, f);
+ return w.buffered();
+}
+
+/// Every finite float prints to something that parses back to the SAME bits,
+/// and every non-finite one prints to its reader literal.
+fn roundTrips(_: void, xs: []f64) anyerror!void {
+ var buf: [512]u8 = undefined;
+ for (xs) |f| {
+ const s = try renderFloat(&buf, f);
+ if (std.math.isNan(f)) {
+ try testing.expectEqualStrings("##NaN", s);
+ } else if (std.math.isPositiveInf(f)) {
+ try testing.expectEqualStrings("##Inf", s);
+ } else if (std.math.isNegativeInf(f)) {
+ try testing.expectEqualStrings("##-Inf", s);
+ } else {
+ const back = std.fmt.parseFloat(f64, s) catch return error.PrintedFloatDoesNotParse;
+ // Bit equality, not `==`: it separates 0.0 from -0.0, which `==` does not.
+ if (@as(u64, @bitCast(back)) != @as(u64, @bitCast(f))) return error.FloatRoundTripLostBits;
+ }
+ }
+}
+
+/// A finite float always prints something a Clojure reader can take back as a
+/// float: a digit is present, and the only non-digit characters are the ones
+/// float syntax allows.
+fn readableShape(_: void, xs: []f64) anyerror!void {
+ var buf: [512]u8 = undefined;
+ for (xs) |f| {
+ if (!std.math.isFinite(f)) continue;
+ const s = try renderFloat(&buf, f);
+ if (s.len == 0) return error.EmptyRendering;
+ var saw_digit = false;
+ for (s) |c| switch (c) {
+ '0'...'9' => saw_digit = true,
+ '.', '-', '+', 'E' => {},
+ else => return error.UnexpectedCharacterInFloat,
+ };
+ if (!saw_digit) return error.NoDigitsInFloat;
+ // A float must not render as an integer literal, or reading it back
+ // yields a Long.
+ if (std.mem.indexOfAny(u8, s, ".E") == null) return error.FloatRendersAsInteger;
+ }
+}
+
+test "property: printFloat output re-parses to the same f64" {
+ try prop.forAll([]f64, testing.allocator, config(), FloatSlice{}, {}, roundTrips);
+}
+
+test "property: a finite float renders as float syntax, never integer syntax" {
+ try prop.forAll([]f64, testing.allocator, config(), FloatSlice{}, {}, readableShape);
+}
From 4b3a4c5135a2461a1a1c7e320173dcd571ba02c9 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:19:55 -0300
Subject: [PATCH 030/466] style(prop): zig fmt the printFloat property fixtures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`zig fmt` owns column alignment in an array literal; the hand-aligned
`specials` table disagreed with it and `ci_gate.sh` step 1 rejected the
file. Same 18 values in the same order — whitespace only.
---
src/testing/prop_print.zig | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/src/testing/prop_print.zig b/src/testing/prop_print.zig
index dcadf5f2b..f8b6136dd 100644
--- a/src/testing/prop_print.zig
+++ b/src/testing/prop_print.zig
@@ -23,12 +23,11 @@ fn config() prop.Config {
/// Values a uniform bit pattern would essentially never produce, drawn often
/// enough that every printer branch is reached.
const specials = [_]f64{
- 0.0, -0.0, 1.0, -1.0,
- 0.5, 0.1, 1.0e7, 9.999999e6,
- 1.0e-3, 9.99e-4, 1.0e20, 1.0e-20,
- std.math.inf(f64), -std.math.inf(f64),
- std.math.nan(f64), std.math.floatMin(f64),
- std.math.floatMax(f64), std.math.floatTrueMin(f64),
+ 0.0, -0.0, 1.0, -1.0,
+ 0.5, 0.1, 1.0e7, 9.999999e6,
+ 1.0e-3, 9.99e-4, 1.0e20, 1.0e-20,
+ std.math.inf(f64), -std.math.inf(f64), std.math.nan(f64), std.math.floatMin(f64),
+ std.math.floatMax(f64), std.math.floatTrueMin(f64),
};
/// A slice of f64 spanning the whole space: one third named boundaries, the
From 39677a2fed54ce0677ba6bf407d53e7d9690381a Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:32:40 -0300
Subject: [PATCH 031/466] test(golden): pin the HAMT print path, above both
promotion boundaries
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first print.zig mutation sweep left two survivors in `printHamtEntries`
(`:1480` separator, `:1496` child loop bound), and the reason was the
fixtures, not the code: every golden collection case — including the five
added the same hour — sat below the promotion boundaries, so the HAMT walk
had no snapshot at all. `{:a 1 :b 2 :c 3 :d 4}` is an ArrayMap; `#{1 2 3 4
5}` is an array set.
Both cases use 40 integer entries: above the boundaries (map 16, set 8) and
above the 32 root slots, so the root carries child nodes and the walk
recurses. Integer keys keep the order a pure function of the values.
---
test/golden/cases/printer_hamt_map.clj | 11 +++++++++++
test/golden/cases/printer_hamt_map.expected | 9 +++++++++
test/golden/cases/printer_hamt_set.clj | 10 ++++++++++
test/golden/cases/printer_hamt_set.expected | 8 ++++++++
4 files changed, 38 insertions(+)
create mode 100644 test/golden/cases/printer_hamt_map.clj
create mode 100644 test/golden/cases/printer_hamt_map.expected
create mode 100644 test/golden/cases/printer_hamt_set.clj
create mode 100644 test/golden/cases/printer_hamt_set.expected
diff --git a/test/golden/cases/printer_hamt_map.clj b/test/golden/cases/printer_hamt_map.clj
new file mode 100644
index 000000000..4eb513091
--- /dev/null
+++ b/test/golden/cases/printer_hamt_map.clj
@@ -0,0 +1,11 @@
+;; Maps ABOVE the ArrayMap->HAMT promotion boundary (16 entries), which the
+;; printer walks with a different routine than the array form. 40 keys exceed
+;; the 32 root slots, so the root carries child nodes and the walk recurses.
+;; Keep the keys integers: their order is hash-determined, and integer hashing
+;; is a pure function of the value, so this snapshot holds on every platform.
+(def m (into {} (map (fn [i] [i (* i i)]) (range 40))))
+(prn (count m))
+(prn m)
+(prn (into {} (map (fn [i] [i i]) (range 17))))
+(binding [*print-length* 5] (prn m))
+(binding [*print-level* 1] (prn (into {} (map (fn [i] [i {:v i}]) (range 20)))))
diff --git a/test/golden/cases/printer_hamt_map.expected b/test/golden/cases/printer_hamt_map.expected
new file mode 100644
index 000000000..bcb09dd7b
--- /dev/null
+++ b/test/golden/cases/printer_hamt_map.expected
@@ -0,0 +1,9 @@
+--- exit ---
+0
+--- stdout ---
+40
+{0 0, 39 1521, 4 16, 15 225, 36 1296, 29 841, 19 361, 14 196, 30 900, 37 1369, 8 64, 7 49, 20 400, 27 729, 1 1, 24 576, 21 441, 31 961, 32 1024, 22 484, 33 1089, 13 169, 6 36, 28 784, 25 625, 34 1156, 17 289, 3 9, 12 144, 2 4, 23 529, 35 1225, 11 121, 9 81, 5 25, 26 676, 16 256, 38 1444, 10 100, 18 324}
+{0 0, 7 7, 1 1, 4 4, 15 15, 13 13, 6 6, 3 3, 14 14, 16 16, 10 10, 8 8, 12 12, 2 2, 11 11, 9 9, 5 5}
+{0 0, 39 1521, 4 16, 15 225, 36 1296, ...}
+{0 #, 7 #, 1 #, 4 #, 15 #, 13 #, 6 #, 19 #, 14 #, 16 #, 8 #, 17 #, 3 #, 12 #, 2 #, 11 #, 9 #, 5 #, 10 #, 18 #}
+--- stderr ---
diff --git a/test/golden/cases/printer_hamt_set.clj b/test/golden/cases/printer_hamt_set.clj
new file mode 100644
index 000000000..d97930f77
--- /dev/null
+++ b/test/golden/cases/printer_hamt_set.clj
@@ -0,0 +1,10 @@
+;; Sets ABOVE the array-set->HAMT promotion boundary (8 elements). 40 elements
+;; exceed the 32 root slots, so the root carries child nodes and the walk
+;; recurses. The separator between set elements is " ", where a map's is ", " —
+;; both come from the same walk, so this file is what pins the set half of it.
+;; Keep the elements integers, per printer_hamt_map.clj.
+(def s (into #{} (range 40)))
+(prn (count s))
+(prn s)
+(prn (into #{} (range 9)))
+(binding [*print-length* 4] (prn s))
diff --git a/test/golden/cases/printer_hamt_set.expected b/test/golden/cases/printer_hamt_set.expected
new file mode 100644
index 000000000..aae3a6833
--- /dev/null
+++ b/test/golden/cases/printer_hamt_set.expected
@@ -0,0 +1,8 @@
+--- exit ---
+0
+--- stdout ---
+40
+#{0 39 4 15 36 29 19 14 30 37 8 7 20 27 1 24 21 31 32 22 33 13 6 28 25 34 17 3 12 2 23 35 11 9 5 26 16 38 10 18}
+#{0 7 1 4 6 3 2 5 8}
+#{0 39 4 15 ...}
+--- stderr ---
From 817e6a044ed967146340a935f5259818bcd981b3 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:34:29 -0300
Subject: [PATCH 032/466] test(golden): pin truncation at index 0, where the
separator must not appear
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`lengthTruncated` writes the separator before `...` only when `i > 0`. The
sweep left that guard unconstrained and it looked equivalent — an empty
collection never truncates, so what could reach it? *print-length* 0 on a
NON-empty collection can: the limit is hit at i == 0, and the marker is
written with nothing before it.
Values pinned against JVM Clojure 1.12, which cljw matches on all eleven
lines including (range 5) printing as (...) and the empty forms not
truncating at all.
---
test/golden/cases/printer_length_zero.clj | 10 ++++++++++
test/golden/cases/printer_length_zero.expected | 15 +++++++++++++++
2 files changed, 25 insertions(+)
create mode 100644 test/golden/cases/printer_length_zero.clj
create mode 100644 test/golden/cases/printer_length_zero.expected
diff --git a/test/golden/cases/printer_length_zero.clj b/test/golden/cases/printer_length_zero.clj
new file mode 100644
index 000000000..1e1c17d18
--- /dev/null
+++ b/test/golden/cases/printer_length_zero.clj
@@ -0,0 +1,10 @@
+;; Truncation at index 0 — *print-length* 0 on a NON-empty collection, the one
+;; input where the truncation marker is written with nothing before it and so
+;; must carry no separator. An empty collection does not truncate at all.
+;; Values pinned against JVM Clojure 1.12: [...] {...} #{...} (...) and [].
+(binding [*print-length* 0]
+ (prn [1 2 3]) (prn {:a 1 :b 2}) (prn #{1 2 3}) (prn '(1 2 3)) (prn (range 5)))
+(binding [*print-length* 0]
+ (prn []) (prn {}) (prn #{}) (prn '()))
+(binding [*print-length* 0] (prn [[1 2] [3 4]]))
+(binding [*print-length* 1] (prn [1 2 3]))
diff --git a/test/golden/cases/printer_length_zero.expected b/test/golden/cases/printer_length_zero.expected
new file mode 100644
index 000000000..a67be74cc
--- /dev/null
+++ b/test/golden/cases/printer_length_zero.expected
@@ -0,0 +1,15 @@
+--- exit ---
+0
+--- stdout ---
+[...]
+{...}
+#{...}
+(...)
+(...)
+[]
+{}
+#{}
+()
+[...]
+[1 ...]
+--- stderr ---
From 0c0178f13894f92d50d2f48160c744bef260c924 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:37:06 -0300
Subject: [PATCH 033/466] test(golden): pin lazy seqs nested inside collections
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The realize-before-print walk over a map's keys had no snapshot, so the
branch the code comment describes — "potpuri's build-tree assocs a lazy
children seq" — was carried by nothing. Each line reaches a different arm:
map value, sorted-map value, vector element, nested map, map entry, set.
The nil key is deliberate. That walk terminates on the empty list, whose
first is nil, so an overrun by one iteration looks up exactly nil; with no
nil key in any fixture the overrun has nowhere to show. All eight lines
match JVM Clojure 1.12.
---
test/golden/cases/printer_lazy_nested.clj | 20 +++++++++++++++++++
.../golden/cases/printer_lazy_nested.expected | 12 +++++++++++
2 files changed, 32 insertions(+)
create mode 100644 test/golden/cases/printer_lazy_nested.clj
create mode 100644 test/golden/cases/printer_lazy_nested.expected
diff --git a/test/golden/cases/printer_lazy_nested.clj b/test/golden/cases/printer_lazy_nested.clj
new file mode 100644
index 000000000..3f8b61c91
--- /dev/null
+++ b/test/golden/cases/printer_lazy_nested.clj
@@ -0,0 +1,20 @@
+;; Lazy seqs held INSIDE collections, which the printer realizes in place
+;; before rendering. Each line puts the lazy seq somewhere a different branch
+;; of the realize walk has to reach: map value, sorted-map value, vector
+;; element, nested map, map entry.
+;;
+;; The `nil` key is a boundary probe, not decoration: the walk over a map's
+;; keys ends on the empty list, whose `first` is nil, so a walk that ran one
+;; iteration too long would look up exactly this key. With nil absent the
+;; overrun is invisible; with it present and holding a lazy value, any
+;; difference has somewhere to show.
+;;
+;; Values pinned against JVM Clojure 1.12.
+(prn {:a (map inc [1 2 3]) :b [4 5]})
+(prn {nil (map inc [1 2 3])})
+(prn (sorted-map :a (map inc [1 2 3]) :b 2))
+(prn [(map inc [1 2]) (filter odd? (range 6))])
+(prn {:a {:b (map inc [1 2])}})
+(prn (first {:k (map inc [1 2])}))
+(prn #{[(map inc [1 2])]})
+(binding [*print-length* 2] (prn {:a (map inc (range 10))}))
diff --git a/test/golden/cases/printer_lazy_nested.expected b/test/golden/cases/printer_lazy_nested.expected
new file mode 100644
index 000000000..e9c60cfa4
--- /dev/null
+++ b/test/golden/cases/printer_lazy_nested.expected
@@ -0,0 +1,12 @@
+--- exit ---
+0
+--- stdout ---
+{:a (2 3 4), :b [4 5]}
+{nil (2 3 4)}
+{:a (2 3 4), :b 2}
+[(2 3) (1 3 5)]
+{:a {:b (2 3)}}
+[:k (2 3)]
+#{[(2 3)]}
+{:a (1 2 ...)}
+--- stderr ---
From eeb0fca89f23478aff2d3d9cd72f01489a067962 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:52:32 -0300
Subject: [PATCH 034/466] fix(lint): drop the two deprecated std.mem aliases
the linter rejects
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`zig build lint -- --max-warnings 0` failed the macOS CI leg on two
no_deprecated warnings: analyzer.zig:727 indexOfScalar and
prop_print.zig:129 indexOfAny. Both are pure aliases in 0.16 std
(`pub const indexOfScalar = findScalar;`, mem.zig:1216/1318), so the
rename is behaviour-identical. No other deprecated alias remains in src/.
The linter only runs on macOS (test/run_all.sh gates it on `uname -s` ==
Darwin), so neither warning could appear on a Linux gate, local or CI —
which is why one of them rode in unnoticed and the other survived a full
green Linux leg in the same run that failed on mac.
---
src/eval/analyzer/analyzer.zig | 2 +-
src/testing/prop_print.zig | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/eval/analyzer/analyzer.zig b/src/eval/analyzer/analyzer.zig
index 150bcbc00..10d7f1c2d 100644
--- a/src/eval/analyzer/analyzer.zig
+++ b/src/eval/analyzer/analyzer.zig
@@ -724,7 +724,7 @@ pub fn resolveClassValue(rt: *Runtime, env: *Env, sym_ns: ?[]const u8, sym_name:
/// or the Var is absent, or the Var holds a non-protocol.
fn protocolByInterfaceName(env: *Env, ns_name: []const u8, pname: []const u8) ?Value {
if (protocolVarIn(env, ns_name, pname)) |v| return v;
- if (std.mem.indexOfScalar(u8, ns_name, '_') == null) return null;
+ if (std.mem.findScalar(u8, ns_name, '_') == null) return null;
var buf: [256]u8 = undefined;
if (ns_name.len > buf.len) return null;
@memcpy(buf[0..ns_name.len], ns_name);
diff --git a/src/testing/prop_print.zig b/src/testing/prop_print.zig
index f8b6136dd..285c20ce0 100644
--- a/src/testing/prop_print.zig
+++ b/src/testing/prop_print.zig
@@ -126,7 +126,7 @@ fn readableShape(_: void, xs: []f64) anyerror!void {
if (!saw_digit) return error.NoDigitsInFloat;
// A float must not render as an integer literal, or reading it back
// yields a Long.
- if (std.mem.indexOfAny(u8, s, ".E") == null) return error.FloatRendersAsInteger;
+ if (std.mem.findAny(u8, s, ".E") == null) return error.FloatRendersAsInteger;
}
}
From 98d750e0ae16356566e9bcf66e611a0234dc97f2 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 15:59:47 -0300
Subject: [PATCH 035/466] docs(changelog): fold the tools.reader surfaces into
1.10.2
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
They sat under [Unreleased] while being committed in 8d3fc769, which is
reachable from where v1.10.2 will be tagged — so the release would have
shipped two features its own changelog called unreleased. CHANGELOG is the
release-history SSOT, so it has to describe what the tag actually contains.
Also records this release's test-layer work and the `--oracle` flag, and
corrects the section's opening claim: with the tools.reader entries folded
in, "all of them were found by driving malli" was no longer true.
---
CHANGELOG.md | 46 ++++++++++++++++++++++++++++++----------------
1 file changed, 30 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c972e7d22..ec8801028 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,27 +7,14 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
-Found by driving [hive-contracts](https://gitea.hive-mcp.com) through the probe
-loop — the next rung after malli. Both are general capabilities reached through
-`clojure.tools.reader`, which `taoensso.encore` (and so `timbre`) requires.
-
-### Added
-
-- **`clojure.lang.PersistentList/create`** — a list of a collection's elements,
- in order. `clojure.tools.reader.edn` calls it to build every list it reads.
-- **`clojure.lang.LineNumberingPushbackReader`** resolves as an opaque class
- (ADR-0109), under its bare name as well as its FQCN since clj auto-imports
- `clojure.lang.*`. cljw readers are not instances of it, so `instance?` is
- uniformly false and `extend`ing it registers a branch nothing dispatches to —
- which is what `tools.reader` does with it.
-
## [1.10.2] - 2026-08-12
-The fork's first runtime changes. All of them were found by driving
+The fork's first runtime changes. Nearly all were found by driving
[malli](https://github.com/metosin/malli) through the
`test/conformance/verified_projects/` probe loop — malli is the first schema
library on the ladder, and the blockers it surfaced are general capabilities,
-not malli accommodations (F-013).
+not malli accommodations (F-013). The last two entries under Added come from the
+next rung, hive-contracts.
### Fixed
@@ -80,6 +67,18 @@ not malli accommodations (F-013).
clauses are dead by construction — but portable code guarding a bounded wait
now loads instead of failing analysis.
+The last two came from the next rung of the same loop,
+[hive-contracts](https://gitea.hive-mcp.com). Both are reached through
+`clojure.tools.reader`, which `taoensso.encore` — and so `timbre` — requires.
+
+- **`clojure.lang.PersistentList/create`** — a list of a collection's elements,
+ in order. `clojure.tools.reader.edn` calls it to build every list it reads.
+- **`clojure.lang.LineNumberingPushbackReader`** resolves as an opaque class
+ (ADR-0109), under its bare name as well as its FQCN since clj auto-imports
+ `clojure.lang.*`. cljw readers are not instances of it, so `instance?` is
+ uniformly false and `extend`ing it registers a branch nothing dispatches to —
+ which is what `tools.reader` does with it.
+
### Internal
- `data/core_surface_extras.txt` regenerated, 104 → 106 names: `monitor-enter`
@@ -97,6 +96,21 @@ not malli accommodations (F-013).
- `test/e2e/phase16_gc_torture.sh` sets and clears environment variables with
bash builtins instead of `env`, which is hijackable by a `~/.local/bin/env`
on PATH.
+- Two deprecated `std.mem` aliases replaced by their current names
+ (`indexOfScalar` → `findScalar`, `indexOfAny` → `findAny`). Both are `pub
+ const` aliases in 0.16 std, so the rename is behaviour-identical; the linter
+ that rejects them runs only on the macOS gate leg, which is why they were
+ reachable at all.
+- The printer gains Layer 6/7 coverage (ADR-0186): a property asserting every
+ `printFloat` output parses back to the same f64 — bit equality, so `0.0` and
+ `-0.0` stay distinct — and golden snapshots for the HAMT print path above both
+ promotion boundaries, for truncation at `*print-length*` 0, and for lazy seqs
+ nested inside collections. The first mutation sweep of `print.zig` scored
+ 36.4%; these pin three of the survivors it named.
+- `scripts/mutation/run.sh` takes `--oracle`. The kill oracle was hardcoded to
+ `zig build test`, so a mutation in a rendering path could never be killed by
+ the golden layer and every printing line scored as unconstrained. `unit`
+ remains the default; `unit+golden` also renders the Layer 6 cases.
### Changed
From c00825b3ea367d7fc8038752cf738bd76c7daca6 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 16:20:10 -0300
Subject: [PATCH 036/466] Extend a protocol TO a protocol (ADR-0187)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`defprotocol` compiles to an interface on the JVM, so a library can name
that interface as an `extend` target and mean "every type implementing
it". clojure.tools.reader does exactly that:
;; reader_types.clj:208
(extend-protocol ReaderCoercer
Object (to-rdr [r] ...)
clojure.tools.reader.reader_types.Reader (to-rdr [r] r)
String (to-rdr [s] (string-reader s))
java.io.Reader (to-rdr [r] (PushbackReader. r)))
cljw has no interfaces — protocols are dispatch tables keyed by
TypeDescriptor (ADR-0007) — so the second clause raised
"__extend-type!: expected type_descriptor, got protocol". That clause
only became REACHABLE with 67128e9f, which taught resolveClassValue to
resolve a protocol by its generated interface name; before that the
symbol did not resolve at all. The two are entangled and a bisect would
blame the resolution fix rather than the missing capability.
The enabling observation is that protocol membership is a property of
the TYPE, not the value: `td.protocol_impls` is the declared-interface
SSOT (D-190 / ADR-0068). So the fallback needs neither the receiver
value nor a reverse index of implementors.
- `__extend-type!` accepts a `.protocol` target and records
{target_proto, impls} in `rt.protocol_target_exts`, keyed by the
PROVIDING protocol's fqcn. The table lives on Runtime rather than in
ProtocolDescriptor because the latter is an extern struct with a
fixed ABI.
- `dispatch` consults it after a per-type miss and BEFORE the Object
default. That order is the faithful one: on the JVM an interface
impl is a real per-type impl and outranks an Object fallback.
- `protocol.satisfiesName` is `satisfies` by name, for callers holding
an fqcn rather than a descriptor.
No memoization. The scan runs only where cljw previously raised, the
per-protocol entry list is 0-2 long in practice, and copying impls onto
a descriptor mid-dispatch would bump protocol_generation — invalidating
every CallSite cache — during a call that may be re-entrant.
Deliberately NOT reusing the load-only-no-op contract (ADR-0109 /
AD-023). Every existing inert target shares one licence: no cljw value
can ever hold that type, so the impl is dead by construction. A cljw
value genuinely CAN satisfy a protocol, so a silent no-op would be the
first case where "inert" hides a live divergence instead of describing a
dead branch. It happens to be invisible in tools.reader — both coercer
protocols guard their Object branch with `(satisfies? Reader rdr)` and
return what the protocol clause returns — but that is one library's
luck, not a guarantee.
Four cases added to the existing phase14_extend_protocol_targets.sh,
including protocol_target_extend_after: the type is extended to Q AFTER
P was extended to Q, which a registration-time propagation design would
silently miss. 8/8 green, verified_projects 20/20.
Smell-audited: 0: clean — one side table, one miss-path branch, one
by-name variant of an existing walk; no existing arm changed.
---
.../0187_protocol_as_extend_target.md | 120 ++++++++++++++++++
src/lang/primitive/protocol.zig | 50 ++++++--
src/runtime/dispatch.zig | 18 +++
src/runtime/protocol.zig | 8 +-
src/runtime/runtime.zig | 32 +++++
test/e2e/phase14_extend_protocol_targets.sh | 22 ++++
6 files changed, 241 insertions(+), 9 deletions(-)
create mode 100644 .dev/decisions/0187_protocol_as_extend_target.md
diff --git a/.dev/decisions/0187_protocol_as_extend_target.md b/.dev/decisions/0187_protocol_as_extend_target.md
new file mode 100644
index 000000000..886d09cd9
--- /dev/null
+++ b/.dev/decisions/0187_protocol_as_extend_target.md
@@ -0,0 +1,120 @@
+# 0187 — Extend a protocol to another protocol via a dispatch-miss fallback
+
+- **Status**: Proposed
+- **Date**: 2026-08-12
+- **Author**: BuddhiLW
+- **Tags**: protocols, dispatch, host-compat
+
+## Context
+
+`defprotocol` on the JVM generates an interface, so a library can name that
+interface as an `extend` target and mean "every type that implements it":
+
+```clojure
+;; clojure.tools.reader.reader-types:208
+(extend-protocol ReaderCoercer
+ Object (to-rdr [r] ...)
+ clojure.tools.reader.reader_types.Reader (to-rdr [r] r)
+ String (to-rdr [s] (string-reader s))
+ java.io.Reader (to-rdr [r] (PushbackReader. r)))
+```
+
+cljw has no interfaces. Protocols are dispatch tables keyed by
+`TypeDescriptor`, and `__extend-type!` requires a `.type_descriptor` target,
+so the second clause raises `expected type_descriptor, got protocol`.
+
+The clause became *reachable* only once commit 67128e9f taught
+`resolveClassValue` to resolve a protocol by its generated interface name.
+Before that the symbol did not resolve at all, so the failure appeared one
+layer earlier. The two changes are entangled: a bisect would blame the
+resolution fix rather than the missing capability.
+
+This blocks `clojure.tools.reader`, and through it `encore`, `timbre`, and
+every hive component that logs. hive-contracts is otherwise green on cljw:
+9 of its 10 namespaces load, and the tenth fails only through a single
+`taoensso.timbre` require behind one `log/warn` call.
+
+The enabling observation is that protocol membership is a property of the
+TYPE, not the value: `td.protocol_impls` is the declared-interface SSOT
+(D-190 / ADR-0068) and `protocol.satisfies` already answers "does this
+descriptor satisfy P" from the descriptor alone. So the fallback needs no
+access to the receiver value and no reverse index of implementors.
+
+## Decision
+
+`__extend-type!` accepts a `.protocol` target. The impls are not written to
+any descriptor — there is no descriptor to write them to. They are recorded
+on the Runtime in a side table keyed by the PROVIDING protocol's fqcn:
+
+ protocol_target_exts : fqcn(A) -> [{ target_proto: fqcn(B), impls }]
+
+`dispatch.dispatch` consults that table after a per-type miss and BEFORE the
+`Object` universal default: for each recorded entry whose `target_proto` the
+receiver's descriptor satisfies, the matching `(protocol, method)` impl is
+called. Ordering is deliberate — on the JVM an interface impl is a real
+per-type impl and beats an `Object` default, so checking it first is the
+faithful order.
+
+The table is stored on `Runtime` rather than in `ProtocolDescriptor` because
+the latter is an `extern struct` with a fixed ABI; a side table keeps the
+wire format untouched.
+
+No memoization. The scan runs only on the already-failing miss path, the
+per-protocol entry list is 0–2 long in practice, and copying impls onto the
+descriptor mid-dispatch would mutate a live table and bump
+`protocol_generation` — invalidating every CallSite cache in the process —
+during a call that may be re-entrant.
+
+This is the same value ADR-0114 Decision C argued for in the neighbouring case
+(`clojure.lang` interfaces as extend targets): membership must be DERIVED, never
+hand-maintained, because "a hardcoded list is a denylist-by-omission" and the
+omission is silent. Deriving membership from `satisfies?` at dispatch time cannot
+drift and cannot silently omit an implementor — including one extended to B after
+this extension was recorded. The mechanism differs from C's synthetic-supertype
+endgame because a protocol's implementor set is OPEN and grows at runtime, so
+there is no fixed chain to write into once.
+
+## Alternatives considered
+
+### Alternative A — registration-time propagation
+
+- **Sketch**: when A is extended to B, copy A's impls onto every type already
+ extended to B, and re-copy whenever a new type is later extended to B.
+- **Why rejected**: needs protocols to track their implementors, which they do
+ not today, and correctness depends on catching BOTH orderings
+ (A-extends-B before/after T-extends-B). Zero hot-path cost is not worth a
+ new reverse index and a second write path that can silently miss a case.
+
+### Alternative B — load-only no-op
+
+- **Sketch**: treat a protocol target the way AD-023 treats a `host_inert`
+ `java.util.Map` target (ADR-0103 / ADR-0109) — load the section, never
+ dispatch it, record an accepted divergence.
+- **Why rejected**: every existing load-only-no-op target shares one licence —
+ no cljw value can ever have that type, so the impl is dead by construction.
+ A cljw map genuinely is not a `java.util.Map`; a cljw value genuinely CAN
+ satisfy protocol B. Reusing the inert contract here would be the first case
+ where "inert" hides a live divergence rather than describing a dead branch,
+ which would also corrode what AD-023 means. It happens to be invisible in
+ tools.reader — both coercer protocols guard their `Object` branch with
+ `(satisfies? Reader rdr)` and return exactly what the protocol clause
+ returns — but that is one library's luck, not a general guarantee.
+
+## Consequences
+
+- **Positive**: `clojure.tools.reader` loads, unblocking encore, timbre, and
+ hive-contracts. A common clj idiom stops being a hard error.
+- **Negative**: the miss path grows a scan. It runs only where cljw would
+ previously have raised, so no currently-succeeding dispatch slows down.
+- **Neutral / follow-ups**: `satisfies?` still answers from the descriptor and
+ will report false for a type that only gets its impls through this fallback.
+ Whether `(satisfies? ReaderCoercer x)` should follow the same rule is a
+ separate question, tracked rather than decided here.
+
+## Affected files
+
+- `src/lang/primitive/protocol.zig` — accept a `.protocol` target; record it.
+- `src/runtime/runtime.zig` — the `protocol_target_exts` side table + deinit.
+- `src/runtime/dispatch.zig` — the miss-path consult, before the Object default.
+- `src/runtime/protocol.zig` — a by-name `satisfiesName` beside `satisfies`.
+- `test/e2e/` — a protocol-to-protocol extend case, both orderings.
diff --git a/src/lang/primitive/protocol.zig b/src/lang/primitive/protocol.zig
index 61e1bf22d..d2e32bc0a 100644
--- a/src/lang/primitive/protocol.zig
+++ b/src/lang/primitive/protocol.zig
@@ -17,7 +17,8 @@
const std = @import("std");
const value = @import("../../runtime/value/value.zig");
const Value = value.Value;
-const Runtime = @import("../../runtime/runtime.zig").Runtime;
+const runtime_mod = @import("../../runtime/runtime.zig");
+const Runtime = runtime_mod.Runtime;
const env_mod = @import("../../runtime/env.zig");
const Env = env_mod.Env;
const error_mod = @import("../../runtime/error/info.zig");
@@ -178,7 +179,7 @@ pub fn extendType(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
// defaults). nil resolves below to the per-Tag nil descriptor, the same
// one dispatch resolves a nil receiver to (dispatch.zig resolveDescriptor),
// so the registered impls dispatch on `(m nil)`.
- if (args[0].tag() != .type_descriptor and args[0].tag() != .nil) {
+ if (args[0].tag() != .type_descriptor and args[0].tag() != .nil and args[0].tag() != .protocol) {
return error_catalog.raise(.type_arg_invalid, loc, .{
.fn_name = "__extend-type!",
.expected = "type_descriptor",
@@ -213,10 +214,15 @@ pub fn extendType(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
if (host_class_mod.isKnownOpaqueClass(fqcn)) return args[0];
}
}
- const td: *td_mod.TypeDescriptor = if (args[0].tag() == .nil)
- try rt.nativeDescriptor(.nil)
- else
- @constCast(td_mod.asTypeDescriptorRef(args[0]));
+ // ADR-0187: a PROTOCOL target has no descriptor to write onto — its
+ // implementor set is open and grows at runtime — so the impls are recorded
+ // on the Runtime and consulted by `dispatch` on a per-type miss. Resolved
+ // after the impls are built, below.
+ const td: ?*td_mod.TypeDescriptor = switch (args[0].tag()) {
+ .nil => try rt.nativeDescriptor(.nil),
+ .protocol => null,
+ else => @constCast(td_mod.asTypeDescriptorRef(args[0])),
+ };
const proto_name: []const u8 = switch (args[1].tag()) {
.protocol => protocol_mod.asProtocol(args[1]).fqcn(),
.symbol => host_interface.canonicalName(symbol_mod.asSymbol(args[1]).name) orelse {
@@ -271,7 +277,15 @@ pub fn extendType(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
.method_val = fn_val,
};
}
- try protocol_mod.extendTypeWithImpls(rt, td, new_impls);
+ // ADR-0187: protocol target — keep `new_impls` (the side table owns it)
+ // and record it against the PROVIDING protocol. Bump the generation so
+ // live CallSite caches re-resolve and can reach the new fallback.
+ if (td == null) {
+ try recordProtocolTargetExt(rt, proto_name, protocol_mod.asProtocol(args[0]).fqcn(), new_impls);
+ rt.protocol_generation +%= 1;
+ return args[0];
+ }
+ try protocol_mod.extendTypeWithImpls(rt, td.?, new_impls);
// extendTypeWithImpls @memcpy's into a fresh combined slice on
// `rt.gc.infra` and swaps it onto `td.method_table`; the input
// `new_impls` slice is no longer referenced, so free it back.
@@ -280,10 +294,30 @@ pub fn extendType(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
// MARKER protocol (`Sequential`, no method_table entry) is still
// detectable, and `protocol_impls` stays an honest "implements P" set
// (D-190 / ADR-0068).
- try protocol_mod.addProtocolImpl(rt, td, proto_name);
+ try protocol_mod.addProtocolImpl(rt, td.?, proto_name);
return args[0];
}
+/// Append `{target_proto, impls}` to `rt.protocol_target_exts[provider]`
+/// (ADR-0187). Takes ownership of `impls`. A repeated (provider, target) pair
+/// appends rather than replaces, so the newest extension is found first.
+fn recordProtocolTargetExt(
+ rt: *Runtime,
+ provider: []const u8,
+ target_proto: []const u8,
+ impls: []const td_mod.TypeDescriptor.MethodEntry,
+) !void {
+ const gpa = rt.gc.infra;
+ const gop = try rt.protocol_target_exts.getOrPut(gpa, provider);
+ const old: []runtime_mod.ProtocolTargetExt = if (gop.found_existing) gop.value_ptr.* else &.{};
+ const grown = try gpa.alloc(runtime_mod.ProtocolTargetExt, old.len + 1);
+ // Newest first: dispatch takes the first satisfying entry.
+ grown[0] = .{ .target_proto = target_proto, .impls = impls };
+ @memcpy(grown[1..], old);
+ if (old.len > 0) gpa.free(old);
+ gop.value_ptr.* = grown;
+}
+
/// `(rt/__native-type tag-keyword)` — return the `.type_descriptor`
/// Value wrapping the native TypeDescriptor for the given Tag. The
/// keyword's name must match a `Value.Tag` enum tag name (e.g.
diff --git a/src/runtime/dispatch.zig b/src/runtime/dispatch.zig
index 45aa27d57..54dbfd1ee 100644
--- a/src/runtime/dispatch.zig
+++ b/src/runtime/dispatch.zig
@@ -37,6 +37,7 @@ const Runtime = @import("runtime.zig").Runtime;
const Env = @import("env.zig").Env;
const SourceLocation = @import("error/info.zig").SourceLocation;
const td_mod = @import("type_descriptor.zig");
+const protocol_mod = @import("protocol.zig");
const method_table = @import("dispatch/method_table.zig");
const error_catalog = @import("error/catalog.zig");
@@ -129,6 +130,23 @@ pub fn dispatch(
loc: SourceLocation,
) anyerror!Value {
if (try dispatchOrNull(rt, env, cs, receiver, protocol_name, method_name, args, loc)) |v| return v;
+ // ADR-0187: `(extend-protocol P some.ns.Q …)` where Q is a PROTOCOL means
+ // "every type satisfying Q". Consulted BEFORE the Object default because on
+ // the JVM an interface impl is a real per-type impl and outranks an Object
+ // fallback. Membership is answered from the receiver's descriptor
+ // (`protocol_impls` is the SSOT, D-190), so no receiver value is needed.
+ if (rt.protocol_target_exts.get(protocol_name)) |exts| {
+ const rtd = try resolveDescriptor(rt, receiver);
+ for (exts) |ext| {
+ if (!protocol_mod.satisfiesName(ext.target_proto, rtd)) continue;
+ for (ext.impls) |me| {
+ if (!std.mem.eql(u8, me.method_name, method_name)) continue;
+ if (me.method_val.tag() == .nil) break;
+ const vt = rt.vtable orelse return error.NoVTable;
+ return try vt.callFn(rt, env, me.method_val, args, loc);
+ }
+ }
+ }
// clj: `(extend-protocol P Object …)` is a UNIVERSAL default — after a
// per-type miss, consult the Object descriptor (where the Object extension
// registered). nil is excluded: clj nil is not an Object, so a type extends
diff --git a/src/runtime/protocol.zig b/src/runtime/protocol.zig
index f4f50c15b..abf97b044 100644
--- a/src/runtime/protocol.zig
+++ b/src/runtime/protocol.zig
@@ -200,7 +200,13 @@ pub fn asProtocolFn(val: Value) *const ProtocolFn {
/// primitive (lang/primitive/protocol.zig) calls this on a
/// typed_instance's descriptor.
pub fn satisfies(proto: *const ProtocolDescriptor, td: *const TypeDescriptor) bool {
- const target_name = proto.fqcn();
+ return satisfiesName(proto.fqcn(), td);
+}
+
+/// `satisfies` by protocol NAME — the same walk, for callers holding an fqcn
+/// rather than a descriptor (ADR-0187's dispatch-miss fallback, which knows
+/// only the recorded target's name).
+pub fn satisfiesName(target_name: []const u8, td: *const TypeDescriptor) bool {
var cursor: ?*const TypeDescriptor = td;
while (cursor) |t| {
for (t.method_table) |entry| {
diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig
index 877a28fea..41ca0e1e9 100644
--- a/src/runtime/runtime.zig
+++ b/src/runtime/runtime.zig
@@ -82,6 +82,14 @@ pub const EmbeddedComponent = struct {
bytes: []const u8,
};
+/// One `(extend-protocol A B …)` where the TARGET `B` is a protocol
+/// (ADR-0187). `target_proto` is B's fqcn; `impls` are A's methods to use for
+/// any type that satisfies B.
+pub const ProtocolTargetExt = struct {
+ target_proto: []const u8,
+ impls: []const TypeDescriptor.MethodEntry,
+};
+
/// Process-wide execution context.
///
/// Carries `io` / `gpa` / `keywords` / `vtable` / `heap_objects`,
@@ -425,6 +433,15 @@ pub const Runtime = struct {
/// lazily built on `gc.infra` by `classDescriptor`, freed in `deinit`.
class_descriptors: std.StringHashMapUnmanaged(*TypeDescriptor) = .empty,
+ /// `(extend-protocol A some.ns.B …)` where B is a PROTOCOL, not a type
+ /// (ADR-0187). Keyed by the PROVIDING protocol's fqcn; each entry names a
+ /// target protocol and the impls to use for any type satisfying it. There is
+ /// no descriptor to write these onto — a protocol's implementor set is open
+ /// and grows at runtime — so `dispatch` consults this on a per-type miss.
+ /// Keys and `MethodEntry.protocol_name` borrow the protocols' interned
+ /// fqcns; `method_name` and the slices are owned here.
+ protocol_target_exts: std.StringHashMapUnmanaged([]ProtocolTargetExt) = .empty,
+
// java.util.Date's descriptor is the canonical rt.types entry
// (ADR-0174 merge) — no per-Runtime lazy field.
@@ -733,6 +750,21 @@ pub const Runtime = struct {
self.class_descriptors.deinit(self.gc.infra);
}
+ // ADR-0187: each entry owns its `impls` slice and every `method_name`
+ // in it. The map key and `protocol_name` borrow interned protocol
+ // fqcns, so neither is freed here.
+ {
+ var it = self.protocol_target_exts.valueIterator();
+ while (it.next()) |exts| {
+ for (exts.*) |ext| {
+ for (ext.impls) |entry| self.gc.infra.free(entry.method_name);
+ if (ext.impls.len > 0) self.gc.infra.free(ext.impls);
+ }
+ if (exts.*.len > 0) self.gc.infra.free(exts.*);
+ }
+ self.protocol_target_exts.deinit(self.gc.infra);
+ }
+
for (self.heap_objects.items) |entry| {
entry.free(self.gpa, entry.ptr);
}
diff --git a/test/e2e/phase14_extend_protocol_targets.sh b/test/e2e/phase14_extend_protocol_targets.sh
index c8459e5b8..02c08c302 100644
--- a/test/e2e/phase14_extend_protocol_targets.sh
+++ b/test/e2e/phase14_extend_protocol_targets.sh
@@ -43,3 +43,25 @@ assert_eq 'native_interface_dispatch' \
assert_eq 'ipv_reaches_map_entry' \
"$(run '(do (defprotocol P (m [x])) (extend-protocol P clojure.lang.IPersistentVector (m [x] (str "V" (count x)))) [(m [1 2]) (m (first {:a 1}))])')" \
'["V2" "V2"]'
+
+# ADR-0187: a PROTOCOL as the extend target means "every type satisfying it".
+# `defprotocol Q` in ns `pt` is named by its generated interface name `pt.Q`.
+assert_eq 'protocol_target_dispatch' \
+ "$(run '(do (ns pt) (defprotocol Q (qm [x])) (defprotocol P (m [x])) (defrecord R [v] Q (qm [_] :q)) (extend-protocol P pt.Q (m [x] (str "viaQ" (qm x)))) (m (->R 1)))')" \
+ '"viaQ:q"'
+
+# ...and it OUTRANKS the Object default, as an interface impl does on the JVM.
+assert_eq 'protocol_target_beats_object' \
+ "$(run '(do (ns pt2) (defprotocol Q (qm [x])) (defprotocol P (m [x])) (defrecord R [v] Q (qm [_] :q)) (extend-protocol P Object (m [x] :obj) pt2.Q (m [x] :viaQ)) [(m (->R 1)) (m 5)])')" \
+ '[:viaQ :obj]'
+
+# A type that does NOT satisfy the target protocol still falls to Object.
+assert_eq 'protocol_target_non_satisfier_falls_through' \
+ "$(run '(do (ns pt3) (defprotocol Q (qm [x])) (defprotocol P (m [x])) (extend-protocol P Object (m [x] :obj) pt3.Q (m [x] :viaQ)) (m "s"))')" \
+ ':obj'
+
+# ORDER-INDEPENDENT: the type is extended to Q AFTER P was extended to Q, so a
+# registration-time copy would miss it. Membership is answered at dispatch.
+assert_eq 'protocol_target_extend_after' \
+ "$(run '(do (ns pt4) (defprotocol Q (qm [x])) (defprotocol P (m [x])) (extend-protocol P pt4.Q (m [x] :viaQ)) (defrecord Late [v] Q (qm [_] :q)) (m (->Late 1)))')" \
+ ':viaQ'
From 447ab9ff8da4c5dc4e8fd980e808011d43a03326 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 16:24:03 -0300
Subject: [PATCH 037/466] docs(mutation): commit the print.zig baseline and the
first kill verdict
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The baseline sweep at ac3a7ff0 under --oracle unit+golden: 4 killed, 7
survived, 1 unviable across 12 of 280 enumerated candidates. Kept because
the next sweep is only meaningful against a recorded predecessor, and
because the survivor list is what the Layer 6/7 work is aimed at.
The score is 4/11 and should be quoted that way. Twelve samples of 280 is
not a coverage figure.
The second file is the targeted --ids re-run at 39677a2f: mutant
d317ae6644b1 (print.zig:1480, the map-vs-set separator) is KILLED by the
HAMT golden cases. That campaign was stopped early to free memory for a
concurrent session, so :1496 has no verdict — the file is one line, not two.
---
.../mutants-39677a2f-seed0-unit-golden.jsonl | 1 +
.../mutants-ac3a7ff0-seed1-unit-golden.jsonl | 12 +++++++++
.../report-ac3a7ff0-seed1-unit-golden.md | 26 +++++++++++++++++++
3 files changed, 39 insertions(+)
create mode 100644 .dev/mutation/mutants-39677a2f-seed0-unit-golden.jsonl
create mode 100644 .dev/mutation/mutants-ac3a7ff0-seed1-unit-golden.jsonl
create mode 100644 .dev/mutation/report-ac3a7ff0-seed1-unit-golden.md
diff --git a/.dev/mutation/mutants-39677a2f-seed0-unit-golden.jsonl b/.dev/mutation/mutants-39677a2f-seed0-unit-golden.jsonl
new file mode 100644
index 000000000..1093fbaf2
--- /dev/null
+++ b/.dev/mutation/mutants-39677a2f-seed0-unit-golden.jsonl
@@ -0,0 +1 @@
+{"id": "d317ae6644b1", "file": "src/runtime/print.zig", "line": 1480, "col": 42, "op": "branch_force_true", "before": "if (!first.*) try w.writeAll(if (kv) \", \" else \" \");", "after": "if (!first.*) try w.writeAll(if (true) \", \" else \" \");", "verdict": "killed"}
diff --git a/.dev/mutation/mutants-ac3a7ff0-seed1-unit-golden.jsonl b/.dev/mutation/mutants-ac3a7ff0-seed1-unit-golden.jsonl
new file mode 100644
index 000000000..5736a7761
--- /dev/null
+++ b/.dev/mutation/mutants-ac3a7ff0-seed1-unit-golden.jsonl
@@ -0,0 +1,12 @@
+{"id": "58155ae4f898", "file": "src/runtime/print.zig", "line": 293, "col": 69, "op": "cmp_gt_to_ge", "before": "while (ks.tag() == .list and list_collection.countOf(ks) > 0) : (ks = list_collection.rest(ks)) {", "after": "while (ks.tag() == .list and list_collection.countOf(ks) >= 0) : (ks = list_collection.rest(ks)) {", "verdict": "survived"}
+{"id": "9141cbd613b4", "file": "src/runtime/print.zig", "line": 386, "col": 32, "op": "cmp_gt_to_ge", "before": "if (items.items.len > lim) break;", "after": "if (items.items.len >= lim) break;", "verdict": "survived"}
+{"id": "b70c218a714b", "file": "src/runtime/print.zig", "line": 441, "col": 23, "op": "cmp_eq_to_ne", "before": "while (t.tag() == .list and list_collection.countOf(t) > 0) : (t = list_collection.rest(t)) {", "after": "while (t.tag() != .list and list_collection.countOf(t) > 0) : (t = list_collection.rest(t)) {", "verdict": "survived"}
+{"id": "dd2dad043b8d", "file": "src/runtime/print.zig", "line": 502, "col": 30, "op": "cmp_le_to_lt", "before": "if (exp10 >= -3 and exp10 <= 6) {", "after": "if (exp10 >= -3 and exp10 < 6) {", "verdict": "killed"}
+{"id": "84aafd32c0a5", "file": "src/runtime/print.zig", "line": 505, "col": 51, "op": "const_bump_in_arith", "before": "const point: usize = @intCast(exp10 + 1); // digits before the point", "after": "const point: usize = @intCast(exp10 + 2); // digits before the point", "verdict": "killed"}
+{"id": "678ff77553f6", "file": "src/runtime/print.zig", "line": 847, "col": 5, "op": "branch_force_true", "before": "if (i > 0) try w.writeAll(sep);", "after": "if (true) try w.writeAll(sep);", "verdict": "survived"}
+{"id": "39b153db36f7", "file": "src/runtime/print.zig", "line": 967, "col": 20, "op": "branch_force_true", "before": ".string => if (print_readably) try printString(w, string_collection.asString(v)) else try w.writeAll(string_collection.a", "after": ".string => if (true) try printString(w, string_collection.asString(v)) else try w.writeAll(string_collection.asString(v)", "verdict": "killed"}
+{"id": "6d3d09bbd4e5", "file": "src/runtime/print.zig", "line": 1261, "col": 65, "op": "cmp_eq_to_ne", "before": "if (inst.field_count >= 1 and inst.fields()[0].tag() == .integer) {", "after": "if (inst.field_count >= 1 and inst.fields()[0].tag() != .integer) {", "verdict": "survived"}
+{"id": "08f81fe36ca3", "file": "src/runtime/print.zig", "line": 1443, "col": 13, "op": "cmp_lt_to_le", "before": "while (i < n) : (i += 1) {", "after": "while (i <= n) : (i += 1) {", "verdict": "killed"}
+{"id": "d317ae6644b1", "file": "src/runtime/print.zig", "line": 1480, "col": 42, "op": "branch_force_true", "before": "if (!first.*) try w.writeAll(if (kv) \", \" else \" \");", "after": "if (!first.*) try w.writeAll(if (true) \", \" else \" \");", "verdict": "survived"}
+{"id": "32bf59779bf4", "file": "src/runtime/print.zig", "line": 1496, "col": 13, "op": "cmp_lt_to_le", "before": "while (j < child_count) : (j += 1) {", "after": "while (j <= child_count) : (j += 1) {", "verdict": "survived"}
+{"id": "dff1e5c6d5d6", "file": "src/runtime/print.zig", "line": 1506, "col": 5, "op": "branch_force_true", "before": "if (compact_ns) |ns| {", "after": "if (true) |ns| {", "verdict": "unviable"}
diff --git a/.dev/mutation/report-ac3a7ff0-seed1-unit-golden.md b/.dev/mutation/report-ac3a7ff0-seed1-unit-golden.md
new file mode 100644
index 000000000..dcf134a49
--- /dev/null
+++ b/.dev/mutation/report-ac3a7ff0-seed1-unit-golden.md
@@ -0,0 +1,26 @@
+# Mutation sweep — ac3a7ff0 (seed 1)
+
+- Candidates enumerated: 280
+- Mutants run: 12
+- Killed: 4
+- Survived: 7
+- Equivalent (registered, not scored): 0
+- Unviable (did not compile): 1
+- **Mutation score: 36.4%** (killed / (killed + survived))
+
+## Survivors — each is a line no test constrains
+
+| file | line | operator | change |
+|---|---:|---|---|
+| `src/runtime/print.zig` | 293 | cmp_gt_to_ge | `while (ks.tag() == .list and list_collection.countOf(ks) > 0) : (ks = list_collection.rest(ks)) {` -> `while (ks.tag() == .list and list_collection.countOf(ks) >= 0) : (ks = list_collection.rest(ks)) {` |
+| `src/runtime/print.zig` | 386 | cmp_gt_to_ge | `if (items.items.len > lim) break;` -> `if (items.items.len >= lim) break;` |
+| `src/runtime/print.zig` | 441 | cmp_eq_to_ne | `while (t.tag() == .list and list_collection.countOf(t) > 0) : (t = list_collection.rest(t)) {` -> `while (t.tag() != .list and list_collection.countOf(t) > 0) : (t = list_collection.rest(t)) {` |
+| `src/runtime/print.zig` | 847 | branch_force_true | `if (i > 0) try w.writeAll(sep);` -> `if (true) try w.writeAll(sep);` |
+| `src/runtime/print.zig` | 1261 | cmp_eq_to_ne | `if (inst.field_count >= 1 and inst.fields()[0].tag() == .integer) {` -> `if (inst.field_count >= 1 and inst.fields()[0].tag() != .integer) {` |
+| `src/runtime/print.zig` | 1480 | branch_force_true | `if (!first.*) try w.writeAll(if (kv) ", " else " ");` -> `if (!first.*) try w.writeAll(if (true) ", " else " ");` |
+| `src/runtime/print.zig` | 1496 | cmp_lt_to_le | `while (j < child_count) : (j += 1) {` -> `while (j <= child_count) : (j += 1) {` |
+
+### Survivors per file
+
+- `src/runtime/print.zig`: 7
+
From 65b79cb569f304043082bff31f6b51e4bb4c873b Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 16:37:23 -0300
Subject: [PATCH 038/466] fix(release): bump the formula's version assertion,
and guard against stale ones
The tap job rewrote the download urls and the checksums but left the
version that `test do` asserts, so v1.10.2 shipped a formula whose
`brew test` fails against the binary it just installed. Derive that
version from the tag, and refuse to write a formula in which any line
names a different one.
---
.github/workflows/release.yml | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3838330f5..86e3fcdf8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -143,14 +143,26 @@ jobs:
import os, pathlib, re
p = pathlib.Path("Formula/cljw.rb")
s = p.read_text()
+ tag = os.environ["TAG_NAME"]
+ version = tag.lstrip("v")
# The formula carries no `version` line: brew scans it from the URL,
# and `brew audit --strict` rejects stating it twice.
- s = re.sub(r'/download/v[^/]+/', '/download/%s/' % os.environ["TAG_NAME"], s)
+ s = re.sub(r'/download/v[^/]+/', '/download/%s/' % tag, s)
# Each sha256 sits under its own url line, so replace positionally.
for host, sha in (("macos-aarch64", os.environ["MAC_SHA256"]),
("linux-x86_64", os.environ["LINUX_SHA256"])):
s = re.sub(r'(cljw-%s\.tar\.gz"\n sha256 ")[0-9a-f]*(")' % host,
lambda m: m.group(1) + sha + m.group(2), s)
+ # `brew test` asserts the installed binary reports this release.
+ s, n = re.subn(r'(assert_match ")[^"]*(", shell_output\("#\{bin\}/cljw --version"\))',
+ lambda m: m.group(1) + version + m.group(2), s)
+ if n != 1:
+ raise SystemExit("expected 1 version assertion in the formula, rewrote %d" % n)
+ # No line may name a version other than the one being released.
+ stale = [l.strip() for l in s.splitlines()
+ if re.search(r'\b\d+\.\d+\.\d+\b', l) and version not in l]
+ if stale:
+ raise SystemExit("formula still names another version: %r" % stale)
p.write_text(s)
PY
grep -q "REPLACE_" Formula/cljw.rb && { echo "formula still carries a placeholder"; exit 1; }
From cdc1efb594a1395ebfaa8efe4af3bebf6f1a2b16 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Wed, 12 Aug 2026 16:54:13 -0300
Subject: [PATCH 039/466] feat(release): release from a build.zig.zon version
bump on main
The formula could only move when a maintainer hand-pushed a tag. Make the
version bump the trigger instead: every push to main runs a short preflight
that resolves the tag from build.zig.zon and releases only when no release
for that version exists. Tag pushes and dispatch still work; dispatch takes
force to re-release. gh release create --target makes the tag, so a failed
build no longer leaves a tag without artifacts.
The skip is in preflight rather than on.push.paths because a paths filter
applies to tag pushes too and would drop the tag trigger.
---
.github/workflows/release.yml | 81 ++++++++++++++++++++++++++++++-----
1 file changed, 71 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 86e3fcdf8..3e5784f73 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,9 +2,18 @@
# target on its NATIVE runner and attach it to the GitHub Release. Native builds
# (not cross-compile) keep the embedded zwasm JIT's arch-specific codegen honest.
#
-# TRIGGER: a maintainer pushing a `v*` tag (a deliberate human act — the
-# autonomous loop never tags or releases; build.zig.zon .version is the SSOT),
-# or manual dispatch. PREPARED-not-fired: this workflow only runs on such a tag.
+# TRIGGER: build.zig.zon's `.version` is the SSOT. Every push to main runs
+# `preflight`, which releases that version only when no release for it exists
+# yet; pushing a `v*` tag releases that tag; manual dispatch releases whatever
+# version main names (`force: true` re-releases an existing one). So a push
+# that does not bump `.version` costs one short job and does nothing.
+#
+# The skip lives in `preflight` rather than in an `on.push.paths` filter
+# because a `paths` filter also applies to tag pushes, which would drop the
+# tag trigger whenever the tagged commit did not touch build.zig.zon.
+#
+# The deliberate human act is the version bump itself — the autonomous loop
+# does not edit `.version`.
#
# NOTE (first-tag validation): the artifact build uses
# `-Dwasm -Doptimize=ReleaseSafe -Dcpu=baseline` (baseline CPU avoids SIGILL on
@@ -16,7 +25,13 @@ name: release
on:
push:
tags: ['v*']
+ branches: [main]
workflow_dispatch:
+ inputs:
+ force:
+ description: 'Release even if one already exists for this version'
+ type: boolean
+ default: false
permissions:
contents: write
@@ -26,8 +41,53 @@ concurrency:
cancel-in-progress: false
jobs:
+ # Resolve the tag to release and decide whether there is anything to do.
+ # `version` comes from build.zig.zon on a branch push / dispatch, and from the
+ # ref itself on a tag push.
+ preflight:
+ name: preflight
+ runs-on: ubuntu-22.04
+ timeout-minutes: 5
+ outputs:
+ tag: ${{ steps.resolve.outputs.tag }}
+ proceed: ${{ steps.resolve.outputs.proceed }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Resolve the tag and check for an existing release
+ id: resolve
+ env:
+ GH_TOKEN: ${{ github.token }}
+ FORCE: ${{ inputs.force }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
+ tag="${GITHUB_REF#refs/tags/}"
+ else
+ # Keep this reader in step with .github/versions.lock's own rule:
+ # split on the first '=' and strip quotes, no inline comments.
+ version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
+ if [[ -z "$version" ]]; then
+ echo "could not read .version from build.zig.zon" >&2
+ exit 1
+ fi
+ tag="v$version"
+ fi
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+ if [[ "$FORCE" != "true" ]] && gh release view "$tag" >/dev/null 2>&1; then
+ echo "release $tag already exists — nothing to do"
+ echo "proceed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "releasing $tag"
+ echo "proceed=true" >> "$GITHUB_OUTPUT"
+ fi
+
build:
name: build (${{ matrix.name }})
+ needs: preflight
+ if: needs.preflight.outputs.proceed == 'true'
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 60
strategy:
@@ -71,13 +131,15 @@ jobs:
( cd dist && shasum -a 256 "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.tar.gz.sha256" )
- name: Attach to GitHub Release
- if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
- TAG_NAME: ${{ github.ref_name }}
+ TAG_NAME: ${{ needs.preflight.outputs.tag }}
shell: bash
run: |
set -euo pipefail
+ # `--target` makes the tag when the run came from a branch push; on a
+ # tag push the tag already exists and the flag is inert.
+ #
# Create the release once; later matrix legs just upload.
#
# view-then-create is a TOCTOU race across the matrix: both legs can
@@ -88,7 +150,7 @@ jobs:
# afterwards; a create that failed for any other reason still fails
# the step, because the trailing view then finds nothing.
gh release view "$TAG_NAME" >/dev/null 2>&1 \
- || gh release create "$TAG_NAME" --title "$TAG_NAME" --generate-notes \
+ || gh release create "$TAG_NAME" --target "$GITHUB_SHA" --title "$TAG_NAME" --generate-notes \
|| gh release view "$TAG_NAME" >/dev/null 2>&1
gh release upload "$TAG_NAME" dist/* --clobber
@@ -101,15 +163,14 @@ jobs:
# PAT: write access reaches that one repository and nothing else in the account.
tap:
name: bump homebrew tap
- needs: build
- if: startsWith(github.ref, 'refs/tags/')
+ needs: [preflight, build]
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Read the release checksums
env:
GH_TOKEN: ${{ github.token }}
- TAG_NAME: ${{ github.ref_name }}
+ TAG_NAME: ${{ needs.preflight.outputs.tag }}
GH_REPO: ${{ github.repository }}
shell: bash
run: |
@@ -134,7 +195,7 @@ jobs:
- name: Write and push the formula
env:
- TAG_NAME: ${{ github.ref_name }}
+ TAG_NAME: ${{ needs.preflight.outputs.tag }}
shell: bash
working-directory: tap
run: |
From ca790fbdacef8bb8a615c4c0023f340ce4a89214 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 13:43:41 -0300
Subject: [PATCH 040/466] docs(adr): ADR-0188 - :cljw joins the
reader-conditional feature set
The old set {:clj, :default} could say what cljw is compatible with and
nothing about where it differs, so a .cljc file had no way to name this
runtime and every divergence could only surface as an unrecordable red
test. Adding :cljw is backward-compatible by construction: selection
stays first-match-left-to-right, so the new key wins only where an
author writes it ahead of :clj, and nothing in the wild writes it yet.
The forcing case is jank-lang/clojure-test-suite, whose entire method of
recording a dialect's behaviour is a reader conditional, and which is
the first per-symbol clj-parity measurement cljw can be held to against
the same oracle as every other dialect.
Smell-audited: depth 1: the feature set was duplicated across #? and #?@
as two identical `or` chains. Left alone that is a silent-divergence
hazard (the same file selecting different branches depending on whether
a form was spliced), so the ADR's Decision names one predicate as part
of the decision rather than leaving it an implementation detail.
---
.../0188_cljw_reader_conditional_feature.md | 125 ++++++++++++++++++
docs/works/README.md | 10 +-
2 files changed, 132 insertions(+), 3 deletions(-)
create mode 100644 .dev/decisions/0188_cljw_reader_conditional_feature.md
diff --git a/.dev/decisions/0188_cljw_reader_conditional_feature.md b/.dev/decisions/0188_cljw_reader_conditional_feature.md
new file mode 100644
index 000000000..403f11dd1
--- /dev/null
+++ b/.dev/decisions/0188_cljw_reader_conditional_feature.md
@@ -0,0 +1,125 @@
+# 0188 — `:cljw` joins the reader-conditional platform feature set
+
+- **Status**: Proposed
+- **Date**: 2026-08-13
+- **Author**: BuddhiLW
+- **Tags**: reader, cljc, conformance, dialects
+
+## Context
+
+cljw's reader-conditional feature set was `{:clj, :default}` (ADR/D-232,
+`src/eval/reader.zig`). The reasoning was sound as far as it went: cljw
+implements Clojure, not ClojureScript, so a `.cljc` file's `:clj` branch is
+the branch cljw should read.
+
+What that set cannot express is **where cljw is not the JVM**. The set says
+"I am clj" and nothing else, so a `.cljc` file has no way to name this
+runtime — every divergence reads as the JVM branch and then fails.
+
+The concrete forcing case is the `clojure.core` compliance suite
+(jank-lang/clojure-test-suite): 249 `.cljc` namespaces, one per core symbol,
+whose entire method of recording a dialect's behaviour is a reader
+conditional. Upstream already carries `:cljs`, `:cljr`, `:bb`, `:lpy`,
+`:phel` and `:jank` branches, and its `portability.cljc` dispatches
+`big-int?`, `sleep`, `lazy-seq?` and the `p/thrown?` `assert-expr`
+multimethod on exactly those keys. jank names itself `:jank`; Basilisp
+`:lpy`; Phel `:phel`; clojurust `:rust`. Under `{:clj, :default}` cljw would
+run every JVM branch verbatim, including the ones that assert
+`clojure.lang.LazySeq` or a `Thread/sleep`, and its real divergences would
+land in the suite as ordinary red tests with nowhere to be recorded.
+
+That is the same failure the accepted-divergence ledger exists to prevent
+(`.claude/rules/accepted_divergences.md`): a divergence that cannot be
+*written down* gets re-discovered every sweep and reads as breakage rather
+than as design.
+
+The suite is also the reason this is not hypothetical alignment work. It is
+the mechanism by which cljw's clj-parity gets measured against the same
+oracle every other dialect is measured against — a public, per-symbol
+compliance surface that `clj_diff_sweep` (one-expression-at-a-time, locally
+authored) does not provide.
+
+## Decision
+
+cljw's platform feature set becomes `{:cljw, :clj, :default}`.
+
+Selection stays exactly as it was: scan clauses left-to-right, read the first
+whose key is in the set. `:cljw` therefore wins only where an author put it
+*ahead of* `:clj`, which is what makes the change backward-compatible —
+every existing `.cljc` in the wild has no `:cljw` clause, so it selects the
+same branch it selected before, and one that writes `#?(:clj a :cljw b)`
+still gets `a`.
+
+The set lives in one predicate, `Reader.isPlatformFeature`, consulted by both
+`readReaderConditional` and `readReaderConditionalSplice`. The two paths
+previously carried the same two-way `or` chain twice; a reader whose `#?`
+and `#?@` disagreed about the feature set would select different branches in
+the same file, which is a silent-wrong-code failure rather than an error.
+
+Naming: `:cljw` matches the binary, the package, and the project's own
+short name, and matches how every other dialect keys itself (`:jank`,
+`:lpy`, `:phel`, `:rust`).
+
+## Alternatives considered
+
+### Alternative A — keep `{:clj, :default}`; encode divergences elsewhere
+
+- **Sketch**: leave the reader alone. Record cljw's divergences from the
+ suite in `.dev/accepted_divergences.yaml` only, and carry a local skip-list
+ of test namespaces.
+- **Why rejected**: the ledger records *that* cljw diverges; the suite is
+ about *what the expected value is on this runtime*. A skip-list deletes the
+ assertion instead of restating it, so the divergent behaviour stops being
+ pinned — the exact property the AD `pin` field exists to guarantee. It also
+ makes cljw the only dialect in the suite that cannot state its own
+ expectation, which pushes the divergence into a fork of the test files.
+
+### Alternative B — `:cljw` REPLACES `:clj`
+
+- **Sketch**: the set becomes `{:cljw, :default}`; cljw stops claiming to be
+ `:clj`.
+- **Why rejected**: it would break every `.cljc` library cljw currently
+ loads. The whole `docs/works/ladder.md` corpus depends on `#?(:clj …)`
+ selecting the JVM-shaped branch, because that branch is the one written
+ against `clojure.core` rather than against `js/`. cljw genuinely is a
+ Clojure runtime; dropping the claim would be false as well as costly.
+
+### Alternative C — a build-time / CLI-configurable feature set
+
+- **Sketch**: `--reader-features cljw,clj` so the suite can run cljw in a
+ "strict cljw" mode where `:clj` is absent.
+- **Why rejected**: speculative generality for one consumer, and it makes a
+ file's meaning depend on an invocation flag — the same source would read as
+ two different programs. If a strict mode is ever genuinely wanted (for
+ measuring "how much of the JVM branch am I riding?"), it can be added then,
+ and it is additive to this decision rather than an alternative to it.
+
+## Consequences
+
+- **Positive**: `.cljc` files can name cljw. The compliance suite can carry
+ cljw expectations in-tree instead of in a fork, and cljw joins the same
+ per-symbol parity measurement as jank / Basilisp / Phel / clojurust.
+- **Positive**: `#?` and `#?@` can no longer drift apart on the feature set.
+- **Negative**: one more key that authors may reach for where `:default`
+ would have been the honest answer. A `:cljw` branch asserting cljw's
+ behaviour is a *recorded* divergence, so it should be paired with an
+ `AD-NNN` when the divergence is intentional, and with a debt row when it
+ is a gap — the reader change does not itself classify anything.
+- **Neutral**: no effect on any existing file. The set only grows, and the
+ new key is one nothing in the wild currently writes.
+
+## Affected files
+
+- `src/eval/reader.zig` — `isPlatformFeature` predicate; both `#?` and `#?@`
+ consult it.
+- `test/e2e/phase15_reader_conditional.sh` — `:cljw` selection, `:clj`-first
+ precedence, lone `:cljw`, and `#?@` parity (14 cases, was 10).
+- `docs/works/README.md` — the `.cljc` paragraph now states the three-key set.
+
+## References
+
+- D-232 — the original `#?` implementation.
+- `.claude/rules/accepted_divergences.md` — why an unrecordable divergence is
+ the failure mode this decision removes.
+- — the compliance suite;
+ `test/clojure/core_test/portability.cljc` is its per-dialect dispatch point.
diff --git a/docs/works/README.md b/docs/works/README.md
index b9388b3ea..ab08d4a0c 100644
--- a/docs/works/README.md
+++ b/docs/works/README.md
@@ -63,9 +63,13 @@ un-cloned transitive dep (not a real cljw feature gap) is marked
un-fetched Maven/transitive dep" — they are not cljw failures, just
not-yet-loadable without laying the dep out.
-`.cljc` reader conditionals resolve to the `:clj` branch on cljw (cljw is a
-Clojure runtime, not ClojureScript), so `#?(:clj ... :cljs ...)` picks the
-JVM-shaped branch — which is exactly where the Java interop blockers live.
+`.cljc` reader conditionals resolve against the feature set
+`{:cljw, :clj, :default}` (ADR-0188). cljw is a Clojure runtime, not
+ClojureScript, so `#?(:clj ... :cljs ...)` picks the JVM-shaped branch — which
+is exactly where the Java interop blockers live. `:cljw` names *this* runtime
+for the cases where it diverges from the JVM; since branches are read
+left-to-right, it only wins where an author put it ahead of `:clj`, so an
+existing library reads exactly the branch it always did.
## How to add a library
From 54a12cfd5a3b754f0ee74ac723fd974fa29b23c8 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 13:43:58 -0300
Subject: [PATCH 041/466] feat(reader): read :cljw as a platform feature
(ADR-0188)
The feature set becomes {:cljw, :clj, :default}, behind one
Reader.isPlatformFeature consulted by both #? and #?@. Selection is
unchanged, so an existing .cljc reads exactly the branch it read
before.
phase15_reader_conditional grows four cases: :cljw is selected; :clj
first still wins (the backward-compatibility guarantee, asserted rather
than argued); a lone :cljw matches with no :clj or :default present;
and #?@ selects the same branch #? does.
Smell-audited: depth 1: the two read paths carried the same two-way `or`
chain. Duplicated matching logic that must agree is a Silent
default-shift waiting to happen -- the failure mode is not an error but
a different program -- so it is now one predicate with the two callers
sharing it, and the #?@ case is pinned by a test.
---
src/eval/reader.zig | 25 ++++++++++++++------
test/e2e/phase15_reader_conditional.sh | 32 ++++++++++++++++++++++----
2 files changed, 46 insertions(+), 11 deletions(-)
diff --git a/src/eval/reader.zig b/src/eval/reader.zig
index e23248a9d..0663c84d9 100644
--- a/src/eval/reader.zig
+++ b/src/eval/reader.zig
@@ -849,11 +849,22 @@ pub const Reader = struct {
return Form{ .data = .nil, .location = discard_loc };
}
- /// `#?(:clj a :cljs b :default c)` reader conditional. cljw's platform
- /// feature set is `{:clj, :default}` (it implements Clojure semantics, not
- /// ClojureScript), so the FIRST branch whose key is `:clj` or `:default`
- /// (scanned left-to-right, clj-faithful) is read; a non-matching `#?` reads
- /// as nothing (like `#_` — the next form is returned).
+ /// `#?(:cljw a :clj b :cljs c :default d)` reader conditional. cljw's
+ /// platform feature set is `{:cljw, :clj, :default}` — `:clj` because it
+ /// implements Clojure semantics (not ClojureScript), `:cljw` because a
+ /// `.cljc` file must be able to name THIS runtime where it diverges from
+ /// the JVM (ADR-0188). The FIRST branch whose key is in that set (scanned
+ /// left-to-right, clj-faithful) is read, so `:cljw` only wins where the
+ /// author put it ahead of `:clj`; a non-matching `#?` reads as nothing
+ /// (like `#_` — the next form is returned).
+ /// cljw's reader-conditional platform feature set, as ONE predicate — `#?`
+ /// and `#?@` must never disagree about which branch a file selects.
+ fn isPlatformFeature(k: []const u8) bool {
+ return std.mem.eql(u8, k, "cljw") or
+ std.mem.eql(u8, k, "clj") or
+ std.mem.eql(u8, k, "default");
+ }
+
fn readReaderConditional(self: *Reader, tok: Token) ReadError!Form {
const loc = self.locOf(tok);
// D-457(3): `#?` is rejected in a data-read (`read-string`) unless opted in
@@ -869,7 +880,7 @@ pub const Reader = struct {
while (i + 1 < items.len) : (i += 2) {
if (items[i].data == .keyword and items[i].data.keyword.ns == null) {
const k = items[i].data.keyword.name;
- if (std.mem.eql(u8, k, "clj") or std.mem.eql(u8, k, "default"))
+ if (isPlatformFeature(k))
return items[i + 1];
}
}
@@ -901,7 +912,7 @@ pub const Reader = struct {
while (i + 1 < items.len) : (i += 2) {
if (items[i].data == .keyword and items[i].data.keyword.ns == null) {
const k = items[i].data.keyword.name;
- if (std.mem.eql(u8, k, "clj") or std.mem.eql(u8, k, "default")) {
+ if (isPlatformFeature(k)) {
return switch (items[i + 1].data) {
.list => |l| l,
.vector => |v| v,
diff --git a/test/e2e/phase15_reader_conditional.sh b/test/e2e/phase15_reader_conditional.sh
index 34e1f7099..80a3e7665 100755
--- a/test/e2e/phase15_reader_conditional.sh
+++ b/test/e2e/phase15_reader_conditional.sh
@@ -1,9 +1,11 @@
#!/usr/bin/env bash
# test/e2e/phase15_reader_conditional.sh — #? reader conditionals (D-232).
# `#?(:clj a :cljs b :default c)` reads ONE branch by platform feature. cljw's
-# feature set is {:clj, :default} (it implements Clojure, not ClojureScript), so
-# the first :clj/:default branch (left-to-right, clj-faithful) is read; a
-# non-matching #? reads as nothing. The .cljc gate for real-world libraries
+# feature set is {:cljw, :clj, :default} — :clj because it implements Clojure
+# (not ClojureScript), :cljw so a .cljc file can name THIS runtime where it
+# diverges from the JVM (ADR-0188). The first branch whose key is in that set
+# (left-to-right, clj-faithful) is read; a non-matching #? reads as nothing.
+# The .cljc gate for real-world libraries
# (weavejester/medley). No corpus: `clj -M -e` rejects #? without :read-cond
# (a .cljc context); the expected values follow clj semantics. Layer 2.
set -euo pipefail
@@ -23,6 +25,23 @@ assert_eq 'clj-branch-reordered' \
"$("$BIN" -e '#?(:cljs 9 :clj 8)' 2>&1 | tail -1)" \
'8'
+# :cljw names THIS runtime — a .cljc file records a cljw-specific expectation
+# by putting the :cljw branch AHEAD of :clj (ADR-0188)
+assert_eq 'cljw-branch' \
+ "$("$BIN" -e '#?(:cljw 1 :clj 2 :cljs 3)' 2>&1 | tail -1)" \
+ '1'
+
+# left-to-right is the whole rule: :clj first still wins, so every existing
+# .cljc keeps reading exactly the branch it read before
+assert_eq 'cljw-after-clj-loses' \
+ "$("$BIN" -e '#?(:clj 2 :cljw 1)' 2>&1 | tail -1)" \
+ '2'
+
+# a lone :cljw branch matches (no :clj / :default needed)
+assert_eq 'cljw-alone' \
+ "$("$BIN" -e '#?(:cljs 9 :cljw 4)' 2>&1 | tail -1)" \
+ '4'
+
# :default is the fallback when there is no :clj branch
assert_eq 'default-fallback' \
"$("$BIN" -e '#?(:cljs 1 :default 7)' 2>&1 | tail -1)" \
@@ -49,6 +68,11 @@ assert_eq 'no-match-ns-tail' \
assert_eq 'splice-clj' \
"$("$BIN" -e '[1 #?@(:clj [2 3]) 4]' 2>&1 | tail -1)" \
'[1 2 3 4]'
+# #?@ obeys the SAME feature set as #? — a reader whose two paths disagree
+# would select different branches for the same file
+assert_eq 'splice-cljw' \
+ "$("$BIN" -e '[1 #?@(:cljw [2 3] :clj [8 9]) 4]' 2>&1 | tail -1)" \
+ '[1 2 3 4]'
# a non-matching #?@ splices nothing
assert_eq 'splice-no-match' \
"$("$BIN" -e '[0 #?@(:cljs [9]) 5]' 2>&1 | tail -1)" \
@@ -62,4 +86,4 @@ assert_eq 'splice-top-level' \
"$("$BIN" -e '#?@(:clj [1 2])' 2>&1 | grep -cm1 'only allowed inside')" \
'1'
-echo "OK — phase15_reader_conditional (10 cases) green"
+echo "OK — phase15_reader_conditional (14 cases) green"
From b4ca37170a334c58dce2801bf62743aa7a4c5f04 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 13:43:59 -0300
Subject: [PATCH 042/466] feat(clojure.test): use-fixtures, and test-var
catches like clj does
use-fixtures was listed as deferred. clj hangs the fixture lists off the
namespace's metadata; cljw namespaces carry no user metadata, so
*fixture-registry* mirrors the existing *test-registry* shape and
use-fixtures reads (ns-name *ns*) at load time, the same way deftest
already does. default-fixture / compose-fixtures / join-fixtures follow
clj's semantics; run-tests wraps the namespace's var loop in :once and
each test-var call in :each.
test-var called ((deref v)) bare. clj wraps it and reports :error with
"Uncaught exception, not in assertion." That difference is not
cosmetic: without the catch, one test throwing outside an `is` aborts
every remaining test in the run.
Both were found by running the clojure.core compliance suite, which
could not get past either.
Smell-audited: depth 0: no smell. Both are clj-parity with no cljw-
specific design introduced, and the fixture registry reuses the shape
the test registry already established rather than inventing a second
one.
---
src/lang/clj/clojure/test.clj | 59 +++++++++++++++++++++++++++++++----
1 file changed, 53 insertions(+), 6 deletions(-)
diff --git a/src/lang/clj/clojure/test.clj b/src/lang/clj/clojure/test.clj
index e4763b8d0..4b10e36d7 100644
--- a/src/lang/clj/clojure/test.clj
+++ b/src/lang/clj/clojure/test.clj
@@ -19,8 +19,7 @@
;; deftest VAR's :file/:line meta (D-563b) where clj reads the failing
;; assertion's stack frame (deftest-line vs is-line — the narrowed AD-041);
;; `report :error` cannot print a JVM cause trace (also AD-041). Deferred:
-;; per-var lifecycle events (begin/end-test-var, end-test-ns), use-fixtures,
-;; with-test.
+;; per-var lifecycle events (begin/end-test-var, end-test-ns), with-test.
(ns clojure.test
(:refer-clojure)
@@ -47,6 +46,11 @@
;; ns-name-symbol -> vector of test Vars (deftest appends; run-tests reads).
(def *test-registry* (atom {}))
+;; ns-name-symbol -> {:once [fixture-fn …] :each [fixture-fn …]}. clj hangs
+;; these off the ns's metadata; cljw namespaces carry no user metadata, so the
+;; registry mirrors *test-registry* and is keyed the same way.
+(def *fixture-registry* (atom {}))
+
;; ---------------------------------------------------------------------------
;; report multimethod (keyed on :type) + do-report. `^:dynamic` so an
;; alternate reporter (e.g. clojure.test.tap) can `(binding [report …] …)`.
@@ -241,13 +245,53 @@
(fn [v#] (conj (or v# []) (var ~name))))
(var ~name)))
+;; ---------------------------------------------------------------------------
+;; Fixtures. A fixture is a function of one 0-arg thunk: it does its setup,
+;; calls the thunk, and does its teardown. `:once` wraps a whole namespace's
+;; run, `:each` wraps every individual test.
+;; ---------------------------------------------------------------------------
+(defn default-fixture
+ "The identity fixture — calls `f` and nothing else."
+ [f]
+ (f))
+
+(defn compose-fixtures
+ "One fixture running `f1` outside `f2`."
+ [f1 f2]
+ (fn [g] (f1 (fn [] (f2 g)))))
+
+(defn join-fixtures
+ "The fixtures composed into a single fixture, applied left to right."
+ [fixtures]
+ (reduce compose-fixtures default-fixture fixtures))
+
+(defn use-fixtures
+ "Register `fns` as `:once` (per namespace) or `:each` (per test) fixtures
+ for the current namespace. Called at load time, like clj's."
+ [fixture-type & fns]
+ (swap! *fixture-registry* update (ns-name *ns*)
+ (fn [m] (update (or m {}) fixture-type (fn [v] (into (or v []) fns)))))
+ nil)
+
+(defn- fixtures-for [ns-sym kind]
+ (join-fixtures (get (get (deref *fixture-registry*) ns-sym) kind)))
+
(defn test-var [v]
(when v
(binding [*testing-vars* (conj *testing-vars* v)]
;; Emit the per-var report events (clj parity) so a reporter that wraps
;; each test — clojure.test.junit's , custom reporters — fires.
(do-report {:type :begin-test-var :var v})
- ((deref v))
+ ;; clj parity: an exception thrown OUTSIDE an `is` is that test's error,
+ ;; not the run's. Without this catch a single bad test aborts every
+ ;; remaining test in the run.
+ (try
+ ((deref v))
+ (catch Throwable e
+ (do-report {:type :error
+ :message "Uncaught exception, not in assertion."
+ :expected nil
+ :actual e})))
(do-report {:type :end-test-var :var v}))))
(defn run-tests [& ns-syms]
@@ -255,9 +299,12 @@
(binding [*report-counters* (atom *initial-report-counters*)]
(doseq [ns-sym targets]
(do-report {:type :begin-test-ns :ns ns-sym})
- (doseq [v (get (deref *test-registry*) ns-sym)]
- (swap! *report-counters* update :test inc)
- (test-var v))
+ (let [each-fixture (fixtures-for ns-sym :each)]
+ ((fixtures-for ns-sym :once)
+ (fn []
+ (doseq [v (get (deref *test-registry*) ns-sym)]
+ (swap! *report-counters* update :test inc)
+ (each-fixture (fn [] (test-var v)))))))
;; clj parity: emit :end-test-ns so a reporter that brackets a namespace
;; (junit's , custom reporters) fires.
(do-report {:type :end-test-ns :ns ns-sym}))
From a5225e954607734a7351c6240af0df161a7bc05e Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 14:41:41 -0300
Subject: [PATCH 043/466] feat(java): java.util.concurrent.Semaphore
Permits in one atomic counter, moved by CAS; a parked caller polls through
the budgeted sleep Thread/sleep owns, so future-cancel and the eval budget
cut the wait short. Surface: (n | n fair?), .acquire, .tryAcquire
(immediate | timed | cost+timed), .release, .availablePermits,
.drainPermits, .getQueueLength, .hasQueuedThreads, .isFair.
Fairness is recorded, not ordered (AD-061). TimeUnit gains a nanosOf reader
so the timed arities convert without a second copy of NANOS_PER.
---
.dev/accepted_divergences.yaml | 20 ++
CHANGELOG.md | 14 +
src/runtime/java/_host_api.zig | 1 +
.../java/util/concurrent/Semaphore.zig | 239 ++++++++++++++++++
src/runtime/java/util/concurrent/TimeUnit.zig | 12 +
test/e2e/semaphore.sh | 89 +++++++
6 files changed, 375 insertions(+)
create mode 100644 src/runtime/java/util/concurrent/Semaphore.zig
create mode 100755 test/e2e/semaphore.sh
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index 6bfc21cfe..8bc28c246 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -967,3 +967,23 @@ accepted:
example: '(re-pattern "(a)\\1") => cljw UNCATCHABLE "this regex feature … is not supported" / clj #"(a)\1" compiles and (re-matches #"(a)\1" "aa") => ["aa" "a"]'
derives_from: "ADR-0031 (Pike-NFA, non-backtracking invariant — linear-time matching is the security property, F-002 finished form)."
pin: "test/e2e/regex_parity_gaps.sh — backref compile raises the uncatchable unsupported signal and a (catch Throwable …) does NOT swallow it."
+ - id: "AD-061"
+ area: "concurrency"
+ summary: |-
+ `java.util.concurrent.Semaphore`'s `fair?` constructor flag is recorded
+ and reported by `.isFair`, but acquisition ALWAYS barges: a waiter takes
+ the permit it can CAS, in no particular order. The JVM's fair mode
+ guarantees FIFO hand-off through an AQS wait queue; cljw's permits live
+ in one atomic counter and a parked caller polls it through the budgeted
+ sleep that `Thread/sleep` owns (so future-cancel and the eval budget cut
+ the wait short). Building a FIFO queue would mean a second parking
+ mechanism whose abandonment path — a timed `tryAcquire` giving up mid-
+ queue — is the part that goes wrong quietly.
+ The flag is kept rather than rejected because the constructor arity is
+ what real code writes (`(Semaphore. n fair?)`), and `.isFair` answering
+ truthfully lets a caller detect the difference. Throughput and mutual
+ exclusion are unaffected; only starvation-freedom under sustained
+ contention is.
+ example: '(let [s (java.util.concurrent.Semaphore. 1 true)] (.isFair s)) => true, yet a thread that has waited longer has no priority over a newcomer, unlike the JVM.'
+ derives_from: "ADR-0029 (host surface registry) + ADR-0106 (host_instance state) + the budgeted-sleep wait Thread/sleep owns (D-442 / D-571)."
+ pin: "test/e2e/semaphore.sh — isFair_records_flag (the flag round-trips) + queue_length_while_parked (a parked waiter is counted, and the permit is handed over)."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec8801028..a21d820f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+### Added
+
+- **`java.util.concurrent.Semaphore`.** Permits live in one atomic counter and
+ move by CAS: `` (`n` / `n fair?`), `.acquire` (`[n]`), `.tryAcquire`
+ (`[n]`, `[timeout unit]`, `[n timeout unit]`), `.release` (`[n]`),
+ `.availablePermits`, `.drainPermits`, `.getQueueLength`, `.hasQueuedThreads`,
+ `.isFair`. A parked caller polls through the budgeted sleep `Thread/sleep`
+ owns, so `future-cancel` and the eval budget cut the wait short instead of
+ pinning a worker for the whole timeout. Fairness is recorded but does not
+ order acquisition (AD-061). Found by driving
+ [hive-weave](https://github.com/hive-agi/hive-weave)'s admission gates
+ through cljw: `hive-weave.gate` and `hive-weave.budget` — permit gating,
+ cost-based budget admission, saturation timeouts and stats — now run on it.
+
## [1.10.2] - 2026-08-12
The fork's first runtime changes. Nearly all were found by driving
diff --git a/src/runtime/java/_host_api.zig b/src/runtime/java/_host_api.zig
index 06a89cfa3..79a829e3a 100644
--- a/src/runtime/java/_host_api.zig
+++ b/src/runtime/java/_host_api.zig
@@ -126,6 +126,7 @@ const java_surfaces = [_]type{
@import("util/regex/Matcher.zig"),
@import("util/regex/Pattern.zig"),
@import("util/concurrent/TimeUnit.zig"),
+ @import("util/concurrent/Semaphore.zig"),
@import("lang/reflect/Array.zig"),
};
diff --git a/src/runtime/java/util/concurrent/Semaphore.zig b/src/runtime/java/util/concurrent/Semaphore.zig
new file mode 100644
index 000000000..0e6dc49d2
--- /dev/null
+++ b/src/runtime/java/util/concurrent/Semaphore.zig
@@ -0,0 +1,239 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.Semaphore` — a counting permit gate
+//! over a `.host_instance` (ADR-0106): state[0] = permits (i64 bitcast, mutated
+//! by CAS only), state[1] = the constructor's fairness flag.
+//!
+//! Backend: impl-only
+//! Impl deps: host_instance, eval_budget, future, clock
+//! Clojure peer: none
+//!
+//! Surface: `` (permits [fair?]), `.acquire` ([n]), `.tryAcquire`
+//! ([n] | [timeout unit] | [n timeout unit]), `.release` ([n]),
+//! `.availablePermits`, `.drainPermits`.
+//!
+//! A blocking wait polls in slices through `eval_budget.budgetedSleep`, the
+//! same wait `Thread/sleep` owns, so future-cancel and the eval budget cut it
+//! short instead of pinning a worker for the whole timeout.
+//!
+//! Divergence AD-061: `fair?` is recorded and reported by `.isFair` but
+//! acquisition always barges — there is no FIFO queue behind the permits.
+
+const std = @import("std");
+const host_api = @import("../../_host_api.zig");
+const type_descriptor = @import("../../../type_descriptor.zig");
+const Value = @import("../../../value/value.zig").Value;
+const Runtime = @import("../../../runtime.zig").Runtime;
+const Env = @import("../../../env.zig").Env;
+const SourceLocation = @import("../../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../../error/catalog.zig");
+const host_instance = @import("../../../host_instance.zig");
+const eval_budget = @import("../../../concurrency/eval_budget.zig");
+const future = @import("../../../future.zig");
+const clock = @import("../../../clock.zig");
+const TimeUnit = @import("TimeUnit.zig");
+
+/// The live rt.types descriptor (set in `initDescriptor`), embedded into every
+/// instance so `` and method dispatch share it.
+var sem_descriptor: ?*const type_descriptor.TypeDescriptor = null;
+
+/// Longest single wait slice; a shorter remaining budget shortens the last one.
+const SLICE_NS: u64 = std.time.ns_per_ms;
+
+fn permitsPtr(recv: Value) *u64 {
+ return &@constCast(host_instance.asHostInstance(recv)).state[0];
+}
+
+fn permitsNow(recv: Value) i64 {
+ return @bitCast(@atomicLoad(u64, permitsPtr(recv), .seq_cst));
+}
+
+/// CAS `n` permits out of the counter. False without blocking when fewer than
+/// `n` are available.
+fn takeNow(recv: Value, n: i64) bool {
+ const ptr = permitsPtr(recv);
+ while (true) {
+ const cur_bits = @atomicLoad(u64, ptr, .seq_cst);
+ const cur: i64 = @bitCast(cur_bits);
+ if (cur < n) return false;
+ const next: u64 = @bitCast(cur - n);
+ if (@cmpxchgWeak(u64, ptr, cur_bits, next, .seq_cst, .seq_cst) == null) return true;
+ }
+}
+
+fn giveBack(recv: Value, n: i64) void {
+ const ptr = permitsPtr(recv);
+ while (true) {
+ const cur_bits = @atomicLoad(u64, ptr, .seq_cst);
+ const cur: i64 = @bitCast(cur_bits);
+ const next: u64 = @bitCast(cur +| n);
+ if (@cmpxchgWeak(u64, ptr, cur_bits, next, .seq_cst, .seq_cst) == null) return;
+ }
+}
+
+/// Poll for `n` permits until `deadline_ns` (null = no deadline). Returns false
+/// on deadline; raises `future_cancel_abort` when the worker is cancelled.
+fn takeWaiting(rt: *Runtime, recv: Value, n: i64, deadline_ns: ?i64) anyerror!bool {
+ if (takeNow(recv, n)) return true;
+ // Registered only once the caller actually parks, so `.getQueueLength`
+ // counts waiters and not passers-by.
+ const waiters = &@constCast(host_instance.asHostInstance(recv)).state[2];
+ _ = @atomicRmw(u64, waiters, .Add, 1, .seq_cst);
+ defer _ = @atomicRmw(u64, waiters, .Sub, 1, .seq_cst);
+ while (true) {
+ if (takeNow(recv, n)) return true;
+ if (deadline_ns) |deadline| {
+ const remaining = deadline - clock.nanoTime(rt.io);
+ if (remaining <= 0) return false;
+ const slice = @min(@as(u64, @intCast(remaining)), SLICE_NS);
+ try eval_budget.budgetedSleep(rt.io, slice, future.currentCancelLatch());
+ } else {
+ try eval_budget.budgetedSleep(rt.io, SLICE_NS, future.currentCancelLatch());
+ }
+ if (future.cancelRequested()) return error_catalog.raise(.future_cancel_abort, .{}, .{});
+ }
+}
+
+/// `(java.util.concurrent.Semaphore. n)` / `(… n fair?)`.
+fn initSemaphore(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ if (args.len != 1 and args.len != 2)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = "java.util.concurrent.Semaphore.", .expected = 1 });
+ const permits = try error_catalog.expectInteger(args[0], "java.util.concurrent.Semaphore.", loc);
+ const fair: u64 = if (args.len == 2 and args[1].isTruthy()) 1 else 0;
+ const td = sem_descriptor orelse return error.NoVTable;
+ return host_instance.alloc(rt, td, .{ @bitCast(@as(i64, permits)), fair, 0, 0 });
+}
+
+/// The permit count an arity carries in `args[1]`, defaulting to 1.
+fn permitArg(args: []const Value, name: []const u8, loc: SourceLocation) anyerror!i64 {
+ if (args.len < 2) return 1;
+ return @as(i64, try error_catalog.expectInteger(args[1], name, loc));
+}
+
+/// `(.acquire s)` / `(.acquire s n)` — block until acquired. Returns nil.
+fn acquire(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArityRange(".acquire", args, 1, 2, loc);
+ _ = try takeWaiting(rt, args[0], try permitArg(args, ".acquire", loc), null);
+ return Value.nil_val;
+}
+
+/// `(.tryAcquire s)` / `(.tryAcquire s n)` — immediate.
+/// `(.tryAcquire s timeout unit)` / `(.tryAcquire s n timeout unit)` — timed.
+fn tryAcquire(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArityRange(".tryAcquire", args, 1, 4, loc);
+ if (args.len <= 2)
+ return Value.initBoolean(takeNow(args[0], try permitArg(args, ".tryAcquire", loc)));
+
+ // Timed: the unit is last, the timeout immediately before it, and a 4-arg
+ // call puts the permit count first — JVM's two timed overloads.
+ const unit_idx = args.len - 1;
+ const timeout = try error_catalog.expectInteger(args[unit_idx - 1], ".tryAcquire", loc);
+ const n: i64 = if (args.len == 4) @as(i64, try error_catalog.expectInteger(args[1], ".tryAcquire", loc)) else 1;
+ const timeout_ns = TimeUnit.nanosOf(args[unit_idx], @intCast(timeout)) orelse
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = ".tryAcquire", .expected = "a java.util.concurrent.TimeUnit", .actual = @tagName(args[unit_idx].tag()) });
+ if (timeout_ns <= 0) return Value.initBoolean(takeNow(args[0], n));
+ const deadline = clock.nanoTime(rt.io) +| timeout_ns;
+ return Value.initBoolean(try takeWaiting(rt, args[0], n, deadline));
+}
+
+/// `(.release s)` / `(.release s n)` — returns nil (JVM release is void).
+fn release(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArityRange(".release", args, 1, 2, loc);
+ giveBack(args[0], try permitArg(args, ".release", loc));
+ return Value.nil_val;
+}
+
+fn availablePermits(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".availablePermits", args, 1, loc);
+ return Value.initInteger(permitsNow(args[0]));
+}
+
+/// `(.drainPermits s)` — take every available permit, returning the count.
+fn drainPermits(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".drainPermits", args, 1, loc);
+ const ptr = permitsPtr(args[0]);
+ while (true) {
+ const cur_bits = @atomicLoad(u64, ptr, .seq_cst);
+ const cur: i64 = @bitCast(cur_bits);
+ if (cur <= 0) return Value.initInteger(0);
+ if (@cmpxchgWeak(u64, ptr, cur_bits, @as(u64, @bitCast(@as(i64, 0))), .seq_cst, .seq_cst) == null)
+ return Value.initInteger(cur);
+ }
+}
+
+/// `(.getQueueLength s)` — callers currently parked in `acquire`/timed
+/// `tryAcquire`. An estimate, as on the JVM.
+fn getQueueLength(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".getQueueLength", args, 1, loc);
+ return Value.initInteger(@intCast(@atomicLoad(u64, &@constCast(host_instance.asHostInstance(args[0])).state[2], .seq_cst)));
+}
+
+fn hasQueuedThreads(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".hasQueuedThreads", args, 1, loc);
+ return Value.initBoolean(@atomicLoad(u64, &@constCast(host_instance.asHostInstance(args[0])).state[2], .seq_cst) > 0);
+}
+
+/// `(.isFair s)` — the constructor flag. See AD-061: it does not change
+/// acquisition order.
+fn isFair(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".isFair", args, 1, loc);
+ return Value.initBoolean(host_instance.asHostInstance(args[0]).state[1] == 1);
+}
+
+const MethodSpec = struct { name: []const u8, f: *const fn (*Runtime, *Env, []const Value, SourceLocation) anyerror!Value };
+
+const METHODS = [_]MethodSpec{
+ .{ .name = "", .f = &initSemaphore },
+ .{ .name = "acquire", .f = &acquire },
+ .{ .name = "tryAcquire", .f = &tryAcquire },
+ .{ .name = "release", .f = &release },
+ .{ .name = "availablePermits", .f = &availablePermits },
+ .{ .name = "drainPermits", .f = &drainPermits },
+ .{ .name = "getQueueLength", .f = &getQueueLength },
+ .{ .name = "hasQueuedThreads", .f = &hasQueuedThreads },
+ .{ .name = "isFair", .f = &isFair },
+};
+
+fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent
+ sem_descriptor = td;
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, METHODS.len);
+ for (METHODS, 0..) |m, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, m.name),
+ .method_val = Value.initBuiltinFn(m.f),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.Semaphore",
+ .descriptor = &descriptor,
+ .init = &initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.Semaphore",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/concurrent/TimeUnit.zig b/src/runtime/java/util/concurrent/TimeUnit.zig
index 1bf859468..25e31b749 100644
--- a/src/runtime/java/util/concurrent/TimeUnit.zig
+++ b/src/runtime/java/util/concurrent/TimeUnit.zig
@@ -64,6 +64,18 @@ fn toNanos(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) an
(if (n < 0) std.math.minInt(i64) else std.math.maxInt(i64)));
}
+/// `n` units of `unit_val` in nanoseconds, saturating; null when `unit_val` is
+/// not a TimeUnit constant. The sole reader of NANOS_PER outside this file.
+pub fn nanosOf(unit_val: Value, n: i64) ?i64 {
+ if (unit_val.tag() != .host_instance) return null;
+ const inst = host_instance.asHostInstance(unit_val);
+ const inst_fqcn = inst.descriptor.fqcn orelse return null;
+ if (!std.mem.eql(u8, inst_fqcn, descriptor.fqcn.?)) return null;
+ const nanos = NANOS_PER[@as(usize, @intCast(inst.state[0]))];
+ return std.math.mul(i64, n, nanos) catch
+ (if (n < 0) std.math.minInt(i64) else std.math.maxInt(i64));
+}
+
fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
if (td.method_table.len != 0) return; // idempotent
const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, 4);
diff --git a/test/e2e/semaphore.sh b/test/e2e/semaphore.sh
new file mode 100755
index 000000000..e3100d6ca
--- /dev/null
+++ b/test/e2e/semaphore.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# test/e2e/semaphore.sh
+#
+# ADR-0029 / ADR-0106 — java.util.concurrent.Semaphore as a stateful native
+# instance. Permits move by CAS; a timed wait polls through the same
+# budgeted sleep Thread/sleep owns. AD-061 pins the fairness divergence.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+
+MS='java.util.concurrent.TimeUnit/MILLISECONDS'
+
+# --- permits: acquire drains, release restores ---
+assert_eq 'available_initial' "$(run '(.availablePermits (java.util.concurrent.Semaphore. 3))')" '3'
+assert_eq 'tryAcquire_until_empty' \
+ "$(run '(let [s (java.util.concurrent.Semaphore. 2)] [(.tryAcquire s) (.tryAcquire s) (.tryAcquire s)])')" \
+ '[true true false]'
+assert_eq 'release_restores' \
+ "$(run '(let [s (java.util.concurrent.Semaphore. 1)] (.tryAcquire s) (.release s) (.availablePermits s))')" '1'
+assert_eq 'release_past_initial' \
+ "$(run '(let [s (java.util.concurrent.Semaphore. 1)] (.release s 4) (.availablePermits s))')" '5'
+
+# --- multi-permit costs ---
+assert_eq 'cost_acquire' \
+ "$(run "(let [s (java.util.concurrent.Semaphore. 10)] [(.tryAcquire s 4 50 $MS) (.availablePermits s) (.tryAcquire s 7 50 $MS)])")" \
+ '[true 6 false]'
+assert_eq 'drainPermits' \
+ "$(run '(let [s (java.util.concurrent.Semaphore. 7)] [(.drainPermits s) (.availablePermits s) (.drainPermits s)])')" \
+ '[7 0 0]'
+
+# --- timed tryAcquire: a release from another thread wakes the waiter ---
+assert_eq 'timed_acquire_after_release' \
+ "$(run "(let [s (java.util.concurrent.Semaphore. 1)
+ _ (.tryAcquire s)
+ f (future (Thread/sleep 80) (.release s))
+ got (.tryAcquire s 2000 $MS)]
+ @f got)")" \
+ 'true'
+
+# --- timed tryAcquire: exhausted semaphore returns false at the deadline ---
+assert_eq 'timed_acquire_times_out' \
+ "$(run "(let [s (java.util.concurrent.Semaphore. 1)
+ _ (.tryAcquire s)
+ t0 (System/currentTimeMillis)
+ got (.tryAcquire s 60 $MS)
+ dt (- (System/currentTimeMillis) t0)]
+ [got (>= dt 55)])")" \
+ '[false true]'
+
+# --- blocking acquire returns once a permit lands ---
+assert_eq 'acquire_blocks_then_returns' \
+ "$(run '(let [s (java.util.concurrent.Semaphore. 0)
+ f (future (Thread/sleep 60) (.release s))]
+ (.acquire s) @f (.availablePermits s))')" '0'
+
+# --- getQueueLength counts parked callers only ---
+assert_eq 'queue_length_idle' \
+ "$(run '(let [s (java.util.concurrent.Semaphore. 1)] [(.getQueueLength s) (.hasQueuedThreads s)])')" \
+ '[0 false]'
+assert_eq 'queue_length_while_parked' \
+ "$(run "(let [s (java.util.concurrent.Semaphore. 1)
+ _ (.tryAcquire s)
+ f (future (.tryAcquire s 400 $MS))
+ _ (Thread/sleep 80)
+ queued (.getQueueLength s)]
+ (.release s) @f [queued (.getQueueLength s)])")" \
+ '[1 0]'
+
+# --- AD-061: fair? is recorded and reported, acquisition still barges ---
+assert_eq 'isFair_records_flag' \
+ "$(run '[(.isFair (java.util.concurrent.Semaphore. 1 true)) (.isFair (java.util.concurrent.Semaphore. 1))]')" \
+ '[true false]'
+
+# --- class / instance identity ---
+assert_eq 'class_name' \
+ "$(run '(= "java.util.concurrent.Semaphore" (str (class (java.util.concurrent.Semaphore. 1))))')" 'true'
+
+echo "OK test/e2e/semaphore.sh"
From 610490144ec64d9bf89716f7c991b7ad85bfc0e6 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 15:35:53 -0300
Subject: [PATCH 044/466] feat(java): atomics, TimeoutException ctor, Runtime
memory readings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The value tier hive-weave's executor-using namespaces sit on, found by
driving the library itself rather than by working down a coverage list.
- java.util.concurrent.atomic.{AtomicLong,AtomicInteger,AtomicBoolean} over
one CAS-moved host_instance word, sharing a comptime-parameterised impl.
AtomicInteger does not truncate to 32 bits (AD-062).
- java.util.concurrent.TimeoutException is now constructible; catch already
resolved the class through host_class.zig's hierarchy.
- java.lang.Runtime .totalMemory/.freeMemory/.maxMemory answer from cljw's
own mark-sweep accounting and the CLJW_EVAL_MAX_HEAP_MB ceiling (AD-063),
never from a fabricated constant.
Fixes a general trap on the way: Value.initInteger silently converts an
out-of-i48 argument to an f64, so .maxMemory was returning a Double that
fails (= ... Long/MAX_VALUE). Exact Longs past i48 go through
big_int.allocFromI64(rt, i, .long).
test/e2e/concurrent_atomics.sh — 13 assertions, including a 4-thread
contended counter landing exactly 2000.
---
.dev/accepted_divergences.yaml | 36 +++
CHANGELOG.md | 29 +++
src/runtime/java/_host_api.zig | 4 +
src/runtime/java/lang/Runtime.zig | 76 +++++-
.../java/util/concurrent/TimeoutException.zig | 56 +++++
.../util/concurrent/atomic/AtomicBoolean.zig | 32 +++
.../util/concurrent/atomic/AtomicInteger.zig | 32 +++
.../util/concurrent/atomic/AtomicLong.zig | 34 +++
.../java/util/concurrent/atomic/_atomic.zig | 223 ++++++++++++++++++
test/e2e/concurrent_atomics.sh | 87 +++++++
10 files changed, 604 insertions(+), 5 deletions(-)
create mode 100644 src/runtime/java/util/concurrent/TimeoutException.zig
create mode 100644 src/runtime/java/util/concurrent/atomic/AtomicBoolean.zig
create mode 100644 src/runtime/java/util/concurrent/atomic/AtomicInteger.zig
create mode 100644 src/runtime/java/util/concurrent/atomic/AtomicLong.zig
create mode 100644 src/runtime/java/util/concurrent/atomic/_atomic.zig
create mode 100755 test/e2e/concurrent_atomics.sh
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index 8bc28c246..80365c284 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -987,3 +987,39 @@ accepted:
example: '(let [s (java.util.concurrent.Semaphore. 1 true)] (.isFair s)) => true, yet a thread that has waited longer has no priority over a newcomer, unlike the JVM.'
derives_from: "ADR-0029 (host surface registry) + ADR-0106 (host_instance state) + the budgeted-sleep wait Thread/sleep owns (D-442 / D-571)."
pin: "test/e2e/semaphore.sh — isFair_records_flag (the flag round-trips) + queue_length_while_parked (a parked waiter is counted, and the permit is handed over)."
+ - id: "AD-062"
+ area: "concurrency"
+ summary: |-
+ `java.util.concurrent.atomic.AtomicInteger` does NOT truncate to 32 bits.
+ cljw has one integer width, so AtomicInteger and AtomicLong share a
+ backing impl over the same `host_instance` word: `.incrementAndGet` at
+ `Integer/MAX_VALUE` yields 2147483648 where the JVM wraps to
+ `Integer/MIN_VALUE`. Arithmetic saturates at the i64 ends rather than
+ wrapping, matching what cljw's own integer ops already do.
+ Emulating 32-bit wraparound would mean introducing a narrower integer
+ cljw has nowhere else, so a value read back out of the atomic would not
+ behave like any other cljw integer. Overflow-dependent code is rare;
+ code that reads a counter is not.
+ example: '(let [a (java.util.concurrent.atomic.AtomicInteger. Integer/MAX_VALUE)] (.incrementAndGet a)) => cljw 2147483648 / clj -2147483648'
+ derives_from: "ADR-0059 (no JVM) + cljw's single i64 integer width (D-165 Long/BigInt origins)."
+ pin: "test/e2e/concurrent_atomics.sh — atomicinteger_no_32bit_truncation."
+ - id: "AD-063"
+ area: "runtime"
+ summary: |-
+ `(.totalMemory r)` / `(.freeMemory r)` / `(.maxMemory r)` answer from
+ cljw's own mark-sweep accounting, not from a JVM heap that does not
+ exist. `totalMemory` is the live set or the adaptive collect threshold,
+ whichever is larger (cljw keeps no reserved-but-unused pool, so the
+ threshold is the honest analogue of committed heap); `freeMemory` is the
+ headroom between them; `maxMemory` is the per-eval ceiling
+ `CLJW_EVAL_MAX_HEAP_MB` sets (ADR-0125), or `Long/MAX_VALUE` when the
+ heap is unmetered.
+ The consequence worth stating: on an unmetered cljw a used/max heap-
+ pressure ratio reads ~0 and a pressure monitor never fires. That is
+ TRUE — an unmetered heap has no ceiling to be pressured against — and it
+ is why the reading is wired to the real ceiling rather than to a
+ fabricated constant, which would make the same monitor fire on numbers
+ that mean nothing.
+ example: 'CLJW_EVAL_MAX_HEAP_MB unset: (.maxMemory (Runtime/getRuntime)) => 9223372036854775807 (no ceiling) / with CLJW_EVAL_MAX_HEAP_MB=64 => 67108864, and the pressure ratio becomes real.'
+ derives_from: "ADR-0028 (mark-sweep + adaptive threshold) + ADR-0125 / D-352 (per-eval heap ceiling) + ADR-0059 (no JVM)."
+ pin: "test/e2e/concurrent_atomics.sh — runtime_memory_is_coherent + runtime_maxmemory_unmetered_is_exact_long."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a21d820f7..cef9bc392 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,35 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
through cljw: `hive-weave.gate` and `hive-weave.budget` — permit gating,
cost-based budget admission, saturation timeouts and stats — now run on it.
+- **`java.util.concurrent.atomic.{AtomicLong,AtomicInteger,AtomicBoolean}`.**
+ One CAS-moved word each: `` (`[v]`), `.get`, `.set`, `.lazySet`,
+ `.getAndSet`, `.compareAndSet` / `.weakCompareAndSet`, plus the numeric
+ `.incrementAndGet`, `.decrementAndGet`, `.getAndIncrement`,
+ `.getAndDecrement`, `.addAndGet`, `.getAndAdd`, `.longValue` / `.intValue`.
+ Exact under cljw's real OS threads — four threads racing 500 increments
+ apiece land 2000, not fewer. AtomicInteger does not truncate to 32 bits
+ (AD-062).
+
+- **`java.util.concurrent.TimeoutException` is constructible.** `catch` already
+ resolved the class (`host_class.zig` carries the FQCN mapping and the
+ `TimeoutException < Exception` edge); the surface adds `(TimeoutException.)`
+ / `(TimeoutException. msg)`, which is what a library needs to raise one.
+
+- **`java.lang.Runtime` reports memory.** `.totalMemory`, `.freeMemory` and
+ `.maxMemory` answer from cljw's own mark-sweep accounting and from the
+ `CLJW_EVAL_MAX_HEAP_MB` ceiling (ADR-0125) rather than from a fabricated
+ constant, so a heap-pressure monitor either reads real numbers or correctly
+ reads "no ceiling" (AD-063).
+
+### Fixed
+
+- **Host surfaces returning a large integer no longer hand back a Double.**
+ `Value.initInteger` silently converts an argument outside the NaN-boxed i48
+ range to an `f64`; an exact Long past that range must be heap-boxed through
+ `big_int.allocFromI64(rt, i, .long)`. Caught on `(.maxMemory
+ (Runtime/getRuntime))`, which returned `9.223372036854776E18` — a value that
+ fails `(= … Long/MAX_VALUE)` and poisons integer arithmetic downstream.
+
## [1.10.2] - 2026-08-12
The fork's first runtime changes. Nearly all were found by driving
diff --git a/src/runtime/java/_host_api.zig b/src/runtime/java/_host_api.zig
index 79a829e3a..533551866 100644
--- a/src/runtime/java/_host_api.zig
+++ b/src/runtime/java/_host_api.zig
@@ -127,6 +127,10 @@ const java_surfaces = [_]type{
@import("util/regex/Pattern.zig"),
@import("util/concurrent/TimeUnit.zig"),
@import("util/concurrent/Semaphore.zig"),
+ @import("util/concurrent/TimeoutException.zig"),
+ @import("util/concurrent/atomic/AtomicLong.zig"),
+ @import("util/concurrent/atomic/AtomicInteger.zig"),
+ @import("util/concurrent/atomic/AtomicBoolean.zig"),
@import("lang/reflect/Array.zig"),
};
diff --git a/src/runtime/java/lang/Runtime.zig b/src/runtime/java/lang/Runtime.zig
index 37df863e8..3e9a76506 100644
--- a/src/runtime/java/lang/Runtime.zig
+++ b/src/runtime/java/lang/Runtime.zig
@@ -7,11 +7,27 @@
//! Clojure peer: none
//!
//! `Runtime/getRuntime` returns the process-lifetime host_instance singleton
-//! (cached on `rt.runtime_instance`; identity holds, clj-faithful). The only
-//! wired instance method is `availableProcessors` (libraries size thread pools
-//! by it) → the host CPU count via `std.Thread.getCpuCount`. cljw is no-JVM
-//! (ADR-0059); the broader Runtime surface (exec / gc / freeMemory / addShutdown
-//! Hook) is out of scope — `exit`/`halt` route through `System/exit`.
+//! (cached on `rt.runtime_instance`; identity holds, clj-faithful).
+//! `availableProcessors` (libraries size thread pools by it) → the host CPU
+//! count via `std.Thread.getCpuCount`. cljw is no-JVM (ADR-0059); the rest of
+//! the Runtime surface (exec / addShutdownHook) is out of scope —
+//! `exit`/`halt` route through `System/exit`.
+//!
+//! The three memory readings answer from cljw's OWN mark-sweep accounting
+//! (`rt.gc.stats`), never from a fabricated constant — a heap-pressure monitor
+//! that reads a made-up denominator is worse than one that cannot read at all.
+//! The mapping onto the JVM's vocabulary (divergence AD-063):
+//!
+//! - `.totalMemory` — the heap cljw currently commits to, i.e. the live set
+//! or the adaptive collect threshold, whichever is larger. cljw keeps no
+//! reserved-but-unused pool, so the threshold is the honest analogue of
+//! "heap the collector will let you grow into".
+//! - `.freeMemory` — `totalMemory` minus the live set: headroom before the
+//! next collection, which is what the JVM reading also means.
+//! - `.maxMemory` — `gc.heap_ceiling` (set by `CLJW_EVAL_MAX_HEAP_MB`,
+//! ADR-0125), or `Long/MAX_VALUE` when the heap is unmetered. An unmetered
+//! cljw genuinely has no ceiling, so a used/max pressure ratio reads ~0.
+//! That is the truth, not a stub.
const std = @import("std");
const host_api = @import("../_host_api.zig");
@@ -23,6 +39,8 @@ const SourceLocation = @import("../../error/info.zig").SourceLocation;
const error_catalog = @import("../../error/catalog.zig");
const host_instance = @import("../../host_instance.zig");
const HeapHeader = @import("../../value/value.zig").HeapHeader;
+const nan_box = @import("../../value/nan_box.zig");
+const big_int = @import("../../numeric/big_int.zig");
/// Implements `(Runtime/getRuntime)` — the process-lifetime Runtime singleton
/// (cached on `rt.runtime_instance`; identity holds across calls).
@@ -51,11 +69,59 @@ fn availableProcessors(rt: *Runtime, env: *Env, args: []const Value, loc: Source
return Value.initInteger(@intCast(n));
}
+/// Bytes the mark-sweep heap is currently holding for live objects.
+fn liveBytes(rt: *const Runtime) usize {
+ const s = rt.gc.stats;
+ return s.bytes_allocated -| s.bytes_freed;
+}
+
+/// Saturating `usize` → a Clojure Long. A heap larger than `Long/MAX_VALUE`
+/// pins rather than reporting a negative size.
+///
+/// Past i48 this MUST heap-box through `big_int` (the `.long` origin, so it
+/// still prints and classes as Long): `Value.initInteger` silently converts an
+/// out-of-i48 argument to an `f64`, which would hand `.maxMemory` back as
+/// `9.223372036854776E18` — a double that fails `(= … Long/MAX_VALUE)` and
+/// poisons any integer arithmetic a caller does with it.
+fn asLong(rt: *Runtime, n: usize) !Value {
+ const i = std.math.cast(i64, n) orelse std.math.maxInt(i64);
+ if (i < nan_box.NB_I48_MIN or i > nan_box.NB_I48_MAX)
+ return big_int.allocFromI64(rt, i, .long);
+ return Value.initInteger(i);
+}
+
+/// `(.totalMemory r)` — see the module docstring for the JVM mapping (AD-063).
+fn totalMemory(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("Runtime/totalMemory", args, 1, loc);
+ return asLong(rt, @max(liveBytes(rt), rt.gc.threshold_bytes));
+}
+
+/// `(.freeMemory r)` — headroom inside `totalMemory` before the next collect.
+fn freeMemory(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("Runtime/freeMemory", args, 1, loc);
+ const live = liveBytes(rt);
+ return asLong(rt, @max(live, rt.gc.threshold_bytes) - live);
+}
+
+/// `(.maxMemory r)` — the per-eval heap ceiling, or `Long/MAX_VALUE` when the
+/// heap is unmetered (cljw then genuinely has no ceiling to report).
+fn maxMemory(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("Runtime/maxMemory", args, 1, loc);
+ const ceiling = rt.gc.heap_ceiling orelse return big_int.allocFromI64(rt, std.math.maxInt(i64), .long);
+ return asLong(rt, ceiling);
+}
+
fn initRuntime(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
if (td.method_table.len != 0) return; // idempotent re-run
const specs = .{
.{ "getRuntime", &getRuntime },
.{ "availableProcessors", &availableProcessors },
+ .{ "totalMemory", &totalMemory },
+ .{ "freeMemory", &freeMemory },
+ .{ "maxMemory", &maxMemory },
};
const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
inline for (specs, 0..) |spec, i| {
diff --git a/src/runtime/java/util/concurrent/TimeoutException.zig b/src/runtime/java/util/concurrent/TimeoutException.zig
new file mode 100644
index 000000000..4a5418b09
--- /dev/null
+++ b/src/runtime/java/util/concurrent/TimeoutException.zig
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.TimeoutException` — the constructor
+//! `(TimeoutException.)` / `(TimeoutException. msg)`.
+//!
+//! Backend: impl-only
+//! Impl deps: none
+//! Clojure peer: clojure.core/ex-info (the cljw-native exception value)
+//!
+//! Same shape as `RuntimeException.zig`; mints an `.ex_info` tagged
+//! "TimeoutException". `catch` already resolved this class before the surface
+//! existed — `host_class.zig` carries both the FQCN mapping and the
+//! `TimeoutException < Exception` edge — so the surface adds only the ability
+//! to CONSTRUCT one. The executor tier's timed `.get` raises through the same
+//! tag, which is why `(catch TimeoutException …)` sees it.
+
+const std = @import("std");
+const host_api = @import("../../_host_api.zig");
+const type_descriptor = @import("../../../type_descriptor.zig");
+const Value = @import("../../../value/value.zig").Value;
+const Runtime = @import("../../../runtime.zig").Runtime;
+const Env = @import("../../../env.zig").Env;
+const ex_info = @import("../../../collection/ex_info.zig");
+const SourceLocation = @import("../../../error/info.zig").SourceLocation;
+
+fn timeoutExceptionCtor(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ _ = loc;
+ return ex_info.allocExceptionFromArgs(rt, args, "TimeoutException");
+}
+
+fn initTimeoutException(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent re-run
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, 1);
+ entries[0] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, ""),
+ .method_val = Value.initBuiltinFn(&timeoutExceptionCtor),
+ };
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.TimeoutException",
+ .descriptor = &descriptor,
+ .init = &initTimeoutException,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.TimeoutException",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/concurrent/atomic/AtomicBoolean.zig b/src/runtime/java/util/concurrent/atomic/AtomicBoolean.zig
new file mode 100644
index 000000000..f861cb12d
--- /dev/null
+++ b/src/runtime/java/util/concurrent/atomic/AtomicBoolean.zig
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.atomic.AtomicBoolean`.
+//!
+//! Backend: impl-only
+//! Impl deps: _atomic.zig (shared CAS impl over `host_instance.state[0]`)
+//! Clojure peer: none
+//!
+//! Surface: `` ([b]), `.get`, `.set`, `.lazySet`, `.getAndSet`,
+//! `.compareAndSet`, `.weakCompareAndSet`.
+
+const host_api = @import("../../../_host_api.zig");
+const type_descriptor = @import("../../../../type_descriptor.zig");
+const atomic = @import("_atomic.zig");
+
+var slot: ?*const type_descriptor.TypeDescriptor = null;
+const Impl = atomic.Impl(&slot, "java.util.concurrent.atomic.AtomicBoolean", .boolean);
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.atomic.AtomicBoolean",
+ .descriptor = &descriptor,
+ .init = &Impl.initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.atomic.AtomicBoolean",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/concurrent/atomic/AtomicInteger.zig b/src/runtime/java/util/concurrent/atomic/AtomicInteger.zig
new file mode 100644
index 000000000..60527375c
--- /dev/null
+++ b/src/runtime/java/util/concurrent/atomic/AtomicInteger.zig
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.atomic.AtomicInteger`.
+//!
+//! Backend: impl-only
+//! Impl deps: _atomic.zig (shared CAS impl over `host_instance.state[0]`)
+//! Clojure peer: none
+//!
+//! Surface: identical to AtomicLong — cljw has one integer width, so this
+//! class stores an `i64` and never truncates to 32 bits (AD-062).
+
+const host_api = @import("../../../_host_api.zig");
+const type_descriptor = @import("../../../../type_descriptor.zig");
+const atomic = @import("_atomic.zig");
+
+var slot: ?*const type_descriptor.TypeDescriptor = null;
+const Impl = atomic.Impl(&slot, "java.util.concurrent.atomic.AtomicInteger", .integer);
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.atomic.AtomicInteger",
+ .descriptor = &descriptor,
+ .init = &Impl.initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.atomic.AtomicInteger",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/concurrent/atomic/AtomicLong.zig b/src/runtime/java/util/concurrent/atomic/AtomicLong.zig
new file mode 100644
index 000000000..52861fcb7
--- /dev/null
+++ b/src/runtime/java/util/concurrent/atomic/AtomicLong.zig
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.atomic.AtomicLong`.
+//!
+//! Backend: impl-only
+//! Impl deps: _atomic.zig (shared CAS impl over `host_instance.state[0]`)
+//! Clojure peer: none
+//!
+//! Surface: `` ([n]), `.get`, `.set`, `.lazySet`, `.getAndSet`,
+//! `.compareAndSet`, `.weakCompareAndSet`, `.incrementAndGet`,
+//! `.decrementAndGet`, `.getAndIncrement`, `.getAndDecrement`, `.addAndGet`,
+//! `.getAndAdd`, `.longValue`, `.intValue`.
+
+const host_api = @import("../../../_host_api.zig");
+const type_descriptor = @import("../../../../type_descriptor.zig");
+const atomic = @import("_atomic.zig");
+
+var slot: ?*const type_descriptor.TypeDescriptor = null;
+const Impl = atomic.Impl(&slot, "java.util.concurrent.atomic.AtomicLong", .integer);
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.atomic.AtomicLong",
+ .descriptor = &descriptor,
+ .init = &Impl.initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.atomic.AtomicLong",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/src/runtime/java/util/concurrent/atomic/_atomic.zig b/src/runtime/java/util/concurrent/atomic/_atomic.zig
new file mode 100644
index 000000000..16a171503
--- /dev/null
+++ b/src/runtime/java/util/concurrent/atomic/_atomic.zig
@@ -0,0 +1,223 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Shared backing impl for the `java.util.concurrent.atomic` surfaces
+//! (ADR-0029 / ADR-0106). One `.host_instance` word (`state[0]`) holds the
+//! value; every mutation moves it with `@cmpxchgWeak` or `@atomicRmw`, so the
+//! surfaces are safe under cljw's real OS threads.
+//!
+//! Backend: impl-only (no `___HOST_EXTENSION` — the per-class surface files
+//! under this directory export the markers and share this `Impl`).
+//! Impl deps: host_instance
+//! Clojure peer: none
+//!
+//! `Impl` is comptime-parameterised by the class's descriptor slot, its name
+//! (for arity/type errors) and its `Repr`, because AtomicLong / AtomicInteger
+//! differ only in the class the value is REPORTED under: cljw has one integer
+//! width, so both store an `i64` and neither truncates. Divergence AD-062.
+
+const std = @import("std");
+const type_descriptor = @import("../../../../type_descriptor.zig");
+const Value = @import("../../../../value/value.zig").Value;
+const Runtime = @import("../../../../runtime.zig").Runtime;
+const Env = @import("../../../../env.zig").Env;
+const SourceLocation = @import("../../../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../../../error/catalog.zig");
+const host_instance = @import("../../../../host_instance.zig");
+
+/// How the single state word is read and written on the Clojure side.
+pub const Repr = enum { integer, boolean };
+
+pub const MethodSpec = struct {
+ name: []const u8,
+ f: *const fn (*Runtime, *Env, []const Value, SourceLocation) anyerror!Value,
+};
+
+/// Build the method set for one atomic class. `td_slot` is the surface file's
+/// own descriptor cell, filled by `initDescriptor` so `` can allocate.
+pub fn Impl(
+ comptime td_slot: *?*const type_descriptor.TypeDescriptor,
+ comptime class_name: []const u8,
+ comptime repr: Repr,
+) type {
+ return struct {
+ const Self = @This();
+
+ fn wordPtr(recv: Value) *u64 {
+ return &@constCast(host_instance.asHostInstance(recv)).state[0];
+ }
+
+ fn load(recv: Value) i64 {
+ return @bitCast(@atomicLoad(u64, wordPtr(recv), .seq_cst));
+ }
+
+ /// The Clojure value the stored word denotes.
+ fn out(n: i64) Value {
+ return switch (repr) {
+ .integer => Value.initInteger(n),
+ .boolean => Value.initBoolean(n != 0),
+ };
+ }
+
+ /// The word an argument denotes. A boolean atomic takes any value and
+ /// reads its truthiness, as `(AtomicBoolean. x)` does on the JVM only
+ /// for a real boolean — cljw has no unboxing step to reject at.
+ fn in(v: Value, loc: SourceLocation) anyerror!i64 {
+ return switch (repr) {
+ .integer => @as(i64, try error_catalog.expectInteger(v, class_name, loc)),
+ .boolean => if (v.isTruthy()) 1 else 0,
+ };
+ }
+
+ /// `(java.util.concurrent.atomic.AtomicLong.)` / `(… n)`.
+ fn initAtomic(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ if (args.len > 1)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = class_name ++ ".", .expected = 1 });
+ const initial: i64 = if (args.len == 1) try in(args[0], loc) else 0;
+ const td = td_slot.* orelse return error.NoVTable;
+ return host_instance.alloc(rt, td, .{ @bitCast(initial), 0, 0, 0 });
+ }
+
+ fn get(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".get", args, 1, loc);
+ return out(load(args[0]));
+ }
+
+ fn set(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".set", args, 2, loc);
+ @atomicStore(u64, wordPtr(args[0]), @bitCast(try in(args[1], loc)), .seq_cst);
+ return Value.nil_val;
+ }
+
+ /// `.lazySet` — cljw has no relaxed-store tier to spend, so it is `.set`.
+ fn lazySet(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ return set(rt, env, args, loc);
+ }
+
+ fn getAndSet(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".getAndSet", args, 2, loc);
+ const next: u64 = @bitCast(try in(args[1], loc));
+ const prev = @atomicRmw(u64, wordPtr(args[0]), .Xchg, next, .seq_cst);
+ return out(@bitCast(prev));
+ }
+
+ fn compareAndSet(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".compareAndSet", args, 3, loc);
+ const expect: u64 = @bitCast(try in(args[1], loc));
+ const next: u64 = @bitCast(try in(args[2], loc));
+ return Value.initBoolean(@cmpxchgStrong(u64, wordPtr(args[0]), expect, next, .seq_cst, .seq_cst) == null);
+ }
+
+ /// Add `delta` and hand back one of the two sides. Saturating, so a
+ /// counter at `Long/MAX_VALUE` pins instead of wrapping to negative.
+ fn addBy(recv: Value, delta: i64, comptime want: enum { before, after }) Value {
+ const ptr = wordPtr(recv);
+ while (true) {
+ const cur_bits = @atomicLoad(u64, ptr, .seq_cst);
+ const cur: i64 = @bitCast(cur_bits);
+ const next = cur +| delta;
+ if (@cmpxchgWeak(u64, ptr, cur_bits, @as(u64, @bitCast(next)), .seq_cst, .seq_cst) == null)
+ return out(switch (want) {
+ .before => cur,
+ .after => next,
+ });
+ }
+ }
+
+ fn incrementAndGet(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".incrementAndGet", args, 1, loc);
+ return addBy(args[0], 1, .after);
+ }
+
+ fn decrementAndGet(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".decrementAndGet", args, 1, loc);
+ return addBy(args[0], -1, .after);
+ }
+
+ fn getAndIncrement(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".getAndIncrement", args, 1, loc);
+ return addBy(args[0], 1, .before);
+ }
+
+ fn getAndDecrement(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".getAndDecrement", args, 1, loc);
+ return addBy(args[0], -1, .before);
+ }
+
+ fn addAndGet(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".addAndGet", args, 2, loc);
+ return addBy(args[0], try in(args[1], loc), .after);
+ }
+
+ fn getAndAdd(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".getAndAdd", args, 2, loc);
+ return addBy(args[0], try in(args[1], loc), .before);
+ }
+
+ fn longValue(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".longValue", args, 1, loc);
+ return Value.initInteger(load(args[0]));
+ }
+
+ const NUMERIC = [_]MethodSpec{
+ .{ .name = "incrementAndGet", .f = &incrementAndGet },
+ .{ .name = "decrementAndGet", .f = &decrementAndGet },
+ .{ .name = "getAndIncrement", .f = &getAndIncrement },
+ .{ .name = "getAndDecrement", .f = &getAndDecrement },
+ .{ .name = "addAndGet", .f = &addAndGet },
+ .{ .name = "getAndAdd", .f = &getAndAdd },
+ .{ .name = "longValue", .f = &longValue },
+ .{ .name = "intValue", .f = &longValue },
+ };
+
+ const COMMON = [_]MethodSpec{
+ .{ .name = "", .f = &initAtomic },
+ .{ .name = "get", .f = &get },
+ .{ .name = "set", .f = &set },
+ .{ .name = "lazySet", .f = &lazySet },
+ .{ .name = "getAndSet", .f = &getAndSet },
+ .{ .name = "compareAndSet", .f = &compareAndSet },
+ .{ .name = "weakCompareAndSet", .f = &compareAndSet },
+ };
+
+ pub const METHODS = switch (repr) {
+ .integer => COMMON ++ NUMERIC,
+ .boolean => COMMON,
+ };
+
+ pub fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent
+ td_slot.* = td;
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, METHODS.len);
+ for (METHODS, 0..) |m, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, m.name),
+ .method_val = Value.initBuiltinFn(m.f),
+ };
+ }
+ td.method_table = entries;
+ }
+ };
+}
diff --git a/test/e2e/concurrent_atomics.sh b/test/e2e/concurrent_atomics.sh
new file mode 100755
index 000000000..f6303d9e0
--- /dev/null
+++ b/test/e2e/concurrent_atomics.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# test/e2e/concurrent_atomics.sh
+#
+# ADR-0029 / ADR-0106 — the value tier under the executor surfaces:
+# java.util.concurrent.atomic.{AtomicLong,AtomicInteger,AtomicBoolean},
+# java.util.concurrent.TimeoutException, and the java.lang.Runtime memory
+# readings. AD-062 pins AtomicInteger's non-truncation; AD-063 pins the
+# Runtime memory mapping onto cljw's own GC accounting.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+
+AL='java.util.concurrent.atomic.AtomicLong'
+AI='java.util.concurrent.atomic.AtomicInteger'
+AB='java.util.concurrent.atomic.AtomicBoolean'
+
+# --- AtomicLong: the get/set/add family ---
+assert_eq 'atomiclong_default_zero' "$(run "(.get ($AL.))")" '0'
+assert_eq 'atomiclong_increment' \
+ "$(run "(let [a ($AL. 0)] [(.incrementAndGet a) (.getAndIncrement a) (.get a) (.decrementAndGet a)])")" \
+ '[1 1 2 1]'
+assert_eq 'atomiclong_add' \
+ "$(run "(let [a ($AL. 10)] [(.addAndGet a 5) (.getAndAdd a 5) (.get a)])")" \
+ '[15 15 20]'
+assert_eq 'atomiclong_set_and_getAndSet' \
+ "$(run "(let [a ($AL. 1)] (.set a 9) [(.get a) (.getAndSet a 3) (.get a)])")" \
+ '[9 9 3]'
+
+# --- compareAndSet only fires on a matching witness ---
+assert_eq 'atomiclong_cas' \
+ "$(run "(let [a ($AL. 7)] [(.compareAndSet a 7 9) (.get a) (.compareAndSet a 7 1) (.get a)])")" \
+ '[true 9 false 9]'
+
+# --- AD-062: AtomicInteger stores an i64 and does NOT truncate to 32 bits ---
+assert_eq 'atomicinteger_no_32bit_truncation' \
+ "$(run "(let [a ($AI. 2147483647)] [(.get a) (.incrementAndGet a)])")" \
+ '[2147483647 2147483648]'
+
+# --- AtomicBoolean ---
+assert_eq 'atomicboolean_cas' \
+ "$(run "(let [a ($AB. false)] [(.get a) (.compareAndSet a false true) (.get a) (.getAndSet a false) (.get a)])")" \
+ '[false true true true false]'
+
+# --- the counter is atomic under cljw's real OS threads (F-006) ---
+assert_eq 'atomiclong_contended_count_is_exact' \
+ "$(run "(let [a ($AL. 0)
+ fs (doall (for [_ (range 4)] (future (dotimes [_ 500] (.incrementAndGet a)))))]
+ (doseq [f fs] @f)
+ (.get a))")" \
+ '2000'
+
+# --- class identity ---
+assert_eq 'atomiclong_class_name' "$(run "(= \"$AL\" (str (class ($AL. 1))))")" 'true'
+
+# --- TimeoutException: constructible, and caught by both spellings ---
+assert_eq 'timeout_exception_caught_by_own_class' \
+ "$(run '(try (throw (java.util.concurrent.TimeoutException. "late"))
+ (catch java.util.concurrent.TimeoutException e (ex-message e)))')" \
+ '"late"'
+assert_eq 'timeout_exception_is_an_exception' \
+ "$(run '(try (throw (java.util.concurrent.TimeoutException. "late"))
+ (catch Exception _ :caught-as-exception))')" \
+ ':caught-as-exception'
+
+# --- AD-063: Runtime memory answers from cljw's own GC accounting ---
+assert_eq 'runtime_memory_is_coherent' \
+ "$(run '(let [r (Runtime/getRuntime) t (.totalMemory r) f (.freeMemory r)]
+ [(pos? t) (<= 0 f) (<= f t) (integer? t)])')" \
+ '[true true true true]'
+# An unmetered heap has no ceiling to report, and Long/MAX_VALUE must stay an
+# exact Long — `Value.initInteger` would silently hand back a Double past i48.
+assert_eq 'runtime_maxmemory_unmetered_is_exact_long' \
+ "$(run '(let [m (.maxMemory (Runtime/getRuntime))] [(= m Long/MAX_VALUE) (str (class m))])')" \
+ '[true "Long"]'
+
+echo "OK test/e2e/concurrent_atomics.sh"
From 9460e2a16989125f5c216ceb2448ae8922bd0828 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 15:45:54 -0300
Subject: [PATCH 045/466] feat(reify): ThreadFactory, Callable and Runnable as
reify-able host interfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
reify accepts only a protocol Var or a quote-wrapped marker name — a class
value fails with "__reify!: expected protocol, got type_descriptor". So a
concurrency library's `(reify ThreadFactory (newThread [_ r] ...))` could not
load, whatever native surface existed.
Adds the three java.util.concurrent/java.lang functional interfaces libraries
actually reify to the host-interface closed set (ADR-0102 / F-013), as
method_family so the method really dispatches: both the qualified spelling and
the bare post-:import one. Gate G4 (yaml SSOT vs zig table) stays green.
Measured against hive-weave: hive-weave.pool's load failure moves from the
reify at line 48 to the ^Future hint at line 184.
test/e2e/concurrent_interfaces.sh — 8 assertions, including a reified factory
minting a named daemon Thread that runs, and a reify combining a host
interface with a plain protocol section.
---
CHANGELOG.md | 8 ++++
data/host_interfaces.yaml | 39 ++++++++++++++++
src/runtime/host_interface.zig | 21 +++++++++
test/e2e/concurrent_interfaces.sh | 77 +++++++++++++++++++++++++++++++
4 files changed, 145 insertions(+)
create mode 100755 test/e2e/concurrent_interfaces.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cef9bc392..53b36e3e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,14 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
constant, so a heap-pressure monitor either reads real numbers or correctly
reads "no ceiling" (AD-063).
+- **`ThreadFactory`, `Callable` and `Runnable` are reify-able.**
+ `(reify java.util.concurrent.ThreadFactory (newThread [_ r] …))` — and the
+ bare spelling after `:import` — now loads and dispatches, so a library can
+ hand a pool its own worker-minting policy. They join the host-interface
+ closed set (`data/host_interfaces.yaml`, ADR-0102) rather than becoming
+ native classes, because `reify` accepts only a protocol Var or a marker name
+ and rejects a class value outright.
+
### Fixed
- **Host surfaces returning a large integer no longer hand back a Double.**
diff --git a/data/host_interfaces.yaml b/data/host_interfaces.yaml
index bb68201b5..96c2c37e1 100644
--- a/data/host_interfaces.yaml
+++ b/data/host_interfaces.yaml
@@ -49,6 +49,45 @@ interfaces:
hasheq: { status: wired, wires_to: "hash consult — clj (hash x) prefers hasheq (D-280d5); hashFn consults Object/hasheq before hashCode" }
equiv: { status: wired, wires_to: "= consult — clj collection value-equality (D-280d8); valueEqual consults Object/equiv before equals (same-type)" }
+ # --- java.util.concurrent functional interfaces: REIFY targets (not
+ # collection supertypes). A concurrency library hands a thread pool a
+ # ThreadFactory to mint workers and a Callable as the unit of work. These
+ # are method_family (the method IS dispatched through the reify's method
+ # table), not host_inert. They live here rather than in compat_tiers.yaml
+ # because reify rejects a class value outright ("expected protocol, got
+ # type_descriptor") — a reify-able host interface can ONLY be a marker name.
+ - name: java.util.concurrent.ThreadFactory
+ aliases: [ThreadFactory]
+ derives_from: "java.util.concurrent.ThreadFactory — the JDK's single-method worker-minting seam; the standard reify target when sizing/naming a pool's threads (java.util.concurrent, stable since 1.5)."
+ tier: A
+ recognised: true
+ note: >-
+ hive-weave.pool reifies it bare after :import; hive-weave.guarded names it
+ fully qualified inline. Both spellings carry a MARKERS row.
+ methods:
+ newThread: { status: wired, wires_to: "reify method dispatch — (.newThread tf r) resolves through the reify's own method_table; the executor tier calls it to mint each worker" }
+ - name: java.util.concurrent.Callable
+ aliases: [Callable]
+ derives_from: "java.util.concurrent.Callable — the JDK's value-returning unit of work; what ExecutorService/.submit accepts and what libraries reify (java.util.concurrent, stable since 1.5)."
+ tier: A
+ recognised: true
+ note: >-
+ cljw's executor tier also accepts a plain 0-arg fn as the unit of work
+ (clj's own submit does the IFn-is-Callable coercion); this row is for code
+ that reifies Callable explicitly.
+ methods:
+ call: { status: wired, wires_to: "reify method dispatch — (.call c) resolves through the reify's own method_table; the executor tier invokes it when the submitted work is a reified Callable rather than a fn" }
+ - name: java.lang.Runnable
+ aliases: [Runnable]
+ derives_from: "java.lang.Runnable — the JDK's void unit of work; what a Thread constructor takes and a standard reify target (java.lang, stable since 1.0)."
+ tier: A
+ recognised: true
+ note: >-
+ cljw's (Thread. f) already accepts a 0-arg fn directly; this row lets code
+ that reifies Runnable explicitly load and dispatch.
+ methods:
+ run: { status: wired, wires_to: "reify method dispatch — (.run r) resolves through the reify's own method_table" }
+
# --- clojure.lang.* deftype/reify supertype family (the closed set; 網羅) ---
# recognised: false → enumerated as the planned closed set, NOT yet in the
# host_interface.zig table. D-275 slices 3+ flip each to recognised: true by
diff --git a/src/runtime/host_interface.zig b/src/runtime/host_interface.zig
index b605a0d61..8519cb2ed 100644
--- a/src/runtime/host_interface.zig
+++ b/src/runtime/host_interface.zig
@@ -92,6 +92,19 @@ const JAVA_UTIL_COLLECTION: HostInterface = .{ .kind = .host_inert, .canonical =
// `.close`/`.read` on the wrapped InputStream resolve at EVAL, so the deftype loads.
const JAVA_IO_CLOSEABLE: HostInterface = .{ .kind = .host_inert, .canonical = "java.io.Closeable" };
+// The java.util.concurrent functional interfaces a concurrency library REIFIES
+// (rather than declares as a collection supertype): a thread pool takes a
+// `ThreadFactory` to mint its workers and a `Callable` as the unit of work.
+// method_family, not host_inert — the method IS dispatched: `(.newThread tf r)`
+// resolves through the reify's own method table, which is how the executor tier
+// consults a caller-supplied factory. Registering them here rather than as
+// native TypeDescriptors is forced by reify itself, which rejects a class value
+// ("__reify!: expected protocol, got type_descriptor") and accepts only a
+// protocol Var or a quote-wrapped marker name.
+const THREAD_FACTORY: HostInterface = .{ .kind = .method_family, .canonical = "java.util.concurrent.ThreadFactory", .wired_methods = &.{"newThread"} };
+const CALLABLE: HostInterface = .{ .kind = .method_family, .canonical = "java.util.concurrent.Callable", .wired_methods = &.{"call"} };
+const RUNNABLE: HostInterface = .{ .kind = .method_family, .canonical = "java.lang.Runnable", .wired_methods = &.{"run"} };
+
// protocol_remap interfaces (D-280b+): the macro rewrites each declared method to
// its cljw (protocol, method) target. ILookup's valAt → ILookup/-lookup (a 3-arity
// valAt collapses onto the same -lookup via D-279 multi-arity; the not-found arm is
@@ -462,6 +475,14 @@ const MARKERS = std.StaticStringMap(HostInterface).initComptime(.{
.{ "MapEquivalence", MAP_EQUIVALENCE },
.{ "IPersistentMap", IPERSISTENT_MAP },
.{ "java.io.Serializable", SERIALIZABLE },
+ // Both spellings: a library `:import`s the class then reifies it bare
+ // (hive-weave.pool), or names it qualified inline (hive-weave.guarded).
+ .{ "java.util.concurrent.ThreadFactory", THREAD_FACTORY },
+ .{ "ThreadFactory", THREAD_FACTORY },
+ .{ "java.util.concurrent.Callable", CALLABLE },
+ .{ "Callable", CALLABLE },
+ .{ "java.lang.Runnable", RUNNABLE },
+ .{ "Runnable", RUNNABLE },
// D-416 bare aliases (data.finger-tree declares these bare) — see the D-306
// collection-base note below.
.{ "ILookup", ILOOKUP },
diff --git a/test/e2e/concurrent_interfaces.sh b/test/e2e/concurrent_interfaces.sh
new file mode 100755
index 000000000..85506a289
--- /dev/null
+++ b/test/e2e/concurrent_interfaces.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# test/e2e/concurrent_interfaces.sh
+#
+# ADR-0102 / F-013 — the java.util.concurrent functional interfaces a
+# concurrency library REIFIES: ThreadFactory, Callable, Runnable.
+#
+# These are host-interface MARKER names (data/host_interfaces.yaml), not native
+# TypeDescriptors, because reify accepts only a protocol Var or a quote-wrapped
+# marker — a class value fails with "expected protocol, got type_descriptor".
+# Both the bare (post-:import) and fully-qualified spellings must work.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+# `-e` prints EVERY top-level form's value, and an `(ns …)` form evaluates to
+# nil — so a snippet that must set up an :import compares its last line only.
+run_last() { "$BIN" -e "$1" 2>/dev/null | tail -1; }
+
+# --- qualified spelling reifies and dispatches ---
+assert_eq 'threadfactory_qualified' \
+ "$(run '(let [tf (reify java.util.concurrent.ThreadFactory (newThread [_ r] [:made r]))]
+ (.newThread tf :work))')" \
+ '[:made :work]'
+assert_eq 'callable_qualified' \
+ "$(run '(.call (reify java.util.concurrent.Callable (call [_] 42)))')" '42'
+assert_eq 'runnable_qualified' \
+ "$(run '(.run (reify java.lang.Runnable (run [_] :ran)))')" ':ran'
+
+# --- bare spelling after :import reifies and dispatches ---
+assert_eq 'threadfactory_bare_after_import' \
+ "$(run_last '(ns t1 (:import [java.util.concurrent ThreadFactory]))
+ (.newThread (reify ThreadFactory (newThread [_ r] [:bare r])) :w)')" \
+ '[:bare :w]'
+assert_eq 'callable_bare_after_import' \
+ "$(run_last '(ns t2 (:import [java.util.concurrent Callable]))
+ (.call (reify Callable (call [_] :bare-call)))')" \
+ ':bare-call'
+assert_eq 'runnable_bare' \
+ "$(run '(.run (reify Runnable (run [_] :bare-run)))')" ':bare-run'
+
+# --- the real shape: a factory that mints a named daemon Thread which runs ---
+assert_eq 'threadfactory_mints_a_running_named_daemon_thread' \
+ "$(run_last '(ns t3 (:import [java.util.concurrent ThreadFactory]))
+ (let [n (java.util.concurrent.atomic.AtomicLong. 0)
+ ok (java.util.concurrent.atomic.AtomicBoolean. false)
+ tf (reify ThreadFactory
+ (newThread [_ r]
+ (doto (Thread. r)
+ (.setName (str "wk-" (.incrementAndGet n)))
+ (.setDaemon true))))
+ t (.newThread tf (fn [] (.set ok true)))]
+ (.start t)
+ (.join t)
+ [(.getName t) (.isDaemon t) (.get ok) (.get n)])')" \
+ '["wk-1" true true 1]'
+
+# --- a reify may combine one of these with a plain protocol section ---
+assert_eq 'threadfactory_alongside_a_protocol' \
+ "$(run_last '(ns t4 (:import [java.util.concurrent ThreadFactory]))
+ (defprotocol Named (nm [_]))
+ (let [o (reify
+ ThreadFactory (newThread [_ r] [:t r])
+ Named (nm [_] :named))]
+ [(.newThread o 1) (nm o)])')" \
+ '[[:t 1] :named]'
+
+echo "OK test/e2e/concurrent_interfaces.sh"
From 0a943770801330e5bc87f39f2b95a173d86572fd Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 16:04:24 -0300
Subject: [PATCH 046/466] feat(java): java.util.concurrent.LinkedBlockingQueue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A bounded FIFO over a cljw vector Value in host_instance state (the
ArrayDeque backing pattern, so one host_trace marks it and nothing needs a
finaliser) plus a head INDEX.
The head index is the design point. vector.subvec is an O(n) eager copy
(D-044 — cljw has no SubVector view), so dequeuing by rebuilding from index 1
would make draining a 256-slot work queue quadratic, on the hottest path a
thread pool has. Instead .poll hands back nth(v, head) and bumps the head; the
vector is compacted with one subvec only once the dead prefix is at least half
of it, which amortises to O(1) per element and bounds what the prefix retains.
Blocking .put/.take/timed .offer/.poll retry around the non-blocking op and
sleep through the same budgeted wait Thread/sleep and Semaphore.acquire own,
so future-cancel and the eval budget cut them short; the per-instance spin
lock is never held across a sleep.
.drainTo returns the drained elements instead of filling a mutable target
collection cljw does not have (AD-064).
test/e2e/linked_blocking_queue.sh — 16 assertions, including FIFO order
surviving the compaction threshold and 4 concurrent producers + 4 consumers
conserving all 400 elements with no loss or duplication.
---
.dev/accepted_divergences.yaml | 18 +
CHANGELOG.md | 13 +
src/runtime/java/_host_api.zig | 1 +
.../util/concurrent/LinkedBlockingQueue.zig | 418 ++++++++++++++++++
test/e2e/linked_blocking_queue.sh | 125 ++++++
5 files changed, 575 insertions(+)
create mode 100644 src/runtime/java/util/concurrent/LinkedBlockingQueue.zig
create mode 100755 test/e2e/linked_blocking_queue.sh
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index 80365c284..ae7618a90 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -1023,3 +1023,21 @@ accepted:
example: 'CLJW_EVAL_MAX_HEAP_MB unset: (.maxMemory (Runtime/getRuntime)) => 9223372036854775807 (no ceiling) / with CLJW_EVAL_MAX_HEAP_MB=64 => 67108864, and the pressure ratio becomes real.'
derives_from: "ADR-0028 (mark-sweep + adaptive threshold) + ADR-0125 / D-352 (per-eval heap ceiling) + ADR-0059 (no JVM)."
pin: "test/e2e/concurrent_atomics.sh — runtime_memory_is_coherent + runtime_maxmemory_unmetered_is_exact_long."
+ - id: "AD-064"
+ area: "concurrency"
+ summary: |-
+ `(.drainTo q coll)` RETURNS the drained elements as a cljw vector and
+ empties the queue; it does not add them to `coll`, and the second
+ argument is accepted-and-ignored. On the JVM `drainTo` mutates the target
+ collection in place and returns the transferred count.
+ cljw's collections are persistent — there is no mutable target to drain
+ INTO, so the JVM signature has no faithful reading here. Returning the
+ elements is the only shape that loses no information: a caller writes
+ `(let [xs (.drainTo q [])] …)` instead of reading `coll` afterwards. The
+ arity is kept so the JVM call shape still compiles.
+ `.remove` follows the JVM instead of diverging: the 1-arity raises on an
+ empty queue (`.poll` is the nil-returning form) and the 2-arity drops the
+ first equal element and reports whether one was found.
+ example: '(let [q (java.util.concurrent.LinkedBlockingQueue. 8)] (.offer q 1) (.drainTo q [])) => cljw [1] / clj 1, with the element pushed into the (mutable) target collection.'
+ derives_from: "ADR-0059 (no JVM) + cljw persistent collections (no mutable drain target)."
+ pin: "test/e2e/linked_blocking_queue.sh — drainTo_returns_and_empties."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53b36e3e8..cc42e979e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,19 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
constant, so a heap-pressure monitor either reads real numbers or correctly
reads "no ceiling" (AD-063).
+- **`java.util.concurrent.LinkedBlockingQueue`.** A bounded FIFO backed by a
+ cljw vector plus a head index: `` (`[capacity]`, unbounded without),
+ `.offer` (`[x]`, `[x timeout unit]`), `.put`, `.poll` (`[]`,
+ `[timeout unit]`), `.take`, `.peek`, `.size`, `.isEmpty`,
+ `.remainingCapacity`, `.remove` (`[]`, `[x]`), `.contains`, `.clear`,
+ `.toArray`, `.drainTo`. The head index is what keeps it a queue —
+ `vector.subvec` is an O(n) eager copy (D-044), so dequeuing by rebuilding
+ from index 1 would make draining a work queue quadratic; the dead prefix is
+ compacted only once it dominates the vector, which amortises to O(1). Blocking
+ waits poll through the budgeted sleep `Thread/sleep` owns, and the per-instance
+ spin lock is never held across one. `.drainTo` returns the drained elements
+ rather than filling a mutable target cljw does not have (AD-064).
+
- **`ThreadFactory`, `Callable` and `Runnable` are reify-able.**
`(reify java.util.concurrent.ThreadFactory (newThread [_ r] …))` — and the
bare spelling after `:import` — now loads and dispatches, so a library can
diff --git a/src/runtime/java/_host_api.zig b/src/runtime/java/_host_api.zig
index 533551866..b89653eb8 100644
--- a/src/runtime/java/_host_api.zig
+++ b/src/runtime/java/_host_api.zig
@@ -128,6 +128,7 @@ const java_surfaces = [_]type{
@import("util/concurrent/TimeUnit.zig"),
@import("util/concurrent/Semaphore.zig"),
@import("util/concurrent/TimeoutException.zig"),
+ @import("util/concurrent/LinkedBlockingQueue.zig"),
@import("util/concurrent/atomic/AtomicLong.zig"),
@import("util/concurrent/atomic/AtomicInteger.zig"),
@import("util/concurrent/atomic/AtomicBoolean.zig"),
diff --git a/src/runtime/java/util/concurrent/LinkedBlockingQueue.zig b/src/runtime/java/util/concurrent/LinkedBlockingQueue.zig
new file mode 100644
index 000000000..8b5688d47
--- /dev/null
+++ b/src/runtime/java/util/concurrent/LinkedBlockingQueue.zig
@@ -0,0 +1,418 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.LinkedBlockingQueue` — a bounded
+//! FIFO queue over a `.host_instance` (ADR-0106), backed by a cljw VECTOR
+//! Value the way `java.util.ArrayDeque` is, so the descriptor's `host_trace`
+//! marks the one Value each GC and no `host_finalise` is needed.
+//!
+//! Backend: impl-only
+//! Impl deps: host_instance, vector, equal, eval_budget, future, clock
+//! Clojure peer: none
+//!
+//! state[0] = the backing vector, state[1] = capacity (0 = unbounded),
+//! state[2] = head index, state[3] = the spin lock guarding a read-modify-
+//! write of the other three.
+//!
+//! The head INDEX is what makes the queue a queue. `vector.subvec` is an O(n)
+//! eager copy (D-044 — cljw has no SubVector view), so dequeuing by rebuilding
+//! from index 1 would make draining a 256-slot work queue quadratic, on the
+//! hottest path a thread pool has. Instead `.poll` hands back `nth(v, head)`
+//! and bumps the head; the vector is COMPACTED (one subvec) only once the dead
+//! prefix is at least half of it, which amortises to O(1) per element and
+//! bounds the garbage the prefix retains.
+//!
+//! Surface: `` ([capacity]), `.offer` ([x] | [x timeout unit]), `.put`,
+//! `.poll` ([] | [timeout unit]), `.take`, `.peek`, `.size`, `.isEmpty`,
+//! `.remainingCapacity`, `.remove` ([] | [x]), `.contains`, `.clear`,
+//! `.toArray`, `.drainTo`.
+//!
+//! A blocking wait polls in slices through `eval_budget.budgetedSleep` — the
+//! same wait `Thread/sleep` and `Semaphore.acquire` own — so future-cancel and
+//! the eval budget cut it short. The spin lock is NEVER held across a sleep:
+//! every blocking op is a retry loop around the non-blocking one.
+
+const std = @import("std");
+const host_api = @import("../../_host_api.zig");
+const type_descriptor = @import("../../../type_descriptor.zig");
+const Value = @import("../../../value/value.zig").Value;
+const Runtime = @import("../../../runtime.zig").Runtime;
+const Env = @import("../../../env.zig").Env;
+const SourceLocation = @import("../../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../../error/catalog.zig");
+const host_instance = @import("../../../host_instance.zig");
+const vector_mod = @import("../../../collection/vector.zig");
+const equal = @import("../../../equal.zig");
+const eval_budget = @import("../../../concurrency/eval_budget.zig");
+const future = @import("../../../future.zig");
+const clock = @import("../../../clock.zig");
+const TimeUnit = @import("TimeUnit.zig");
+const mark_sweep = @import("../../../gc/mark_sweep.zig");
+const gc_heap_mod = @import("../../../gc/gc_heap.zig");
+
+var lbq_descriptor: ?*const type_descriptor.TypeDescriptor = null;
+
+/// Longest single wait slice; a shorter remaining deadline shortens the last.
+const SLICE_NS: u64 = std.time.ns_per_ms;
+
+/// Dead-prefix threshold: compact once the head has passed this many slots AND
+/// the prefix is at least half the backing vector.
+const COMPACT_MIN: u32 = 32;
+
+fn vecOf(recv: Value) Value {
+ return @enumFromInt(host_instance.asHostInstance(recv).state[0]);
+}
+
+fn setVec(recv: Value, v: Value) void {
+ host_instance.setState(recv, 0, @intFromEnum(v));
+}
+
+fn capOf(recv: Value) u32 {
+ return @truncate(host_instance.asHostInstance(recv).state[1]);
+}
+
+fn headOf(recv: Value) u32 {
+ return @truncate(host_instance.asHostInstance(recv).state[2]);
+}
+
+fn setHead(recv: Value, h: u32) void {
+ host_instance.setState(recv, 2, @as(u64, h));
+}
+
+fn lockPtr(recv: Value) *u64 {
+ return &@constCast(host_instance.asHostInstance(recv)).state[3];
+}
+
+/// Take the per-instance spin lock. Critical sections are a handful of vector
+/// ops, so spinning beats parking; nothing sleeps while holding it.
+fn lock(recv: Value) void {
+ const p = lockPtr(recv);
+ while (@cmpxchgWeak(u64, p, 0, 1, .acquire, .monotonic) != null)
+ std.atomic.spinLoopHint();
+}
+
+fn unlock(recv: Value) void {
+ @atomicStore(u64, lockPtr(recv), 0, .release);
+}
+
+/// Live element count. Caller holds the lock.
+fn sizeLocked(recv: Value) u32 {
+ return vector_mod.count(vecOf(recv)) - headOf(recv);
+}
+
+/// Enqueue without blocking. False when a bounded queue is full.
+fn tryOffer(rt: *Runtime, recv: Value, x: Value) !bool {
+ lock(recv);
+ defer unlock(recv);
+ const cap = capOf(recv);
+ if (cap != 0 and sizeLocked(recv) >= cap) return false;
+ setVec(recv, try vector_mod.conj(rt, vecOf(recv), x));
+ return true;
+}
+
+/// Dequeue without blocking. Null when empty. Compacts the dead prefix once it
+/// dominates the backing vector, so the retained garbage stays bounded.
+fn tryPoll(rt: *Runtime, recv: Value) !?Value {
+ lock(recv);
+ defer unlock(recv);
+ const v = vecOf(recv);
+ const n = vector_mod.count(v);
+ const head = headOf(recv);
+ if (head >= n) return null;
+ const x = vector_mod.nth(v, head);
+ const next = head + 1;
+ if (next >= n) {
+ // Drained: drop the whole backing vector so nothing stays reachable.
+ setVec(recv, vector_mod.empty());
+ setHead(recv, 0);
+ } else if (next >= COMPACT_MIN and next * 2 >= n) {
+ setVec(recv, try vector_mod.subvec(rt, v, next, n));
+ setHead(recv, 0);
+ } else {
+ setHead(recv, next);
+ }
+ return x;
+}
+
+/// Sleep one slice of a bounded (or unbounded) wait. Returns false when the
+/// deadline has passed; raises when the worker is cancelled.
+fn waitSlice(rt: *Runtime, deadline_ns: ?i64) !bool {
+ if (deadline_ns) |deadline| {
+ const remaining = deadline - clock.nanoTime(rt.io);
+ if (remaining <= 0) return false;
+ const slice = @min(@as(u64, @intCast(remaining)), SLICE_NS);
+ try eval_budget.budgetedSleep(rt.io, slice, future.currentCancelLatch());
+ } else {
+ try eval_budget.budgetedSleep(rt.io, SLICE_NS, future.currentCancelLatch());
+ }
+ if (future.cancelRequested()) return error_catalog.raise(.future_cancel_abort, .{}, .{});
+ return true;
+}
+
+/// The `timeout`/`unit` pair two args in, as a deadline. Null unit → error.
+fn deadlineFrom(rt: *Runtime, args: []const Value, timeout_idx: usize, name: []const u8, loc: SourceLocation) !?i64 {
+ const timeout = try error_catalog.expectInteger(args[timeout_idx], name, loc);
+ const unit_val = args[timeout_idx + 1];
+ const ns = TimeUnit.nanosOf(unit_val, @intCast(timeout)) orelse
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = name, .expected = "a java.util.concurrent.TimeUnit", .actual = @tagName(unit_val.tag()) });
+ if (ns <= 0) return null; // already expired — one non-blocking attempt only
+ return clock.nanoTime(rt.io) +| ns;
+}
+
+/// `(java.util.concurrent.LinkedBlockingQueue.)` unbounded /
+/// `(… capacity)` bounded.
+fn initQueue(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ if (args.len > 1)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = "java.util.concurrent.LinkedBlockingQueue.", .expected = 1 });
+ var cap: u64 = 0;
+ if (args.len == 1) {
+ const n = try error_catalog.expectInteger(args[0], "java.util.concurrent.LinkedBlockingQueue.", loc);
+ if (n <= 0)
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "java.util.concurrent.LinkedBlockingQueue.", .expected = "a positive capacity", .actual = "a non-positive capacity" });
+ cap = @intCast(n);
+ }
+ const td = lbq_descriptor orelse return error.NoVTable;
+ return host_instance.alloc(rt, td, .{ @intFromEnum(vector_mod.empty()), cap, 0, 0 });
+}
+
+/// `(.offer q x)` immediate / `(.offer q x timeout unit)` timed.
+fn offer(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArityRange(".offer", args, 2, 4, loc);
+ if (args.len == 2) return Value.initBoolean(try tryOffer(rt, args[0], args[1]));
+ if (args.len != 4)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = ".offer", .expected = 4 });
+ const deadline = try deadlineFrom(rt, args, 2, ".offer", loc);
+ while (true) {
+ if (try tryOffer(rt, args[0], args[1])) return Value.initBoolean(true);
+ if (deadline == null) return Value.initBoolean(false);
+ if (!try waitSlice(rt, deadline)) return Value.initBoolean(false);
+ }
+}
+
+/// `(.put q x)` — block until there is room. Returns nil (JVM put is void).
+fn put(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".put", args, 2, loc);
+ while (!try tryOffer(rt, args[0], args[1]))
+ _ = try waitSlice(rt, null);
+ return Value.nil_val;
+}
+
+/// `(.poll q)` immediate / `(.poll q timeout unit)` timed. nil when empty.
+fn poll(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArityRange(".poll", args, 1, 3, loc);
+ if (args.len == 1) return (try tryPoll(rt, args[0])) orelse Value.nil_val;
+ if (args.len != 3)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = ".poll", .expected = 3 });
+ const deadline = try deadlineFrom(rt, args, 1, ".poll", loc);
+ while (true) {
+ if (try tryPoll(rt, args[0])) |x| return x;
+ if (deadline == null) return Value.nil_val;
+ if (!try waitSlice(rt, deadline)) return Value.nil_val;
+ }
+}
+
+/// `(.take q)` — block until an element is available.
+fn take(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".take", args, 1, loc);
+ while (true) {
+ if (try tryPoll(rt, args[0])) |x| return x;
+ _ = try waitSlice(rt, null);
+ }
+}
+
+/// `(.peek q)` — the head without removing it, or nil.
+fn peek(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".peek", args, 1, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ const v = vecOf(args[0]);
+ const head = headOf(args[0]);
+ if (head >= vector_mod.count(v)) return Value.nil_val;
+ return vector_mod.nth(v, head);
+}
+
+fn size(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".size", args, 1, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ return Value.initInteger(@intCast(sizeLocked(args[0])));
+}
+
+fn isEmpty(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".isEmpty", args, 1, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ return Value.initBoolean(sizeLocked(args[0]) == 0);
+}
+
+/// `(.remainingCapacity q)` — an unbounded queue reports `Integer/MAX_VALUE`,
+/// as the JVM's does.
+fn remainingCapacity(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".remainingCapacity", args, 1, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ const cap = capOf(args[0]);
+ if (cap == 0) return Value.initInteger(std.math.maxInt(i32));
+ return Value.initInteger(@intCast(cap - @min(cap, sizeLocked(args[0]))));
+}
+
+/// The live elements as a cljw vector. Caller holds the lock.
+fn snapshotLocked(rt: *Runtime, recv: Value) !Value {
+ const v = vecOf(recv);
+ const n = vector_mod.count(v);
+ const head = headOf(recv);
+ if (head >= n) return vector_mod.empty();
+ return vector_mod.subvec(rt, v, head, n);
+}
+
+/// `(.toArray q)` — a snapshot vector of the live elements, head first.
+fn toArray(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity(".toArray", args, 1, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ return snapshotLocked(rt, args[0]);
+}
+
+/// `(.remove q)` — remove and return the head, raising when empty (JVM-faithful;
+/// `.poll` is the nil-returning form). `(.remove q x)` — drop the first element
+/// equal to `x`, returning whether one was found.
+fn remove(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ try error_catalog.checkArityRange(".remove", args, 1, 2, loc);
+ if (args.len == 1) {
+ return (try tryPoll(rt, args[0])) orelse
+ error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = ".remove", .expected = "a non-empty queue", .actual = "an empty queue" });
+ }
+ // Rebuild without the first match. O(n) — the JVM's is too, and this is a
+ // coalescing path, not the hot enqueue/dequeue one.
+ lock(args[0]);
+ defer unlock(args[0]);
+ const live = try snapshotLocked(rt, args[0]);
+ const n = vector_mod.count(live);
+ var out = vector_mod.empty();
+ var found = false;
+ var i: u32 = 0;
+ while (i < n) : (i += 1) {
+ const e = vector_mod.nth(live, i);
+ if (!found and try equal.valueEqual(rt, env, e, args[1])) {
+ found = true;
+ continue;
+ }
+ out = try vector_mod.conj(rt, out, e);
+ }
+ if (found) {
+ setVec(args[0], out);
+ setHead(args[0], 0);
+ }
+ return Value.initBoolean(found);
+}
+
+fn contains(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ try error_catalog.checkArity(".contains", args, 2, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ const live = try snapshotLocked(rt, args[0]);
+ const n = vector_mod.count(live);
+ var i: u32 = 0;
+ while (i < n) : (i += 1) {
+ if (try equal.valueEqual(rt, env, vector_mod.nth(live, i), args[1]))
+ return Value.initBoolean(true);
+ }
+ return Value.initBoolean(false);
+}
+
+fn clear(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".clear", args, 1, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ setVec(args[0], vector_mod.empty());
+ setHead(args[0], 0);
+ return Value.nil_val;
+}
+
+/// `(.drainTo q coll)` — cljw has no mutable target collection to drain INTO,
+/// so this returns the drained elements as a vector and empties the queue. The
+/// second argument is accepted and ignored for call-shape compatibility
+/// (divergence AD-064).
+fn drainTo(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArityRange(".drainTo", args, 1, 2, loc);
+ lock(args[0]);
+ defer unlock(args[0]);
+ const live = try snapshotLocked(rt, args[0]);
+ setVec(args[0], vector_mod.empty());
+ setHead(args[0], 0);
+ return live;
+}
+
+/// Mark the one backing Value each GC (the ArrayDeque pattern — no owned heap
+/// memory, so no `host_finalise`).
+fn traceState(gc_ptr: *anyopaque, state: *[host_instance.STATE_WORDS]u64) void {
+ const gc: *gc_heap_mod.GcHeap = @ptrCast(@alignCast(gc_ptr));
+ const v: Value = @enumFromInt(state[0]);
+ if (v.heapHeader()) |hdr| mark_sweep.mark(gc, hdr);
+}
+
+const MethodSpec = struct { name: []const u8, f: *const fn (*Runtime, *Env, []const Value, SourceLocation) anyerror!Value };
+
+const METHODS = [_]MethodSpec{
+ .{ .name = "", .f = &initQueue },
+ .{ .name = "offer", .f = &offer },
+ .{ .name = "put", .f = &put },
+ .{ .name = "poll", .f = &poll },
+ .{ .name = "take", .f = &take },
+ .{ .name = "peek", .f = &peek },
+ .{ .name = "element", .f = &peek },
+ .{ .name = "size", .f = &size },
+ .{ .name = "isEmpty", .f = &isEmpty },
+ .{ .name = "remainingCapacity", .f = &remainingCapacity },
+ .{ .name = "remove", .f = &remove },
+ .{ .name = "contains", .f = &contains },
+ .{ .name = "clear", .f = &clear },
+ .{ .name = "toArray", .f = &toArray },
+ .{ .name = "drainTo", .f = &drainTo },
+};
+
+fn initDescriptor(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
+ if (td.method_table.len != 0) return; // idempotent
+ lbq_descriptor = td;
+ td.host_trace = &traceState;
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, METHODS.len);
+ for (METHODS, 0..) |m, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, m.name),
+ .method_val = Value.initBuiltinFn(m.f),
+ };
+ }
+ td.method_table = entries;
+}
+
+pub const ___HOST_EXTENSION: host_api.Extension = .{
+ .cljw_ns = "cljw.java.util.concurrent.LinkedBlockingQueue",
+ .descriptor = &descriptor,
+ .init = &initDescriptor,
+};
+
+var descriptor: type_descriptor.TypeDescriptor = .{
+ .fqcn = "java.util.concurrent.LinkedBlockingQueue",
+ .kind = .native,
+ .field_layout = null,
+ .protocol_impls = &.{},
+ .method_table = &.{},
+ .parent = null,
+ .meta = .nil_val,
+};
diff --git a/test/e2e/linked_blocking_queue.sh b/test/e2e/linked_blocking_queue.sh
new file mode 100755
index 000000000..f5d30beb9
--- /dev/null
+++ b/test/e2e/linked_blocking_queue.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+# test/e2e/linked_blocking_queue.sh
+#
+# ADR-0029 / ADR-0106 — java.util.concurrent.LinkedBlockingQueue as a bounded
+# FIFO over a cljw vector Value plus a head index. The head index is the point:
+# vector.subvec is an O(n) eager copy (D-044), so dequeuing by rebuilding would
+# make draining quadratic on a thread pool's hottest path. AD-064 pins drainTo.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+
+Q='java.util.concurrent.LinkedBlockingQueue'
+MS='java.util.concurrent.TimeUnit/MILLISECONDS'
+
+# --- FIFO order, not LIFO ---
+assert_eq 'fifo_order' \
+ "$(run "(let [q ($Q. 8)] (doseq [x [:a :b :c]] (.offer q x)) [(.poll q) (.poll q) (.poll q) (.poll q)])")" \
+ '[:a :b :c nil]'
+
+# --- bounded: offer refuses past capacity, and space returns after a poll ---
+assert_eq 'bounded_offer_refuses_when_full' \
+ "$(run "(let [q ($Q. 2)] [(.offer q 1) (.offer q 2) (.offer q 3) (.size q)])")" \
+ '[true true false 2]'
+assert_eq 'space_returns_after_poll' \
+ "$(run "(let [q ($Q. 2)] (.offer q 1) (.offer q 2) (.poll q) [(.offer q 3) (.size q) (.poll q) (.poll q)])")" \
+ '[true 2 2 3]'
+
+# --- size / remainingCapacity / isEmpty / peek ---
+assert_eq 'size_and_remaining' \
+ "$(run "(let [q ($Q. 5)] (.offer q :x) [(.size q) (.remainingCapacity q) (.isEmpty q) (.peek q) (.size q)])")" \
+ '[1 4 false :x 1]'
+assert_eq 'unbounded_remaining_is_int_max' \
+ "$(run "(.remainingCapacity ($Q.))")" '2147483647'
+assert_eq 'empty_queue' \
+ "$(run "(let [q ($Q. 4)] [(.isEmpty q) (.peek q) (.poll q) (.size q)])")" \
+ '[true nil nil 0]'
+
+# --- toArray snapshots the live elements, head first; clear empties ---
+assert_eq 'toArray_snapshot_and_clear' \
+ "$(run "(let [q ($Q. 8)] (doseq [x [1 2 3]] (.offer q x)) (.poll q)
+ (let [snap (vec (.toArray q))] (.clear q) [snap (.size q)]))")" \
+ '[[2 3] 0]'
+
+# --- remove drops the first equal element; contains sees the live window ---
+assert_eq 'remove_by_value' \
+ "$(run "(let [q ($Q. 8)] (doseq [x [:a :b :a]] (.offer q x))
+ [(.remove q :a) (vec (.toArray q)) (.remove q :zz) (.contains q :b)])")" \
+ '[true [:b :a] false true]'
+
+# --- AD-064: drainTo returns the drained elements and empties the queue ---
+assert_eq 'drainTo_returns_and_empties' \
+ "$(run "(let [q ($Q. 8)] (doseq [x [1 2 3]] (.offer q x)) [(vec (.drainTo q [])) (.size q)])")" \
+ '[[1 2 3] 0]'
+
+# --- the head index survives past the compaction threshold (>32 dequeues) ---
+assert_eq 'fifo_survives_compaction' \
+ "$(run "(let [q ($Q. 300)]
+ (dotimes [i 200] (.offer q i))
+ (let [drained (vec (repeatedly 200 #(.poll q)))]
+ [(= drained (vec (range 200))) (.size q) (.poll q)]))")" \
+ '[true 0 nil]'
+
+# --- timed offer on a full queue: succeeds once a consumer makes room ---
+assert_eq 'timed_offer_succeeds_after_a_poll' \
+ "$(run "(let [q ($Q. 1)
+ _ (.offer q :first)
+ f (future (Thread/sleep 80) (.poll q))]
+ (let [ok (.offer q :second 2000 $MS)] @f [ok (.poll q)]))")" \
+ '[true :second]'
+
+# --- timed offer on a queue nobody drains returns false at the deadline ---
+assert_eq 'timed_offer_times_out' \
+ "$(run "(let [q ($Q. 1)
+ _ (.offer q :first)
+ t0 (System/currentTimeMillis)
+ ok (.offer q :second 60 $MS)
+ dt (- (System/currentTimeMillis) t0)]
+ [ok (>= dt 55)])")" \
+ '[false true]'
+
+# --- take blocks until a producer offers ---
+assert_eq 'take_blocks_then_returns' \
+ "$(run "(let [q ($Q. 4)
+ f (future (Thread/sleep 60) (.offer q :late))]
+ (let [x (.take q)] @f [x (.size q)]))")" \
+ '[:late 0]'
+
+# --- timed poll on an empty queue returns nil at the deadline ---
+assert_eq 'timed_poll_times_out' \
+ "$(run "(let [q ($Q. 4) t0 (System/currentTimeMillis)
+ x (.poll q 60 $MS)
+ dt (- (System/currentTimeMillis) t0)]
+ [x (>= dt 55)])")" \
+ '[nil true]'
+
+# --- concurrent producers + consumers lose nothing and duplicate nothing ---
+assert_eq 'concurrent_producers_and_consumers_conserve_every_element' \
+ "$(run "(let [q ($Q. 64)
+ ps (doall (for [p (range 4)]
+ (future (dotimes [i 100] (.put q (+ (* p 100) i))))))
+ cs (doall (for [_ (range 4)]
+ (future (loop [acc []]
+ (if (= 100 (count acc))
+ acc
+ (recur (conj acc (.take q))))))))]
+ (doseq [p ps] @p)
+ (let [got (sort (mapcat deref cs))]
+ [(= got (range 400)) (.size q)]))")" \
+ '[true 0]'
+
+# --- class identity ---
+assert_eq 'class_name' "$(run "(= \"$Q\" (str (class ($Q. 1))))")" 'true'
+
+echo "OK test/e2e/linked_blocking_queue.sh"
From ada0dc499bf910e48d5754bf84269dcb8723185b Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 16:31:17 -0300
Subject: [PATCH 047/466] perf(vector): pop through the trie barrier instead of
rebuilding (O-056)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
(pop v) on a vector was (into [] (take (dec (count coll)) coll)) — an O(n)
rebuild of a whole fresh trie — while runtime/collection/vector.zig::pop
already implemented the JVM PersistentVector.pop() algorithm correctly (tail
fast path; else pull the last leaf out of the root and collapse a level). That
correct implementation was reachable only from java.util.ArrayDeque.
This is an abstraction-barrier violation, not a missing algorithm: the layer
above re-implemented a barrier operation out of high-level seq ops instead of
calling through it. The fix adds no lower-layer code — it exposes the existing
barrier op as the __vector-pop primitive and has pop call it, which is the
minimal-interface move.
Measured, 2000 pops, ReleaseSafe:
n=1000 539.5 -> 1.14 ms
n=4000 2151.5 -> 1.30 ms
n=16000 8278.3 -> 2.47 ms (~3352x)
Reference JVM clj 1.12.4, same box and probe: 1.93 ms. cljw was ~4288x slower
than clj on this op and is now within ~1.3x. pop also shares structure with its
parent again instead of allocating a fresh trie.
Found by a cljw-vs-clj collection scaling bench, now kept in .dev/bench/ with
the ranked remainder (chunked vector-seq view; SubVector view — D-044's
"land when the benefit is measurable" benefit is now measured at 7480x; cached
PersistentList count).
Verified: scripts/check_corpus_regression.sh 4347/4347 golden clj outputs
reproduced; e2e phase14_peek_pop, phase14_deftype_collection_base,
phase14_deftype_object, phase14_catch_internal; meta preserved, list arm still
rest, queue arm still __queue-pop, empty still raises IllegalStateException.
---
.dev/bench/README.md | 79 +++++++++++++++++++++++++++++++
.dev/bench/scaling.clj | 71 +++++++++++++++++++++++++++
.dev/bench/scaling_form.edn | 24 ++++++++++
.dev/bench/traversal.clj | 64 +++++++++++++++++++++++++
.dev/optimizations.md | 1 +
src/lang/clj/clojure/core.clj | 2 +-
src/lang/primitive/collection.zig | 22 +++++++++
7 files changed, 262 insertions(+), 1 deletion(-)
create mode 100644 .dev/bench/README.md
create mode 100644 .dev/bench/scaling.clj
create mode 100644 .dev/bench/scaling_form.edn
create mode 100644 .dev/bench/traversal.clj
diff --git a/.dev/bench/README.md b/.dev/bench/README.md
new file mode 100644
index 000000000..0607b7562
--- /dev/null
+++ b/.dev/bench/README.md
@@ -0,0 +1,79 @@
+# Collection-algorithm parity bench (cljw vs clj)
+
+Motivating question (user, 2026-08-13): *for performance, should cljw reimplement
+the same immutable algorithms core Clojure does?*
+
+Answered by measurement rather than by reading either implementation, per the
+"measure the provider before you abstract it" principle: the same probe form is
+evaluated in a cljw nREPL and a JVM Clojure 1.12.4 nREPL on the same box.
+
+## Method
+
+`scaling_form.edn` — run a FIXED number of operations (2000, after 300 warmup
+iterations so the JVM has JIT-compiled) against collections of growing size
+`[1000 4000 16000]`. Flat wall-clock across sizes means the operation is
+O(1)/O(log n) per call; time growing with the size factor means O(n).
+
+Run it in both REPLs:
+
+ code cider spawn repl_type=cljw name=perf-cljw project_dir=
+ code cider spawn repl_type=clj name=perf-clj project_dir=
+ code cider eval session_name=... code=
+
+`scaling.clj` / `traversal.clj` are the standalone script forms (`cljw
+.dev/bench/scaling.clj`) — same probes, human-readable output, plus a
+whole-collection traversal probe that checks for accidental O(n²).
+
+## Result, 2026-08-13 (ms per 2000 ops at n=16000, ReleaseSafe vs clj 1.12.4)
+
+| op | clj | cljw | verdict |
+|---|---|---|---|
+| `nth` (mid) | 0.31 | 0.26 | cljw faster |
+| `assoc` (mid) | 5.17 | 1.20 | cljw faster |
+| `conj` | 2.79 | 2.29 | comparable |
+| `map` `get` | 0.58 | 0.42 | cljw faster |
+| `map` `assoc` | 3.38 | 2.26 | cljw faster |
+| `map` `dissoc` | 5.09 | 1.15 | cljw faster |
+| `list` `conj` | 0.36 | 0.43 | comparable |
+| **`pop`** | **1.93** | **8278** | **4288× slower → FIXED (O-056), now 2.47** |
+| **`subvec`** | **1.06** | **7929** | **7480× slower** |
+| **`seq`** | **0.60** | **2123** | **3549× slower** |
+| **`rest`** | **0.87** | **2253** | **2576× slower** |
+| **`next`** | **0.99** | **2164** | **2184× slower** |
+| **`list` `count`** | **0.18** | **481** | **2711× slower** |
+
+The headline is NOT "cljw is slow". The HAMT work is sound — cljw beats the JVM
+on `nth`/`assoc`/`conj`/`get`/`dissoc`. Every loss is one of two specific
+mistakes, and neither is a data-structure problem:
+
+1. **An abstraction-barrier violation** — the Clojure layer re-implements an
+ operation the Zig barrier already provides correctly. `pop` was the pure
+ case: `vector.zig::pop` is a faithful `PersistentVector.pop()`, and
+ `core.clj` bypassed it with `(into [] (take (dec (count coll)) coll))`.
+ Fixed as O-056; the fix added no algorithm, it just exposed the barrier op.
+
+2. **Materialising where Clojure keeps a VIEW** — `seq`/`rest`/`next` on a
+ vector build an eager `PersistentList` (`sequence.zig` `vectorToList` /
+ `vectorTailAsList`) where Clojure returns a `ChunkedSeq` holding
+ `(vector, index)`; `subvec` eagerly copies (D-044) where Clojure returns an
+ `APersistentVector$SubVector` sharing the parent's root and tail.
+
+## Ranked remaining work
+
+| # | Fix | Kind | Unblocks |
+|---|---|---|---|
+| 1 | Chunked vector-seq VIEW (vector + index) behind the existing seq protocol | view, not algorithm | `seq` + `rest` + `next` in one change — the systemic one, since these are what every hand-written seq walk uses |
+| 2 | `SubVector` view (D-044 — "land when the benefit is measurable"; it is now measured: 7480×) | view | `subvec`, and every `drop`/`take`-style slice built on it |
+| 3 | Cached count on `PersistentList` | one field | `count` on a list |
+
+(1) and (2) are the same shape and should follow OCP: add a new seq/vector
+REPRESENTATION that satisfies the existing protocol, so no consumer changes —
+not an `if` branch at each call site. That is also what keeps D-044's "new Tag
+slot + polymorphic dispatch" cost honest.
+
+Caveat on (1): `reduce` / `doseq` / `into` over a vector are already fast — the
+index-walk and chunk-drain fast paths (O-002, O-004, O-032) bypass `seq`
+entirely, and `traversal.clj` confirms they stay linear. So the eager `seq`
+hurts hand-written `loop`/`recur` walks and library code, not the idiomatic
+core paths. That is why it is ranked by breadth rather than by any single
+benchmark regression.
diff --git a/.dev/bench/scaling.clj b/.dev/bench/scaling.clj
new file mode 100644
index 000000000..8eaad8583
--- /dev/null
+++ b/.dev/bench/scaling.clj
@@ -0,0 +1,71 @@
+;; Complexity-class probe: run the SAME number of operations against
+;; collections of growing size. Flat wall-clock across sizes => the operation is
+;; O(1)/O(log n) per call; time growing with the size factor => it is O(n).
+;;
+;; Reported as the ratio t(4n)/t(n). ~1 means constant, ~4 means linear.
+
+(def SIZES [1000 4000 16000])
+(def OPS 2000)
+
+(defn- ms [f]
+ (let [t0 (System/nanoTime)]
+ (f)
+ (/ (- (System/nanoTime) t0) 1e6)))
+
+(defn- probe
+ "build: size -> collection. op: (fn [coll size]) doing ONE unit of work."
+ [label build op]
+ (let [times (vec (for [s SIZES]
+ (let [c (build s)]
+ ;; warm
+ (dotimes [_ 50] (op c s))
+ (ms #(dotimes [_ OPS] (op c s))))))
+ ratios (vec (map (fn [a b] (if (zero? a) 0.0 (/ b a)))
+ (butlast times) (rest times)))
+ worst (if (seq ratios) (apply max ratios) 0.0)
+ verdict (cond (< worst 1.8) "O(1)/O(log n)"
+ (< worst 2.8) "sub-linear?"
+ :else "O(n) <-- LINEAR")]
+ (println (format "%-26s t=%-28s ratio=%-16s %s"
+ label
+ (vec (map #(format "%.1f" %) times))
+ (vec (map #(format "%.2f" %) ratios))
+ verdict))))
+
+(def V (fn [s] (vec (range s))))
+(def M (fn [s] (into {} (map (fn [i] [i i]) (range s)))))
+(def S (fn [s] (into #{} (range s))))
+(def L (fn [s] (apply list (range s))))
+
+(println "=== cljw collection scaling ===")
+(println (format "%d ops per size, sizes %s\n" OPS SIZES))
+
+(println "-- vector --")
+(probe "conj" V (fn [c _] (conj c 1)))
+(probe "nth (mid)" V (fn [c s] (nth c (quot s 2))))
+(probe "assoc (mid)" V (fn [c s] (assoc c (quot s 2) 0)))
+(probe "pop" V (fn [c _] (pop c)))
+(probe "count" V (fn [c _] (count c)))
+(probe "peek" V (fn [c _] (peek c)))
+(probe "subvec 1..n" V (fn [c s] (subvec c 1 s)))
+(probe "rest" V (fn [c _] (rest c)))
+(probe "first" V (fn [c _] (first c)))
+
+(println "\n-- map --")
+(probe "assoc (new key)" M (fn [c s] (assoc c (+ s 1) 0)))
+(probe "get (hit)" M (fn [c s] (get c (quot s 2))))
+(probe "dissoc" M (fn [c s] (dissoc c (quot s 2))))
+(probe "count" M (fn [c _] (count c)))
+(probe "contains?" M (fn [c s] (contains? c (quot s 2))))
+
+(println "\n-- set --")
+(probe "conj" S (fn [c s] (conj c (+ s 1))))
+(probe "contains?" S (fn [c s] (contains? c (quot s 2))))
+(probe "disj" S (fn [c s] (disj c (quot s 2))))
+(probe "count" S (fn [c _] (count c)))
+
+(println "\n-- list / seq --")
+(probe "list conj" L (fn [c _] (conj c 1)))
+(probe "list first" L (fn [c _] (first c)))
+(probe "list rest" L (fn [c _] (rest c)))
+(probe "list count" L (fn [c _] (count c)))
diff --git a/.dev/bench/scaling_form.edn b/.dev/bench/scaling_form.edn
new file mode 100644
index 000000000..6ed6ce2d6
--- /dev/null
+++ b/.dev/bench/scaling_form.edn
@@ -0,0 +1,24 @@
+(let [SIZES [1000 4000 16000]
+ OPS 2000
+ ms (fn [f] (let [t0 (System/nanoTime)] (f) (/ (- (System/nanoTime) t0) 1e6)))
+ probe (fn [build op]
+ (vec (for [s SIZES]
+ (let [c (build s)]
+ (dotimes [_ 300] (op c s)) ; warm (JIT on clj)
+ (double (ms #(dotimes [_ OPS] (op c s))))))))
+ V (fn [s] (vec (range s)))
+ M (fn [s] (into {} (map (fn [i] [i i]) (range s))))
+ L (fn [s] (apply list (range s)))]
+ {:vec/conj (probe V (fn [c _] (conj c 1)))
+ :vec/nth (probe V (fn [c s] (nth c (quot s 2))))
+ :vec/assoc (probe V (fn [c s] (assoc c (quot s 2) 0)))
+ :vec/pop (probe V (fn [c _] (pop c)))
+ :vec/subvec (probe V (fn [c s] (subvec c 1 s)))
+ :vec/rest (probe V (fn [c _] (rest c)))
+ :vec/seq (probe V (fn [c _] (seq c)))
+ :vec/next (probe V (fn [c _] (next c)))
+ :map/assoc (probe M (fn [c s] (assoc c (+ s 1) 0)))
+ :map/get (probe M (fn [c s] (get c (quot s 2))))
+ :map/dissoc (probe M (fn [c s] (dissoc c (quot s 2))))
+ :list/count (probe L (fn [c _] (count c)))
+ :list/conj (probe L (fn [c _] (conj c 1)))})
diff --git a/.dev/bench/traversal.clj b/.dev/bench/traversal.clj
new file mode 100644
index 000000000..e921b399d
--- /dev/null
+++ b/.dev/bench/traversal.clj
@@ -0,0 +1,64 @@
+;; Whole-collection traversal: does total time scale LINEARLY with n (4x per
+;; step) or QUADRATICALLY (16x)? A vector `rest` that materialises would make
+;; every seq walk over a vector O(n^2), which matters far more than subvec.
+
+(def SIZES [2000 8000 32000])
+
+(defn- ms [f]
+ (let [t0 (System/nanoTime)] (f) (/ (- (System/nanoTime) t0) 1e6)))
+
+(defn- probe [label build run]
+ (let [times (vec (for [s SIZES] (let [c (build s)] (ms #(run c)))))
+ ratios (vec (map (fn [a b] (if (zero? a) 0.0 (/ b a)))
+ (butlast times) (rest times)))
+ worst (if (seq ratios) (apply max ratios) 0.0)
+ verdict (cond (< worst 6.0) "LINEAR O(n)"
+ (< worst 11.0) "superlinear"
+ :else "QUADRATIC O(n^2) <-- BAD")]
+ (println (format "%-30s t=%-30s ratio=%-14s %s"
+ label
+ (vec (map #(format "%.1f" %) times))
+ (vec (map #(format "%.1f" %) ratios))
+ verdict))))
+
+(def V (fn [s] (vec (range s))))
+(def L (fn [s] (apply list (range s))))
+(def M (fn [s] (into {} (map (fn [i] [i i]) (range s)))))
+
+(println "=== whole-collection traversal (4x size per step) ===")
+(println (format "sizes %s\n" SIZES))
+
+(println "-- over a vector --")
+(probe "reduce +" V #(reduce + 0 %))
+(probe "doseq" V #(doseq [x %] x))
+(probe "loop over (rest)" V #(loop [s (seq %) n 0] (if s (recur (next s) (inc n)) n)))
+(probe "count (map inc)" V #(count (map inc %)))
+(probe "count (filter odd?)" V #(count (filter odd? %)))
+(probe "last" V #(last %))
+(probe "apply +" V #(apply + %))
+(probe "into [] " V #(into [] %))
+(probe "vec (map inc)" V #(vec (map inc %)))
+(probe "reverse" V #(reverse %))
+(probe "sort" V #(sort %))
+(probe "transduce (map inc)" V #(transduce (map inc) + 0 %))
+(probe "into [] xf" V #(into [] (map inc) %))
+
+(println "\n-- over a list --")
+(probe "reduce +" L #(reduce + 0 %))
+(probe "doseq" L #(doseq [x %] x))
+
+(println "\n-- over a map --")
+(probe "reduce-kv +" M #(reduce-kv (fn [a k _] (+ a k)) 0 %))
+(probe "doseq over entries" M #(doseq [e %] e))
+(probe "into {}" M #(into {} %))
+(probe "keys + count" M #(count (keys %)))
+
+(println "\n-- building --")
+(probe "into [] (range n)" identity #(into [] (range %)))
+(probe "reduce conj []" identity #(reduce conj [] (range %)))
+(probe "persistent! loop" identity
+ #(loop [t (transient []) i 0] (if (< i %) (recur (conj! t i) (inc i)) (persistent! t))))
+(probe "into #{}" identity #(into #{} (range %)))
+(probe "into {} pairs" identity #(into {} (map (fn [i] [i i]) (range %))))
+(probe "apply str" identity #(apply str (repeat % "x")))
+(probe "str/join" identity #(clojure.string/join "," (range %)))
diff --git a/.dev/optimizations.md b/.dev/optimizations.md
index 7ddbef666..ada23acf8 100644
--- a/.dev/optimizations.md
+++ b/.dev/optimizations.md
@@ -102,6 +102,7 @@
| O-040 | `eval/backend/vm.zig` (`op_vector_literal`) (ADR-0148; gc_alloc_rate) | `op_vector_literal` built an N-element vector via `vector.empty()` + N×`vector.conj` — each conj path-copies + allocates a fresh intermediate vector, so `[i (+ i 1) (+ i 2) (+ i 3)]` (gc_alloc_rate's loop body, ×200K) allocated 4 throwaway vectors per literal. `sample` showed the bench is NOT malloc-bound (18/3729 leaf, 0.5%) — the cost was the per-conj construction work (the map literal already had this fixed as O-026; vectors were left on the slow path). | One-shot `vector.fromSlice(rt, stack[sp-n..sp])`: n≤32 → 1 TailNode + 1 Vector (memcpy the elements); n>32 → bulk HAMT build. The elements stay rooted on the operand stack (op_top watermark) across the build. VM-only, mirroring O-026's map fast path (TreeWalk keeps empty+conj; diff oracle proves equality). | **`26_gc_alloc_rate` ReleaseSafe 108.4→45.8 ms (2.37×; 2.81×→1.30× vs Babashka 35.3 ms); system time 31→6.5 ms.** Broad win for every vector literal (esp. small literals in hot loops). | `zig build test -Dwasm` ×2 (diff oracle, TreeWalk slow ≡ VM fromSlice) + corpus + smoke; spot-checked n=0/1/40/nested vs clj (count 40, sum 780) | O-003, O-026, ADR-0148, D-450 |
| O-039 | `runtime/numeric/promote.zig` (`operandManaged`/`OwnedManaged` + add/sub/mul BigInt else-branches) (ADR-0148; bigint_factorial) | The integer/BigInt arithmetic else-branch (reached when an operand is a heap BigInt) called `coerceToManaged` on BOTH operands, which **clones** a `.big_int` operand via `cloneWithDifferentAllocator` (alloc + limb memcpy) — yet `r.add`/`sub`/`mul` only READ them. `bigint_factorial` (`(reduce *' …)`, acc grows to ~9 limbs) clones the growing accumulator on every `*`. Same waste O-037 fixed for ratios, single-Managed flavour. | `operandManaged(rt, v, *OwnedManaged)` returns `*const Managed`: a BigInt aliases its stored Managed (zero alloc); an immediate Long materialises into a caller-stable `OwnedManaged` local (`deinit` only when active). The 3 else-branches use it; the `@addWithOverflow` both-int sub-paths keep `coerceToManaged` (both small, nothing to alias). | Removes the per-op accumulator clone. **`32_bigint_factorial` ReleaseSafe 26.4→22.4 ms (12 runs; 1.49×→~1.27× vs Babashka 17.7 ms).** ratio_sum unchanged (separate path). Broad win for all BigInt `+ - *`. | `zig build test -Dwasm` ×2 (diff oracle) + corpus + smoke; output unchanged (158 = digit count of 100!) | O-037, ADR-0148, D-450 |
| O-038 | `runtime/numeric/promote.zig` (`ratioArith` scratch) + `runtime/numeric/ratio.zig` (`allocFromManagedPair` reduce-scratch) (ADR-0148; ratio_sum) | Each ratio op allocated its transient cross-multiply + gcd/divTrunc scratch as individual `Managed.init(rt.gc.infra)` calls — `ratioArith` 2-4 (rn/rd/lhs/rhs) + `allocFromManagedPair` 4 (gcd/r_num/r_den/rem) — so ~8 GPA malloc/free per `+`. `sample` profiling showed `libsystem_malloc` (`_xzm_*malloc`/`_xzm_free`/`memset`/`bzero`) ≈ 20% of leaf self-time. | Each function opens ONE `std.heap.ArenaAllocator` over `rt.gc.infra` and allocates all its scratch Managed from it; `defer arena.deinit()` bulk-frees on return. The two arenas don't alias (rn/rd live on `ratioArith`'s arena, read inside `allocFromManagedPair` which has its own). Result BigInts (numer/denom) still clone onto `gc.infra` (GC-managed, outlive the call). | Collapses ~8 individual malloc/free into ~2 arena chunk alloc/free per op. **`33_ratio_sum` 103.3→82.9 ms (12 runs); combined with O-037 108.1→82.9 (23%).** Re-profile: mem-leaf 227/1950 ≈ 11.6% (alloc no longer dominant; residual is interpreter dispatch + bignum compute → D-386). Broad win for all ratio arithmetic. | `zig build test -Dwasm` ×2 (diff oracle) + corpus 3157/3157 + smoke gate; output unchanged | O-037, ADR-0148, D-450 |
+| O-056 | `lang/clj/clojure/core.clj` `pop` (vector arm) + `lang/primitive/collection.zig` `__vector-pop` (new) | `(pop v)` on a vector was `(into [] (take (dec (count coll)) coll))` — an O(n) REBUILD of a whole fresh trie. Meanwhile `runtime/collection/vector.zig::pop` already implements the JVM `PersistentVector.pop()` algorithm correctly (tail fast path; else pull the last leaf out of the root and collapse a level), and was reachable ONLY from `java.util.ArrayDeque`. A stratified-design ABSTRACTION-BARRIER violation (Grokking Simplicity ch.8): the layer above re-implemented a barrier operation with high-level seq ops instead of calling through it | Expose the existing barrier fn as the `__vector-pop` primitive and have `pop` call it. No new lower-layer code — the "minimal interface" move: the barrier already had the op | O(n) -> O(log32 n), and it now SHARES structure with the parent instead of allocating a fresh trie. Measured 2000 pops, ReleaseSafe: n=1000 539.5 -> 1.14 ms; n=4000 2151.5 -> 1.30 ms; **n=16000 8278.3 -> 2.47 ms (~3352x)**. Reference JVM clj 1.12.4 on the same box/probe: 1.93 ms — cljw was ~4288x SLOWER than clj here and is now within ~1.3x | `scripts/check_corpus_regression.sh` **4347/4347** golden clj outputs reproduced + e2e `phase14_peek_pop`, `phase14_deftype_collection_base`, `phase14_deftype_object`, `phase14_catch_internal` + spot checks (meta preserved, list arm -> rest, queue arm -> `__queue-pop`, empty still raises IllegalStateException) | D-044, ADR-0148, `.dev/bench/` |
| O-055 | `eval/backend/vm.zig` (eval loop) + `eval/backend/tree_walk.zig` (`evalLoop` + fn-tail-recur loop) — budget-poll TLV hoist (D-450 lever 1) | The per-op / per-back-edge budget poll read the `threadlocal eval_budget_mod.current` each iteration. On macOS every threadlocal access is a dyld `_tlv_get_addr` CALL (D-571 moved the budget off the near-free Runtime field into the threadlocal, silently voiding D-386's "polls are near-free" verdict); `sample` on the rush-hour BFS showed `_tlv_get_addr` ≈ 7% top-of-stack, part of it this poll | Read `current` ONCE into a local at eval/loop entry; the per-op poll unwraps the local. Semantics preserved because every writer of `current` is stack-disciplined around a host call that re-enters eval (`with-budget` installs → invokes via callFn = a NEW eval invocation that re-reads at its entry → restores before returning to the hoisted loop); worker adopt/release bracket the worker's own eval | Same-session A/B on the rush-hour BFS headline (`(g/generate 7 :medium)`, M4 Pro, ReleaseSafe): median A 16.86 s (8 runs) → B 16.48 s (10 runs), **−2.3%**. The remaining TLV cost sits under inlined `stepOnce` (error-info per-call trace push/pop candidates — D-450 lever 2) | `zig build test -Dwasm` ×2 (diff oracle + the ADR-0125 dual-backend budget kill test) + `budget_thread_ownership` + `phase16_eval_budget` e2e green on the hoisted binary + smoke | D-450, D-571, D-386, ADR-0125 |
| O-053 | `runtime/collection/value_sort.zig` (new) + Arrays/Collections/higher_order sort sites + 3 tiny-N insertion swaps — **binary-size axis** (ADR-0172 L6) | Every `std.mem.sort(T, items, ctx, less)` call monomorphizes ~25KB of block sort per (element, context, comparator) triple — 9 call sites had accreted ~224KB / 93 symbols; three of them sort `[]Value` with site-local comparators, and three more sort tiny-N arrays (txn ref set / fn arities / bencode dict keys) where a full block sort buys nothing | ONE shared instantiation: `value_sort.sort(items, ctx: *anyopaque, less: fn-pointer)` — the three Value sites pass their context opaquely (cast back inside the comparator); the three tiny-N cold sites drop to `std.sort.insertion` (~1KB each). Hot index-permutation sort (O-010 sortByKeysFn) keeps its dedicated instantiation | Shipped ReleaseSafe **7,073,080 → 6,974,584 B (−98.5KB)**; sort perf NEUTRAL (50k-element `(sort xs)` ×10: 1.00±0.03 hyperfine A/B — the indirect call is invisible next to valueCompare) | `zig build test -Dwasm` ×2 + new value_sort unit + probes: `(sort [3 1 2])`, string sort, `java.util.Collections/sort` + smoke | ADR-0172 (L6), O-010, D-515 |
| O-052 | `build.zig` (`exe_mod.unwind_tables`) — **binary-size axis** (ADR-0172 L1) | Release builds carried `__eh_frame` + `__unwind_info` (749 KB, ~8% of the 9,469,816 B shipped binary) for native stack unwinding — but the shipped binary is STRIPPED (O-008), and a stripped Zig binary already prints "stack tracing is disabled" on panic (probed 2026-07-16 with a forced OOB panic, identical output with/without tables), so the tables bought nothing user-visible | `.unwind_tables = if (optimize != .Debug and !profile) .none else null` — mirrors the O-008 strip predicate; Debug + `-Dprofile` keep tables for lldb/Instruments. cljw renders Clojure errors from its own StackFrame stack; arm64 keeps frame pointers for debugger frame-walks | Shipped ReleaseSafe `cljw` **9,469,816 → 8,731,096 B (−738,720 B / −7.8%)**; `__eh_frame`/`__unwind_info` gone from `size -m` | smoke (units + diff oracle + e2e core on the table-less ReleaseSafe binary) + `size_claims` gate + panic-output probe pre/post identical | ADR-0172 (L1), O-008, D-515 |
diff --git a/src/lang/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj
index 77cc8150c..91238b36d 100644
--- a/src/lang/clj/clojure/core.clj
+++ b/src/lang/clj/clojure/core.clj
@@ -671,7 +671,7 @@
(cljw.internal/__queue-pop coll)
(if (vector? coll)
(if (pos? (count coll))
- (into [] (take (dec (count coll)) coll))
+ (cljw.internal/__vector-pop coll)
(throw (IllegalStateException. "Can't pop empty vector")))
(if (list? coll)
(if (seq coll)
diff --git a/src/lang/primitive/collection.zig b/src/lang/primitive/collection.zig
index ebef49179..25d795b21 100644
--- a/src/lang/primitive/collection.zig
+++ b/src/lang/primitive/collection.zig
@@ -1045,6 +1045,27 @@ pub fn queuePopFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
return persistent_queue.pop(rt, args[0]);
}
+/// `(__vector-pop v)` — drop the LAST element via `vector.pop`, the JVM
+/// `PersistentVector.pop()` algorithm (tail fast path, else pull the last leaf
+/// out of the trie and collapse a level if it empties): O(log32 n) and it
+/// shares structure with the parent.
+///
+/// The Clojure-level `pop` used to rebuild instead — `(into [] (take (dec
+/// (count coll)) coll))` — which is O(n) and allocates a whole fresh trie, so
+/// `pop` was ~4000x slower than clj's on a 16k vector while the correct
+/// algorithm sat in `vector.zig` reachable only from `java.util.ArrayDeque`.
+/// Empty is rejected here so `pop` keeps raising IllegalStateException.
+pub fn vectorPopFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ try error_catalog.checkArity("__vector-pop", args, 1, loc);
+ if (args[0].tag() != .vector)
+ return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "__vector-pop", .expected = "a vector", .actual = @tagName(args[0].tag()) });
+ return vector.pop(rt, args[0]) catch |e| switch (e) {
+ error.PopEmpty => error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "__vector-pop", .expected = "a non-empty vector", .actual = "an empty vector" }),
+ else => e,
+ };
+}
+
/// `#queue (e1 e2 …)` data-reader (ADR-0087): build a queue by conj-ing the
/// form's elements in order. cljw extension (clj has no `#queue` reader); makes
/// the cljw print form reader-round-trippable.
@@ -1118,6 +1139,7 @@ const ENTRIES = [_]Entry{
.{ .name = "__kv-reduce-or", .f = &kvReduceOrFn },
.{ .name = "queue?", .f = &queueQFn },
.{ .name = "__queue-pop", .f = &queuePopFn },
+ .{ .name = "__vector-pop", .f = &vectorPopFn },
.{ .name = "disj", .f = &disjFn },
.{ .name = "contains?", .f = &containsQFn },
.{ .name = "get", .f = &getFn },
From 59cb6c2270ae4182861442811aa835642619d4bf Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Thu, 13 Aug 2026 16:34:14 -0300
Subject: [PATCH 048/466] =?UTF-8?q?docs(bench):=20correct=20the=20cljw-vs-?=
=?UTF-8?q?clj=20reading=20=E2=80=94=20separate=20interpreter=20speed=20fr?=
=?UTF-8?q?om=20structure=20speed?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first table's 'cljw faster' verdicts on the sub-microsecond ops came from
too few reps and no baseline. Re-measured best-of-7 over 20000 ops with the
empty-loop cost subtracted:
empty dotimes clj 0.043 cljw 0.900 clj 21x (45ns vs 2.2ns per iter)
nth clj 0.373 cljw 0.879 cljw 2.4x
assoc clj 49.38 cljw 21.43 cljw 2.3x
map assoc clj 73.93 cljw 18.77 cljw 3.9x
map dissoc clj 2.62 cljw 8.15 clj 3.1x
conj clj 1.75 cljw 7.62 clj 4.4x
Two things were conflated. Interpretation speed: the JVM is far ahead, because
a tree-walk/VM interpreter loses to a JIT that compiled the loop to native code
— not a Zig-vs-Java result, and not something collection work can change. The
persistent structures themselves: same league, trading wins.
The 1000x+ gaps (pop/subvec/seq/rest/next/list-count) remain purely
algorithmic, which is the point the bench exists to make.
---
.dev/bench/README.md | 37 ++++++++++++++++++++++++++++++++++---
1 file changed, 34 insertions(+), 3 deletions(-)
diff --git a/.dev/bench/README.md b/.dev/bench/README.md
index 0607b7562..4faa10330 100644
--- a/.dev/bench/README.md
+++ b/.dev/bench/README.md
@@ -42,9 +42,40 @@ whole-collection traversal probe that checks for accidental O(n²).
| **`next`** | **0.99** | **2164** | **2184× slower** |
| **`list` `count`** | **0.18** | **481** | **2711× slower** |
-The headline is NOT "cljw is slow". The HAMT work is sound — cljw beats the JVM
-on `nth`/`assoc`/`conj`/`get`/`dissoc`. Every loss is one of two specific
-mistakes, and neither is a data-structure problem:
+### Correction: the small deltas above are NOT reliable
+
+The first table's "cljw faster" verdicts on the sub-microsecond ops were an
+artefact of too few reps and no baseline. Re-measured best-of-7 over 20000 ops
+with an empty-loop baseline subtracted (n=16000):
+
+| per 20000 ops | clj | cljw | |
+|---|---|---|---|
+| **empty `dotimes`** | **0.043** | **0.900** | **clj 21× faster** |
+| `nth` | 0.373 | 0.879 | cljw 2.4× |
+| `assoc` | 49.38 | 21.43 | cljw 2.3× |
+| map `assoc` | 73.93 | 18.77 | cljw 3.9× |
+| map `dissoc` | 2.62 | 8.15 | clj 3.1× |
+| `conj` | 1.75 | 7.62 | clj 4.4× |
+
+Two separate things, which the first table conflated:
+
+- **Interpretation speed: the JVM is far ahead.** An empty loop costs cljw 45ns
+ per iteration against the JVM's 2.2ns. That is not Zig losing to Java — it is
+ an *interpreter* (tree-walk / VM) losing to a *JIT* that has compiled the loop
+ to native code with the induction variable in a register. Nothing about the
+ collection work changes it; only a compiler would.
+- **The persistent structures themselves: same league, trading wins.** cljw is
+ ahead on `nth` and on both `assoc`es (its bump-allocating GC makes the
+ path-copy cheaper); the JVM is ahead on `conj` and `dissoc`. No 1000× effects
+ in either direction once the algorithms match.
+
+So: do not read this bench as "Zig beats the JVM". Read it as "the data
+structures are sound, and every catastrophic number below is algorithmic".
+
+### The catastrophic numbers are algorithmic, not linguistic
+
+Every loss is one of two specific mistakes, and neither is a data-structure
+problem:
1. **An abstraction-barrier violation** — the Clojure layer re-implements an
operation the Zig barrier already provides correctly. `pop` was the pure
From 8c5bd01206d5cd3d9cf2d7c79e625872df894d25 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 12:10:56 -0300
Subject: [PATCH 049/466] =?UTF-8?q?fix(gate):=20three=20red=20checks=20?=
=?UTF-8?q?=E2=80=94=20marker=20anchor,=20stale=20placement,=20mac-denomin?=
=?UTF-8?q?ated=20ceiling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All three were red at HEAD (verified on a detached worktree), inherited from the
2026-08-13 batch. The gate goes 438 passed / 3 failed -> 441 / 0.
surface_marker: _atomic.zig's "Backend: impl-only" carried a trailing
parenthetical. check_surface_marker.sh anchors that marker with $, so the prose
made it read as malformed. The explanation moves to its own lines, with a note
saying why it cannot share the marker line.
placement_drift: data/placement.yaml was stale. Root cause was O-056 landing
cljw.internal/__vector-pop without running gen_placement.sh in the same commit —
the index is DERIVED, so it has to be regenerated with the change that moves a
var. Regenerated: 1362 vars, which also absorbs the clojure.test use-fixtures
vars from b4ca3717.
size_claims: NOT bloat — a unit error, and the interesting one. ADR-0172 §1 names
macOS arm64 as the reference for "every number in this ledger", and §2's
per-component budgets are all measured there, so the derived ceiling is in mac
bytes. The gate enforced that absolute against whatever host it ran on, while the
same §1 says the Linux artifact is expected "within ~±15%" of the reference. A
mac-denominated ceiling applied to a systematically larger platform had to fail
eventually; the ADR's own Revision history logs it approaching and reads it as a
scare rather than a bug — Linux at "91% of ceiling", then "94%", against a ceiling
the reference artifact sat at ~80% of. It reached 100.01%, failing on 1,032 bytes.
The script already applied the right reasoning one check over: it skips the README
claim-drift comparison off-reference because that would be "comparing two different
things". The ceiling check never got the same treatment.
BUDGET_CEILING_BYTES stays 8,800,000 as the REFERENCE ceiling — the budget does not
move where it was derived — and PLATFORM_SIZE_FACTOR=1.13 scales it off-reference.
1.13 is the ratio this ADR's history already measured twice (7,073,240 B / ~8.01 MB
= 1.132; 7,352,376 B / ~8.29 MB = 1.128), deliberately not the looser ±15% envelope.
Linux's ceiling becomes 9,944,000 B and the artifact sits at 88.5% of it, against
the reference artifact's 85.8% of 8.8 MB — the two now tell the same story about
the same tree, which is the tell that the unit is right.
Verified by deliberate break: PLATFORM_SIZE_FACTOR=1.0 still fails the gate on this
binary, so the check is re-denominated, not defanged. ADR-0172 Revision entry added,
as the script's own comment requires for any ceiling change.
Not attempted: re-measuring the reference artifact. zig build -Dtarget=aarch64-macos
does not cross-compile from Linux — vendored zwasm's platform layer carries arm64
inline-asm clobber lists and Linux-only syscall constants (MAP.JIT, siginfo_t.addr),
6 errors. Linux-side per-component attribution is also unavailable, because
binary_size_report.sh's breakdown parses macOS `size -m`.
---
.../0172_binary_size_budget_and_ledger.md | 45 +++++++++++++++++++
data/placement.yaml | 15 +++++--
scripts/binary_size_report.sh | 44 ++++++++++++++++--
.../java/util/concurrent/atomic/_atomic.zig | 9 +++-
4 files changed, 104 insertions(+), 9 deletions(-)
diff --git a/.dev/decisions/0172_binary_size_budget_and_ledger.md b/.dev/decisions/0172_binary_size_budget_and_ledger.md
index 0dd934e0a..d119608e1 100644
--- a/.dev/decisions/0172_binary_size_budget_and_ledger.md
+++ b/.dev/decisions/0172_binary_size_budget_and_ledger.md
@@ -112,6 +112,14 @@ A component crossing its budget line triggers: attribute (report tool) →
either a lever lands or the budget line is consciously amended **in this ADR**
(Revision history entry). Never silently.
+**Every figure in this table is in reference-platform bytes** (macOS arm64, per
+§1) — including the derived ceiling. The gate therefore scales it by
+`PLATFORM_SIZE_FACTOR` when it runs off the reference platform; enforcing the
+mac-denominated total as an absolute on Linux compares two different things and
+failed for exactly that reason on 2026-08-14 (see Revision history). A number
+here moves only for a component that actually grew, never to accommodate a
+platform.
+
### 3. Size-claims gate (the recurrence-prevention mechanism)
`test/run_all.sh` gains the `size_claims` step (full gate, after
@@ -406,6 +414,43 @@ dispositions grounded, platform binding declared, peer number measured).
## Revision history
+- **2026-08-14 (ceiling re-denominated per platform, conscious — governance
+ correction, NOT a budget increase)**: the `size_claims` gate failed on the
+ Linux x86_64 leg at 8,801,032 B against the 8,800,000 B derived ceiling — by
+ 1,032 bytes, 0.012%. Attribution showed the overrun is not a component
+ regression but a unit error in the gate itself.
+
+ §1 of this ADR names macOS arm64 as the reference for "every number in this
+ ledger", and every component budget in §2's table is measured there. The gate
+ nonetheless compared that mac-denominated total against whatever host it ran
+ on — and §1 also states the Linux artifact is expected "within ~±15%" of the
+ reference. A ceiling in mac bytes enforced as an absolute on a platform this
+ ADR says is systematically larger had to fail eventually; the two entries
+ below record it approaching, at "91% of ceiling" and then "94% of ceiling",
+ while the reference artifact those budgets describe sat at ~80%.
+
+ Fix: `binary_size_report.sh` keeps `BUDGET_CEILING_BYTES=8800000` as the
+ **reference-platform** ceiling — unchanged, so the budget does not move where
+ it was derived — and scales it by a `PLATFORM_SIZE_FACTOR` of **1.13** off
+ the reference platform. 1.13 is the ratio this history already measured
+ twice (7,073,240 B / ~8.01 MB = 1.132; 7,352,376 B / ~8.29 MB = 1.128), not
+ the looser ±15% envelope. Linux's effective ceiling becomes 9,944,000 B, and
+ the current artifact sits at 88.5% of it — consistent with the reference
+ artifact's 85.8% of 8.8 MB, which is the tell that the two are now measuring
+ the same thing. The "cannot grow without a decision" intent is unchanged and
+ still enforced on every platform; only the unit is corrected. Verified by a
+ deliberate break: `PLATFORM_SIZE_FACTOR=1.0` still fails the gate on this
+ binary, so the check is re-denominated, not defanged.
+
+ Not attempted here: re-measuring the reference artifact to confirm mac's
+ current headroom. `zig build -Dtarget=aarch64-macos` does not cross-compile
+ from Linux — vendored zwasm's platform layer carries arm64 inline-asm
+ clobber lists and Linux-only syscall constants (`MAP.JIT`, `siginfo_t.addr`),
+ 6 compile errors. So the mac figure in §2's table remains the 2026-08-04
+ measurement until a run on the reference platform refreshes it, and the
+ Linux-side per-component attribution stays unavailable because
+ `binary_size_report.sh`'s breakdown parses macOS `size -m` output.
+
- **2026-07-17 (D-561 Unicode name table, conscious)**: `Character/getName`
+ `codePointOf` land the full UCD 16.0.0 name table as pre-compressed
word-indexed blobs (40,013 names; 545 KB raw → ~280 KB stored after the
diff --git a/data/placement.yaml b/data/placement.yaml
index ce77ad430..de7b1fe46 100644
--- a/data/placement.yaml
+++ b/data/placement.yaml
@@ -24,10 +24,10 @@
schema_version: "2.0"
summary:
namespaces_required: 32
- total: 1355
- zig: 396
- clj: 748
- value: 205
+ total: 1362
+ zig: 397
+ clj: 753
+ value: 206
unbound: 6
vars:
"cljw.error/*error-context*": { impl: value, flags: "dynamic" }
@@ -110,6 +110,7 @@ vars:
"cljw.internal/__string-writer": { impl: zig, flags: "-" }
"cljw.internal/__unsupported-host-ref": { impl: zig, flags: "-" }
"cljw.internal/__update-in": { impl: zig, flags: "-" }
+ "cljw.internal/__vector-pop": { impl: zig, flags: "-" }
"cljw.internal/__writer->str": { impl: zig, flags: "-" }
"cljw.json/decode": { impl: clj, flags: "-" }
"cljw.json/decode-strict": { impl: clj, flags: "-" }
@@ -1295,6 +1296,7 @@ vars:
"clojure.test.tap/print-tap-plan": { impl: clj, flags: "-" }
"clojure.test.tap/tap-report": { impl: clj, flags: "dynamic" }
"clojure.test.tap/with-tap-output": { impl: clj, flags: "macro" }
+ "clojure.test/*fixture-registry*": { impl: value, flags: "-" }
"clojure.test/*initial-report-counters*": { impl: value, flags: "-" }
"clojure.test/*report-counters*": { impl: value, flags: "dynamic" }
"clojure.test/*stack-trace-depth*": { impl: value, flags: "dynamic" }
@@ -1304,13 +1306,17 @@ vars:
"clojure.test/*testing-vars*": { impl: value, flags: "dynamic" }
"clojure.test/are": { impl: clj, flags: "macro" }
"clojure.test/assert-expr": { impl: clj, flags: "-" }
+ "clojure.test/compose-fixtures": { impl: clj, flags: "-" }
+ "clojure.test/default-fixture": { impl: clj, flags: "-" }
"clojure.test/deftest": { impl: clj, flags: "macro" }
"clojure.test/do-report": { impl: clj, flags: "-" }
"clojure.test/file-position": { impl: clj, flags: "-" }
+ "clojure.test/fixtures-for": { impl: clj, flags: "private" }
"clojure.test/function?": { impl: clj, flags: "-" }
"clojure.test/inc-report": { impl: clj, flags: "-" }
"clojure.test/inc-report-counter": { impl: clj, flags: "-" }
"clojure.test/is": { impl: clj, flags: "macro" }
+ "clojure.test/join-fixtures": { impl: clj, flags: "-" }
"clojure.test/print-contexts": { impl: clj, flags: "-" }
"clojure.test/report": { impl: clj, flags: "dynamic" }
"clojure.test/run-all-tests": { impl: clj, flags: "-" }
@@ -1320,6 +1326,7 @@ vars:
"clojure.test/testing-contexts-str": { impl: clj, flags: "-" }
"clojure.test/testing-vars-str": { impl: clj, flags: "-" }
"clojure.test/try-expr": { impl: clj, flags: "macro" }
+ "clojure.test/use-fixtures": { impl: clj, flags: "-" }
"clojure.test/with-test-out": { impl: clj, flags: "macro" }
"clojure.tools.cli/apply-spec": { impl: clj, flags: "private" }
"clojure.tools.cli/compile-spec": { impl: clj, flags: "private" }
diff --git a/scripts/binary_size_report.sh b/scripts/binary_size_report.sh
index 3cf44fdb7..f06cf4261 100755
--- a/scripts/binary_size_report.sh
+++ b/scripts/binary_size_report.sh
@@ -43,8 +43,33 @@ BIN="${1:-zig-out/bin/cljw}"
# ADR-0172 §2 derived ceiling (per-component budgets sum). Keep in sync with
# the ADR's budget table — a change here REQUIRES an ADR-0172 Revision
# history entry (the budget moves consciously, never to silence this check).
+#
+# Denominated in REFERENCE-PLATFORM bytes, because that is what the budget
+# table measures: ADR-0172 §1 names macOS arm64 as the reference for "every
+# number in this ledger". See PLATFORM_SIZE_FACTOR below for why enforcing
+# this number as an absolute on every host was a latent contradiction.
BUDGET_CEILING_BYTES=8800000
+# The same source builds measurably bigger off the reference platform, so a
+# ceiling derived from mac-denominated component budgets cannot be applied to
+# a Linux artifact as an absolute — that compares two different things, which
+# is the same error this script already avoids for the claim-drift check.
+#
+# ADR-0172 §1 states the expectation as "within ~±15%". The two mac/Linux
+# pairs its Revision history actually records are tighter and agree with each
+# other: 7,073,240 B / ~8.01 MB = 1.132, and 7,352,376 B / ~8.29 MB = 1.128.
+# 1.13 is that measured ratio, not the ±15% envelope — the envelope would be
+# the looser choice and there is no reason to spend it.
+#
+# This is a correction, NOT a relaxation: the reference-platform ceiling is
+# unchanged at 8,800,000 B, so the budget does not move where it was derived.
+# It only stops a Linux run from being measured against a number that was
+# never in Linux bytes. The ADR's own Revision history shows the contradiction
+# accumulating — Linux recorded at "91% of ceiling", then "94% of ceiling",
+# against a ceiling mac was at ~80% of — and it reached 100.01% on
+# 2026-08-14, failing the gate on 1,032 bytes.
+PLATFORM_SIZE_FACTOR="${PLATFORM_SIZE_FACTOR:-1.13}"
+
if [[ ! -f "$BIN" ]]; then
echo "binary_size_report: binary not found: $BIN (build first: zig build -Dwasm -Doptimize=ReleaseSafe)" >&2
exit 1
@@ -78,15 +103,28 @@ is_reference_platform() {
}
if [[ "$MODE" == "check" ]]; then
- if [[ "$ACTUAL" -gt "$BUDGET_CEILING_BYTES" ]]; then
- echo "size_claims: built binary ${ACTUAL_MB} MB (${ACTUAL} B) exceeds the ADR-0172 derived ceiling ($BUDGET_CEILING_BYTES B)." >&2
+ # The ceiling ENFORCED here is the reference-platform budget scaled to the
+ # host being measured, so the artifact still cannot grow without a decision
+ # on any platform (that intent is host-independent) while the comparison
+ # itself is between like quantities (the bytes are not).
+ if is_reference_platform; then
+ EFFECTIVE_CEILING="$BUDGET_CEILING_BYTES"
+ CEILING_NOTE="reference platform"
+ else
+ EFFECTIVE_CEILING=$(awk "BEGIN{printf \"%d\", $BUDGET_CEILING_BYTES * $PLATFORM_SIZE_FACTOR}")
+ CEILING_NOTE="$(uname -s)/$(uname -m): reference ceiling ${BUDGET_CEILING_BYTES} B × ${PLATFORM_SIZE_FACTOR} platform factor"
+ fi
+
+ if [[ "$ACTUAL" -gt "$EFFECTIVE_CEILING" ]]; then
+ echo "size_claims: built binary ${ACTUAL_MB} MB (${ACTUAL} B) exceeds the ADR-0172 derived ceiling ($EFFECTIVE_CEILING B — $CEILING_NOTE)." >&2
echo " Attribute with 'bash scripts/binary_size_report.sh' (-Dprofile build for symbols), then land a lever" >&2
echo " or consciously amend the budget table in .dev/decisions/0172_binary_size_budget_and_ledger.md." >&2
+ echo " Do NOT raise PLATFORM_SIZE_FACTOR to pass — it encodes a measured mac/Linux ratio, not a slack knob." >&2
exit 1
fi
if ! is_reference_platform; then
- echo " size_claims: ceiling ok (${ACTUAL_MB} MB); claim-drift check skipped — the documented figures are macOS arm64 (ADR-0172 reference platform), this is $(uname -s)/$(uname -m)"
+ echo " size_claims: ceiling ok (${ACTUAL_MB} MB vs ${EFFECTIVE_CEILING} B — $CEILING_NOTE); claim-drift check skipped — the documented figures are macOS arm64 (ADR-0172 reference platform), this is $(uname -s)/$(uname -m)"
exit 0
fi
diff --git a/src/runtime/java/util/concurrent/atomic/_atomic.zig b/src/runtime/java/util/concurrent/atomic/_atomic.zig
index 16a171503..7aa9e6c45 100644
--- a/src/runtime/java/util/concurrent/atomic/_atomic.zig
+++ b/src/runtime/java/util/concurrent/atomic/_atomic.zig
@@ -4,11 +4,16 @@
//! value; every mutation moves it with `@cmpxchgWeak` or `@atomicRmw`, so the
//! surfaces are safe under cljw's real OS threads.
//!
-//! Backend: impl-only (no `___HOST_EXTENSION` — the per-class surface files
-//! under this directory export the markers and share this `Impl`).
+//! Backend: impl-only
//! Impl deps: host_instance
//! Clojure peer: none
//!
+//! This file exports no `___HOST_EXTENSION` of its own — the per-class surface
+//! files under this directory carry the markers and share this `Impl`. The
+//! three marker lines above must each stand alone: `check_surface_marker.sh`
+//! anchors `Backend:` with `$`, so a trailing parenthetical reads as a
+//! malformed marker.
+//!
//! `Impl` is comptime-parameterised by the class's descriptor slot, its name
//! (for arity/type errors) and its `Repr`, because AtomicLong / AtomicInteger
//! differ only in the class the value is REPORTED under: cljw has one integer
From 166d2ea00199903799a29070aebc4f77581e23fd Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 12:11:34 -0300
Subject: [PATCH 050/466] perf(list): count a pure list in O(1) via a
self-validating cached count (O-057)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
(count lst) walked every .list, though Cons has always stored an O(1) count and
list.countOf has always returned it. The field was distrusted for a real reason: a
.list cell can head a MIXED chain whose rest leaves .list — (cons x (map …)),
(conj (range 3) 99) — and there `1 + countOf(tail)` records the .list PREFIX length
as if it were the total. countFn's comment deferred the fast path to the F-004
.list/.cons tag split (D-178), i.e. to an architectural change nobody is scheduling.
Make the stored count self-validating instead of splitting the tag. countPrepending
writes COUNT_UNKNOWN when the tail is a non-.list seq OR is itself unknown, so
unknown-ness propagates head-ward and the field is never a prefix presented as a
total. exactCountOf returns ?u32; countFn takes O(1) when it is non-null and keeps
the existing walk otherwise, so mixed chains keep their exact answer.
The sentinel is maxInt(u32) deliberately: every existing reader uses countOf(v) > 0
or == 0 as an EMPTINESS test, and a cons cell always holds at least one element, so
"unknown" reads as non-empty and all of those sites stay exact with no edit. The one
caller that wants a LENGTH — equal.zig seqLen — is already unreachable for .list,
because isCountable excludes it for exactly the same prefix-lie reason, so the
sentinel cannot leak into the equality short-circuit.
Measured, 2000 count ops on a 16000-element list, ReleaseSafe:
380.15 -> 0.19 ms (~2006x)
Reference JVM clj 1.12.4, same box and probe: 0.18 ms. cljw was ~2711x slower than
clj on this op and is now at parity.
This is the same class as O-056: there the barrier op existed and the layer above
re-implemented it; here the barrier VALUE existed and the layer above distrusted it
wholesale. When a cached value is right most of the time, say which times in the
value itself — a blanket distrust throws the fast path away for every caller.
test/e2e/list_count_exact.sh — 22 assertions covering both directions: pure chains
(literal, quoted, empty, apply list, conj) take the fast path; mixed chains over
map/range/vector-seq/lazy-seq/string still walk to the exact count, including
nesting a cons onto an already-unknown chain; and the readers that share the field
(equality, empty?, seq, first/rest, with-meta) are pinned.
Full gate: 441 passed, 0 failed.
---
.dev/bench/README.md | 35 +++++++++++++--
.dev/optimizations.md | 1 +
src/lang/primitive/sequence.zig | 16 ++++---
src/runtime/collection/list.zig | 80 +++++++++++++++++++++++++++++++--
test/e2e/list_count_exact.sh | 65 +++++++++++++++++++++++++++
test/run_all.sh | 1 +
6 files changed, 184 insertions(+), 14 deletions(-)
create mode 100755 test/e2e/list_count_exact.sh
diff --git a/.dev/bench/README.md b/.dev/bench/README.md
index 4faa10330..6e56c9d73 100644
--- a/.dev/bench/README.md
+++ b/.dev/bench/README.md
@@ -40,7 +40,7 @@ whole-collection traversal probe that checks for accidental O(n²).
| **`seq`** | **0.60** | **2123** | **3549× slower** |
| **`rest`** | **0.87** | **2253** | **2576× slower** |
| **`next`** | **0.99** | **2164** | **2184× slower** |
-| **`list` `count`** | **0.18** | **481** | **2711× slower** |
+| **`list` `count`** | **0.18** | **481** | **2711× slower → FIXED (O-057), now 0.19** |
### Correction: the small deltas above are NOT reliable
@@ -83,6 +83,16 @@ problem:
`core.clj` bypassed it with `(into [] (take (dec (count coll)) coll))`.
Fixed as O-056; the fix added no algorithm, it just exposed the barrier op.
+ `list count` was the same class with a twist: the barrier op existed
+ (`Cons.count`, O(1)) but was DISTRUSTED, because a `.list` cell can head a
+ mixed chain (`(cons x (map …))`) where the stored number is the `.list`
+ prefix length, not the total. Fixed as O-057 by making the field
+ self-validating — a `COUNT_UNKNOWN` sentinel marks exactly the chains that
+ must still walk — rather than by waiting for the D-178 `.list`/`.cons` tag
+ split the old comment deferred to. When a barrier value is right *most* of
+ the time, say WHICH times in the value itself; a blanket distrust throws
+ the fast path away for every caller.
+
2. **Materialising where Clojure keeps a VIEW** — `seq`/`rest`/`next` on a
vector build an eager `PersistentList` (`sequence.zig` `vectorToList` /
`vectorTailAsList`) where Clojure returns a `ChunkedSeq` holding
@@ -93,9 +103,26 @@ problem:
| # | Fix | Kind | Unblocks |
|---|---|---|---|
-| 1 | Chunked vector-seq VIEW (vector + index) behind the existing seq protocol | view, not algorithm | `seq` + `rest` + `next` in one change — the systemic one, since these are what every hand-written seq walk uses |
-| 2 | `SubVector` view (D-044 — "land when the benefit is measurable"; it is now measured: 7480×) | view | `subvec`, and every `drop`/`take`-style slice built on it |
-| 3 | Cached count on `PersistentList` | one field | `count` on a list |
+| 1 | `SubVector` view (D-044 — "land when the benefit is measurable"; it is now measured: 7480×) | view | `subvec`, every `drop`/`take`-style slice built on it — **and (2), see below** |
+| 2 | Chunked vector-seq VIEW behind the existing seq protocol | view, not algorithm | `seq` + `rest` + `next` in one change — the systemic one, since these are what every hand-written seq walk uses |
+| ~~3~~ | ~~Cached count on `PersistentList`~~ | ~~one field~~ | **DONE — O-057. The field already existed; what was missing was a way to say when it is exact.** |
+
+**(1) now ranks above (2) because it is (2)'s enabler.** `.range` already shows
+the shape a chunked seq takes here without costing a Tag: `range.seqChunk`
+materialises ≤32 elements into a `chunked_cons` whose `next` is *a smaller
+`.range`* — a self-similar producer value, not a closure and not a new
+representation. The vector analogue of "a smaller range" is "the vector minus
+its first 32 elements", which is exactly a `SubVector` view. Land (1) and (2)
+is `chunked_cons{v[k..k+32], subvec(v, k+32)}`, reusing the one chunk
+mechanism (F-011) with no new seq type at all.
+
+Note the constraint that makes this the attractive route: `heap_tag.zig`'s
+census is closed at 64 slots, and its own doc says spending one of the 9
+`unallocated` names is a user decision, not an implementation detail. A design
+that needs no new Tag sidesteps that question entirely. `SubVector` itself
+still has to answer it — either a repurposed slot, or a `start: u32` inside
+`Vector`'s existing `_pad: [6]u8` (no struct growth, but then EVERY vector op
+must honour the offset, which is the real cost and the thing to weigh).
(1) and (2) are the same shape and should follow OCP: add a new seq/vector
REPRESENTATION that satisfies the existing protocol, so no consumer changes —
diff --git a/.dev/optimizations.md b/.dev/optimizations.md
index ada23acf8..ef9169e3c 100644
--- a/.dev/optimizations.md
+++ b/.dev/optimizations.md
@@ -103,6 +103,7 @@
| O-039 | `runtime/numeric/promote.zig` (`operandManaged`/`OwnedManaged` + add/sub/mul BigInt else-branches) (ADR-0148; bigint_factorial) | The integer/BigInt arithmetic else-branch (reached when an operand is a heap BigInt) called `coerceToManaged` on BOTH operands, which **clones** a `.big_int` operand via `cloneWithDifferentAllocator` (alloc + limb memcpy) — yet `r.add`/`sub`/`mul` only READ them. `bigint_factorial` (`(reduce *' …)`, acc grows to ~9 limbs) clones the growing accumulator on every `*`. Same waste O-037 fixed for ratios, single-Managed flavour. | `operandManaged(rt, v, *OwnedManaged)` returns `*const Managed`: a BigInt aliases its stored Managed (zero alloc); an immediate Long materialises into a caller-stable `OwnedManaged` local (`deinit` only when active). The 3 else-branches use it; the `@addWithOverflow` both-int sub-paths keep `coerceToManaged` (both small, nothing to alias). | Removes the per-op accumulator clone. **`32_bigint_factorial` ReleaseSafe 26.4→22.4 ms (12 runs; 1.49×→~1.27× vs Babashka 17.7 ms).** ratio_sum unchanged (separate path). Broad win for all BigInt `+ - *`. | `zig build test -Dwasm` ×2 (diff oracle) + corpus + smoke; output unchanged (158 = digit count of 100!) | O-037, ADR-0148, D-450 |
| O-038 | `runtime/numeric/promote.zig` (`ratioArith` scratch) + `runtime/numeric/ratio.zig` (`allocFromManagedPair` reduce-scratch) (ADR-0148; ratio_sum) | Each ratio op allocated its transient cross-multiply + gcd/divTrunc scratch as individual `Managed.init(rt.gc.infra)` calls — `ratioArith` 2-4 (rn/rd/lhs/rhs) + `allocFromManagedPair` 4 (gcd/r_num/r_den/rem) — so ~8 GPA malloc/free per `+`. `sample` profiling showed `libsystem_malloc` (`_xzm_*malloc`/`_xzm_free`/`memset`/`bzero`) ≈ 20% of leaf self-time. | Each function opens ONE `std.heap.ArenaAllocator` over `rt.gc.infra` and allocates all its scratch Managed from it; `defer arena.deinit()` bulk-frees on return. The two arenas don't alias (rn/rd live on `ratioArith`'s arena, read inside `allocFromManagedPair` which has its own). Result BigInts (numer/denom) still clone onto `gc.infra` (GC-managed, outlive the call). | Collapses ~8 individual malloc/free into ~2 arena chunk alloc/free per op. **`33_ratio_sum` 103.3→82.9 ms (12 runs); combined with O-037 108.1→82.9 (23%).** Re-profile: mem-leaf 227/1950 ≈ 11.6% (alloc no longer dominant; residual is interpreter dispatch + bignum compute → D-386). Broad win for all ratio arithmetic. | `zig build test -Dwasm` ×2 (diff oracle) + corpus 3157/3157 + smoke gate; output unchanged | O-037, ADR-0148, D-450 |
| O-056 | `lang/clj/clojure/core.clj` `pop` (vector arm) + `lang/primitive/collection.zig` `__vector-pop` (new) | `(pop v)` on a vector was `(into [] (take (dec (count coll)) coll))` — an O(n) REBUILD of a whole fresh trie. Meanwhile `runtime/collection/vector.zig::pop` already implements the JVM `PersistentVector.pop()` algorithm correctly (tail fast path; else pull the last leaf out of the root and collapse a level), and was reachable ONLY from `java.util.ArrayDeque`. A stratified-design ABSTRACTION-BARRIER violation (Grokking Simplicity ch.8): the layer above re-implemented a barrier operation with high-level seq ops instead of calling through it | Expose the existing barrier fn as the `__vector-pop` primitive and have `pop` call it. No new lower-layer code — the "minimal interface" move: the barrier already had the op | O(n) -> O(log32 n), and it now SHARES structure with the parent instead of allocating a fresh trie. Measured 2000 pops, ReleaseSafe: n=1000 539.5 -> 1.14 ms; n=4000 2151.5 -> 1.30 ms; **n=16000 8278.3 -> 2.47 ms (~3352x)**. Reference JVM clj 1.12.4 on the same box/probe: 1.93 ms — cljw was ~4288x SLOWER than clj here and is now within ~1.3x | `scripts/check_corpus_regression.sh` **4347/4347** golden clj outputs reproduced + e2e `phase14_peek_pop`, `phase14_deftype_collection_base`, `phase14_deftype_object`, `phase14_catch_internal` + spot checks (meta preserved, list arm -> rest, queue arm -> `__queue-pop`, empty still raises IllegalStateException) | D-044, ADR-0148, `.dev/bench/` |
+| O-057 | `runtime/collection/list.zig` (`COUNT_UNKNOWN` + `countPrepending` + `exactCountOf`) + `lang/primitive/sequence.zig` `countFn` (`.list`/`.cons`/`.lazy_seq` arm) | `(count lst)` WALKED every `.list`, even though `Cons` already stores an O(1) `count`. The stored field was distrusted for a real reason: a `.list` cell may head a MIXED chain whose `rest` leaves `.list` — `(cons x (map …))`, `(conj (range 3) 99)` — and there `1 + countOf(tail)` records the `.list` PREFIX length as if it were the total. The prior comment deferred the fast path to the F-004 `.list`/`.cons` tag split (D-178) | Make the stored count SELF-VALIDATING rather than splitting the tag: `countPrepending` writes `COUNT_UNKNOWN` (`maxInt(u32)`) when the tail is a non-`.list` seq or itself unknown, so unknown-ness propagates head-ward and the field is never a prefix presented as a total. `exactCountOf` returns `?u32`; `countFn` takes the O(1) path when it is non-null and keeps the existing walk otherwise. Sentinel chosen so every existing `countOf(v) > 0` / `== 0` emptiness test keeps its meaning (a cons cell always holds ≥1 element) | O(n) -> O(1) for a pure list. Measured 2000 `count` ops on a 16000-element list, ReleaseSafe: **380.15 -> 0.19 ms (~2006x)**. Reference JVM clj 1.12.4, same box and probe: 0.18 ms — cljw was ~2711x slower than clj on this op and is now at PARITY. Mixed chains keep the walk, so they keep their exact answer | Mixed-chain probes all still exact (`(cons 1 (map inc [1 2 3]))` 4, `(conj (range 3) 99)` 4, `(cons 0 (seq [1 2 3]))` 4, nested `(cons 1 (cons 2 (map inc [1 2 3])))` 5, pure `(list 1 2 3)` 3, `(list)` 0) + `(= (cons 1 (lazy-seq [2 3])) (list 1 2 3))` still true (the equal.zig `isCountable` exclusion of `.list` is what keeps `COUNT_UNKNOWN` out of the length short-circuit) + list.zig units + e2e `list_count_exact` + corpus | D-178, D-044, `.dev/bench/` |
| O-055 | `eval/backend/vm.zig` (eval loop) + `eval/backend/tree_walk.zig` (`evalLoop` + fn-tail-recur loop) — budget-poll TLV hoist (D-450 lever 1) | The per-op / per-back-edge budget poll read the `threadlocal eval_budget_mod.current` each iteration. On macOS every threadlocal access is a dyld `_tlv_get_addr` CALL (D-571 moved the budget off the near-free Runtime field into the threadlocal, silently voiding D-386's "polls are near-free" verdict); `sample` on the rush-hour BFS showed `_tlv_get_addr` ≈ 7% top-of-stack, part of it this poll | Read `current` ONCE into a local at eval/loop entry; the per-op poll unwraps the local. Semantics preserved because every writer of `current` is stack-disciplined around a host call that re-enters eval (`with-budget` installs → invokes via callFn = a NEW eval invocation that re-reads at its entry → restores before returning to the hoisted loop); worker adopt/release bracket the worker's own eval | Same-session A/B on the rush-hour BFS headline (`(g/generate 7 :medium)`, M4 Pro, ReleaseSafe): median A 16.86 s (8 runs) → B 16.48 s (10 runs), **−2.3%**. The remaining TLV cost sits under inlined `stepOnce` (error-info per-call trace push/pop candidates — D-450 lever 2) | `zig build test -Dwasm` ×2 (diff oracle + the ADR-0125 dual-backend budget kill test) + `budget_thread_ownership` + `phase16_eval_budget` e2e green on the hoisted binary + smoke | D-450, D-571, D-386, ADR-0125 |
| O-053 | `runtime/collection/value_sort.zig` (new) + Arrays/Collections/higher_order sort sites + 3 tiny-N insertion swaps — **binary-size axis** (ADR-0172 L6) | Every `std.mem.sort(T, items, ctx, less)` call monomorphizes ~25KB of block sort per (element, context, comparator) triple — 9 call sites had accreted ~224KB / 93 symbols; three of them sort `[]Value` with site-local comparators, and three more sort tiny-N arrays (txn ref set / fn arities / bencode dict keys) where a full block sort buys nothing | ONE shared instantiation: `value_sort.sort(items, ctx: *anyopaque, less: fn-pointer)` — the three Value sites pass their context opaquely (cast back inside the comparator); the three tiny-N cold sites drop to `std.sort.insertion` (~1KB each). Hot index-permutation sort (O-010 sortByKeysFn) keeps its dedicated instantiation | Shipped ReleaseSafe **7,073,080 → 6,974,584 B (−98.5KB)**; sort perf NEUTRAL (50k-element `(sort xs)` ×10: 1.00±0.03 hyperfine A/B — the indirect call is invisible next to valueCompare) | `zig build test -Dwasm` ×2 + new value_sort unit + probes: `(sort [3 1 2])`, string sort, `java.util.Collections/sort` + smoke | ADR-0172 (L6), O-010, D-515 |
| O-052 | `build.zig` (`exe_mod.unwind_tables`) — **binary-size axis** (ADR-0172 L1) | Release builds carried `__eh_frame` + `__unwind_info` (749 KB, ~8% of the 9,469,816 B shipped binary) for native stack unwinding — but the shipped binary is STRIPPED (O-008), and a stripped Zig binary already prints "stack tracing is disabled" on panic (probed 2026-07-16 with a forced OOB panic, identical output with/without tables), so the tables bought nothing user-visible | `.unwind_tables = if (optimize != .Debug and !profile) .none else null` — mirrors the O-008 strip predicate; Debug + `-Dprofile` keep tables for lldb/Instruments. cljw renders Clojure errors from its own StackFrame stack; arm64 keeps frame pointers for debugger frame-walks | Shipped ReleaseSafe `cljw` **9,469,816 → 8,731,096 B (−738,720 B / −7.8%)**; `__eh_frame`/`__unwind_info` gone from `size -m` | smoke (units + diff oracle + e2e core on the table-less ReleaseSafe binary) + `size_claims` gate + panic-output probe pre/post identical | ADR-0172 (L1), O-008, D-515 |
diff --git a/src/lang/primitive/sequence.zig b/src/lang/primitive/sequence.zig
index c316f4054..40a1d428c 100644
--- a/src/lang/primitive/sequence.zig
+++ b/src/lang/primitive/sequence.zig
@@ -162,13 +162,15 @@ pub fn countFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
});
},
.list, .cons, .lazy_seq => {
- // O(n) generic walk. A `.list` cons may hold a non-list seq as
- // its rest (a "Cons over a seq", e.g. `(cons x (map …))` /
- // `(conj (range 3) 99)`), so the O(1) `.count` field is a lie
- // for mixed chains — walk instead, forcing lazy layers and
- // advancing through whatever tag the rest takes. Pure-list O(1)
- // count is the F-004 finished form once the `.list` / `.cons`
- // tags split (PersistentList vs Cons); see debt D-178.
+ // PERF: a PURE `.list` chain carries an exact O(1) count, so
+ // return it without walking. The walk below stays for the mixed
+ // chain a `.list` cell can head — a "Cons over a seq"
+ // (`(cons x (map …))` / `(conj (range 3) 99)`) — where the stored
+ // count would describe the `.list` prefix only. `exactCountOf`
+ // reports which case this is instead of the caller guessing, so
+ // no D-178 `.list`/`.cons` tag split is needed to get the fast
+ // path. [refs: O-057]
+ if (list.exactCountOf(coll)) |n| return Value.initInteger(@intCast(n));
var n: i64 = 0;
var cur = try lazy_seq.seq(rt, env, coll);
while (!cur.isNil()) {
diff --git a/src/runtime/collection/list.zig b/src/runtime/collection/list.zig
index 9c78a92ab..3610284d8 100644
--- a/src/runtime/collection/list.zig
+++ b/src/runtime/collection/list.zig
@@ -15,6 +15,35 @@ const tag_ops = @import("../gc/tag_ops.zig");
const gc_heap_mod = @import("../gc/gc_heap.zig");
const mark_sweep = @import("../gc/mark_sweep.zig");
+/// Sentinel `count` meaning "this cell's length is NOT known in O(1)".
+///
+/// A `.list` cell may hold a NON-list seq as its `rest` — `(cons 1 (map inc
+/// xs))`, `(conj (range 3) 99)` — in which case the stored count can only
+/// describe the `.list` PREFIX, not the realized length. Storing that prefix
+/// length silently makes the field a lie; storing this sentinel makes the
+/// unknown EXPLICIT, so a reader can take the O(1) path when the chain is a
+/// pure list and walk only when it genuinely must.
+///
+/// Chosen as `maxInt(u32)` so every existing `countOf(v) > 0` non-emptiness
+/// test keeps its meaning: a cons cell always holds at least one element, so
+/// "unknown" is correctly non-empty. `isEmpty`'s `== 0` likewise stays exact.
+pub const COUNT_UNKNOWN: u32 = std.math.maxInt(u32);
+
+/// Stored `count` for a cell prepending one element to `tail`: exact when
+/// `tail` is nil or a pure `.list` with an exact count, `COUNT_UNKNOWN`
+/// otherwise (a non-list seq tail, or a tail that is itself unknown —
+/// unknown-ness is contagious head-ward, which is what keeps the field
+/// honest for the whole chain rather than only at the splice point).
+fn countPrepending(tail: Value) u32 {
+ if (tail.isNil()) return 1;
+ if (tail.tag() != .list) return COUNT_UNKNOWN;
+ const n = tail.decodePtr(*const Cons).count;
+ // `>= COUNT_UNKNOWN - 1` saturates rather than wrapping into a
+ // plausible-looking small count at the u32 boundary.
+ if (n >= COUNT_UNKNOWN - 1) return COUNT_UNKNOWN;
+ return n + 1;
+}
+
/// Cons cell — fundamental list-building block. `extern struct` so
/// declaration order is preserved + HeapHeader lands at offset 0
/// (required by `gc.alloc(T)` per the comptime check).
@@ -24,6 +53,8 @@ pub const Cons = extern struct {
first: Value,
rest: Value,
meta: Value,
+ /// Element count, or `COUNT_UNKNOWN` when the chain leaves `.list`
+ /// (see that constant). Never a prefix length presented as a total.
count: u32,
comptime {
@@ -42,7 +73,7 @@ pub fn cons(alloc: std.mem.Allocator, head: Value, tail: Value) !Value {
.first = head,
.rest = tail,
.meta = .nil_val,
- .count = 1 + countOf(tail),
+ .count = countPrepending(tail),
};
return Value.encodeHeapPtr(.list, cell);
}
@@ -72,7 +103,7 @@ pub fn consHeap(rt: *Runtime, head: Value, tail: Value) !Value {
.first = head,
.rest = tail,
.meta = .nil_val,
- .count = 1 + countOf(tail),
+ .count = countPrepending(tail),
};
return Value.encodeHeapPtr(.list, cell);
}
@@ -146,7 +177,9 @@ pub fn rest(val: Value) Value {
};
}
-/// O(1) length. `0` for non-list inputs.
+/// O(1) length. `0` for non-list inputs. May return `COUNT_UNKNOWN` — every
+/// caller here uses it as a `> 0` / `== 0` emptiness test, which stays exact
+/// under the sentinel. A caller wanting a LENGTH must use `exactCountOf`.
pub fn countOf(val: Value) u32 {
return switch (val.tag()) {
.list => val.decodePtr(*Cons).count,
@@ -154,6 +187,14 @@ pub fn countOf(val: Value) u32 {
};
}
+/// Exact O(1) length, or `null` when the chain leaves `.list` so the realized
+/// length is only discoverable by walking it (see `COUNT_UNKNOWN`).
+pub fn exactCountOf(val: Value) ?u32 {
+ if (val.tag() != .list) return null;
+ const n = val.decodePtr(*const Cons).count;
+ return if (n == COUNT_UNKNOWN) null else n;
+}
+
/// Sequence view: `val` itself if non-empty, otherwise `nil`. Mirrors
/// Clojure's `(seq xs)` — empty colls become `nil`.
pub fn seq(val: Value) Value {
@@ -314,3 +355,36 @@ test "structural sharing across cons calls" {
try testing.expectEqual(@as(u32, 3), countOf(a));
try testing.expectEqual(@as(u32, 3), countOf(b));
}
+
+test "exactCountOf: pure chain exact, non-list tail unknown, unknown-ness propagates head-ward (O-057)" {
+ var arena = std.heap.ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // A pure `.list` chain reports its length in O(1).
+ const pure = try cons(alloc, Value.initInteger(1), try cons(alloc, Value.initInteger(2), .nil_val));
+ try testing.expectEqual(@as(?u32, 2), exactCountOf(pure));
+
+ // A cell over a NON-list tail cannot know its realized length. A vector
+ // stands in for the seq tails the runtime actually splices here
+ // (`(cons x (map …))`, `(conj (range 3) 99)`); what matters is only that
+ // the tail's tag is not `.list`.
+ const seq_tail = Value.initInteger(7); // any non-list, non-nil tail
+ const mixed = try cons(alloc, Value.initInteger(0), seq_tail);
+ try testing.expectEqual(@as(?u32, null), exactCountOf(mixed));
+ try testing.expectEqual(COUNT_UNKNOWN, countOf(mixed));
+
+ // Unknown-ness is contagious toward the head: consing onto an unknown
+ // cell must not resurrect a plausible small count.
+ const over_mixed = try cons(alloc, Value.initInteger(-1), mixed);
+ try testing.expectEqual(@as(?u32, null), exactCountOf(over_mixed));
+
+ // The sentinel keeps every emptiness test exact — a cons cell always
+ // holds at least one element, so "unknown" must read as NON-empty.
+ try testing.expect(!isEmpty(mixed));
+ try testing.expect(countOf(mixed) > 0);
+ try testing.expect(seq(mixed).tag() == .list);
+
+ // A non-list input has no exact count at all (distinct from length 0).
+ try testing.expectEqual(@as(?u32, null), exactCountOf(.nil_val));
+}
diff --git a/test/e2e/list_count_exact.sh b/test/e2e/list_count_exact.sh
new file mode 100755
index 000000000..2c71890e1
--- /dev/null
+++ b/test/e2e/list_count_exact.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# test/e2e/list_count_exact.sh
+#
+# O-057 — (count lst) takes the stored O(1) count for a PURE .list chain and
+# keeps the element walk for a MIXED chain (a .list cell whose rest leaves
+# .list). The behaviour under test is that both answers stay EXACT: the
+# optimisation is only sound because `COUNT_UNKNOWN` marks the chains whose
+# stored count would otherwise be a prefix length presented as a total.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+
+# --- pure .list chains: the O(1) path ---
+assert_eq 'pure_list_literal' "$(run '(count (list 1 2 3))')" '3'
+assert_eq 'pure_quoted' "$(run "(count '(1 2 3 4))")" '4'
+assert_eq 'pure_empty' "$(run '(count (list))')" '0'
+assert_eq 'pure_empty_quoted' "$(run "(count '())")" '0'
+assert_eq 'pure_cons_chain' "$(run '(count (cons 1 (cons 2 (list 3))))')" '3'
+assert_eq 'pure_apply_list' "$(run '(count (apply list (range 1000)))')" '1000'
+assert_eq 'pure_conj_list' "$(run '(count (conj (list 1 2) 0))')" '3'
+
+# --- MIXED chains: a .list cell over a tail that is NOT a .list. The stored
+# count can only describe the .list prefix, so these must still walk. Each
+# one would report the prefix length (1 or 2) if the sentinel were missing.
+assert_eq 'cons_over_lazy_map' "$(run '(count (cons 1 (map inc [1 2 3])))')" '4'
+assert_eq 'cons_over_range' "$(run '(count (conj (range 3) 99))')" '4'
+assert_eq 'cons_over_vec_seq' "$(run '(count (cons 0 (seq [1 2 3])))')" '4'
+assert_eq 'cons_over_lazy_seq' "$(run '(count (cons 1 (lazy-seq [2 3])))')" '3'
+assert_eq 'cons_over_string' "$(run '(count (cons \a (seq "bcd")))')" '4'
+
+# Unknown-ness must propagate head-ward: a second cons onto a mixed chain
+# must not resurrect a small stored count.
+assert_eq 'nested_over_mixed' "$(run '(count (cons 1 (cons 2 (map inc [1 2 3]))))')" '5'
+assert_eq 'deep_over_mixed' "$(run '(count (cons 0 (cons 1 (cons 2 (seq [3 4 5])))))')" '6'
+
+# --- the sentinel must not leak into anything that reads the count field ---
+# Equality walks lists precisely because a Cons count is not trusted for the
+# length short-circuit; that must still hold.
+assert_eq 'equality_mixed_vs_pure' \
+ "$(run '(= (cons 1 (lazy-seq [2 3])) (list 1 2 3))')" 'true'
+assert_eq 'equality_pure_vs_pure' "$(run '(= (list 1 2 3) (list 1 2 3))')" 'true'
+assert_eq 'equality_differing' "$(run '(= (list 1 2) (list 1 2 3))')" 'false'
+
+# Emptiness tests read the same field via `> 0` / `== 0`; a mixed chain is
+# non-empty, and `seq` of one is itself.
+assert_eq 'mixed_is_not_empty' "$(run '(empty? (cons 1 (map inc [1 2])))')" 'false'
+assert_eq 'mixed_seq_is_itself' "$(run '(count (seq (cons 1 (map inc [1 2]))))')" '3'
+assert_eq 'mixed_first_rest' "$(run '(let [c (cons 1 (map inc [1 2]))] [(first c) (count (rest c))])')" '[1 2]'
+
+# with-meta shares the chain and must carry the same count classification.
+assert_eq 'with_meta_pure' "$(run "(count (with-meta '(1 2 3) {:a 1}))")" '3'
+assert_eq 'with_meta_mixed' "$(run '(count (with-meta (cons 1 (map inc [1 2])) {:a 1}))')" '3'
+
+echo "OK test/e2e/list_count_exact.sh"
diff --git a/test/run_all.sh b/test/run_all.sh
index ff6c7af50..5c56746bf 100644
--- a/test/run_all.sh
+++ b/test/run_all.sh
@@ -780,6 +780,7 @@ run_step "e2e_phase14_deftype_transient_family" "bash test/e2e/phase14_deftype_t
run_step "e2e_phase14_print_method" "bash test/e2e/phase14_print_method.sh"
run_step "e2e_phase14_member_on_native" "bash test/e2e/phase14_member_on_native.sh"
run_step "e2e_phase14_java_method_grouping" "bash test/e2e/phase14_java_method_grouping.sh"
+run_step "e2e_list_count_exact" "bash test/e2e/list_count_exact.sh"
run_step "e2e_phase14_fn_prepost" "bash test/e2e/phase14_fn_prepost.sh"
run_step "e2e_phase14_deftype_ideref" "bash test/e2e/phase14_deftype_ideref.sh"
run_step "e2e_phase14_equality" "bash test/e2e/phase14_equality.sh"
From 419cef45f6c28d9a74f62883d42c6b900b9903c9 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 12:50:55 -0300
Subject: [PATCH 051/466] feat(require): resolve .cljw as a source extension
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`require` probed only `.clj`/`.cljc`, so a namespace whose ClojureWasm
implementation differs structurally from the JVM one had nowhere to live:
cljw code and JVM code were the same files under the same names, and
nothing — runtime, editor or indexer — could tell them apart.
Probe `.cljw` first, then `.clj`, then `.cljc`. A `foo/bar.cljw` beside a
`foo/bar.clj` is now the file cljw loads, the same precedence ClojureScript
gives `.cljs` over `.cljc`. Root order still dominates: an earlier classpath
root's `.clj` beats a later root's `.cljw`.
The order is the contract, so it is a named `search_exts` const pinned by a
unit test — a reordering is otherwise silent, since only a project carrying
two of the three extensions can observe it.
Backward compatible: the list only grows and nothing in the wild ships a
`.cljw`, so every existing project resolves the file it resolved before.
This also closes an inconsistency — `cljw foo.cljw` already ran as a script,
because the positional path never inspected the extension.
ADR-0189. e2e phase15_require_fs: 6 -> 11 cases.
---
.dev/decisions/0189_cljw_require_extension.md | 158 ++++++++++++++++++
docs/works/README.md | 8 +-
src/app/cli.zig | 3 +-
src/lang/require_resolver.zig | 32 +++-
test/e2e/phase15_require_fs.sh | 28 +++-
5 files changed, 219 insertions(+), 10 deletions(-)
create mode 100644 .dev/decisions/0189_cljw_require_extension.md
diff --git a/.dev/decisions/0189_cljw_require_extension.md b/.dev/decisions/0189_cljw_require_extension.md
new file mode 100644
index 000000000..08d535a98
--- /dev/null
+++ b/.dev/decisions/0189_cljw_require_extension.md
@@ -0,0 +1,158 @@
+# 0189 — `.cljw` is a source extension `require` resolves
+
+- **Status**: Proposed
+- **Date**: 2026-08-14
+- **Author**: BuddhiLW
+- **Tags**: require, classpath, dialects, cljc, conformance
+
+## Context
+
+ADR-0188 gave cljw a *name* inside a portable file: `{:cljw, :clj, :default}`
+lets a `.cljc` say "on this runtime, the answer is different". That closed the
+expression gap at the level of a **form**.
+
+The gap it did not close is at the level of a **file**. `filesystemResolver`
+(ADR-0084, D-158) probed exactly two extensions:
+
+```zig
+const exts = [_][]const u8{ ".clj", ".cljc" };
+```
+
+so a namespace whose cljw implementation differs *structurally* from the JVM
+one — different requires, different deftypes, a different set of top-level
+forms — has nowhere to live. The author's only options are:
+
+1. Put everything in one `.cljc` and reader-condition the whole file. This is
+ what `#?` is for at the granularity of an expression; at the granularity of
+ a file it produces a source where the two runtimes' code is interleaved
+ line by line and neither reads as a program.
+2. Ship a `.clj` and let cljw load it. That is what happens today, and it is
+ the reason cljw is currently indistinguishable from Clojure at the
+ filesystem layer: **cljw code and JVM code are the same files with the same
+ names**, so nothing — not the runtime, not an editor, not an indexer —
+ can tell which runtime a given source was written for.
+
+Every other dialect in the family already resolves its own extension:
+ClojureScript probes `.cljs` then `.cljc`; Basilisp `.lpy`; Babashka `.bb`;
+ClojureCLR `.cljr`; clojurust `.cljrs`. cljw was the only one whose "native"
+sources were spelled with another runtime's extension.
+
+The measured state before this decision (cljw 1.10.2, 2026-08-14):
+
+```
+$ cljw -cp . main.clj # probe/lib.cljw on the classpath
+cljw FAILED: Could not locate 'probe.lib' on the require resolver
+$ cljw script.cljw # positional script path
+:script-ran-from :cljw-ext # already worked — no extension check there
+```
+
+The positional-script path never inspected the extension, so `cljw foo.cljw`
+already ran. Only `require` was closed, which is the inconsistency this
+decision removes.
+
+## Decision
+
+`require` probes three extensions at each classpath root, in this order:
+
+```zig
+pub const search_exts = [_][]const u8{ ".cljw", ".clj", ".cljc" };
+```
+
+**`.cljw` is probed first.** A `foo/bar.cljw` beside a `foo/bar.clj` is the
+file cljw loads. This is the same precedence ClojureScript gives `.cljs` over
+`.cljc`: the dialect-native extension is by construction the more specific
+statement about *this* runtime, so it wins where both exist.
+
+**Root order still dominates extension order.** The search is root-major —
+for each root in `load_paths`, try each extension — so an earlier classpath
+root holding `bar.clj` wins over a later root holding `bar.cljw`. That is JVM
+classpath semantics (first root wins), and extension precedence is a
+within-root tiebreak, never a cross-root one.
+
+**The order is the contract**, so it is pinned by name (`search_exts`) and
+asserted directly, not only end-to-end. A reordering is otherwise silent:
+every project carrying only one of the three extensions resolves identically
+under any order, and only a project carrying two can observe the difference.
+
+Backward compatibility is total by the same argument ADR-0188 made: the list
+only grows, and nothing in the wild currently ships a `.cljw` file, so every
+existing project resolves the exact file it resolved before.
+
+## Alternatives considered
+
+### Alternative A — leave it at `{.clj, .cljc}`
+
+- **Sketch**: cljw keeps loading `.clj`. Structural divergence is expressed by
+ reader-conditioning a `.cljc`, or by shipping a separate namespace name
+ (`foo.bar-cljw`).
+- **Why rejected**: a whole-file `#?` split is not a readable program, and a
+ separate *namespace* is worse than a separate *file* — it changes the name
+ callers must write, so every consumer becomes runtime-aware. The point of a
+ per-dialect extension is that the namespace stays `foo.bar` and only the
+ implementation swaps. It also leaves cljw as the only dialect in the family
+ with no extension of its own, which is the observation that prompted this.
+
+### Alternative B — `.cljw` probed LAST (after `.cljc`)
+
+- **Sketch**: same three extensions, order `{.clj, .cljc, .cljw}`, so `.cljw`
+ is a fallback rather than an override.
+- **Why rejected**: it inverts what the extension means. `.cljw` would then
+ only ever load when no portable or JVM source exists — i.e. exactly when the
+ override is *not* needed. An override that loses to the thing it overrides
+ cannot express "on cljw, use this instead", which is the entire use case.
+
+### Alternative C — `.cljw` REPLACES `.clj` in the probe list
+
+- **Sketch**: cljw stops reading `.clj` at all; `{.cljw, .cljc}`.
+- **Why rejected**: it would break every library cljw currently loads. The
+ `docs/works/ladder.md` corpus is `.clj` and `.cljc`; refusing `.clj` would
+ un-load all of it overnight. This is the file-level twin of ADR-0188's
+ rejected Alternative B, and fails for the same reason: cljw genuinely is a
+ Clojure runtime and should keep reading Clojure.
+
+### Alternative D — make the extension list CLI-configurable
+
+- **Sketch**: `--source-exts cljw,clj,cljc`.
+- **Why rejected**: speculative generality, and it makes a project's meaning
+ depend on an invocation flag — the same tree would resolve to different
+ files under different commands. Same objection as ADR-0188's Alternative C,
+ and it can be added later additively if a real second consumer appears.
+
+## Consequences
+
+- **Positive**: a namespace can carry a cljw-specific implementation under its
+ own extension, with the namespace name unchanged, so consumers need not know
+ which runtime they are on.
+- **Positive**: cljw sources become *identifiable*. An indexer, an editor mode
+ or a build can tell cljw code from JVM code by path, which is what makes
+ per-dialect tooling (carto lang provenance, per-dialect source roots)
+ possible at all.
+- **Positive**: closes the inconsistency where `cljw foo.cljw` ran as a script
+ but `(require 'foo)` could not find the same file.
+- **Negative**: a project may now carry `bar.cljw` and `bar.clj` that drift
+ apart, with cljw silently loading one and clj the other. That is inherent to
+ per-dialect extensions (cljs has had it since 2011); the mitigation is the
+ same one cljs uses — prefer `.cljc` with `#?` for anything that can be
+ shared, and reach for `.cljw` only for genuine structural divergence.
+- **Neutral**: no effect on any existing project. Nothing in the wild ships a
+ `.cljw` file, and both `.clj` and `.cljc` keep their relative order.
+
+## Affected files
+
+- `src/lang/require_resolver.zig` — `search_exts` (new named pub const, was an
+ inline two-element array) + the module and fn doc comments; one unit test
+ pinning the probe order.
+- `src/app/cli.zig` — `-cp` help text now names the three extensions in order.
+- `test/e2e/phase15_require_fs.sh` — `.cljw` resolution, `.cljw`-beats-`.clj`,
+ `.clj`-still-beats-`.cljc`, cross-extension require, and root-order-beats-
+ extension-order (11 cases, was 6).
+- `docs/works/README.md` — the classpath paragraph states the three-extension
+ probe order.
+
+## References
+
+- ADR-0084 / D-158 — the filesystem require resolver this extends.
+- ADR-0188 — `:cljw` as a reader-conditional feature; the form-level twin of
+ this file-level decision. The backward-compatibility argument is the same.
+- ClojureScript's `.cljs` > `.cljc` resolution — the precedent for probing the
+ dialect-native extension first.
diff --git a/docs/works/README.md b/docs/works/README.md
index ab08d4a0c..4af2bba04 100644
--- a/docs/works/README.md
+++ b/docs/works/README.md
@@ -51,8 +51,12 @@ cd consumer-proj && cljw -e '(require (quote clojure.data.priority-map))'
```
The older manual classpath still works: `-cp ` (alias `--classpath`,
-colon-separated) makes `require` search each root for `.clj` then
-`.cljc` (ADR-0084); `$CLJW_PATH` is the fallback when `-cp` is absent.
+colon-separated) makes `require` search each root for `.cljw`, then
+`.clj`, then `.cljc` (ADR-0084, ADR-0189); `$CLJW_PATH` is the fallback when
+`-cp` is absent. `.cljw` is cljw's own extension and is probed first, so a
+`foo/bar.cljw` beside a `foo/bar.clj` is the file cljw loads — the same
+precedence ClojureScript gives `.cljs` over `.cljc`. Roots are still searched
+in order, so an earlier root's `.clj` beats a later root's `.cljw`.
**The residual limitation is Maven, not deps.edn:** a `:mvn/version` coord is
**skipped** (ADR-0101 am.1), not fetched — cljw has no artifact downloader — so
diff --git a/src/app/cli.zig b/src/app/cli.zig
index 5295a41c8..e4f8aab86 100644
--- a/src/app/cli.zig
+++ b/src/app/cli.zig
@@ -333,7 +333,8 @@ fn dispatchArgsRest(
\\ Run the named source file as a script (no result echo).
\\ - Run stdin as a script (no result echo; heredoc-friendly).
\\ -cp, --classpath Colon-separated dirs `require` searches
- \\ for `.clj`/`.cljc` libs (else $CLJW_PATH, else ".").
+ \\ for `.cljw`/`.clj`/`.cljc` libs, in that order
+ \\ (else $CLJW_PATH, else ".").
\\ -A:a1:a2 Select deps.edn aliases (their :extra-paths /
\\ :extra-deps join the classpath).
\\ -M[:a] [main-opts] Run mode: the alias :main-opts + your args via
diff --git a/src/lang/require_resolver.zig b/src/lang/require_resolver.zig
index 33d57ae84..11059d9cb 100644
--- a/src/lang/require_resolver.zig
+++ b/src/lang/require_resolver.zig
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: EPL-2.0
//! Filesystem + chained require resolvers (ADR-0084, D-158).
//!
-//! `filesystemResolver` maps a namespace symbol to a `.clj`/`.cljc` file on the
-//! `rt.load_paths` classpath (`foo.bar-baz` → `foo/bar_baz.clj`, JVM munge) and
+//! `filesystemResolver` maps a namespace symbol to a `.cljw`/`.clj`/`.cljc` file
+//! on the `rt.load_paths` classpath (`foo.bar-baz` → `foo/bar_baz.clj`, JVM munge) and
//! reads its source. `chainedResolver` tries the bootstrap-embedded resolver
//! FIRST (so clojure.core / clojure.test can never be shadowed by a stray
//! on-disk file), then the filesystem. The CLI installs `chainedResolver` for
@@ -57,16 +57,24 @@ fn mungeNsToPath(arena: std.mem.Allocator, ns_name: []const u8) !?[]u8 {
return buf;
}
+/// Source extensions `require` probes at each classpath root, in probe order
+/// (ADR-0189). `.cljw` is cljw's own extension and is probed FIRST, so a
+/// `foo/bar.cljw` beside a `foo/bar.clj` is the file cljw loads; `.clj` and
+/// `.cljc` follow, unchanged. First hit wins — the search stops at the first
+/// readable file, and a later extension is never consulted for that root.
+pub const search_exts = [_][]const u8{ ".cljw", ".clj", ".cljc" };
+
/// Resolve `ns_name` to source by searching `rt.load_paths` for
-/// `/.clj` then `.cljc`. Returns null when no file is found (so the
-/// chain / caller maps to `lib_not_found`); raises `lib_load_failed` when a file
-/// exists but cannot be read. Source + label live in `rt.load_arena`.
+/// `/` + each extension in `search_exts`, in that order. Returns
+/// null when no file is found (so the chain / caller maps to `lib_not_found`);
+/// raises `lib_load_failed` when a file exists but cannot be read. Source +
+/// label live in `rt.load_arena`.
pub fn filesystemResolver(rt: *Runtime, ns_name: []const u8) anyerror!?ResolvedSource {
const arena = rt.load_arena.allocator();
// An empty-segment name resolves to nothing, like clj's classloader — the
// caller maps null to `lib_not_found`.
const rel = (try mungeNsToPath(arena, ns_name)) orelse return null;
- const exts = [_][]const u8{ ".clj", ".cljc" };
+ const exts = search_exts;
for (rt.load_paths) |dir| {
for (exts) |ext| {
const path = try std.fmt.allocPrint(arena, "{s}/{s}{s}", .{ dir, rel, ext });
@@ -128,3 +136,15 @@ test "mungeNsToPath refuses an empty namespace segment (D-343)" {
try testing.expectEqualStrings("demo/math", (try mungeNsToPath(a, "demo.math")).?);
try testing.expectEqualStrings("demo__math", (try mungeNsToPath(a, "demo--math")).?);
}
+
+test "search_exts probes .cljw before .clj before .cljc (ADR-0189)" {
+ // Order IS the contract: the resolver stops at the first readable file, so
+ // a `.cljw` beside a `.clj` must be the one that loads. Pinned here rather
+ // than only end-to-end because a reordering is silent — both orders resolve
+ // every single-extension project identically, and only a project carrying
+ // BOTH files can observe the difference.
+ try testing.expectEqual(@as(usize, 3), search_exts.len);
+ try testing.expectEqualStrings(".cljw", search_exts[0]);
+ try testing.expectEqualStrings(".clj", search_exts[1]);
+ try testing.expectEqualStrings(".cljc", search_exts[2]);
+}
diff --git a/test/e2e/phase15_require_fs.sh b/test/e2e/phase15_require_fs.sh
index dd901ff92..a77a30893 100755
--- a/test/e2e/phase15_require_fs.sh
+++ b/test/e2e/phase15_require_fs.sh
@@ -34,4 +34,30 @@ printf '(ns cyc.b (:require [cyc.a]))\n' > "$CYC/cyc/b.clj"
assert_eq 'cycle' "$("$BIN" -cp "$CYC" -e '(require (quote cyc.a))' 2>&1 | grep -c 'Cyclic load dependency')" '2'
rm -rf "$CYC"
-echo "OK — phase15_require_fs (6 cases) green"
+# --- .cljw extension resolution (ADR-0189) ---
+EXT="$(mktemp -d)"; mkdir -p "$EXT/ext"
+# a lone .cljw lib resolves
+printf '(ns ext.only) (def who :from-cljw)\n' > "$EXT/ext/only.cljw"
+assert_eq 'cljw-only' "$("$BIN" -cp "$EXT" -e '(require (quote ext.only)) ext.only/who' 2>&1 | tail -1)" ':from-cljw'
+# .cljw WINS over a sibling .clj and .cljc at the same root (probe order)
+printf '(ns ext.both) (def who :from-cljw)\n' > "$EXT/ext/both.cljw"
+printf '(ns ext.both) (def who :from-clj)\n' > "$EXT/ext/both.clj"
+printf '(ns ext.both) (def who :from-cljc)\n' > "$EXT/ext/both.cljc"
+assert_eq 'cljw-wins' "$("$BIN" -cp "$EXT" -e '(require (quote ext.both)) ext.both/who' 2>&1 | tail -1)" ':from-cljw'
+# .clj still wins over .cljc when no .cljw is present (no regression)
+printf '(ns ext.pair) (def who :from-clj)\n' > "$EXT/ext/pair.clj"
+printf '(ns ext.pair) (def who :from-cljc)\n' > "$EXT/ext/pair.cljc"
+assert_eq 'clj-over-cljc' "$("$BIN" -cp "$EXT" -e '(require (quote ext.pair)) ext.pair/who' 2>&1 | tail -1)" ':from-clj'
+# a .cljw lib can require a .clj lib and vice versa — the extension is not a barrier
+printf '(ns ext.mixed (:require [ext.pair :as p])) (def who p/who)\n' > "$EXT/ext/mixed.cljw"
+assert_eq 'cljw-requires-clj' "$("$BIN" -cp "$EXT" -e '(require (quote ext.mixed)) ext.mixed/who' 2>&1 | tail -1)" ':from-clj'
+# an EARLIER classpath root wins even when a later root holds the .cljw —
+# roots are searched outermost-first (JVM classpath semantics), extension
+# order applies WITHIN a root, never across them.
+EXT2="$(mktemp -d)"; mkdir -p "$EXT2/ext"
+printf '(ns ext.rooted) (def who :from-root2-cljw)\n' > "$EXT2/ext/rooted.cljw"
+printf '(ns ext.rooted) (def who :from-root1-clj)\n' > "$EXT/ext/rooted.clj"
+assert_eq 'root-beats-ext' "$("$BIN" -cp "$EXT:$EXT2" -e '(require (quote ext.rooted)) ext.rooted/who' 2>&1 | tail -1)" ':from-root1-clj'
+rm -rf "$EXT" "$EXT2"
+
+echo "OK — phase15_require_fs (11 cases) green"
From ae029ddda25f11636f35b0aa0ff81219a5f72080 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 12:57:16 -0300
Subject: [PATCH 052/466] feat(java): java.util.concurrent.Future's method
names over cljw's futures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`(future …)` already answered "Future" to `class` and already had every semantic
java.util.concurrent.Future needs — @f, realized?, future-cancel,
future-cancelled? — but no Java spelling resolved: `.get`, `.isDone`, `.cancel`
and `.isCancelled` all raised "No implementation of method 'X' on protocol
'<.member>' for type 'Future'". So this is a method-name adapter over existing
machinery, not new concurrency code.
runtime/java/util/concurrent/Future.zig installs the four on the `.future`
native descriptor — the installNativeMethods path Throwable.zig uses to put
.getMessage on .ex_info, and the no-___HOST_EXTENSION shape _atomic.zig
already establishes for a file with no static surface of its own. Each method
delegates to the primitive rather than reading the Future cell, so `.get` and
`@` cannot drift apart: a cancelled future still throws CancellationException,
and a failed one still re-raises the worker's MARSHALLED error (ADR-0120)
instead of a second set of answers invented for the Java spelling.
The timed arity is the one place it must NOT reuse cljw's deref. JVM
Future.get(timeout, unit) THROWS TimeoutException; cljw's 3-arity deref RETURNS
a caller default. Code that catches TimeoutException to decide whether to cancel
would read a returned default as success, so the timed arity mints one via
ex_info.allocException + worker_error.reraise. The eval budget still bounds the
wait (deadline = min(caller, budget)) and a still-pending future reports the
budget first, since that one is uncatchable.
Future also joins the reify-able host interfaces (both spellings), for the
separate case of a library synthesising an already-completed Future rather than
minting a worker — the caller-runs fallback a shut-down pool returns.
AD-065: mayInterruptIfRunning is accepted and IGNORED. cljw cancellation is
cooperative (D-442 / ADR-0153) — there is no queue-vs-running distinction to
honour and no interrupt to withhold, so respecting only one of the two values
would make `false` silently mean something it does not.
Also adds RejectedExecutionException to the known-exception table (a
RuntimeException, as on the JVM) so `(catch RejectedExecutionException …)`
compiles. Caught-but-never-thrown for now, like InterruptedException /
TimeoutException / ExecutionException — cljw has no pool that rejects yet.
Measured against hive-weave, one process per namespace: hive-weave.pool now
LOADS (it was failing at line 184's `(reify Future …)`, then at line 207's
catch), and hive-weave.guarded moves off both onto Executors. The library goes
7/16 -> 8/16 namespaces loading; every remaining surface failure is now the
single missing symbol java.util.concurrent.Executors.
test/e2e/future_methods.sh — 22 assertions: .get agrees with @ on value,
cancellation and a thrown body; .isDone is realized? including after cancel;
.cancel reports truthfully on a second call and on an already-realised future;
the timed arity throws TimeoutException while 3-arity deref still returns its
default; and both reify spellings dispatch.
Full gate: 442 passed, 0 failed.
---
.dev/accepted_divergences.yaml | 23 +++
CHANGELOG.md | 35 +++++
data/host_interfaces.yaml | 18 +++
src/lang/primitive.zig | 6 +
src/runtime/error/host_class.zig | 6 +
src/runtime/host_interface.zig | 7 +
src/runtime/java/util/concurrent/Future.zig | 156 ++++++++++++++++++++
test/e2e/future_methods.sh | 119 +++++++++++++++
test/run_all.sh | 1 +
9 files changed, 371 insertions(+)
create mode 100644 src/runtime/java/util/concurrent/Future.zig
create mode 100755 test/e2e/future_methods.sh
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index ae7618a90..1a56e44bd 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -1041,3 +1041,26 @@ accepted:
example: '(let [q (java.util.concurrent.LinkedBlockingQueue. 8)] (.offer q 1) (.drainTo q [])) => cljw [1] / clj 1, with the element pushed into the (mutable) target collection.'
derives_from: "ADR-0059 (no JVM) + cljw persistent collections (no mutable drain target)."
pin: "test/e2e/linked_blocking_queue.sh — drainTo_returns_and_empties."
+ - id: "AD-065"
+ area: "concurrency"
+ summary: |-
+ `(.cancel f mayInterruptIfRunning)` ACCEPTS the flag and IGNORES it: both
+ `true` and `false` behave as the JVM's `cancel(true)` would, and the
+ 1-arity `(.cancel f)` is accepted too.
+ cljw's cancellation is cooperative (D-442 / ADR-0153): `future-cancel`
+ moves a still-pending future to `.cancelled` and wakes it, and a blocking
+ primitive inside the thunk (`Thread/sleep`, a Semaphore wait) polls
+ `cancelRequested` and aborts. A thunk in a tight CPU loop with no blocking
+ point runs to completion either way — which is also what the JVM's
+ `cancel(true)` does in practice, since it interrupts rather than kills.
+ So the flag's JVM meaning ("may I interrupt a RUNNING task, or only
+ cancel a queued one?") has no distinct implementation here: there is no
+ queue-vs-running distinction to honour and no interrupt to withhold.
+ Ignoring it is more honest than picking one of the two values to respect,
+ which would make `false` silently mean something it does not.
+ `.cancel` still reports truthfully: it returns true only when THIS call is
+ the one that cancelled a still-pending future, false for a future that
+ already realised or was already cancelled — the JVM's contract.
+ example: '(let [f (future (Thread/sleep 5000))] [(.cancel f false) (.isCancelled f)]) => cljw [true true] / clj [false false] — the JVM refuses to cancel a running task when the flag is false.'
+ derives_from: "D-442 / ADR-0153 (cooperative cancellation) + ADR-0059 (no JVM thread interrupt)."
+ pin: "test/e2e/future_methods.sh — cancel_flag_is_ignored_ad065."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc42e979e..01b9868d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,41 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
### Added
+- **`java.util.concurrent.Future`'s method names on cljw's own futures.** `.get`
+ (`[]` / `[timeout unit]`), `.isDone`, `.isCancelled` and `.cancel`
+ (`[]` / `[mayInterruptIfRunning]`) now resolve on the value `(future …)`
+ returns — which already answered `Future` to `class` and already had every
+ semantic behind `deref` / `realized?` / `future-cancel` / `future-cancelled?`.
+ Each method delegates to that existing primitive, so `.get` and `@` cannot
+ disagree: a cancelled future still throws `CancellationException` and a failed
+ one still re-raises the worker's marshalled error (ADR-0120) rather than a
+ second set of answers invented for the Java spelling.
+
+ The timed arity is the one deliberate departure from cljw's own `deref`:
+ `(.get f timeout unit)` **throws `java.util.concurrent.TimeoutException`**
+ where the 3-arity `deref` returns a caller-supplied default, because code that
+ catches TimeoutException to decide whether to cancel would read a returned
+ default as success. The eval budget still bounds the wait, and a future still
+ pending at the budget's deadline reports the budget instead.
+
+ `Future` also joins the reify-able host interfaces (both the qualified and the
+ bare post-`:import` spelling), for libraries that synthesise an
+ already-completed Future rather than minting a worker.
+
+ `mayInterruptIfRunning` is accepted and ignored (AD-065): cljw's cancellation
+ is cooperative, so there is no queue-vs-running distinction to honour.
+
+- **`java.util.concurrent.RejectedExecutionException` is a known exception
+ type**, so `(catch RejectedExecutionException …)` compiles. A `RuntimeException`
+ like on the JVM. Caught-but-never-thrown for now, joining `InterruptedException`
+ / `TimeoutException` / `ExecutionException` in that family — cljw has no pool
+ that rejects a submission yet.
+
+ Both found by driving [hive-weave](https://github.com/hive-agi/hive-weave)
+ through cljw: `hive-weave.pool` — a bounded pool with caller-runs fallback,
+ binding-conveying `submit!` and a synthetic Future for work that ran on the
+ caller thread — now loads on it.
+
- **`java.util.concurrent.Semaphore`.** Permits live in one atomic counter and
move by CAS: `` (`n` / `n fair?`), `.acquire` (`[n]`), `.tryAcquire`
(`[n]`, `[timeout unit]`, `[n timeout unit]`), `.release` (`[n]`),
diff --git a/data/host_interfaces.yaml b/data/host_interfaces.yaml
index 96c2c37e1..f4681e630 100644
--- a/data/host_interfaces.yaml
+++ b/data/host_interfaces.yaml
@@ -87,6 +87,24 @@ interfaces:
that reifies Runnable explicitly load and dispatch.
methods:
run: { status: wired, wires_to: "reify method dispatch — (.run r) resolves through the reify's own method_table" }
+ - name: java.util.concurrent.Future
+ aliases: [Future]
+ derives_from: "java.util.concurrent.Future — the JDK's handle on a pending result; libraries reify it to hand back an already-computed value through the same type a pool returns (java.util.concurrent, stable since 1.5)."
+ tier: A
+ recognised: true
+ note: >-
+ cljw's native (future …) value already answers these four methods — see
+ runtime/java/util/concurrent/Future.zig, which installs them on the
+ `.future` descriptor. This row is the separate REIFY seam: a library that
+ must synthesise a Future for an already-known value (hive-weave.pool's
+ rejected-fallback future, returned when the pool is shut down and the work
+ ran on the caller thread) reifies the interface instead of minting a real
+ worker thread.
+ methods:
+ get: { status: wired, wires_to: "reify method dispatch — (.get f) resolves through the reify's own method_table; on a NATIVE future it is deref's future arm instead" }
+ cancel: { status: wired, wires_to: "reify method dispatch — (.cancel f flag); on a native future it is future-cancel" }
+ isDone: { status: wired, wires_to: "reify method dispatch — (.isDone f); on a native future it is realized?" }
+ isCancelled: { status: wired, wires_to: "reify method dispatch — (.isCancelled f); on a native future it is future-cancelled?" }
# --- clojure.lang.* deftype/reify supertype family (the closed set; 網羅) ---
# recognised: false → enumerated as the planned closed set, NOT yet in the
diff --git a/src/lang/primitive.zig b/src/lang/primitive.zig
index 857408fa1..abfef5200 100644
--- a/src/lang/primitive.zig
+++ b/src/lang/primitive.zig
@@ -164,6 +164,12 @@ pub fn registerAll(env: *Env) !void {
// `.char` instance methods (charValue / compareTo) — the
// java.lang.Character instance surface.
try @import("../runtime/java/lang/Character.zig").installNativeMethods(env.rt);
+ // java.util.concurrent.Future method NAMES (.get/.isDone/.isCancelled/
+ // .cancel) on the `.future` value — the semantics already existed as
+ // deref / realized? / future-cancelled? / future-cancel, so a library
+ // holding a `^Future` (hive-weave.pool, .guarded) reaches them by their
+ // Java spelling instead of only the Clojure one.
+ try @import("../runtime/java/util/concurrent/Future.zig").installNativeMethods(env.rt);
// ADR-0171: `__`-prefixed kernel helpers leave clojure.core for
// cljw.internal BEFORE the user refer + printer index, so neither
diff --git a/src/runtime/error/host_class.zig b/src/runtime/error/host_class.zig
index 2effb4b70..a50d0e835 100644
--- a/src/runtime/error/host_class.zig
+++ b/src/runtime/error/host_class.zig
@@ -105,6 +105,11 @@ pub const ENTRIES = [_]Entry{
.{ .name = "IllegalStateException", .parent = "RuntimeException" },
// java.util.concurrent.CancellationException ⊂ IllegalStateException (D-442).
.{ .name = "CancellationException", .parent = "IllegalStateException" },
+ // java.util.concurrent.RejectedExecutionException ⊂ RuntimeException — what a
+ // saturated or shut-down pool throws on submit. Caught (never thrown yet) by
+ // portable code that falls back to running the work on the caller thread;
+ // hive-weave.pool guards its `.submit` with it.
+ .{ .name = "RejectedExecutionException", .parent = "RuntimeException" },
.{ .name = "IndexOutOfBoundsException", .parent = "RuntimeException" },
.{ .name = "NullPointerException", .parent = "RuntimeException" },
.{ .name = "UnsupportedOperationException", .parent = "RuntimeException" },
@@ -131,6 +136,7 @@ const FQCN_MAP = std.StaticStringMap([]const u8).initComptime(.{
.{ "java.lang.NumberFormatException", "NumberFormatException" },
.{ "java.lang.IllegalStateException", "IllegalStateException" },
.{ "java.util.concurrent.CancellationException", "CancellationException" },
+ .{ "java.util.concurrent.RejectedExecutionException", "RejectedExecutionException" },
.{ "java.lang.InterruptedException", "InterruptedException" },
.{ "java.util.concurrent.TimeoutException", "TimeoutException" },
.{ "java.util.concurrent.ExecutionException", "ExecutionException" },
diff --git a/src/runtime/host_interface.zig b/src/runtime/host_interface.zig
index 8519cb2ed..a2b7bce6e 100644
--- a/src/runtime/host_interface.zig
+++ b/src/runtime/host_interface.zig
@@ -104,6 +104,11 @@ const JAVA_IO_CLOSEABLE: HostInterface = .{ .kind = .host_inert, .canonical = "j
const THREAD_FACTORY: HostInterface = .{ .kind = .method_family, .canonical = "java.util.concurrent.ThreadFactory", .wired_methods = &.{"newThread"} };
const CALLABLE: HostInterface = .{ .kind = .method_family, .canonical = "java.util.concurrent.Callable", .wired_methods = &.{"call"} };
const RUNNABLE: HostInterface = .{ .kind = .method_family, .canonical = "java.lang.Runnable", .wired_methods = &.{"run"} };
+// Future is BOTH a native value and a reify target. `(future …)` answers these
+// four already (java/util/concurrent/Future.zig installs them on the `.future`
+// descriptor); this row covers the other direction — a library synthesising a
+// Future for an already-computed value, which cannot be a real worker thread.
+const FUTURE: HostInterface = .{ .kind = .method_family, .canonical = "java.util.concurrent.Future", .wired_methods = &.{ "get", "cancel", "isDone", "isCancelled" } };
// protocol_remap interfaces (D-280b+): the macro rewrites each declared method to
// its cljw (protocol, method) target. ILookup's valAt → ILookup/-lookup (a 3-arity
@@ -483,6 +488,8 @@ const MARKERS = std.StaticStringMap(HostInterface).initComptime(.{
.{ "Callable", CALLABLE },
.{ "java.lang.Runnable", RUNNABLE },
.{ "Runnable", RUNNABLE },
+ .{ "java.util.concurrent.Future", FUTURE },
+ .{ "Future", FUTURE },
// D-416 bare aliases (data.finger-tree declares these bare) — see the D-306
// collection-base note below.
.{ "ILookup", ILOOKUP },
diff --git a/src/runtime/java/util/concurrent/Future.zig b/src/runtime/java/util/concurrent/Future.zig
new file mode 100644
index 000000000..c0a97d17a
--- /dev/null
+++ b/src/runtime/java/util/concurrent/Future.zig
@@ -0,0 +1,156 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Java surface for `java.util.concurrent.Future` — the Java METHOD NAMES over
+//! cljw's native `.future`, which already has every semantic they need.
+//!
+//! Backend: impl-only
+//! Impl deps: future, ex_info, worker_error, eval_budget, clock, TimeUnit
+//! Clojure peer: clojure.core/deref, future-done?, future-cancel, future-cancelled?
+//!
+//! Surface: `.get` (`[]` | `[timeout unit]`), `.isDone`, `.isCancelled`,
+//! `.cancel` (`[]` | `[mayInterruptIfRunning]`).
+//!
+//! No `___HOST_EXTENSION` and NOT on `_host_api.zig`'s `java_surfaces` list:
+//! there is no `Future/…` static surface and no separate instance type to
+//! register. `(future …)` already answers `Future` to `class`, so this file
+//! only populates that native descriptor's method table — the `_atomic.zig`
+//! shape, and the same `installNativeMethods` path `Throwable.zig` uses to put
+//! `.getMessage` on `.ex_info`.
+//!
+//! Every method here maps onto an existing primitive rather than reaching into
+//! the Future cell: `.get` is `deref`'s future arm (so a cancelled future still
+//! throws CancellationException and a failed one still re-raises the worker's
+//! MARSHALLED error per ADR-0120, instead of this file inventing a second set
+//! of answers), `.isDone` is `realized?`, `.cancel` is `future-cancel`.
+//!
+//! Divergence AD-065: `.cancel`'s `mayInterruptIfRunning` argument is ACCEPTED
+//! and IGNORED. cljw's cancellation is cooperative (D-442 / ADR-0153) — a
+//! blocking primitive polls `cancelRequested` and aborts; a thunk in a tight
+//! CPU loop runs to completion either way. So `false` cannot mean "let it
+//! finish but report cancelled" the way the JVM's flag does, and honouring only
+//! one of the two values would be a worse lie than ignoring it.
+
+const std = @import("std");
+const Value = @import("../../../value/value.zig").Value;
+const Runtime = @import("../../../runtime.zig").Runtime;
+const Env = @import("../../../env.zig").Env;
+const SourceLocation = @import("../../../error/info.zig").SourceLocation;
+const error_catalog = @import("../../../error/catalog.zig");
+const type_descriptor = @import("../../../type_descriptor.zig");
+const future_mod = @import("../../../future.zig");
+const ex_info = @import("../../../collection/ex_info.zig");
+const worker_error = @import("../../../concurrency/worker_error.zig");
+const eval_budget = @import("../../../concurrency/eval_budget.zig");
+const clock = @import("../../../clock.zig");
+const TimeUnit = @import("TimeUnit.zig");
+
+/// Shared terminal-state reading for a `deref` that came back null. Ordered as
+/// `stm.zig`'s future arm orders it, so `.get` and `@` cannot disagree about
+/// why the same future stopped: still-pending means the eval budget's wall
+/// clock cut it, then cancellation, then the marshalled worker error.
+fn raiseForNullDeref(rt: *Runtime, f: Value, loc: SourceLocation) anyerror {
+ if (!future_mod.isRealised(f)) {
+ eval_budget.checkDeadlineNow(rt.io) catch |e| return e;
+ }
+ if (future_mod.isCancelled(f)) {
+ return error_catalog.raise(.future_cancelled, loc, .{});
+ }
+ if (future_mod.errorValue(f)) |ev| return worker_error.reraise(ev);
+ return error_catalog.raise(.future_thunk_failed, loc, .{});
+}
+
+/// `(.get f)` — block until realised, then the value. Same answers as `@f`.
+/// `(.get f timeout unit)` — block up to the timeout; on expiry throw a real
+/// `java.util.concurrent.TimeoutException`, as the JVM does. This is the one
+/// place the Java surface must NOT reuse cljw's 3-arity `deref`, which returns
+/// a caller-supplied default instead of throwing — `hive-weave.guarded` catches
+/// TimeoutException to decide whether to cancel the task, so a returned default
+/// would silently read as success.
+fn get(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = env;
+ if (args.len != 1 and args.len != 3)
+ return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = ".get", .expected = 1 });
+ const f = args[0];
+
+ if (args.len == 1) {
+ if (future_mod.deref(f, eval_budget.deadlineOf())) |v| return v;
+ return raiseForNullDeref(rt, f, loc);
+ }
+
+ const timeout = try error_catalog.expectInteger(args[1], ".get", loc);
+ const timeout_ns = TimeUnit.nanosOf(args[2], @intCast(timeout)) orelse
+ return error_catalog.raise(.type_arg_invalid, loc, .{
+ .fn_name = ".get",
+ .expected = "a java.util.concurrent.TimeUnit",
+ .actual = @tagName(args[2].tag()),
+ });
+
+ // The eval budget still bounds the wait: whichever deadline lands first
+ // wins, so a generous `.get` timeout under a metered eval cannot outlive
+ // the budget (and `raiseForNullDeref` then reports the budget, not a
+ // TimeoutException, because the future is still pending).
+ var deadline = clock.nanoTime(rt.io) +| @max(timeout_ns, 0);
+ if (eval_budget.deadlineOf()) |budget_deadline| deadline = @min(deadline, budget_deadline);
+
+ if (future_mod.deref(f, deadline)) |v| return v;
+ if (!future_mod.isRealised(f)) {
+ // Budget first (it is the uncatchable one), then the honest timeout.
+ try eval_budget.checkDeadlineNow(rt.io);
+ const ex = try ex_info.allocException(rt, "future did not complete within the timeout", "TimeoutException");
+ return worker_error.reraise(ex);
+ }
+ return raiseForNullDeref(rt, f, loc);
+}
+
+/// `(.isDone f)` — `realized?`. True for a future that completed with a value,
+/// completed with an error, OR was cancelled, matching the JVM: `isDone` asks
+/// whether the task is finished, not whether it succeeded.
+fn isDone(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".isDone", args, 1, loc);
+ return Value.initBoolean(future_mod.isRealised(args[0]));
+}
+
+/// `(.isCancelled f)` — `future-cancelled?`.
+fn isCancelled(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArity(".isCancelled", args, 1, loc);
+ return Value.initBoolean(future_mod.isCancelled(args[0]));
+}
+
+/// `(.cancel f)` / `(.cancel f mayInterruptIfRunning)` — `future-cancel`. True
+/// iff this call is the one that cancelled a still-pending future. The flag is
+/// accepted and ignored (AD-065).
+fn cancel(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
+ _ = rt;
+ _ = env;
+ try error_catalog.checkArityRange(".cancel", args, 1, 2, loc);
+ return Value.initBoolean(future_mod.cancel(args[0]));
+}
+
+const MethodSpec = struct { name: []const u8, f: *const fn (*Runtime, *Env, []const Value, SourceLocation) anyerror!Value };
+
+const METHODS = [_]MethodSpec{
+ .{ .name = "get", .f = &get },
+ .{ .name = "isDone", .f = &isDone },
+ .{ .name = "isCancelled", .f = &isCancelled },
+ .{ .name = "cancel", .f = &cancel },
+};
+
+/// Populate the per-Runtime `.future` native descriptor's method table.
+/// Idempotent. Called at runtime init alongside the other native installers.
+pub fn installNativeMethods(rt: *Runtime) !void {
+ const td = try rt.nativeDescriptor(.future);
+ if (td.method_table.len != 0) return; // idempotent re-run
+ const gpa = rt.gc.infra;
+ const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, METHODS.len);
+ for (METHODS, 0..) |m, i| {
+ entries[i] = .{
+ .protocol_name = "",
+ .method_name = try gpa.dupe(u8, m.name),
+ .method_val = Value.initBuiltinFn(m.f),
+ };
+ }
+ td.method_table = entries;
+}
diff --git a/test/e2e/future_methods.sh b/test/e2e/future_methods.sh
new file mode 100755
index 000000000..fab83ffc2
--- /dev/null
+++ b/test/e2e/future_methods.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+# test/e2e/future_methods.sh
+#
+# java.util.concurrent.Future's METHOD NAMES over cljw's native `.future`
+# (runtime/java/util/concurrent/Future.zig), plus Future as a reify target
+# (host_interface.zig FUTURE).
+#
+# The point of the surface is that `.get` and `@` cannot disagree: both must
+# report the same terminal state for the same future (value, cancelled, thrown).
+# The one deliberate difference from cljw's own deref is the timed arity, which
+# must THROW TimeoutException rather than return a default — a library catching
+# TimeoutException to decide whether to cancel would read a returned default as
+# success.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+
+MS='java.util.concurrent.TimeUnit/MILLISECONDS'
+
+# --- .get agrees with deref on a value ---
+assert_eq 'get_value' "$(run '(.get (future (+ 1 2)))')" '3'
+assert_eq 'get_matches_deref' "$(run '(let [f (future (* 6 7))] [(.get f) @f])')" '[42 42]'
+assert_eq 'get_nil_body' "$(run '(nil? (.get (future nil)))')" 'true'
+
+# --- .isDone is realized?: true for value, error AND cancellation ---
+assert_eq 'isDone_after_value' "$(run '(let [f (future 1)] @f (.isDone f))')" 'true'
+assert_eq 'isDone_matches_realized' \
+ "$(run '(let [f (future 1)] @f [(.isDone f) (realized? f)])')" '[true true]'
+assert_eq 'isDone_after_cancel' \
+ "$(run '(let [f (future (Thread/sleep 5000))] (.cancel f true) (.isDone f))')" 'true'
+
+# --- .cancel / .isCancelled agree with future-cancel / future-cancelled? ---
+assert_eq 'cancel_pending_wins' \
+ "$(run '(let [f (future (Thread/sleep 5000))] [(.cancel f true) (.isCancelled f)])')" '[true true]'
+assert_eq 'cancel_twice_second_loses' \
+ "$(run '(let [f (future (Thread/sleep 5000))] [(.cancel f true) (.cancel f true)])')" '[true false]'
+assert_eq 'cancel_realised_returns_false' \
+ "$(run '(let [f (future 1)] @f (.cancel f true))')" 'false'
+assert_eq 'isCancelled_false_when_completed' \
+ "$(run '(let [f (future 1)] @f [(.isCancelled f) (future-cancelled? f)])')" '[false false]'
+
+# AD-065: the mayInterruptIfRunning flag is accepted and IGNORED, and the
+# 1-arity is accepted. On the JVM `(.cancel f false)` would refuse a running
+# task; cljw cancels either way.
+assert_eq 'cancel_flag_is_ignored_ad065' \
+ "$(run '(let [f (future (Thread/sleep 5000))] [(.cancel f false) (.isCancelled f)])')" '[true true]'
+assert_eq 'cancel_arity_1_accepted' \
+ "$(run '(let [f (future (Thread/sleep 5000))] (.cancel f))')" 'true'
+
+# --- .get on a cancelled future throws CancellationException, as @ does ---
+assert_eq 'get_cancelled_throws_cancellation' \
+ "$(run '(let [f (future (Thread/sleep 5000))]
+ (.cancel f true)
+ (try (.get f) :no-throw
+ (catch java.util.concurrent.CancellationException _ :cancellation)))')" \
+ ':cancellation'
+
+# --- .get on a thrown body re-raises the ORIGINAL error (ADR-0120), as @ does ---
+assert_eq 'get_rethrows_original' \
+ "$(run '(let [f (future (throw (ex-info "boom" {:a 1})))]
+ (try (.get f) :no-throw (catch Exception e (keyword (.getMessage e)))))')" \
+ ':boom'
+
+# --- timed .get: value in time, TimeoutException past it ---
+assert_eq 'get_timed_returns_value' \
+ "$(run "(.get (future (+ 20 22)) 2000 $MS)")" '42'
+assert_eq 'get_timed_throws_timeout' \
+ "$(run "(let [f (future (Thread/sleep 5000))]
+ (try (.get f 50 $MS) :no-throw
+ (catch java.util.concurrent.TimeoutException _ :timeout)))")" \
+ ':timeout'
+# TimeoutException is an Exception, so a broad catch still sees it.
+assert_eq 'timeout_is_an_exception' \
+ "$(run "(let [f (future (Thread/sleep 5000))]
+ (try (.get f 50 $MS) :no-throw (catch Exception _ :caught)))")" \
+ ':caught'
+# The timed arity must NOT quietly return a default the way cljw's 3-arity
+# deref does — that is the whole reason it exists separately.
+assert_eq 'timed_deref_still_returns_default' \
+ "$(run '(deref (future (Thread/sleep 5000)) 50 :fallback)')" ':fallback'
+
+# --- bad unit is a type error, not a silent success ---
+assert_eq 'get_timed_bad_unit' \
+ "$(run '(let [f (future 1)] (try (.get f 10 :not-a-unit) :no-throw (catch Exception _ :threw)))')" \
+ ':threw'
+
+# --- Future is reify-able: a synthetic already-completed Future ---
+# hive-weave.pool returns one of these when the pool is shut down and the work
+# ran on the caller thread.
+assert_eq 'reify_future_get' \
+ "$(run '(let [f (reify java.util.concurrent.Future
+ (get [_] 99)
+ (get [_ _t _u] 99)
+ (isDone [_] true)
+ (isCancelled [_] false)
+ (cancel [_ _] false))]
+ [(.get f) (.isDone f) (.isCancelled f) (.cancel f true)])')" \
+ '[99 true false false]'
+# The bare post-:import spelling must resolve to the same interface.
+assert_eq 'reify_future_bare_spelling' \
+ "$(run '(let [f (reify Future (get [_] 7) (isDone [_] true) (isCancelled [_] false) (cancel [_ _] false))]
+ [(.get f) (.isDone f)])')" \
+ '[7 true]'
+
+# --- class identity is unchanged ---
+assert_eq 'class_name' "$(run '(= "Future" (str (class (future 1))))')" 'true'
+
+echo "OK test/e2e/future_methods.sh"
diff --git a/test/run_all.sh b/test/run_all.sh
index 5c56746bf..20ca607c7 100644
--- a/test/run_all.sh
+++ b/test/run_all.sh
@@ -781,6 +781,7 @@ run_step "e2e_phase14_print_method" "bash test/e2e/phase14_print_method
run_step "e2e_phase14_member_on_native" "bash test/e2e/phase14_member_on_native.sh"
run_step "e2e_phase14_java_method_grouping" "bash test/e2e/phase14_java_method_grouping.sh"
run_step "e2e_list_count_exact" "bash test/e2e/list_count_exact.sh"
+run_step "e2e_future_methods" "bash test/e2e/future_methods.sh"
run_step "e2e_phase14_fn_prepost" "bash test/e2e/phase14_fn_prepost.sh"
run_step "e2e_phase14_deftype_ideref" "bash test/e2e/phase14_deftype_ideref.sh"
run_step "e2e_phase14_equality" "bash test/e2e/phase14_equality.sh"
From fbe21c19a722d6c1d873eab642862b260b2412d3 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 14:59:01 -0300
Subject: [PATCH 053/466] ci(release): roll the patch version automatically on
push to main
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
build.zig.zon's `.version` stays the SSOT, but it now names the MAJOR.MINOR
series rather than every shipped version. When preflight finds that version
already released, it takes the first free patch in the series, writes it back
into build.zig.zon, commits it as `chore(release): vX.Y.Z` and cuts the release
from that commit — so `cljw --version` keeps matching its tag with no human
bump. A hand-written minor or major bump still ships verbatim, and a `v*` tag
push is untouched.
A branch push only cuts a patch when `src/**`, `build.zig`, versions.lock or
this workflow moved since the last release. build.zig.zon is deliberately off
that list: the bump commit touches it, and counting it would make every release
mint the next one.
The build and tap jobs now check out and tag preflight's SHA, since the run's
own SHA still carries the previous `.version`.
---
.github/workflows/release.yml | 149 +++++++++++++++++++++++++++-------
1 file changed, 120 insertions(+), 29 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3e5784f73..89a19a78b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,18 +2,37 @@
# target on its NATIVE runner and attach it to the GitHub Release. Native builds
# (not cross-compile) keep the embedded zwasm JIT's arch-specific codegen honest.
#
-# TRIGGER: build.zig.zon's `.version` is the SSOT. Every push to main runs
-# `preflight`, which releases that version only when no release for it exists
-# yet; pushing a `v*` tag releases that tag; manual dispatch releases whatever
-# version main names (`force: true` re-releases an existing one). So a push
-# that does not bump `.version` costs one short job and does nothing.
+# TRIGGER: build.zig.zon's `.version` is the SSOT for the MAJOR.MINOR series;
+# the PATCH rolls forward on its own. Every push to main runs `preflight`:
+#
+# * `.version` names a version that has never shipped -> release it verbatim.
+# A MINOR or MAJOR bump is still the deliberate human act it always was:
+# write `1.11.0` by hand and the next push ships `v1.11.0`.
+# * `.version` names a version that HAS shipped -> this push is a patch.
+# preflight takes the first free patch in that series, writes it back into
+# build.zig.zon, commits it as `chore(release): vX.Y.Z` and releases THAT
+# commit — so `cljw --version` still matches its tag with no human bump.
+#
+# A `v*` tag push releases that tag verbatim, no bumping. Manual dispatch cuts a
+# patch on demand (skipping the relevance check below); `force: true` re-releases
+# whatever `.version` currently names.
+#
+# RELEVANCE: a branch push cuts a patch only when something that can change the
+# binary moved since the last release — `src/**`, `build.zig`,
+# `.github/versions.lock`, this workflow. build.zig.zon is deliberately NOT on
+# that list: the bump commit touches it, and counting it would make each release
+# mint the next one forever. A dependency-hash-only edit to build.zig.zon rides
+# along with the next source change, or with a manual dispatch.
+#
+# The bump commit is pushed with GITHUB_TOKEN, whose pushes do not start new
+# workflow runs, so the loop is closed on that side too.
#
# The skip lives in `preflight` rather than in an `on.push.paths` filter
# because a `paths` filter also applies to tag pushes, which would drop the
# tag trigger whenever the tagged commit did not touch build.zig.zon.
#
-# The deliberate human act is the version bump itself — the autonomous loop
-# does not edit `.version`.
+# CHANGELOG.md's `[Unreleased]` heading is NOT stamped here; the release notes
+# come from `--generate-notes` over the commits.
#
# NOTE (first-tag validation): the artifact build uses
# `-Dwasm -Doptimize=ReleaseSafe -Dcpu=baseline` (baseline CPU avoids SIGILL on
@@ -41,48 +60,113 @@ concurrency:
cancel-in-progress: false
jobs:
- # Resolve the tag to release and decide whether there is anything to do.
- # `version` comes from build.zig.zon on a branch push / dispatch, and from the
- # ref itself on a tag push.
+ # Resolve the tag to release, auto-bumping the patch when the version
+ # build.zig.zon names has already shipped. `sha` is the commit the release is
+ # cut from: the pushed head, or the bump commit this job just made.
preflight:
name: preflight
runs-on: ubuntu-22.04
- timeout-minutes: 5
+ timeout-minutes: 10
outputs:
tag: ${{ steps.resolve.outputs.tag }}
+ sha: ${{ steps.resolve.outputs.sha }}
proceed: ${{ steps.resolve.outputs.proceed }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # The relevance check diffs against the previous release tag, and the
+ # bump commit is pushed from here.
+ fetch-depth: 0
- - name: Resolve the tag and check for an existing release
+ - name: Resolve the tag, bumping the patch when the series has shipped
id: resolve
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ inputs.force }}
+ EVENT: ${{ github.event_name }}
shell: bash
run: |
set -euo pipefail
+
+ emit() {
+ {
+ echo "tag=$1"
+ echo "sha=$2"
+ echo "proceed=$3"
+ } >> "$GITHUB_OUTPUT"
+ }
+ released() { gh release view "$1" >/dev/null 2>&1; }
+
+ # A tag push releases exactly that tag: no bumping, no relevance check.
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
tag="${GITHUB_REF#refs/tags/}"
- else
- # Keep this reader in step with .github/versions.lock's own rule:
- # split on the first '=' and strip quotes, no inline comments.
- version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
- if [[ -z "$version" ]]; then
- echo "could not read .version from build.zig.zon" >&2
- exit 1
+ if [[ "$FORCE" != "true" ]] && released "$tag"; then
+ echo "release $tag already exists — nothing to do"
+ emit "$tag" "$GITHUB_SHA" false
+ exit 0
fi
- tag="v$version"
- fi
- echo "tag=$tag" >> "$GITHUB_OUTPUT"
- if [[ "$FORCE" != "true" ]] && gh release view "$tag" >/dev/null 2>&1; then
- echo "release $tag already exists — nothing to do"
- echo "proceed=false" >> "$GITHUB_OUTPUT"
- else
echo "releasing $tag"
- echo "proceed=true" >> "$GITHUB_OUTPUT"
+ emit "$tag" "$GITHUB_SHA" true
+ exit 0
+ fi
+
+ # Keep this reader in step with .github/versions.lock's own rule:
+ # split on the first '=' and strip quotes, no inline comments.
+ version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
+ if [[ -z "$version" ]]; then
+ echo "could not read .version from build.zig.zon" >&2
+ exit 1
+ fi
+
+ # The version main names has never shipped — release it as written.
+ # This is the hand-written MINOR/MAJOR bump, and `force` re-cuts it.
+ if [[ "$FORCE" == "true" ]] || ! released "v$version"; then
+ echo "releasing v$version as build.zig.zon names it"
+ emit "v$version" "$GITHUB_SHA" true
+ exit 0
+ fi
+
+ # It has shipped, so this push is a patch. Skip when nothing that can
+ # change the binary moved since that release. build.zig.zon is absent
+ # from these paths on purpose — see the header.
+ if [[ "$EVENT" == "push" ]]; then
+ changed=$(git diff --name-only "v$version...HEAD" -- \
+ src build.zig .github/versions.lock .github/workflows/release.yml)
+ if [[ -z "$changed" ]]; then
+ echo "nothing release-relevant since v$version — nothing to do"
+ emit "v$version" "$GITHUB_SHA" false
+ exit 0
+ fi
+ echo "release-relevant since v$version:"
+ echo "$changed"
+ fi
+
+ if [[ ! "$version" =~ ^([0-9]+\.[0-9]+)\.([0-9]+)$ ]]; then
+ echo "cannot patch-bump the non-numeric version '$version' — bump .version by hand" >&2
+ exit 1
fi
+ series="${BASH_REMATCH[1]}"
+ patch="${BASH_REMATCH[2]}"
+ # Take the first free patch in the series rather than version+1, so a
+ # hand-cut tag further ahead is never overwritten.
+ while patch=$((patch + 1)); released "v$series.$patch"; do :; done
+ next="$series.$patch"
+
+ sed -i "s/^\([[:space:]]*\.version[[:space:]]*=[[:space:]]*\"\)[^\"]*\(\"\)/\1$next\2/" build.zig.zon
+ wrote=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
+ if [[ "$wrote" != "$next" ]]; then
+ echo "rewriting .version did not take (build.zig.zon now says '$wrote')" >&2
+ exit 1
+ fi
+
+ git config user.name "clojurewasm-release"
+ git config user.email "clojurewasm-release@users.noreply.github.com"
+ git add build.zig.zon
+ git commit -m "chore(release): v$next"
+ git push origin "HEAD:${GITHUB_REF_NAME}"
+ echo "bumped .version $version -> $next, releasing v$next"
+ emit "v$next" "$(git rev-parse HEAD)" true
build:
name: build (${{ matrix.name }})
@@ -105,6 +189,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # The bump commit, when preflight made one — the run's own SHA still
+ # carries the PREVIOUS `.version`, and that is what the binary reports.
+ ref: ${{ needs.preflight.outputs.sha }}
- name: Install Zig 0.16.0
shell: bash
@@ -134,11 +222,14 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
+ TARGET_SHA: ${{ needs.preflight.outputs.sha }}
shell: bash
run: |
set -euo pipefail
# `--target` makes the tag when the run came from a branch push; on a
- # tag push the tag already exists and the flag is inert.
+ # tag push the tag already exists and the flag is inert. It names
+ # preflight's SHA, so an auto-bumped release is tagged at the commit
+ # whose build.zig.zon actually says this version.
#
# Create the release once; later matrix legs just upload.
#
@@ -150,7 +241,7 @@ jobs:
# afterwards; a create that failed for any other reason still fails
# the step, because the trailing view then finds nothing.
gh release view "$TAG_NAME" >/dev/null 2>&1 \
- || gh release create "$TAG_NAME" --target "$GITHUB_SHA" --title "$TAG_NAME" --generate-notes \
+ || gh release create "$TAG_NAME" --target "$TARGET_SHA" --title "$TAG_NAME" --generate-notes \
|| gh release view "$TAG_NAME" >/dev/null 2>&1
gh release upload "$TAG_NAME" dist/* --clobber
From b82e861af937ad64adbcc419d4b5aa98d06fa542 Mon Sep 17 00:00:00 2001
From: clojurewasm-release
Date: Fri, 14 Aug 2026 17:59:44 +0000
Subject: [PATCH 054/466] chore(release): v1.10.3
---
build.zig.zon | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.zig.zon b/build.zig.zon
index 22cd64d6c..623447946 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.2",
+ .version = "1.10.3",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From 4a295706a59cf30dca469eabd8f78271df2186a9 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 15:49:30 -0300
Subject: [PATCH 055/466] perf(seq): a vector's seq is a VIEW, not an eager
copy (O-058)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`(seq v)` / `(rest v)` / `(next v)` on a vector built an eager
PersistentList — one cons cell and one trie descent per element, per
call. Clojure never copies: PersistentVector$ChunkedSeq is a view over
(vector, index). This is that view.
Measured over a 16000-element vector, 2000 ops, ReleaseSafe, against
JVM Clojure 1.12.4 on the same box and probe:
seq 2123 ms -> 0.36 (clj 0.60)
rest 2253 ms -> 0.39 (clj 0.87)
next 2164 ms -> 0.38 (clj 0.99)
~5700-5900x, and from 2184-3549x slower than clj to 1.7-2.6x faster.
`nth` on such a seq is O(1) too, where clj walks (ChunkedSeq is
Counted, not Indexed).
The address was already reserved: A14 `.array_seq` was declared across
every protocol table in interface_membership.zig and named in
class_name.zig, with no producer — equal.zig's own header anticipated
exactly this ("when a producer mints those tags"). So this spends no
slot from heap_tag.unallocated and needs no F-004 census decision.
This closes item 2 of .dev/bench/README.md's ranked list, which that
file had recorded as blocked on item 1 (SubVector). It was not: `next`
of a seq is a seq, and clj's own answer holds the SAME vector at a
higher index rather than building a smaller one. The correction is in
that file. `subvec` stays eager (D-044) and keeps the tag question,
since it must return a vector.
Found while wiring the new arms, and fixed here:
- `(with-meta (seq v) m)` — a seq is IObj on the JVM (ASeq), and the
eager list carried meta for free. Caught by the golden clj corpus.
- `(empty (range 3))` and `(empty (map inc [1 2]))` RAISED instead of
answering `()`. Pre-existing, unrelated to the view. Every ISeq
empties to `()` (clj ASeq.empty()), so the arm covers the family.
AD-066 records the one remaining difference: `(class (seq v))` answers
ArraySeq where clj answers PersistentVector$ChunkedSeq — cljw has one
indexed-view seq where clj has a family, the same collapse F-005 makes
in the numeric tower.
docs/works/collection_performance.md is the user-facing view: what is
faster, what is slower, and the honest split between interpreter speed
and structure speed.
Gate: 443 passed, 0 failed.
---
.dev/accepted_divergences.yaml | 26 ++
.dev/bench/README.md | 99 ++++---
.dev/optimizations.md | 1 +
CHANGELOG.md | 24 ++
README.md | 8 +
docs/README.md | 3 +
docs/works/collection_performance.md | 136 ++++++++++
src/eval/backend/clojure_lang_method.zig | 4 +-
src/lang/primitive/collection.zig | 20 ++
src/lang/primitive/higher_order.zig | 6 +-
src/lang/primitive/metadata.zig | 4 +
src/lang/primitive/sequence.zig | 56 ++--
src/runtime/collection/array_seq.zig | 313 +++++++++++++++++++++++
src/runtime/equal.zig | 36 ++-
src/runtime/lazy_seq.zig | 3 +
src/runtime/meta.zig | 2 +
src/runtime/print.zig | 20 +-
src/runtime/runtime.zig | 1 +
test/e2e/vector_seq_view.sh | 156 +++++++++++
test/run_all.sh | 1 +
20 files changed, 852 insertions(+), 67 deletions(-)
create mode 100644 docs/works/collection_performance.md
create mode 100644 src/runtime/collection/array_seq.zig
create mode 100755 test/e2e/vector_seq_view.sh
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index 1a56e44bd..68394f67c 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -1064,3 +1064,29 @@ accepted:
example: '(let [f (future (Thread/sleep 5000))] [(.cancel f false) (.isCancelled f)]) => cljw [true true] / clj [false false] — the JVM refuses to cancel a running task when the flag is false.'
derives_from: "D-442 / ADR-0153 (cooperative cancellation) + ADR-0059 (no JVM thread interrupt)."
pin: "test/e2e/future_methods.sh — cancel_flag_is_ignored_ad065."
+
+ - id: "AD-066"
+ area: "collections"
+ summary: |-
+ `(class (seq v))` on a vector answers `ArraySeq`, where clj answers
+ `clojure.lang.PersistentVector$ChunkedSeq`.
+ cljw has ONE indexed-view seq representation (`.array_seq`,
+ `runtime/collection/array_seq.zig`, O-058) standing in for the family of
+ indexed seqs clj gives separate classes: `PersistentVector$ChunkedSeq`
+ over a vector and `ArraySeq` over a Java array are the same two words
+ here — a backing collection plus a cursor — read through the
+ `backingCount` / `elementAt` switches, so a second backing is a new arm
+ rather than a new type. This is the same collapse F-005 performs on the
+ numeric tower (BigInt and BigInteger become one `.big_int`, whose
+ class-name consequence is already recorded above), applied to seqs.
+ The name kept is the reserved day-1 name for the A14 slot. Only the class
+ NAME diverges: `seq?` / `sequential?` / `coll?` / `counted?` / `=` /
+ `hash` / print form / `nth` / `count` all match clj, and the value is `=`
+ to both the list and the vector with the same elements, so nothing that
+ treats it AS a seq can tell the difference.
+ Before O-058 this expression answered `PersistentList`, because `seq` on
+ a vector eagerly copied into one. Neither the old nor the new answer
+ matches clj, so no code that was portable has been broken.
+ example: '(class (seq [1 2 3])) => cljw ArraySeq / clj clojure.lang.PersistentVector$ChunkedSeq. Also `(class (seq (into-array [1 2])))` would be ArraySeq in BOTH — the one backing for which the shared name is exact.'
+ derives_from: "AD-003 (simple class name for cljw-native types) + F-004 A14 slot reservation + the F-005 family-collapse precedent."
+ pin: "test/e2e/vector_seq_view.sh — class_name."
diff --git a/.dev/bench/README.md b/.dev/bench/README.md
index 6e56c9d73..f25f30e90 100644
--- a/.dev/bench/README.md
+++ b/.dev/bench/README.md
@@ -37,9 +37,9 @@ whole-collection traversal probe that checks for accidental O(n²).
| `list` `conj` | 0.36 | 0.43 | comparable |
| **`pop`** | **1.93** | **8278** | **4288× slower → FIXED (O-056), now 2.47** |
| **`subvec`** | **1.06** | **7929** | **7480× slower** |
-| **`seq`** | **0.60** | **2123** | **3549× slower** |
-| **`rest`** | **0.87** | **2253** | **2576× slower** |
-| **`next`** | **0.99** | **2164** | **2184× slower** |
+| **`seq`** | **0.60** | **2123** | **3549× slower → FIXED (O-058), now 0.36** |
+| **`rest`** | **0.87** | **2253** | **2576× slower → FIXED (O-058), now 0.39** |
+| **`next`** | **0.99** | **2164** | **2184× slower → FIXED (O-058), now 0.38** |
| **`list` `count`** | **0.18** | **481** | **2711× slower → FIXED (O-057), now 0.19** |
### Correction: the small deltas above are NOT reliable
@@ -103,35 +103,64 @@ problem:
| # | Fix | Kind | Unblocks |
|---|---|---|---|
-| 1 | `SubVector` view (D-044 — "land when the benefit is measurable"; it is now measured: 7480×) | view | `subvec`, every `drop`/`take`-style slice built on it — **and (2), see below** |
-| 2 | Chunked vector-seq VIEW behind the existing seq protocol | view, not algorithm | `seq` + `rest` + `next` in one change — the systemic one, since these are what every hand-written seq walk uses |
-| ~~3~~ | ~~Cached count on `PersistentList`~~ | ~~one field~~ | **DONE — O-057. The field already existed; what was missing was a way to say when it is exact.** |
-
-**(1) now ranks above (2) because it is (2)'s enabler.** `.range` already shows
-the shape a chunked seq takes here without costing a Tag: `range.seqChunk`
-materialises ≤32 elements into a `chunked_cons` whose `next` is *a smaller
-`.range`* — a self-similar producer value, not a closure and not a new
-representation. The vector analogue of "a smaller range" is "the vector minus
-its first 32 elements", which is exactly a `SubVector` view. Land (1) and (2)
-is `chunked_cons{v[k..k+32], subvec(v, k+32)}`, reusing the one chunk
-mechanism (F-011) with no new seq type at all.
-
-Note the constraint that makes this the attractive route: `heap_tag.zig`'s
-census is closed at 64 slots, and its own doc says spending one of the 9
-`unallocated` names is a user decision, not an implementation detail. A design
-that needs no new Tag sidesteps that question entirely. `SubVector` itself
-still has to answer it — either a repurposed slot, or a `start: u32` inside
-`Vector`'s existing `_pad: [6]u8` (no struct growth, but then EVERY vector op
-must honour the offset, which is the real cost and the thing to weigh).
-
-(1) and (2) are the same shape and should follow OCP: add a new seq/vector
-REPRESENTATION that satisfies the existing protocol, so no consumer changes —
-not an `if` branch at each call site. That is also what keeps D-044's "new Tag
-slot + polymorphic dispatch" cost honest.
-
-Caveat on (1): `reduce` / `doseq` / `into` over a vector are already fast — the
-index-walk and chunk-drain fast paths (O-002, O-004, O-032) bypass `seq`
-entirely, and `traversal.clj` confirms they stay linear. So the eager `seq`
-hurts hand-written `loop`/`recur` walks and library code, not the idiomatic
-core paths. That is why it is ranked by breadth rather than by any single
-benchmark regression.
+| ~~1~~ | ~~`SubVector` view~~ → still open, but on its OWN | view | `subvec` (7480×) and slice-style ops built on it |
+| ~~2~~ | ~~Chunked vector-seq VIEW behind the existing seq protocol~~ | view, not algorithm | **DONE — O-058. `seq`/`rest`/`next` are now an `.array_seq` view; all three are FASTER than clj.** |
+| ~~3~~ | ~~Cached count on `PersistentList`~~ | ~~one field~~ | **DONE — O-057.** |
+
+### Correction: (2) never needed (1)
+
+The earlier revision of this file ranked `SubVector` above the vector-seq on the
+grounds that it was the seq's *enabler* — "the vector analogue of 'a smaller
+range' is 'the vector minus its first 32 elements', which is exactly a
+`SubVector` view". That was wrong, and it stalled the larger win behind a
+`heap_tag`-census question that did not apply to it.
+
+`next` of a seq is a **seq**, not a collection. Clojure's own answer is
+`PersistentVector$ChunkedSeq`, which holds the SAME vector at a higher index —
+it never constructs a smaller vector. So the seq view needs `(vector, index)`,
+which is what O-058 built, and `subvec` is an independent problem about
+returning a *vector* that shares a parent's root and tail.
+
+Two lessons worth keeping, because both were reasoning failures rather than
+implementation ones:
+
+- **Check what the reference implementation actually returns** before deriving a
+ dependency from an analogy. The analogy to `.range` was apt for the chunking
+ *mechanism* and misleading about the *tail type*.
+- **A blocked item can block a neighbour that is not actually downstream.** The
+ census question ("may we spend one of the 9 `unallocated` names") is real for
+ `SubVector` and was never live for the seq view — A14 `.array_seq` was already
+ an allocated slot, already declared across every protocol table in
+ `interface_membership.zig`, already named in `class_name.zig`, and merely had
+ no producer. `equal.zig`'s header anticipated it in as many words: "when a
+ producer mints those tags."
+
+### What is left: `subvec` (7480×)
+
+`vector.zig::subvec` eagerly copies (D-044) where Clojure returns an
+`APersistentVector$SubVector` sharing the parent's root and tail. This one DOES
+have to answer the tag question, and it is a genuine user decision per
+`heap_tag.zig`'s own doc:
+
+- **(a) repurpose an `unallocated` slot** (`.tuple` / `.box` are the plausible
+ names) — clean and polymorphic, and the census is explicitly published so that
+ the question is "may `box` become `subvec`", not "may we amend F-004". Spends
+ a name.
+- **(b) `start: u32` inside `Vector`'s existing `_pad: [6]u8`** — no struct
+ growth and no Tag spent, but then EVERY vector op must honour the offset, and
+ any path that reads `root`/`tail` without it goes silently wrong. That review
+ surface is the real cost, not the four bytes.
+
+Note the asymmetry with O-058: there, no consumer had to change, because a new
+representation satisfied an existing protocol. Option (b) is the opposite shape
+— it changes the meaning of an existing representation, so it touches every
+consumer. That is the argument for (a) even though (a) is the one that spends
+something.
+
+### Caveat that still holds
+
+`reduce` / `doseq` / `into` over a vector were already fast — the index-walk and
+chunk-drain fast paths (O-002, O-004, O-032) bypass `seq` entirely, and
+`traversal.clj` confirms they stay linear. So O-058 helped hand-written
+`loop`/`recur` walks and library code rather than the idiomatic core paths,
+which is why it was ranked by breadth rather than by a benchmark regression.
diff --git a/.dev/optimizations.md b/.dev/optimizations.md
index ef9169e3c..e97ffbe85 100644
--- a/.dev/optimizations.md
+++ b/.dev/optimizations.md
@@ -103,6 +103,7 @@
| O-039 | `runtime/numeric/promote.zig` (`operandManaged`/`OwnedManaged` + add/sub/mul BigInt else-branches) (ADR-0148; bigint_factorial) | The integer/BigInt arithmetic else-branch (reached when an operand is a heap BigInt) called `coerceToManaged` on BOTH operands, which **clones** a `.big_int` operand via `cloneWithDifferentAllocator` (alloc + limb memcpy) — yet `r.add`/`sub`/`mul` only READ them. `bigint_factorial` (`(reduce *' …)`, acc grows to ~9 limbs) clones the growing accumulator on every `*`. Same waste O-037 fixed for ratios, single-Managed flavour. | `operandManaged(rt, v, *OwnedManaged)` returns `*const Managed`: a BigInt aliases its stored Managed (zero alloc); an immediate Long materialises into a caller-stable `OwnedManaged` local (`deinit` only when active). The 3 else-branches use it; the `@addWithOverflow` both-int sub-paths keep `coerceToManaged` (both small, nothing to alias). | Removes the per-op accumulator clone. **`32_bigint_factorial` ReleaseSafe 26.4→22.4 ms (12 runs; 1.49×→~1.27× vs Babashka 17.7 ms).** ratio_sum unchanged (separate path). Broad win for all BigInt `+ - *`. | `zig build test -Dwasm` ×2 (diff oracle) + corpus + smoke; output unchanged (158 = digit count of 100!) | O-037, ADR-0148, D-450 |
| O-038 | `runtime/numeric/promote.zig` (`ratioArith` scratch) + `runtime/numeric/ratio.zig` (`allocFromManagedPair` reduce-scratch) (ADR-0148; ratio_sum) | Each ratio op allocated its transient cross-multiply + gcd/divTrunc scratch as individual `Managed.init(rt.gc.infra)` calls — `ratioArith` 2-4 (rn/rd/lhs/rhs) + `allocFromManagedPair` 4 (gcd/r_num/r_den/rem) — so ~8 GPA malloc/free per `+`. `sample` profiling showed `libsystem_malloc` (`_xzm_*malloc`/`_xzm_free`/`memset`/`bzero`) ≈ 20% of leaf self-time. | Each function opens ONE `std.heap.ArenaAllocator` over `rt.gc.infra` and allocates all its scratch Managed from it; `defer arena.deinit()` bulk-frees on return. The two arenas don't alias (rn/rd live on `ratioArith`'s arena, read inside `allocFromManagedPair` which has its own). Result BigInts (numer/denom) still clone onto `gc.infra` (GC-managed, outlive the call). | Collapses ~8 individual malloc/free into ~2 arena chunk alloc/free per op. **`33_ratio_sum` 103.3→82.9 ms (12 runs); combined with O-037 108.1→82.9 (23%).** Re-profile: mem-leaf 227/1950 ≈ 11.6% (alloc no longer dominant; residual is interpreter dispatch + bignum compute → D-386). Broad win for all ratio arithmetic. | `zig build test -Dwasm` ×2 (diff oracle) + corpus 3157/3157 + smoke gate; output unchanged | O-037, ADR-0148, D-450 |
| O-056 | `lang/clj/clojure/core.clj` `pop` (vector arm) + `lang/primitive/collection.zig` `__vector-pop` (new) | `(pop v)` on a vector was `(into [] (take (dec (count coll)) coll))` — an O(n) REBUILD of a whole fresh trie. Meanwhile `runtime/collection/vector.zig::pop` already implements the JVM `PersistentVector.pop()` algorithm correctly (tail fast path; else pull the last leaf out of the root and collapse a level), and was reachable ONLY from `java.util.ArrayDeque`. A stratified-design ABSTRACTION-BARRIER violation (Grokking Simplicity ch.8): the layer above re-implemented a barrier operation with high-level seq ops instead of calling through it | Expose the existing barrier fn as the `__vector-pop` primitive and have `pop` call it. No new lower-layer code — the "minimal interface" move: the barrier already had the op | O(n) -> O(log32 n), and it now SHARES structure with the parent instead of allocating a fresh trie. Measured 2000 pops, ReleaseSafe: n=1000 539.5 -> 1.14 ms; n=4000 2151.5 -> 1.30 ms; **n=16000 8278.3 -> 2.47 ms (~3352x)**. Reference JVM clj 1.12.4 on the same box/probe: 1.93 ms — cljw was ~4288x SLOWER than clj here and is now within ~1.3x | `scripts/check_corpus_regression.sh` **4347/4347** golden clj outputs reproduced + e2e `phase14_peek_pop`, `phase14_deftype_collection_base`, `phase14_deftype_object`, `phase14_catch_internal` + spot checks (meta preserved, list arm -> rest, queue arm -> `__queue-pop`, empty still raises IllegalStateException) | D-044, ADR-0148, `.dev/bench/` |
+| O-058 | `runtime/collection/array_seq.zig` (NEW, `.array_seq` A14) + `lang/primitive/sequence.zig` (`seqFn`/`restFn`/`nextFn`/`countFn`/`firstFn` + the three seq-walk helpers) + `.array_seq` arms in `equal.zig` / `print.zig` / `higher_order.zig` / `lazy_seq.zig` / `collection.zig` (`nthFn`) / `clojure_lang_method.zig` | `(seq v)` / `(rest v)` / `(next v)` on a vector built an EAGER `PersistentList` copy (`vectorToList` / `vectorTailAsList`): n `consHeap` allocations AND n trie descents per call. Clojure never copies — `PersistentVector$ChunkedSeq` is a VIEW holding `(vector, index)`. `.dev/bench/README.md` ranked this #2 and called SubVector (#1) its prerequisite, on the reading that a chunked seq's `next` must be "the vector minus its first 32 elements". It is not: `next` of a seq is a SEQ, and clj's own answer is a ChunkedSeq over the SAME vector at a higher index. So #2 never depended on #1, and the `heap_tag` census question #1 raises does not gate it | A new REPRESENTATION satisfying the existing seq protocol (OCP — no consumer changes, no `if` at call sites): `ArraySeq{backing, index, meta}`, 32 bytes, fixed size whatever it spans. `first` = one indexed read, `rest` = the same view at `index+1`, `count` = `backingCount - index`, `nth` = one indexed read (clj WALKS here — ChunkedSeq is Counted, not Indexed). Invariant: `make` answers nil when the view would be empty, mirroring `.range`, which is what makes `first` total. Costs NO slot from `heap_tag.unallocated`: A14 `.array_seq` was a day-1 reservation already declared across every `interface_membership.zig` protocol table and already named in `class_name.zig` but had no producer — `equal.zig`'s header literally anticipated "when a producer mints those tags". It also REPAIRS a latent census inaccuracy: A14 was classified boxed-in-production while nothing produced it | O(n) alloc + O(n log32 n) -> O(1) for all three. Measured 2000 ops on a 16000-element vector, ReleaseSafe: **`seq` 2123 -> 0.36 ms (~5897x), `rest` 2253 -> 0.39 ms (~5777x), `next` 2164 -> 0.38 ms (~5695x)**. Reference JVM clj 1.12.4, same box and probe: 0.60 / 0.87 / 0.99 ms — cljw was 2184-3549x SLOWER than clj on these and is now 1.7-2.6x FASTER. Broad win: this is what every hand-written `loop`/`recur` seq walk and every library seq walk pays (the idiomatic `reduce`/`doseq`/`into` paths already bypassed `seq` via O-002/O-004/O-032, which is why no single benchmark regressed on it) | `zig build test -Dwasm -Doptimize=ReleaseSafe` x2 (VM + tree_walk diff oracle) + new array_seq.zig units (layout, never-empty invariant, count-at-offset, rest/drop termination, saturating drop) + new e2e `vector_seq_view.sh` (76 assertions: print-as-seq, `=`/`hash`/map-key interop with list AND vector, length-mismatch must lose, count exact at every offset, nth + default + OOB + negative, empty/one-element boundaries, next-walk visits each exactly once, 5000-element multi-level trie, nested collections) + `CLJW_GC_TORTURE=1` on the view (proves the trace fn reaches `backing`) + `phase16_gc_torture` | D-044, O-056, O-057, ADR-0179, F-004, `.dev/bench/README.md` |
| O-057 | `runtime/collection/list.zig` (`COUNT_UNKNOWN` + `countPrepending` + `exactCountOf`) + `lang/primitive/sequence.zig` `countFn` (`.list`/`.cons`/`.lazy_seq` arm) | `(count lst)` WALKED every `.list`, even though `Cons` already stores an O(1) `count`. The stored field was distrusted for a real reason: a `.list` cell may head a MIXED chain whose `rest` leaves `.list` — `(cons x (map …))`, `(conj (range 3) 99)` — and there `1 + countOf(tail)` records the `.list` PREFIX length as if it were the total. The prior comment deferred the fast path to the F-004 `.list`/`.cons` tag split (D-178) | Make the stored count SELF-VALIDATING rather than splitting the tag: `countPrepending` writes `COUNT_UNKNOWN` (`maxInt(u32)`) when the tail is a non-`.list` seq or itself unknown, so unknown-ness propagates head-ward and the field is never a prefix presented as a total. `exactCountOf` returns `?u32`; `countFn` takes the O(1) path when it is non-null and keeps the existing walk otherwise. Sentinel chosen so every existing `countOf(v) > 0` / `== 0` emptiness test keeps its meaning (a cons cell always holds ≥1 element) | O(n) -> O(1) for a pure list. Measured 2000 `count` ops on a 16000-element list, ReleaseSafe: **380.15 -> 0.19 ms (~2006x)**. Reference JVM clj 1.12.4, same box and probe: 0.18 ms — cljw was ~2711x slower than clj on this op and is now at PARITY. Mixed chains keep the walk, so they keep their exact answer | Mixed-chain probes all still exact (`(cons 1 (map inc [1 2 3]))` 4, `(conj (range 3) 99)` 4, `(cons 0 (seq [1 2 3]))` 4, nested `(cons 1 (cons 2 (map inc [1 2 3])))` 5, pure `(list 1 2 3)` 3, `(list)` 0) + `(= (cons 1 (lazy-seq [2 3])) (list 1 2 3))` still true (the equal.zig `isCountable` exclusion of `.list` is what keeps `COUNT_UNKNOWN` out of the length short-circuit) + list.zig units + e2e `list_count_exact` + corpus | D-178, D-044, `.dev/bench/` |
| O-055 | `eval/backend/vm.zig` (eval loop) + `eval/backend/tree_walk.zig` (`evalLoop` + fn-tail-recur loop) — budget-poll TLV hoist (D-450 lever 1) | The per-op / per-back-edge budget poll read the `threadlocal eval_budget_mod.current` each iteration. On macOS every threadlocal access is a dyld `_tlv_get_addr` CALL (D-571 moved the budget off the near-free Runtime field into the threadlocal, silently voiding D-386's "polls are near-free" verdict); `sample` on the rush-hour BFS showed `_tlv_get_addr` ≈ 7% top-of-stack, part of it this poll | Read `current` ONCE into a local at eval/loop entry; the per-op poll unwraps the local. Semantics preserved because every writer of `current` is stack-disciplined around a host call that re-enters eval (`with-budget` installs → invokes via callFn = a NEW eval invocation that re-reads at its entry → restores before returning to the hoisted loop); worker adopt/release bracket the worker's own eval | Same-session A/B on the rush-hour BFS headline (`(g/generate 7 :medium)`, M4 Pro, ReleaseSafe): median A 16.86 s (8 runs) → B 16.48 s (10 runs), **−2.3%**. The remaining TLV cost sits under inlined `stepOnce` (error-info per-call trace push/pop candidates — D-450 lever 2) | `zig build test -Dwasm` ×2 (diff oracle + the ADR-0125 dual-backend budget kill test) + `budget_thread_ownership` + `phase16_eval_budget` e2e green on the hoisted binary + smoke | D-450, D-571, D-386, ADR-0125 |
| O-053 | `runtime/collection/value_sort.zig` (new) + Arrays/Collections/higher_order sort sites + 3 tiny-N insertion swaps — **binary-size axis** (ADR-0172 L6) | Every `std.mem.sort(T, items, ctx, less)` call monomorphizes ~25KB of block sort per (element, context, comparator) triple — 9 call sites had accreted ~224KB / 93 symbols; three of them sort `[]Value` with site-local comparators, and three more sort tiny-N arrays (txn ref set / fn arities / bencode dict keys) where a full block sort buys nothing | ONE shared instantiation: `value_sort.sort(items, ctx: *anyopaque, less: fn-pointer)` — the three Value sites pass their context opaquely (cast back inside the comparator); the three tiny-N cold sites drop to `std.sort.insertion` (~1KB each). Hot index-permutation sort (O-010 sortByKeysFn) keeps its dedicated instantiation | Shipped ReleaseSafe **7,073,080 → 6,974,584 B (−98.5KB)**; sort perf NEUTRAL (50k-element `(sort xs)` ×10: 1.00±0.03 hyperfine A/B — the indirect call is invisible next to valueCompare) | `zig build test -Dwasm` ×2 + new value_sort unit + probes: `(sort [3 1 2])`, string sort, `java.util.Collections/sort` + smoke | ADR-0172 (L6), O-010, D-515 |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01b9868d0..2f039e20c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -97,6 +97,30 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
native classes, because `reify` accepts only a protocol Var or a marker name
and rejects a class value outright.
+### Changed
+
+- **`(seq v)` / `(rest v)` / `(next v)` on a vector are now a VIEW, not a copy.**
+ They used to build an eager `PersistentList` — n cell allocations and n trie
+ descents per call. They now return an `ArraySeq` holding `(vector, index)`,
+ which is what Clojure does (`PersistentVector$ChunkedSeq`). All three drop
+ from O(n) to O(1): measured over a 16000-element vector, `seq` 2123 → 0.36 ms,
+ `rest` 2253 → 0.39 ms, `next` 2164 → 0.38 ms per 2000 ops — from 2184-3549×
+ slower than JVM Clojure 1.12.4 to 1.7-2.6× faster. `nth` on such a seq is now
+ O(1) too, where clj still walks. See `O-058` in `.dev/optimizations.md`.
+
+ Everything observable is unchanged — it prints as a seq, is `=` to the list
+ and the vector with the same elements, hashes with them, and works as a map
+ key — with ONE exception: **`(class (seq [1 2 3]))` now answers `ArraySeq`
+ where it used to answer `PersistentList`.** Neither matches the JVM's
+ `PersistentVector$ChunkedSeq`; code that branches on that class name was
+ already non-portable, and `seq?` / `sequential?` / `coll?` / `counted?` are
+ unaffected.
+
+ This closes item 2 of `.dev/bench/README.md`'s ranked list — which that file
+ had wrongly recorded as blocked on item 1 (`SubVector`). See the correction
+ there: `next` of a seq is a seq, so the vector-seq needed `(vector, index)`
+ and never needed a smaller vector. `subvec` remains eager (D-044).
+
### Fixed
- **Host surfaces returning a large integer no longer hand back a Double.**
diff --git a/README.md b/README.md
index 97e06d5ab..15460b44d 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,11 @@ still use what other languages have already built.
- **A lot of everyday Clojure runs** — `clojure.core` plus a growing set of
standard-library namespaces (`clojure.string` / `set` / `walk` / `zip` /
`edn` / `data.json` / `data.csv` / `math` / `pprint` / `test` / `tools.cli` …).
+- **Persistent collections at JVM-comparable speed** — vectors, maps and sets
+ trade wins with JVM Clojure op by op, and `seq` / `rest` / `next` over a
+ vector are currently faster. Tight numeric loops are still the JVM's, since
+ cljw interprets where the JVM JIT-compiles;
+ [measured, both sides](docs/works/collection_performance.md).
- **A CIDER-compatible nREPL** — `cljw nrepl` and connect your editor to
evaluate real Clojure live.
- **WebAssembly as an FFI** — `(wasm/load "mod.wasm")` then
@@ -170,6 +175,9 @@ and should not be relied on. The source is the most complete example of what
- [`docs/testing.md`](./docs/testing.md) — how to run the suite, what each of
the eight test layers is for, and where a new test belongs. **Read this
before sending a patch.**
+- [`docs/works/collection_performance.md`](./docs/works/collection_performance.md)
+ — measured collection performance against JVM Clojure, and the honest split
+ between interpreter speed and data-structure speed.
- [`bench/README.md`](./bench/README.md) — the benchmark catalogue and
cross-language cold-start numbers.
diff --git a/docs/README.md b/docs/README.md
index 18fc545f2..cbd1abb8b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -33,6 +33,9 @@ GitHub wiki.
- [`landscape.md`](./landscape.md) — where cljw sits among Clojure runtimes.
- [`works/binary_size.md`](./works/binary_size.md) — the measured size
comparison behind the headline number.
+- [`works/collection_performance.md`](./works/collection_performance.md) — cljw
+ against JVM Clojure on the persistent data structures, measured: where it is
+ faster, where it is slower, and the one operation still to fix.
- [`../CHANGELOG.md`](../CHANGELOG.md) — release history; the SSOT for what
changed when.
diff --git a/docs/works/collection_performance.md b/docs/works/collection_performance.md
new file mode 100644
index 000000000..be1e35d73
--- /dev/null
+++ b/docs/works/collection_performance.md
@@ -0,0 +1,136 @@
+# Collection performance — cljw against JVM Clojure, measured
+
+The question this page answers came from a user: *for performance, should cljw
+reimplement the same immutable algorithms core Clojure does?*
+
+It is answered by measurement rather than by reading either implementation. The
+internal ledger of individual optimizations lives in `.dev/optimizations.md`;
+this page is the user-facing view — what you can expect, and where cljw is still
+slower.
+
+**Short version.** The persistent data structures are in the same league as the
+JVM's, trading wins op by op. The *interpreter* is far behind the JVM's JIT and
+will stay behind until cljw compiles. Every catastrophic gap ever measured here
+turned out to be a specific algorithmic mistake in cljw, not a consequence of
+Zig-vs-Java or of the GC; three of the four found so far are fixed, and one
+remains.
+
+## Method
+
+`.dev/bench/scaling_form.edn` runs a **fixed** number of operations against
+collections of growing size `[1000 4000 16000]`, after warmup iterations so the
+JVM has JIT-compiled. Flat wall-clock across sizes means the operation is
+O(1)/O(log n) per call; time growing with the size factor means O(n).
+
+Both runtimes are exercised on the same box, same probe, in the same session:
+cljw built `-Doptimize=ReleaseSafe`, against JVM **Clojure 1.12.4**. Numbers
+below are at **n = 16000**. Reproduce with:
+
+```sh
+cljw .dev/bench/scaling.clj # cljw side, human-readable
+```
+
+Sub-microsecond results are reported best-of-7 with an empty-loop baseline
+subtracted — without that, per-call noise and loop overhead swamp the signal and
+produce confident-looking nonsense in both directions.
+
+## Two different questions, which are easy to conflate
+
+### 1. Interpretation speed — the JVM is far ahead
+
+| per 20000 iterations | clj 1.12.4 | cljw |
+|---|---|---|
+| empty `dotimes` | **0.043 ms** | 0.900 ms |
+
+An empty loop costs cljw ~45 ns per iteration against the JVM's ~2.2 ns. That is
+not Zig losing to Java. It is an **interpreter** (tree-walk / VM) losing to a
+**JIT** that has compiled the loop to native code with the induction variable in
+a register. No amount of collection work changes it; only a compiler would. This
+is tracked as the runtime's own "gap area III" (fusion → a narrow ARM64 JIT).
+
+Every number in the next section carries this overhead. Read them as *relative*
+structural cost, not as a language comparison.
+
+### 2. The persistent structures themselves — same league, trading wins
+
+| per 20000 ops, n=16000 | clj 1.12.4 | cljw | faster |
+|---|---|---|---|
+| vector `assoc` (mid) | 49.38 ms | **21.43 ms** | cljw, 2.3× |
+| map `assoc` | 73.93 ms | **18.77 ms** | cljw, 3.9× |
+| vector `nth` | **0.373 ms** | 0.879 ms | clj, 2.4× |
+| map `dissoc` | **2.62 ms** | 8.15 ms | clj, 3.1× |
+| vector `conj` | **1.75 ms** | 7.62 ms | clj, 4.4× |
+
+cljw is ahead on both `assoc`es — its bump-allocating GC makes the path-copy
+cheaper — and behind on `conj` and `dissoc`. There are no 1000× effects in
+either direction once the algorithms match.
+
+So: this is not "Zig beats the JVM". It is "the data structures are sound, and
+the catastrophic numbers are bugs."
+
+## The catastrophic gaps were all algorithmic
+
+Four operations were once 1000×–7000× slower than JVM Clojure. Every one was a
+specific mistake, of exactly two kinds.
+
+**An abstraction-barrier violation** — the Clojure layer re-implementing an
+operation the Zig layer already provided correctly:
+
+| op | was | now | vs clj |
+|---|---|---|---|
+| `pop` (vector) | 8278 ms | **2.47 ms** | 1.93 ms — within 1.3× |
+| `count` (list) | 481 ms | **0.19 ms** | 0.18 ms — parity |
+
+`pop` was `(into [] (take (dec (count coll)) coll))` in `core.clj`, rebuilding a
+whole fresh trie, while `vector.zig` already had a faithful
+`PersistentVector.pop()` that nothing called. `count` on a list walked the chain
+even though every cons cell already stored its length — the stored number was
+distrusted because a cell can head a mixed chain where it means something else.
+The fix was to make the field say *when* it is exact, not to compute it again.
+
+**Materialising where Clojure keeps a view** — the more systemic kind:
+
+| op | was | now | vs clj |
+|---|---|---|---|
+| `seq` (vector) | 2123 ms | **0.36 ms** | 0.60 ms — cljw 1.7× faster |
+| `rest` (vector) | 2253 ms | **0.39 ms** | 0.87 ms — cljw 2.2× faster |
+| `next` (vector) | 2164 ms | **0.38 ms** | 0.99 ms — cljw 2.6× faster |
+
+`(seq v)` built an eager `PersistentList` copy — one allocation and one trie
+descent per element, per call. Clojure never copies: its seq over a vector is a
+**view** holding `(vector, index)`. cljw now does the same, so all three are
+O(1), and `nth` on such a seq is O(1) too (Clojure walks there).
+
+This one mattered out of proportion to any single benchmark, because `seq` /
+`rest` / `next` are what every hand-written `loop`/`recur` walk and every
+library seq walk uses. It did not show up as a benchmark regression precisely
+because the *idiomatic* paths — `reduce`, `doseq`, `into` — already bypassed
+`seq` with index-walk and chunk-drain fast paths.
+
+## What is still slow
+
+| op | clj 1.12.4 | cljw | |
+|---|---|---|---|
+| **`subvec`** | **1.06 ms** | **9215 ms** | **~7500× slower** |
+
+`subvec` eagerly copies where Clojure returns a `SubVector` sharing the parent's
+root and tail. It is the last measured catastrophic gap, and the fix is a real
+design decision rather than a missing call: cljw's heap-tag space is a fixed
+64-slot census, so a new vector *representation* has to either spend one of the
+reserved names or thread an offset through every existing vector operation.
+Until it lands, prefer `(into [] (take n (drop k v)))`-style slicing only where
+you would have paid for a copy anyway, and avoid `subvec` in a loop.
+
+## What this means if you are choosing cljw
+
+- **Data-structure-heavy code is fine.** Persistent vectors, maps and sets
+ perform comparably to the JVM's, sometimes better.
+- **Tight numeric loops are not cljw's strength yet.** The interpreter overhead
+ dominates, and that is a compiler problem, not a library one.
+- **Startup is where cljw wins outright** — milliseconds against the JVM's
+ ~1–2 s, which is the whole point for CLI tools, scripts and serverless. See
+ [`binary_size.md`](./binary_size.md) for the size side of the same story.
+- **If you hit something pathologically slow, it is probably a bug.** Every gap
+ on this page was, and each was found by running a probe rather than by reading
+ code. `.dev/bench/` has the harness; an issue with a reproducing form is
+ useful.
diff --git a/src/eval/backend/clojure_lang_method.zig b/src/eval/backend/clojure_lang_method.zig
index 6960e057a..97e9c7358 100644
--- a/src/eval/backend/clojure_lang_method.zig
+++ b/src/eval/backend/clojure_lang_method.zig
@@ -107,7 +107,7 @@ fn isNativeTransient(tag: Value.Tag) bool {
/// still raises the caller's `<.member>` error, not a spurious core-fn coercion.
fn isNativeCollection(tag: Value.Tag) bool {
return switch (tag) {
- .list, .vector, .array_map, .hash_map, .hash_set, .sorted_map, .sorted_set, .persistent_queue, .range, .map_entry, .cons, .lazy_seq => true,
+ .list, .vector, .array_map, .hash_map, .hash_set, .sorted_map, .sorted_set, .persistent_queue, .range, .array_seq, .map_entry, .cons, .lazy_seq => true,
else => false,
};
}
@@ -118,7 +118,7 @@ fn isNativeCollection(tag: Value.Tag) bool {
/// caller's `<.member>` error here.
fn isSequentialCollection(tag: Value.Tag) bool {
return switch (tag) {
- .list, .vector, .persistent_queue, .range, .map_entry, .cons, .lazy_seq => true,
+ .list, .vector, .persistent_queue, .range, .array_seq, .map_entry, .cons, .lazy_seq => true,
else => false,
};
}
diff --git a/src/lang/primitive/collection.zig b/src/lang/primitive/collection.zig
index 25d795b21..afb20cda5 100644
--- a/src/lang/primitive/collection.zig
+++ b/src/lang/primitive/collection.zig
@@ -45,6 +45,7 @@ const IPS_FQCN: []const u8 = "IPersistentSet";
const sequence = @import("sequence.zig");
const range_mod = @import("../../runtime/collection/range.zig");
const vector = @import("../../runtime/collection/vector.zig");
+const array_seq = @import("../../runtime/collection/array_seq.zig");
const java_array = @import("../../runtime/collection/java_array.zig");
const list = @import("../../runtime/collection/list.zig");
const map = @import("../../runtime/collection/map.zig");
@@ -510,6 +511,25 @@ pub fn nthFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation)
}
break :blk vector.nth(coll, @intCast(idx));
},
+ // PERF: clj reaches `nth` on a vector's seq through `RT.nthFrom`, which
+ // WALKS (PersistentVector$ChunkedSeq is Counted, not Indexed). The view
+ // holds the backing vector, so the same answer is one indexed read.
+ // Index discipline mirrors the `.vector` arm. [refs: O-058]
+ .array_seq => blk: {
+ if (idx < 0) {
+ if (has_default) break :blk default;
+ break :blk error_catalog.raise(.type_arg_invalid, loc, .{
+ .fn_name = "nth",
+ .expected = "non-negative integer index",
+ .actual = "negative",
+ });
+ }
+ if (idx >= array_seq.countOf(coll)) {
+ if (has_default) break :blk default;
+ break :blk error_catalog.raise(.index_out_of_range, loc, .{ .fn_name = "nth" });
+ }
+ break :blk array_seq.nth(coll, @intCast(idx));
+ },
// A MapEntry is a 2-vector: index 0→key, 1→val (D-209 / ADR-0078).
.map_entry => blk: {
if (idx == 0 or idx == 1) break :blk map_entry.nth(coll, @intCast(idx));
diff --git a/src/lang/primitive/higher_order.zig b/src/lang/primitive/higher_order.zig
index fca5f2538..23c6d9fef 100644
--- a/src/lang/primitive/higher_order.zig
+++ b/src/lang/primitive/higher_order.zig
@@ -122,7 +122,11 @@ fn fuseBaseKind(coll: Value) FuseBaseKind {
cur = vector_mod.nth(fuse, 1); // fuse = [xform source]
}
return switch (cur.tag()) {
- .range, .vector, .chunked_cons => .chunked,
+ // `.array_seq` joins the CONCRETE bases: its elements are reachable
+ // without forcing anything, which is the property this enum is really
+ // sorting on (only `.unchunked` is acted upon — it alone delegates to
+ // `-fused-reduce`). [refs: O-058]
+ .range, .vector, .chunked_cons, .array_seq => .chunked,
.list, .cons, .nil => .unchunked,
else => .unknown,
};
diff --git a/src/lang/primitive/metadata.zig b/src/lang/primitive/metadata.zig
index 44a9ad086..d9602f910 100644
--- a/src/lang/primitive/metadata.zig
+++ b/src/lang/primitive/metadata.zig
@@ -18,6 +18,7 @@ const error_catalog = @import("../../runtime/error/catalog.zig");
const SourceLocation = error_mod.SourceLocation;
const dispatch = @import("../../runtime/dispatch.zig");
const vector = @import("../../runtime/collection/vector.zig");
+const array_seq = @import("../../runtime/collection/array_seq.zig");
const map = @import("../../runtime/collection/map.zig");
const set = @import("../../runtime/collection/set.zig");
const list = @import("../../runtime/collection/list.zig");
@@ -83,6 +84,9 @@ pub fn withMetaFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
.array_map, .hash_map => try map.withMeta(rt, v, m),
.hash_set => try set.withMeta(rt, v, m),
.list => try list.withMeta(rt, v, m),
+ // A seq is IObj on the JVM (ASeq), so a vector VIEW must round-trip
+ // meta as the eager list it replaced did.
+ .array_seq => try array_seq.withMeta(rt, v, m),
.lazy_seq => try lazy_seq.withMeta(rt, v, m),
// D-304 / ADR-0110: mints a fresh non-interned symbol carrying meta.
// Keyword stays in the `else` arm (clj rejects keyword metadata).
diff --git a/src/lang/primitive/sequence.zig b/src/lang/primitive/sequence.zig
index 40a1d428c..b0bc23946 100644
--- a/src/lang/primitive/sequence.zig
+++ b/src/lang/primitive/sequence.zig
@@ -51,6 +51,7 @@ const string_collection = @import("../../runtime/collection/string.zig");
const chunked_cons = @import("../../runtime/collection/chunked_cons.zig");
const chunk_transform = @import("chunk_transform.zig");
const range = @import("../../runtime/collection/range.zig");
+const array_seq = @import("../../runtime/collection/array_seq.zig");
const java_array = @import("../../runtime/collection/java_array.zig");
const transient_vector = @import("../../runtime/collection/transient/transient_vector.zig");
const transient_array_map = @import("../../runtime/collection/transient/transient_array_map.zig");
@@ -120,6 +121,10 @@ pub fn countFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
.chunked_cons => Value.initInteger(@intCast(chunked_cons.count(coll))),
// PERF: O(1) precomputed range length, no element walk [refs: O-001]
.range => Value.initInteger(range.countOf(coll)),
+ // PERF: a vector view counts by subtraction — backing count minus the
+ // read cursor — where the eager list copy it replaced had to allocate
+ // n cells before it could answer. [refs: O-058]
+ .array_seq => Value.initInteger(@intCast(array_seq.countOf(coll))),
// deftype/reify share one arm (rseq's pattern): clj RT.count routes BOTH
// by the same rules, so a reify must not bypass the deftype logic (D-422
// twin). A defrecord counts by field_count (with a -count override honoured
@@ -290,7 +295,13 @@ pub fn seqFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation)
return try stringToList(rt, s);
},
.list, .cons => list.seq(coll),
- .vector => if (vector.count(coll) > 0) try vectorToList(rt, coll) else .nil_val,
+ // PERF: a VIEW, not a copy — `(seq v)` is two words holding
+ // `(vector, index)` like the JVM's PersistentVector$ChunkedSeq, where
+ // this used to build an eager n-cell PersistentList. `make` answers nil
+ // for an empty vector, so no count guard is needed. [refs: O-058]
+ .vector => try array_seq.make(rt, coll, 0),
+ // An ArraySeq is already a seq; `seq` on one is identity.
+ .array_seq => coll,
// ADR-0105: a Java array is Seqable (clj parity), eager element seq.
.array => if (java_array.alength(coll) > 0) try arrayToList(rt, coll) else .nil_val,
// A MapEntry seqs as `(key val)` (D-209 / ADR-0078).
@@ -397,6 +408,8 @@ pub fn firstFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
.chunked_cons => chunked_cons.first(coll),
// PERF: O(1) head (start), no chunk materialised for just first [refs: O-001]
.range => range.first(coll),
+ // PERF: O(1) indexed read through the view [refs: O-058]
+ .array_seq => array_seq.first(coll),
.lazy_seq => try lazy_seq.first(rt, env, coll),
.string => firstStringCodepoint(coll),
.array_map, .hash_map, .hash_set => blk: {
@@ -438,7 +451,9 @@ pub fn restFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation)
const coll = args[0];
const raw: Value = if (coll.isNil()) .nil_val else switch (coll.tag()) {
.list, .cons => list.rest(coll),
- .vector => if (vector.count(coll) > 1) try vectorTailAsList(rt, coll, 1) else .nil_val,
+ // PERF: advance the view instead of copying the tail [refs: O-058]
+ .vector => try array_seq.make(rt, coll, 1),
+ .array_seq => try array_seq.rest(rt, coll),
.map_entry => try list.consHeap(rt, map_entry.valOf(coll), .nil_val), // (rest [k v]) → (v)
.persistent_queue => blk: {
const s = try persistent_queue.seqOf(rt, coll);
@@ -491,7 +506,11 @@ pub fn nextFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation)
// For an explicit-nil tail, seqFn(nil) is nil — unchanged.
break :blk try seqFn(rt, env, &.{r}, loc);
},
- .vector => if (vector.count(coll) > 1) try vectorTailAsList(rt, coll, 1) else .nil_val,
+ // PERF: advance the view instead of copying the tail. `make` answers
+ // nil past the end, which is exactly `next`'s empty-tail rule (`rest`
+ // lifts that nil to the empty list at its own exit). [refs: O-058]
+ .vector => try array_seq.make(rt, coll, 1),
+ .array_seq => try array_seq.rest(rt, coll),
.map_entry => try list.consHeap(rt, map_entry.valOf(coll), .nil_val), // (next [k v]) → (v)
.persistent_queue => blk: {
const s = try persistent_queue.seqOf(rt, coll);
@@ -597,7 +616,12 @@ pub fn emptyFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
.sorted_map => try sorted.emptyMapBy(rt, coll.decodePtr(*const sorted.SortedMap).comparator), // repr-decode-ok: comparator field read; sorted_map has a single backing
.sorted_set => try sorted.emptySetBy(rt, coll.decodePtr(*const sorted.SortedSet).map.decodePtr(*const sorted.SortedMap).comparator), // repr-decode-ok: comparator field read; sorted_set has a single backing
.persistent_queue => try persistent_queue.emptyQueue(rt), // (empty q) → EMPTY
- .list, .cons => try list.emptyList(rt), // (empty '(1 2)) → () (D-164)
+ // Every ISeq empties to `()`, not just a cons chain: clj's `ASeq.empty()`
+ // returns `PersistentList.EMPTY` for a range / lazy seq / chunked seq /
+ // vector view alike. `.range`, `.lazy_seq` and `.chunked_cons` were
+ // reaching the `else` dispatch and raising, which is a pre-existing gap
+ // this arm closes alongside `.array_seq`. (D-164)
+ .list, .cons, .range, .lazy_seq, .chunked_cons, .array_seq => try list.emptyList(rt),
// JVM Clojure: (empty "hi") → nil (String is not a Clojure
// collection per IPersistentCollection contract). cw v1
// follows the same semantic; a 0-length string is not what
@@ -620,11 +644,6 @@ pub fn emptyFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
// --- helpers ---
-/// vector → eager list build (head-to-tail copy).
-fn vectorToList(rt: *Runtime, vec: Value) !Value {
- return try vectorTailAsList(rt, vec, 0);
-}
-
/// Java array → eager list view (ADR-0105). Built head-to-tail so generic seq
/// walkers (vec / map / reduce / into) see the array elements in order.
fn arrayToList(rt: *Runtime, arr: Value) !Value {
@@ -638,20 +657,6 @@ fn arrayToList(rt: *Runtime, arr: Value) !Value {
return acc;
}
-/// vector[start..] → list view, built eager (head-to-tail).
-fn vectorTailAsList(rt: *Runtime, vec: Value, start: u32) !Value {
- const n = vector.count(vec);
- if (n <= start) return .nil_val;
- var acc: Value = .nil_val;
- var i = n;
- while (i > start) {
- i -= 1;
- const elt = try vector_nth_safe(vec, i, .{ .line = 0, .column = 0 });
- acc = try list.consHeap(rt, elt, acc);
- }
- return acc;
-}
-
/// string → eager char list build. Elements are `.char` Values (JVM
/// parity: `(seq "abc")` → `(\a \b \c)`, chars not 1-char strings).
fn stringToList(rt: *Runtime, s: []const u8) !Value {
@@ -701,6 +706,7 @@ fn firstOfSeq(rt: *Runtime, env: *Env, sv: Value, loc: SourceLocation) anyerror!
.list, .cons => list.first(sv),
.chunked_cons => chunked_cons.first(sv),
.range => range.first(sv),
+ .array_seq => array_seq.first(sv),
.lazy_seq => try lazy_seq.first(rt, env, sv),
else => return error_catalog.raise(.type_arg_invalid, loc, .{
.fn_name = "first",
@@ -716,6 +722,7 @@ fn restOfSeq(rt: *Runtime, env: *Env, sv: Value, loc: SourceLocation) anyerror!V
.list, .cons => list.rest(sv),
.chunked_cons => try chunked_cons.rest(rt, sv),
.range => try chunked_cons.rest(rt, try range.seqChunk(rt, sv)),
+ .array_seq => try array_seq.rest(rt, sv),
.lazy_seq => try lazy_seq.rest(rt, env, sv),
else => return error_catalog.raise(.type_arg_invalid, loc, .{
.fn_name = "rest",
@@ -740,6 +747,9 @@ fn seqNext(rt: *Runtime, env: *Env, cur: Value) anyerror!Value {
// as non-nil). Within-chunk rest is a chunked_cons → seq is a no-op.
.chunked_cons => try lazy_seq.seq(rt, env, try chunked_cons.rest(rt, cur)),
.range => try lazy_seq.seq(rt, env, try chunked_cons.rest(rt, try range.seqChunk(rt, cur))),
+ // No lazy tail is possible over an immutable vector, so the view's
+ // own nil-past-the-end is already the walk-terminating answer.
+ .array_seq => try array_seq.rest(rt, cur),
.lazy_seq => try lazy_seq.next(rt, env, cur),
else => .nil_val,
};
diff --git a/src/runtime/collection/array_seq.zig b/src/runtime/collection/array_seq.zig
new file mode 100644
index 000000000..a0b15edd2
--- /dev/null
+++ b/src/runtime/collection/array_seq.zig
@@ -0,0 +1,313 @@
+// SPDX-License-Identifier: EPL-2.0
+//! ArraySeq (A14) — an O(1) INDEXED VIEW seq over an immutable indexed
+//! collection, standing in for the JVM's `PersistentVector$ChunkedSeq`.
+//!
+//! `(seq v)` / `(rest v)` / `(next v)` on a vector used to build an EAGER
+//! `PersistentList` copy (`sequence.zig` `vectorToList` / `vectorTailAsList`):
+//! n `Cons` allocations plus n trie descents per call, which measured
+//! 2184-3549x slower than clj on `.dev/bench/scaling_form.edn`. Clojure never
+//! copies — its seq over a vector is a VIEW holding `(vector, index)`. This is
+//! that view: a fixed-size cell (backing + cursor + meta), allocated once per
+//! step whatever the length, and no element is ever moved.
+//!
+//! Tag: A14 `.array_seq` was a day-1 reservation already declared across every
+//! protocol table (`interface_membership.zig` ISeq / Sequential / Counted /
+//! IHashEq / java.util.List / java.util.Collection) and already named in
+//! `class_name.zig`, but had NO producer — `equal.zig`'s header anticipated
+//! exactly this ("...when a producer mints those tags"). So this costs no slot
+//! from `heap_tag.unallocated`: the address and its protocol membership were
+//! already spent, only the producer was missing.
+//!
+//! ## Invariant: an ArraySeq is never empty
+//!
+//! `make` returns nil when `index >= count(backing)`, mirroring `.range`
+//! ("empty → nil at construction, so a count-0 LongRange never exists").
+//! That is what makes `first` total and lets `countOf` be a subtraction.
+//!
+//! ## Why vectors only, for now
+//!
+//! `backing` is typed as a `Value` and read through `backingCount` / `elementAt`
+//! switches so a second producer is a new arm, not a new type (OCP). It is NOT
+//! yet wired for `.array`: a Java array is MUTABLE, so a view over one aliases
+//! storage a caller can still write, and `(seq arr)` would stop being a
+//! snapshot. The JVM's `ArraySeq` accepts that aliasing; adopting it here is a
+//! semantic decision, not an extension, so `arrayToList` keeps copying until
+//! it is made deliberately.
+//!
+//! ## GC
+//!
+//! Unlike `.range` this holds a `Value` child, so it registers a trace fn.
+//! `make` performs exactly ONE allocation and stores `backing` after it, so a
+//! collect inside `alloc` must not sweep `backing` — every call site passes a
+//! value already rooted by its own caller (`args[0]` of seq/rest/next, or the
+//! receiver ArraySeq whose trace reaches the same backing). No fabrication
+//! bracket is needed for a single-alloc builder. [ref: .dev/gc_rooting.md §A]
+
+const std = @import("std");
+const value_mod = @import("../value/value.zig");
+const Value = value_mod.Value;
+const HeapHeader = value_mod.HeapHeader;
+const Runtime = @import("../runtime.zig").Runtime;
+const tag_ops = @import("../gc/tag_ops.zig");
+const gc_heap_mod = @import("../gc/gc_heap.zig");
+const mark_sweep = @import("../gc/mark_sweep.zig");
+const vector = @import("vector.zig");
+
+/// ArraySeq (A14) — `backing` viewed from `index` to its end.
+pub const ArraySeq = extern struct {
+ header: HeapHeader,
+ _pad: [2]u8 = .{ 0, 0 },
+ /// Read cursor into `backing`. Invariant: `index < backingCount(backing)`.
+ index: u32 = 0,
+ /// The viewed collection — a `.vector` (see the "vectors only" note).
+ backing: Value = Value.nil_val,
+ /// Optional metadata map. A seq is IObj on the JVM (`ASeq` implements it),
+ /// so `(with-meta (seq v) m)` must round-trip; dropping it would regress
+ /// the eager list this replaced, which carried meta for free.
+ meta: Value = Value.nil_val,
+
+ comptime {
+ std.debug.assert(@alignOf(ArraySeq) >= 8);
+ std.debug.assert(@offsetOf(ArraySeq, "header") == 0);
+ }
+};
+
+/// Element count of a viewable backing collection. A tag with no arm is not
+/// viewable and answers 0, which makes `make` return nil rather than mint an
+/// ArraySeq whose `first` would be undefined.
+fn backingCount(backing: Value) u32 {
+ return switch (backing.tag()) {
+ .vector => vector.count(backing),
+ else => 0,
+ };
+}
+
+/// `backing[i]` for a viewable backing. Callers must satisfy
+/// `i < backingCount(backing)`; the ArraySeq invariant guarantees it.
+fn elementAt(backing: Value, i: u32) Value {
+ return switch (backing.tag()) {
+ .vector => vector.nth(backing, i),
+ else => Value.nil_val,
+ };
+}
+
+/// True when `seq` over this collection can be served as a view rather than a
+/// copy. The one predicate producers consult, so adding a backing type is a
+/// single arm in `backingCount`/`elementAt` plus this.
+pub fn viewable(backing: Value) bool {
+ return backing.tag() == .vector;
+}
+
+/// View `backing` from `index`. nil when the view would be empty — an ArraySeq
+/// is never empty (see the header invariant).
+pub fn make(rt: *Runtime, backing: Value, index: u32) !Value {
+ if (index >= backingCount(backing)) return Value.nil_val;
+ const as = try rt.gc.alloc(ArraySeq);
+ as.* = .{
+ .header = HeapHeader.init(.array_seq),
+ .index = index,
+ .backing = backing,
+ };
+ return Value.encodeHeapPtr(.array_seq, as);
+}
+
+pub fn backingOf(v: Value) Value {
+ return v.decodePtr(*const ArraySeq).backing;
+}
+
+pub fn indexOf(v: Value) u32 {
+ return v.decodePtr(*const ArraySeq).index;
+}
+
+/// `(first as)` — total, by the never-empty invariant.
+pub fn first(v: Value) Value {
+ const as = v.decodePtr(*const ArraySeq);
+ return elementAt(as.backing, as.index);
+}
+
+/// `(count as)` — O(1): the backing's count minus the cursor. This is the
+/// whole point of the `count` field a copy would have had to recompute.
+pub fn countOf(v: Value) u32 {
+ const as = v.decodePtr(*const ArraySeq);
+ return backingCount(as.backing) - as.index;
+}
+
+/// `(nth as i)` — O(1) into the backing, no walk.
+pub fn nth(v: Value, i: u32) Value {
+ const as = v.decodePtr(*const ArraySeq);
+ return elementAt(as.backing, as.index + i);
+}
+
+/// `(rest as)` / `(next as)` — the same view one step along, or nil at the end.
+/// Callers apply the `rest`/`next` empty-tail convention (`rest` lifts nil to
+/// the empty list at its own exit, `next` keeps nil).
+pub fn rest(rt: *Runtime, v: Value) !Value {
+ const as = v.decodePtr(*const ArraySeq);
+ return try make(rt, as.backing, as.index + 1);
+}
+
+/// Drop `n` elements in ONE step — `(nthrest as n)` without walking n cells.
+pub fn drop(rt: *Runtime, v: Value, n: u32) !Value {
+ const as = v.decodePtr(*const ArraySeq);
+ return try make(rt, as.backing, as.index +| n);
+}
+
+pub fn metaOf(v: Value) Value {
+ return v.decodePtr(*const ArraySeq).meta;
+}
+
+/// `(with-meta as m)` — a fresh view over the same backing at the same cursor.
+/// Not routed through `make`, which carries no meta and would re-check an
+/// invariant the receiver already satisfies. Meta does NOT propagate to `rest`,
+/// matching the JVM: `ASeq.next()` returns a seq without the receiver's meta.
+pub fn withMeta(rt: *Runtime, v: Value, m: Value) !Value {
+ const src = v.decodePtr(*const ArraySeq);
+ const as = try rt.gc.alloc(ArraySeq);
+ as.* = .{
+ .header = HeapHeader.init(.array_seq),
+ .index = src.index,
+ .backing = src.backing,
+ .meta = m,
+ };
+ return Value.encodeHeapPtr(.array_seq, as);
+}
+
+/// Per-tag trace fn — the `backing` and `meta` children.
+pub fn traceArraySeq(gc_ptr: *anyopaque, header: *HeapHeader) void {
+ const gc: *gc_heap_mod.GcHeap = @ptrCast(@alignCast(gc_ptr));
+ const as: *ArraySeq = @ptrCast(@alignCast(header));
+ if (as.backing.heapHeader()) |h| mark_sweep.mark(gc, h);
+ if (as.meta.heapHeader()) |h| mark_sweep.mark(gc, h);
+}
+
+/// Register the ArraySeq trace fn. Idempotent; called from `Runtime.init`.
+pub fn registerGcHooks() void {
+ tag_ops.registerTrace(.array_seq, &traceArraySeq);
+}
+
+// --- tests ---
+
+const testing = std.testing;
+
+const RuntimeFixture = struct {
+ threaded: std.Io.Threaded,
+ rt: Runtime,
+
+ fn init() RuntimeFixture {
+ var fix: RuntimeFixture = .{
+ .threaded = std.Io.Threaded.init(testing.allocator, .{}),
+ .rt = undefined,
+ };
+ fix.rt = Runtime.init(fix.threaded.io(), testing.allocator);
+ return fix;
+ }
+ fn deinit(self: *RuntimeFixture) void {
+ self.rt.deinit();
+ self.threaded.deinit();
+ }
+};
+
+test "ArraySeq struct layout: HeapHeader at offset 0, 8-byte aligned" {
+ try testing.expectEqual(@as(usize, 0), @offsetOf(ArraySeq, "header"));
+ try testing.expect(@alignOf(ArraySeq) >= 8);
+ // 8-byte HeapHeader + cursor + backing + meta. Fixed size no matter how
+ // many elements the view spans — that constancy, not the absolute number,
+ // is what replaces the n-cell copy.
+ try testing.expectEqual(@as(usize, 32), @sizeOf(ArraySeq));
+ try testing.expectEqual(@as(usize, 16), @offsetOf(ArraySeq, "backing"));
+}
+
+test "make: never mints an empty view; count is backing minus cursor (O-058)" {
+ var fix = RuntimeFixture.init();
+ defer fix.deinit();
+
+ var items: [4]Value = .{
+ Value.initInteger(10), Value.initInteger(20),
+ Value.initInteger(30), Value.initInteger(40),
+ };
+ const v = try vector.fromSlice(&fix.rt, &items);
+
+ // An empty view is nil, not a count-0 ArraySeq — the invariant that makes
+ // `first` total. Both the past-the-end and the exactly-at-the-end cases.
+ try testing.expect((try make(&fix.rt, v, 4)).isNil());
+ try testing.expect((try make(&fix.rt, v, 99)).isNil());
+ // An empty vector has no view at all.
+ try testing.expect((try make(&fix.rt, vector.empty(), 0)).isNil());
+ // A non-viewable backing answers nil rather than minting a view whose
+ // `first` would be undefined.
+ try testing.expect((try make(&fix.rt, Value.initInteger(1), 0)).isNil());
+
+ const s = try make(&fix.rt, v, 0);
+ try testing.expectEqual(Value.Tag.array_seq, s.tag());
+ try testing.expectEqual(@as(u32, 4), countOf(s));
+ try testing.expectEqual(@as(i48, 10), first(s).asInteger());
+ try testing.expectEqual(@as(i48, 30), nth(s, 2).asInteger());
+
+ // The cursor is subtracted from the backing count at every offset — a view
+ // that reported the BACKING's count would pass at offset 0 and fail here.
+ const s2 = try make(&fix.rt, v, 2);
+ try testing.expectEqual(@as(u32, 2), countOf(s2));
+ try testing.expectEqual(@as(i48, 30), first(s2).asInteger());
+ try testing.expectEqual(@as(i48, 40), nth(s2, 1).asInteger());
+ // The view shares the backing rather than copying it.
+ try testing.expectEqual(@intFromEnum(v), @intFromEnum(backingOf(s2)));
+ try testing.expectEqual(@as(u32, 2), indexOf(s2));
+}
+
+test "rest / drop advance the cursor and terminate at the end (O-058)" {
+ var fix = RuntimeFixture.init();
+ defer fix.deinit();
+
+ var items: [3]Value = .{ Value.initInteger(1), Value.initInteger(2), Value.initInteger(3) };
+ const v = try vector.fromSlice(&fix.rt, &items);
+ const s = try make(&fix.rt, v, 0);
+
+ const r1 = try rest(&fix.rt, s);
+ try testing.expectEqual(@as(i48, 2), first(r1).asInteger());
+ const r2 = try rest(&fix.rt, r1);
+ try testing.expectEqual(@as(i48, 3), first(r2).asInteger());
+ // One step past the last element is nil — the walk terminates exactly, and
+ // `rest` never yields a count-0 view for a caller to `first`.
+ try testing.expect((try rest(&fix.rt, r2)).isNil());
+
+ // `drop` reaches the same place in one step as repeated `rest`.
+ try testing.expectEqual(@as(i48, 3), first(try drop(&fix.rt, s, 2)).asInteger());
+ try testing.expect((try drop(&fix.rt, s, 3)).isNil());
+ // A saturating add, so a huge n cannot wrap into a valid-looking cursor.
+ try testing.expect((try drop(&fix.rt, s, std.math.maxInt(u32))).isNil());
+}
+
+test "withMeta: fresh view, same backing and cursor, meta not shared (O-058)" {
+ var fix = RuntimeFixture.init();
+ defer fix.deinit();
+
+ var items: [3]Value = .{ Value.initInteger(1), Value.initInteger(2), Value.initInteger(3) };
+ const v = try vector.fromSlice(&fix.rt, &items);
+ const s = try make(&fix.rt, v, 1);
+ try testing.expect(metaOf(s).isNil());
+
+ const m = try vector.fromSlice(&fix.rt, &items); // any heap Value stands in
+ const tagged = try withMeta(&fix.rt, s, m);
+ try testing.expectEqual(@intFromEnum(m), @intFromEnum(metaOf(tagged)));
+ // The VALUE is untouched: same backing, same cursor, same elements.
+ try testing.expectEqual(@intFromEnum(v), @intFromEnum(backingOf(tagged)));
+ try testing.expectEqual(@as(u32, 1), indexOf(tagged));
+ try testing.expectEqual(@as(u32, 2), countOf(tagged));
+ try testing.expectEqual(@as(i48, 2), first(tagged).asInteger());
+ // The receiver is unchanged — with-meta is not a mutation.
+ try testing.expect(metaOf(s).isNil());
+ // Meta does not ride along to the tail (clj's ASeq.next()).
+ try testing.expect(metaOf(try rest(&fix.rt, tagged)).isNil());
+}
+
+test "viewable gates the producers to immutable backings" {
+ // A vector is viewable; a Java array is deliberately NOT (it is mutable —
+ // see the header). Locking this keeps the "seq is a snapshot" property from
+ // being relaxed by accident rather than by decision.
+ var fix = RuntimeFixture.init();
+ defer fix.deinit();
+ var items: [1]Value = .{Value.initInteger(1)};
+ try testing.expect(viewable(try vector.fromSlice(&fix.rt, &items)));
+ try testing.expect(!viewable(Value.nil_val));
+ try testing.expect(!viewable(Value.initInteger(3)));
+}
diff --git a/src/runtime/equal.zig b/src/runtime/equal.zig
index 6d885b053..22884ee5b 100644
--- a/src/runtime/equal.zig
+++ b/src/runtime/equal.zig
@@ -37,6 +37,7 @@ const tagged_literal_mod = @import("tagged_literal.zig");
const vector = @import("collection/vector.zig");
const list = @import("collection/list.zig");
const range = @import("collection/range.zig");
+const array_seq = @import("collection/array_seq.zig");
const persistent_queue = @import("collection/persistent_queue.zig");
const map = @import("collection/map.zig");
const map_entry_mod = @import("collection/map_entry.zig");
@@ -75,7 +76,7 @@ fn isSequential(v: Value) bool {
const t = v.tag();
// A MapEntry is a 2-vector (D-209), so `(= (first {:a 1}) [:a 1])`→true.
// A queue is Sequential, so `(= (conj EMPTY 1 2) [1 2])`→true (ADR-0087).
- if (t == .vector or t == .list or t == .lazy_seq or t == .range or t == .map_entry or t == .persistent_queue)
+ if (t == .vector or t == .list or t == .lazy_seq or t == .range or t == .array_seq or t == .map_entry or t == .persistent_queue)
return true;
// A deftype/reify declaring clojure.lang.Sequential (e.g. data.finger-tree's
// double-list) compares element-wise like clj's `=` (Util.pcequiv over the
@@ -98,12 +99,15 @@ fn isCountable(v: Value) bool {
// length short-circuit drops the lazy tail (`(= (cons 1 (lazy-seq [2 3]))
// (list 1 2 3))` → wrongly false). The element walk early-outs at the first
// length mismatch anyway. Vector / map_entry / queue counts ARE reliable.
- return t == .vector or t == .map_entry or t == .persistent_queue;
+ // `.array_seq` IS countable: it views an IMMUTABLE vector, so its
+ // `backing count - index` is exact and no lazy tail can extend it.
+ return t == .vector or t == .map_entry or t == .persistent_queue or t == .array_seq;
}
fn seqLen(v: Value) u32 {
return switch (v.tag()) {
.vector => vector.count(v),
+ .array_seq => array_seq.countOf(v),
.list => list.countOf(v),
.map_entry => 2,
.persistent_queue => @intCast(persistent_queue.count(v)),
@@ -118,6 +122,9 @@ const Cursor = union(enum) {
/// A compact `.range` walked by index via `start + i*step` — O(1) per
/// element, no allocation (the seq-walk would materialise chunks).
rng: struct { v: Value, i: i64, n: i64 },
+ /// A `.array_seq` vector view walked by index through the view — O(1) per
+ /// element and rt-free, like `.vec` (the seq-walk would allocate a cell).
+ aseq: struct { v: Value, i: u32, n: u32 },
/// A MapEntry walked as the 2-vector `[key val]` (D-209).
ment: struct { v: Value, i: u32 },
/// A PersistentQueue walked front-list then rear-vector (ADR-0087).
@@ -132,6 +139,7 @@ const Cursor = union(enum) {
return switch (v.tag()) {
.vector => .{ .vec = .{ .v = v, .i = 0, .n = vector.count(v) } },
.range => .{ .rng = .{ .v = v, .i = 0, .n = range.countOf(v) } },
+ .array_seq => .{ .aseq = .{ .v = v, .i = 0, .n = array_seq.countOf(v) } },
.map_entry => .{ .ment = .{ .v = v, .i = 0 } },
.persistent_queue => .{ .q = .{ .front = persistent_queue.frontOf(v), .rear = persistent_queue.rearOf(v), .ri = 0 } },
// A `.list` cons may carry a NON-list seq as its rest (a "Cons over
@@ -170,6 +178,12 @@ const Cursor = union(enum) {
s.i += 1;
return e;
},
+ .aseq => |*s| {
+ if (s.i >= s.n) return null;
+ const e = array_seq.nth(s.v, s.i);
+ s.i += 1;
+ return e;
+ },
.ment => |*s| {
if (s.i >= 2) return null;
const e = map_entry_mod.nth(s.v, s.i);
@@ -571,7 +585,10 @@ pub fn keyEqValue(a: Value, b: Value) bool {
}
inline fn isSeqKeyTag(t: Value.Tag) bool {
- return t == .vector or t == .list or t == .map_entry;
+ // `.array_seq` qualifies for the rt-FREE path because `SeqKeyCursor` walks
+ // it by index with no forcing — the reason `.lazy_seq`/`.range` are absent
+ // does not apply to a view over an immutable vector.
+ return t == .vector or t == .list or t == .map_entry or t == .array_seq;
}
/// Symbol equality (ADR-0110): ns+name structural, metadata IGNORED — symbol
@@ -734,7 +751,7 @@ pub fn valueHash(v: Value) u32 {
// recursive, via the SAME formula so an equal vector and list
// collide into one bucket (Clojure's sequential =). Partner of
// seqKeyEq (D-092).
- .vector, .list, .map_entry, .persistent_queue => seqHash(v),
+ .vector, .list, .map_entry, .persistent_queue, .array_seq => seqHash(v),
// Map / set keys hash by content (order-independent), rt-free via
// the collection module's structure walk (D-092). Partner of
// map.contentEq / set.contentEq.
@@ -884,6 +901,8 @@ const SeqKeyCursor = struct {
const Inner = union(enum) {
vec: struct { v: Value, i: u32, n: u32 },
+ /// A `.array_seq` vector view — rt-free index walk, same as `.vec`.
+ aseq: struct { v: Value, i: u32, n: u32 },
/// A MapEntry walked as the 2-vector `[key val]` (D-209) — rt-free.
ment: struct { v: Value, i: u32 },
/// A PersistentQueue walked front-list then rear-vector (ADR-0087).
@@ -894,6 +913,7 @@ const SeqKeyCursor = struct {
fn init(v: Value) SeqKeyCursor {
return .{ .inner = switch (v.tag()) {
.vector => .{ .vec = .{ .v = v, .i = 0, .n = vector.count(v) } },
+ .array_seq => .{ .aseq = .{ .v = v, .i = 0, .n = array_seq.countOf(v) } },
.map_entry => .{ .ment = .{ .v = v, .i = 0 } },
.persistent_queue => .{ .q = .{ .front = persistent_queue.frontOf(v), .rear = persistent_queue.rearOf(v), .ri = 0 } },
else => .{ .lst = v },
@@ -908,6 +928,12 @@ const SeqKeyCursor = struct {
s.i += 1;
return e;
},
+ .aseq => |*s| {
+ if (s.i >= s.n) return null;
+ const e = array_seq.nth(s.v, s.i);
+ s.i += 1;
+ return e;
+ },
.ment => |*s| {
if (s.i >= 2) return null;
const e = map_entry_mod.nth(s.v, s.i);
@@ -1176,7 +1202,7 @@ pub fn hashConsult(v: Value) ClojureWasmError!u32 {
/// `.persistent_queue => seqHash` arm — closes a hash/eq inconsistency).
fn isSeqKeyValue(v: Value) bool {
return switch (v.tag()) {
- .vector, .list, .map_entry, .persistent_queue, .lazy_seq, .range, .chunked_cons => true,
+ .vector, .list, .map_entry, .persistent_queue, .lazy_seq, .range, .chunked_cons, .array_seq => true,
.typed_instance, .reified_instance => isSequential(v),
else => false,
};
diff --git a/src/runtime/lazy_seq.zig b/src/runtime/lazy_seq.zig
index ea475e132..935209564 100644
--- a/src/runtime/lazy_seq.zig
+++ b/src/runtime/lazy_seq.zig
@@ -56,6 +56,7 @@ const safepoint = @import("concurrency/safepoint.zig");
const list_mod = @import("collection/list.zig");
const chunked_cons_mod = @import("collection/chunked_cons.zig");
const range_mod = @import("collection/range.zig");
+const array_seq_mod = @import("collection/array_seq.zig");
const vector_mod = @import("collection/vector.zig");
const set_mod = @import("collection/set.zig");
const map_mod = @import("collection/map.zig");
@@ -251,6 +252,7 @@ pub fn first(rt: *Runtime, env: *env_mod.Env, v: Value) !Value {
// these are part of the Layer-0 seq-accessor protocol too.
.chunked_cons => chunked_cons_mod.first(current),
.range => range_mod.first(current),
+ .array_seq => array_seq_mod.first(current),
.nil => Value.nil_val,
else => Value.nil_val,
};
@@ -268,6 +270,7 @@ pub fn rest(rt: *Runtime, env: *env_mod.Env, v: Value) !Value {
.list => list_mod.rest(current),
.chunked_cons => try chunked_cons_mod.rest(rt, current),
.range => try chunked_cons_mod.rest(rt, try range_mod.seqChunk(rt, current)),
+ .array_seq => try array_seq_mod.rest(rt, current),
.nil => Value.nil_val,
else => Value.nil_val,
};
diff --git a/src/runtime/meta.zig b/src/runtime/meta.zig
index 6b72e5818..28652afd8 100644
--- a/src/runtime/meta.zig
+++ b/src/runtime/meta.zig
@@ -29,6 +29,7 @@ const ref = @import("stm/ref.zig");
const symbol = @import("symbol.zig");
const keyword = @import("keyword.zig");
const td_mod = @import("type_descriptor.zig");
+const array_seq = @import("collection/array_seq.zig");
/// `(meta obj)` — obj's metadata map, or nil for a non-IObj / no-meta value.
/// The single meta-read switch shared by the `(meta x)` primitive and
@@ -42,6 +43,7 @@ pub fn metaOf(rt: *Runtime, env: *Env, v: Value, loc: SourceLocation) anyerror!V
.hash_set => set.metaOf(v),
.list => list.metaOf(v),
.lazy_seq => lazy_seq.metaOf(v),
+ .array_seq => array_seq.metaOf(v),
.var_ref => try synthVarMeta(rt, v),
.atom => atom.metaOf(v),
.agent => agent.metaOf(v),
diff --git a/src/runtime/print.zig b/src/runtime/print.zig
index 4dd2aa0fe..9498a0da9 100644
--- a/src/runtime/print.zig
+++ b/src/runtime/print.zig
@@ -62,6 +62,7 @@ const duration_value_mod = @import("time/duration_value.zig");
const local_date_time_value_mod = @import("time/local_date_time_value.zig");
const lazy_seq_mod = @import("lazy_seq.zig");
const range_collection = @import("collection/range.zig");
+const array_seq_collection = @import("collection/array_seq.zig");
const env_mod = @import("env.zig");
const dispatch_mod = @import("dispatch.zig");
const writer_value = @import("writer_value.zig");
@@ -832,7 +833,7 @@ fn snapshotPrintLimits() void {
/// collection, including a `.map_entry` which renders as the 2-vector `[k v]`).
fn isCollectionTag(t: Value.Tag) bool {
return switch (t) {
- .list, .range, .vector, .map_entry, .persistent_queue, .hash_set, .sorted_set, .sorted_map, .array_map, .hash_map => true,
+ .list, .range, .array_seq, .vector, .map_entry, .persistent_queue, .hash_set, .sorted_set, .sorted_map, .array_map, .hash_map => true,
else => false,
};
}
@@ -967,6 +968,7 @@ fn printValueNative(w: *Writer, v: Value) anyerror!void {
.string => if (print_readably) try printString(w, string_collection.asString(v)) else try w.writeAll(string_collection.asString(v)),
.list => try printList(w, v),
.range => try printRange(w, v),
+ .array_seq => try printArraySeq(w, v),
.vector => try printVector(w, v),
// A MapEntry prints as the 2-vector `[k v]`.
.map_entry => {
@@ -1448,6 +1450,22 @@ pub fn printRange(w: *Writer, v: Value) anyerror!void {
try w.writeByte(')');
}
+/// Render an `.array_seq` vector view as a list `(a b c)` — it is a seq, so it
+/// prints in seq form, not the `[…]` of the vector it views. Index-driven
+/// through the view like `printRange`, so it allocates nothing and never walks
+/// a cons chain.
+pub fn printArraySeq(w: *Writer, v: Value) anyerror!void {
+ try w.writeByte('(');
+ const n = array_seq_collection.countOf(v);
+ var i: u32 = 0;
+ while (i < n) : (i += 1) {
+ if (try lengthTruncated(w, @intCast(i), " ")) break;
+ if (i > 0) try w.writeByte(' ');
+ try printValue(w, array_seq_collection.nth(v, i));
+ }
+ try w.writeByte(')');
+}
+
/// Render a heap Vector in `[a b c]` form. Indexes via
/// `vector_collection.nth` so this stays decoupled from the HAMT
/// internals.
diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig
index 41ca0e1e9..273edac7d 100644
--- a/src/runtime/runtime.zig
+++ b/src/runtime/runtime.zig
@@ -599,6 +599,7 @@ pub const Runtime = struct {
@import("collection/java_array.zig").registerGcHooks();
@import("collection/set.zig").registerGcHooks();
@import("lazy_seq.zig").registerGcHooks();
+ @import("collection/array_seq.zig").registerGcHooks();
@import("collection/chunked_cons.zig").registerGcHooks();
@import("collection/reduced.zig").registerGcHooks();
@import("collection/sorted.zig").registerGcHooks();
diff --git a/test/e2e/vector_seq_view.sh b/test/e2e/vector_seq_view.sh
new file mode 100755
index 000000000..b1942c4f3
--- /dev/null
+++ b/test/e2e/vector_seq_view.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+# test/e2e/vector_seq_view.sh
+#
+# `(seq v)` / `(rest v)` / `(next v)` over a vector are an `.array_seq` VIEW
+# (runtime/collection/array_seq.zig, O-058), not the eager PersistentList copy
+# they used to build.
+#
+# The point of a view is that NOTHING observable may change: it must print as a
+# seq, be `=` to the list and the vector with the same elements, hash with them,
+# work as a map key, count exactly, and terminate a `next` walk at the same
+# place. A representation swap that changes any of those is a bug, not an
+# optimisation — so this file pins the observable surface, and the timing claim
+# lives in `.dev/optimizations.md` where it can be re-measured.
+
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() {
+ local name="$1" got="$2" want="$3"
+ [[ "$got" == "$want" ]] || fail "$name: got '$got', want '$want'"
+ echo "PASS $name -> $want"
+}
+run() { "$BIN" -e "$1" 2>/dev/null; }
+
+# --- it is a seq, and it prints as one (not as the vector it views) ---
+assert_eq 'seq_prints_as_list' "$(run '(seq [1 2 3])')" '(1 2 3)'
+assert_eq 'rest_prints_as_list' "$(run '(rest [1 2 3])')" '(2 3)'
+assert_eq 'next_prints_as_list' "$(run '(next [1 2 3])')" '(2 3)'
+assert_eq 'pr_str_is_seq_form' "$(run '(pr-str (seq [1 2]))')" '"(1 2)"'
+assert_eq 'str_is_seq_form' "$(run '(str (seq [1 2]))')" '"(1 2)"'
+assert_eq 'is_seq' "$(run '(seq? (seq [1 2]))')" 'true'
+assert_eq 'is_sequential' "$(run '(sequential? (seq [1 2]))')" 'true'
+assert_eq 'is_coll' "$(run '(coll? (seq [1 2]))')" 'true'
+assert_eq 'not_vector' "$(run '(vector? (seq [1 2]))')" 'false'
+# AD-066: one indexed-view seq representation stands in for clj's family, so
+# the class NAME diverges while everything that treats it as a seq does not.
+assert_eq 'class_name' "$(run '(str (class (seq [1 2])))')" '"ArraySeq"'
+
+# --- IObj: a seq carries metadata on the JVM (ASeq), so the view must too ---
+assert_eq 'with_meta_round_trips' \
+ "$(run '(meta (with-meta (seq [1 2]) {:a 1}))')" '{:a 1}'
+assert_eq 'with_meta_keeps_value' \
+ "$(run '(with-meta (seq [1 2]) {:a 1})')" '(1 2)'
+assert_eq 'with_meta_keeps_equality' \
+ "$(run '(= (with-meta (seq [1 2]) {:a 1}) (list 1 2))')" 'true'
+assert_eq 'meta_is_nil_by_default' "$(run '(nil? (meta (seq [1 2])))')" 'true'
+# clj's ASeq.next() drops the receiver's meta — the tail is a fresh seq.
+assert_eq 'meta_does_not_propagate_to_rest' \
+ "$(run '(nil? (meta (rest (with-meta (seq [1 2 3]) {:a 1}))))')" 'true'
+
+# --- empty: every ISeq empties to (), not just a cons chain ---
+# `.range` / `.lazy_seq` / `.chunked_cons` used to reach the -empty dispatch and
+# RAISE; they answer `()` like clj now, alongside the view.
+assert_eq 'empty_of_view' "$(run '(empty (seq [1 2]))')" '()'
+assert_eq 'empty_of_range' "$(run '(empty (range 3))')" '()'
+assert_eq 'empty_of_lazy_seq' "$(run '(empty (map inc [1 2]))')" '()'
+assert_eq 'empty_of_list' "$(run '(empty (list 1 2))')" '()'
+
+# --- the empty / one-element boundaries: a view is NEVER empty ---
+assert_eq 'seq_of_empty_is_nil' "$(run '(nil? (seq []))')" 'true'
+assert_eq 'next_of_one_is_nil' "$(run '(nil? (next [1]))')" 'true'
+assert_eq 'rest_of_one_is_empty' "$(run '(rest [1])')" '()'
+assert_eq 'rest_of_one_is_seq' "$(run '(empty? (rest [1]))')" 'true'
+assert_eq 'seq_of_one' "$(run '(seq [1])')" '(1)'
+assert_eq 'next_walks_off_end' "$(run '(nil? (next (next [1 2])))')" 'true'
+
+# --- equality: with a list, with a vector, and with another view ---
+assert_eq 'eq_to_list' "$(run '(= (seq [1 2 3]) (list 1 2 3))')" 'true'
+assert_eq 'eq_to_vector' "$(run '(= (seq [1 2 3]) [1 2 3])')" 'true'
+assert_eq 'list_eq_to_view' "$(run '(= (list 1 2 3) (seq [1 2 3]))')" 'true'
+assert_eq 'eq_to_other_view' "$(run '(= (seq [1 2 3]) (seq [1 2 3]))')" 'true'
+assert_eq 'rest_eq_to_list' "$(run '(= (rest [1 2 3]) (list 2 3))')" 'true'
+assert_eq 'neq_on_content' "$(run '(= (seq [1 2 3]) (list 1 2 4))')" 'false'
+# A length mismatch must lose even though the SHORTER one is a prefix — the
+# O(1) count short-circuit must not read the backing vector's full length.
+assert_eq 'neq_on_length' "$(run '(= (rest [1 2 3]) (list 1 2 3))')" 'false'
+assert_eq 'neq_prefix' "$(run '(= (seq [1 2]) (list 1 2 3))')" 'false'
+
+# --- hash agrees with =, so a view works as a map key / set member ---
+assert_eq 'hash_matches_vector' "$(run '(= (hash (seq [1 2 3])) (hash [1 2 3]))')" 'true'
+assert_eq 'hash_matches_list' "$(run '(= (hash (seq [1 2 3])) (hash (list 1 2 3)))')" 'true'
+assert_eq 'view_as_map_key' "$(run '(get {(seq [1 2]) :v} (list 1 2))')" ':v'
+assert_eq 'list_key_found_by_view' "$(run '(get {(list 1 2) :v} (seq [1 2]))')" ':v'
+assert_eq 'set_dedups_across_reprs' "$(run '(count #{(seq [1 2]) (list 1 2) [1 2]})')" '1'
+
+# --- count is exact at every offset (the subtraction, not the backing count) ---
+assert_eq 'count_full' "$(run '(count (seq [1 2 3 4]))')" '4'
+assert_eq 'count_rest' "$(run '(count (rest [1 2 3 4]))')" '3'
+assert_eq 'count_nested' "$(run '(count (rest (rest [1 2 3 4])))')" '2'
+assert_eq 'counted' "$(run '(counted? (seq [1 2]))')" 'true'
+
+# --- element access ---
+assert_eq 'first' "$(run '(first (seq [1 2 3]))')" '1'
+assert_eq 'first_of_rest' "$(run '(first (rest [1 2 3]))')" '2'
+assert_eq 'second' "$(run '(second (seq [1 2 3]))')" '2'
+assert_eq 'last' "$(run '(last (seq [1 2 3]))')" '3'
+assert_eq 'nth' "$(run '(nth (seq [1 2 3]) 1)')" '2'
+assert_eq 'nth_offset' "$(run '(nth (rest [1 2 3]) 1)')" '3'
+assert_eq 'nth_default' "$(run '(nth (seq [1 2]) 9 :d)')" ':d'
+assert_eq 'nth_oob_throws' \
+ "$(run '(try (nth (seq [1 2]) 9) :no-throw (catch Exception _ :threw))')" ':threw'
+assert_eq 'nth_negative_throws' \
+ "$(run '(try (nth (seq [1 2]) -1) :no-throw (catch Exception _ :threw))')" ':threw'
+
+# --- generic seq consumers see no difference ---
+assert_eq 'into_set' "$(run '(into #{} (seq [1 2 3]))')" '#{1 2 3}'
+assert_eq 'vec_round' "$(run '(vec (seq [1 2 3]))')" '[1 2 3]'
+assert_eq 'map' "$(run '(doall (map inc (seq [1 2 3])))')" '(2 3 4)'
+assert_eq 'filter' "$(run '(doall (filter odd? (seq [1 2 3])))')" '(1 3)'
+assert_eq 'reduce' "$(run '(reduce + (seq [1 2 3]))')" '6'
+assert_eq 'apply' "$(run '(apply + (seq [1 2 3]))')" '6'
+assert_eq 'sort' "$(run '(sort (seq [3 1 2]))')" '(1 2 3)'
+assert_eq 'reverse' "$(run '(reverse [1 2 3])')" '(3 2 1)'
+assert_eq 'concat' "$(run '(concat (seq [1 2]) (seq [3]))')" '(1 2 3)'
+assert_eq 'cons_onto' "$(run '(cons 0 (seq [1 2]))')" '(0 1 2)'
+assert_eq 'seq_of_seq' "$(run '(seq (seq [1 2]))')" '(1 2)'
+assert_eq 'take' "$(run '(take 2 (seq [1 2 3]))')" '(1 2)'
+assert_eq 'drop' "$(run '(drop 2 [1 2 3 4])')" '(3 4)'
+assert_eq 'partition' "$(run '(partition 2 [1 2 3 4])')" '((1 2) (3 4))'
+
+# A hand-written next-walk — the shape the eager copy actually punished — must
+# visit each element exactly once and stop exactly at the end.
+assert_eq 'next_walk_visits_each_once' \
+ "$(run '(loop [s (seq [1 2 3 4 5]) acc []] (if s (recur (next s) (conj acc (first s))) acc))')" \
+ '[1 2 3 4 5]'
+
+# --- scale: the view arithmetic must hold on a multi-level trie ---
+assert_eq 'large_count' "$(run '(count (seq (vec (range 5000))))')" '5000'
+assert_eq 'large_rest_count' "$(run '(count (rest (vec (range 5000))))')" '4999'
+assert_eq 'large_last' "$(run '(last (seq (vec (range 5000))))')" '4999'
+assert_eq 'large_walk_sum' "$(run '(reduce + 0 (seq (vec (range 5000))))')" '12497500'
+assert_eq 'large_nth' "$(run '(nth (rest (vec (range 5000))) 4000)')" '4001'
+assert_eq 'large_eq' "$(run '(= (seq (vec (range 1000))) (apply list (range 1000)))')" 'true'
+
+# --- the view keeps the backing vector alive (GC) ---
+# The view holds its backing through a traced Value; a collect between minting
+# the view and reading it must not sweep the vector out from under it.
+assert_eq 'view_survives_gc' \
+ "$(run '(let [s (seq (vec (range 200)))] (dotimes [_ 200] (vec (range 200))) [(count s) (first s) (last s)])')" \
+ '[200 0 199]'
+# The same under a collect at EVERY back-edge poll, which is what actually
+# proves the trace fn reaches `backing` — without it the vector is swept while
+# the view still points at it and the reads come back garbage or crash.
+assert_eq 'view_survives_gc_torture' \
+ "$(CLJW_GC_TORTURE=1 "$BIN" -e '(let [s (seq (vec (range 300)))] (dotimes [_ 50] (vec (range 300))) [(count s) (last s) (reduce + 0 s)])' 2>/dev/null)" \
+ '[300 299 44850]'
+
+# --- nested collections inside a view print + compare by content ---
+assert_eq 'nested_print' "$(run '(seq [[1 2] {:a 1}])')" '([1 2] {:a 1})'
+assert_eq 'nested_eq' "$(run '(= (seq [[1 2]]) (list [1 2]))')" 'true'
+
+echo "OK test/e2e/vector_seq_view.sh"
diff --git a/test/run_all.sh b/test/run_all.sh
index 20ca607c7..1444fc8ec 100644
--- a/test/run_all.sh
+++ b/test/run_all.sh
@@ -782,6 +782,7 @@ run_step "e2e_phase14_member_on_native" "bash test/e2e/phase14_member_on_na
run_step "e2e_phase14_java_method_grouping" "bash test/e2e/phase14_java_method_grouping.sh"
run_step "e2e_list_count_exact" "bash test/e2e/list_count_exact.sh"
run_step "e2e_future_methods" "bash test/e2e/future_methods.sh"
+run_step "e2e_vector_seq_view" "bash test/e2e/vector_seq_view.sh"
run_step "e2e_phase14_fn_prepost" "bash test/e2e/phase14_fn_prepost.sh"
run_step "e2e_phase14_deftype_ideref" "bash test/e2e/phase14_deftype_ideref.sh"
run_step "e2e_phase14_equality" "bash test/e2e/phase14_equality.sh"
From 03a8bfde39094ef781b2d2a7ca801a399ade4916 Mon Sep 17 00:00:00 2001
From: clojurewasm-release
Date: Fri, 14 Aug 2026 18:50:28 +0000
Subject: [PATCH 056/466] chore(release): v1.10.4
---
build.zig.zon | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.zig.zon b/build.zig.zon
index 623447946..5768b694e 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.3",
+ .version = "1.10.4",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From f61686f650adf463a49e4de88b5daabded1ea27f Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 16:24:22 -0300
Subject: [PATCH 057/466] fix(lint): drop the dead `std` import from Future.zig
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
zlinter's `no_unused` flags `const std = @import("std")` in
runtime/java/util/concurrent/Future.zig — the file never reaches for
`std.`. Introduced by ae029ddd (the Future method surface).
It survived that commit's gate because the zlinter step is Mac-host
only (test/run_all.sh:362), and this session's gate runs on Linux,
where the step is skipped rather than failed. So a green local gate
here does not cover lint, and the CI gate is the only place it shows.
---
src/runtime/java/util/concurrent/Future.zig | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/runtime/java/util/concurrent/Future.zig b/src/runtime/java/util/concurrent/Future.zig
index c0a97d17a..0d03cafbf 100644
--- a/src/runtime/java/util/concurrent/Future.zig
+++ b/src/runtime/java/util/concurrent/Future.zig
@@ -29,7 +29,6 @@
//! finish but report cancelled" the way the JVM's flag does, and honouring only
//! one of the two values would be a worse lie than ignoring it.
-const std = @import("std");
const Value = @import("../../../value/value.zig").Value;
const Runtime = @import("../../../runtime.zig").Runtime;
const Env = @import("../../../env.zig").Env;
From 064e380bedb06b337c47a19ef6386a639e43d385 Mon Sep 17 00:00:00 2001
From: clojurewasm-release
Date: Fri, 14 Aug 2026 19:25:34 +0000
Subject: [PATCH 058/466] chore(release): v1.10.5
---
build.zig.zon | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.zig.zon b/build.zig.zon
index 5768b694e..4c5ccd3b5 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.4",
+ .version = "1.10.5",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From b12139ca08a9318637a87e0d3aca94f60e8ab1dd Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 16:38:57 -0300
Subject: [PATCH 059/466] docs(changelog): stamp released versions, and keep
doing it automatically
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Automatic patch releases left CHANGELOG.md stranded: 1.10.3, 1.10.4 and 1.10.5
shipped while 124 lines sat under a single [Unreleased] heading with no version
to attach to. Split by commit range — the java.util.concurrent surface and the
large-integer fix to 1.10.3, the O-058 seq-as-view change to 1.10.4, the dead
import to 1.10.5. Content is moved verbatim, not rewritten.
Going forward preflight stamps the section in the same commit that bumps
.version, so the file and the tag are written together. An empty [Unreleased]
is not stamped: patches cut themselves on every source push, and a release
nobody wrote up must not mint an empty heading.
CHANGELOG.md stays out of the relevance paths for the reason build.zig.zon
does — the bump commit touches both, and counting them would make each release
mint the next one.
---
.github/stamp_changelog.py | 89 +++++++++++++++++++++++++++++++++++
.github/workflows/release.yml | 38 +++++++++++----
CHANGELOG.md | 77 ++++++++++++++++++++----------
3 files changed, 171 insertions(+), 33 deletions(-)
create mode 100644 .github/stamp_changelog.py
diff --git a/.github/stamp_changelog.py b/.github/stamp_changelog.py
new file mode 100644
index 000000000..6ff8830fe
--- /dev/null
+++ b/.github/stamp_changelog.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Stamp CHANGELOG.md's [Unreleased] section as a released version.
+
+Usage: stamp_changelog.py VERSION [PATH]
+
+Renames `## [Unreleased]` to `## [VERSION] - YYYY-MM-DD` and opens a fresh
+empty `## [Unreleased]` above it.
+
+Exit 0 the file was rewritten (caller should `git add` it)
+Exit 1 nothing to stamp - no file, no [Unreleased] heading, or its body is
+ empty. An auto-cut patch with nothing written up must not mint an
+ empty version heading, so this is a normal outcome, not an error.
+Exit 2 the file is malformed in a way that needs a human.
+"""
+
+import datetime
+import pathlib
+import re
+import sys
+
+UNRELEASED = "## [Unreleased]"
+VERSION_RE = re.compile(r"^## \[")
+
+
+def stamp(text: str, version: str, today: str):
+ """Return the rewritten text, or None when there is nothing to stamp."""
+ lines = text.split("\n")
+
+ start = next((i for i, l in enumerate(lines) if l.strip() == UNRELEASED), None)
+ if start is None:
+ return None
+
+ end = next(
+ (i for i in range(start + 1, len(lines)) if VERSION_RE.match(lines[i])),
+ len(lines),
+ )
+
+ body = lines[start + 1 : end]
+ if not any(l.strip() for l in body):
+ return None
+
+ while body and not body[0].strip():
+ body.pop(0)
+ while body and not body[-1].strip():
+ body.pop()
+
+ return "\n".join(
+ lines[:start]
+ + [UNRELEASED, "", f"## [{version}] - {today}", ""]
+ + body
+ + [""]
+ + lines[end:]
+ )
+
+
+def main() -> int:
+ if len(sys.argv) < 2:
+ print(__doc__, file=sys.stderr)
+ return 2
+
+ version = sys.argv[1]
+ path = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else "CHANGELOG.md")
+
+ if not path.is_file():
+ print(f"{path}: not found - nothing to stamp", file=sys.stderr)
+ return 1
+
+ text = path.read_text()
+ # Count HEADINGS, not substring hits: an entry that quotes the literal
+ # `## [Unreleased]` in its prose is ordinary changelog writing, not a
+ # malformed file.
+ headings = sum(1 for l in text.split("\n") if l.strip() == UNRELEASED)
+ if headings > 1:
+ print(f"{path}: {headings} '{UNRELEASED}' headings", file=sys.stderr)
+ return 2
+
+ today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
+ out = stamp(text, version, today)
+ if out is None:
+ print(f"{path}: [Unreleased] is empty - not stamping {version}", file=sys.stderr)
+ return 1
+
+ path.write_text(out)
+ print(f"{path}: stamped [Unreleased] as [{version}] - {today}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 89a19a78b..431f89dcd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,12 +17,19 @@
# patch on demand (skipping the relevance check below); `force: true` re-releases
# whatever `.version` currently names.
#
+# CHANGELOG: the bump commit also stamps `## [Unreleased]` as `## [X.Y.Z] - date`
+# and opens a fresh one (`.github/stamp_changelog.py`), so CHANGELOG.md and the
+# tag are written together and cannot drift. A patch whose [Unreleased] body is
+# empty is NOT stamped — an auto-cut release nobody wrote up must not mint an
+# empty version heading. The GitHub release page still uses `--generate-notes`.
+#
# RELEVANCE: a branch push cuts a patch only when something that can change the
# binary moved since the last release — `src/**`, `build.zig`,
-# `.github/versions.lock`, this workflow. build.zig.zon is deliberately NOT on
-# that list: the bump commit touches it, and counting it would make each release
-# mint the next one forever. A dependency-hash-only edit to build.zig.zon rides
-# along with the next source change, or with a manual dispatch.
+# `.github/versions.lock`, this workflow, the stamper. build.zig.zon and
+# CHANGELOG.md are deliberately NOT on that list: the bump commit touches both,
+# and counting them would make each release mint the next one forever. A
+# dependency-hash-only edit to build.zig.zon rides along with the next source
+# change, or with a manual dispatch.
#
# The bump commit is pushed with GITHUB_TOKEN, whose pushes do not start new
# workflow runs, so the loop is closed on that side too.
@@ -31,9 +38,6 @@
# because a `paths` filter also applies to tag pushes, which would drop the
# tag trigger whenever the tagged commit did not touch build.zig.zon.
#
-# CHANGELOG.md's `[Unreleased]` heading is NOT stamped here; the release notes
-# come from `--generate-notes` over the commits.
-#
# NOTE (first-tag validation): the artifact build uses
# `-Dwasm -Doptimize=ReleaseSafe -Dcpu=baseline` (baseline CPU avoids SIGILL on
# shared/older hosts, the shipped/deploy config). Validate each artifact runs
@@ -132,7 +136,8 @@ jobs:
# from these paths on purpose — see the header.
if [[ "$EVENT" == "push" ]]; then
changed=$(git diff --name-only "v$version...HEAD" -- \
- src build.zig .github/versions.lock .github/workflows/release.yml)
+ src build.zig .github/versions.lock \
+ .github/workflows/release.yml .github/stamp_changelog.py)
if [[ -z "$changed" ]]; then
echo "nothing release-relevant since v$version — nothing to do"
emit "v$version" "$GITHUB_SHA" false
@@ -160,9 +165,24 @@ jobs:
exit 1
fi
+ # Stamp CHANGELOG.md's [Unreleased] section as this version, in the
+ # SAME commit as the bump so the file and the tag never disagree.
+ # Exit 1 means its body was empty and nothing was stamped — an
+ # ordinary outcome for a patch nobody wrote up, not a failure.
+ stamped=()
+ set +e
+ python3 .github/stamp_changelog.py "$next"
+ rc=$?
+ set -e
+ case "$rc" in
+ 0) stamped=(CHANGELOG.md) ;;
+ 1) ;;
+ *) echo "stamp_changelog.py failed ($rc)" >&2; exit 1 ;;
+ esac
+
git config user.name "clojurewasm-release"
git config user.email "clojurewasm-release@users.noreply.github.com"
- git add build.zig.zon
+ git add build.zig.zon "${stamped[@]}"
git commit -m "chore(release): v$next"
git push origin "HEAD:${GITHUB_REF_NAME}"
echo "bumped .version $version -> $next, releasing v$next"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f039e20c..f0d90f27d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,59 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+### Internal
+
+- **The release bump also stamps this file.** `preflight` renames
+ `## [Unreleased]` to `## [X.Y.Z] - date` and opens a fresh one in the SAME
+ `chore(release)` commit that rewrites `build.zig.zon`, so the changelog and
+ the tag are written together and cannot drift
+ (`.github/stamp_changelog.py`).
+
+ A version whose `[Unreleased]` body is **empty is not stamped**: patches now
+ cut themselves on every source push, and a release nobody wrote up must not
+ mint an empty version heading. CHANGELOG.md is deliberately absent from the
+ workflow's relevance paths for the same reason build.zig.zon is — the bump
+ commit touches both, and counting them would make each release mint the next.
+
+ `1.10.3`–`1.10.5` were stamped by hand: they shipped between the arrival of
+ automatic patch releases and this, so their entries had accumulated under a
+ single `[Unreleased]` heading with no version to attach to.
+
+## [1.10.5] - 2026-08-14
+
+### Fixed
+
+- **Dropped a dead `std` import from `Future.zig`.** Lint-only; the module's
+ behaviour is unchanged.
+
+## [1.10.4] - 2026-08-14
+
+### Changed
+
+- **`(seq v)` / `(rest v)` / `(next v)` on a vector are now a VIEW, not a copy.**
+ They used to build an eager `PersistentList` — n cell allocations and n trie
+ descents per call. They now return an `ArraySeq` holding `(vector, index)`,
+ which is what Clojure does (`PersistentVector$ChunkedSeq`). All three drop
+ from O(n) to O(1): measured over a 16000-element vector, `seq` 2123 → 0.36 ms,
+ `rest` 2253 → 0.39 ms, `next` 2164 → 0.38 ms per 2000 ops — from 2184-3549×
+ slower than JVM Clojure 1.12.4 to 1.7-2.6× faster. `nth` on such a seq is now
+ O(1) too, where clj still walks. See `O-058` in `.dev/optimizations.md`.
+
+ Everything observable is unchanged — it prints as a seq, is `=` to the list
+ and the vector with the same elements, hashes with them, and works as a map
+ key — with ONE exception: **`(class (seq [1 2 3]))` now answers `ArraySeq`
+ where it used to answer `PersistentList`.** Neither matches the JVM's
+ `PersistentVector$ChunkedSeq`; code that branches on that class name was
+ already non-portable, and `seq?` / `sequential?` / `coll?` / `counted?` are
+ unaffected.
+
+ This closes item 2 of `.dev/bench/README.md`'s ranked list — which that file
+ had wrongly recorded as blocked on item 1 (`SubVector`). See the correction
+ there: `next` of a seq is a seq, so the vector-seq needed `(vector, index)`
+ and never needed a smaller vector. `subvec` remains eager (D-044).
+
+## [1.10.3] - 2026-08-14
+
### Added
- **`java.util.concurrent.Future`'s method names on cljw's own futures.** `.get`
@@ -97,30 +150,6 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
native classes, because `reify` accepts only a protocol Var or a marker name
and rejects a class value outright.
-### Changed
-
-- **`(seq v)` / `(rest v)` / `(next v)` on a vector are now a VIEW, not a copy.**
- They used to build an eager `PersistentList` — n cell allocations and n trie
- descents per call. They now return an `ArraySeq` holding `(vector, index)`,
- which is what Clojure does (`PersistentVector$ChunkedSeq`). All three drop
- from O(n) to O(1): measured over a 16000-element vector, `seq` 2123 → 0.36 ms,
- `rest` 2253 → 0.39 ms, `next` 2164 → 0.38 ms per 2000 ops — from 2184-3549×
- slower than JVM Clojure 1.12.4 to 1.7-2.6× faster. `nth` on such a seq is now
- O(1) too, where clj still walks. See `O-058` in `.dev/optimizations.md`.
-
- Everything observable is unchanged — it prints as a seq, is `=` to the list
- and the vector with the same elements, hashes with them, and works as a map
- key — with ONE exception: **`(class (seq [1 2 3]))` now answers `ArraySeq`
- where it used to answer `PersistentList`.** Neither matches the JVM's
- `PersistentVector$ChunkedSeq`; code that branches on that class name was
- already non-portable, and `seq?` / `sequential?` / `coll?` / `counted?` are
- unaffected.
-
- This closes item 2 of `.dev/bench/README.md`'s ranked list — which that file
- had wrongly recorded as blocked on item 1 (`SubVector`). See the correction
- there: `next` of a seq is a seq, so the vector-seq needed `(vector, index)`
- and never needed a smaller vector. `subvec` remains eager (D-044).
-
### Fixed
- **Host surfaces returning a large integer no longer hand back a Double.**
From 2bc37bd61a8f42d58a1ec114b082577c2dfdfdf9 Mon Sep 17 00:00:00 2001
From: clojurewasm-release
Date: Fri, 14 Aug 2026 19:39:18 +0000
Subject: [PATCH 060/466] chore(release): v1.10.6
---
CHANGELOG.md | 2 ++
build.zig.zon | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0d90f27d..ee7027dd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
## [Unreleased]
+## [1.10.6] - 2026-08-14
+
### Internal
- **The release bump also stamps this file.** `preflight` renames
diff --git a/build.zig.zon b/build.zig.zon
index 4c5ccd3b5..cd96f54cd 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.5",
+ .version = "1.10.6",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From b5f57d5d22894f376be7ecee679f1353eea983fb Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Fri, 14 Aug 2026 16:46:18 -0300
Subject: [PATCH 061/466] feat(reader): #uuid and #inst survive a macro
expansion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A macro's arguments arrive as VALUES and its expansion is re-analysed
as a form, so every value the reader can produce needs an inverse.
`#uuid` and `#inst` had none, so any macro whose body contained one
took the whole namespace down with it.
`print.zig` grows `readerTagParts`, the shared `# ""`
formatter for a descriptor-tagged host value, and the analyzer's
Value→Form inverse reads the same function. The two must agree:
whatever prints as a reader literal is exactly what has to read back
as the same value, and two copies of that rule would drift.
Also in the same pass, both surfaced by the clojure.core compliance
suite:
- `resolve` looked its prefix up as a real namespace name only, so an
aliased `str/blank?` answered nil while the var plainly existed.
- a BigDecimal exponent (`1.5e3M`) was rejected outright.
test/e2e/phase15_literal_roundtrip.sh covers the three, and registers
the four concurrent e2e scripts that had been committed without a
runner entry (concurrent_atomics, concurrent_interfaces,
linked_blocking_queue, semaphore) — which is what check_e2e_reach has
been failing on since b4ca3717.
.nrepl-port is gitignored rather than committed: `cljw nrepl` writes
the live port there at startup, so it is machine-local state, not
source.
---
.gitignore | 3 +
src/eval/analyzer/analyzer.zig | 71 ++++++++++++++---
src/lang/clj/clojure/test.clj | 9 ++-
src/lang/macro_transforms.zig | 13 +++-
src/lang/primitive/core.zig | 6 +-
src/lang/primitive/namespace.zig | 13 +++-
src/runtime/error/catalog.zig | 10 ++-
src/runtime/print.zig | 39 +++++++---
test/e2e/phase15_literal_roundtrip.sh | 107 ++++++++++++++++++++++++++
test/run_all.sh | 5 ++
10 files changed, 241 insertions(+), 35 deletions(-)
create mode 100755 test/e2e/phase15_literal_roundtrip.sh
diff --git a/.gitignore b/.gitignore
index 5661500f8..f332a1fb0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,6 @@ __pycache__/
# Throwaway detached worktrees for scratch work (git worktree add --detach .dev/wt-)
.dev/wt-*/
+
+# nREPL writes the live port here at `cljw nrepl` startup — machine-local.
+.nrepl-port
diff --git a/src/eval/analyzer/analyzer.zig b/src/eval/analyzer/analyzer.zig
index 10d7f1c2d..4b517011f 100644
--- a/src/eval/analyzer/analyzer.zig
+++ b/src/eval/analyzer/analyzer.zig
@@ -64,6 +64,7 @@ const big_decimal = @import("../../runtime/numeric/big_decimal.zig");
const ratio_mod = @import("../../runtime/numeric/ratio.zig");
const print_mod = @import("../../runtime/print.zig");
const regex_value = @import("../../runtime/regex/value.zig");
+const uuid_mod = @import("../../runtime/uuid.zig");
const class_name = @import("../../runtime/class_name.zig");
const host_class = @import("../../runtime/error/host_class.zig");
const host_interface = @import("../../runtime/host_interface.zig");
@@ -491,10 +492,23 @@ pub fn parseBigIntLiteral(rt: *Runtime, digits: []const u8, loc: error_mod.Sourc
/// integer is the input with the decimal point removed; the scale is
/// the number of digits after the dot.
pub fn parseBigDecimalLiteral(rt: *Runtime, digits: []const u8, loc: error_mod.SourceLocation) !Value {
- // Locate the decimal point if any. JVM accepts `1M`, `1.5M`, and
- // exponent `1.5e3M` — the latter is left as a debt row.
+ // JVM accepts `1M`, `1.5M`, and exponent `1.5e3M`. The exponent is not a
+ // separate representation — a BigDecimal is (unscaled, scale), and
+ // `× 10^exp` is exactly `scale -= exp`. Split it off first so the
+ // mantissa below is the plain `[-]ddd[.ddd]` shape.
+ var mantissa = digits;
+ var exponent: i32 = 0;
+ if (std.mem.indexOfAny(u8, digits, "eE")) |e_pos| {
+ mantissa = digits[0..e_pos];
+ const exp_txt = digits[e_pos + 1 ..];
+ if (exp_txt.len == 0) return error_catalog.raise(.big_decimal_literal_invalid, loc, .{ .text = digits });
+ exponent = std.fmt.parseInt(i32, exp_txt, 10) catch
+ return error_catalog.raise(.big_decimal_literal_invalid, loc, .{ .text = digits });
+ }
+
+ // Locate the decimal point if any.
var dot_pos: ?usize = null;
- for (digits, 0..) |c, idx| {
+ for (mantissa, 0..) |c, idx| {
if (c == '.') {
dot_pos = idx;
break;
@@ -507,25 +521,29 @@ pub fn parseBigDecimalLiteral(rt: *Runtime, digits: []const u8, loc: error_mod.S
var buf_len: usize = 0;
var scale: i32 = 0;
if (dot_pos) |p| {
- const pre = digits[0..p];
- const post = digits[p + 1 ..];
+ const pre = mantissa[0..p];
+ const post = mantissa[p + 1 ..];
if (pre.len + post.len > buf.len) {
- return error_catalog.raise(.float_literal_invalid, loc, .{ .text = digits });
+ return error_catalog.raise(.big_decimal_literal_invalid, loc, .{ .text = digits });
}
std.mem.copyForwards(u8, buf[0..pre.len], pre);
std.mem.copyForwards(u8, buf[pre.len .. pre.len + post.len], post);
buf_len = pre.len + post.len;
scale = @intCast(post.len);
} else {
- if (digits.len > buf.len) {
- return error_catalog.raise(.float_literal_invalid, loc, .{ .text = digits });
+ if (mantissa.len > buf.len) {
+ return error_catalog.raise(.big_decimal_literal_invalid, loc, .{ .text = digits });
}
- std.mem.copyForwards(u8, buf[0..digits.len], digits);
- buf_len = digits.len;
+ std.mem.copyForwards(u8, buf[0..mantissa.len], mantissa);
+ buf_len = mantissa.len;
}
+ // `× 10^exponent` on a (unscaled, scale) pair is a scale shift, so a
+ // literal exponent never touches the digits.
+ scale = std.math.sub(i32, scale, exponent) catch
+ return error_catalog.raise(.big_decimal_literal_invalid, loc, .{ .text = digits });
var unscaled = big_int.parseBase10(rt, buf[0..buf_len]) catch
- return error_catalog.raise(.float_literal_invalid, loc, .{ .text = digits });
+ return error_catalog.raise(.big_decimal_literal_invalid, loc, .{ .text = digits });
defer unscaled.deinit();
return try big_decimal.allocFromManagedScale(rt, &unscaled, scale);
@@ -1581,6 +1599,37 @@ pub fn valueToForm(
// A regex in a macro expansion (e.g. `(is (thrown-with-msg? E #"…" …))`)
// round-trips through its reader-literal source.
.regex => .{ .data = .{ .regex_literal = regex_value.asRegex(v).source() }, .location = call_loc },
+ // A uuid has no literal Form of its own, but it round-trips exactly
+ // through the `#uuid "…"` tagged literal that produced it —
+ // `formToValue` applies the `uuid` data-reader to rebuild the value.
+ // A macro whose argument or expansion contains a `#uuid` literal
+ // reaches here, which is any `(when-var-exists uuid? …)`-shaped body.
+ .uuid => blk: {
+ const canonical = uuid_mod.canonicalOf(v);
+ const inner = try arena.create(Form);
+ inner.* = .{ .data = .{ .string = try arena.dupe(u8, &canonical) }, .location = call_loc };
+ break :blk .{
+ .data = .{ .tagged = .{ .tag = .{ .name = "uuid" }, .form = inner } },
+ .location = call_loc,
+ };
+ },
+ // `#inst "…"` — same round-trip as `.uuid`, via the descriptor's
+ // reader tag. `readerTagParts` is the printer's own rule, shared so a
+ // value cannot print as one literal and re-analyse as another. A
+ // typed_instance with no reader tag (a deftype / defrecord instance,
+ // or a bare-toString time value) has no literal to return to and
+ // falls through to the error below.
+ .typed_instance => blk: {
+ var buf: [48]u8 = undefined;
+ const parts = print_mod.readerTagParts(v, &buf) orelse
+ return error_catalog.raise(.macro_return_not_data, call_loc, .{ .tag = @tagName(v.tag()) });
+ const inner = try arena.create(Form);
+ inner.* = .{ .data = .{ .string = try arena.dupe(u8, parts.body) }, .location = call_loc };
+ break :blk .{
+ .data = .{ .tagged = .{ .tag = .{ .name = parts.tag }, .form = inner } },
+ .location = call_loc,
+ };
+ },
// Any seq tag — FORCE lazy layers so a macro's `(seq (concat …))`
// syntax-quote expansion (and its lazy tail) realizes into a concrete
// list Form. A plain `list_collection.rest` would not force the lazy
diff --git a/src/lang/clj/clojure/test.clj b/src/lang/clj/clojure/test.clj
index 4b10e36d7..c844b1b1c 100644
--- a/src/lang/clj/clojure/test.clj
+++ b/src/lang/clj/clojure/test.clj
@@ -46,10 +46,11 @@
;; ns-name-symbol -> vector of test Vars (deftest appends; run-tests reads).
(def *test-registry* (atom {}))
-;; ns-name-symbol -> {:once [fixture-fn …] :each [fixture-fn …]}. clj hangs
-;; these off the ns's metadata; cljw namespaces carry no user metadata, so the
-;; registry mirrors *test-registry* and is keyed the same way.
-(def *fixture-registry* (atom {}))
+;; clj hangs these off the ns's metadata; cljw namespaces carry no user
+;; metadata, so the registry mirrors *test-registry* and is keyed the same way.
+(def *fixture-registry*
+ "Atom of ns-name-symbol -> {:once [fixture-fn …] :each [fixture-fn …]}."
+ (atom {}))
;; ---------------------------------------------------------------------------
;; report multimethod (keyed on :type) + do-report. `^:dynamic` so an
diff --git a/src/lang/macro_transforms.zig b/src/lang/macro_transforms.zig
index a61fe5abf..e74744809 100644
--- a/src/lang/macro_transforms.zig
+++ b/src/lang/macro_transforms.zig
@@ -1033,7 +1033,8 @@ fn expandWhile(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc:
/// `(when-first [x coll] body…)` →
/// `(when-let [g (seq coll)] (let* [x (first g)] (do body…)))`.
fn expandWhenFirst(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc: SourceLocation) macro_dispatch.ExpandError!Form {
- if (args.len < 2)
+ // Binding vector only, like when-let: an empty body folds to `(do)` = nil.
+ if (args.len < 1)
return error_catalog.raise(.when_first_form_incomplete, loc, .{});
if (args[0].data != .vector or args[0].data.vector.len != 2)
return error_catalog.raise(.when_first_bindings_invalid, args[0].location, .{});
@@ -2862,7 +2863,10 @@ fn expandWhenLet(
loc: SourceLocation,
) macro_dispatch.ExpandError!Form {
_ = rt;
- if (args.len < 2)
+ // Only the binding vector is required: `(when-let [x 1])` is legal and
+ // yields nil either way (clj parity), which is what a `(testing "label")`
+ // with no body expands into.
+ if (args.len < 1)
return error_catalog.raise(.when_let_form_incomplete, loc, .{});
const binding_form = args[0];
@@ -3445,8 +3449,9 @@ fn expandThunkWrapper(
args: []const Form,
loc: SourceLocation,
) macro_dispatch.ExpandError!Form {
- if (args.len == 0)
- return error_catalog.raise(.form_malformed, loc, .{ .name = "delay/future requires at least one body form" });
+ // An empty body is legal for every caller: `(delay)` / `(future)` /
+ // `(dosync)` yield nil and `(lazy-seq)` yields (), because `(fn* [])`
+ // with no body forms is itself nil-valued. clj parity.
const empty_params = try arena.dupe(Form, &.{});
const params_form: Form = .{ .data = .{ .vector = empty_params }, .location = loc };
var fn_items = try arena.alloc(Form, 2 + args.len);
diff --git a/src/lang/primitive/core.zig b/src/lang/primitive/core.zig
index 1dadb335c..193585c74 100644
--- a/src/lang/primitive/core.zig
+++ b/src/lang/primitive/core.zig
@@ -1602,7 +1602,9 @@ pub fn gensymFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocatio
}
/// `(rt/__resolve sym)` — the Var that `sym` resolves to, as a `.var_ref`
-/// Value. A qualified `ns/name` symbol consults that namespace; an
+/// Value. A qualified `ns/name` symbol consults that namespace — resolved
+/// the way every other `s/name` site resolves a prefix, so a REQUIRE ALIAS
+/// works (`(resolve 'str/blank?)` under `[clojure.string :as str]`); an
/// unqualified name the current namespace (own mappings, then refers).
/// nil when the symbol — or its named namespace — does not resolve. The
/// `.clj` `resolve` wraps this; the var_ref derefs to the Var's value and
@@ -1618,7 +1620,7 @@ pub fn resolvePrim(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLoca
}
const sym = symbol_mod.asSymbol(args[0]);
const ns: *env_mod.Namespace = if (sym.ns) |ns_name|
- (env.findNs(ns_name) orelse
+ (env.findNsOrAlias(ns_name) orelse
// A qualified ns miss may be a class symbol (clojure.lang.BigInt,
// java.lang.String) — clj's `resolve` returns the Class. D-421.
return (try analyzer.resolveClassValue(rt, env, sym.ns, sym.name)) orelse Value.nil_val)
diff --git a/src/lang/primitive/namespace.zig b/src/lang/primitive/namespace.zig
index eb75e61cd..b680b78cf 100644
--- a/src/lang/primitive/namespace.zig
+++ b/src/lang/primitive/namespace.zig
@@ -218,12 +218,23 @@ pub fn nsRefersFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
/// `(ns-resolve ns sym)` — the Var `sym` resolves to within `ns` (mappings then
/// refers), or nil. Spec: clojure.core/ns-resolve (2-arity; the 3-arity env
/// form is not modelled).
+///
+/// A QUALIFIED `sym` names its own namespace, and that prefix is read in the
+/// context of `ns` — its aliases first, then real namespace names — so
+/// `(ns-resolve 'my.app 'str/blank?)` follows my.app's `:as str`. Ignoring
+/// the prefix and looking the bare name up in `ns` would answer confidently
+/// about the wrong namespace.
pub fn nsResolveFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
_ = rt;
try error_catalog.checkArity("ns-resolve", args, 2, loc);
const ns = try resolveNsOrRaise(env, args[0], "ns-resolve", loc);
if (args[1].tag() != .symbol) return Value.nil_val;
- if (ns.resolve(symbol_mod.asSymbol(args[1]).name)) |v| return Value.encodeHeapPtr(.var_ref, v);
+ const sym = symbol_mod.asSymbol(args[1]);
+ const target: *Namespace = if (sym.ns) |prefix|
+ (ns.aliases.get(prefix) orelse env.findNs(prefix) orelse return Value.nil_val)
+ else
+ ns;
+ if (target.resolve(sym.name)) |v| return Value.encodeHeapPtr(.var_ref, v);
return Value.nil_val;
}
diff --git a/src/runtime/error/catalog.zig b/src/runtime/error/catalog.zig
index 37e005696..820cc6f0e 100644
--- a/src/runtime/error/catalog.zig
+++ b/src/runtime/error/catalog.zig
@@ -74,6 +74,7 @@ pub const Code = enum {
form_malformed,
integer_literal_invalid,
float_literal_invalid,
+ big_decimal_literal_invalid,
string_unterminated,
map_literal_arity_odd,
@@ -612,6 +613,11 @@ pub fn entry(comptime code: Code) Entry {
.phase = .parse,
.template = "Invalid float literal '{[text]s}'",
},
+ .big_decimal_literal_invalid => .{
+ .kind = .number_error,
+ .phase = .parse,
+ .template = "Invalid bigdec literal '{[text]s}M'",
+ },
.string_unterminated => .{
.kind = .string_error,
.phase = .parse,
@@ -1242,7 +1248,7 @@ pub fn entry(comptime code: Code) Entry {
.when_first_form_incomplete => .{
.kind = .syntax_error,
.phase = .macroexpand,
- .template = "when-first requires [name coll] and at least one body form",
+ .template = "when-first requires a [name coll] binding vector",
},
.when_first_bindings_invalid => .{
.kind = .syntax_error,
@@ -1407,7 +1413,7 @@ pub fn entry(comptime code: Code) Entry {
.when_let_form_incomplete => .{
.kind = .syntax_error,
.phase = .macroexpand,
- .template = "when-let requires [name expr] and at least one body form",
+ .template = "when-let requires a [name expr] binding vector",
},
// --- Eval (type) ---
diff --git a/src/runtime/print.zig b/src/runtime/print.zig
index 9498a0da9..021929adf 100644
--- a/src/runtime/print.zig
+++ b/src/runtime/print.zig
@@ -1210,6 +1210,30 @@ const ExtmapPrintCtx = struct {
}
};
+/// The `# ""` reader-literal parts of a descriptor-tagged host
+/// value (today only `#inst`), or null when `v` is not one. `buf` holds the
+/// formatted body.
+///
+/// Shared by the printer and by the analyzer's Value→Form inverse. The two
+/// MUST agree: whatever prints as a reader literal is exactly what has to
+/// read back as the same value, and a macro expansion carrying such a value
+/// round-trips through this form. Two copies of the rule would let a printed
+/// literal and a re-analysed one drift apart silently.
+pub fn readerTagParts(v: Value, buf: []u8) ?struct { tag: []const u8, body: []const u8 } {
+ if (v.tag() != .typed_instance) return null;
+ const inst = v.decodePtr(*const td_mod.TypedInstance);
+ const tag = inst.descriptor.print_tag orelse return null;
+ if (inst.field_count < 1 or inst.fields()[0].tag() != .integer) return null;
+ const epoch_ms = inst.fields()[0].asInteger();
+ // A 2-field inst value is a Timestamp (epoch-ms + nanos) → 9-digit
+ // fraction; a 1-field one is a Date → 3-digit ms.
+ const iso = if (inst.field_count >= 2 and inst.fields()[1].tag() == .integer)
+ instant_mod.formatInstantNanos(buf, epoch_ms, @intCast(inst.fields()[1].asInteger()))
+ else
+ instant_mod.formatInstantMillis(buf, epoch_ms);
+ return .{ .tag = tag, .body = iso };
+}
+
fn printTypedInstance(w: *Writer, v: Value) anyerror!void {
const inst = v.decodePtr(*const td_mod.TypedInstance);
// A deftype declaring clojure.lang.IPersistentMap prints map-style when a
@@ -1279,17 +1303,10 @@ fn printTypedInstance(w: *Writer, v: Value) anyerror!void {
// Reader-tag host value: emit `# ""`. Today only
// `#inst` (java.util.Date) — body = the epoch-ms field 0 as the
// canonical ISO string. Descriptor-driven (no rt, no surface import).
- if (inst.descriptor.print_tag) |tag| {
- if (inst.field_count >= 1 and inst.fields()[0].tag() == .integer) {
- var buf: [48]u8 = undefined;
- const epoch_ms = inst.fields()[0].asInteger();
- // A 2-field inst value is a Timestamp (epoch-ms + nanos) →
- // 9-digit fraction; a 1-field one is a Date → 3-digit ms.
- const iso = if (inst.field_count >= 2 and inst.fields()[1].tag() == .integer)
- instant_mod.formatInstantNanos(&buf, epoch_ms, @intCast(inst.fields()[1].asInteger()))
- else
- instant_mod.formatInstantMillis(&buf, epoch_ms);
- try w.print("#{s} \"{s}\"", .{ tag, iso });
+ {
+ var buf: [48]u8 = undefined;
+ if (readerTagParts(v, &buf)) |parts| {
+ try w.print("#{s} \"{s}\"", .{ parts.tag, parts.body });
return;
}
}
diff --git a/test/e2e/phase15_literal_roundtrip.sh b/test/e2e/phase15_literal_roundtrip.sh
new file mode 100755
index 000000000..c85c7b9eb
--- /dev/null
+++ b/test/e2e/phase15_literal_roundtrip.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# test/e2e/phase15_literal_roundtrip.sh — literals that must survive a macro
+# expansion, plus the two literal/resolution gaps the clojure.core compliance
+# suite surfaced.
+#
+# A macro's arguments arrive as VALUES and its expansion is re-analysed as a
+# form, so every value the reader can produce needs an inverse. `#uuid` and
+# `#inst` had none: any macro whose body contained one killed the whole
+# namespace. `resolve` looked its prefix up as a real namespace name only, so
+# an aliased `str/blank?` answered nil while the var plainly existed. And a
+# BigDecimal exponent was rejected outright. Layer 2.
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+BIN="zig-out/bin/cljw"
+[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
+fail() { echo "FAIL $1" >&2; exit 1; }
+assert_eq() { local n="$1" g="$2" w="$3"; [[ "$g" == "$w" ]] || fail "$n: got '$g' want '$w'"; echo "PASS $n -> $w"; }
+
+MACRO='(defmacro m [& body] `(do ~@body))'
+
+# --- reader literals round-trip through a macro expansion ---
+
+assert_eq 'uuid-through-macro' \
+ "$("$BIN" -e "(do $MACRO (m #uuid \"550e8400-e29b-41d4-a716-446655440000\"))" 2>&1 | tail -1)" \
+ '#uuid "550e8400-e29b-41d4-a716-446655440000"'
+
+# the value is a uuid, not a re-read string
+assert_eq 'uuid-through-macro-type' \
+ "$("$BIN" -e "(do $MACRO (uuid? (m #uuid \"550e8400-e29b-41d4-a716-446655440000\")))" 2>&1 | tail -1)" \
+ 'true'
+
+assert_eq 'inst-through-macro' \
+ "$("$BIN" -e "(do $MACRO (inst? (m #inst \"2010-11-12T13:14:15.666-05:00\")))" 2>&1 | tail -1)" \
+ 'true'
+
+# the printer and the analyzer's inverse share one rule, so a value that
+# prints as a reader literal reads back as the same value
+assert_eq 'inst-round-trip-equal' \
+ "$("$BIN" -e "(do $MACRO (= #inst \"2010-11-12T18:14:15.666-00:00\" (m #inst \"2010-11-12T13:14:15.666-05:00\")))" 2>&1 | tail -1)" \
+ 'true'
+
+# --- resolve follows a require alias ---
+
+assert_eq 'resolve-through-alias' \
+ "$("$BIN" -e "(do (require '[clojure.string :as str]) (resolve 'str/blank?))" 2>&1 | tail -1)" \
+ "#'clojure.string/blank?"
+
+assert_eq 'resolve-real-ns-still-works' \
+ "$("$BIN" -e "(do (require '[clojure.string :as str]) (resolve 'clojure.string/blank?))" 2>&1 | tail -1)" \
+ "#'clojure.string/blank?"
+
+# an unknown name under a known alias is still nil, not an error
+assert_eq 'resolve-alias-miss' \
+ "$("$BIN" -e "(do (require '[clojure.string :as str]) (resolve 'str/no-such-var))" 2>&1 | tail -1)" \
+ 'nil'
+
+# ns-resolve reads the symbol's prefix in the CONTEXT of the named ns
+assert_eq 'ns-resolve-through-alias' \
+ "$("$BIN" -e "(do (require '[clojure.string :as str]) (ns-resolve 'user 'str/upper-case))" 2>&1 | tail -1)" \
+ "#'clojure.string/upper-case"
+
+# --- BigDecimal exponents ---
+# `× 10^exp` on a (unscaled, scale) pair is a scale shift; the digits never move
+
+assert_eq 'bigdec-positive-exponent' \
+ "$("$BIN" -e '1e5M' 2>&1 | tail -1)" \
+ '1E+5M'
+
+assert_eq 'bigdec-negative-exponent' \
+ "$("$BIN" -e '1E-10M' 2>&1 | tail -1)" \
+ '1E-10M'
+
+assert_eq 'bigdec-signed-mantissa-exponent' \
+ "$("$BIN" -e '-1.5E-10M' 2>&1 | tail -1)" \
+ '-1.5E-10M'
+
+assert_eq 'bigdec-exponent-value' \
+ "$("$BIN" -e '(= 150000M 1.5e5M)' 2>&1 | tail -1)" \
+ 'true'
+
+assert_eq 'bigdec-plain-unchanged' \
+ "$("$BIN" -e '123.456M' 2>&1 | tail -1)" \
+ '123.456M'
+
+# --- when-let / when-first with no body (clj returns nil either way) ---
+
+assert_eq 'when-let-empty-body-truthy' \
+ "$("$BIN" -e '(when-let [x 1])' 2>&1 | tail -1)" \
+ 'nil'
+
+assert_eq 'when-let-empty-body-falsey' \
+ "$("$BIN" -e '(when-let [x nil])' 2>&1 | tail -1)" \
+ 'nil'
+
+assert_eq 'when-let-body-still-runs' \
+ "$("$BIN" -e '(when-let [x 5] (* x 2))' 2>&1 | tail -1)" \
+ '10'
+
+assert_eq 'when-first-empty-body' \
+ "$("$BIN" -e '(when-first [x [1 2]])' 2>&1 | tail -1)" \
+ 'nil'
+
+assert_eq 'when-first-body-still-runs' \
+ "$("$BIN" -e '(when-first [x [7 8]] (inc x))' 2>&1 | tail -1)" \
+ '8'
+
+echo "OK — phase15_literal_roundtrip (17 cases) green"
diff --git a/test/run_all.sh b/test/run_all.sh
index 1444fc8ec..7fa381fd0 100644
--- a/test/run_all.sh
+++ b/test/run_all.sh
@@ -728,6 +728,7 @@ run_step "e2e_phase15_class_as_value" "bash test/e2e/phase15_class_as_valu
run_step "e2e_phase15_pr_newline" "bash test/e2e/phase15_pr_newline.sh"
run_step "e2e_phase15_ns_docstring" "bash test/e2e/phase15_ns_docstring.sh"
run_step "e2e_phase15_reader_conditional" "bash test/e2e/phase15_reader_conditional.sh"
+run_step "e2e_phase15_literal_roundtrip" "bash test/e2e/phase15_literal_roundtrip.sh"
run_step "e2e_phase15_persistent_queue" "bash test/e2e/phase15_persistent_queue.sh"
run_step "e2e_phase15_clojure_stacktrace" "bash test/e2e/phase15_clojure_stacktrace.sh"
run_step "e2e_phase15_clojure_uuid" "bash test/e2e/phase15_clojure_uuid.sh"
@@ -780,6 +781,10 @@ run_step "e2e_phase14_deftype_transient_family" "bash test/e2e/phase14_deftype_t
run_step "e2e_phase14_print_method" "bash test/e2e/phase14_print_method.sh"
run_step "e2e_phase14_member_on_native" "bash test/e2e/phase14_member_on_native.sh"
run_step "e2e_phase14_java_method_grouping" "bash test/e2e/phase14_java_method_grouping.sh"
+run_step "e2e_semaphore" "bash test/e2e/semaphore.sh"
+run_step "e2e_concurrent_atomics" "bash test/e2e/concurrent_atomics.sh"
+run_step "e2e_concurrent_interfaces" "bash test/e2e/concurrent_interfaces.sh"
+run_step "e2e_linked_blocking_queue" "bash test/e2e/linked_blocking_queue.sh"
run_step "e2e_list_count_exact" "bash test/e2e/list_count_exact.sh"
run_step "e2e_future_methods" "bash test/e2e/future_methods.sh"
run_step "e2e_vector_seq_view" "bash test/e2e/vector_seq_view.sh"
From 4e053e7f03a34c6f22c2a1747b0c4dc90c0bc4f2 Mon Sep 17 00:00:00 2001
From: clojurewasm-release
Date: Fri, 14 Aug 2026 19:46:46 +0000
Subject: [PATCH 062/466] chore(release): v1.10.7
---
build.zig.zon | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.zig.zon b/build.zig.zon
index cd96f54cd..1dd5ab2d8 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.6",
+ .version = "1.10.7",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From 157a53b8ac0fe2146d0568962f145bddefba553b Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 11:40:53 -0300
Subject: [PATCH 063/466] fix(dissoc): multi-key dissoc on a non-map receiver
is catchable, folds like assoc
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 1: removes a Miscategorised-error smell — the feature-gap Kind
(feature_not_supported -> not_implemented, deliberately uncatchable) was applied
to a recoverable bad-receiver rejection; adopts the accepted assoc/D-378 finished
form; not_implemented stays uncatchable for genuine feature gaps.
Multi-key dissoc on an extend-type IPersistentMap receiver raised the uncatchable
not_implemented Kind, so a portability try/catch could not defend against it and
the process died. Rewrite the dissoc else arm to fold multi-key over single-key
-without (mirroring the assoc D-378 else arm): the multi-key gap disappears and a
non-map receiver now hits the catchable protocol_no_satisfies (value_error), same
as the single-key path. clj throws a catchable ClassCastException here.
---
src/lang/primitive/collection.zig | 19 ++++++++++---------
test/e2e/data_errors_catchable.sh | 9 ++++++++-
test/e2e/phase8_d089_assoc_extend.sh | 11 ++++++++++-
3 files changed, 28 insertions(+), 11 deletions(-)
diff --git a/src/lang/primitive/collection.zig b/src/lang/primitive/collection.zig
index afb20cda5..de2488c39 100644
--- a/src/lang/primitive/collection.zig
+++ b/src/lang/primitive/collection.zig
@@ -872,16 +872,17 @@ pub fn dissocFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocatio
break :blk try td_mod.allocInstanceFull(rt, inst.descriptor, fields, inst.meta, ext);
},
else => blk: {
- // D-089 row 8.6 cycle 3: IPersistentMap -without slow-path.
- // Single-key only; multi-key extension folds via reduce in
- // user code (same shape as the assoc outer-else).
- if (args.len != 2) {
- break :blk error_catalog.raise(.feature_not_supported, loc, .{
- .name = "multi-key dissoc on extend-type IPersistentMap receiver",
- });
+ // D-089 row 8.6 cycle 3: IPersistentMap `-without` slow-path. Multi-key
+ // folds over repeated single-key `-without` (mirrors the assoc else arm,
+ // D-378); a non-map receiver raises a catchable protocol-no-impl on the
+ // first dispatch, same as the single-key path.
+ var acc: Value = coll;
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ var cs: dispatch.CallSite = .{};
+ acc = try dispatch.dispatch(rt, env, &cs, acc, IPM_FQCN, "-without", &.{ acc, args[i] }, loc);
}
- var cs: dispatch.CallSite = .{};
- break :blk try dispatch.dispatch(rt, env, &cs, coll, IPM_FQCN, "-without", args, loc);
+ break :blk acc;
},
};
}
diff --git a/test/e2e/data_errors_catchable.sh b/test/e2e/data_errors_catchable.sh
index 4ef930434..835b1c169 100755
--- a/test/e2e/data_errors_catchable.sh
+++ b/test/e2e/data_errors_catchable.sh
@@ -70,6 +70,13 @@ catchable eval_bad_destructure "(eval '(let [{:bad 1} {}] 1))"
catchable read_string_bad_cond '(read-string "#?[")'
catchable substring_oob '(.substring "abc" 1 99)'
+# Multi-key dissoc on a non-map receiver folds over single-key `-without`, and the
+# first dispatch raises a catchable protocol-no-impl (clj: ClassCastException on the
+# String->IPersistentMap cast). Was the uncatchable `feature_not_supported` — the
+# process died straight through `(catch Throwable …)`.
+catchable dissoc_multikey_nonmap '(dissoc "string" \s \t)'
+catchable dissoc_multikey_apply '(apply dissoc "string" [\s \t])'
+
# The counter-example: a genuinely unsupported feature stays UNCATCHABLE, so
# `(catch Throwable …)` must NOT swallow it. Asserted by the inverse: the
# process exits non-zero and the catch does not produce a value.
@@ -85,4 +92,4 @@ grep -qx ':caught' <<< "$got" \
&& fail "unsupported_stays_uncatchable: a catch swallowed an unsupported feature"
echo "PASS unsupported_stays_uncatchable"
-echo "data_errors_catchable: 14/14 cases pass"
+echo "data_errors_catchable: 16/16 cases pass"
diff --git a/test/e2e/phase8_d089_assoc_extend.sh b/test/e2e/phase8_d089_assoc_extend.sh
index d4a7d225f..b55c3321d 100755
--- a/test/e2e/phase8_d089_assoc_extend.sh
+++ b/test/e2e/phase8_d089_assoc_extend.sh
@@ -46,6 +46,15 @@ EOF
) || fail "case2: non-zero exit ($got)"
assert_eq 'long_dissoc_via_extend_type' "$(last_line "$got")" ':without-on-int'
+# --- Case 2b: multi-key -without folds over repeated single-key dispatch ---
+got=$("$BIN" - <<'EOF' 2>/dev/null
+(def Long (cljw.internal/__native-type :integer))
+(extend-type Long IPersistentMap (-without [n k] n))
+(prn (dissoc 42 :a :b :c))
+EOF
+) || fail "case2b: non-zero exit ($got)"
+assert_eq 'long_dissoc_multikey_fold' "$(last_line "$got")" '42'
+
# --- Case 3: native Tag (Long) -keys via outer-else slow-path ---
got=$("$BIN" - <<'EOF' 2>/dev/null
(def Long (cljw.internal/__native-type :integer))
@@ -85,4 +94,4 @@ assert_eq 'builtin_map_dissoc_preserved' "$(last_line "$got")" '{:b 2}'
got=$("$BIN" -e '(contains? #{:a :b} :a)' 2>/dev/null) || fail "case8: non-zero exit ($got)"
assert_eq 'builtin_set_contains_preserved' "$(last_line "$got")" 'true'
-echo "phase8_d089_assoc_extend: 8/8 cases pass"
+echo "phase8_d089_assoc_extend: 9/9 cases pass"
From 9e9d7686e368e578cf01e14ec4ddeb1197748906 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 11:42:06 -0300
Subject: [PATCH 064/466] test(equal): Layer-7 property pins the hash/eq law
over equal.zig
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 0: test-only; reuses the existing prop.IntSlice generator and the
prop_collections Fixture idiom (no hand-rolled Value generator) and encodes the
true law rather than shaping the test around a weakness.
equal.zig (~1383 lines) carried zero tests. Add a Layer-7 property (ADR-0186)
pinning equal(a,b) => hash(a)==hash(b) — the law a hashed collection depends on —
plus reflexivity and symmetry, over a pool that mints the same value through
different representations (fixnum vs BigInt, vector vs list) so equal-but-
differently-built pairs actually occur. Both the = surface (valueEqual) and the
map/set key surface (keyEqValue) are pinned against valueHash. Wired into the
main.zig test aggregator; runs in zig build test on both backends.
---
src/main.zig | 1 +
src/testing/prop_equal.zig | 182 +++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+)
create mode 100644 src/testing/prop_equal.zig
diff --git a/src/main.zig b/src/main.zig
index 5c5403b69..7453ba264 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -68,6 +68,7 @@ test {
_ = @import("testing/prop.zig");
_ = @import("testing/prop_collections.zig");
_ = @import("testing/prop_print.zig");
+ _ = @import("testing/prop_equal.zig");
_ = @import("app/cli.zig");
_ = @import("app/repl/line_editor.zig");
diff --git a/src/testing/prop_equal.zig b/src/testing/prop_equal.zig
new file mode 100644
index 000000000..edd333dbe
--- /dev/null
+++ b/src/testing/prop_equal.zig
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Layer 7 (Property) — the hash/eq law over `clojure.core/=`. ADR-0186 / ADR-0052.
+//!
+//! The load-bearing law a hashed collection depends on: two `=` values MUST
+//! carry the same hash, so an `=` key finds its bucket. This file pins that
+//! direction plus reflexivity and symmetry over a pool of values built from
+//! GENERATED specs, deliberately minting the same value through DIFFERENT
+//! representations (a fixnum and a heap BigInt of one integer; a vector and a
+//! list of one element) so the equal-but-differently-built pairs — the ones the
+//! law exists to constrain — actually occur.
+//!
+//! Two equality surfaces are pinned against `valueHash`: the user-facing
+//! `valueEqual` (`=`) and the map/set key `keyEqValue`; both must imply equal
+//! hash. Values are finite / NaN-free so `=` stays reflexive (clj's `=` is
+//! non-reflexive only on NaN, a documented divergence not weakened here).
+
+const std = @import("std");
+const prop = @import("prop.zig");
+const build_options = @import("build_options");
+
+const Value = @import("../runtime/value/value.zig").Value;
+const Runtime = @import("../runtime/runtime.zig").Runtime;
+const Env = @import("../runtime/env.zig").Env;
+const equal = @import("../runtime/equal.zig");
+const big_int = @import("../runtime/numeric/big_int.zig");
+const keyword_mod = @import("../runtime/keyword.zig");
+const string_mod = @import("../runtime/collection/string.zig");
+const vector = @import("../runtime/collection/vector.zig");
+const list = @import("../runtime/collection/list.zig");
+
+const testing = std.testing;
+
+/// Gate defaults, overridable for a sweep: `zig build test -Dprop-seed=0x… -Dprop-iters=…`.
+fn config() prop.Config {
+ return .{ .seed = build_options.prop_seed, .iters = build_options.prop_iters };
+}
+
+/// 10 kinds × 4 payloads = 40 distinct specs, drawn small so the pool collides
+/// often (same value from one spec, and — across kinds 0/1 and 8/9 — the same
+/// value from two representations).
+const kinds = 10;
+const payloads = 4;
+const kw_names = [payloads][]const u8{ "ka", "kb", "kc", "kd" };
+const str_vals = [payloads][]const u8{ "sa", "sb", "sc", "sd" };
+
+/// Map a generated i64 spec to a Value. Kinds 0/1 (fixnum / heap BigInt) and 8/9
+/// (vector / list) are the two cross-representation pairs the law hinges on.
+fn valueFromSpec(rt: *Runtime, spec: i64) !Value {
+ const kind = @mod(spec, kinds);
+ const p: i64 = @mod(@divFloor(spec, kinds), payloads);
+ const pu: usize = @intCast(p);
+ return switch (kind) {
+ 0 => Value.initInteger(p),
+ 1 => try big_int.allocFromI64(rt, p, .bigint),
+ 2 => Value.initFloat(@floatFromInt(p)),
+ 3 => try keyword_mod.intern(rt, null, kw_names[pu]),
+ 4 => try string_mod.alloc(rt, str_vals[pu]),
+ 5 => Value.initChar(@intCast(97 + p)),
+ 6 => Value.initBoolean(@mod(p, 2) == 0),
+ 7 => Value.nil_val,
+ 8 => vec: {
+ const elems = [_]Value{Value.initInteger(p)};
+ break :vec try vector.fromSlice(rt, &elems);
+ },
+ 9 => lst: {
+ break :lst try list.consHeap(rt, Value.initInteger(p), try list.emptyList(rt));
+ },
+ else => unreachable,
+ };
+}
+
+fn buildPool(rt: *Runtime, xs: []i64, out: *std.ArrayList(Value)) !void {
+ for (xs) |spec| try out.append(testing.allocator, try valueFromSpec(rt, spec));
+}
+
+/// A fresh runtime + env per property evaluation. `Env.init` only mints the
+/// namespace objects (no bootstrap load), so per-iteration cost is negligible.
+const Fixture = struct {
+ fn run(comptime body: fn (*Runtime, *Env, []i64) anyerror!void, xs: []i64) anyerror!void {
+ var th = std.Io.Threaded.init(testing.allocator, .{});
+ defer th.deinit();
+ var rt = Runtime.init(th.io(), testing.allocator);
+ defer rt.deinit();
+ var env = try Env.init(&rt);
+ defer env.deinit();
+ try body(&rt, &env, xs);
+ }
+};
+
+const gen = prop.IntSlice{ .distinct = kinds * payloads, .max_len = 48 };
+
+// --- the laws ---
+
+test "property: = implies equal hash (the load-bearing hash/eq law)" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, env: *Env, xs: []i64) anyerror!void {
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ for (pool.items) |b| {
+ if ((try equal.valueEqual(rt, env, a, b)) and
+ (equal.valueHash(a) != equal.valueHash(b)))
+ return error.EqualButHashDiffers;
+ }
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: map/set key equality implies equal hash (HAMT bucketing)" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, _: *Env, xs: []i64) anyerror!void {
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ for (pool.items) |b| {
+ if (equal.keyEqValue(a, b) and (equal.valueHash(a) != equal.valueHash(b)))
+ return error.KeyEqualButHashDiffers;
+ }
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: = is reflexive for every generated value" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, env: *Env, xs: []i64) anyerror!void {
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ if (!try equal.valueEqual(rt, env, a, a)) return error.NotReflexive;
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: = is symmetric" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, env: *Env, xs: []i64) anyerror!void {
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ for (pool.items) |b| {
+ if ((try equal.valueEqual(rt, env, a, b)) != (try equal.valueEqual(rt, env, b, a)))
+ return error.NotSymmetric;
+ }
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
From 2641bcc48f83a5bdb1a166c4b8e9656eb08cdac7 Mon Sep 17 00:00:00 2001
From: clojurewasm-release
Date: Mon, 17 Aug 2026 14:43:05 +0000
Subject: [PATCH 065/466] chore(release): v1.10.8
---
build.zig.zon | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.zig.zon b/build.zig.zon
index 1dd5ab2d8..bd84e8c19 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .cljw,
- .version = "1.10.7",
+ .version = "1.10.8",
.fingerprint = 0x1869d207073beffa,
.minimum_zig_version = "0.16.0",
.dependencies = .{
From 77372e58b8cb828e0f892dbbc6cde260304f5890 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 12:13:34 -0300
Subject: [PATCH 066/466] fix(numeric): NaN-safe float order stops sort /
sorted-coll / mod panicking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 2: duplicated-logic smell — math.zig's compare primitive already had
the NaN->eq rule while valueCompare (the native sort path) hit std.math.order's
unreachable on NaN. The fix lands ONE shared promote.floatOrder used by both float-
order sites (no third copy of the rule). Depth 2 because it also records AD-018 for
the quot/rem/mod-NaN family per the accepted_divergences discipline.
Zig's std.math.order ends in `unreachable` after ==/>, and NaN is false to all
three, so (sort [1 ##NaN]), (sorted-set 1.0 ##NaN) and (mod ##NaN 3) panicked the
runtime — a violated invariant no catch Throwable could see. Two float-operand sites
fed it: compare.zig numericOrder's .floating arm (valueCompare, reached by sort /
sort-by / sorted-set / sorted-map) and promote.zig numSign's .float arm (modPromoting
+ cross-category orderNumeric). Route both through a shared promote.floatOrder that
returns .eq for any NaN operand — the clj compare rule (NaN equals everything), so a
stable sort preserves input order and sorted collections treat NaN as a duplicate key.
Fixes CLJW-SORT-NAN + CLJW-MOD-PANIC.
quot/rem/mod on a NaN operand now return ##NaN uniformly (mod joins quot/rem, which
already did); JVM Clojure throws NumberFormatException here, recorded as AD-018 (the
AD-008 shape, derives_from F-005). Tests: phase14_sort/sorted/quot_rem_mod_tower NaN
rows + 2 promote.zig unit tests + 2 compare_cross_numeric corpus cases.
---
.dev/accepted_divergences.yaml | 16 ++++++++++
src/runtime/compare.zig | 2 +-
src/runtime/numeric/promote.zig | 30 ++++++++++++++++++-
.../diff/clj_corpus/compare_cross_numeric.txt | 4 +++
test/e2e/phase14_quot_rem_mod_tower.sh | 9 ++++++
test/e2e/phase14_sort.sh | 10 +++++++
test/e2e/phase14_sorted.sh | 5 ++++
7 files changed, 74 insertions(+), 2 deletions(-)
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index 68394f67c..c3f4372f4 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -240,6 +240,22 @@ accepted:
`:volatile-mutable` keyword (assignability) is acted on.
derives_from: "F-004 (NaN-box uniform 8-byte Value, no primitive field storage) + F-005 (no primitive specialization) + ADR-0104"
pin: "test/e2e/phase14_deftype_mutable.sh — long_hint_ignored_accepts_non_long (a `^long` mutable field accepts a String set)"
+ - id: "AD-018"
+ summary: "quot/rem/mod with a NaN operand yield NaN in cljw; JVM Clojure THROWS NumberFormatException (its double remainder routes a NaN quotient through new BigDecimal(NaN))"
+ example: '(mod ##NaN 3) => cljw ##NaN / clj THROW NumberFormatException "Infinite or NaN"; likewise (rem ##NaN 3), (quot ##NaN 3), (mod 3 ##NaN)'
+ why: |-
+ F-005's single-f64 numeric tower runs quot/rem/mod on a float operand
+ through one clean IEEE path (@trunc of the f64 quotient), so a NaN in
+ yields NaN out uniformly. JVM Clojure's DoubleOps.remainder casts the
+ quotient to long only when it is in Long range; a NaN quotient fails
+ that range test and falls to new BigDecimal(q), which throws
+ NumberFormatException on NaN/Infinity. cljw does not reproduce that
+ long-cast-BigDecimal fallback, so it returns NaN instead of throwing —
+ the same "cljw's cleaner numeric surface vs clj's throw" shape as
+ AD-008. quot/rem already returned NaN; fixing CLJW-MOD-PANIC (numSign's
+ float arm hit std.math.order's unreachable on NaN) made mod join them.
+ derives_from: "F-005 (numeric tower: single internal f64, one clean float quot/rem/mod path) + AD-008 (cljw's cleaner numeric surface where clj throws)"
+ pin: "test/e2e/phase14_quot_rem_mod_tower.sh — quot_nan / rem_nan / mod_nan_d / mod_nan_v lock cljw's ##NaN (the divergent value; clj throws)"
- id: "AD-019"
summary: "Java arrays are type-erased: element type hints (`^\"[B\"`, `^ints`, `^bytes`) are advisory only, and a typed-array constructor / `aset` does not coerce or type-check non-byte/short/char elements."
example: '(let [^"[B" b (byte-array 1)] (aset b 0 65) (aget b 0)) => 65 (a non-byte stored into a byte-typed array, no error); (class (object-array 0)) => cljw "array" / clj "[Ljava.lang.Object;"'
diff --git a/src/runtime/compare.zig b/src/runtime/compare.zig
index afeda601a..33bc7c2c7 100644
--- a/src/runtime/compare.zig
+++ b/src/runtime/compare.zig
@@ -82,7 +82,7 @@ fn numericOrder(rt: *Runtime, a: Value, b: Value, loc: SourceLocation) anyerror!
if (ca == cb) {
return switch (ca) {
.integer => intOrder(a, b),
- .floating => std.math.order(a.asFloat(), b.asFloat()),
+ .floating => promote.floatOrder(a.asFloat(), b.asFloat()),
.ratio => try ratio.compareValue(rt, a, b),
.decimal => try big_decimal.compareValue(rt, a, b),
.none => unreachable,
diff --git a/src/runtime/numeric/promote.zig b/src/runtime/numeric/promote.zig
index 378ae1a09..bcb096849 100644
--- a/src/runtime/numeric/promote.zig
+++ b/src/runtime/numeric/promote.zig
@@ -623,6 +623,14 @@ pub fn divPromoting(rt: *Runtime, a: Value, b: Value) !Value {
return try wrapManaged(rt, &q);
}
+/// Three-way order of two f64 that maps a NaN operand to `.eq`
+/// (the clojure.core/compare rule). Shared by every numeric-tower
+/// ordering site: `valueCompare`'s float arm + `numSign`.
+pub fn floatOrder(a: f64, b: f64) std.math.Order {
+ if (std.math.isNan(a) or std.math.isNan(b)) return .eq;
+ return std.math.order(a, b);
+}
+
/// Exact sign of a numeric Value: `.lt` (negative) / `.eq` (zero) /
/// `.gt` (positive). Used by `modPromoting`'s floor-mod correction.
/// Exact (no float round-off) for the integer / BigInt / Ratio cases;
@@ -631,7 +639,7 @@ fn numSign(v: Value) std.math.Order {
return switch (v.tag()) {
.integer => std.math.order(@as(i64, v.asInteger()), 0),
.char => std.math.order(@as(i64, v.asChar()), 0),
- .float => std.math.order(v.asFloat(), 0),
+ .float => floatOrder(v.asFloat(), 0),
.big_int => big_int.asManaged(v).toConst().orderAgainstScalar(0),
.ratio => switch (ratio_mod.parts(v)) {
.small => |s| std.math.order(s.n, 0),
@@ -1072,3 +1080,23 @@ test "exactI64 accepts integer / in-range BigInt; rejects float / ratio" {
const half = try divPromoting(&fix.rt, Value.initInteger(1), Value.initInteger(2));
try testing.expectError(error.NotAnInteger, exactI64(half));
}
+
+test "floatOrder maps a NaN operand to .eq (no std.math.order unreachable)" {
+ const nan = std.math.nan(f64);
+ try testing.expectEqual(std.math.Order.eq, floatOrder(nan, 1.0));
+ try testing.expectEqual(std.math.Order.eq, floatOrder(1.0, nan));
+ try testing.expectEqual(std.math.Order.eq, floatOrder(nan, nan));
+ try testing.expectEqual(std.math.Order.lt, floatOrder(1.0, 2.0));
+ try testing.expectEqual(std.math.Order.gt, floatOrder(2.0, 1.0));
+ try testing.expectEqual(std.math.Order.eq, floatOrder(1.0, 1.0));
+}
+
+test "modPromoting with a NaN operand yields NaN, never a numSign panic" {
+ var fix = Fixture.init();
+ defer fix.deinit();
+ const nan = Value.initFloat(std.math.nan(f64));
+ const md = try modPromoting(&fix.rt, nan, Value.initInteger(3));
+ try testing.expect(md.isFloat() and std.math.isNan(md.asFloat()));
+ const mv = try modPromoting(&fix.rt, Value.initInteger(3), nan);
+ try testing.expect(mv.isFloat() and std.math.isNan(mv.asFloat()));
+}
diff --git a/test/diff/clj_corpus/compare_cross_numeric.txt b/test/diff/clj_corpus/compare_cross_numeric.txt
index da27c0944..57f6c8a40 100644
--- a/test/diff/clj_corpus/compare_cross_numeric.txt
+++ b/test/diff/clj_corpus/compare_cross_numeric.txt
@@ -40,3 +40,7 @@
;;=> 0
(compare ##NaN 1.5M)
;;=> 0
+(sort [1 ##NaN])
+;;=> (1 ##NaN)
+(sort [2.0 ##NaN])
+;;=> (2.0 ##NaN)
diff --git a/test/e2e/phase14_quot_rem_mod_tower.sh b/test/e2e/phase14_quot_rem_mod_tower.sh
index 580fa8a7d..64da85296 100755
--- a/test/e2e/phase14_quot_rem_mod_tower.sh
+++ b/test/e2e/phase14_quot_rem_mod_tower.sh
@@ -41,6 +41,15 @@ assert_eq 'mod_neg' "$("$BIN" -e '(mod -7 3)')" '2'
assert_eq 'mod_neg_div' "$("$BIN" -e '(mod 7 -3)')" '-2'
assert_eq 'mod_float_frac' "$("$BIN" -e '(mod 10.5 3)')" '1.5'
+# --- NaN operand: cljw's single-f64 tower (F-005) returns NaN uniformly for
+# quot/rem/mod (AD-018). JVM Clojure THROWS NumberFormatException here (its double
+# remainder routes a NaN quotient through new BigDecimal(NaN)); cljw does not
+# reproduce that fallback. mod must NOT panic (was numSign's std.math.order unreachable).
+assert_eq 'quot_nan' "$("$BIN" -e '(quot ##NaN 3)')" '##NaN'
+assert_eq 'rem_nan' "$("$BIN" -e '(rem ##NaN 3)')" '##NaN'
+assert_eq 'mod_nan_d' "$("$BIN" -e '(mod ##NaN 3)')" '##NaN'
+assert_eq 'mod_nan_v' "$("$BIN" -e '(mod 3 ##NaN)')" '##NaN'
+
# --- divide-by-zero throws for every category (incl. float — unlike `/`) ---
assert_has 'quot_zero' "$("$BIN" -e '(quot 3N 0)' 2>&1)" 'Divide by zero'
assert_has 'rem_zero' "$("$BIN" -e '(rem 10 0)' 2>&1)" 'Divide by zero'
diff --git a/test/e2e/phase14_sort.sh b/test/e2e/phase14_sort.sh
index cdf5fb1b0..5759868c4 100755
--- a/test/e2e/phase14_sort.sh
+++ b/test/e2e/phase14_sort.sh
@@ -43,5 +43,15 @@ assert_eq 'sort_large_max' "$("$BIN" -e '(last (sort (reverse (range 5000))))')"
assert_eq 'sort_seq' "$("$BIN" -e '(sort [3 1 2])')" '(1 2 3)'
assert_eq 'sort_isseq' "$("$BIN" -e '(seq? (sort [3 1 2]))')" 'true'
assert_eq 'sortby_seq' "$("$BIN" -e '(sort-by - [3 1 2])')" '(3 2 1)'
+# CLJW-SORT-NAN: a NaN operand must not panic the native sort. NaN compares .eq
+# to every number (clj compare rule) so a stable sort preserves input order.
+# int+NaN exercises the cross-category numSign path; float+NaN the same-category
+# floating arm. Exact tie-order for 3+ mixed-magnitude elements is not asserted
+# (block sort vs clj TimSort under a non-transitive comparator) — cardinality is.
+assert_eq 'sort_nan_int' "$("$BIN" -e '(into [] (sort [1 ##NaN]))')" '[1 ##NaN]'
+assert_eq 'sort_nan_flt' "$("$BIN" -e '(into [] (sort [2.0 ##NaN]))')" '[2.0 ##NaN]'
+assert_eq 'sort_nan_cnt' "$("$BIN" -e '(count (sort [3 ##NaN 1]))')" '3'
+assert_eq 'sort_nan_fcnt' "$("$BIN" -e '(count (sort [2.0 ##NaN 1.0 3.0]))')" '4'
+assert_eq 'sortby_nan' "$("$BIN" -e '(into [] (sort-by identity [1 ##NaN]))')" '[1 ##NaN]'
echo "ALL phase14_sort PASS"
diff --git a/test/e2e/phase14_sorted.sh b/test/e2e/phase14_sorted.sh
index 277dea4a6..69a94bd15 100644
--- a/test/e2e/phase14_sorted.sh
+++ b/test/e2e/phase14_sorted.sh
@@ -30,6 +30,11 @@ assert_eq 'set_cont' "$("$BIN" -e '(contains? (sorted-set 1 2 3) 2)')" 'true
assert_eq 'set_conj' "$("$BIN" -e '(seq (conj (sorted-set 1 3) 2))')" '(1 2 3)'
assert_eq 'set_dup' "$("$BIN" -e '(count (sorted-set 1 2 2 3))')" '3'
assert_eq 'set_print' "$("$BIN" -e '(str (sorted-set 3 1 2))')" '"#{1 2 3}"'
+# CLJW-SORT-NAN: NaN compares .eq to every number -> treated as a duplicate key on
+# LLRB insert (no native-compare panic); the tie's value wins insert-order (clj parity).
+assert_eq 'set_nan' "$("$BIN" -e '(str (sorted-set 1.0 ##NaN))')" '"#{1.0}"'
+assert_eq 'set_nan2' "$("$BIN" -e '(seq (sorted-set 1.0 ##NaN 2.0))')" '(1.0 2.0)'
+assert_eq 'map_nan' "$("$BIN" -e '(str (sorted-map 1.0 :a ##NaN :b))')" '"{1.0 :b}"'
# sorted?
assert_eq 'sortedQ_m' "$("$BIN" -e '(sorted? (sorted-map))')" 'true'
assert_eq 'sortedQ_s' "$("$BIN" -e '(sorted? (sorted-set))')" 'true'
From 6028e6d16805f4bffb4b61ddd58a728ddf11b4b5 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 12:46:52 -0300
Subject: [PATCH 067/466] docs(compat_tiers): math/spec/spec.gen are
implemented, not "unimplemented"
The 2026-05-29 "down-tiered, UNIMPLEMENTED, no math.clj / no spec/ directory
(verified)" note is stale. clojure.math (clojure/math.clj + corpus),
clojure.spec.alpha (2019-line port + phase15_spec.sh + spec.txt + D-475) and
clojure.spec.gen.alpha are all bundled under src/lang/clj/clojure/ and eager-
loaded by bootstrap.zig. Fix the status strings; Tier letter stays B (a status-
truth fix, not a tier change, per tier_classification.md). Verified 2026-08-17.
Generative spec (s/gen, s/exercise) still throws at call time pending
clojure.test.check bundling.
---
data/compat_tiers.yaml | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/data/compat_tiers.yaml b/data/compat_tiers.yaml
index 578e6ef38..12f61e818 100644
--- a/data/compat_tiers.yaml
+++ b/data/compat_tiers.yaml
@@ -725,14 +725,15 @@ tiers:
clojure.data: {status: "planned (clojure.data/diff)"}
clojure.java.io: {status: "planned (cw-native rewrite)"}
clojure.java.shell: {status: "planned"}
- # Down-tiered from A 2026-05-29: these were declared Tier A
- # ("100% PASS gate") but are UNIMPLEMENTED at v0.1.0 — no math.clj,
- # no spec/ directory (verified). Their math/spec surfaces are
- # post-v0.1.0 quality-loop work (F-010), not a v0.1.0 Tier A peer of
- # JVM Clojure. cw v1 math fns currently live in clojure.core.
- clojure.math: {status: "deferred (post-v0.1.0 quality loop, F-010; unimplemented)"}
- clojure.spec.alpha: {status: "deferred (post-v0.1.0 quality loop, F-010; unimplemented)"}
- clojure.spec.gen.alpha: {status: "deferred (post-v0.1.0 quality loop, F-010; unimplemented)"}
+ # Down-tiered from A 2026-05-29, then IMPLEMENTED in the post-v0.1.0
+ # F-010 quality loop (verified 2026-08-17): all three are bundled .clj
+ # under src/lang/clj/clojure/, eager-loaded by bootstrap.zig, and
+ # exercised by e2e + clj_corpus. The 2026-05-29 "no math.clj / no spec/
+ # directory (verified)" claim is stale and superseded. Tier letter stays
+ # B (a status-truth fix, not a tier change — tier_classification.md).
+ clojure.math: {status: "implemented (clojure/math.clj; loaded by bootstrap.zig; corpus clojure_math.txt + math_doc_claims.txt)"}
+ clojure.spec.alpha: {status: "implemented (clojure/spec/alpha.clj, 2019-line port; loaded by bootstrap.zig; e2e phase15_spec.sh + corpus spec.txt; D-475). Generative subset (s/gen, s/exercise) throws at call time — clojure.test.check not bundled"}
+ clojure.spec.gen.alpha: {status: "implemented (clojure/spec/gen/alpha.clj; loaded by bootstrap.zig). Combinators/prims dynaload clojure.test.check.* at call time; unbundled so they throw when forced"}
C:
description: "Thin reflective implementation via TypeDescriptor walk."
special_forms:
From b9010f922e8e6952f08cd7534ebd01cb86cf754e Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 12:48:32 -0300
Subject: [PATCH 068/466] docs(adr-0187): Proposed -> Accepted (code + e2e
already on main)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 0: doc-truth fix — ADR-0187's code (extend a protocol TO a
protocol via a dispatch-miss fallback, commit c00825b3) and its e2e
(phase14_extend_protocol_targets.sh, 8/8) are ancestors of HEAD, so the
"Proposed" status was a doc-vs-code lag. No code change.
---
.dev/decisions/0187_protocol_as_extend_target.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.dev/decisions/0187_protocol_as_extend_target.md b/.dev/decisions/0187_protocol_as_extend_target.md
index 886d09cd9..d37b1932e 100644
--- a/.dev/decisions/0187_protocol_as_extend_target.md
+++ b/.dev/decisions/0187_protocol_as_extend_target.md
@@ -1,6 +1,6 @@
# 0187 — Extend a protocol to another protocol via a dispatch-miss fallback
-- **Status**: Proposed
+- **Status**: Accepted (code: commit c00825b3; e2e phase14_extend_protocol_targets.sh 8/8; flipped 2026-08-17)
- **Date**: 2026-08-12
- **Author**: BuddhiLW
- **Tags**: protocols, dispatch, host-compat
From 2bd7e2d74ef014dedb5ee944078e102e1ca55782 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 12:57:48 -0300
Subject: [PATCH 069/466] docs(adr-0190): SubVector O(1) subvec design + DA
(Proposed; impl D-044 pending)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 3: new ADR (depth 3) capturing the user-decided O(1) subvec
representation (option a — a new .subvec heap_tag slot; F-004 slot spend
approved) + the mandatory Devil's-advocate alternatives verbatim + three
DA-surfaced correctness requirements (independent nth bounds, empty->real-empty
Vector, nested collapse onto the original parent). Status Proposed not Accepted:
the ~18-file impl needs a dual-backend smoke and the shared machine is memory-
constrained; flips to Accepted with the implementation.
---
.dev/decisions/0190_subvector_o1_view.md | 156 +++++++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 .dev/decisions/0190_subvector_o1_view.md
diff --git a/.dev/decisions/0190_subvector_o1_view.md b/.dev/decisions/0190_subvector_o1_view.md
new file mode 100644
index 000000000..da3ce7f13
--- /dev/null
+++ b/.dev/decisions/0190_subvector_o1_view.md
@@ -0,0 +1,156 @@
+# 0190 — SubVector: O(1) subvec via a repurposed .subvec heap tag
+
+- **Status**: Proposed (design + Devil's-advocate complete; implementation D-044 pending — deferred 2026-08-17 under a build-environment memory constraint; flips to Accepted when the impl lands)
+- **Date**: 2026-08-17
+- **Author**: BuddhiLW (autonomous loop)
+- **Tags**: collections, nan-box, heap-tag, subvec, F-004, OCP, gc
+
+## Context
+
+`clojure.core/subvec` builds a fresh dense vector via
+`(into [] (take (- end start) (drop start v)))` — O(n), measured **7480x**
+slower than clj (9215 ms / 2000 ops at n=16000 vs clj 1.06 ms; D-044). JVM
+Clojure returns an `APersistentVector$SubVector` sharing the parent's root+tail.
+The Zig `vector.zig::subvec` (eager copy) is reached ONLY by
+`clojure.core/subvec` today; the LinkedBlockingQueue's use of it is a separate
+concern (see Decision). The NaN-box tag space (F-004, ADR-0027) is a 64-slot
+census with 9 producerless `unallocated` names; `heap_tag.zig`'s own doc marks
+spending one a user-owned F-004-adjacent decision.
+
+Pedro approved option (a) — a new heap_tag slot — on 2026-08-17 (memory
+`20260817105025-46230807`).
+
+## Decision
+
+Repurpose the producerless `.box` slot (D11, 59) → `.subvec`. New representation
+`SubVector { parent: *Vector, start: u32, len: u32, meta }` in
+`runtime/collection/subvec.zig`, sharing the parent's root+tail and answering
+the vector protocol by offset-delegating into the parent. Direct structural
+mirror of **O-058** (`.array_seq`, commit 4a295706): a new representation
+satisfying an existing protocol, dispatched by **additive** `.subvec` arms next
+to `.vector`, changing no existing consumer's contract.
+
+`clojure.core/subvec` keeps its bounds-check + IndexOutOfBounds throw, then
+calls a new `cljw.internal/__subvec` primitive. The former eager-copy
+`vector.subvec` is renamed `vector.copyRange` and RETAINED for
+LinkedBlockingQueue compaction (a view would leak dropped elements).
+`seq(subvec)` reuses `.array_seq` (array_seq already views any backing via
+`backingCount`/`elementAt`/`viewable` — a `.subvec` arm on those three
+suffices). SubVector is IObj / Indexed / Associative / IPersistentStack /
+Reversible / Counted / Comparable / IHashEq / IFn / Sequential but NOT
+IEditableCollection / IKVReduce / IReduceInit / ISeq (clj class-hierarchy
+parity → `transient`/`reduce-kv` on a subvec throw). GC: `traceSubVector` marks
+`parent`+`meta`; `conj`/`assoc` (a two-alloc window) run in a fabrication
+no-collect region.
+
+### Three non-obvious correctness requirements (DA-surfaced; LSP checklist)
+
+1. **`nth` is offset + INDEPENDENT bounds, not bare delegation.** `nth(sv, i)`
+ bounds-checks against the SubVector's own `len` BEFORE delegating —
+ `(nth (subvec v 0 3) 5)` is nil even though `parent[start+5]` is live
+ (existing `vector.zig:851` pattern).
+2. **Empty-subvec returns a real empty Vector, NOT a count-0 SubVector.**
+ `(subvec v k k)` → `vector.empty()`. The O-058 "never-empty → nil at
+ construction" invariant does NOT carry over.
+3. **Nested-subvec collapses onto the ORIGINAL parent.**
+ `(subvec (subvec v a b) c d)` folds offsets onto `v`; `parent` is always
+ typed `*Vector`, never `*SubVector` (matches clj; keeps the trace one hop).
+
+The "~18-file fan-out" is a **FLOOR**: the IPersistentVector surface is wider
+than ArraySeq's ISeq. The landing must enumerate the exact runtime protocol
+subset (rubric `20260817105712`). A SubVector never arises from a reader `[…]`
+literal — only at runtime from `(subvec …)` — so reader/analyzer/serialize
+literal-paths are out of the surface.
+
+## Consequences
+
+`subvec` O(n)→O(1) (view); `pop`/`peek`/`nth`/`count`/`seq` O(1); `conj`/`assoc`
+O(log32 n) via a parent assoc (clj parity). Unallocated slots 9→8. A SubVector
+pins its whole parent alive (clj-identical liveness). `(class (subvec …))` →
+`"APersistentVector$SubVector"` (a new AD-067, ADR-0059/AD-003 simple-class-name
+family). Enables the chunked vector-seq follow-up (memory
+`20260814102851-4cd92d07`). Review surface = the additive arm fan-out (isolated,
+OCP); GC parent-rooting is the single high-risk seam, closed by the registered
+trace + fabrication brackets + a `CLJW_GC_TORTURE` e2e.
+
+## Affected files
+
+`runtime/value/heap_tag.zig`, `runtime/value/value.zig`,
+`runtime/collection/subvec.zig` (new), `runtime/collection/vector.zig`,
+`runtime/collection/array_seq.zig`, `runtime/runtime.zig`, `main.zig`,
+`runtime/java/util/concurrent/LinkedBlockingQueue.zig`,
+`runtime/interface_membership.zig`, `runtime/equal.zig`, `runtime/print.zig`,
+`runtime/compare.zig`, `runtime/class_name.zig`, `runtime/meta.zig`,
+`runtime/metadata.zig`, `runtime/collection/lookup.zig`,
+`eval/backend/tree_walk.zig`, `eval/backend/clojure_lang_method.zig`,
+`lang/primitive/collection.zig`, `lang/primitive/sequence.zig`,
+`lang/primitive/core.zig`, `lang/clj/clojure/core.clj`; docs `.dev/debt.yaml`
+(D-044 discharge), `.dev/gc_rooting.md`, `.dev/accepted_divergences.yaml`
+(AD-067), `.dev/bench/README.md`; tests `test/e2e/phase14_subvec.sh`,
+`test/diff/clj_corpus/subvec_substring_bounds.txt`.
+
+## Alternatives considered
+
+Verbatim from the mandatory Devil's-advocate fork (fresh context, F-004-briefed,
+2026-08-17):
+
+**Leading finding — no option violates F-004.** All three shapes stay within the
+64-slot budget. (a) spends one of the 9 producerless `unallocated` slots
+(`box`=59/D11, boxable, zero production references — confirmed by
+`assertCensusCloses`). (b) spends zero slots (reuses `Vector._pad[6]`). The
+wildcard still builds the `.subvec` repr for the views it creates, so it spends
+the same one slot as (a). No F-004 block; the choice is a cleanliness/correctness
+call, where F-002 governs. The design space for a first-class O(1) vector *view*
+inside F-004 is essentially binary: **mint a distinct tag (a)** or **overload the
+existing `.vector` representation (b)**; any wildcard is a scheduling/threshold
+wrapper on top of (a), not a fourth representation.
+
+**Alt 1 — (a) CHOSEN: new `.subvec` slot (finished-form-clean).** Better than
+(b): additive/OCP, fails LOUD not silent (a `.subvec` value never satisfies
+`decodePtr(*const Vector)`, so a missed site trips an assert/new-arm, never a
+silent-wrong read); zero existing consumers change semantics (pure-Vector fast
+paths untouched — LSP); GC seam is the pre-trodden ArraySeq template (single
+`gc.alloc`, caller-rooted parent → no bracket for `make`; `isGcManaged` via the
+default else-arm; one §F trace row). Costs: spends 1 of 9 slots (justified — a
+vector view is genuinely a distinct first-class type: `vector?`=true, must answer
+nth/assoc/pop/peek/invoke/rseq, an ISeq cannot stand in); the LSP surface is
+WIDER than O-058's and "~18 files" is a floor; the three correctness pitfalls
+above (independent `nth` bounds, empty→real-empty-Vector, nested-collapse-onto-
+original) must be got right.
+
+**Alt 2 — (b) smallest-diff: `start:u32` in `Vector._pad[6]` (REJECTED).**
+Better: zero slots, less struct churn. Worse (decisive): it changes the MEANING
+of the incumbent `.vector` representation — every site that reads
+`root`/`tail`/`count` directly becomes obligated to honour `start`, and any that
+forgets passes every type check and returns wrong data SILENTLY (the F-011 class
+F-004/F-002 most want to avoid; (a)'s missed site is a build error, (b)'s is a
+corrupt read that ships). The review surface is the entire vector subsystem,
+unbounded and permanent (anti-OCP), plus a serialize/`_pad`-zeroing hazard.
+Trading one slot for a diffuse silent-failure mutation of the most-depended-on
+collection is the more expensive price under F-002. Reject.
+
+**Alt 3 — WILDCARD: small-slice-copies + large-slice-views threshold (REJECTED).**
+Better: shrinks the hot LSP surface in practice (common tiny slices stay pure
+`.vector`); bounds worst-case parent-liveness retention. Worse: does NOT avoid
+the slot or the repr (large slices still build SubVector → (a)+a branch, strictly
+more code); introduces an OBSERVABLE behaviour cliff clj lacks (class/instance?/
+identity/liveness/perf flip at a magic N → a fresh F-011 divergence to accept or
+chase); the materialize-on-mutation sub-variant re-introduces an O(n) first-
+mutation cost cliff; a tuning threshold baked into a *representation* is the
+"works-for-the-benchmark" knob F-002 rejects. Reject.
+
+**Recommendation (non-binding; (a) stands within F-004).** Proceed with (a) —
+the finished-form-clean pole of a binary design space; it converts (b)'s
+silent-corruption into loud build/test failures (OCP+LSP) and rides the O-058
+GC/trace template. The one binding landing condition: an explicit enumeration of
+the IPersistentVector protocol surface (treat "~18 files" as a floor), with
+particular care on the three DA-surfaced points (independent `nth` bounds;
+empty-subvec → real empty Vector; nested collapse onto the original `*Vector`).
+
+## Revision history
+
+- 2026-08-17: Proposed. Design + DA complete; source implementation (D-044) is
+ drafted (`private`/scratch landing plan) but DEFERRED — the ~18-file fan-out
+ needs a dual-backend smoke, and the shared machine was under a severe memory
+ constraint (concurrent-session JVM + zig builds, swap exhausted). Flips to
+ Accepted in the commit that lands the implementation.
From 4b3da3cfdff730a52d93305d12af1546bd9946a8 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 13:03:11 -0300
Subject: [PATCH 070/466] test(numeric): classify the +/-Inf-dividend
quot/rem/mod divergence under AD-018
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 1: closes an unclassified floating DIFF (accepted_divergences
discipline). Broaden AD-018 from NaN-only to the whole non-finite family: clj
throws NumberFormatException whenever the QUOTIENT is non-finite (any NaN operand
OR a +/-Inf dividend), while cljw's single-f64 tower (F-005) returns the
non-finite result. Verified 2026-08-17 against the clj oracle: (quot ##Inf 3)
=> ##Inf, (rem ##Inf 3) => ##NaN, (mod ##Inf 3) => ##NaN, (quot ##-Inf 3)
=> ##-Inf all throw in clj; (mod 3 ##Inf) => ##NaN AGREES (Inf divisor, finite
quotient — not a divergence). Adds four +/-Inf pin rows.
---
.dev/accepted_divergences.yaml | 10 +++++++---
test/e2e/phase14_quot_rem_mod_tower.sh | 6 ++++++
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/.dev/accepted_divergences.yaml b/.dev/accepted_divergences.yaml
index c3f4372f4..96b0bd7f6 100644
--- a/.dev/accepted_divergences.yaml
+++ b/.dev/accepted_divergences.yaml
@@ -241,8 +241,8 @@ accepted:
derives_from: "F-004 (NaN-box uniform 8-byte Value, no primitive field storage) + F-005 (no primitive specialization) + ADR-0104"
pin: "test/e2e/phase14_deftype_mutable.sh — long_hint_ignored_accepts_non_long (a `^long` mutable field accepts a String set)"
- id: "AD-018"
- summary: "quot/rem/mod with a NaN operand yield NaN in cljw; JVM Clojure THROWS NumberFormatException (its double remainder routes a NaN quotient through new BigDecimal(NaN))"
- example: '(mod ##NaN 3) => cljw ##NaN / clj THROW NumberFormatException "Infinite or NaN"; likewise (rem ##NaN 3), (quot ##NaN 3), (mod 3 ##NaN)'
+ summary: "quot/rem/mod whose QUOTIENT is non-finite — any NaN operand, or a +/-Inf dividend — yield a non-finite result (NaN or +/-Inf) in cljw; JVM Clojure THROWS NumberFormatException (a non-finite quotient routes through new BigDecimal). An Inf DIVISOR over a finite dividend has a finite (0) quotient and AGREES (both NaN)."
+ example: '(quot ##Inf 3) => cljw ##Inf / clj THROW; (rem ##Inf 3) => cljw ##NaN / clj THROW; (quot ##-Inf 3) => cljw ##-Inf / clj THROW; (mod ##NaN 3) => cljw ##NaN / clj THROW "Infinite or NaN"; (mod 3 ##Inf) => ##NaN in BOTH (Inf divisor, finite quotient — not a divergence)'
why: |-
F-005's single-f64 numeric tower runs quot/rem/mod on a float operand
through one clean IEEE path (@trunc of the f64 quotient), so a NaN in
@@ -254,8 +254,12 @@ accepted:
the same "cljw's cleaner numeric surface vs clj's throw" shape as
AD-008. quot/rem already returned NaN; fixing CLJW-MOD-PANIC (numSign's
float arm hit std.math.order's unreachable on NaN) made mod join them.
+ The rule is the QUOTIENT's finiteness: a +/-Inf dividend also yields a
+ non-finite quotient so clj throws there too, while an Inf divisor over a
+ finite dividend gives a 0 quotient and both runtimes return NaN (verified
+ 2026-08-17 against the clj oracle).
derives_from: "F-005 (numeric tower: single internal f64, one clean float quot/rem/mod path) + AD-008 (cljw's cleaner numeric surface where clj throws)"
- pin: "test/e2e/phase14_quot_rem_mod_tower.sh — quot_nan / rem_nan / mod_nan_d / mod_nan_v lock cljw's ##NaN (the divergent value; clj throws)"
+ pin: "test/e2e/phase14_quot_rem_mod_tower.sh — quot_nan/rem_nan/mod_nan_d/mod_nan_v (NaN family) + quot_inf/rem_inf/mod_inf/quot_ninf (+/-Inf-dividend family) lock cljw's non-finite result (clj throws)"
- id: "AD-019"
summary: "Java arrays are type-erased: element type hints (`^\"[B\"`, `^ints`, `^bytes`) are advisory only, and a typed-array constructor / `aset` does not coerce or type-check non-byte/short/char elements."
example: '(let [^"[B" b (byte-array 1)] (aset b 0 65) (aget b 0)) => 65 (a non-byte stored into a byte-typed array, no error); (class (object-array 0)) => cljw "array" / clj "[Ljava.lang.Object;"'
diff --git a/test/e2e/phase14_quot_rem_mod_tower.sh b/test/e2e/phase14_quot_rem_mod_tower.sh
index 64da85296..68002fb22 100755
--- a/test/e2e/phase14_quot_rem_mod_tower.sh
+++ b/test/e2e/phase14_quot_rem_mod_tower.sh
@@ -49,6 +49,12 @@ assert_eq 'quot_nan' "$("$BIN" -e '(quot ##NaN 3)')" '##NaN'
assert_eq 'rem_nan' "$("$BIN" -e '(rem ##NaN 3)')" '##NaN'
assert_eq 'mod_nan_d' "$("$BIN" -e '(mod ##NaN 3)')" '##NaN'
assert_eq 'mod_nan_v' "$("$BIN" -e '(mod 3 ##NaN)')" '##NaN'
+# +/-Inf DIVIDEND: clj throws (non-finite quotient); cljw returns the non-finite
+# result. An Inf DIVISOR over a finite dividend agrees (0 quotient -> NaN both).
+assert_eq 'quot_inf' "$("$BIN" -e '(quot ##Inf 3)')" '##Inf'
+assert_eq 'rem_inf' "$("$BIN" -e '(rem ##Inf 3)')" '##NaN'
+assert_eq 'mod_inf' "$("$BIN" -e '(mod ##Inf 3)')" '##NaN'
+assert_eq 'quot_ninf' "$("$BIN" -e '(quot ##-Inf 3)')" '##-Inf'
# --- divide-by-zero throws for every category (incl. float — unlike `/`) ---
assert_has 'quot_zero' "$("$BIN" -e '(quot 3N 0)' 2>&1)" 'Divide by zero'
From a0a2d312a91b7be5a2962555d502052b085fef68 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 13:49:19 -0300
Subject: [PATCH 071/466] =?UTF-8?q?fix(empty=3F):=20(not=20(seq=20coll))?=
=?UTF-8?q?=20=E2=80=94=20no=20infinite-lazy-seq=20hang,=20O(1),=20clj=20p?=
=?UTF-8?q?arity?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 0: clj-parity correction toward finished form (F-011). empty? was
(= 0 (count coll)), which fully REALIZES an infinite lazy seq — (empty? (range))
hung the compliance suite for 30s — and is silently O(n) on every finite seq. clj
defines it (not (seq coll)); seq is O(1) on a lazy seq (returns the realized head
without walking). Direct probe: (empty? (range)) now returns false instantly;
empty_seq corpus 73/73 incl three new infinite-lazy guards ((range) / (iterate inc
0) / (repeat 1) => false, deliberately un-take'd so a count-based regression re-
hangs under the corpus timeout). Finite empty? behaviour is unchanged.
---
src/lang/clj/clojure/core.clj | 2 +-
test/diff/clj_corpus/empty_seq.txt | 6 ++++++
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/lang/clj/clojure/core.clj b/src/lang/clj/clojure/core.clj
index 91238b36d..5ec9cf125 100644
--- a/src/lang/clj/clojure/core.clj
+++ b/src/lang/clj/clojure/core.clj
@@ -1226,7 +1226,7 @@
;; `(empty? coll)` — true when coll has no items (nil counts as empty).
(def empty?
- (fn* [coll] (= 0 (count coll))))
+ (fn* [coll] (not (seq coll))))
;; `(fnil f x)` / `(fnil f x y)` / `(fnil f x y z)` — wrap f so its first
;; 1/2/3 args are replaced by the defaults when nil; trailing args pass
diff --git a/test/diff/clj_corpus/empty_seq.txt b/test/diff/clj_corpus/empty_seq.txt
index 192b0265d..1a1f1fc31 100644
--- a/test/diff/clj_corpus/empty_seq.txt
+++ b/test/diff/clj_corpus/empty_seq.txt
@@ -128,6 +128,12 @@
;;=> 0
(empty? ())
;;=> true
+(empty? (range))
+;;=> false
+(empty? (iterate inc 0))
+;;=> false
+(empty? (repeat 1))
+;;=> false
(pr-str ())
;;=> "()"
(str ())
From ea0ebe108434ebc3fd3ad5a25831e4e0023f69cd Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 13:49:21 -0300
Subject: [PATCH 072/466] =?UTF-8?q?test(compare):=20Layer-7=20property=20?=
=?UTF-8?q?=E2=80=94=20valueCompare=20is=20a=20NaN-safe=20total=20order?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 0: test-only. Pins the SORT-NAN defect class — valueCompare (the
native sort / sorted-coll path) must be a total order returning .eq for any NaN
operand, not hit std.math.order's unreachable. Laws: reflexivity, antisymmetry,
NaN->eq over ints / floats / BigInt / NaN / +/-Inf. The compare primitive's NaN
behaviour is separately corpus-pinned (compare_cross_numeric.txt), so both impls
are locked and cannot silently diverge. Runtime-only imports (no cross-zone).
---
src/main.zig | 1 +
src/testing/prop_compare.zig | 146 +++++++++++++++++++++++++++++++++++
2 files changed, 147 insertions(+)
create mode 100644 src/testing/prop_compare.zig
diff --git a/src/main.zig b/src/main.zig
index 7453ba264..ab234fccc 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -69,6 +69,7 @@ test {
_ = @import("testing/prop_collections.zig");
_ = @import("testing/prop_print.zig");
_ = @import("testing/prop_equal.zig");
+ _ = @import("testing/prop_compare.zig");
_ = @import("app/cli.zig");
_ = @import("app/repl/line_editor.zig");
diff --git a/src/testing/prop_compare.zig b/src/testing/prop_compare.zig
new file mode 100644
index 000000000..6d7b02907
--- /dev/null
+++ b/src/testing/prop_compare.zig
@@ -0,0 +1,146 @@
+// SPDX-License-Identifier: EPL-2.0
+//! Layer 7 (Property) — valueCompare is a NaN-safe total order. ADR-0186.
+//!
+//! Pins the SORT-NAN defect class: the native ordering path
+//! (runtime/compare.zig valueCompare, reached by sort / sort-by / sorted-set /
+//! sorted-map) must be a total order over the numeric tower, returning .eq for
+//! any NaN operand (the clojure.core/compare rule) rather than hitting
+//! std.math.order's `unreachable`. The compare PRIMITIVE
+//! (lang/primitive/math.zig) is separately pinned by
+//! test/diff/clj_corpus/compare_cross_numeric.txt, so both implementations are
+//! locked and cannot silently diverge. Laws checked: reflexivity, antisymmetry,
+//! and NaN -> .eq, over a pool of ints (+/-), floats, BigInts, and the
+//! non-finite floats NaN / +Inf / -Inf.
+
+const std = @import("std");
+const prop = @import("prop.zig");
+const build_options = @import("build_options");
+
+const Value = @import("../runtime/value/value.zig").Value;
+const Runtime = @import("../runtime/runtime.zig").Runtime;
+const compare = @import("../runtime/compare.zig");
+const big_int = @import("../runtime/numeric/big_int.zig");
+const SourceLocation = @import("../runtime/error/info.zig").SourceLocation;
+
+const testing = std.testing;
+
+/// Gate defaults, overridable for a sweep: `zig build test -Dprop-seed=0x.. -Dprop-iters=..`.
+fn config() prop.Config {
+ return .{ .seed = build_options.prop_seed, .iters = build_options.prop_iters };
+}
+
+const kinds = 7;
+const payloads = 4;
+
+/// Map a generated i64 spec to a numeric Value. Kinds 4/5/6 are the non-finite
+/// floats (NaN / +Inf / -Inf) whose ordering the SORT-NAN fix made total.
+fn valueFromSpec(rt: *Runtime, spec: i64) !Value {
+ const kind = @mod(spec, kinds);
+ const p: i64 = @mod(@divFloor(spec, kinds), payloads);
+ return switch (kind) {
+ 0 => Value.initInteger(p),
+ 1 => Value.initInteger(-p),
+ 2 => Value.initFloat(@floatFromInt(p)),
+ 3 => try big_int.allocFromI64(rt, p, .bigint),
+ 4 => Value.initFloat(std.math.nan(f64)),
+ 5 => Value.initFloat(std.math.inf(f64)),
+ 6 => Value.initFloat(-std.math.inf(f64)),
+ else => unreachable,
+ };
+}
+
+fn buildPool(rt: *Runtime, xs: []i64, out: *std.ArrayList(Value)) !void {
+ for (xs) |spec| try out.append(testing.allocator, try valueFromSpec(rt, spec));
+}
+
+fn sign(o: std.math.Order) i8 {
+ return switch (o) {
+ .lt => -1,
+ .eq => 0,
+ .gt => 1,
+ };
+}
+
+/// A fresh runtime per property evaluation (valueCompare needs no Env).
+const Fixture = struct {
+ fn run(comptime body: fn (*Runtime, []i64) anyerror!void, xs: []i64) anyerror!void {
+ var th = std.Io.Threaded.init(testing.allocator, .{});
+ defer th.deinit();
+ var rt = Runtime.init(th.io(), testing.allocator);
+ defer rt.deinit();
+ try body(&rt, xs);
+ }
+};
+
+const gen = prop.IntSlice{ .distinct = kinds * payloads, .max_len = 40 };
+
+// --- the laws ---
+
+test "property: valueCompare is reflexive over the numeric tower" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ const loc = SourceLocation{};
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ if (sign(try compare.valueCompare(rt, a, a, loc)) != 0) return error.NotReflexive;
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: valueCompare is antisymmetric (sign(a,b) == -sign(b,a))" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ const loc = SourceLocation{};
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ for (pool.items) |b| {
+ const ab = sign(try compare.valueCompare(rt, a, b, loc));
+ const ba = sign(try compare.valueCompare(rt, b, a, loc));
+ if (ab != -ba) return error.NotAntisymmetric;
+ }
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
+
+test "property: valueCompare returns .eq for any NaN operand (no unreachable)" {
+ const Ctx = struct {};
+ const body = struct {
+ fn f(rt: *Runtime, xs: []i64) anyerror!void {
+ const loc = SourceLocation{};
+ const nan = Value.initFloat(std.math.nan(f64));
+ var pool: std.ArrayList(Value) = .empty;
+ defer pool.deinit(testing.allocator);
+ try buildPool(rt, xs, &pool);
+ for (pool.items) |a| {
+ if (sign(try compare.valueCompare(rt, nan, a, loc)) != 0) return error.NanNotEq;
+ if (sign(try compare.valueCompare(rt, a, nan, loc)) != 0) return error.NanNotEq;
+ }
+ }
+ }.f;
+ const wrapped = struct {
+ fn f(_: Ctx, xs: []i64) anyerror!void {
+ try Fixture.run(body, xs);
+ }
+ }.f;
+ try prop.forAll([]i64, testing.allocator, config(), gen, Ctx{}, wrapped);
+}
From 291a722be8f9e9fba08c63411a67a8e341b2ccfb Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Mon, 17 Aug 2026 13:54:34 -0300
Subject: [PATCH 073/466] =?UTF-8?q?test(golden):=20pin=20print.zig=20reali?=
=?UTF-8?q?zation=20survivors=20=E2=80=94=20deftype=20ISeq=20w/=20lazy=20t?=
=?UTF-8?q?ail?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Smell-audited: 0: test-only. Kills two mutation survivors in print.zig's
realization stratum — the *print-length* cutoff and the instance-seq lazy-tail
drain loop — that no existing golden reached: bare lazy seqs never enter the
realizeInstanceSeq branch, only a Sequential deftype ISeq whose -next returns a
lazy tail does. Golden case printer_iseq_lazy_tail verified 1 passed; all 6
outputs byte-match clj 1.12 (auto-discovered by test/golden/run.sh; the
unit+golden mutation oracle renders it, so the survivors now score killed).
---
test/golden/cases/printer_iseq_lazy_tail.clj | 31 +++++++++++++++++++
.../cases/printer_iseq_lazy_tail.expected | 10 ++++++
2 files changed, 41 insertions(+)
create mode 100644 test/golden/cases/printer_iseq_lazy_tail.clj
create mode 100644 test/golden/cases/printer_iseq_lazy_tail.expected
diff --git a/test/golden/cases/printer_iseq_lazy_tail.clj b/test/golden/cases/printer_iseq_lazy_tail.clj
new file mode 100644
index 000000000..9ffd1c469
--- /dev/null
+++ b/test/golden/cases/printer_iseq_lazy_tail.clj
@@ -0,0 +1,31 @@
+;; A Sequential deftype ISeq whose -next hands back a LAZY tail, printed several
+;; ways. This is the mutation-killing pin for print.zig's two realization-stratum
+;; survivors: the *print-length* cutoff and the instance-seq lazy-tail drain loop.
+;; A custom ISeq instance forces the printer through realizeInstanceSeq, whose
+;; -next returns a non-instance lazy seq that the walk must then drain (the tail
+;; loop) while honouring *print-length* (the cutoff). Bare lazy seqs never reach
+;; that instance branch, so only a deftype ISeq exercises it.
+;;
+;; Values pinned against JVM Clojure 1.12.
+(deftype LT [h t]
+ clojure.lang.Sequential
+ clojure.lang.Seqable
+ (seq [this] this)
+ clojure.lang.ISeq
+ (first [this] h)
+ (next [this] t)
+ (more [this] (if (nil? t) '() t))
+ (cons [this x] (cons x this)))
+
+;; instance walk whose tail is a lazy seq → drains the tail loop
+(prn (LT. 1 (map inc [1 2 3])))
+;; a lazy head element + a filtered lazy tail, both realized in one instance walk
+(prn (LT. (map inc [9]) (filter odd? (range 6))))
+;; *print-length* cutoff on the instance tail
+(binding [*print-length* 2] (prn (LT. 0 (map inc (range 10)))))
+;; INFINITE lazy tail that only terminates because the cutoff is honoured
+(binding [*print-length* 3] (prn (LT. 0 (map inc (iterate inc 0)))))
+;; the same cutoff on a bare lazy seq, crossing the truncation boundary
+(binding [*print-length* 3] (prn (map inc (iterate inc 0))))
+;; -next nil skips the tail branch
+(prn (LT. :only nil))
diff --git a/test/golden/cases/printer_iseq_lazy_tail.expected b/test/golden/cases/printer_iseq_lazy_tail.expected
new file mode 100644
index 000000000..fb6a60020
--- /dev/null
+++ b/test/golden/cases/printer_iseq_lazy_tail.expected
@@ -0,0 +1,10 @@
+--- exit ---
+0
+--- stdout ---
+(1 2 3 4)
+((10) 1 3 5)
+(0 1 ...)
+(0 1 2 ...)
+(1 2 3 ...)
+(:only)
+--- stderr ---
From 79299406b87650ecb456c087740ad5f23d6bc2f1 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Tue, 18 Aug 2026 00:27:01 -0300
Subject: [PATCH 074/466] fix(macros): emit clojure.core-qualified symbols from
macro expansions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Macro expansions built their heads with bare `sym("seq")` /
`makeCall(…, "first", …)`, so a namespace that shadows a core name captured the
expansion. Not a load error — a silently different program:
(ns hyg (:refer-clojure :exclude [seq]))
(defn seq [x] :shadowed)
(for [i (range 3)] (* i i)) ; was: *: expected number, got nil
clojure.test.check.rose-tree is exactly that shape
(:refer-clojure :exclude [filter remove seq]), so shrinking was among the
casualties.
Adds coreSym/coreCall and routes the 71 emission sites that name a clojure.core
Var through them. Special forms (if do quote recur throw def let* fn* loop*
letfn* import* .) stay bare — the compiler resolves them, not a Var. caseTest
already emitted a qualified `clojure.core/or` for this reason; this generalises
that one case to every core name a macro emits.
Unblocks clojure.test.check.rose-tree (D-298 rung).
---
src/lang/macro_transforms.zig | 160 +++++++++++++++++++---------------
1 file changed, 90 insertions(+), 70 deletions(-)
diff --git a/src/lang/macro_transforms.zig b/src/lang/macro_transforms.zig
index e74744809..43a9421db 100644
--- a/src/lang/macro_transforms.zig
+++ b/src/lang/macro_transforms.zig
@@ -228,14 +228,14 @@ fn expandDefonce(
declform[0] = sym("def", loc);
declform[1] = name;
var bform = try arena.alloc(Form, 2); // (bound? (def name))
- bform[0] = sym("bound?", loc);
+ bform[0] = coreSym("bound?", loc);
bform[1] = try list(arena, declform, loc);
var dform = try arena.alloc(Form, 3); // (def name expr)
dform[0] = sym("def", loc);
dform[1] = name;
dform[2] = args[1];
var wform = try arena.alloc(Form, 3);
- wform[0] = sym("when-not", loc);
+ wform[0] = coreSym("when-not", loc);
wform[1] = try list(arena, bform, loc);
wform[2] = try list(arena, dform, loc);
return list(arena, wform, loc);
@@ -379,7 +379,7 @@ fn sequentialDestructure(
if (has_rest) {
cur_seq = sym(try rt.gensym(arena, "seq"), loc);
try out.append(arena, cur_seq);
- try out.append(arena, try makeCall(arena, "seq", &.{g}, loc));
+ try out.append(arena, try coreCall(arena, "seq", &.{g}, loc));
}
var idx: i64 = 0;
@@ -405,14 +405,14 @@ fn sequentialDestructure(
if (has_rest) {
const gfirst = sym(try rt.gensym(arena, "first"), loc);
try out.append(arena, gfirst);
- try out.append(arena, try makeCall(arena, "first", &.{cur_seq}, loc));
+ try out.append(arena, try coreCall(arena, "first", &.{cur_seq}, loc));
const next_seq = sym(try rt.gensym(arena, "seq"), loc);
try out.append(arena, next_seq);
- try out.append(arena, try makeCall(arena, "next", &.{cur_seq}, loc));
+ try out.append(arena, try coreCall(arena, "next", &.{cur_seq}, loc));
cur_seq = next_seq;
try destructureInto(out, arena, rt, e, gfirst, loc);
} else {
- const nth_val = try makeCall(arena, "nth", &.{ g, intForm(idx, loc), nilForm(loc) }, loc);
+ const nth_val = try coreCall(arena, "nth", &.{ g, intForm(idx, loc), nilForm(loc) }, loc);
try destructureInto(out, arena, rt, e, nth_val, loc);
}
idx += 1;
@@ -448,14 +448,14 @@ fn associativeDestructure(
// (if (seq? g) (if (next g) (apply hash-map g) (if (seq g) (first g) {})) g)
try out.append(arena, g);
try out.append(arena, try makeCall(arena, "if", &.{
- try makeCall(arena, "seq?", &.{g}, loc),
+ try coreCall(arena, "seq?", &.{g}, loc),
try makeCall(arena, "if", &.{
- try makeCall(arena, "next", &.{g}, loc),
- try makeCall(arena, "apply", &.{ sym("hash-map", loc), g }, loc),
+ try coreCall(arena, "next", &.{g}, loc),
+ try coreCall(arena, "apply", &.{ coreSym("hash-map", loc), g }, loc),
try makeCall(arena, "if", &.{
- try makeCall(arena, "seq", &.{g}, loc),
- try makeCall(arena, "first", &.{g}, loc),
- try makeCall(arena, "hash-map", &.{}, loc),
+ try coreCall(arena, "seq", &.{g}, loc),
+ try coreCall(arena, "first", &.{g}, loc),
+ try coreCall(arena, "hash-map", &.{}, loc),
}, loc),
}, loc),
g,
@@ -562,8 +562,8 @@ fn makeGet(
default: ?Form,
loc: SourceLocation,
) macro_dispatch.ExpandError!Form {
- if (default) |d| return makeCall(arena, "get", &.{ g, key, d }, loc);
- return makeCall(arena, "get", &.{ g, key }, loc);
+ if (default) |d| return coreCall(arena, "get", &.{ g, key, d }, loc);
+ return coreCall(arena, "get", &.{ g, key }, loc);
}
/// Look up a binding symbol's `:or` default by name. `null` if absent.
@@ -593,6 +593,26 @@ fn makeCall(
return list(arena, items, loc);
}
+/// Build a `clojure.core/` symbol Form.
+fn coreSym(name: []const u8, loc: SourceLocation) Form {
+ return .{ .data = .{ .symbol = .{ .ns = "clojure.core", .name = name } }, .location = loc };
+}
+
+/// Build a call Form `(clojure.core/fn_name args...)` — the qualified head a
+/// macro expansion must emit for any clojure.core Var it names, so a namespace
+/// that shadows the name cannot capture the expansion (macro hygiene).
+fn coreCall(
+ arena: std.mem.Allocator,
+ fn_name: []const u8,
+ call_args: []const Form,
+ loc: SourceLocation,
+) macro_dispatch.ExpandError!Form {
+ var items = try arena.alloc(Form, 1 + call_args.len);
+ items[0] = coreSym(fn_name, loc);
+ @memcpy(items[1..], call_args);
+ return list(arena, items, loc);
+}
+
// --- loop — `loop*` rename + destructuring ---
//
// `(loop [bindings] body...)` → `(loop* [slots] (let [patterns] body))`.
@@ -651,7 +671,7 @@ fn expandLoop(
}
var let_items = try arena.alloc(Form, 2 + body.len);
- let_items[0] = sym("let", loc);
+ let_items[0] = coreSym("let", loc);
let_items[1] = try vec(arena, lets.items, loc);
@memcpy(let_items[2..], body);
const wrapped = try list(arena, let_items, loc);
@@ -849,7 +869,7 @@ fn someThread(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc: S
for (forms, 0..) |form, i| {
const threaded = try threadStep(arena, g, form, dir);
const nilq_items = try arena.alloc(Form, 2);
- nilq_items[0] = sym("nil?", loc);
+ nilq_items[0] = coreSym("nil?", loc);
nilq_items[1] = g;
const if_items = try arena.alloc(Form, 4);
if_items[0] = sym("if", loc);
@@ -902,14 +922,14 @@ fn expandIfSome(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc:
inner_binding[0] = name_form;
inner_binding[1] = sym(gname, loc);
const inner_let_items = try arena.alloc(Form, 3);
- inner_let_items[0] = sym("let", loc);
+ inner_let_items[0] = coreSym("let", loc);
inner_let_items[1] = .{ .data = .{ .vector = inner_binding }, .location = loc };
inner_let_items[2] = then_form;
const inner_let = try list(arena, inner_let_items, loc);
// (nil? g)
const nilq_items = try arena.alloc(Form, 2);
- nilq_items[0] = sym("nil?", loc);
+ nilq_items[0] = coreSym("nil?", loc);
nilq_items[1] = sym(gname, loc);
// (if (nil? g) else (let* [name g] then))
@@ -934,7 +954,7 @@ fn expandWhenSome(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, lo
return error_catalog.raise(.when_some_form_incomplete, loc, .{});
const body_form = try foldBody(arena, args[1..], loc);
const items = try arena.alloc(Form, 4);
- items[0] = sym("if-some", loc);
+ items[0] = coreSym("if-some", loc);
items[1] = args[0];
items[2] = body_form;
items[3] = nilForm(loc);
@@ -979,12 +999,12 @@ fn expandDotimes(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc
const body = args[1..];
const ng = sym(try rt.gensym(arena, "dotimes_n"), loc);
- const recur_form = try makeCall(arena, "recur", &.{try makeCall(arena, "inc", &.{i_sym}, loc)}, loc);
- const lt_form = try makeCall(arena, "<", &.{ i_sym, ng }, loc);
+ const recur_form = try makeCall(arena, "recur", &.{try coreCall(arena, "inc", &.{i_sym}, loc)}, loc);
+ const lt_form = try coreCall(arena, "<", &.{ i_sym, ng }, loc);
// (when (< i ng) body… (recur (inc i)))
const when_items = try arena.alloc(Form, 3 + body.len);
- when_items[0] = sym("when", loc);
+ when_items[0] = coreSym("when", loc);
when_items[1] = lt_form;
@memcpy(when_items[2 .. 2 + body.len], body);
when_items[2 + body.len] = recur_form;
@@ -994,7 +1014,7 @@ fn expandDotimes(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc
loop_binding[0] = i_sym;
loop_binding[1] = intForm(0, loc);
const loop_items = try arena.alloc(Form, 3);
- loop_items[0] = sym("loop", loc);
+ loop_items[0] = coreSym("loop", loc);
loop_items[1] = .{ .data = .{ .vector = loop_binding }, .location = loc };
loop_items[2] = try list(arena, when_items, loc);
@@ -1017,14 +1037,14 @@ fn expandWhile(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc:
// (when test body… (recur))
const when_items = try arena.alloc(Form, 3 + body.len);
- when_items[0] = sym("when", loc);
+ when_items[0] = coreSym("when", loc);
when_items[1] = test_form;
@memcpy(when_items[2 .. 2 + body.len], body);
when_items[2 + body.len] = recur_form;
// (loop [] (when …))
const loop_items = try arena.alloc(Form, 3);
- loop_items[0] = sym("loop", loc);
+ loop_items[0] = coreSym("loop", loc);
loop_items[1] = .{ .data = .{ .vector = try arena.alloc(Form, 0) }, .location = loc };
loop_items[2] = try list(arena, when_items, loc);
return list(arena, loop_items, loc);
@@ -1049,9 +1069,9 @@ fn expandWhenFirst(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, l
// pattern lowers (plain symbol reduces to let*, no regression).
const inner_binding = try arena.alloc(Form, 2);
inner_binding[0] = x_sym;
- inner_binding[1] = try makeCall(arena, "first", &.{g}, loc);
+ inner_binding[1] = try coreCall(arena, "first", &.{g}, loc);
const inner_let_items = try arena.alloc(Form, 3);
- inner_let_items[0] = sym("let", loc);
+ inner_let_items[0] = coreSym("let", loc);
inner_let_items[1] = .{ .data = .{ .vector = inner_binding }, .location = loc };
inner_let_items[2] = try foldBody(arena, args[1..], loc);
const inner_let = try list(arena, inner_let_items, loc);
@@ -1059,9 +1079,9 @@ fn expandWhenFirst(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, l
// (when-let [g (seq coll)] inner_let)
const wl_binding = try arena.alloc(Form, 2);
wl_binding[0] = g;
- wl_binding[1] = try makeCall(arena, "seq", &.{coll_expr}, loc);
+ wl_binding[1] = try coreCall(arena, "seq", &.{coll_expr}, loc);
const wl_items = try arena.alloc(Form, 3);
- wl_items[0] = sym("when-let", loc);
+ wl_items[0] = coreSym("when-let", loc);
wl_items[1] = .{ .data = .{ .vector = wl_binding }, .location = loc };
wl_items[2] = inner_let;
return list(arena, wl_items, loc);
@@ -1082,7 +1102,7 @@ const DoseqStep = struct { needrec: bool, form: Form };
/// `(let )` — used so doseq binds destructure.
fn makeLet(arena: std.mem.Allocator, binding: Form, body: Form, loc: SourceLocation) macro_dispatch.ExpandError!Form {
const items = try arena.alloc(Form, 3);
- items[0] = sym("let", loc);
+ items[0] = coreSym("let", loc);
items[1] = binding;
items[2] = body;
return list(arena, items, loc);
@@ -1118,7 +1138,7 @@ fn doseqStep(
// (when v inner.form [recform if inner.needrec])
const append = inner.needrec and recform != null;
const items = try arena.alloc(Form, if (append) 4 else 3);
- items[0] = sym("when", loc);
+ items[0] = coreSym("when", loc);
items[1] = v;
items[2] = inner.form;
if (append) items[3] = recform.?;
@@ -1144,18 +1164,18 @@ fn doseqStep(
// A real `bind coll` pair → build a loop over (seq coll), slow path only.
const gname = sym(try rt.gensym(arena, "doseq_seq"), loc);
- const recform_inner = try makeCall(arena, "recur", &.{try makeCall(arena, "next", &.{gname}, loc)}, loc);
+ const recform_inner = try makeCall(arena, "recur", &.{try coreCall(arena, "next", &.{gname}, loc)}, loc);
const inner = try doseqStep(arena, rt, recform_inner, rest, body, loc);
// (let [bind (first g)] inner.form)
const lb = try arena.alloc(Form, 2);
lb[0] = k;
- lb[1] = try makeCall(arena, "first", &.{gname}, loc);
+ lb[1] = try coreCall(arena, "first", &.{gname}, loc);
const let_form = try makeLet(arena, .{ .data = .{ .vector = lb }, .location = loc }, inner.form, loc);
// (when g [recur (next g) if inner.needrec])
const when_items = try arena.alloc(Form, if (inner.needrec) 4 else 3);
- when_items[0] = sym("when", loc);
+ when_items[0] = coreSym("when", loc);
when_items[1] = gname;
when_items[2] = let_form;
if (inner.needrec) when_items[3] = recform_inner;
@@ -1163,9 +1183,9 @@ fn doseqStep(
// (loop [g (seq coll)] )
const loop_binding = try arena.alloc(Form, 2);
loop_binding[0] = gname;
- loop_binding[1] = try makeCall(arena, "seq", &.{v}, loc);
+ loop_binding[1] = try coreCall(arena, "seq", &.{v}, loc);
const loop_items = try arena.alloc(Form, 3);
- loop_items[0] = sym("loop", loc);
+ loop_items[0] = coreSym("loop", loc);
loop_items[1] = .{ .data = .{ .vector = loop_binding }, .location = loc };
loop_items[2] = try list(arena, when_items, loc);
return .{ .needrec = true, .form = try list(arena, loop_items, loc) };
@@ -1214,7 +1234,7 @@ fn forStep(
) macro_dispatch.ExpandError!Form {
if (exprs.len == 0)
// Innermost: emit the body and lazily continue the enclosing iterator.
- return makeCall(arena, "cons", &.{ body, outer_cont.? }, loc);
+ return coreCall(arena, "cons", &.{ body, outer_cont.? }, loc);
const k = exprs[0];
const v = exprs[1];
@@ -1232,7 +1252,7 @@ fn forStep(
} else if (std.mem.eql(u8, kname, "while")) {
// (when v inner) - false => nil => the lazy-seq terminates.
const items = try arena.alloc(Form, 3);
- items[0] = sym("when", loc);
+ items[0] = coreSym("when", loc);
items[1] = v;
items[2] = inner;
return list(arena, items, loc);
@@ -1251,20 +1271,20 @@ fn forStep(
// A real `bind coll` pair -> a self-recursive lazy iterator over (seq coll).
const giter = sym(try rt.gensym(arena, "for_iter"), loc);
const gxs = sym(try rt.gensym(arena, "for_s"), loc);
- const cont_form = try listOf(arena, &.{ giter, try makeCall(arena, "rest", &.{gxs}, loc) }, loc); // (giter (rest gxs))
- const recform = try makeCall(arena, "recur", &.{try makeCall(arena, "rest", &.{gxs}, loc)}, loc); // (recur (rest gxs))
+ const cont_form = try listOf(arena, &.{ giter, try coreCall(arena, "rest", &.{gxs}, loc) }, loc); // (giter (rest gxs))
+ const recform = try makeCall(arena, "recur", &.{try coreCall(arena, "rest", &.{gxs}, loc)}, loc); // (recur (rest gxs))
const inner = try forStep(arena, rt, cont_form, recform, rest, body, loc);
// (let [bind (first gxs)] inner)
const lb = try arena.alloc(Form, 2);
lb[0] = k;
- lb[1] = try makeCall(arena, "first", &.{gxs}, loc);
+ lb[1] = try coreCall(arena, "first", &.{gxs}, loc);
const let_form = try makeLet(arena, .{ .data = .{ .vector = lb }, .location = loc }, inner, loc);
// (when (seq gxs) )
const when_items = try arena.alloc(Form, 3);
- when_items[0] = sym("when", loc);
- when_items[1] = try makeCall(arena, "seq", &.{gxs}, loc);
+ when_items[0] = coreSym("when", loc);
+ when_items[1] = try coreCall(arena, "seq", &.{gxs}, loc);
when_items[2] = let_form;
const when_form = try list(arena, when_items, loc);
@@ -1284,12 +1304,12 @@ fn forStep(
const spec_items = try arena.alloc(Form, 3);
spec_items[0] = giter;
spec_items[1] = .{ .data = .{ .vector = param_vec }, .location = loc };
- spec_items[2] = try makeCall(arena, "lazy-seq", &.{loop_form}, loc);
+ spec_items[2] = try coreCall(arena, "lazy-seq", &.{loop_form}, loc);
const spec_form = try list(arena, spec_items, loc);
const letfn_bindings = try arena.alloc(Form, 1);
letfn_bindings[0] = spec_form;
const letfn_items = try arena.alloc(Form, 3);
- letfn_items[0] = sym("letfn", loc);
+ letfn_items[0] = coreSym("letfn", loc);
letfn_items[1] = .{ .data = .{ .vector = letfn_bindings }, .location = loc };
letfn_items[2] = try listOf(arena, &.{ giter, v }, loc); // (giter coll)
const iter_expr = try list(arena, letfn_items, loc);
@@ -1302,11 +1322,11 @@ fn forStep(
const gfs = sym(try rt.gensym(arena, "for_fs"), loc);
const fb = try arena.alloc(Form, 2);
fb[0] = gfs;
- fb[1] = try makeCall(arena, "seq", &.{iter_expr}, loc);
+ fb[1] = try coreCall(arena, "seq", &.{iter_expr}, loc);
const if_items = try arena.alloc(Form, 4);
if_items[0] = sym("if", loc);
if_items[1] = gfs;
- if_items[2] = try makeCall(arena, "concat", &.{ gfs, outer_cont.? }, loc);
+ if_items[2] = try coreCall(arena, "concat", &.{ gfs, outer_cont.? }, loc);
if_items[3] = outer_recform.?;
return makeLet(arena, .{ .data = .{ .vector = fb }, .location = loc }, try list(arena, if_items, loc), loc);
}
@@ -1332,7 +1352,7 @@ fn expandFor(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc: So
/// `(= g (quote const))` — value-equality against an unevaluated constant.
fn caseConstEq(arena: std.mem.Allocator, g: Form, const_form: Form, loc: SourceLocation) macro_dispatch.ExpandError!Form {
const quoted = try makeCall(arena, "quote", &.{const_form}, loc);
- return makeCall(arena, "=", &.{ g, quoted }, loc);
+ return coreCall(arena, "=", &.{ g, quoted }, loc);
}
/// Test for one clause's test-constant: a list `(c1 c2 …)` →
@@ -1361,7 +1381,7 @@ fn noMatchThrow(arena: std.mem.Allocator, g: Form, loc: SourceLocation) macro_di
map_items[1] = g;
const map_form: Form = .{ .data = .{ .map = map_items }, .location = loc };
const msg: Form = .{ .data = .{ .string = "No matching clause" }, .location = loc };
- const exinfo = try makeCall(arena, "ex-info", &.{ msg, map_form }, loc);
+ const exinfo = try coreCall(arena, "ex-info", &.{ msg, map_form }, loc);
return makeCall(arena, "throw", &.{exinfo}, loc);
}
@@ -1442,7 +1462,7 @@ fn condpEmit(arena: std.mem.Allocator, rt: *Runtime, gpred: Form, gexpr: Form, a
binding[0] = p;
binding[1] = pred_call;
const ifl = try arena.alloc(Form, 4);
- ifl[0] = sym("if-let", loc);
+ ifl[0] = coreSym("if-let", loc);
ifl[1] = .{ .data = .{ .vector = binding }, .location = loc };
ifl[2] = fn_call;
ifl[3] = more;
@@ -1512,11 +1532,11 @@ fn expandAssert(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc:
const prefix: Form = .{ .data = .{ .string = "Assert failed: " }, .location = loc };
// (pr-str (quote )) — the asserted form, printed clj-style.
const quoted = try makeCall(arena, "quote", &.{expr}, loc);
- const pr_form = try makeCall(arena, "pr-str", &.{quoted}, loc);
+ const pr_form = try coreCall(arena, "pr-str", &.{quoted}, loc);
const msg: Form = if (args.len == 2) blk: {
const nl: Form = .{ .data = .{ .string = "\n" }, .location = loc };
- break :blk try makeCall(arena, "str", &.{ prefix, args[1], nl, pr_form }, loc);
- } else try makeCall(arena, "str", &.{ prefix, pr_form }, loc);
+ break :blk try coreCall(arena, "str", &.{ prefix, args[1], nl, pr_form }, loc);
+ } else try coreCall(arena, "str", &.{ prefix, pr_form }, loc);
const ae_sym: Form = .{ .data = .{ .symbol = .{ .ns = "cljw.internal", .name = "__assertion-error" } }, .location = loc };
var ae_items = try arena.alloc(Form, 2);
ae_items[0] = ae_sym;
@@ -1532,8 +1552,8 @@ fn expandAssert(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc:
fn expandLazyCat(arena: std.mem.Allocator, rt: *Runtime, args: []const Form, loc: SourceLocation) macro_dispatch.ExpandError!Form {
_ = rt;
const items = try arena.alloc(Form, 1 + args.len);
- items[0] = sym("concat", loc);
- for (args, 0..) |a, i| items[1 + i] = try makeCall(arena, "lazy-seq", &.{a}, loc);
+ items[0] = coreSym("concat", loc);
+ for (args, 0..) |a, i| items[1 + i] = try coreCall(arena, "lazy-seq", &.{a}, loc);
return list(arena, items, loc);
}
@@ -1708,7 +1728,7 @@ fn expandIfLet(
inner_binding[0] = name_form;
inner_binding[1] = sym(gname, loc);
const inner_let_items = try arena.alloc(Form, 3);
- inner_let_items[0] = sym("let", loc);
+ inner_let_items[0] = coreSym("let", loc);
inner_let_items[1] = .{ .data = .{ .vector = inner_binding }, .location = loc };
inner_let_items[2] = then_form;
const inner_let = try list(arena, inner_let_items, loc);
@@ -1915,7 +1935,7 @@ fn transformFnArity(
const new_params_vec = try vec(arena, new_params.items, loc);
var let_items = try arena.alloc(Form, 2 + body.len);
- let_items[0] = sym("let", loc);
+ let_items[0] = coreSym("let", loc);
let_items[1] = try vec(arena, lets.items, loc);
@memcpy(let_items[2..], body);
const wrapped = try arena.alloc(Form, 1);
@@ -1940,7 +1960,7 @@ fn expandFn(
if (args.len >= 2 and args[0].data == .symbol) {
const name = args[0];
var inner_items = try arena.alloc(Form, args.len);
- inner_items[0] = sym("fn", loc);
+ inner_items[0] = coreSym("fn", loc);
@memcpy(inner_items[1..], args[1..]);
var binding = try arena.alloc(Form, 2);
binding[0] = name;
@@ -1993,7 +2013,7 @@ fn wrapBodyInDo(arena: std.mem.Allocator, body: []const Form, loc: SourceLocatio
/// `(assert cond)` form.
fn assertForm(arena: std.mem.Allocator, cond: Form, loc: SourceLocation) macro_dispatch.ExpandError!Form {
var items = try arena.alloc(Form, 2);
- items[0] = sym("assert", loc);
+ items[0] = coreSym("assert", loc);
items[1] = cond;
return list(arena, items, loc);
}
@@ -2106,7 +2126,7 @@ fn expandDefmulti(
// the no-match dispatch value, `:hierarchy ` swaps the hierarchy var).
// Inline key/value pairs; unknown keys are ignored (forward-compatible).
var default_form: Form = .{ .data = .{ .keyword = .{ .ns = null, .name = "default" } }, .location = loc };
- var hierarchy_form: Form = sym("-global-hierarchy", loc);
+ var hierarchy_form: Form = coreSym("-global-hierarchy", loc);
var oi: usize = di + 1;
while (oi + 1 < args.len) : (oi += 2) {
const opt = args[oi];
@@ -2181,7 +2201,7 @@ fn expandDefmethod(
// method param (`(defmethod m :k [a [b c]] …)`) is lowered (clj parity;
// clojure.test-helper's `assert-expr` methods destructure their args).
var fn_items = try arena.alloc(Form, 3);
- fn_items[0] = sym("fn", loc);
+ fn_items[0] = coreSym("fn", loc);
fn_items[1] = params_form;
fn_items[2] = body_form;
const fn_form = try list(arena, fn_items, loc);
@@ -2376,7 +2396,7 @@ fn rewriteProtocolRemap(
// interface's CLASS value and fail __extend-type!'s "expected protocol" check.
if (impls.len == 0) {
var ext_items = try arena.alloc(Form, 3);
- ext_items[0] = sym("extend-type", loc);
+ ext_items[0] = coreSym("extend-type", loc);
ext_items[1] = target_form;
ext_items[2] = try quoteWrap(arena, sym(declared_name, loc));
return list(arena, ext_items, loc);
@@ -2423,7 +2443,7 @@ fn rewriteProtocolRemap(
var sections = try arena.alloc(Form, protos.items.len + 1);
{
var decl_items = try arena.alloc(Form, 3);
- decl_items[0] = sym("extend-type", loc);
+ decl_items[0] = coreSym("extend-type", loc);
decl_items[1] = target_form;
decl_items[2] = try quoteWrap(arena, sym(declared_name, loc));
sections[protos.items.len] = try list(arena, decl_items, loc);
@@ -2431,7 +2451,7 @@ fn rewriteProtocolRemap(
for (protos.items, 0..) |proto, si| {
var sec: std.ArrayList(Form) = .empty;
defer sec.deinit(arena);
- try sec.append(arena, sym("extend-type", loc));
+ try sec.append(arena, coreSym("extend-type", loc));
try sec.append(arena, target_form);
try sec.append(arena, sym(proto, loc));
for (impls) |impl| {
@@ -2621,7 +2641,7 @@ fn expandExtendType(
nt_items[1] = .{ .data = .{ .keyword = .{ .name = tag } }, .location = loc };
const nt = try list(arena, nt_items, loc);
var ext_items = try arena.alloc(Form, 3 + (args.len - 2));
- ext_items[0] = sym("extend-type", loc);
+ ext_items[0] = coreSym("extend-type", loc);
ext_items[1] = nt;
ext_items[2] = args[1];
@memcpy(ext_items[3..], args[2..]);
@@ -2653,7 +2673,7 @@ fn expandExtendType(
while (i < args.len and args[i].data == .list) : (i += 1) {}
const impls = args[start..i];
var ext_items = try arena.alloc(Form, 3 + impls.len);
- ext_items[0] = sym("extend-type", proto.location);
+ ext_items[0] = coreSym("extend-type", proto.location);
ext_items[1] = target_form;
ext_items[2] = proto;
@memcpy(ext_items[3..], impls);
@@ -2841,7 +2861,7 @@ fn expandExtendProtocol(
// the next macro pass, so no need to duplicate the
// method-impl normalisation here.
var ext_items = try arena.alloc(Form, 3 + impls.len);
- ext_items[0] = sym("extend-type", type_form.location);
+ ext_items[0] = coreSym("extend-type", type_form.location);
ext_items[1] = type_form;
ext_items[2] = protocol_form;
@memcpy(ext_items[3..], impls);
@@ -2884,7 +2904,7 @@ fn expandWhenLet(
// through expandIfLet on the next pass, so we get the gensym for
// free.
const if_let_items = try arena.alloc(Form, 4);
- if_let_items[0] = sym("if-let", loc);
+ if_let_items[0] = coreSym("if-let", loc);
if_let_items[1] = binding_form;
if_let_items[2] = body_form;
if_let_items[3] = nilForm(loc);
@@ -3307,7 +3327,7 @@ fn lowerDefType(
try out_impls.append(arena, impl);
}
var ext_items = try arena.alloc(Form, 3 + out_impls.items.len);
- ext_items[0] = sym("extend-type", section.proto.location);
+ ext_items[0] = coreSym("extend-type", section.proto.location);
ext_items[1] = args[0];
ext_items[2] = section.proto;
@memcpy(ext_items[3..], out_impls.items);
@@ -3493,7 +3513,7 @@ fn expandLetfn(
const spec_items = spec.data.list;
// (fn params... body...)
var fn_items = try arena.alloc(Form, spec_items.len);
- fn_items[0] = sym("fn", spec.location);
+ fn_items[0] = coreSym("fn", spec.location);
@memcpy(fn_items[1..], spec_items[1..]);
binds[i * 2] = spec_items[0];
binds[i * 2 + 1] = try list(arena, fn_items, spec.location);
From 1829b1f45109891ca085d8a63fb0386b4b2bc2c9 Mon Sep 17 00:00:00 2001
From: Pedro Gomes Branquinho
Date: Tue, 18 Aug 2026 00:27:15 -0300
Subject: [PATCH 075/466] docs(dev): probes + differential oracle for the
test.check rung (D-298)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
test.check is the generator engine malli.generator sits on, so it gates every
schema-driven property/mutation suite. Four blockers measured against v1.10.8;
the macro-hygiene one is fixed, three remain:
rose_tree.cljc:101 `for` bound the ns's shadowed `seq` FIXED
random.clj:178 `proxy [ThreadLocal]` unresolved D-298
properties.cljc:38 java.lang.ThreadDeath unknown catch class open
clojure_test.cljc:172 clojure.lang.MultiFn unresolvable open
Blockers 3 and 4 were only reachable behind blocker 2, via the proxy-free
`random.clj` overlay kept here — it is a measurement device, not a fix, and is
sound only because cljw is single-threaded.
Correction to D-298's parked analysis: its level (a) (recognise `proxy`, raise
at eval) does NOT unblock test.check. The proxy is CONSTRUCTED at load time, and
gen/generate + gen/sample-seq route through the 0-arity make-random, so a
define-but-do-not-call stub is insufficient.
pending-verified-project/ holds the finished verified_projects entry. It stays
here until green: per that directory's README the presence of the dir IS the
claim that the library works on cljw. Its verify.clj already passes on JVM
Clojure 1.12.1, so it is a differential oracle — it cannot pass by encoding a
cljw bug as expected behaviour.
---
.dev/probes/testcheck/README.md | 62 ++++++
.dev/probes/testcheck/goldenroot_surface.clj | 10 +
.dev/probes/testcheck/hostsurface.clj | 11 +
.dev/probes/testcheck/hyg.clj | 3 +
.dev/probes/testcheck/interop.clj | 10 +
.dev/probes/testcheck/interop3.clj | 5 +
.dev/probes/testcheck/mf.clj | 5 +
.../overlay/clojure/test/check/random.clj | 190 ++++++++++++++++++
.../pending-verified-project/deps.edn | 5 +
.../pending-verified-project/verify.clj | 69 +++++++
.dev/probes/testcheck/qual.clj | 10 +
11 files changed, 380 insertions(+)
create mode 100644 .dev/probes/testcheck/README.md
create mode 100644 .dev/probes/testcheck/goldenroot_surface.clj
create mode 100644 .dev/probes/testcheck/hostsurface.clj
create mode 100644 .dev/probes/testcheck/hyg.clj
create mode 100644 .dev/probes/testcheck/interop.clj
create mode 100644 .dev/probes/testcheck/interop3.clj
create mode 100644 .dev/probes/testcheck/mf.clj
create mode 100644 .dev/probes/testcheck/overlay/clojure/test/check/random.clj
create mode 100644 .dev/probes/testcheck/pending-verified-project/deps.edn
create mode 100644 .dev/probes/testcheck/pending-verified-project/verify.clj
create mode 100644 .dev/probes/testcheck/qual.clj
diff --git a/.dev/probes/testcheck/README.md b/.dev/probes/testcheck/README.md
new file mode 100644
index 000000000..b5c30f6b3
--- /dev/null
+++ b/.dev/probes/testcheck/README.md
@@ -0,0 +1,62 @@
+# probes/testcheck — landing clojure.test.check on cljw (D-298)
+
+Measurement scripts for the test.check rung. test.check is the generator engine
+`malli.generator` sits on, so it gates every schema-driven property/mutation
+suite (hive-schemas.test, hive-test.properties).
+
+Run each with the freshly built binary:
+
+ B=zig-out/bin/cljw
+ $B .dev/probes/testcheck/