From bd918ac9d21949af6468d80b22303d5ac8ca25e6 Mon Sep 17 00:00:00 2001 From: SerhiiRI Date: Tue, 4 Aug 2026 00:11:43 +0300 Subject: [PATCH 1/2] feat/execute-flow-control: expose execute's pipeline as data (steps-pipeline-default/execute-steps/with-execute-context) - ADDED api interface to build own/custom commando/execute - REFACTORED all old pipeline functions were renamed by adding step- prefix before the function name - UPDATED README.md by adding section about control-flow. --- CHANGELOG.md | 8 +- README.md | 64 ++++-- src/commando/core.cljc | 202 ++++++++++++++---- src/commando/debug.cljc | 6 +- src/commando/impl/utils.cljc | 4 +- test/unit/commando/core_test.cljc | 63 ++++-- test/unit/commando/impl/dependency_test.cljc | 30 +-- .../commando/impl/finding_commands_test.cljc | 14 +- test/unit/commando/impl/graph_test.cljc | 10 +- 9 files changed, 284 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea12f0..4847b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Added -ADDED `:hook-command-guard-outer-fn` / `:hook-command-guard-inner-fn` / `:hook-command-guard-all-fn` config keys to `commando/execute` — a pre-execution reject gate for validating an Instruction's structure per-call, before any command runs. Existing hooks (`:hook-execute-start`/`:hook-execute-end`) are pure observers whose return value is discarded, so there was no way to reject an instruction up front. A guard fn has the shape of an internal pipeline step — `(fn [status-map]) => status-map` — and runs once as a new `guard-commands` step right after `find-commands`. Outer/inner/all scope the check to call depth, since config is inherited by nested `execute` calls (`:commando/macro`, `:commando/resolve`) and an outer-only check must not also reject the library's own internal nested calls. +ADDED `:hook-command-guard-outer-fn` / `:hook-command-guard-inner-fn` / `:hook-command-guard-all-fn` config keys to `commando/execute` — a pre-execution reject gate for validating an Instruction's structure per-call, before any command runs. Existing hooks (`:hook-execute-start`/`:hook-execute-end`) are pure observers whose return value is discarded, so there was no way to reject an instruction up front. A guard fn has the shape of an internal pipeline step — `(fn [status-map]) => status-map` — and runs once as a new `step-guard-commands` step right after `step-find-commands`. Outer/inner/all scope the check to call depth, since config is inherited by nested `execute` calls (`:commando/macro`, `:commando/resolve`) and an outer-only check must not also reject the library's own internal nested calls. When defining your own guard fn: to reject, return the status-map wrapped with `commando.impl.status-map/status-map-handle-error` — anything else (including the status-map as-is) lets execution continue. @@ -18,6 +18,12 @@ Plain example: ADDED `commando.utils` namespace, with `hook-reject-commands-fn` — a convenience helper meant to be called from inside a `:hook-command-guard-*-fn`. Walks the already-computed `:internal/cm-list` (no extra instruction traversal) and calls a `{:command-type :path :value} -> error-map-or-nil` predicate per found command; every found command is checked, so multiple violations all end up in `:errors`, not just the first. +ADDED `commando.core/steps-pipeline-default` / `execute-steps` / `with-execute-context` — `execute`'s pipeline exposed as data (a vector of `{:step-name :step-fn :step-assert}` maps) instead of a hardcoded `->` chain, for stopping and resuming execution at a chosen step (e.g. to inspect `:internal/cm-running-order` for a UI/debug tool before commands actually run). The individual pipeline steps (`step-use-registry`, `step-find-commands`, `step-guard-commands`, `step-build-deps-tree`, `step-sort-commands-by-deps`, `step-prepare-execution-status-map`, `step-execute-commands!`) are now public functions instead of private ones, all under the `step-*` naming convention. +- `execute-steps` throws if called outside `with-execute-context` (steps read `*execute-internals*`/`*execute-config*` and would otherwise silently fall back to defaults instead of failing loudly). +- Each step carries a `:step-assert` — `commando.core/step-assert-keys` throws if the status-map is missing keys the step needs, catching a miscomposed/reordered step subset. This is a wiring bug, not a data problem, so it's a thrown exception rather than a status-map `:errors` entry — `execute-steps` itself doesn't interpret `:step-assert`, so a custom step can throw whatever shape it wants. +- `execute` itself is now just `(execute-steps status-map (steps-pipeline-default registry))` wrapped in `with-execute-context` — no behavior change for existing callers. +- This opens the door to building your own execute pipelines on top of Commando's — stopping/resuming at a chosen step, or composing a custom chain of steps — without forking `execute`. See the README's "Flow Control (advanced)" section. + ADDED `command-quote-spec` in `commando.commands.builtin` — a new command type `:commando/quote` (string form `"commando-quote"`) that brings Lisp-style quasiquote semantics to instructions. A `:commando/quote` body is treated as **inert data**: commands inside it are NOT executed and the scanner stops descending — *except* inside `:commando/unquote` holes (string form `"commando-unquote"`), which ARE executed and whose results are substituted back into the body. - `:commando/unquote` is pure syntax recognized only inside a quote — it is not a registered command and has no special meaning outside a quote. - A nested `:commando/quote` stays fully inert (its wrapper is left untouched), so quotes can be nested safely. diff --git a/README.md b/README.md index f863e81..1d6cb0e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Debugging](#debugging) - [Status-Map and Internals](#status-map-and-internals) - [Configuring Execution Behavior](#configuring-execution-behavior) + - [Flow Control (advanced)](#flow-control-advanced) - [Performance](#performance) - [Examples & Guides](#examples--guides) - [See Also](#see-also) @@ -780,10 +781,10 @@ Add `:__title` (or `"__title"`) to an instruction to label it in the trace outpu - `:errors` — vector of error objects accumulated during execution. Each entry is a map with at least `:message`; may also contain `:error` (serialized exception), `:command-path`, `:command-type`. Empty `[]` on success. - `:warnings` — vector of non-critical issues, e.g. skipped pipeline steps after a failure. Empty `[]` when there are no warnings. - `:successes` — vector of informational messages about completed pipeline steps. Each entry is a map with `:message`. -- `:stats` — vector of timing measurements for each pipeline step. Each entry is a tuple `[step-name duration-ns formatted-string]`, e.g. `["execute-commands!" 95838 "95.838µs"]`. +- `:stats` — vector of timing measurements for each pipeline step. Each entry is a tuple `[step-name duration-ns formatted-string]`, e.g. `["step-execute-commands!" 95838 "95.838µs"]`. - `:uuid` — unique identifier for this execution invocation. - `:registry` — the built command registry used for this execution. -- `:internal/cm-list` — set of all discovered Command objects (`CommandMapPath`) found during the `find-commands` step. +- `:internal/cm-list` — set of all discovered Command objects (`CommandMapPath`) found during the `step-find-commands` step. - `:internal/cm-dependency` — forward dependency graph `{CommandMapPath → #{deps}}`, which commands each command depends on. - `:internal/cm-running-order` — vector of commands in topologically sorted execution order (Kahn's algorithm). - `:internal/cm-results` — map `{CommandMapPath → resolved-value}`, the result of each command's `:apply` function. @@ -812,12 +813,12 @@ Add `:__title` (or `"__title"`) to an instruction to label it in the trace outpu {:message "Dependency map was successfully built"} {:message "..."}] :stats - [["use-registry" 12500 "12.5µs"] - ["find-commands" 35000 "35µs"] - ["build-deps-tree" 18000 "18µs"] - ["sort-commands-by-deps" 9000 "9µs"] - ["execute-commands!" 95838 "95.838µs"] - ["execute" 1085471 "1.085471ms"]] + [["step-use-registry" 12500 "12.5µs"] + ["step-find-commands" 35000 "35µs"] + ["step-build-deps-tree" 18000 "18µs"] + ["step-sort-commands-by-deps" 9000 "9µs"] + ["step-execute-commands!" 95838 "95.838µs"] + ["execute" 1085471 "1.085471ms"]] :uuid "a1b2c3..." :registry ... :internal/cm-list #{...} @@ -848,8 +849,8 @@ Add `:__title` (or `"__title"`) to an instruction to label it in the trace outpu :path ["3"] :command {:commando/from ["WRONG" "PATH"]}}] :warnings - [{:message "Skipping sort-commands-by-deps"} - {:message "Skipping execute-commands!"}] + [{:message "Skipping step-sort-commands-by-deps"} + {:message "Skipping step-execute-commands!"}] :successes [{:message "Commands were successfully collected"}] :stats [...] @@ -890,7 +891,7 @@ Hooks allow you to observe or instrument the execution lifecycle — for example #### `:hook-command-guard-*-fn` -Unlike `:hook-execute-start`/`:hook-execute-end`, a guard fn's return value is used: it receives the current `status-map` and must return a `status-map` — the same shape every internal pipeline step (`use-registry`, `build-deps-tree`, ...) already works with. Call `commando.impl.status-map/status-map-handle-error` on it (directly, or via the `hook-reject-commands-fn` helper below) to fail the whole `execute` call before `build-deps-tree`/`execute-commands!` run — you decide the `:message`, how many errors to add, and whether to short-circuit early. +Unlike `:hook-execute-start`/`:hook-execute-end`, a guard fn's return value is used: it receives the current `status-map` and must return a `status-map` — the same shape every internal pipeline step (`step-use-registry`, `step-build-deps-tree`, ...) already works with. Call `commando.impl.status-map/status-map-handle-error` on it (directly, or via the `hook-reject-commands-fn` helper below) to fail the whole `execute` call before `step-build-deps-tree`/`step-execute-commands!` run — you decide the `:message`, how many errors to add, and whether to short-circuit early. The outer/inner split exists because config is inherited by nested `execute` calls (triggered internally by things like `:commando/macro`): a check meant only for the outermost, client-facing call (e.g. an access gate rejecting "private" commands) would otherwise also reject the library's own internal nested calls to the same command type. @@ -954,6 +955,29 @@ When `:error-data-string` is `true`, the `:data` key within serialized `Exceptio ;; :value {:commando/from "BROKEN"}}} ``` +### Flow Control (advanced) + +`execute` itself is built from three lower-level functions. They're exported so you can build your own execute chain — e.g. stop the pipeline at a chosen step, inspect the status-map, and resume later. + +- `commando.core/steps-pipeline-default` — the default pipeline as data. +- `commando.core/execute-steps` — runs a coll of steps over a status-map. +- `commando.core/with-execute-context` — sets up the execution context `execute-steps` needs. + +See their docstrings for the exact contract. Example — a custom `execute` variant that stops before commands actually run: + +```clojure +(require '[commando.core :as commando]) +(require '[commando.impl.status-map :as smap]) + +(defn execute-until-commands [registry instruction] + (commando/with-execute-context nil + (fn [] + (commando/execute-steps (smap/status-map-pure {:instruction instruction}) + (take-while #(not= (:step-name %) :step-execute-commands!) + (commando/steps-pipeline-default registry)))))) + +(:internal/cm-running-order (execute-until-commands registry instruction)) +``` ### Performance @@ -966,17 +990,17 @@ All benchmarks were conducted on an **Intel Core i9-13980HX**. The primary metri The graph below illustrates the total execution time for instructions with a typical number of dependencies, ranging from 1,250 to 80,000. As you can see, the execution time scales linearly and remains in the low millisecond range, demonstrating excellent performance for common use cases.
- +
#### Execution Step Analysis To provide deeper insight, we've broken down the execution into five distinct steps: -1. **use-registry**: Builds the command registry from the provided specs. -2. **find-commands**: Scans the instruction map to identify all command instances. -3. **build-deps-tree**: Constructs a Directed Acyclic Graph (DAG) of dependencies between commands. -4. **sort-commands-by-deps**: Sorts the commands based on the dependency graph to determine the correct execution order. -5. **execute-commands!**: Executes the commands in the resolved order. +1. **step-use-registry**: Builds the command registry from the provided specs. +2. **step-find-commands**: Scans the instruction map to identify all command instances. +3. **step-build-deps-tree**: Constructs a Directed Acyclic Graph (DAG) of dependencies between commands. +4. **step-sort-commands-by-deps**: Sorts the commands based on the dependency graph to determine the correct execution order. +5. **step-execute-commands!**: Executes the commands in the resolved order. The following graphs show the performance of each step under both normal and extreme load conditions. @@ -985,15 +1009,15 @@ The following graphs show the performance of each step under both normal and ext Under normal conditions, each execution step completes in just a few milliseconds. The overhead of parsing, dependency resolution, and execution is minimal, ensuring a fast and responsive system.
- +
**Massive Workloads (up to 5,000,000 dependencies)** -To test the limits of the library, we benchmarked it with instructions containing up to 5 million dependencies. The graph below shows that while the system scales, the `find-commands` (parsing) and `build-deps-tree` (dependency graph construction) phases become the primary bottlenecks. This demonstrates that the core execution remains fast, but performance at extreme scales is dominated by the initial analysis steps. +To test the limits of the library, we benchmarked it with instructions containing up to 5 million dependencies. The graph below shows that while the system scales, the `step-find-commands` (parsing) and `step-build-deps-tree` (dependency graph construction) phases become the primary bottlenecks. This demonstrates that the core execution remains fast, but performance at extreme scales is dominated by the initial analysis steps.
- +
# Examples & Guides diff --git a/src/commando/core.cljc b/src/commando/core.cljc index 9f52aac..7cb9d6d 100644 --- a/src/commando/core.cljc +++ b/src/commando/core.cljc @@ -77,18 +77,26 @@ (registry/registry-remove built-registry command-map-spec-type)) ;; -- Core Steps -- +;; +;; Every step below is public and follows the same contract: +;; `(fn [status-map]) => status-map` (`step-use-registry` also takes `registry`, +;; baked into a closure by `steps-pipeline-default`), wrapped in `core-step-safe` — +;; skips itself with a warning if `status-map` is already `:failed`, catches +;; exceptions, records timing under `:stats`. Safe to call directly, or to +;; compose/reorder/replace via `steps-pipeline-default`/`execute-steps` — see those for +;; the caveats of doing so. -(defn ^:private use-registry +(defn step-use-registry [status-map registry] - (smap/core-step-safe status-map "use-registry" + (smap/core-step-safe status-map "step-use-registry" (fn [sm] (assoc sm :registry (-> (registry-create registry) (registry/enrich-runtime-registry)))))) -(defn ^:private find-commands +(defn step-find-commands [{:keys [instruction registry] :as status-map}] - (smap/core-step-safe status-map "find-commands" + (smap/core-step-safe status-map "step-find-commands" (fn [sm] (let [{:keys [commands trie]} (finding-commands/find-commands instruction registry)] (-> sm @@ -96,15 +104,15 @@ (assoc :internal/path-trie trie) (smap/status-map-handle-success {:message "Commands were successfully collected"})))))) -(defn ^:private guard-commands - "Runs the configured :hook-command-guard-*-fn once, right after find-commands - and before build-deps-tree/execute-commands! run. +(defn step-guard-commands + "Runs the configured :hook-command-guard-*-fn once, right after step-find-commands + and before step-build-deps-tree/step-execute-commands! run. - See - - `commando.impl.utils/*execute-config*` + See + - `commando.impl.utils/*execute-config*` - `commando.utils/hook-reject-commands-fn`" [status-map] - (smap/core-step-safe status-map "guard-commands" + (smap/core-step-safe status-map "step-guard-commands" (fn [sm] (let [outer? (= 1 (count (:stack utils/*execute-internals*))) config (utils/execute-config) @@ -114,19 +122,19 @@ (:hook-command-guard-inner-fn config)))] (if (nil? guard-fn) sm (guard-fn sm)))))) -(defn ^:private build-deps-tree - "Builds forward dependency graph using the path-trie produced by find-commands." +(defn step-build-deps-tree + "Builds forward dependency graph using the path-trie produced by step-find-commands." [{:keys [instruction] :internal/keys [cm-list path-trie] :as status-map}] - (smap/core-step-safe status-map "build-deps-tree" + (smap/core-step-safe status-map "step-build-deps-tree" (fn [sm] (let [fwd (dependency/build-dependency-graph instruction cm-list path-trie)] (-> sm (assoc :internal/cm-dependency fwd) (smap/status-map-handle-success {:message "Dependency map was successfully built"})))))) -(defn ^:private sort-commands-by-deps +(defn step-sort-commands-by-deps [status-map] - (smap/core-step-safe status-map "sort-commands-by-deps" + (smap/core-step-safe status-map "step-sort-commands-by-deps" (fn [sm] (let [{:keys [sorted cyclic]} (graph/topological-sort (:internal/cm-dependency sm)) sm (assoc sm :internal/cm-running-order (vec sorted))] @@ -139,11 +147,11 @@ {:message (str utils/exception-message-header "sort-entities-by-deps. Entities was sorted and prepare for evaluating")})))))) -(defn ^:private execute-commands! +(defn step-execute-commands! [{:keys [instruction registry] :internal/keys [cm-running-order] :as status-map}] - (smap/core-step-safe status-map "execute-commands!" + (smap/core-step-safe status-map "step-execute-commands!" (fn [sm] (binding [utils/*command-map-spec-registry* registry] (if (empty? cm-running-order) @@ -163,17 +171,123 @@ (assoc :internal/cm-results cm-results) (smap/status-map-handle-success {:message "All commands executed successfully"}))))))))) -(defn ^:private prepare-execution-status-map [status-map] - (if (smap/failed? status-map) - status-map - (-> status-map - (update :internal/cm-running-order registry/remove-runtime-registry-commands-from-command-list) - (update :registry registry/reset-runtime-registry)))) +(defn step-prepare-execution-status-map + "Strips runtime-registry-only commands out of the running order and resets + the runtime registry back to its pre-execution shape. Must run after + step-sort-commands-by-deps and before step-execute-commands!." + [status-map] + (smap/core-step-safe status-map "step-prepare-execution-status-map" + (fn [sm] + (-> sm + (update :internal/cm-running-order registry/remove-runtime-registry-commands-from-command-list) + (update :registry registry/reset-runtime-registry))))) -;; -- Public API -- +;; -- Flow Control -- -(defn failed? [status-map] (smap/failed? status-map)) -(defn ok? [status-map] (smap/ok? status-map)) +(defn step-assert-keys + "Builds a `:step-assert` fn: throws if `status-map` is missing any of + `keys`. The throwing is the assert's own job, not `execute-steps`'s — + see `execute-steps`." + [step-name keys] + (fn [status-map] + (when-let [missing (seq (remove #(contains? status-map %) keys))] + (throw + (ex-info + (str utils/exception-message-header + "Cannot run " step-name ". Missing required status-map keys: " (vec missing)) + {:step step-name :missing-keys (vec missing)}))))) + +(defn steps-pipeline-default + "execute's pipeline as data — an ordered vector of maps: + :step-name keyword matching the `step-*` fn it wraps + :step-fn `(fn [status-map]) => status-map` + :step-assert `(fn [status-map])` — a wiring precondition (not a data + check): throws if it fails, since that means the step + chain itself was assembled wrong, not that the + instruction/config is bad. + + `registry` is baked into `:step-use-registry`'s `step-fn` via closure. + + See + - `commando.core/execute-steps`" + [registry] + [{:step-name :step-use-registry + :step-fn (fn [sm] (step-use-registry sm registry)) + :step-assert (step-assert-keys :step-use-registry #{})} + {:step-name :step-find-commands + :step-fn step-find-commands + :step-assert (step-assert-keys :step-find-commands #{:instruction :registry})} + {:step-name :step-guard-commands + :step-fn step-guard-commands + :step-assert (step-assert-keys :step-guard-commands #{})} + {:step-name :step-build-deps-tree + :step-fn step-build-deps-tree + :step-assert (step-assert-keys :step-build-deps-tree #{:instruction :internal/cm-list :internal/path-trie})} + {:step-name :step-sort-commands-by-deps + :step-fn step-sort-commands-by-deps + :step-assert (step-assert-keys :step-sort-commands-by-deps #{:internal/cm-dependency})} + {:step-name :step-prepare-execution-status-map + :step-fn step-prepare-execution-status-map + :step-assert (step-assert-keys :step-prepare-execution-status-map #{:internal/cm-running-order :registry})} + {:step-name :step-execute-commands! + :step-fn step-execute-commands! + :step-assert (step-assert-keys :step-execute-commands! #{:instruction :registry :internal/cm-running-order})}]) + +(defn with-execute-context + "Creates the internal execution context for `execute` (and nested calls), + preparing `opts` for use by the steps. + + See + - `commando.impl.utils/*execute-config*` + - `commando.impl.utils/*execute-internals*` + - `commando.core/execute` + - `commando.core/execute-steps`" + [opts thunk] + (binding [utils/*execute-internals* (utils/-execute-internals-push (str (random-uuid))) + utils/*execute-config* (utils/execute-config-update opts)] + (thunk))) + +(defn execute-steps + "Runs `steps` over `status-map` — the same mechanism `execute` uses + internally, exposed for running only part of the pipeline. Must run + inside `with-execute-context`. Before each step, calls its `:step-assert` + (skipped once `status-map` is `:failed`) — `execute-steps` doesn't + interpret what that does; a `:step-assert` throwing is entirely up to + the step author, e.g. `commando.core/step-assert-keys`. + + Examples + ;; equivalent to a plain `execute` call + (with-execute-context nil + (fn [] + (execute-steps (smap/status-map-pure {:instruction instruction}) + (steps-pipeline-default registry)))) + + ;; stop before commands run, inspect, resume later + (def steps (steps-pipeline-default registry)) + (def halted + (with-execute-context nil + (fn [] + (execute-steps (smap/status-map-pure {:instruction instruction}) + (take-while #(not= (:step-name %) :step-execute-commands!) steps))))) + + See + - `commando.core/steps-pipeline-default` + - `commando.core/with-execute-context`" + [status-map steps] + (when (empty? (:stack utils/*execute-internals*)) + (throw + (ex-info + (str + utils/exception-message-header + "execute-steps must run inside with-execute-context (or a top-level execute call) — *execute-internals*/*execute-config* are not bound.") + {:execute-internals utils/*execute-internals*}))) + (reduce + (fn [status-map-acc {:keys [step-fn step-assert]}] + (when-not (smap/failed? status-map-acc) + (step-assert status-map-acc)) + (step-fn status-map-acc)) + status-map + steps)) (defn execute "Evaluates an instruction with a command registry. @@ -188,7 +302,7 @@ right after commands are found, only on the outermost execute call (stack depth 1); unlike the hooks above its return value IS used — it can call status-map-handle-error to reject the whole execution - before build-deps-tree/execute-commands! run. See + before step-build-deps-tree/step-execute-commands! run. See `commando.utils/hook-reject-commands-fn` for a per-command convenience helper to call from inside it. :hook-command-guard-inner-fn - same contract, only on nested execute @@ -200,6 +314,11 @@ Config keys are inherited by nested execute calls. Inner calls can override specific keys — non-overridden keys come from the parent. + `execute` itself is a thin wrapper around `steps-pipeline-default`/`execute-steps`/ + `with-execute-context` — see those for stopping the pipeline at a chosen + step (e.g. to inspect `:internal/cm-running-order` before anything runs) + and resuming it later. + Examples: ;; Full execution (execute reg instruction) @@ -209,20 +328,17 @@ ([registry instruction] (execute registry instruction nil)) ([registry instruction opts] {:pre [(or (vector? registry) (registry/built? registry))]} - (binding [utils/*execute-internals* (utils/-execute-internals-push (str (random-uuid))) - utils/*execute-config* (utils/execute-config-update opts)] - (let [start-time (utils/now) - config (utils/execute-config)] - (-> (smap/status-map-pure {:instruction instruction}) - (utils/hook-process (:hook-execute-start config)) - (use-registry registry) - (find-commands) - (guard-commands) - (build-deps-tree) - (sort-commands-by-deps) - (prepare-execution-status-map) - (execute-commands!) - (smap/status-map-add-measurement "execute" start-time (utils/now)) - (utils/hook-process (:hook-execute-end config)) - (assoc :internal/original-instruction instruction)))))) + (with-execute-context opts + (fn [] + (let [start-time (utils/now) + config (utils/execute-config)] + (-> (smap/status-map-pure {:instruction instruction}) + (utils/hook-process (:hook-execute-start config)) + (execute-steps (steps-pipeline-default registry)) + (smap/status-map-add-measurement "execute" start-time (utils/now)) + (utils/hook-process (:hook-execute-end config)) + (assoc :internal/original-instruction instruction))))))) +(defn failed? [status-map] (smap/failed? status-map)) + +(defn ok? [status-map] (smap/ok? status-map)) diff --git a/src/commando/debug.cljc b/src/commando/debug.cljc index 29b2f6f..447637e 100644 --- a/src/commando/debug.cljc +++ b/src/commando/debug.cljc @@ -474,12 +474,12 @@ [stats] (when (seq stats) (let [stats-map (into {} (map (fn [[k _ formatted]] [(name k) formatted]) stats)) - cmds-t (get stats-map "execute-commands!") + cmds-t (get stats-map "step-execute-commands!") exec-t (get stats-map "execute")] (cond - (and cmds-t exec-t) (str "execute-commands! " cmds-t " · execute " exec-t) + (and cmds-t exec-t) (str "step-execute-commands! " cmds-t " · execute " exec-t) exec-t (str "execute " exec-t) - cmds-t (str "execute-commands! " cmds-t) + cmds-t (str "step-execute-commands! " cmds-t) :else nil)))) (defn ^:private trace-print-node diff --git a/src/commando/impl/utils.cljc b/src/commando/impl/utils.cljc index c4e44d1..9059a94 100644 --- a/src/commando/impl/utils.cljc +++ b/src/commando/impl/utils.cljc @@ -31,7 +31,7 @@ - `:hook-command-guard-outer-fn` (fn [status-map] status-map): unlike the hooks above, its return value IS used — it can call `status-map-handle-error` to reject the whole execution before - `build-deps-tree`/`execute-commands!` run. Runs only on the outermost + `step-build-deps-tree`/`step-execute-commands!` run. Runs only on the outermost `execute` call (stack depth 1). - `:hook-command-guard-inner-fn` (fn [status-map] status-map): same contract, but runs only on nested `execute` calls (stack depth > 1 — @@ -125,7 +125,7 @@ See `commando.core/execute` - `commando.core/execute-commands!`(binding)" + `commando.core/step-execute-commands!`(binding)" [] (or *command-map-spec-registry* [])) ;; == Function Resolvers ================================== diff --git a/test/unit/commando/core_test.cljc b/test/unit/commando/core_test.cljc index a1e508d..e4189f2 100644 --- a/test/unit/commando/core_test.cljc +++ b/test/unit/commando/core_test.cljc @@ -7,6 +7,7 @@ [commando.utils :as commando-utils] [commando.impl.command-map :as cm] [commando.impl.pathtrie :as pathtrie] + [commando.impl.status-map :as smap] [commando.impl.utils :as utils] [malli.core :as malli] [commando.impl.registry :as commando-registry])) @@ -123,46 +124,46 @@ (deftest execute-commands!-test (testing "Status handling" - (is (commando/failed? (#'commando/execute-commands! fail-status-map)) "Failed status is preserved") - (is (not-empty (:warnings (#'commando/execute-commands! fail-status-map))) "Warnings are preserved") - (is (commando/failed? (#'commando/execute-commands! midway-fail-execution-map)) + (is (commando/failed? (#'commando/step-execute-commands! fail-status-map)) "Failed status is preserved") + (is (not-empty (:warnings (#'commando/step-execute-commands! fail-status-map))) "Warnings are preserved") + (is (commando/failed? (#'commando/step-execute-commands! midway-fail-execution-map)) "Failed status when command fails midway") - (is (commando/ok? (#'commando/execute-commands! basic-command-execution-map)) + (is (commando/ok? (#'commando/step-execute-commands! basic-command-execution-map)) "Success status when commands execute successfully") - (is (commando/ok? (#'commando/execute-commands! empty-execution-map)) "Success status when no commands to execute") - (is (commando/ok? (#'commando/execute-commands! from-command)) "Success status for from command") - (is (commando/ok? (#'commando/execute-commands! fn-command)) "Success status for fn command") - (is (commando/ok? (#'commando/execute-commands! apply-command)) "Success status for apply command") - (is (commando/ok? (#'commando/execute-commands! nil-handler-execution-map)) "Nil returning command is successful")) + (is (commando/ok? (#'commando/step-execute-commands! empty-execution-map)) "Success status when no commands to execute") + (is (commando/ok? (#'commando/step-execute-commands! from-command)) "Success status for from command") + (is (commando/ok? (#'commando/step-execute-commands! fn-command)) "Success status for fn command") + (is (commando/ok? (#'commando/step-execute-commands! apply-command)) "Success status for apply command") + (is (commando/ok? (#'commando/step-execute-commands! nil-handler-execution-map)) "Nil returning command is successful")) (testing "Basic functionality" - (is (= 10 (get-in (#'commando/execute-commands! basic-command-execution-map) [:instruction "val"])) + (is (= 10 (get-in (#'commando/step-execute-commands! basic-command-execution-map) [:instruction "val"])) "Non-command values preserved") - (is (= :test-id (get-in (#'commando/execute-commands! basic-command-execution-map) [:instruction "cmd" :id])) + (is (= :test-id (get-in (#'commando/step-execute-commands! basic-command-execution-map) [:instruction "cmd" :id])) "Command executed") - (is (= 42 (get-in (#'commando/execute-commands! from-command) [:instruction "ref"])) + (is (= 42 (get-in (#'commando/step-execute-commands! from-command) [:instruction "ref"])) "commando/from executes correctly") - (is (= 6 (get-in (#'commando/execute-commands! fn-command) [:instruction "calc"])) + (is (= 6 (get-in (#'commando/step-execute-commands! fn-command) [:instruction "calc"])) "commando/fn executes function with args") - (is (= 10 (get-in (#'commando/execute-commands! apply-command) [:instruction "transform"])) + (is (= 10 (get-in (#'commando/step-execute-commands! apply-command) [:instruction "transform"])) "commando/apply transforms value") - (is (= :test-id (get-in (#'commando/execute-commands! midway-fail-execution-map) [:instruction "good" :id])) + (is (= :test-id (get-in (#'commando/step-execute-commands! midway-fail-execution-map) [:instruction "good" :id])) "When failure happens - partial results are returned") (is (= {:test/add-id "should-not-execute"} - (get-in (#'commando/execute-commands! midway-fail-execution-map) [:instruction "never"])) + (get-in (#'commando/step-execute-commands! midway-fail-execution-map) [:instruction "never"])) "After one command fails next ones do not execute") - (is (contains? (get-in (#'commando/execute-commands! deep-nested-execution-map) + (is (contains? (get-in (#'commando/step-execute-commands! deep-nested-execution-map) [:instruction "level1" "level2" "level3" "deep"]) :id) "Deep nested command executes") - (is (not-empty (:errors (#'commando/execute-commands! bad-command-execution-map))) + (is (not-empty (:errors (#'commando/step-execute-commands! bad-command-execution-map))) "Errors populated for failing command") - (is (every? #(contains? (get-in (#'commando/execute-commands! large-commands-execution-map) [:instruction %]) :id) + (is (every? #(contains? (get-in (#'commando/step-execute-commands! large-commands-execution-map) [:instruction %]) :id) (range 20)) "All commands execute successfully")) (testing "Edge cases" - (is (= {"val" 42} (:instruction (#'commando/execute-commands! empty-execution-map))) + (is (= {"val" 42} (:instruction (#'commando/step-execute-commands! empty-execution-map))) "Empty running order preserves instruction values") - (is (= nil (get-in (#'commando/execute-commands! nil-handler-execution-map) [:instruction "nil-handler"])) + (is (= nil (get-in (#'commando/step-execute-commands! nil-handler-execution-map) [:instruction "nil-handler"])) "Nil values handled correctly"))) ;; -- Integration: execute pipeline -- @@ -298,6 +299,26 @@ (is (every? :custom-error-shape (:errors result)))))) +;; -- Flow Control (steps-pipeline-default / execute-steps / with-execute-context) -- + +(deftest execute-steps-halt-and-resume-test + (testing "Pipeline can be halted before commands run, inspected, then resumed to completion" + (let [instruction {"a" 1 "b" {:commando/from ["a"]}} + steps (commando/steps-pipeline-default [cmds-builtin/command-from-spec]) + halted (commando/with-execute-context nil + (fn [] + (commando/execute-steps + (smap/status-map-pure {:instruction instruction}) + (take-while #(not= (:step-name %) :step-execute-commands!) steps)))) + resumed (commando/with-execute-context nil + (fn [] + (commando/execute-steps halted + (drop-while #(not= (:step-name %) :step-execute-commands!) steps))))] + (is (some? (:internal/cm-running-order halted)) "Running order is available before commands run") + (is (nil? (:internal/cm-results halted)) "Commands have not run yet") + (is (commando/ok? resumed)) + (is (= 1 (get-in resumed [:instruction "b"])) "Resumed run executes the deferred commands")))) + ;; -- Internals always retained -- (deftest internals-always-retained-test diff --git a/test/unit/commando/impl/dependency_test.cljc b/test/unit/commando/impl/dependency_test.cljc index 5f4fb3f..e44c355 100644 --- a/test/unit/commando/impl/dependency_test.cljc +++ b/test/unit/commando/impl/dependency_test.cljc @@ -117,31 +117,31 @@ (deftest build-deps-tree (testing "Status handling" - (is (commando/failed? (#'commando/build-deps-tree failed-status-map)) "Failed status is preserved") - (is (commando/failed? (#'commando/build-deps-tree + (is (commando/failed? (#'commando/step-build-deps-tree failed-status-map)) "Failed status is preserved") + (is (commando/failed? (#'commando/step-build-deps-tree {:status :ok :instruction {:ref {:commando/from [:nonexistent]}} :registry registry :internal/cm-list [(cm/command-map-path [:ref] cmds-builtin/command-from-spec)]})) "Returns failed status for non-existent path references") - (is (commando/ok? (#'commando/build-deps-tree empty-ok-status-map)) "Success status with empty command list")) + (is (commando/ok? (#'commando/step-build-deps-tree empty-ok-status-map)) "Success status with empty command list")) (testing "Dependency patterns" - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree all-inside-status-map))] + (let [deps (:internal/cm-dependency (#'commando/step-build-deps-tree all-inside-status-map))] (is (contains? (get deps parent-cmd) child-cmd) "Parent depends on child (all-inside)")) - (is (contains? (get (:internal/cm-dependency (#'commando/build-deps-tree point-deps-status-map)) ref-cmd) + (is (contains? (get (:internal/cm-dependency (#'commando/step-build-deps-tree point-deps-status-map)) ref-cmd) target-cmd) "Ref depends on target (point)") - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree chained-deps-map))] + (let [deps (:internal/cm-dependency (#'commando/step-build-deps-tree chained-deps-map))] (is (contains? (get deps chain-cmd-a) chain-cmd-b) "A depends on B") (is (contains? (get deps chain-cmd-b) chain-cmd-c) "B depends on C") (is (empty? (get deps chain-cmd-c)) "C has no dependencies")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree diamond-deps-map))] + (let [deps (:internal/cm-dependency (#'commando/step-build-deps-tree diamond-deps-map))] (is (contains? (get deps diamond-cmd-b) diamond-cmd-d) "B depends on D") (is (contains? (get deps diamond-cmd-c) diamond-cmd-d) "C depends on D") (is (empty? (get deps diamond-cmd-d)) "D has no dependencies")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree deep-cross-ref-map))] + (let [deps (:internal/cm-dependency (#'commando/step-build-deps-tree deep-cross-ref-map))] (is (contains? (get deps deep-shallow) shallow-target) "Deep nested depends on shallow target")) - (let [deps (:internal/cm-dependency (#'commando/build-deps-tree sibling-deps-map))] + (let [deps (:internal/cm-dependency (#'commando/step-build-deps-tree sibling-deps-map))] (is (contains? (get deps sibling1) sibling2) "Sibling1 depends on sibling2"))) (testing "Complex multi-level dependency resolution" (let [large-instruction @@ -154,10 +154,10 @@ :cache {:commando/from [:products :load]}} :orders {:create {:commando/from [:users :validate]} :prepare {:commando/from [:products :cache]}}} - found (#'commando/find-commands + found (#'commando/step-find-commands {:status :ok :instruction large-instruction :registry registry}) cmds (:internal/cm-list found) - result (#'commando/build-deps-tree found) + result (#'commando/step-build-deps-tree found) deps (:internal/cm-dependency result)] (is (commando/ok? result) "Successfully processes large dependency tree") (is (contains? (get deps (cmd-by-path [:users :fetch] cmds)) (cmd-by-path [:config :database] cmds)) @@ -169,7 +169,7 @@ (is (contains? (get deps (cmd-by-path [:orders :prepare] cmds)) (cmd-by-path [:products :cache] cmds)) "orders.prepare depends on products.cache"))) (testing "Empty command list" - (let [result (#'commando/build-deps-tree + (let [result (#'commando/step-build-deps-tree (status-map-with-trie {:status :ok :instruction {} :registry registry :internal/cm-list []}))] (is (commando/ok? result) "Handles empty command list") @@ -185,7 +185,7 @@ :some-val {:test/add-id :nested}}} :registry registry :internal/cm-list [goal2-cmd goal2-someval-cmd]}) - result (#'commando/build-deps-tree test-status-map) + result (#'commando/step-build-deps-tree test-status-map) deps (:internal/cm-dependency result)] (is (commando/ok? result) "Successfully processes :all-inside dependency") (is (contains? (get deps goal2-cmd) goal2-someval-cmd) @@ -199,7 +199,7 @@ :ref {:commando/from [:goal-1]}} :registry registry :internal/cm-list [goal1-cmd ref-cmd]}) - result (#'commando/build-deps-tree test-status-map) + result (#'commando/step-build-deps-tree test-status-map) deps (:internal/cm-dependency result)] (is (commando/ok? result) "Successfully processes :point dependency") (is (contains? (get deps ref-cmd) goal1-cmd) @@ -215,7 +215,7 @@ :instruction {:standalone {:test/none :independent}} :registry (commando/registry-create [none-command]) :internal/cm-list [none-cmd]}) - result (#'commando/build-deps-tree test-status-map) + result (#'commando/step-build-deps-tree test-status-map) deps (:internal/cm-dependency result)] (is (commando/ok? result) "Successfully processes :none dependency") (is (empty? (get deps none-cmd)) "Command with :none mode has no dependencies")))) diff --git a/test/unit/commando/impl/finding_commands_test.cljc b/test/unit/commando/impl/finding_commands_test.cljc index 219cc30..4f83bd4 100644 --- a/test/unit/commando/impl/finding_commands_test.cljc +++ b/test/unit/commando/impl/finding_commands_test.cljc @@ -23,7 +23,7 @@ (deftest find-commands (testing "Basic cases" (is (= #{(cm/command-map-path [] #'commando-registry/default-command-map-spec)} - (:internal/cm-list (#'commando/find-commands + (:internal/cm-list (#'commando/step-find-commands {:status :ok :instruction {} :registry registry}))) @@ -37,7 +37,7 @@ (cm/command-map-path [:some-val :a] #'commando-registry/default-command-value-spec) (cm/command-map-path [:i :am] #'commando-registry/default-command-map-spec) (cm/command-map-path [:i :am :deep] #'commando-registry/default-command-value-spec)} - (:internal/cm-list (#'commando/find-commands + (:internal/cm-list (#'commando/step-find-commands {:status :ok :instruction {:some-val {:a 2} :some-other 3 @@ -51,7 +51,7 @@ (cm/command-map-path [:list] #'commando-registry/default-command-value-spec) (cm/command-map-path [:primitive] #'commando-registry/default-command-value-spec) (cm/command-map-path [:java-obj] #'commando-registry/default-command-value-spec)} - (:internal/cm-list (#'commando/find-commands + (:internal/cm-list (#'commando/step-find-commands {:status :ok :instruction {:set #{:commando/from [:target]} :list (list {:commando/from [:target]}) @@ -66,7 +66,7 @@ (cm/command-map-path [:valid] #'commando-registry/default-command-vec-spec) (cm/command-map-path [:target] #'commando-registry/default-command-value-spec) (cm/command-map-path [:valid 0] cmds-builtin/command-from-spec)} - (:internal/cm-list (#'commando/find-commands + (:internal/cm-list (#'commando/step-find-commands {:status :ok :instruction {:set #{:not-found} :list (list :not-found) @@ -82,17 +82,17 @@ (cm/command-map-path [:a "some" :c] #'commando-registry/default-command-vec-spec) (cm/command-map-path [:a "some" :c 0] #'commando-registry/default-command-value-spec) (cm/command-map-path [:a "some" :c 1] cmds-builtin/command-from-spec)} - (:internal/cm-list (#'commando/find-commands + (:internal/cm-list (#'commando/step-find-commands {:status :ok :instruction {:a {"some" {:c [:some {:commando/from [:target]}]}} :target 42} :registry registry}))) "Example of usage commando/from inside of deep map") (is (= :failed - (:status (#'commando/find-commands {:status :failed}))) + (:status (#'commando/step-find-commands {:status :failed}))) "Failed status is preserved") (is - (let [mixed-keys-result (:internal/cm-list (#'commando/find-commands + (let [mixed-keys-result (:internal/cm-list (#'commando/step-find-commands {:status :ok :instruction {"string-key" {:commando/from [:a]} :keyword-key {:commando/from [:a]} diff --git a/test/unit/commando/impl/graph_test.cljc b/test/unit/commando/impl/graph_test.cljc index c9a1535..26d7c31 100644 --- a/test/unit/commando/impl/graph_test.cljc +++ b/test/unit/commando/impl/graph_test.cljc @@ -34,10 +34,10 @@ (deftest sort-entities-by-deps (testing "Status handling" - (is (commando/failed? (#'commando/sort-commands-by-deps + (is (commando/failed? (#'commando/step-sort-commands-by-deps {:status :failed :instruction {} :registry registry :internal/cm-list []})) "Failed status is preserved") - (is (commando/ok? (#'commando/sort-commands-by-deps + (is (commando/ok? (#'commando/step-sort-commands-by-deps {:status :ok :instruction {:a 1} :registry registry :internal/cm-list []})) "Success status with empty dependency map")) (testing "Simple dependency chain ordering" @@ -49,7 +49,7 @@ :internal/cm-dependency {chain-cmd-a #{chain-cmd-b} chain-cmd-b #{chain-cmd-c} chain-cmd-c #{}}} - result (#'commando/sort-commands-by-deps deps-map) + result (#'commando/step-sort-commands-by-deps deps-map) order (:internal/cm-running-order result)] (is (commando/ok? result) "Successfully sorts linear dependency chain") (is (= 3 (count order)) "Returns all commands in order") @@ -66,7 +66,7 @@ diamond-cmd-b #{diamond-cmd-d} diamond-cmd-c #{diamond-cmd-d} diamond-cmd-d #{}}} - result (#'commando/sort-commands-by-deps deps-map) + result (#'commando/step-sort-commands-by-deps deps-map) order (:internal/cm-running-order result)] (is (commando/ok? result) "Successfully sorts diamond dependency") (is (= 4 (count order)) "Returns all commands in order") @@ -80,7 +80,7 @@ :registry registry :internal/cm-dependency {circular-cmd-a #{circular-cmd-b} circular-cmd-b #{circular-cmd-a}}} - result (#'commando/sort-commands-by-deps deps-map)] + result (#'commando/step-sort-commands-by-deps deps-map)] (is (commando/failed? result) "Detects circular dependency and returns failed status") (is (some #(re-find #"cyclic dependency" %) (map :message (:errors result))) "Error message mentions cyclic dependency")))) From 7363649294cdee6dc14d06d5fee2c4daa088cb88 Mon Sep 17 00:00:00 2001 From: SerhiiRI Date: Sun, 9 Aug 2026 12:26:04 +0300 Subject: [PATCH 2/2] Bump version 1.3.0 --- CHANGELOG.md | 2 +- README.md | 6 +++--- pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4847b20..bc8a247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 1.2.1 +# 1.3.0 ## Added diff --git a/README.md b/README.md index 1d6cb0e..6b6deb2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Clojars Project](https://img.shields.io/clojars/v/org.clojars.funkcjonariusze/commando.svg)](https://clojars.org/org.clojars.funkcjonariusze/commando) [![Run tests](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml/badge.svg)](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml) -[![cljdoc badge](https://cljdoc.org/badge/org.clojars.funkcjonariusze/commando)](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.2.1) +[![cljdoc badge](https://cljdoc.org/badge/org.clojars.funkcjonariusze/commando)](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.3.0) **Commando** is a flexible Clojure/ClojureScript library for building data-driven DSLs. @@ -42,10 +42,10 @@ ```clojure ;; deps.edn with git -{org.clojars.funkcjonariusze/commando {:mvn/version "1.2.1"}} +{org.clojars.funkcjonariusze/commando {:mvn/version "1.3.0"}} ;; leiningen -[org.clojars.funkcjonariusze/commando "1.2.1"] +[org.clojars.funkcjonariusze/commando "1.3.0"] ``` ## Quick Start diff --git a/pom.xml b/pom.xml index eeaf94b..ab1f83d 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ jar org.clojars.funkcjonariusze commando - 1.2.1 + 1.3.0 commando @@ -42,6 +42,6 @@ https://github.com/funkcjonariusze/commando scm:git:git://github.com/funkcjonariusze/commando.git scm:git:ssh://git@github.com:funkcjonariusze/commando.git - 1.2.1 + 1.3.0