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

Commit aa7c935

Browse files
committed
0021: cljwit.edn -- sync!, status and check, shaped by its own review
S2's first unit. The project file holds what write-ns! takes, keyed by namespace so a duplicate is unrepresentable; discovery is the working directory plus :file, matching what the shadow-cljs precedent actually does rather than what the first draft claimed for it. An adversarial review changed most of the surface before implementation: - Drift is tiered, not byte-equality. Byte-compare's certain failure is the first generator release flagging every user file as drifted with a message blaming their component. So: exports-hash line differs => :stale (the component changed); bytes differ under a matching hash => :modified (hand edit, option change, or newer generator -- the new generator: header line says which). This supersedes 0020 A's "string compare" sentence, recorded there and here. - sync! plans everything before writing anything -- source-for is pure, so a bad entry aborts with zero bytes written, failures collected across entries, each carrying its namespace. - status also looks in the other direction: a generated file no entry claims (:orphans) is exactly the silent staleness this unit exists to kill. Nothing is deleted. - check enumerates the drift on stdout and in ex-message, because -X shows a caller nothing else (measured: ex-data goes to a temp-file report CI discards). The documented CI form is an alias carrying --enable-native-access, and the note now states the real prerequisite: the runner needs libwasmtime. - The review also caught write-ns! silently dropping unknown keys -- 0014's nil-on-typo, live in the REPL path. known-opts! now rejects them for every caller. The -X coordinate (cljwit.project/check) is provisional until the S2 server process is designed; the note says so. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent f4b497d commit aa7c935

4 files changed

Lines changed: 507 additions & 1 deletion

File tree

