|
| 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. |
0 commit comments