Refine ci diagnostics and tighten module exports
Cycle members were also failing the reachability check, generating
Cycle + N Unreachable errors for one bad cycle. Skip them.

(ci.sh ...) reported "argv list is empty" for any zero-len table,
including string-keyed maps passed by mistake. One accurate message
handles both shapes.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change txuvqsskutzslxupornppqrlnzzuumsv
commit b27417452e323c7a4747d71f85ea76c82377175f
author Alpha Chen <alpha@kejadlen.dev>
date
parent rxpmmrtz
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index 410f412..15148c6 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -146,6 +146,10 @@ impl Cmd {
     /// Spawn this command with the given options, blocking until exit,
     /// and capture the result. Inherits the runner's env with
     /// `opts.env` merged on top.
+    //
+    // TODO: stream stdout/stderr live instead of buffering. `output()`
+    // captures the full child output in memory and only returns at exit,
+    // so long-running or chatty jobs show nothing until they finish.
     fn run(self, opts: ShOpts) -> std::io::Result<Output> {
         let mut command: std::process::Command = self.into();
         for (k, v) in opts.env {
@@ -155,6 +159,8 @@ impl Cmd {
             command.current_dir(cwd);
         }
         let output = command.output()?;
+        // Signal-killed processes have no exit code; collapse them to -1
+        // for now. Surfacing the signal as a separate field is future work.
         Ok(Output {
             exit: output.status.code().unwrap_or(-1),
             stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
@@ -171,10 +177,16 @@ impl mlua::FromLua for Cmd {
         match &value {
             mlua::Value::String(_) => lua.from_value(value),
             mlua::Value::Table(t) if t.raw_len() == 0 => {
+                // `raw_len() == 0` covers both an empty sequence (`[]`)
+                // and a string-keyed table (`{:env {...}}`) passed in
+                // place of an argv list. One message handles both.
                 Err(mlua::Error::FromLuaConversionError {
                     from: "table",
                     to: "Cmd".into(),
-                    message: Some("ci.sh: argv list is empty".into()),
+                    message: Some(
+                        "ci.sh: cmd must be a non-empty sequence of strings or a shell string"
+                            .into(),
+                    ),
                 })
             }
             mlua::Value::Table(_) => lua.from_value(value),
@@ -371,6 +383,29 @@ mod tests {
         );
     }
 
+    #[test]
+    fn ci_sh_rejects_non_sequence_table_as_cmd() {
+        let f = fennel();
+        let pipeline = load(
+            &f,
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [_] (ci.sh {:env {:FOO "bar"}})))"#,
+            "ci.fnl",
+            "ci.fnl",
+            HashMap::new(),
+        )
+        .expect("load should succeed");
+        let err = pipeline.jobs()[0]
+            .run_fn
+            .call::<mlua::Value>(())
+            .unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("sequence"),
+            "expected sequence-shape error, got: {msg}"
+        );
+    }
+
     #[test]
     fn ci_sh_rejects_empty_argv() {
         let f = fennel();
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 9bc0d0e..629b714 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -1,8 +1,8 @@
 //! CI: trigger runs from push events, validate the job graph.
 
 mod lua;
-pub mod pipeline;
-pub mod run;
+mod pipeline;
+mod run;
 
 pub use pipeline::{Job, LoadError, Pipeline, ValidationError};
 pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 0626ea9..2ba4a97 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -201,6 +201,7 @@ pub struct LoadError {
 /// here.
 fn validate_post_graph(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
     let mut errors = Vec::new();
+    let mut cycle_members: std::collections::HashSet<&str> = std::collections::HashSet::new();
 
     // Rule 1: acyclic.
     //
@@ -236,15 +237,27 @@ fn validate_post_graph(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationEr
             .filter_map(|&idx| jobs.iter().find(|j| j.id == graph[idx]))
             .collect();
         members.sort_by(|a, b| a.id.cmp(&b.id));
+        for j in &members {
+            cycle_members.insert(j.id.as_str());
+        }
         let cycle_jobs = members.iter().map(|j| j.id.clone()).collect();
         let spans = members.iter().map(|j| j.span).collect();
         errors.push(ValidationError::Cycle { cycle_jobs, spans });
     }
 
     // Rule 3: reachability — every job's transitive inputs must include a source ref.
+    //
+    // TODO: replace the `quire/` prefix check with a whitelist of real
+    // source refs (`quire/push`, etc.) once those are implemented, so
+    // typos like `:quire/posh` don't silently make a job "reachable."
     let is_source = |name: &str| name.starts_with("quire/");
 
     for job in jobs {
+        // Cycle members are already reported via Cycle; skip them here so
+        // a single bad cycle doesn't generate N+1 errors.
+        if cycle_members.contains(job.id.as_str()) {
+            continue;
+        }
         let mut visited = std::collections::HashSet::new();
         let mut stack: Vec<&str> = job.inputs.iter().map(|s| s.as_str()).collect();
         let mut found_source = false;
@@ -462,13 +475,36 @@ mod tests {
         );
     }
 
+    #[test]
+    fn validate_does_not_double_report_cycle_as_unreachable() {
+        // Jobs in a cycle are technically also unreachable from any
+        // source ref, but reporting both is noise. Cycle alone is enough.
+        let jobs = parsed_jobs(
+            r#"
+(local ci (require :quire.ci))
+(ci.job :a [:b] (fn [_] nil))
+(ci.job :b [:a] (fn [_] nil))
+"#,
+        );
+        let errs = validate_post_graph(&jobs).unwrap_err();
+        let unreachable_count = errs
+            .iter()
+            .filter(|e| matches!(e, ValidationError::Unreachable { .. }))
+            .count();
+        assert_eq!(
+            unreachable_count, 0,
+            "cycle members should not also be reported as unreachable: {errs:?}"
+        );
+    }
+
     #[test]
     fn validate_rejects_unreachable_jobs() {
-        // An orphan with non-empty self-input passes pre-graph rules
-        // and reaches the post-graph reachability check.
+        // A job whose only input names a non-existent job passes
+        // pre-graph rules (inputs is non-empty, id has no slash) and
+        // reaches the post-graph reachability check.
         let jobs = parsed_jobs(
             r#"(local ci (require :quire.ci))
-(ci.job :orphan [:orphan] (fn [_] nil))"#,
+(ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
         );
         let errs = validate_post_graph(&jobs).unwrap_err();
         assert!(