‎doc/design/0021-cljwit-edn.md‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# 0021 — `cljwit.edn`: the project file, starting as a list of components
2+
3+
**Status:** accepted · 2026-07-30 · S2's first unit. `0020` promised its
4+
option keys would be this file's keys, and deferred the drift check here.
5+
Rewritten after an adversarial review ran its claims: the drift contract,
6+
the transaction semantics, the path story and the CI command all changed,
7+
and two of the first draft's factual claims were measured false.
8+
9+
## The question
10+
11+
`0020` generates one namespace per call, and what it wrote can go stale
12+
silently — the file cannot notice a swapped `.wasm`, and nothing regenerates
13+
it but a human remembering to. A project with three components has three
14+
invocations to remember, in the right directories, with the same options as
15+
last time. shadow-cljs answered the same question with a project file; the
16+
roadmap names `cljwit.edn` as S2's first deliverable. What goes in it now,
17+
and what does not?
18+
19+
## The decision
20+
21+
### A. One file, found in the working directory, components keyed by namespace
22+
23+
```clojure
24+
;; cljwit.edn
25+
{:components {acme.resize {:wasm "components/resize.wasm"}
26+
acme.api.v1 {:wasm "components/api.wasm"
27+
:interface "acme:api/svc@1.0.0"
28+
:dir "src/gen"}}}
29+
```
30+
31+
Each value is `0020`'s option map minus `:ns`, which moved into the key —
32+
**a duplicate namespace is now unrepresentable** rather than validated, and
33+
the map diffs one line per component. What still needs a written check is
34+
the sibling collision edn cannot see: `acme.foo-bar` and `acme.foo_bar`
35+
munge to one file path.
36+
37+
**Discovery is the working directory, plus `:file`.** `(project/sync!)`
38+
reads `./cljwit.edn` — the same rule shadow-cljs uses, and the same anchor
39+
`-X` already gives `deps.edn` — and a monorepo passes
40+
`{:file "sub/cljwit.edn"}`. No walking up: two `cljwit.edn`s in one tree is
41+
a question this note refuses to answer implicitly. `:wasm` and `:dir` are
42+
resolved relative to the file's directory **for I/O only**; the string
43+
printed into a generated header stays the edn-relative one, because a
44+
committed header carrying an absolute path is a machine-specific assumption
45+
in a committed file. (`write-ns!` grew a `:source` override to keep those
46+
two apart.)
47+
48+
Unknown keys are an error naming the key — at the top level, in an entry,
49+
**and in `write-ns!` itself**: the review found the REPL path silently
50+
dropping `:renmae` today, which is `0014`'s nil-on-typo live in the
51+
function this file wraps. The check lives in `gen`, so both paths inherit
52+
it. Every failure `sync!` reports carries its provenance —
53+
`{:file "cljwit.edn" :ns acme.api.v1}` — because "identical to the REPL
54+
error" is the wrong goal for entry 3 of 5.
55+
56+
### B. `sync!` returns data; `check` throws with the list in its message
57+
58+
Both live in **`cljwit.project`**, not `cljwit.host.gen`: reading,
59+
validating and reconciling the project file is not "generation over a
60+
list", and the roadmap already names two more consumers of this file (the
61+
nREPL entry point, the watcher). A `-X` coordinate pasted into CI YAML is
62+
a public API; it should name the file's owner.
63+
64+
```clojure
65+
(project/sync!) ; plan everything, then write; {:wrote [...] :unchanged [...]}
66+
(project/status) ; touch nothing; {:ok [...] :stale [...] :modified [...]
67+
; :missing [...] :orphans [...]}
68+
(project/check) ; status, printed, thrown if anything is not :ok
69+
```
70+
71+
- **Plan all, then write all.** `source-for` is pure, so validation
72+
failures in any entry abort before the first byte is written — no
73+
half-synced tree. Failures are collected across entries, not
74+
first-wins.
75+
- **Drift is tiered, because byte-equality misdiagnoses the certain case.**
76+
The first draft said "differs from what regeneration would produce" and
77+
the review pointed out what that flags: **every generated file in every
78+
user project, on the first cljwit release that touches emission** — with
79+
a message blaming their component. So: the `exports-hash` header line
80+
differs → **`:stale`**, the component's API changed, regenerate. Hash
81+
matches but bytes differ → **`:modified`** — a hand edit, a changed
82+
option, or a newer generator, and the header's new `generator:` line
83+
says which of those it was. This supersedes `0020` A's "the API hash
84+
makes it a string compare" — the hash alone misses hand edits and
85+
`:rename` changes; the bytes alone cannot name what happened. Both
86+
tiers cost one `describe` of one `.wasm`.
87+
- **Orphans are findings, not garbage collection.** A file under a
88+
configured `:dir` that carries the generated header but no entry —
89+
someone deleted the entry and left the namespace loadable — is exactly
90+
the silent staleness this unit exists to kill, so `status` reports it.
91+
Nothing is deleted: removal is the human's, with the list in hand.
92+
- **`check` is the CI verb**, and it enumerates on stdout and in the
93+
exception message — measured: `-X` shows a caller only `ex-message`;
94+
`ex-data` lands in a temp-file report CI discards.
95+
96+
### C. The CI invocation is an alias, and says its prerequisites out loud
97+
98+
```clojure
99+
;; deps.edn
100+
:aliases {:cljwit {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]
101+
:exec-fn cljwit.project/check}}
102+
```
103+
104+
`clojure -X:cljwit` exits non-zero on drift. The bare
105+
`clojure -X cljwit.project/check` of the first draft prints three JEP-472
106+
restricted-method warnings today and is scheduled to become an error, so
107+
the alias is the documented form. And the prerequisite is stated rather
108+
than implied: **this check runs the generator, so the runner needs
109+
libwasmtime** (`0019`) — a heavier ask than the `bb` this note's first
110+
draft rejected as too demanding. A CI without the native library cannot
111+
run `check`; committing the generated files and reviewing their diffs is
112+
what it has instead.
113+
114+
The `-X` coordinate is **provisional until the S2 server process is
115+
designed** — the shadow-cljs model has a long-lived process end up owning
116+
this file and the engine, and that design may want to own the CLI too.
117+
118+
### D. What is *not* in the file yet
119+
120+
A file watcher, an nREPL entry point, compiler options — the rest of S2 —
121+
earn their keys when they exist. A config surface that anticipates its
122+
features is the scaffolding failure `0006` documents, in user-facing form.
123+
124+
## Why
125+
126+
- **The drift check has to live above the generated files.** `0020` settled
127+
that a generated file cannot check itself; only the thing that knows every
128+
component→namespace pair can. This file is that thing, and `status` makes
129+
the knowledge executable in both directions — entries without files,
130+
files without entries.
131+
- **Saved arguments beat remembered ones.** The regeneration command is
132+
already printed in every generated header; this file is the same
133+
information once, for all of them, diffable.
134+
- **shadow-cljs is the precedent, at its actual strength.** Its lesson is
135+
the narrow one: a project file the tool reads and the human edits, at the
136+
root, in edn, found in the working directory. It has no
137+
find-from-anywhere property and neither does this.
138+
139+
## Alternatives rejected
140+
141+
- **Metadata in the generated files instead of a central file.** Loses the
142+
complete set — no `:missing`, no `:orphans`.
143+
- **A `deps.edn` alias carrying the config.** tools.deps passes exec args,
144+
it does not define config schemas. (An alias still *carries the JVM
145+
flags* — C — which is a different job.)
146+
- **babashka tasks as the interface.** This repo uses bb; a library user
147+
need not.
148+
- **Regenerating inside `require` / at load time.** Reopens `0015`'s
149+
measured rejections through the back door.
150+
- **A vector of entries with `:ns` inside** (the first draft). Made the
151+
file's one invariant — one owner per namespace — something to validate
152+
instead of something unrepresentable.
153+
- **Byte-equality as the whole drift contract** (the first draft). Certain
154+
to misfire on the first generator release; see B.
155+
- **`sync!` throwing on drift.** Hostile from a REPL; data composes,
156+
`check` throws.
157+
158+
## What would falsify this
159+
160+
- A second S2 feature whose config does not fit "a map of `write-ns!`
161+
argument maps" — the file would need a second top-level key, and this
162+
note an amendment saying which.
163+
- A user's cljfmt config reformatting generated files, so that `:modified`
164+
fires on every checked file. The generator's output is stable under this
165+
repo's cljfmt (measured in review); a divergent user config would force
166+
emission to normalize harder or `:modified` to diff structurally.
167+
- The S2 server design wanting different ownership of the file and the
168+
`-X` coordinate — the coordinate is declared provisional for exactly
169+
that.

