README + tools-deps.md cover --tree-shake and --direct-link: what tree-shaking does (whole-program reachability over app + libraries + clojure.core, drop unreachable, drop the compiler for no-eval apps), and why it bails to keep-all when reachable code resolves vars by name at runtime (eval/resolve/ns-resolve/...), with the diagnostic output and how to make an app shakeable. Notes the Stalin soundness model.
8.4 KiB
deps.edn support — design notes
How Jolt loads pure-Clojure libraries from a deps.edn, and why it's built the
way it is. For how to use it, see building-and-deps.md.
Scope, decided up front:
- git + local deps only — no Maven/
~/.m2resolution. - pure
clj/cljc— anything needing the JVM won't load or run; expected. - no classpath abstraction —
requirejust needs to find a dep's namespaces; "the classpath" is an ordered list of source directories. - own resolver, own reader —
deps.ednis read by jolt's own reader, and git fetch/cache is a thin shell-out togit; no external package manager. - deps-agnostic runtime core — resolution is a CLI front-end concern, not a
runtime one. The runtime knows nothing about
deps.edn; it only consumes a list of source roots. The CLI resolves adeps.edninto those roots before running.
How resolution works
jolt.deps (jolt-core/jolt/deps.clj) reads deps.edn (jolt's own reader
parses the EDN), then walks :deps:
:git/url+:git/sha(+ optional:deps/root) → clone the sha into the git cache and contribute the checkout (or its:deps/rootsubdir);:local/root→ the path as-is;:mvn/*→ skipped with a warning;- anything else → ignored.
git resolution shells out to git through jolt.host/sh — git init + remote
add + fetch + reset at the requested sha. Clones land in a global, sha-immutable
cache ($JOLT_GITLIBS, else ~/.jolt/gitlibs) shared across projects, the
tools.gitlibs ~/.gitlibs model.
Each resolved dependency contributes its own :paths (default ["src"]) as
source roots; the walk is breadth-first so every top-level coordinate
registers before any transitive one — a top-level pin always wins, matching
tools.deps. The result is a de-duplicated, ordered list of directories.
Two tools.deps features are mirrored in reduced form. Aliases: :aliases
entries supply :extra-paths/:extra-deps (accumulate across the aliases
selected with -A:a:b) and :main-opts (last-wins, run with -M:alias).
Tasks: the honest subset of babashka's — a string task is a shell command, a
map task is {:main-opts […]}; bare Clojure expressions aren't a separate task
form.
How the CLI ties it together
jolt.main (jolt-core/jolt/main.clj) is the CLI dispatch. Driven by cli.ss,
it resolves the project (jolt.deps/resolve-project), prepends the resolved
roots, and de-sugars the argv into a run:
run -m NS args→ loadNS, call its-main;run FILE→ load the file;-M:alias→ run the alias's:main-opts;-A:alias→ add the alias's paths/deps, then run the rest;repl→ a line REPL;path→ print the resolved roots;build -m NS [-o OUT] [--opt|--dev]→ AOT-compile the app into a standalone binary;<task>→ run adeps.edn:tasksentry.
The resolver lives in the overlay alongside the runtime, but the runtime's only dependency interface is the list of source roots it's handed.
Native libraries
A library that binds C declares the shared objects it needs under :jolt/native,
so jolt.main loads them before the namespace is required and its foreign-fn
bindings resolve. Each entry is a map — {:name "sqlite3" :darwin ["libsqlite3.0.dylib" …] :linux ["libsqlite3.so.0" …]} — with optional
:optional true (absence is fine, a feature-gated dep) and :process true (use
the running process's own symbols, e.g. libc sockets, no external file). A
project inherits its dependencies' :jolt/native.
Standalone binaries
joltc build -m NS compiles the app and every library into one executable (the
runtime + compiler are baked in). It loads the resolved :jolt/native libs at
startup, so an FFI app — sockets, SQLite — runs with no jolt or Chez on the path.
Output goes under the project's target/, cargo-style: target/release/<project>
by default and with --opt, target/debug/<project> with --dev (the
<name>.build scratch dir sits beside it). -o PATH overrides — absolute as-is,
relative against the project dir. Paths resolve against the project (JOLT_PWD),
not the CLI's cwd, since bin/joltc runs from the jolt repo.
:jolt/build {:embed ["resources" …]} bakes those directories' files into the
binary; io/resource serves them from the image with no files on disk. Resources
not embedded resolve at runtime against JOLT_PWD (or the cwd), so the
ship-the-binary-with-its-resources/-dir model also works. Files read through
io/file (e.g. a config.edn a config library loads) stay external by design —
edit them without rebuilding.
A standalone build needs Chez's kernel dev files (libkernel.a, scheme.h) and
a C compiler; JOLT_CHEZ_CSV overrides the auto-detected csv<ver>/<machine>
dir. --opt turns on the inference/flatten/scalar-replace passes; the default
release mode is const-fold only.
--direct-link (or :jolt/build {:direct-link true}) opts into a closed world: a
call between the app's own functions binds to its target directly, skipping the var
lookup and generic dispatch a runtime call pays — at the cost of runtime
redefinition of those vars and eval/load-string. It's off by default, so
ordinary builds (including release and --opt) stay dynamically linked. A var
marked ^:redef or ^:dynamic stays indirect even under --direct-link, and calls
into clojure.core stay indirect in every mode.
Tree-shaking
--tree-shake (or :jolt/build {:tree-shake true}) ships only the code reachable
from -main. The build constructs one call graph spanning the app, every resolved
library, and the clojure.core/stdlib prelude, then keeps -main, every
side-effecting top-level form (so a defmethod/defrecord/protocol registration
keeps its targets live), and everything reachable from those — dropping the rest. A
reference counts whether it's a call or a value (#'x, a fn passed to map, a fn
stored in a map): any reference keeps its target live, so nothing reachable is ever
dropped. An app that never compiles at runtime (no reachable eval/load-string)
also drops the analyzer and back end from the binary. Typical savings are 1–2 MB;
behaviour is unchanged.
It bails — keeps everything — when reachable code resolves a var by name at
runtime (eval, resolve, ns-resolve, requiring-resolve, find-var,
intern, load-string, load-file). A static call graph can't follow a runtime
resolve, so dropping anything would be unsound. The build prints which definitions
forced the bail:
jolt build: tree-shake skipped (reachable code resolves vars at runtime):
selmer.filters/generate-json -> clojure.core/resolve
clojure.tools.logging/call-str -> clojure.core/ns-resolve
These are almost always libraries, not your code — resolve is how mature Clojure
libraries implement plugin systems and optional integrations (a logging backend
chosen at runtime, a template filter that lazily loads an optional dependency). On
the JVM that costs nothing; in a closed-world binary it defeats reachability. To make
an app tree-shakeable, keep runtime resolution off the reachable path: a backend
that's fixed on jolt can be referenced directly rather than resolved (the jolt
tools.logging port dropped the JVM's dynamic factory selection for exactly this),
and an optional integration you don't use can be dropped or hard-wired. Unreached
resolve-using code is shaken away like anything else — only resolution on the live
path triggers the bail.
The closed-world soundness model follows Stalin's dead-code analysis: in a program
with no eval, a definition is live iff it is referenced (called or as a value) from
a root, transitively.
Limitations
- Pure
clj/cljconly — JVM interop, host classes, and unimplementedclojure.corecorners fail. Coverage is per-function: a namespace can load with most functions working and a few not. - Source only; compiled
.classfiles in a git dep are ignored. - git
:git/shamust be a full SHA (git fetchcan't resolve a short one).
Conformance
The known-working libraries (see libraries.md) and the
examples exercise real pure-cljc git
libraries end to end — resolving them from git, loading their namespaces, and
running sample calls. A library fails when it relies on something Jolt doesn't
provide — JVM interop, or a regex feature like Unicode property classes
(\p{…}).