Liking cljdoc? Tell your friends :D

Skeptic

CI Clojars Project Clojars Project

Skeptic statically type-checks Clojure and ClojureScript projects based on Plumatic Schema and Malli annotations. It runs as a Leiningen plugin or as a Clojure CLI tool.

What Skeptic checks

  • Call sites where inferred argument types do not fit the declared input schema:

    (s/defn inc-int :- s/Int
      [x :- s/Int]
      (+ x 1))
    
    (inc-int "1")  ; String flows into an s/Int parameter
    
  • Return values where the inferred result does not fit the declared output schema:

    (s/defn as-int :- s/Int
      [x :- s/Int]
      (str x))  ; body returns String, not s/Int
    
  • Nilability and structural mismatches that flow into declared schemas:

    (s/defn inc-int :- s/Int
      [x :- s/Int]
      (+ x 1))
    
    (s/defn caller
      [m :- {:n (s/maybe s/Int)}]
      (inc-int (:n m)))  ; (:n m) is (maybe s/Int); nil case flows into s/Int
    

Installation

Leiningen

Add the plugin to the :plugins vector in your project.clj:

:plugins [[org.clojars.nomicflux/lein-skeptic "0.8.1"]]

Or for the snapshot version:

:plugins [[org.clojars.nomicflux/lein-skeptic "0.9.0-rc9"]]

deps.edn / Clojure CLI

Add a tool alias to your deps.edn:

{:aliases
 {:skeptic
  {:deps {org.clojars.nomicflux/skeptic {:mvn/version "0.9.0-rc9"}}
   :ns-default skeptic.tool}}}

Then run it from the project you want to check:

clj -T:skeptic check

Running it

From the project you want to check.

With Leiningen:

lein skeptic

With the Clojure CLI:

clj -T:skeptic check

Both invocations exit with status 0 when no inconsistencies are found and status 1 when they report inconsistencies.

Options:

  • -n, --namespace NAMESPACE: only check the specified namespace. Repeatable, and accepts comma-separated values: -n a.ns -n b.ns and -n a.ns,b.ns are equivalent.
  • -c, --show-context: print local-variable and reference context for each result.
  • -v, --verbose: print extra progress and debugging output.
  • -a, --analyzer: print the analyzer forms for the namespace being checked.
  • -k, --keep-empty: include analyzed expressions even when they produced no mismatches.
  • --explain-full: show fully expanded structural forms in type-mismatch output instead of compact declared names.
  • -p, --porcelain: emit machine-readable JSONL (one JSON object per line) instead of the default human-readable output. See Output below.
  • --plumatic-disable: skip Plumatic Schema intake.
  • --malli-disable: skip Malli intake.
  • --cljs-enable: turn on ClojureScript analysis (experimental; off by default).
  • --profile: profile the run (CPU, memory, wall-clock time). Long-only.
  • -o, --output OUTPUT_FILE: write Skeptic's output to this file instead of stdout. Works with text and -p JSONL output.
  • :project-dir PATH (deps.edn tool only): absolute path to the project to check, for running the tool from outside the project root. Example: clj -T:skeptic check :project-dir '"/path/to/project"'.
  • :paths PATHS (deps.edn tool only): string or vector of source paths to check, overriding the paths discovered from deps.edn. Example: clj -T:skeptic check :paths '["src"]'.
  • :alias ALIAS (deps.edn tool only): keyword, string, or vector of aliases to merge when discovering source paths. Example: clj -T:skeptic check :alias :test.

Output

Text (default)

The default output is a human-readable, ANSI-coloured report, one block per inconsistency, grouped by namespace. Findings include the source of the reported type, such as Schema, Malli, a built-in/native declaration, a type override, or inference.

---------
Namespace: 		skeptic.showcase
Location: 		/Users/demouser/Code/skeptic/skeptic/src/skeptic/showcase.clj:10:3 [source: native]
Blame: 			context( value )
---
(str x)

has an output mismatch against the declared return type.

Declared return type expects:

Int

Problem fields:

	- Str but expected Int

---------
Namespace: 		skeptic.showcase
Location: 		/Users/demouser/Code/skeptic/skeptic/src/skeptic/showcase.clj:14:3 [source: schema]
Blame: 			context( value )
---
(:n m)

	in

(inc-int (:n m))

has inferred type incompatible with the expected type:

Problems:

	- a nullable value was provided where the type requires a non-null value

Per-namespace inconsistencies:
  skeptic.showcase: 2

When the run finds inconsistencies, the report ends with a per-namespace summary listing each affected namespace and its error count, sorted with the worst-offending namespace first.

JSONL (-p / --porcelain)

lein skeptic -p switches stdout to newline-delimited JSON. One object per line, in this order:

