deps: aliases, user config merge, tools.deps conflict semantics

deps.edn :aliases now work the tools.deps way, scoped to what jolt
supports (git/:local, no maven): :extra-paths and :extra-deps
accumulate across selected aliases, :main-opts is last-wins. The CLI
grows -A:dev:test (selects aliases for path/run/repl/-e) and
-M:alias (runs the alias :main-opts through jolt). A user-level
deps.edn ($JOLT_CONFIG, else $XDG_CONFIG_HOME/jolt, else ~/.jolt)
merges under the project file: :deps and :aliases merge per key with
the project winning, everything else replaces.

Resolution is now breadth-first so a top-level coordinate always
beats a transitive one for the same lib (it was DFS first-wins —
a dep's pin could shadow the project's own). Conflicting coordinates
for one lib warn on stderr with both coords and which won. Also
fixes dedup keying: it hashed the symbol struct's string repr, which
carries reader position metadata, so the same lib from two files
never deduped.

resolve-deps-cached keys on project edn + user edn + aliases.
Tests: deps-aliases-test and deps-conflicts-test, local deps only.
This commit is contained in:
Yogthos 2026-06-10 23:13:46 -04:00
parent f50fdad0ee
commit add80c3018
4 changed files with 358 additions and 44 deletions

View file

@ -0,0 +1,119 @@
# deps.edn aliases + user-level config merge (jolt-4go).
#
# Mirrors tools.deps semantics scoped to what jolt supports (git/:local, no
# maven): :aliases with :extra-paths / :extra-deps / :main-opts selected by
# keyword; a user deps.edn (under $JOLT_CONFIG, else $XDG_CONFIG_HOME/jolt,
# else ~/.jolt) merged UNDER the project file — :deps and :aliases merge per
# key with the project winning, :paths replaces. Local deps only: no network.
(import ../../src/jolt/deps :as deps)
(def base (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-deps-aliases-" (os/time)))
(defn rmrf [p]
(when (os/stat p)
(if (= :directory (os/stat p :mode))
(do (each e (os/dir p) (rmrf (string p "/" e))) (os/rmdir p))
(os/rm p))))
(rmrf base)
(defn mkdirs [p]
(def abs (string/has-prefix? "/" p))
(var acc nil)
(each seg (filter |(not= "" $) (string/split "/" p))
(set acc (cond (nil? acc) (if abs (string "/" seg) seg) (string acc "/" seg)))
(unless (os/stat acc) (os/mkdir acc))))
(each d ["proj/src" "proj/dev" "proj/test" "a/src" "c/src" "u/src" "config"]
(mkdirs (string base "/" d)))
# project: src + dep a; :dev alias adds dev/ + dep c; :test alias adds test/
(spit (string base "/proj/deps.edn")
`{:paths ["src"]
:deps {my/a {:local/root "../a"}}
:aliases {:dev {:extra-paths ["dev"]
:extra-deps {my/c {:local/root "../c"}}}
:test {:extra-paths ["test"]
:main-opts ["-e" "(run-tests)"]}
:bench {:main-opts ["-e" "(bench)"]}}}`)
(spit (string base "/a/deps.edn") `{:paths ["src"]}`)
(spit (string base "/c/deps.edn") `{:paths ["src"]}`)
(spit (string base "/u/deps.edn") `{:paths ["src"]}`)
# user-level config: a :user-tool alias the project file doesn't have, plus a
# :dev alias that the PROJECT's :dev must shadow (per-key merge, project wins)
(spit (string base "/config/deps.edn")
`{:aliases {:user-tool {:extra-deps {my/u {:local/root "BASE/u"}}}
:dev {:extra-paths ["should-not-win"]}}}`)
# :local/root in the user file is relative to... nothing useful; use absolute
(spit (string base "/config/deps.edn")
(string/replace "BASE" base (slurp (string base "/config/deps.edn"))))
(var fails 0)
(defn check [label got expected]
(if (= got expected) (print " ok " label)
(do (++ fails) (printf " FAIL %s: want %q, got %q" label expected got))))
(defn has-suffix-root [roots suff]
(truthy? (some |(string/has-suffix? suff $) roots)))
(os/cd (string base "/proj"))
(os/setenv "JOLT_CONFIG" (string base "/config"))
(def tree (string base "/proj/jpm_tree"))
# --- no aliases: plain resolution, dev/test/c absent ---------------------------
(def plain (deps/resolve-deps "deps.edn" tree))
(check "plain: project src present" (has-suffix-root plain "/proj/src") true)
(check "plain: dep a present" (has-suffix-root plain "/a/src") true)
(check "plain: alias path absent" (has-suffix-root plain "/proj/dev") false)
(check "plain: alias dep absent" (has-suffix-root plain "/c/src") false)
# --- :dev alias: extra-paths + extra-deps --------------------------------------
(def dev (deps/resolve-deps "deps.edn" tree [:dev]))
(check "dev: extra path present" (has-suffix-root dev "/proj/dev") true)
(check "dev: extra dep present" (has-suffix-root dev "/c/src") true)
(check "dev: base dep still present" (has-suffix-root dev "/a/src") true)
(check "dev: project :dev shadows user :dev"
(has-suffix-root dev "/proj/should-not-win") false)
# --- multiple aliases combine ---------------------------------------------------
(def both (deps/resolve-deps "deps.edn" tree [:dev :test]))
(check "multi: both extra paths"
(and (has-suffix-root both "/proj/dev") (has-suffix-root both "/proj/test")) true)
# --- alias from the USER deps.edn ----------------------------------------------
(def ut (deps/resolve-deps "deps.edn" tree [:user-tool]))
(check "user alias resolves its dep" (has-suffix-root ut "/u/src") true)
# --- main-opts: from alias, last alias wins ------------------------------------
(check "main-opts from alias"
(deps/alias-main-opts "deps.edn" [:test]) ["-e" "(run-tests)"])
(check "main-opts last alias wins"
(deps/alias-main-opts "deps.edn" [:test :bench]) ["-e" "(bench)"])
(check "main-opts absent is nil"
(deps/alias-main-opts "deps.edn" [:dev]) nil)
# --- cached resolution keys on aliases + user config ----------------------------
(check "cache: aliased result differs from plain"
(deep= (deps/resolve-deps-cached "deps.edn" tree)
(deps/resolve-deps-cached "deps.edn" tree [:dev]))
false)
(check "cache: same key returns same roots"
(deep= (deps/resolve-deps-cached "deps.edn" tree [:dev])
(deps/resolve-deps-cached "deps.edn" tree [:dev]))
true)
# --- unknown alias errors --------------------------------------------------------
(check "unknown alias errors"
(let [r (protect (deps/resolve-deps "deps.edn" tree [:nope]))] (r 0))
false)
# --- works without a user config (env unset) ------------------------------------
(os/setenv "JOLT_CONFIG" (string base "/no-such-dir"))
(check "no user config still resolves"
(has-suffix-root (deps/resolve-deps "deps.edn" tree) "/a/src") true)
(os/cd "/")
(rmrf base)
(if (> fails 0)
(error (string "deps-aliases-test: " fails " failing check(s)"))
(print "\nAll deps-aliases tests passed!"))

View file

@ -0,0 +1,69 @@
# deps.edn conflict semantics (jolt-42f), tools.deps-shaped:
# a TOP-LEVEL :deps entry beats any transitive coordinate for the same lib
# (resolution is breadth-first, top level enqueued first), and when two
# DIFFERENT coordinates for one lib meet, a warning naming both goes to stderr.
# Local deps only: no network.
(import ../../src/jolt/deps :as deps)
(def base (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-deps-conflicts-" (os/time)))
(defn rmrf [p]
(when (os/stat p)
(if (= :directory (os/stat p :mode))
(do (each e (os/dir p) (rmrf (string p "/" e))) (os/rmdir p))
(os/rm p))))
(rmrf base)
(defn mkdirs [p]
(def abs (string/has-prefix? "/" p))
(var acc nil)
(each seg (filter |(not= "" $) (string/split "/" p))
(set acc (cond (nil? acc) (if abs (string "/" seg) seg) (string acc "/" seg)))
(unless (os/stat acc) (os/mkdir acc))))
# proj depends on A and on B@b2 (top level). A depends on B@b1 (transitive).
# DFS-first-wins would pick b1; tools.deps semantics pick the top-level b2.
(each d ["proj/src" "a/src" "b1/src" "b2/src"] (mkdirs (string base "/" d)))
(spit (string base "/proj/deps.edn")
`{:paths ["src"]
:deps {my/a {:local/root "../a"}
my/b {:local/root "../b2"}}}`)
(spit (string base "/a/deps.edn")
`{:paths ["src"] :deps {my/b {:local/root "../b1"}}}`)
(spit (string base "/b1/deps.edn") `{:paths ["src"]}`)
(spit (string base "/b2/deps.edn") `{:paths ["src"]}`)
(var fails 0)
(defn check [label got expected]
(if (= got expected) (print " ok " label)
(do (++ fails) (printf " FAIL %s: want %q, got %q" label expected got))))
(defn has-suffix-root [roots suff]
(truthy? (some |(string/has-suffix? suff $) roots)))
(os/cd (string base "/proj"))
(def tree (string base "/proj/jpm_tree"))
(def warn-buf @"")
(def roots (with-dyns [:err warn-buf] (deps/resolve-deps "deps.edn" tree)))
(check "top-level coordinate wins" (has-suffix-root roots "/b2/src") true)
(check "transitive loser not on path" (has-suffix-root roots "/b1/src") false)
(check "dep a still resolved" (has-suffix-root roots "/a/src") true)
(check "conflict warning names the lib"
(truthy? (string/find "my/b" (string warn-buf))) true)
(check "conflict warning names both coordinates"
(and (truthy? (string/find "../b1" (string warn-buf)))
(truthy? (string/find "../b2" (string warn-buf)))) true)
# same coordinate twice from different parents: no warning
(spit (string base "/a/deps.edn")
`{:paths ["src"] :deps {my/b {:local/root "../b2"}}}`)
(def warn2 @"")
(with-dyns [:err warn2] (deps/resolve-deps "deps.edn" tree))
(check "agreeing coordinates warn nothing" (string warn2) "")
(os/cd "/")
(rmrf base)
(if (> fails 0)
(error (string "deps-conflicts-test: " fails " failing check(s)"))
(print "\nAll deps-conflicts tests passed!"))