Add defrun macro for run-fn boilerplate
`(defrun [{: sh}] body)` expands to `(fn [] (let [{: sh} runtime]
body))`, so destructuring the ambient runtime stops being repeated at
every job. Macros and the existing runtime module share the `quire.ci`
module name; `fennel.macro-loaded` and `package.loaded` are independent
caches, no collision. The macro is itself the greppable effect-site
marker — looking for run-fns that touch the host is `defrun` plus any
remaining bare `runtime.X`.

Assisted-by: Claude Opus 4.7 via Claude Code
change louwvnkorttklxqouvumtkqoulukwqys
commit 6cfc396bf6b43c7e1604c092d85165a8c6581c4a
author Alpha Chen <alpha@kejadlen.dev>
date
parent xmtuwprt
diff --git a/.quire/ci.fnl b/.quire/ci.fnl
index 688370f..03a0bce 100644
--- a/.quire/ci.fnl
+++ b/.quire/ci.fnl
@@ -1,6 +1,7 @@
 (local {: job : mirror} (require :quire.ci))
+(import-macros {: defrun} :quire.ci)
 
-; (job :test [:quire/push] (fn [{: sh}] (sh [:cargo :test])))
+; (job :test [:quire/push] (defrun [{: sh}] (sh [:cargo :test])))
 
 (mirror "https://github.com/kejadlen/quire.git"
         {:refs [:refs/heads/main]
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index c055ac1..ab9bfcb 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -94,6 +94,29 @@ Run-fns are zero-arg functions. The runtime is available as a global `runtime` t
 
 The `runtime.` prefix is a visible explicit-context marker so reviewers can grep for effect sites. Accessing `runtime` outside a run-fn raises: `runtime accessed outside a job — primitives are only available while a run-fn is executing`.
 
+When a run-fn destructures several primitives the `runtime.` prefix gets repetitive; the `defrun` macro takes the destructure pattern as its arglist and expands to the `let`-from-runtime shape:
+
+```
+(import-macros {: defrun} :quire.ci)
+
+(job :test [:quire/push]
+  (defrun [{: sh : jobs}]
+    (let [push (jobs :quire/push)]
+      (sh ["cargo" "test"]))))
+```
+
+expands to
+
+```
+(job :test [:quire/push]
+  (fn []
+    (let [{: sh : jobs} runtime]
+      (let [push (jobs :quire/push)]
+        (sh ["cargo" "test"])))))
+```
+
+`defrun` itself is the effect-site marker — reviewers grep for `defrun` (and any remaining bare `runtime.`) to find places that touch the host. The bare `(fn [] (runtime.X …))` form stays available for one-off use; the explicit `(let [{: sh} runtime] …)` form is also fine when you want the destructure visible without the macro.
+
 > **v0 status:** `(jobs :quire/push)` is wired. Job-to-job outputs (where `(jobs :build)` returns a job's `run-fn` return value) are not — there's no writer API yet, and a reachable name with no recorded outputs returns `nil`.
 
 ### Sources
diff --git a/quire-core/src/ci/macros.fnl b/quire-core/src/ci/macros.fnl
new file mode 100644
index 0000000..c7251df
--- /dev/null
+++ b/quire-core/src/ci/macros.fnl
@@ -0,0 +1,36 @@
+;; quire.ci macros — imported via `(import-macros {: defrun} :quire.ci)`.
+;;
+;; `defrun` is sugar for the common run-fn shape: a zero-arg function
+;; whose body needs `sh` / `secret` / `jobs` / `mirror` from the
+;; ambient `runtime` global. Writing `(let [{: sh} runtime] …)` at the
+;; top of every job becomes the macro itself, with the destructure
+;; pattern as the apparent argument list.
+;;
+;;   (defrun [{: sh : jobs}]
+;;     (let [push (jobs :quire/push)]
+;;       (sh ["cargo" "test"])))
+;;
+;; expands to:
+;;
+;;   (fn []
+;;     (let [{: sh : jobs} runtime]
+;;       (let [push (jobs :quire/push)]
+;;         (sh ["cargo" "test"]))))
+;;
+;; An empty arglist skips the `let` entirely:
+;;
+;;   (defrun [] (do-something))  =>  (fn [] (do-something))
+
+(fn defrun [arglist ...]
+  (assert-compile (<= (length arglist) 1)
+                  "defrun expects an arglist with 0 or 1 destructure pattern"
+                  arglist)
+  (let [body [...]]
+    (if (= 0 (length arglist))
+        `(fn []
+           ,(unpack body))
+        `(fn []
+           (let [,(. arglist 1) runtime]
+             ,(unpack body))))))
+
+{: defrun}
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index bc1bfa6..1c1c991 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -12,6 +12,12 @@ const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 /// Fennel where they're easier to evolve.
 const STDLIB_FNL: &str = include_str!("ci/stdlib.fnl");
 
+/// Embedded Fennel macros — exposed as `(import-macros {…} :quire.ci)`
+/// alongside the runtime-side `quire.ci` module. Same module name,
+/// different cache (`fennel.macro-loaded` vs `package.loaded`); the
+/// two namespaces are independent.
+const MACROS_FNL: &str = include_str!("ci/macros.fnl");
+
 /// Error kinds from the Fennel loader.
 #[derive(Debug, Error, Diagnostic)]
 pub enum FennelError {
@@ -91,6 +97,7 @@ impl Fennel {
 
         let f = Self { lua };
         f.preload_stdlib()?;
+        f.preload_macros()?;
         Ok(f)
     }
 
@@ -105,6 +112,26 @@ impl Fennel {
         Ok(())
     }
 
+    /// Compile the embedded `quire.ci` macros and register them in
+    /// `fennel.macro-loaded` so `(import-macros {…} :quire.ci)`
+    /// resolves without hitting the filesystem. The macros file is
+    /// evaluated in the Fennel compiler environment (where
+    /// quasi-quote, `unpack`, `assert-compile`, etc. are bound).
+    fn preload_macros(&self) -> Result<(), FennelError> {
+        let fennel: mlua::Table = self.lua.globals().get("fennel")?;
+        let eval: mlua::Function = fennel.get("eval")?;
+        let opts = self.lua.create_table()?;
+        opts.set("filename", "quire.ci/macros.fnl")?;
+        opts.set("env", "_COMPILER")?;
+        opts.set("correlate", true)?;
+        let macros: mlua::Value = eval
+            .call((MACROS_FNL, opts))
+            .map_err(|e| FennelError::from_lua(MACROS_FNL, "quire.ci macros", e))?;
+        let macro_loaded: mlua::Table = fennel.get("macro-loaded")?;
+        macro_loaded.set("quire.ci", macros)?;
+        Ok(())
+    }
+
     /// Borrow the underlying Lua VM. Useful for callers that need to
     /// `to_value` / `from_value` against the same VM the Fennel script
     /// ran in.
@@ -444,6 +471,87 @@ mod tests {
         let _: mlua::Function = module.get("mirror").expect("mirror should be a function");
     }
 
+    #[test]
+    fn defrun_macro_compiles_to_function() {
+        let f = fennel();
+        let source = "\
+(import-macros {: defrun} :quire.ci)
+(defrun [{: sh}] nil)";
+        let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
+        assert!(
+            matches!(value, mlua::Value::Function(_)),
+            "expected function, got {value:?}"
+        );
+    }
+
+    #[test]
+    fn defrun_destructures_from_ambient_runtime() {
+        let f = fennel();
+
+        // Replace the stub with a runtime table whose `sh` records calls.
+        let calls: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
+            std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
+        let cb_calls = calls.clone();
+        let rt = f.lua().create_table().expect("rt");
+        rt.set(
+            "sh",
+            f.lua()
+                .create_function(move |_, cmd: String| {
+                    cb_calls.borrow_mut().push(cmd);
+                    Ok(())
+                })
+                .expect("create sh"),
+        )
+        .expect("set sh");
+        f.lua().globals().set("runtime", rt).expect("set runtime");
+
+        let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [{: sh}] (sh :from-macro))
+"#;
+        let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
+        let mlua::Value::Function(func) = value else {
+            panic!("expected function, got {value:?}");
+        };
+        func.call::<()>(()).expect("call");
+
+        assert_eq!(*calls.borrow(), vec!["from-macro".to_string()]);
+    }
+
+    #[test]
+    fn defrun_with_empty_arglist_skips_destructure() {
+        let f = fennel();
+        let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [] 42)
+"#;
+        let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
+        let mlua::Value::Function(func) = value else {
+            panic!("expected function, got {value:?}");
+        };
+        let result: i64 = func.call(()).expect("call");
+        assert_eq!(result, 42);
+    }
+
+    #[test]
+    fn defrun_rejects_multi_element_arglist() {
+        let f = fennel();
+        let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [a b] nil)
+"#;
+        let err = f
+            .eval_raw(source, "test.fnl", |_| Ok(()))
+            .expect_err("multi-element arglist should fail to compile");
+        let msg = err.to_string();
+        let chain = format!("{err:?}");
+        let combined = format!("{msg} {chain}");
+        assert!(
+            combined.contains("defrun expects"),
+            "expected arity error, got: {combined}"
+        );
+    }
+
     #[test]
     fn quire_runtime_module_preloaded_to_stub() {
         let f = fennel();