Compare commits

...

2 Commits

Author SHA1 Message Date
Chris Truter
48026e557a Tie plugin modules to their directories, and find plugin tests
A file under modules/X now belongs to module/X whatever its namespace says, and a config test fails when a
namespace resolves to a plugin from outside its directory, or a file in the directory resolves elsewhere.
dev.deps-graph infers namespaces for files under a plugin's src and test roots, so test selection finds plugin
tests. A module inside a plugin's own subtree with :uses :any still counts as that plugin's dependent.
2026-09-17 12:21:38 -04:00
Chris Truter
60d2407b5c Make separately built plugins modules of their own
A plugin under modules/X is now module/X, implying the namespace prefix metabase-module.X, the way enterprise/X
implies metabase-enterprise.X. Nothing under src/ or enterprise/ can use that namespace, so a plugin's files
can't shadow core ones, and core code can't land inside a plugin's prefix. The in-process embedder moves from
enterprise/search.embeddings.embedder and metabase-enterprise.embedder to module/embedder and
metabase-module.embedder; its directory was already outside enterprise/.

A plugin may be missing at runtime, so only its own subtree may use it. The resolver refuses it to anyone else,
:uses :any included, and mage's CI selection no longer counts :any modules as its dependents. Every file in a
plugin's directory maps to it.

The directory rule lives in hooks.common.modules/module-directory, shared by mage, dev.deps-graph, the module
explorer and the test runner. The runtime log attribution in metabase.util.log follows the same prefix rule.
CI's kondo run now lints the embedder, and the ratchet scan counts suppressions in each plugin's src and test
directories rather than whole plugin trees, so build copies under target/ never count twice.

A note in the resolver records the plan for drivers: module/driver.X, keeping metabase.driver.X in
modules/drivers/X, so driver-test selection can come from the module graph.
2026-09-17 07:00:35 -04:00
23 changed files with 343 additions and 119 deletions

View File

