clj-kondo 2026-10-16

I have a custom macro that expands into a mount/defstate and I tried to macroexpand-hook it, but end up getting an unresolved symbol

bash -c '
  tmp=$(mktemp -d)
  cleanup() { rm -rf "$tmp"; }
  trap cleanup EXIT
  cd "$tmp" || exit

  mkdir -p src/demo .clj-kondo

  cat > deps.edn <<"EOF"
{:paths ["src"]
 :deps {mount/mount {:mvn/version "0.1.24"}}}
EOF

  echo "{}" > .clj-kondo/config.edn

  cat > src/demo/api.clj <<"EOF"
(ns demo.api
  (:require [mount.core :refer [defstate]]))

(defmacro defclient
  {:clj-kondo/macroexpand-hook true}
  [sym]
  `(defstate ~sym :start (identity 1)))
EOF

  cat > src/demo/repro.clj <<"EOF"
(ns demo.repro
  (:require [demo.api :as api]))

(api/defclient client)
client
EOF

  clj-kondo --version
  clj-kondo --dependencies --copy-configs \
    --lint "$(clojure -Srepro -Spath)" &&
  clj-kondo --lint src --fail-level error --report-level error
'

clj-kondo v2026.08.04
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStoreType=KeychainStore
Configs copied:
- .clj-kondo/imports/mount/mount
src/demo/repro.clj:4:16: error: Unresolved symbol: client
linting took 26ms, errors: 1

does it work when you fully qualify defstate?

The important parts are:

(defmacro defclient
  {:clj-kondo/macroexpand-hook true}
  [sym]
  `(defstate ~sym :start (identity 1)))
(api/defclient client)

and you also have a hook for mount defstate?

what if you literally use mount defstate, does that "work"?

I don't but mount itself does publish one

and its hook works fine

let me add a few more bits to the repro

Okay, here's a larger repro with a few cases, marked with comments

bash -c '
  tmp=$(mktemp -d)
  cleanup() { rm -rf "$tmp"; }
  trap cleanup EXIT
  cd "$tmp" || exit

  mkdir -p src/demo .clj-kondo

  cat > deps.edn <<"EOF"
{:paths ["src"]
 :deps {mount/mount {:mvn/version "0.1.24"}}}
EOF

  echo "{}" > .clj-kondo/config.edn

  cat > src/demo/api.clj <<"EOF"
(ns demo.api
  (:require [mount.core :as mount :refer [defstate]]))

(defmacro defclient1
  {:clj-kondo/macroexpand-hook true}
  [sym]
  `(defstate ~sym :start (identity 1)))

  (defmacro defclient2
    {:clj-kondo/macroexpand-hook true}
    [sym]
    `(mount/defstate ~sym :start (identity 1)))
EOF

  cat > src/demo/repro1.clj <<"EOF"
(ns demo.repro1
  (:require [demo.api :as api]))

(api/defclient1 client1) ;; client1 unresolved
client1
EOF

  cat > src/demo/repro2.clj <<"EOF"
(ns demo.repro2
  (:require [demo.api :as api]))

(api/defclient2 client2) ;; client2 unresolved
client2
EOF

  cat > src/demo/repro3.clj <<"EOF"
(ns demo.repro3
  (:require [demo.api :as api]
            [mount.core]))

(api/defclient1 client1) ;; client1 unresolved
client1
(api/defclient2 client2) ;; no error (referencing mount directly?)
client2
EOF

  cat > src/demo/repro_mount.clj <<"EOF"
(ns demo.repro-mount
  (:require [mount.core :as mount :refer [defstate]]))

;; no errors using mount directly
(defstate state1 :start (identity 1))
(mount/defstate state2 :start (identity 1))
state1
state2
EOF

  clj-kondo --version
  clj-kondo --dependencies --copy-configs --lint "$(clojure -Srepro -Spath)" &&
  clj-kondo --lint src --fail-level error --report-level error
'

Important bits: (using refer and as in the 2 macros)

(defmacro defclient1
  {:clj-kondo/macroexpand-hook true}
  [sym]
  `(defstate ~sym :start (identity 1)))

  (defmacro defclient2
    {:clj-kondo/macroexpand-hook true}
    [sym]
    `(mount/defstate ~sym :start (identity 1)))
(ns demo.repro1
  (:require [demo.api :as api]))

(api/defclient1 client1) ;; client1 unresolved
(ns demo.repro2
  (:require [demo.api :as api]))

(api/defclient2 client2) ;; client2 unresolved
client2
When I explicitly include mount, the second case clears:
(ns demo.repro3
  (:require [demo.api :as api]
            [mount.core]))

(api/defclient1 client1) ;; client1 unresolved
client1
(api/defclient2 client2) ;; no error (referencing mount directly?)
client2

Can you make a Github issue so this doesn't get lost?

Here is another interesting one I just found ๐Ÿงต

printf '%s\n' \
        '(ns demo.minimal (:require [clojure.test :refer :all]))' \
        '(def client (identity client))' \
      | clj-kondo --lint - --filename demo/minimal.clj --report-level error
demo/minimal.clj:2:23: error: Unresolved symbol: client
linting took 40ms, errors: 1

whereas

printf '%s\n' \
        '(ns demo.minimal)' \
        '(def client (identity client))' \
      | clj-kondo --lint - --filename demo/minimal.clj --report-level error
linting took 38ms, errors: 0

The tricky code is (def client (identity client)) which is valid (but not too useful) clojure

and it's considered valid, unless the namespace uses (:require [clojure.test :refer :all])

๐Ÿ˜„ 1

I'll create an issue

๐Ÿ‘ 1

(deleted my off-topic message in github, sorry lol)

@nbtheduke the repro case is a minimal reduction of the problem I saw (with mount, again, but it turns out mount is not required for it)

๐Ÿ‘ 1

yeah, i thought it was silly and then thought about it for two seconds and realized that the behavior is how recursive functions work