Created
August 31, 2026 03:38
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;; ============================================================================ | |
| ;; 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