fix: transient invokable lookup, assoc! odd args, use-after-persistent! invalidation

Real transient correctness gaps surfaced by the clojure-test-suite:

- Transients are now invokable for read-only lookup like their persistent forms:
  ((transient v) i), ((transient m) k [default]), (:k (transient m)),
  ((transient s) x). jolt-invoke/coll-lookup gained a transient branch
  (transient-lookup) that indexes :arr / canon-keyed :tbl. Added phm/canon as a
  public canonicalizer so collection keys compare by value here too.
- assoc! accepts an ODD arg count (a missing final value is nil), unlike assoc —
  core-assoc! now uses nil-safe get for the value instead of erroring.
- Using a transient after persistent! (or a second persistent!, or pop! on an
  empty transient vector) now throws, via a :jolt/persistent invalidation flag
  checked by the mutating ops. Catches the classic transient footgun.

spec: transients-spec gains invokable-lookup (7), assoc!-odd-args (4),
invalidation (4). clojure-test-suite pass 1704->1719.

Remaining transient fails are accepted lenient divergences (jolt doesn't throw
on bad-shape conj!/assoc!/pop! of wrong-typed args; nil set elements). jpm test green.
This commit is contained in:
Yogthos 2026-06-05 09:29:36 -04:00
parent e75771084e
commit 09532dac05
5 changed files with 83 additions and 10 deletions

View file

@ -44,3 +44,35 @@
"(let [v [1 2 3] _ (persistent! (conj! (transient v) 4))] (= v [1 2 3]))"]
["source map unchanged" "true"
"(let [m {:a 1} _ (persistent! (assoc! (transient m) :b 2))] (= m {:a 1}))"])
# Transients are invokable for read-only lookup, like their persistent forms.
(defspec "transient / invokable lookup"
["vector index" "20" "((transient [10 20 30]) 1)"]
["map key as fn" "7" "((transient {:x 7}) :x)"]
["map key default" "99" "((transient {:x 7}) :z 99)"]
["keyword on transient" "7" "(:x (transient {:x 7}))"]
["set membership" "2" "((transient #{1 2 3}) 2)"]
["set miss default" ":no" "((transient #{1 2 3}) 42 :no)"]
["collection key" ":v" "((transient {[1 2] :v}) [1 2])"])
# assoc! (unlike assoc) accepts an odd arg count — a missing final value is nil.
# (A struct literal can't express an explicit nil value, so assert via contains?.)
(defspec "transient / assoc! odd args"
["odd arg key present" "true"
"(contains? (persistent! (assoc! (transient {}) :a 1 :b)) :b)"]
["odd arg value is nil" "true"
"(nil? (get (persistent! (assoc! (transient {}) :a 1 :b)) :b))"]
["odd arg keeps prior" "1"
"(get (persistent! (assoc! (transient {}) :a 1 :b)) :a)"]
["vector odd arg" "[9 nil]"
"(persistent! (apply assoc! (transient []) [0 9 1]))"])
# Using a transient after persistent! (or popping an empty one) throws.
(defspec "transient / invalidation"
["conj! after persistent!" :throws
"(let [t (transient [])] (persistent! t) (conj! t 1))"]
["assoc! after persistent!" :throws
"(let [t (transient {})] (persistent! t) (assoc! t :a 1))"]
["persistent! twice" :throws
"(let [t (transient [])] (persistent! t) (persistent! t))"]
["pop! empty" :throws "(pop! (transient []))"])