@@ -4131,12 +4131,6 @@
:api #{metabase-enterprise.embeddings.client}
:uses #{enterprise/search.semantic}}
enterprise/search.embeddings.embedder
{:team "Metabot"
:ns-prefix "metabase-enterprise.embedder"
:api #{}
:uses #{search.embeddings util util.classloader}}
enterprise/search.entity-retrieval
{:team "Metabot"
:ns-prefix "metabase-enterprise.entity-retrieval"
@@ -4440,7 +4434,12 @@
util
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Database :model/Field :model/Table}}}
:model-imports #{:model/Database :model/Field :model/Table}}
module/embedder
{:team "Metabot"
:api #{}
:uses #{search.embeddings util util.classloader}}}
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals
;; aren't allowed in EDN just used the `(str <regex>)` version i.e. two slashes instead of one.

View File

@@ -110,11 +110,11 @@
(when-not (contains? (hooks.common/ignored-linters ns-symb-node) :metabase/namespace-name)
(let [parts (str/split (str (hooks/sexpr ns-symb-node)) #"\.")]
(when (and (<= (count parts) 2)
(#{"metabase" "metabase-enterprise"} (first parts))
(#{"metabase" "metabase-enterprise" "metabase-module"} (first parts))
;; exclude test namespaces for now
(not (str/ends-with? (last parts) "test")))
(hooks/reg-finding! (assoc (meta ns-symb-node)
:message "Metabase namespaces should have the form metabase[-enterprise].<module>.* [:metabase/namespace-name]"
:message "Metabase namespaces should have the form metabase[-enterprise|-module].<module>.* [:metabase/namespace-name]"
:type :metabase/namespace-name)))))))
(defn- ns-form-node->import-node [ns-form-node]

View File

@@ -21,12 +21,30 @@
;;;; Dots express nesting: `lib.schema` is a child of `lib`. A declared
;;;; `enterprise/X` module is also a child of OSS module `X`. A namespace
;;;; belongs to the module with the most specific matching `:ns-prefix`.
;;;;
;;;; A `module/X` module is a plugin built from `modules/X`, shipped as its own jar and possibly absent at
;;;; runtime, so only its own subtree may use it.
;; TODO (Chris 2026-09-17) -- Driver plugins are not modules yet. The plan is `module/driver.X`, implying
;; `metabase.driver.X` in `modules/drivers/X`, so drivers and the third-party plugins built like them keep
;; their namespaces. With each driver a module, "should this change run driver tests" can come from the module
;; graph instead of the override list in `mage.modules`. The drivers' `metabase.test.data.X` helpers will need
;; resolving by directory, or a rename.
(defn plugin-module?
"Whether `module` is a plugin, built from `modules/<name>` and shipped as its own jar."
[module]
(= "module" (namespace module)))
(defn default-ns-prefix
"Namespace prefix a module owns unless it sets `:ns-prefix`: `metabase.lib.schema` for `lib.schema`,
`metabase-enterprise.foo` for `enterprise/foo`."
`metabase-enterprise.foo` for `enterprise/foo`, `metabase-module.embedder` for `module/embedder`."
[module]
(str (if (= "enterprise" (namespace module)) "metabase-enterprise." "metabase.") (name module)))
(str (case (namespace module)
"enterprise" "metabase-enterprise."
"module" "metabase-module."
"metabase.")
(name module)))
(defn module-ns-prefix
"Namespace prefix `module` owns: its `:ns-prefix`, else [[default-ns-prefix]]."
@@ -34,6 +52,21 @@
(or (get-in modules [module :ns-prefix])
(default-ns-prefix module)))
(defn module-directory
"Repo-relative directory holding `module`'s namespaces in `tree`, `\"src\"` or `\"test\"`, e.g.
`src/metabase/lib/schema`, `enterprise/backend/test/metabase_enterprise/sso` or
`modules/embedder/src/metabase_module/embedder`."
[modules module tree]
(let [ns-prefix (module-ns-prefix modules module)]
(str (cond
(plugin-module? module)
(str "modules/" (first (str/split (name module) #"\.")) "/")
(str/starts-with? ns-prefix "metabase-enterprise.")
"enterprise/backend/")
tree "/"
(-> ns-prefix (str/replace "." "/") (str/replace "-" "_")))))
(defn build-prefix->module
"Map each declared namespace prefix to its module, for [[declared-module]]."
[modules]
@@ -56,6 +89,7 @@
(resolve-module prefix->module 'metabase.qp.middleware.wow) => 'qp
(resolve-module prefix->module 'metabase-enterprise.whatever.core) => enterprise/whatever
(resolve-module prefix->module 'metabase-module.whatever.core) => module/whatever
An unmatched Metabase namespace resolves to its first segment so the linter can report an undeclared module."
[prefix->module ns-symb]
@@ -69,6 +103,9 @@
(some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
second
(symbol "enterprise"))
(some->> (re-find #"^metabase-module\.([^.]+)" (str ns-symb))
second
(symbol "module"))
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
second
symbol))))
@@ -119,17 +156,37 @@
(recur ancestor)
{:ancestor ancestor, :child child}))))
(defn plugin-root
"The top-level plugin `module` belongs to, e.g. `module/embedder` for `module/embedder.model`.
Returns `nil` when `module` is no plugin."
[modules module]
(when (plugin-module? module)
(last (take-while some? (iterate #(parent-module modules %) module)))))
(defn- plugin-error
"Explain why `caller` may not use plugin `target`, or return `nil` when `target` is no plugin or `caller`
sits inside the plugin."
[modules caller target]
(when-let [plugin (plugin-root modules target)]
(when-not (descendant-of? modules caller plugin)
(format (str "Module %s is a plugin that may not be installed; %s may not use it. Load it through "
"metabase.plugins instead. [:metabase/modules %s :uses]")
target caller caller))))
(defn namability-error
"Explain why `caller` may not refer to `target` in `:uses`, or return `nil` if it may.
A nested module is private to the nearest ancestor with a missing export.
Exporting every link makes it public."
Exporting every link makes it public.
A plugin is private to itself, since it may be missing at runtime."
[modules caller target]
(when-let [{:keys [ancestor child]} (blocking-export modules target)]
(when-not (descendant-of? modules caller ancestor)
(format (str "Module %s is nested and not exported by its ancestors; %s may not use it. Add %s to %s's "
":module-exports, or move the caller into the %s subtree. [:metabase/modules %s :module-exports]")
target caller child ancestor ancestor ancestor))))
(or (plugin-error modules caller target)
(when-let [{:keys [ancestor child]} (blocking-export modules target)]
(when-not (descendant-of? modules caller ancestor)
(format (str "Module %s is nested and not exported by its ancestors; %s may not use it. Add %s to "
"%s's :module-exports, or move the caller into the %s subtree. "
"[:metabase/modules %s :module-exports]")
target caller child ancestor ancestor ancestor)))))
;;;; Lint rules. These functions take the full linter config.

View File

@@ -98,6 +98,9 @@ interactive explorer with a dependency graph.
target's `:api`.
- Each `:module-exports` entry widens a nested module's visibility by one ancestor. Export every link to
make it available everywhere. OSS module `X` exports its `enterprise/X` companion automatically.
- A `module/X` module is a plugin built from `modules/X` and shipped as its own jar, with namespaces under
`metabase-module.X`. It may be missing at runtime, so only its own subtree may use it, and `:uses :any` does not
reach it; the rest of the backend loads it through `metabase.plugins`.
## Ratchets

View File

@@ -576,6 +576,7 @@
"bin/load-namespaces/test"
"bin/release-list/src"
"bin/release-list/test"
"modules/embedder/src"
"modules/drivers/athena/src"
"modules/drivers/athena/test"
"modules/drivers/bigquery-cloud-sdk/src"

View File

@@ -76,15 +76,19 @@
^java.io.File []
(io/file (str (.getAbsolutePath (project-root-directory)) "/enterprise/backend/src")))
(declare kondo-config)
(mu/defn- plugin-source-roots :- [:sequential (ms/InstanceOfClass java.io.File)]
"Source roots of the separately built plugin modules under `modules/`: every driver, plus the siblings that
ship as their own jar. They are off the main classpath, so nothing else here would find them."
"Source roots of the separately built plugins under `modules/`: every driver, plus `modules/X/src` for each
declared `module/X`. They are off the main classpath, so nothing else here would find them."
[]
(let [modules (io/file (str (.getAbsolutePath (project-root-directory)) "/modules"))]
(concat
(for [driver (.listFiles (io/file modules "drivers"))]
(io/file driver "src"))
[(io/file modules "embedder" "src")])))
(for [module (keys (kondo-config))
:when (modules/plugin-module? module)]
(io/file modules (name module) "src")))))
(mu/defn- find-source-files :- [:sequential (ms/InstanceOfClass java.io.File)]
[]
@@ -278,8 +282,6 @@
{:file file}
e)))))
(declare kondo-config)
(comment
(file-dependencies (modules/build-prefix->module (kondo-config)) "src/metabase/app_db/setup.clj")
;; should ignore the entries from [[ignored-dependencies]]
@@ -653,6 +655,7 @@
[file]
(-> file
file->path-relative-to-project-root
(str/replace #"^modules/(?:drivers/)?[^/]+/(?=(?:src|test)/)" "")
(str/replace #"^enterprise/backend/" "")
(str/replace #"^(?:(?:src)|(?:test))/" "")
(str/replace #"\.clj[cs]?$" "")
@@ -731,10 +734,7 @@
[".clj" ".cljc" ".cljs" ".bb"])
(defn- module->test-path-prefix [modules-config module]
(let [ns-prefix (modules/module-ns-prefix modules-config module)]
(str (when (str/starts-with? ns-prefix "metabase-enterprise.") "enterprise/backend/")
"test/"
(-> ns-prefix (str/replace "." "/") (str/replace "-" "_")))))
(modules/module-directory modules-config module "test"))
(defn- existing-test-file-paths [path-prefix]
(into (sorted-set)

View File

@@ -281,8 +281,27 @@
(known-linter-hint))
{:unknown (vec unknown)}))))
(def ^:private source-roots
["src" "test" "enterprise" "modules/drivers" "dev" "bin" "mage"])
(defn plugin-source-roots
"Each plugin's `src` and `test` directories under `modules/`, drivers included, relative to `repo-root`.
A plugin's other directories hold no code of its own, and a local build copies its sources into `target/`,
which would otherwise count twice."
([]
(plugin-source-roots "."))
([repo-root]
(let [subdirs (fn [parent]
(->> (.listFiles (io/file repo-root parent))
(filter #(.isDirectory ^java.io.File %))
(map #(str parent "/" (.getName ^java.io.File %)))
sort))]
(for [plugin (concat (remove #{"modules/drivers"} (subdirs "modules"))
(subdirs "modules/drivers"))
tree ["src" "test"]
:let [root (str plugin "/" tree)]
:when (.isDirectory (io/file repo-root root))]
root))))
(defn- source-roots []
(into ["src" "test" "enterprise" "dev" "bin" "mage"] (plugin-source-roots)))
(def ^:private source-extensions
[".clj" ".cljc" ".cljs"])
@@ -461,7 +480,7 @@
Returns `{:file \"src/...\", :line 42, :linters [...], :justified? boolean}` maps.
Forms inside string literals or line comments don't count."
([]
(scan source-roots))
(scan (source-roots)))
([roots]
(for [root roots
^java.io.File f (file-seq (io/file root))

View File

@@ -19,14 +19,15 @@
"The path segments used to display `module` in the tree.
An enterprise module appears under its OSS counterpart when one exists.
Otherwise its full `enterprise/` name appears at the root."
Otherwise its full `enterprise/` name appears at the root, as a `module/` plugin's always does."
[modules-config module]
(let [segments (str/split (name module) #"\.")]
(if (= (namespace module) "enterprise")
(if (contains? modules-config (symbol (first segments)))
;; Keep an enterprise subtree together under its OSS module.
(into [(first segments) "enterprise"] (rest segments))
(into [(str "enterprise/" (first segments))] (rest segments)))
(case (namespace module)
"enterprise" (if (contains? modules-config (symbol (first segments)))
;; Keep an enterprise subtree together under its OSS module.
(into [(first segments) "enterprise"] (rest segments))
(into [(str "enterprise/" (first segments))] (rest segments)))
"module" (into [(str "module/" (first segments))] (rest segments))
segments)))
(defn explicit-ns-prefix
@@ -36,13 +37,6 @@
(when (and prefix (not= prefix (modules/default-ns-prefix module)))
prefix)))
(defn- ns-prefix->source-dir
"Repo-relative source directory for a namespace prefix: `metabase.lib-be` -> `src/metabase/lib_be`."
[ns-prefix]
(str (when (str/starts-with? ns-prefix "metabase-enterprise.") "enterprise/backend/")
"src/"
(-> ns-prefix (str/replace "." "/") (str/replace "-" "_"))))
(defn- set->strings
"Sort and stringify a set-valued config field."
[x]
@@ -88,7 +82,7 @@
"One module's config as the plain data the page consumes."
[modules-config module->uses used-by module]
(let [{:keys [api uses friends] :as entry} (get modules-config module)
source-dir (ns-prefix->source-dir (modules/module-ns-prefix modules-config module))]
source-dir (modules/module-directory modules-config module "src")]
{:id (str module)
:enterprise (= (namespace module) "enterprise")
:team (modules/module-team modules-config module)
@@ -108,7 +102,7 @@
;;; Per-module source metrics, from tracked files and git history.
(def ^:private source-dirs ["src" "enterprise/backend/src" "test" "enterprise/backend/test" "modules/drivers"])
(def ^:private source-dirs ["src" "enterprise/backend/src" "test" "enterprise/backend/test" "modules"])
(defn- git-output!
"Run Git and return stdout, or throw with its stderr when it fails."
@@ -123,7 +117,8 @@
str/split-lines
(filter #(re-find #"\.clj[cs]?$" %))))
(def ^:private source-root #"^(?:enterprise/backend/)?(?:src|test)/|^modules/drivers/[^/]+/(?:src|test)/")
(def ^:private source-root
#"^(?:enterprise/backend/)?(?:src|test)/|^modules/drivers/[^/]+/(?:src|test)/|^modules/[^/]+/(?:src|test)/")
(defn- file->ns
"The namespace a source or test file declares, going by its path."

View File

@@ -40,13 +40,19 @@
;;;; Ordering — must match `metabase.core.modules-test`
;;;; ---------------------------------------------------------------------------
(defn- enterprise? [module-name]
(str/starts-with? (str module-name) "enterprise/"))
(defn- module-group
"Where `module-name` sorts: OSS modules first, then `enterprise/` ones, then `module/` plugins."
[module-name]
(let [s (str module-name)]
(cond
(str/starts-with? s "enterprise/") 1
(str/starts-with? s "module/") 2
:else 0)))
(defn- sort-module-names
"Sort module symbols with `enterprise/` modules last. Mirrors the test's `sort-module-names`."
"Sort module symbols by [[module-group]], then by name. Mirrors the test's `sort-module-names`."
[module-names]
(sort-by (juxt #(if (enterprise? %) 1 0) str) module-names))
(sort-by (juxt module-group str) module-names))
(defn- sort-for-key
"Return the sorted element vector for a generated key. `:uses` sorts modules enterprise-last; everything
@@ -246,7 +252,7 @@
(str/join ", " to-add)))
(not sorted?)
(conj "Modules are not sorted by name (enterprise/ last) — reorder them by hand."))))
(conj "Modules are not sorted by name (enterprise/, then module/, last) — reorder them by hand."))))
;;;; ---------------------------------------------------------------------------
;;;; Entry point

View File

@@ -153,6 +153,21 @@
:extra 1}
(kondo-ratchet/actual-counts occurrences))))))
(deftest ^:parallel plugin-source-roots-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"plugin-roots"
(make-array java.nio.file.attribute.FileAttribute 0)))]
(doseq [path ["modules/embedder/src"
"modules/embedder/resources"
"modules/embedder/target/classes"
"modules/drivers/mongo/src"
"modules/drivers/mongo/test"
"modules/drivers/mongo/target"]]
(.mkdirs (io/file dir path)))
(testing "plugins and drivers contribute their src and test directories, never target or resources"
(is (= ["modules/embedder/src" "modules/drivers/mongo/src" "modules/drivers/mongo/test"]
(kondo-ratchet/plugin-source-roots (.getPath dir)))))))
(deftest ^:parallel scan-error-identifies-file-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-error-test"

View File

@@ -87,17 +87,18 @@
modules'))))
(defn- sort-module-names
"Sort module names in order but sort the `enterprise/` modules last."
"Sort module names in order, with `enterprise/` modules after the OSS ones and `module/` plugins last."
[module-names]
(sort-by (fn [module-name]
[(if (str/starts-with? module-name "enterprise/")
1
0)
[(cond
(str/starts-with? module-name "enterprise/") 1
(str/starts-with? module-name "module/") 2
:else 0)
module-name])
module-names))
(deftest modules-should-be-sorted-by-name-test
(testing "Modules configs should sorted by module name with enterprise/modules appearing last"
(testing "Modules are sorted by name, with enterprise/ modules and then module/ plugins last"
(let [actual (module-names-in-file-order)
expected (sort-module-names actual)]
(is (= expected
@@ -189,6 +190,24 @@
(testing (format "Remove %s from %s" (pr-str extraneous) (pr-str ks))
(is (empty? extraneous)))))))
(deftest plugin-namespaces-live-in-their-plugin-directory-test
(testing "a namespace resolves to plugin module/X exactly when its file sits under modules/X/"
(let [config (dev.deps-graph/kondo-config)]
(doseq [{:keys [filename module]} (dev.deps-graph/dependencies)
:let [file (#'dev.deps-graph/file->path-relative-to-project-root filename)
directory (second (re-find #"^modules/(?!drivers/)([^/]+)/" file))
plugin (some->> module (modules/plugin-root config))]
:when (or directory plugin)]
(testing (format "\n%s resolves to %s" file (pr-str module))
(is (= directory (some-> plugin name))))))))
(deftest ^:parallel plugin-files-name-their-namespaces-test
(is (= '[metabase-module.embedder.plugin metabase-module.embedder.plugin-test metabase.driver.mongo]
(map #'dev.deps-graph/file->namespace
["modules/embedder/src/metabase_module/embedder/plugin.clj"
"modules/embedder/test/metabase_module/embedder/plugin_test.clj"
"modules/drivers/mongo/src/metabase/driver/mongo.clj"]))))
(deftest ^:parallel uses-references-must-be-namable-test
(testing "every module named in :uses is visible to the caller"
(let [config (dev.deps-graph/kondo-config)]
@@ -256,8 +275,10 @@
(testing "`:module` resolves through each module's :ns-prefix, so dotted and renamed modules find their tests"
(is (= ["test/metabase/lib/schema"
"test/metabase/actions_rest"
"enterprise/backend/test/metabase_enterprise/transforms_python"]
(metabase.test-runner/module-folders '[lib.schema actions.rest enterprise/transforms.python])))))
"enterprise/backend/test/metabase_enterprise/transforms_python"
"modules/embedder/test/metabase_module/embedder"]
(metabase.test-runner/module-folders
'[lib.schema actions.rest enterprise/transforms.python module/embedder])))))
(deftest do-not-use-rest-modules-in-other-modules-test
(doseq [[module {:keys [uses], :as _config}] (dev.deps-graph/kondo-config)

View File

@@ -10,8 +10,8 @@
(set! *warn-on-reflection* true)
(def ^:private lint-roots
["src" "test" "enterprise/backend" "modules/drivers" "dev" "bin" "mage"])
(defn- lint-roots []
(into ["src" "test" "enterprise/backend" "dev" "bin" "mage"] (kondo-ratchet/plugin-source-roots)))
(defn- kondo-findings!
"Kondo run over `roots`, optionally with `linter` forced to `:warning`; returns findings as EDN maps
@@ -337,10 +337,10 @@
An `--audit` removal that sticks takes its stale marker comment with it."
[parsed]
;; Without this, hooks reading the analysis cache report nothing and their ignores all look redundant.
(kondo/warm-cache! lint-roots)
(kondo/warm-cache! (lint-roots))
(println "Running kondo with :redundant-ignore enabled (full lint, takes a minute or two)...")
(let [audit? (get-in parsed [:options :audit])
findings (kondo-findings! :redundant-ignore lint-roots)
findings (kondo-findings! :redundant-ignore (lint-roots))
{located true, homeless false} (group-by #(some? (:row %)) findings)
file-lines (memoize (fn [file] (vec (str/split-lines (slurp file)))))
{lsp-only true, verifiable false}
@@ -478,9 +478,9 @@
(when (str/blank? (str linter-arg))
(throw (ex-info "Usage: ./bin/mage kondo-insert-ignores LINTER [PATHS...]" {:exit-code 1})))
;; Without this, a cache-reading hook linter finds no sites at all.
(kondo/warm-cache! lint-roots)
(kondo/warm-cache! (lint-roots))
(let [linter (keyword (str/replace-first linter-arg #"^:" ""))
roots (or (seq paths) lint-roots)
roots (or (seq paths) (lint-roots))
_ (println (format "Running kondo with %s enabled over %s..." linter (str/join " " roots)))
findings (kondo-findings! linter roots)
by-file (group-by :filename findings)]

View File

@@ -14,6 +14,8 @@
(def ^:dynamic ^:private *github-output-only?* false)
;; TODO (Chris 2026-09-17) -- Once drivers are `module/driver.X` modules (see `hooks.common.modules`), which
;; driver tests a change needs can follow from the module graph, and these lists can go.
(def default-modules-which-trigger-drivers
"Modules that, when affected by changes, should trigger driver tests."
'#{driver transforms})
@@ -23,25 +25,41 @@
'#{query-processor transforms
enterprise/transforms enterprise/transforms.python})
(def ^:private plugin-root-pattern
"A plugin's own source or test root under `modules/`, such as `modules/embedder/src/`. Drivers are excluded:
they are not modules, and resolving them would claim them for `driver`."
#"^modules/(?!drivers/)[^/]+/(?:src|test)/")
;;; TODO (Cam 2025-11-07) changes to test files should only cause us to run tests for that module as well, not
;;; everything that depends on that module directly or indirectly in `src`
(defn- file->ns-symbol [filename]
(when (re-find #"^(?:(?:src|test)/metabase|enterprise/backend/(?:src|test)/metabase_enterprise)/" filename)
(-> filename
(str/replace #"^(?:enterprise/backend/)?(?:src|test)/" "")
(str/replace #"\.[^./]+$" "")
(str/replace "/" ".")
(str/replace "_" "-")
symbol)))
(let [filename (str/replace filename plugin-root-pattern "src/")]
(when (re-find #"^(?:(?:src|test)/metabase(?:_enterprise|_module)?|enterprise/backend/(?:src|test)/metabase_enterprise)/"
filename)
(-> filename
(str/replace #"^(?:enterprise/backend/)?(?:src|test)/" "")
(str/replace #"\.[^./]+$" "")
(str/replace "/" ".")
(str/replace "_" "-")
symbol))))
(defn- file->module [prefix->module filename]
(or
;; A file in a plugin's directory belongs to that plugin, whatever its namespace says.
;; The module config tests reject the mismatch.
(when-let [[_match plugin] (re-matches #"^modules/(?!drivers/)([^/]+)/.*$" filename)]
(let [root (symbol "module" plugin)
by-ns (some->> (file->ns-symbol filename) (modules/declared-module prefix->module))
config (zipmap (vals prefix->module) (repeat {}))]
(if (and by-ns (modules/descendant-of? config by-ns root)) by-ns root)))
(some->> (file->ns-symbol filename) (modules/declared-module prefix->module))
;; otherwise a file inside a directory belongs to the (undeclared) module that directory names
(when-let [[_match module] (re-matches #"^(?:(?:src)|(?:test))/metabase/([^/]+)/.*$" filename)]
(symbol (str/replace module #"_" "-")))
(when-let [[_match module] (re-matches #"^enterprise/backend/(?:(?:src)|(?:test))/metabase_enterprise/([^/]+)/.*$" filename)]
(symbol "enterprise" (str/replace module #"_" "-")))))
(symbol "enterprise" (str/replace module #"_" "-")))
(when-let [[_match module] (re-matches #"^(?:src|test)/metabase_module/([^/]+)/.*$" filename)]
(symbol "module" (str/replace module #"_" "-")))))
(defn- read-modules-config []
(-> (with-open [r (java.io.PushbackReader. (java.io.FileReader. ".clj-kondo/config/modules/config.edn"))]
@@ -63,10 +81,7 @@
[".clj" ".cljc"])
(defn- module->test-path-prefix [modules-config module]
(let [ns-prefix (modules/module-ns-prefix modules-config module)]
(str (when (str/starts-with? ns-prefix "metabase-enterprise.") "enterprise/backend/")
"test/"
(-> ns-prefix (str/replace "." "/") (str/replace "-" "_")))))
(modules/module-directory modules-config module "test"))
(defn- module->test-paths [modules-config module]
(let [prefix->module (modules/build-prefix->module modules-config)
@@ -104,8 +119,12 @@
[deps module]
(into (sorted-set)
(keep (fn [[a-module module-deps]]
(when (or (= module-deps :any)
(contains? module-deps module))
;; `:any` reaches a plugin, which may not be installed, only from inside that plugin.
(when (if (= module-deps :any)
(if-let [plugin (modules/plugin-root deps module)]
(modules/descendant-of? deps a-module plugin)
true)
(contains? module-deps module))
a-module)))
deps))
@@ -280,12 +299,13 @@
(keys modules-config)))
(defn- sorted-children
"Sort child nodes alphabetically, with enterprise nodes last."
"Sort child nodes alphabetically, with enterprise nodes and then `module/` plugins last."
[node]
(sort-by (fn [[segment _]] [(if (or (= segment "enterprise")
(str/starts-with? segment "enterprise/"))
1
0)
(sort-by (fn [[segment _]] [(cond
(or (= segment "enterprise")
(str/starts-with? segment "enterprise/")) 1
(str/starts-with? segment "module/") 2
:else 0)
segment])
(:children node)))

View File

@@ -107,7 +107,7 @@
;; namespaces will be converted to their file paths, so this check will work.
(def ^:private namespace-prefixes
["metabase." "metabase-enterprise." "hooks."])
["metabase." "metabase-enterprise." "metabase-module." "hooks."])
(defn- namespace-like? [s]
(some #(str/starts-with? s %) namespace-prefixes))

View File

@@ -411,14 +411,32 @@
(deftest explorer-file->module-test
(let [file->module #'module-explorer/file->module
prefix->module (modules/build-prefix->module '{lib {} lib.schema {} driver {}})]
prefix->module (modules/build-prefix->module
'{lib {}
lib.schema {}
driver {}
module/embedder {}})]
(testing "files resolve through the declared prefixes, tests included"
(is (= 'lib.schema (file->module prefix->module "src/metabase/lib/schema/join.cljc")))
(is (= 'lib.schema (file->module prefix->module "test/metabase/lib/schema_test.cljc")))
(is (= 'lib (file->module prefix->module "src/metabase/lib/core.cljc"))))
(testing "driver-plugin files belong to the driver module regardless of namespace"
(is (= 'driver (file->module prefix->module "modules/drivers/mysql/src/metabase/driver/mysql.clj")))
(is (= 'driver (file->module prefix->module "modules/drivers/mysql/test/metabase/test/data/mysql.clj"))))))
(is (= 'driver (file->module prefix->module "modules/drivers/mysql/test/metabase/test/data/mysql.clj"))))
(testing "other plugin files resolve through their namespace"
(is (= 'module/embedder
(file->module prefix->module "modules/embedder/src/metabase_module/embedder/model.clj"))))))
(deftest explorer-module->tree-path-test
(let [config '{search {}, enterprise/search {}, module/embedder {}, module/embedder.model {}}]
(is (= [["search" "enterprise"] ["module/embedder"] ["module/embedder" "model"]]
(map #(module-explorer/module->tree-path config %)
'[enterprise/search module/embedder module/embedder.model])))))
(deftest explorer-tracks-plugin-files-test
(testing "the explorer scans plugin directories, not just the drivers"
(is (some #(str/starts-with? % "modules/embedder/src/")
(#'module-explorer/tracked-source-files)))))
(deftest explorer-page-test
(let [html (module-explorer/page {:modules [{:id "</script>"}]})]
@@ -443,6 +461,45 @@
(is (= 'lib.schema
(#'mage.modules/file->module prefix->module "src/metabase/lib/schema/config.edn"))))))
(deftest plugin-files-resolve-to-their-module-test
(let [prefix->module (modules/build-prefix->module '{module/embedder {}, driver {}})]
(testing "a plugin's own source tree resolves like any other"
(is (= 'module/embedder
(#'mage.modules/file->module prefix->module
"modules/embedder/src/metabase_module/embedder/model.clj"))))
(testing "a file in a plugin's directory belongs to it even when its namespace names another module"
(is (= 'module/embedder
(#'mage.modules/file->module (modules/build-prefix->module '{module/embedder {}, core {}})
"modules/embedder/src/metabase/core.clj"))))
(testing "a plugin's other files belong to it too"
(is (= 'module/embedder
(#'mage.modules/file->module prefix->module "modules/embedder/deps.edn")))
(is (= 'module/embedder
(#'mage.modules/file->module prefix->module
"modules/embedder/resources/metabase/embedder/metabase-plugin.yaml"))))
(testing "an undeclared plugin still names its module"
(is (= 'module/other
(#'mage.modules/file->module prefix->module "modules/other/src/metabase_module/other/core.clj"))))
(testing "driver plugins are not claimed for the driver module"
(is (nil? (#'mage.modules/file->module prefix->module
"modules/drivers/mysql/src/metabase/driver/mysql.clj"))))))
(deftest plugin-paths-test
(testing "a plugin's tests live under its own modules/ directory"
(is (= "modules/embedder/test/metabase_module/embedder"
(#'mage.modules/module->test-path-prefix '{module/embedder {}} 'module/embedder)))))
(deftest uses-any-does-not-reach-plugins-test
(let [deps '{core :any
search #{module/embedder}
module/embedder #{}
module/embedder.model :any
util #{}}]
(is (= '{module/embedder #{module/embedder.model search}
util #{core module/embedder.model}}
{'module/embedder (#'mage.modules/direct-dependents deps 'module/embedder)
'util (#'mage.modules/direct-dependents deps 'util)}))))
(deftest top-level-files-belong-only-to-declared-modules-test
(testing "a file directly under metabase/ belongs to a module only when a declared prefix owns its namespace"
(let [prefix->module (modules/build-prefix->module '{driver {}})]

View File

@@ -5,4 +5,4 @@ info:
metabase-plugin-api-version: 1
init:
- step: load-namespace
namespace: metabase-enterprise.embedder.plugin
namespace: metabase-module.embedder.plugin

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.embedder.catalog
(ns metabase-module.embedder.catalog
"Immutable metadata for the model artifact bundled in the embedder plugin."
(:require
[clojure.edn :as edn]

View File

@@ -1,7 +1,7 @@
(ns metabase-enterprise.embedder.model
(ns metabase-module.embedder.model
"Lazy DJL/ONNX Runtime lifecycle for the bundled embedding models."
(:require
[metabase-enterprise.embedder.catalog :as catalog]
[metabase-module.embedder.catalog :as catalog]
[metabase.util.log :as log])
(:import
(ai.djl.huggingface.translator TextEmbeddingTranslatorFactory)

View File

@@ -1,7 +1,7 @@
(ns metabase-enterprise.embedder.plugin
(ns metabase-module.embedder.plugin
"Manifest entry point for the in-process embedding provider."
(:require
[metabase-enterprise.embedder.catalog :as catalog]
[metabase-module.embedder.catalog :as catalog]
[metabase.classloader.core :as classloader]
[metabase.embeddings.provider :as embeddings.provider]
[metabase.util.log :as log]))
@@ -50,8 +50,8 @@
;; `require` skips its classloader setup when the namespace is already loaded, so install it unconditionally for
;; callers running on thread pools that predate plugin initialization.
(classloader/the-classloader)
(classloader/require 'metabase-enterprise.embedder.model)
(or (ns-resolve 'metabase-enterprise.embedder.model fn-name)
(classloader/require 'metabase-module.embedder.model)
(or (ns-resolve 'metabase-module.embedder.model fn-name)
(throw (ex-info (format "Embedder plugin model implementation does not define %s." fn-name)
{:provider provider-name :fn fn-name}))))

View File

@@ -124,7 +124,10 @@
prefix->module (into {}
(map (fn [[module {:keys [ns-prefix]}]]
[(or ns-prefix
(str (if (= "enterprise" (namespace module)) "metabase-enterprise." "metabase.")
(str (case (namespace module)
"enterprise" "metabase-enterprise."
"module" "metabase-module."
"metabase.")
(name module)))
module]))
config)

View File

@@ -59,7 +59,16 @@
(is (= "metabase.lib.schema" (modules/default-ns-prefix 'lib.schema)))
(is (= "metabase.query-processor" (modules/default-ns-prefix 'query-processor)))
(is (= "metabase-enterprise.transforms" (modules/default-ns-prefix 'enterprise/transforms)))
(is (= "metabase-enterprise.transforms.python" (modules/default-ns-prefix 'enterprise/transforms.python))))
(is (= "metabase-enterprise.transforms.python" (modules/default-ns-prefix 'enterprise/transforms.python)))
(is (= "metabase-module.embedder" (modules/default-ns-prefix 'module/embedder))))
(deftest ^:parallel module-directory-test
(is (= ["src/metabase/lib/schema"
"enterprise/backend/test/metabase_enterprise/sso"
"modules/embedder/src/metabase_module/embedder"]
[(modules/module-directory {} 'lib.schema "src")
(modules/module-directory {} 'enterprise/sso "test")
(modules/module-directory {} 'module/embedder "src")])))
(deftest ^:parallel module-ns-prefix-explicit-override-test
(let [modules '{lib {}
@@ -363,6 +372,28 @@
'lib.other
'metabase.lib.schema.foo)))))))
(deftest ^:parallel usage-error-plugins-are-private-to-themselves-test
(let [config {:metabase/modules {'module/embedder {:api :any}
'module/embedder.model {:api :any :uses #{'module/embedder}}
'caller {:api :any :uses :any}
'explicit {:api :any :uses #{'module/embedder}}}}]
(testing "namespaces resolve to the plugin"
(is (= 'module/embedder (modules/module config 'metabase-module.embedder.plugin)))
(is (= 'module/other (modules/module config 'metabase-module.other.core))
"an undeclared plugin namespace names its plugin, so the linter can report it"))
(testing "neither `:uses :any` nor an explicit `:uses` reaches a plugin from outside"
(is (some? (modules/usage-error config 'caller 'metabase-module.embedder.plugin)))
(is (some? (modules/namability-error (:metabase/modules config) 'explicit 'module/embedder))))
(testing "the plugin's own subtree may use it"
(is (nil? (modules/namability-error (:metabase/modules config)
'module/embedder.model
'module/embedder))))))
(deftest ^:parallel plugin-root-test
(let [modules '{module/embedder {}, module/embedder.model {}, lib {}}]
(is (= '[module/embedder module/embedder nil]
(mapv #(modules/plugin-root modules %) '[module/embedder module/embedder.model lib])))))
(deftest ^:parallel usage-error-privacy-scoped-to-nearest-non-opening-ancestor-test
(testing "An unexported child is private to its nearest non-exporting ancestor's subtree, not the top-level one"
(let [config {:metabase/modules {'outer {:module-exports #{}

View File

@@ -71,13 +71,13 @@
(defn- check-platform-support!
"Which platforms the bundled natives claim to support, driven through the catalog's private probes."
[]
(let [libc-var (ns-resolve 'metabase-enterprise.embedder.catalog 'linux-libc)
os-var (ns-resolve 'metabase-enterprise.embedder.catalog 'operating-system)
arch-var (ns-resolve 'metabase-enterprise.embedder.catalog 'architecture)
detect-libc-var (ns-resolve 'metabase-enterprise.embedder.catalog 'detect-linux-libc)
(let [libc-var (ns-resolve 'metabase-module.embedder.catalog 'linux-libc)
os-var (ns-resolve 'metabase-module.embedder.catalog 'operating-system)
arch-var (ns-resolve 'metabase-module.embedder.catalog 'architecture)
detect-libc-var (ns-resolve 'metabase-module.embedder.catalog 'detect-linux-libc)
detect-libc (var-get detect-libc-var)
glibc-version-var (ns-resolve 'metabase-enterprise.embedder.catalog 'glibc-version)
supported-version-var (ns-resolve 'metabase-enterprise.embedder.catalog 'supported-glibc-version?)
glibc-version-var (ns-resolve 'metabase-module.embedder.catalog 'glibc-version)
supported-version-var (ns-resolve 'metabase-module.embedder.catalog 'supported-glibc-version?)
supported-version? (var-get supported-version-var)]
(is (= :unknown (detect-libc false nil)) "unreadable process maps fail closed")
(is (= :musl (detect-libc true nil)))
@@ -127,8 +127,8 @@
"A catalog entry with no export for this architecture must fail rather than hash a nil sha256 into a
well-formed but meaningless embedding-space id."
[requested-model]
(let [model-spec-var (ns-resolve 'metabase-enterprise.embedder.catalog 'model-spec)
arch-var (ns-resolve 'metabase-enterprise.embedder.catalog 'architecture)
(let [model-spec-var (ns-resolve 'metabase-module.embedder.catalog 'model-spec)
arch-var (ns-resolve 'metabase-module.embedder.catalog 'architecture)
spec ((var-get model-spec-var) (:model-name requested-model))
without-arch (update spec :architectures dissoc ((var-get arch-var)))]
(with-redefs-fn {model-spec-var (constantly without-arch)}
@@ -154,13 +154,13 @@
(is (embeddings.provider/registered? "in-process"))
(is (= "true" (System/getProperty "ai.djl.offline")))
(is (= "true" (System/getProperty "OPT_OUT_TRACKING")))
(is (str/starts-with? (str (io/resource "metabase_enterprise/embedder/plugin.clj")) "jar:file:"))
(is (nil? (find-ns 'metabase-enterprise.embedder.model))
(is (str/starts-with? (str (io/resource "metabase_module/embedder/plugin.clj")) "jar:file:"))
(is (nil? (find-ns 'metabase-module.embedder.model))
"manifest registration must not initialize the DJL model namespace")
(let [{:keys [readiness resolved]} (await-thread! pre-plugin-thread)]
(is (true? (:ready? readiness)))
(is (re-matches #"emb:v1:sha256:[0-9a-f]{64}" (:embedding-space-id resolved)))
(is (nil? (find-ns 'metabase-enterprise.embedder.model))
(is (nil? (find-ns 'metabase-module.embedder.model))
"catalog access from an old worker thread remains lazy")))
(testing "the built artifact contains a ready, immutable model space"
(is (true? (:ready? (embeddings.provider/readiness retrieval-model))))
@@ -172,10 +172,10 @@
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"has 384 dimensions"
(embeddings.provider/resolve-model
(assoc retrieval-model :vector-dimensions 768))))
(let [architecture-var (ns-resolve 'metabase-enterprise.embedder.catalog 'architecture)
os-var (ns-resolve 'metabase-enterprise.embedder.catalog 'operating-system)
libc-var (ns-resolve 'metabase-enterprise.embedder.catalog 'linux-libc)
glibc-version-var (ns-resolve 'metabase-enterprise.embedder.catalog 'glibc-version)
(let [architecture-var (ns-resolve 'metabase-module.embedder.catalog 'architecture)
os-var (ns-resolve 'metabase-module.embedder.catalog 'operating-system)
libc-var (ns-resolve 'metabase-module.embedder.catalog 'linux-libc)
glibc-version-var (ns-resolve 'metabase-module.embedder.catalog 'glibc-version)
resolve-for (fn [arch]
(with-redefs-fn {architecture-var (constantly arch)
os-var (constantly "linux")
@@ -191,7 +191,7 @@
(is (= normal bounded) "model identity ignores ambient printer bindings")
(is (apply not= normal)
"architecture-specific exports intentionally have distinct embedding spaces"))
(let [model-spec-var (ns-resolve 'metabase-enterprise.embedder.catalog 'model-spec)
(let [model-spec-var (ns-resolve 'metabase-module.embedder.catalog 'model-spec)
model-spec-fn (var-get model-spec-var)
spec (model-spec-fn (:model-name retrieval-model))
normal (embeddings.provider/resolve-model retrieval-model)
@@ -216,7 +216,7 @@
(:embedding-space-id resolved))
"the two bundled models must never share an embedding space")))
(testing "the artifact bounds local inference batches before loading the runtime"
(let [model-fn-var (ns-resolve 'metabase-enterprise.embedder.plugin 'model-fn)
(let [model-fn-var (ns-resolve 'metabase-module.embedder.plugin 'model-fn)
batch-sizes (atom [])]
(with-redefs-fn
{model-fn-var (constantly
@@ -225,7 +225,7 @@
(mapv (fn [_] (float-array 384)) texts)))}
#(embeddings.provider/embed-texts retrieval-model (repeat 65 "text")))
(is (= [32 32 1] @batch-sizes))
(is (nil? (find-ns 'metabase-enterprise.embedder.model)))))
(is (nil? (find-ns 'metabase-module.embedder.model)))))
(testing "real inference runs through the plugin jar"
(let [network-attempts (atom [])
original-proxy (ProxySelector/getDefault)]
@@ -235,7 +235,7 @@
(await-thread!
(isolated-thread-call
#(embeddings.provider/embed-texts retrieval-model ["dog" "puppy" "invoice"])))]
(is (some? (find-ns 'metabase-enterprise.embedder.model)))
(is (some? (find-ns 'metabase-module.embedder.model)))
(is (= [384 384 384] (mapv alength [dog puppy invoice])))
(is (> (cosine dog puppy) (cosine dog invoice))))
(testing "the complexity-score model infers from the same loaded artifact"

View File

@@ -107,11 +107,8 @@
(defn module-folders
[modules]
(let [config (-> (slurp ".clj-kondo/config/modules/config.edn") edn/read-string :metabase/modules)]
(for [m modules
:let [ns-prefix (hooks.modules/module-ns-prefix config m)]]
(str (when (str/starts-with? ns-prefix "metabase-enterprise.") "enterprise/backend/")
"test/"
(-> ns-prefix (str/replace "." "/") (str/replace "-" "_"))))))
(for [m modules]
(hooks.modules/module-directory config m "test"))))
(defn parse-options
[options]