{"kind": "ns-discovery-warning", "path": "src/foo/broken.clj", "message": "..."}
{
  "kind": "finding",
  "ns": "foo.bar",
  "report_kind": "input",
  "location": {
    "file": "src/foo/bar.clj",
    "line": 42,
    "column": 3,
    "source": "schema",
    "lang": "clj"
  },
  "blame": "(+ 1 :x)",
  "blame_side": "term",
  "blame_polarity": "positive",
  "rule": "ground-mismatch",
  "actual_type":   {"t": "ground", "name": "Keyword"},
  "expected_type": {"t": "ground", "name": "Int"},
  "actual_type_str":   "Keyword",
  "expected_type_str": "Int",
  "focuses":             ["x"],
  "enclosing_form":      "(defn f [x] (+ 1 x))",
  "expanded_expression": "(clojure.core/+ 1 x)",
  "messages":            ["Keyword is not compatible with Int at ..."]
}
{
  "kind": "exception",
  "ns": "foo.bar",
  "phase": "declaration",
  "location": {
    "file": "src/foo/bar.clj",
    "line": 99,
    "source": "schema",
    "lang": "clj"
  },
  "blame": "my-fn",
  "exception_class": "java.lang.RuntimeException",
  "exception_message": "could not resolve schema",
  "messages": ["Skeptic hit an exception while checking declared schema for my-fn ..."]
}
{
  "kind": "namespace-error-summary",
  "counts": {
    "foo.bar": 5,
    "foo.baz": 2
  }
}
{
  "kind": "run-summary",
  "errored": true,
  "finding_count": 7,
  "exception_count": 1,
  "namespace_count": 12,
  "namespaces_with_findings": 3
}

Exit code matches text mode (0 clean, 1 otherwise).

See docs/jsonl-output.md for the full per-kind field spec and the structured type-tag reference.

Configuration

Skeptic reads optional project-level configuration from .skeptic/config.edn at the project root. The file is EDN and every key is optional.

{:exclude-files ["src/fixtures/*.clj"
                 "test/**/*_examples.clj"]
 :type-overrides {clojure.tools.logging/infof {:output (s/eq nil)}}}

:exclude-files

Vector of glob patterns matched against each file's path relative to the project root. Matched files are skipped entirely. Patterns use the platform's java.nio.file.PathMatcher glob syntax (*, **, ?, character classes).

:type-overrides

Map from fully-qualified symbol to an override map with any of :schema, :output, :arglists. Values are Plumatic Schema expressions evaluated with [schema.core :as s] in scope, so you can write (s/eq nil), s/Int, etc. Overrides replace whatever Skeptic would otherwise infer or collect for that symbol at call sites.

{:type-overrides {clojure.tools.logging/infof {:output (s/eq nil)}}}

After this, call sites of infof are checked as returning nil.

ClojureScript support

ClojureScript support is experimental and off by default: .cljs files are skipped and .cljc files are admitted as :clj-only — the :cljs reader-conditional branch is discarded.

Pass --cljs-enable (Leiningen) or :cljs-enable true (deps.edn tool) to load and admit ClojureScript source files alongside Clojure. .cljc files are admitted twice — once with :clj reader-conditional features active and once with :cljs.

Malli support

Skeptic reads Malli function declarations from :malli/schema Var metadata and from the compile-time (malli.core/function-schemas) registry.

(defn takes-int
  {:malli/schema [:=> [:cat :int] :string]}
  [x]
  (str x))

Sequence/regex combinators outside the :=> head (e.g. :cat outside the function head, :alt, :*, :+, :?, :repeat, :re, :fn) are not currently supported.

Suppressing checks

Skeptic provides three opt-out mechanisms for when its inference is wrong or too dynamic.

Ignoring a function body

Use :skeptic/ignore-body in a function's attribute map to skip checks inside the function body. The declared schema still applies to all callers:

(s/defn my-fn :- s/Int
  {:skeptic/ignore-body true}
  [x :- s/Int]
  (int-add nil x))

The body's internal mismatch (passing nil to int-add) is suppressed. Callers are still checked against the declared :- s/Int schema.

Treating a function as a black box

Use :skeptic/opaque in a function's attribute map to exclude both the function body and its schema from checking. Callers see the function as accepting and returning s/Any:

(s/defn my-fn :- s/Int
  {:skeptic/opaque true}
  [x :- s/Int]
  "not-an-int")

The body is not checked, and callers can pass any type and expect any type back.

Overriding an expression's type

Use ^{:skeptic/type T} metadata on an expression to pin its inferred type to schema T:

(let [y ^{:skeptic/type s/Int} (some-call-that-returns-any)]
  (int-add y 1))

The expression's type is treated as s/Int for subsequent checks. Clojure does not allow metadata on bare literal values (numbers, strings, keywords); wrap them in a form if needed:

^{:skeptic/type s/Int} (identity 42)

Attribution

Skeptic's cast semantics — how directional checks between inferred and declared types assign responsibility for a mismatch — are informed by the polymorphic blame calculus and runtime cast algorithm from:

Amal Ahmed, Robert Bruce Findler, Jeremy G. Siek, and Philip Wadler. Blame for All. In Proceedings of the 38th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL), Austin, TX, USA, January 2011. ACM. https://doi.org/10.1145/1926385.1926409

The key takeaway is that Any is not treated as some sort of Top or Object type. Any (and internally, Dyn) is treated as a Dynamic type that should be treated as typechecking anything, until a more precise form can be inferred. This is what lets Skeptic work with dynamic code without requiring users to add in a library of type hints: proveable inconsistencies are flagged without required proof of consistency.

Building from source

To run an unreleased version of the plugin from a local checkout:

  1. Running script/install-local.sh we delete old versions in your .m2 cache, install the Skeptic library, then install the lein-skeptic plugin. This will ensure a clean run, but careful if you are running multiple versions.
  2. Otherwise, run lein install in the skeptic/ directory first to install the core library, then again in the lein-skeptic/ directory to install the plugin.

License

The MIT License (MIT)

Can you improve this documentation? These fine people already did:
Michael A., github-actions[bot] & Michael Anderson
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close