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

Commit f897fa4

Browse files
committed
Check #5's three findings: guard order, absolute paths, and 0020's overdue amendment
An independent critique of the 0021 pair (17/20) executed the shapes the tests had not: - {:components [vector]} -- the rejected first-draft shape, and the likeliest mistake -- hit an eager mapcat before the map? guard and surfaced as a raw nth exception instead of the message written for it. The guard now runs first, and the shape is a test. - An absolute :wasm or :dir crashed inside io/file (it throws on an absolute child) with no entry named. Refused at read-project with provenance; everything in cljwit.edn is root-relative by design, and a committed absolute path is a machine-specific assumption anyway. - A hand-deleted exports-hash line was misread as :stale ("the component changed"); :stale must only ever mean the API moved, so a missing hash line is :modified. The orphan scan also always covers the default src and dedupes files reachable through nested :dirs, and its recorded limit -- deleting a custom :dir's last entry removes that dir from view -- is in the docstring and the note. - 0020 A still said "the API hash makes it a string compare" while aa7c935 claimed the supersession was recorded there. Now it is. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent c98d3a3 commit f897fa4

4 files changed

Lines changed: 69 additions & 20 deletions

File tree

‎doc/design/0020-generated-namespaces.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ anywhere. The *loud* half is already covered — marshalling is rebuilt from
4545
the live component at instantiate, so a renamed export fails
4646
`:no-such-export` with a did-you-mean and a changed argument list fails
4747
`:wrong-arity`. The silent half is exactly the regenerate-and-diff check
48-
that `cljwit.edn` (S2) owes its components; the API hash makes it a string
49-
compare. Until S2 exists, staleness detection is the reviewer's, and this
50-
paragraph is the warning.
48+
that `cljwit.edn` (S2) owes its components. *(Superseded on that point by
49+
`0021` B: the hash alone misses hand edits and `:rename` changes, so the
50+
check became tiered — hash line for `:stale`, bytes for `:modified` — and
51+
the hash's job shrank to distinguishing the two.)*
5152

5253
### B. Every var takes the instance first
5354

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ a public API; it should name the file's owner.
8989
someone deleted the entry and left the namespace loadable — is exactly
9090
the silent staleness this unit exists to kill, so `status` reports it.
9191
Nothing is deleted: removal is the human's, with the list in hand.
92+
One recorded limit: the scan covers the `:dir`s of *current* entries
93+
plus the default `src`, so deleting the last entry of a custom `:dir`
94+
also removes that dir from view. Scanning the whole tree instead would
95+
make `status` cost scale with the repo, not the config.
96+
- **Paths in the file are relative, enforced.** An absolute `:wasm` or
97+
`:dir` is refused with its entry named — `io/file` throws on an
98+
absolute child, so it would otherwise crash without provenance, and a
99+
committed absolute path is a machine-specific assumption anyway.
92100
- **`check` is the CI verb**, and it enumerates on stdout and in the
93101
exception message — measured: `-X` shows a caller only `ex-message`;
94102
`ex-data` lands in a temp-file report CI discards.

‎src/cljwit/project.clj‎

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
(throw (ex-info (str "unknown key(s) in " f ": " (pr-str (vec bad)))
3737
{:cljwit/error :bad-project :cljwit/keys (vec bad)})))
3838
(let [comps (:components cfg)
39+
;; This guard must run before anything walks comps: a vector here
40+
;; — the rejected first-draft shape, and the likeliest mistake —
41+
;; would otherwise surface as a raw nth exception.
42+
_ (when-not (map? comps)
43+
(throw (ex-info ":components must be a map of namespace symbol -> options"
44+
{:cljwit/error :bad-project})))
3945
errs (into []
4046
(mapcat (fn [[nss opts]]
4147
(cond
@@ -48,11 +54,20 @@
4854
(when-let [bad (seq (remove ENTRY-KEYS (keys opts)))]
4955
[(str nss " — unknown key(s): " (pr-str (vec bad)))])
5056
(when-not (string? (:wasm opts))
51-
[(str nss " — :wasm must be a path string")])))))
57+
[(str nss " — :wasm must be a path string")])
58+
;; io/file throws on an absolute child, so
59+
;; an absolute path here would crash later
60+
;; without naming its entry. Everything in
61+
;; this file is root-relative by design
62+
;; (0021 A) — a committed absolute path is
63+
;; a machine-specific assumption anyway.
64+
(for [k [:wasm :dir]
65+
:let [v (get opts k)]
66+
:when (and (string? v)
67+
(.isAbsolute (io/file v)))]
68+
(str nss " — " k " must be relative to cljwit.edn: "
69+
(pr-str v)))))))
5270
comps)]
53-
(when-not (map? comps)
54-
(throw (ex-info ":components must be a map of namespace symbol -> options"
55-
{:cljwit/error :bad-project})))
5671
(when (seq errs)
5772
(throw (ex-info (str "cljwit.edn: " (str/join "; " errs))
5873
{:cljwit/error :bad-project :cljwit/errors errs})))
@@ -145,10 +160,12 @@
145160
"Touches nothing. {:ok [...] :stale [...] :modified [...] :missing [...]
146161
:orphans [...]} — :stale means the component's API changed (the
147162
exports-hash line differs: regenerate); :modified means the API did not
148-
(a hand edit, a changed option, or a newer generator — the file's
149-
generator: line says which); :orphans are files under a configured :dir
150-
that carry the generated header but no entry, which is the silent
151-
staleness this file exists to kill (0021 B). Nothing is deleted."
163+
(a hand edit, a changed option, a newer generator, or a direct
164+
write-ns! over a sync!-managed file — the generator: and regenerate:
165+
lines say which); :orphans are generated files no entry claims, found
166+
under the :dirs current entries configure plus the default \"src\" —
167+
so removing the *last* entry of a custom :dir also removes that dir
168+
from view, a recorded limit (0021 B). Nothing is deleted."
152169
([] (status nil))
153170
([opts]
154171
(let [proj (read-project (or opts {}))
@@ -160,19 +177,26 @@
160177
(update acc :missing conj (str file))
161178
(= src (slurp file))
162179
(update acc :ok conj (str file))
163-
(not= (hash-line src) (hash-line (slurp file)))
180+
;; No hash line at all is an edit, not a changed
181+
;; component — :stale must only ever mean "the
182+
;; API moved".
183+
(and (some? (hash-line (slurp file)))
184+
(not= (hash-line src) (hash-line (slurp file))))
164185
(update acc :stale conj (str file))
165186
:else
166187
(update acc :modified conj (str file))))
167188
{:ok [] :stale [] :modified [] :missing [] :orphans []}
168189
entries)
169-
orphans (for [d (distinct (map :dir entries))
170-
^File f (file-seq (io/file (:root proj) (str d)))
171-
:when (and (.isFile f)
172-
(str/ends-with? (.getName f) ".clj")
173-
(not (claimed (str (.getCanonicalFile f))))
174-
(generated? f))]
175-
(str (.getCanonicalFile f)))]
190+
;; distinct twice over: "src" is always scanned, so a custom :dir
191+
;; under it would visit the same file through both roots.
192+
orphans (distinct
193+
(for [d (distinct (cons "src" (map :dir entries)))
194+
^File f (file-seq (io/file (:root proj) (str d)))
195+
:when (and (.isFile f)
196+
(str/ends-with? (.getName f) ".clj")
197+
(not (claimed (str (.getCanonicalFile f))))
198+
(generated? f))]
199+
(str (.getCanonicalFile f))))]
176200
(update base :orphans into orphans))))
177201

