Skip to content

Instantly share code, notes, and snippets.

@escherize
Created August 31, 2026 03:38
Show Gist options
  • Select an option

  • Save escherize/e7211f08a988f6b394b231bf00bcae31 to your computer and use it in GitHub Desktop.

Select an option

Save escherize/e7211f08a988f6b394b231bf00bcae31 to your computer and use it in GitHub Desktop.
Static Clojure — starter .clj-kondo/config.edn (config-only half of a 100-rule static-analysis style guide; each entry cites its rule number)
;; ============================================================================
;; Static Clojure — starter .clj-kondo/config.edn
;;
;; Companion to RULES.md (100 rules, each tagged KONDO: YES/HOOK/ANALYSIS/NO).
;; This file is the executable half of the guide. It covers the 28 rules
;; tagged KONDO: YES — everything clj-kondo can enforce with configuration
;; alone, zero custom hook code.
;;
;; PURPOSE AND PHILOSOPHY
;;
;; A style guide that lives in a wiki is a suggestion. A style guide that
;; lives in config.edn is a program. Every entry below converts a human
;; disagreement ("we shouldn't do that") into a machine check ("the build
;; fails when someone does it anyway"). Three principles drove the choices:
;;
;; 1. Errors, not warnings. A warning that doesn't fail CI is a decision
;; that hasn't been made yet. Everything here is :error unless it is
;; explicitly scoped (rule 9) or being rolled out (rule 96).
;; 2. Close the sets. Every banned var/namespace below shrinks the space a
;; static analyzer must reason about: no eval, no runtime name resolution,
;; no runtime keyword/symbol construction, no open-ended state. What the
;; analyzer cannot see, the codebase cannot ship.
;; 3. Exemptions live here, not inline. Every exception is a key under
;; :config-in-ns / :ns-groups — visible, countable, and greppable in one
;; file (rules 4, 5). No #_:clj-kondo/ignore scattered through source.
;;
;; SHAPE NOTE (clj-kondo v2025.07.26): under :linters, :discouraged-var and
;; :discouraged-namespace take their var/namespace map DIRECTLY —
;; {:linters {:discouraged-var {:level :error, clojure.core/eval {...}}}}
;; The older {:config {...}} wrapper is silently ignored. Do not wrap.
;;
;; NOT in this file: 21 rules need custom :analyze-call hooks, 11 are CI
;; scripts over `clj-kondo --config '{:output {:analysis true}}'`, and 40
;; are not statically checkable (malli, clojure-lsp, review). RULES.md holds
;; the full tag table.
;;
;; SETUP: replace the `myapp` namespace prefix in :ns-groups with your
;; project's, and adjust the layer patterns to your directory layout.
;;
;; ROLLOUT (rule 96): land this config with :error scoped to domain/ first,
;; :off elsewhere, then widen one directory at a time. To soften during
;; rollout, drop a linter to :warning — and drop it back once the count hits
;; zero. A linter you muted forever is a rule you deleted.
;; ============================================================================
{:ns-groups
;; Layer taxonomy (rules 12, 13, 96). Groups are the only sanctioned way to
;; scope or exempt rules (rule 5): one place, named, countable. Every group
;; below is a phase in the rule-96 rollout — a rule starts on `domain` and
;; migrates group by group.
[{:pattern "myapp\\.domain\\..*" :name domain}
{:pattern "myapp\\.app\\..*" :name app}
{:pattern "myapp\\.io\\..*" :name io}
{:pattern "myapp\\.ui\\..*" :name ui}
{:pattern "myapp\\.plugins.*" :name plugins}
{:pattern "myapp\\..*-test" :name tests}
;; API namespaces are the published boundary of each layer (rules 9, 21):
{:pattern "myapp\\..*\\.api$" :name api}]
;; --------------------------------------------------------------------------
;; Rule 13 (layering, config half). :discouraged-namespace takes exact
;; namespaces, not patterns, so the full one-way graph check (ui→app→domain,
;; io→domain, domain touches nothing project-internal) is rule 13's ANALYSIS
;; half. Add your real internal namespaces under :linters below to ban the
;; worst violations from config alone, e.g.:
;;
;; :discouraged-namespace {..., myapp.ui.billing {:message "rule 13: domain may not require ui"}}
;; --------------------------------------------------------------------------
:linters
{;; ========================================================================
;; Section 1 — Make the tools authoritative (rules 8, 23, 27)
;;
;; Purpose: if the analyzer cannot resolve it, it cannot check anything
;; downstream. These linters are the foundation: everything else in this
;; file assumes unresolved symbols, shadowed bindings, and inline defs are
;; already impossible.
;; ========================================================================
:unresolved-symbol {:level :error} ;; rule 8
:unresolved-var {:level :error} ;; rule 8
:unused-binding {:level :error} ;; rule 8
:used-underscored-binding {:level :error} ;; rule 8 — `_` is a promise not to use it
:shadowed-var {:level :error} ;; rule 8 — shadowing is a 3am bug
:namespace-name-mismatch {:level :error} ;; rule 23 — file path must match ns name
:inline-def {:level :error} ;; rule 27 — vars are defined at load time or not at all
;; ========================================================================
;; Section 2 — Require discipline (rules 15, 16)
;;
;; Purpose: every cross-namespace reference must be resolvable by alias
;; alone. A consistent alias means "str" means clojure.string everywhere in
;; the project — grep, review, and the analyzer all get one answer.
;; ========================================================================
:refer-all {:level :error} ;; rule 16 — `:refer :all` closes no set, checks nothing
:consistent-alias
{:aliases {clojure.string str
clojure.set set
clojure.edn edn}} ;; rule 15 — extend to every require your project uses
;; ========================================================================
;; Section 3 — Structure and flow linters (rule 76)
;; ========================================================================
:cond-else {:level :error} ;; rule 76 — a cond without :else is an unhandled case in disguise
;; `:missing-docstring` is configured under :config-in-ns below (rule 9):
;; off globally, :warning inside API namespaces only. Docstrings everywhere
;; is noise; docstrings on the boundary is documentation.
:missing-docstring {:level :off}
;; ========================================================================
;; Section 4 — Universally banned vars (rules 10, 16, 18, 19, 28, 29, 48,
;; 49, 52, 53, 66, 86)
;;
;; Purpose and philosophy: this map is the negative space of the language.
;; Each entry removes a runtime decision a reader would otherwise have to
;; simulate: eval and its cousins erase the call graph; name resolution and
;; intern erase the var table; keyword/symbol construction erases
;; greppability; defrecord/deftype erase the plain-map data model;
;; with-redefs and alter-var-root erase the meaning of "definition".
;;
;; Rule 10 is the meta-rule: every banned form in RULES.md gets an entry
;; here. A rule with no linter is a preference.
;; ========================================================================
:discouraged-var
{:level :error
;; rule 16 — never :refer :all, never `use`
clojure.core/use {:message "rule 16: use explicit :require with :as or :refer [syms]"}
;; rule 18 — namespace surgery is loader magic
clojure.core/in-ns {:message "rule 18: no in-ns in application code"}
clojure.core/create-ns {:message "rule 18: no create-ns in application code"}
clojure.core/remove-ns {:message "rule 18: no remove-ns in application code"}
;; rule 19 — runtime code loading is the single largest blind spot for
;; any analyzer. These are the single largest wins in this file.
clojure.core/eval {:message "rule 19: no eval — move the decision to load time"}
clojure.core/load {:message "rule 19: no runtime loading"}
clojure.core/load-file {:message "rule 19: no runtime loading"}
clojure.core/load-string {:message "rule 19: no runtime loading"}
clojure.core/read-string {:message "rule 19: no runtime reading — parse at an io/ boundary with clojure.edn"}
;; rule 29 — runtime name resolution erases the var table
clojure.core/intern {:message "rule 29: no runtime var creation"}
clojure.core/ns-resolve {:message "rule 29: no runtime name resolution"}
clojure.core/find-var {:message "rule 29: no runtime name resolution"}
clojure.core/var-get {:message "rule 29: no runtime name resolution"}
clojure.core/resolve {:message "rule 29: no runtime name resolution"}
;; rule 20 — the single sanctioned escape hatch lives in plugins.clj
;; (exempted below). Banned everywhere else.
clojure.core/requiring-resolve {:message "rule 20: requiring-resolve only in the plugins registry namespace"}
;; rule 28 — state changes to definitions at runtime
clojure.core/alter-var-root {:message "rule 28: alter-var-root only in system/start!"}
clojure.core/with-redefs {:message "rule 28: with-redefs only in tests"}
;; rules 48, 49 — every keyword/symbol must be a greppable literal.
;; Constructed keywords are the reason "find usages of :invoice/id"
;; silently returns three of the nine real call sites.
clojure.core/keyword {:message "rule 48: no runtime keyword construction"}
clojure.core/symbol {:message "rule 49: no runtime symbol construction"}
;; rules 52, 53 — schematized plain maps analyze better; records/types
;; are a protocol-dispatch + performance escape hatch, not a default
clojure.core/defrecord {:message "rule 52: schematized maps over records"}
clojure.core/deftype {:message "rule 53: no deftype outside a documented perf namespace"}
;; rule 66 — read-eval is eval with a reader face
clojure.core/*read-eval* {:message "rule 66: no *read-eval*"}
;; rule 86 — unbounded concurrency outside io/ (the fan-out bound is review)
clojure.core/future {:message "rule 86: no future outside io/"}
clojure.core/pmap {:message "rule 86: no pmap outside io/"}}
;; ========================================================================
;; Section 4 — Banned libraries (rules 41, 56, 80, 87)
;;
;; Purpose: pick one of each competing tool and make the loser un-splittable.
;; Two schema libraries is zero schema libraries; two lifecycle libraries is
;; a startup-order bug you haven't met yet.
;; ========================================================================
:discouraged-namespace
{:level :error
;; rule 41 — we picked malli; spec is the loser
clojure.spec.alpha {:message "rule 41: one schema library — malli"}
clojure.spec {:message "rule 41: one schema library — malli"}
clojure.spec.gen.alpha {:message "rule 41: one schema library — malli"}
clojure.spec.test.alpha {:message "rule 41: one schema library — malli"}
;; rule 56 — java.time only. (Constructor interop `(java.util.Date.)`
;; needs a hook or review; this catches static-member interop.)
java.util.Date {:message "rule 56: java.time only"}
clj-time.core {:message "rule 56: java.time only"}
clj-time.format {:message "rule 56: java.time only"}
clj-time.coerce {:message "rule 56: java.time only"}
;; rule 80 — we picked integrant; flip this entry if you pick component
com.stuartsierra.component {:message "rule 80: one lifecycle library — integrant"}
;; rule 87 — core.async terminates in io/
clojure.core.async {:message "rule 87: channels do not cross layer boundaries"}}}
;; ==========================================================================
;; Section 5 — Scoped rules and exemptions (:config-in-ns)
;;
;; Philosophy: an exemption that lives here is a decision someone made on
;; purpose, next to the rule it bends. This is the counterweight to Section
;; 4 — read the two together and the whole policy is on one screen.
;;
;; Entries here MERGE with the global linter config: a {:level :off} under a
;; group turns a global ban off for that group only.
;; ==========================================================================
:config-in-ns
{;; ------------------------------------------------------------------
;; rules 79, 82 — zero atoms/refs/agents in domain and app. All mutable
;; state lives in io/; state handles are passed as arguments, never
;; reached for. (Rule 81's "no top-level (def x (atom ...))" singleton
;; check is a HOOK — see RULES.md.)
;;
;; rule 67 — no binding-based dependency injection here either; pass the
;; dependency. Dynamic scope is invisible control flow.
domain {:linters {:discouraged-var
{clojure.core/atom {:message "rule 79: no state in domain"}
clojure.core/ref {:message "rule 79: no state in domain"}
clojure.core/agent {:message "rule 79: no state in domain"}
clojure.core/binding {:message "rule 67: pass the dependency, don't bind it"}}}}
app {:linters {:discouraged-var
{clojure.core/atom {:message "rule 79: no state in app"}
clojure.core/ref {:message "rule 79: no state in app"}
clojure.core/agent {:message "rule 79: no state in app"}
clojure.core/binding {:message "rule 67: pass the dependency, don't bind it"}}}}
;; ------------------------------------------------------------------
;; io/ — the pressure-relief valve. Everything banned above for
;; "application code" is allowed exactly here, at the trust boundary:
;; EDN parsing (rule 48's exemption), keyword construction from parsed
;; wire data, concurrency primitives (rule 86), channels terminating
;; (rule 87), and dynamic binding for process-wide config.
;; ------------------------------------------------------------------
io {:linters {:discouraged-var
{clojure.core/keyword {:level :off}
clojure.edn/read-string {:level :off}
clojure.core/future {:level :off}
clojure.core/pmap {:level :off}
clojure.core/binding {:level :off}}
:discouraged-namespace
{clojure.core.async {:level :off}}}}
;; ------------------------------------------------------------------
;; Exemption pockets, each with a documented reason:
;;
;; plugins (rule 20): the single registry namespace with an enumerated
;; target set is the one place requiring-resolve is legal.
plugins {:linters {:discouraged-var
{clojure.core/requiring-resolve {:level :off}}}}
;; tests (rule 28): with-redefs is a test tool; it stays out of production
;; namespaces by being banned everywhere except here. (binding is also
;; tolerated in tests for fixture setup.)
tests {:linters {:discouraged-var
{clojure.core/with-redefs {:level :off}
clojure.core/binding {:level :off}}}}
;; ------------------------------------------------------------------
;; api namespaces (rule 9): this is where docstrings are documentation.
;; :warning, not :error — an unpublished draft API shouldn't block CI,
;; but it should be visible on every lint run.
;; ------------------------------------------------------------------
api {:linters {:missing-docstring {:level :warning}}}}}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment