Validate ci.fnl at load time, against the working copy
`pipeline::load` now parses and validates in one step, so `Pipeline`
is opaque and validated by construction. The validate command
resolves `@` rather than `@-` so uncommitted edits are inspected,
and `#[related]` moves onto the `ValidationError` vec field so
miette renders each violation under the top-level diagnostic.

Assisted-by: Claude Opus 4.7 via Claude Code
change tosuurqvuqwtrvsxrkmrotzkymkplwpm
commit a60eb1eda62692e9c5b974737bd74845ef50bade
author Alpha Chen <alpha@kejadlen.dev>
date
parent mtzrxmnm
diff --git a/build.rs b/build.rs
index ad48ba1..21a6237 100644
--- a/build.rs
+++ b/build.rs
@@ -5,7 +5,14 @@ fn main() {
         let date = cmd("date", &["-u", "+%Y-%m-%d"]);
         let change = cmd(
             "jj",
-            &["log", "-r", "@", "--no-graph", "-T", "change_id.short()"],
+            &[
+                "log",
+                "--revisions",
+                "@",
+                "--no-graph",
+                "--template",
+                "change_id.short()",
+            ],
         );
         format!("{date}+{change}-dev")
     });
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 9dec87c..71d0d83 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -5,9 +5,9 @@ use quire::ci::{Ci, CommitRef};
 
 /// Validate a repo's ci.fnl without executing any jobs.
 ///
-/// Loads the Fennel source at the given SHA (or HEAD) to extract
-/// the job registration table, then runs the four structural validations.
-/// Prints each job found and any validation errors.
+/// `Ci::load` parses the Fennel source and validates the resulting
+/// job graph; this command surfaces the registered jobs and any
+/// validation errors via the standard miette diagnostic path.
 pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
     let repo_path = discover_repo()?;
     let commit = resolve_commit(maybe_sha)?;
@@ -30,33 +30,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
         println!("  {} ← [{}]", job.id, inputs);
     }
 
-    match pipeline.validate() {
-        Ok(()) => {
-            println!("\nAll validations passed.");
-        }
-        Err(errors) => {
-            println!("\nValidation errors:");
-            for err in &errors {
-                let label = match err {
-                    quire::ci::ValidationError::Cycle { cycle_jobs } => {
-                        format!("cycle: {}", cycle_jobs.join(" → "))
-                    }
-                    quire::ci::ValidationError::EmptyInputs { job_id } => {
-                        format!("{job_id}: empty inputs")
-                    }
-                    quire::ci::ValidationError::Unreachable { job_id } => {
-                        format!("{job_id}: unreachable from any source ref")
-                    }
-                    quire::ci::ValidationError::ReservedSlash { job_id } => {
-                        format!("{job_id}: '/' in job id")
-                    }
-                };
-                println!("  ✗ {label}");
-            }
-            std::process::exit(1);
-        }
-    }
-
+    println!("\nAll validations passed.");
     Ok(())
 }
 
@@ -92,7 +66,10 @@ fn discover_repo() -> Result<PathBuf> {
     Ok(PathBuf::from(path.trim()))
 }
 
-/// Get the git commit ID of the latest committed revision via jj.
+/// Get the git commit ID of the working copy revision via jj.
+///
+/// Resolves `@`, which jj snapshots into a real commit on every
+/// invocation, so the SHA reflects uncommitted edits.
 fn current_commit() -> Result<String> {
     let output = std::process::Command::new("jj")
         .args([
@@ -100,9 +77,9 @@ fn current_commit() -> Result<String> {
             "--limit",
             "1",
             "--no-graph",
-            "-r",
-            "@-",
-            "-T",
+            "--revisions",
+            "@",
+            "--template",
             "commit_id",
         ])
         .output()
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 7e15ff7..e82bfc7 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -45,9 +45,11 @@ impl Ci {
         Runs::new(runs_base)
     }
 
-    /// Load ci.fnl at a given SHA and return the parsed pipeline.
+    /// Load ci.fnl at a given SHA and return the validated pipeline.
     ///
     /// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
+    /// Errors if the Fennel source fails to parse/evaluate or if the
+    /// resulting job graph violates any structural rule.
     pub fn load(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
         let Some(source) = self.source(&commit.sha)? else {
             return Ok(None);
@@ -146,21 +148,12 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
     let fennel = crate::fennel::Fennel::new()?;
     let name = CI_FNL.to_string();
     let lua_name = format!("{}:{CI_FNL}", push_ref.new_sha);
-    let pipeline = match pipeline::load(&fennel, &source, &lua_name, &name) {
-        Ok(r) => r,
-        Err(e) => {
-            run.transition(RunState::Failed)?;
-            return Err(e);
-        }
-    };
 
-    match pipeline.validate() {
-        Ok(()) => {
-            run.transition(RunState::Complete)?;
-        }
+    match pipeline::load(&fennel, &source, &lua_name, &name) {
+        Ok(_pipeline) => run.transition(RunState::Complete)?,
         Err(e) => {
             run.transition(RunState::Failed)?;
-            Err(e)?;
+            return Err(e);
         }
     }
 
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 392dff6..c656124 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -18,27 +18,20 @@ pub struct Job {
     pub(crate) run_fn: mlua::Function,
 }
 
-/// A loaded CI pipeline — the parsed job graph from ci.fnl.
+/// A validated CI pipeline — a job graph that has passed all
+/// structural rules.
 ///
-/// Returned by `Ci::load`. Holds the registered jobs and their
-/// run functions. Call `validate` to check structural rules before
-/// execution.
+/// Obtain via `pipeline::load`, which parses the Fennel source and
+/// validates the result. Holding a `Pipeline` is proof that the graph
+/// is sound.
 pub struct Pipeline {
-    pub(crate) jobs: Vec<Job>,
+    jobs: Vec<Job>,
 }
 
 impl Pipeline {
     pub fn jobs(&self) -> &[Job] {
         &self.jobs
     }
-
-    /// Validate the structural rules of this pipeline.
-    ///
-    /// Returns `Ok(())` if all four rules pass, or `Err` with all
-    /// violations found.
-    pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
-        validate(&self.jobs)
-    }
 }
 
 /// The `quire.ci` module exposed to Fennel scripts via `require`.
@@ -65,16 +58,25 @@ impl UserData for CiModule {
     }
 }
 
-/// Load a ci.fnl source string, registering jobs via the `quire.ci` module.
+/// Parse and validate a ci.fnl source string into a `Pipeline`.
 ///
 /// Injects `quire.ci` into `package.loaded` so scripts can
-/// `(require :quire.ci)`, evaluates the source, and takes the accumulated jobs.
+/// `(require :quire.ci)`, evaluates the source to register jobs, and
+/// then runs the structural validations.
 pub(crate) fn load(
     fennel: &Fennel,
     source: &str,
     filename: &str,
     display: &str,
 ) -> Result<Pipeline> {
+    let jobs = parse(fennel, source, filename, display)?;
+    validate(&jobs)?;
+    Ok(Pipeline { jobs })
+}
+
+/// Evaluate `source` with the `quire.ci` module bound and collect the
+/// registered jobs without validating them.
+fn parse(fennel: &Fennel, source: &str, filename: &str, display: &str) -> Result<Vec<Job>> {
     let jobs = Rc::new(RefCell::new(Vec::new()));
 
     fennel.eval_raw(source, filename, display, |lua| {
@@ -83,7 +85,7 @@ pub(crate) fn load(
         Ok(())
     })?;
 
-    Ok(Pipeline { jobs: jobs.take() })
+    Ok(jobs.take())
 }
 
 /// A validation error found in the job graph.
@@ -107,6 +109,7 @@ pub enum ValidationError {
 /// Validate the structural rules of a job graph.
 ///
 /// Returns `Ok(())` if all four rules pass, or `Err` with all violations found.
+/// Used by `pipeline::load`.
 fn validate(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
     let mut errors = Vec::new();
 
@@ -212,10 +215,11 @@ mod tests {
         let f = fennel();
         let source = r#"(local ci (require :quire.ci))
 (ci:job :test [:quire/push] (fn [_] nil))"#;
-        let result = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        assert_eq!(result.jobs.len(), 1);
-        assert_eq!(result.jobs[0].id, "test");
-        assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
+        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("load should succeed");
+        let jobs = pipeline.jobs();
+        assert_eq!(jobs.len(), 1);
+        assert_eq!(jobs[0].id, "test");
+        assert_eq!(jobs[0].inputs, vec!["quire/push"]);
     }
 
     #[test]
@@ -226,12 +230,13 @@ mod tests {
 (ci:job :build [:quire/push] (fn [_] nil))
 (ci:job :test [:build] (fn [_] nil))
 "#;
-        let result = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        assert_eq!(result.jobs.len(), 2);
-        assert_eq!(result.jobs[0].id, "build");
-        assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
-        assert_eq!(result.jobs[1].id, "test");
-        assert_eq!(result.jobs[1].inputs, vec!["build"]);
+        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("load should succeed");
+        let jobs = pipeline.jobs();
+        assert_eq!(jobs.len(), 2);
+        assert_eq!(jobs[0].id, "build");
+        assert_eq!(jobs[0].inputs, vec!["quire/push"]);
+        assert_eq!(jobs[1].id, "test");
+        assert_eq!(jobs[1].inputs, vec!["build"]);
     }
 
     #[test]
@@ -241,28 +246,35 @@ mod tests {
         assert!(result.is_err(), "malformed Fennel should fail");
     }
 
+    /// Parse a Fennel source into raw jobs without validating, for tests
+    /// that exercise individual validation rules.
+    fn parse_jobs(source: &str) -> Vec<Job> {
+        let f = fennel();
+        parse(&f, source, "ci.fnl", "ci.fnl").expect("parse should succeed")
+    }
+
     #[test]
     fn validate_accepts_valid_config() {
-        let f = fennel();
-        let source = r#"
+        let jobs = parse_jobs(
+            r#"
 (local ci (require :quire.ci))
 (ci:job :build [:quire/push] (fn [_] nil))
 (ci:job :test [:build :quire/push] (fn [_] nil))
-"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        assert!(pipeline.validate().is_ok());
+"#,
+        );
+        assert!(validate(&jobs).is_ok());
     }
 
     #[test]
     fn validate_rejects_cycle() {
-        let f = fennel();
-        let source = r#"
+        let jobs = parse_jobs(
+            r#"
 (local ci (require :quire.ci))
 (ci:job :a [:b] (fn [_] nil))
 (ci:job :b [:a] (fn [_] nil))
-"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        let errs = pipeline.validate().unwrap_err();
+"#,
+        );
+        let errs = validate(&jobs).unwrap_err();
         assert!(
             errs.iter().any(|e| matches!(e, ValidationError::Cycle { cycle_jobs } if cycle_jobs.contains(&"a".to_string()) && cycle_jobs.contains(&"b".to_string()))),
             "should report a cycle involving a and b: {errs:?}"
@@ -271,15 +283,15 @@ mod tests {
 
     #[test]
     fn validate_cycle_only_reports_cycle_members() {
-        let f = fennel();
-        let source = r#"
+        let jobs = parse_jobs(
+            r#"
 (local ci (require :quire.ci))
 (ci:job :a [:b :quire/push] (fn [_] nil))
 (ci:job :b [:a :quire/push] (fn [_] nil))
 (ci:job :clean [:quire/push] (fn [_] nil))
-"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        let errs = pipeline.validate().unwrap_err();
+"#,
+        );
+        let errs = validate(&jobs).unwrap_err();
         let cycle_errs: Vec<&Vec<String>> = errs
             .iter()
             .filter_map(|e| match e {
@@ -297,16 +309,16 @@ mod tests {
 
     #[test]
     fn validate_reports_disjoint_cycles_separately() {
-        let f = fennel();
-        let source = r#"
+        let jobs = parse_jobs(
+            r#"
 (local ci (require :quire.ci))
 (ci:job :a [:b :quire/push] (fn [_] nil))
 (ci:job :b [:a :quire/push] (fn [_] nil))
 (ci:job :c [:d :quire/push] (fn [_] nil))
 (ci:job :d [:c :quire/push] (fn [_] nil))
-"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        let errs = pipeline.validate().unwrap_err();
+"#,
+        );
+        let errs = validate(&jobs).unwrap_err();
         let cycle_count = errs
             .iter()
             .filter(|e| matches!(e, ValidationError::Cycle { .. }))
@@ -316,11 +328,11 @@ mod tests {
 
     #[test]
     fn validate_rejects_empty_inputs() {
-        let f = fennel();
-        let source = r#"(local ci (require :quire.ci))
-(ci:job :setup [] (fn [_] nil))"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        let errs = pipeline.validate().unwrap_err();
+        let jobs = parse_jobs(
+            r#"(local ci (require :quire.ci))
+(ci:job :setup [] (fn [_] nil))"#,
+        );
+        let errs = validate(&jobs).unwrap_err();
         assert!(
             errs.iter()
                 .any(|e| matches!(e, ValidationError::EmptyInputs { job_id } if job_id == "setup")),
@@ -330,11 +342,11 @@ mod tests {
 
     #[test]
     fn validate_rejects_unreachable_jobs() {
-        let f = fennel();
-        let source = r#"(local ci (require :quire.ci))
-(ci:job :orphan [:orphan] (fn [_] nil))"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        let errs = pipeline.validate().unwrap_err();
+        let jobs = parse_jobs(
+            r#"(local ci (require :quire.ci))
+(ci:job :orphan [:orphan] (fn [_] nil))"#,
+        );
+        let errs = validate(&jobs).unwrap_err();
         assert!(
             errs.iter().any(
                 |e| matches!(e, ValidationError::Unreachable { job_id } if job_id == "orphan")
@@ -345,11 +357,11 @@ mod tests {
 
     #[test]
     fn validate_rejects_slash_in_job_id() {
-        let f = fennel();
-        let source = r#"(local ci (require :quire.ci))
-(ci:job :foo/bar [:quire/push] (fn [_] nil))"#;
-        let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("eval should succeed");
-        let errs = pipeline.validate().unwrap_err();
+        let jobs = parse_jobs(
+            r#"(local ci (require :quire.ci))
+(ci:job :foo/bar [:quire/push] (fn [_] nil))"#,
+        );
+        let errs = validate(&jobs).unwrap_err();
         assert!(
             errs.iter().any(
                 |e| matches!(e, ValidationError::ReservedSlash { job_id } if job_id == "foo/bar")
diff --git a/src/error.rs b/src/error.rs
index 85a882c..ea9eca9 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -21,8 +21,7 @@ pub enum Error {
     Fennel(#[from] Box<FennelError>),
 
     #[error("CI validation failed")]
-    #[related]
-    Validation(Vec<ValidationError>),
+    Validation(#[related] Vec<ValidationError>),
 
     #[error("invalid run transition: {from:?} -> {to:?}")]
     InvalidTransition { from: RunState, to: RunState },