178202
(defn check

‎test/cljwit/project_test.clj‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@
5252
(testing "a missing wasm is named before anything is written"
5353
(let [d (tmp-project! {:components {'acme.a {:wasm "nowhere.wasm"}}})
5454
ex (is (thrown? clojure.lang.ExceptionInfo (project/sync! (opts d))))]
55-
(is (str/includes? (ex-message ex) "nowhere.wasm")))))
55+
(is (str/includes? (ex-message ex) "nowhere.wasm"))))
56+
(testing "a non-map :components gets the written message, not an nth trace"
57+
;; The vector is the rejected first-draft shape — the likeliest mistake.
58+
(let [d (tmp-project! {:components [{:ns 'acme.a :wasm "a.wasm"}]})
59+
ex (is (thrown? clojure.lang.ExceptionInfo (project/status (opts d))))]
60+
(is (= :bad-project (:cljwit/error (ex-data ex))))))
61+
(testing "an absolute path is refused with its entry named"
62+
(let [d (tmp-project! {:components {'acme.a {:wasm "/abs/a.wasm"}}})
63+
ex (is (thrown? clojure.lang.ExceptionInfo (project/status (opts d))))]
64+
(is (str/includes? (ex-message ex) "acme.a"))
65+
(is (str/includes? (ex-message ex) "relative to cljwit.edn")))))
5666

5767
(defn- build-echo-into! [^File d]
5868
(let [core (File/createTempFile "cljwit-proj" ".core.wasm")
@@ -107,6 +117,12 @@
107117
(is (= [(str (.getCanonicalFile f))] (:stale (project/status o))))
108118
(spit f pristine)))
109119

120+
(testing "deleting the hash line altogether is :modified — an edit, not an API change"
121+
(let [pristine (slurp f)]
122+
(spit f (str/replace pristine #";; exports-hash: sha256:[0-9a-f]+\n" ""))
123+
(is (= [(str (.getCanonicalFile f))] (:modified (project/status o))))
124+
(spit f pristine)))
125+
110126
(testing "a deleted file is :missing"
111127
(let [pristine (slurp f)]
112128
(.delete f)

0 commit comments

Comments
 (0)