‎src/cljwit/host/gen.clj‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@
1313

1414
(set! *warn-on-reflection* true)
1515

16+
(def VERSION
17+
"Bumped when emission changes, so a `:modified` verdict from
18+
`cljwit.project/status` can say \"newer generator\" instead of blaming
19+
the component (`0021` B)."
20+
"1")
21+
22+
(def ^:private KNOWN-OPTS #{:ns :dir :interface :rename :engine :source :regenerate})
23+
24+
(defn- known-opts!
25+
"An unknown option silently dropped is `0014`'s nil-on-typo — a review
26+
caught :renmae sailing through this very function's callers."
27+
[opts]
28+
(when-let [bad (seq (remove KNOWN-OPTS (keys opts)))]
29+
(throw (ex-info (str "unknown option(s): " (pr-str (vec bad)))
30+
{:cljwit/error :bad-options :cljwit/keys (vec bad)}))))
31+
1632
(defn- var-name
1733
"The last WIT segment as a symbol: annotations dropped, dots to dashes.
1834
`…#[method]counter.bump` → counter-bump."
@@ -127,6 +143,7 @@
127143
the collision rules and the emitted shape are testable without a
128144
component. `write-ns!` is the wrapper that reads one."
129145
[described {:keys [ns source] :as opts}]
146+
(known-opts! opts)
130147
(when-not (symbol? ns)
131148
(throw (ex-info ":ns must be a symbol" {:cljwit/error :bad-options :cljwit/ns ns})))
132149
(let [rows (plan described opts)
@@ -136,7 +153,10 @@
136153
(str ";; Generated by cljwit.host.gen (doc/design/0020). Do not edit by hand.\n"
137154
(when source (str ";; source: " source "\n"))
138155
";; exports-hash: sha256:" (api-hash (into {} (map (juxt :wit :sig)) rows)) "\n"
139-
";; regenerate: (cljwit.host.gen/write-ns! \"" (or source "<component.wasm>") "\" '" regen ")\n"
156+
";; generator: cljwit.host.gen v" VERSION "\n"
157+
";; regenerate: " (or (:regenerate opts)
158+
(str "(cljwit.host.gen/write-ns! \""
159+
(or source "<component.wasm>") "\" '" regen ")")) "\n"
140160
"(ns " ns "\n"
141161
" \"Exports of a Wasm component, one var per export; the instance is the\n"
142162
" first argument (doc/design/0020). cljwit.host/unwrap is the result sugar.\""

0 commit comments

Comments
 (0)