Label post-graph CI errors with source spans
`Cycle` carries `Vec<SourceSpan>` (one per member) and `Unreachable`
carries `SourceSpan`, both wired through miette's `#[label]` so
each violation points at the offending `(ci:job …)` call. `Job`
keeps only its span — `line` is dropped as redundant; the test
derives the line from `span.offset()` when needed.

Assisted-by: Claude Opus 4.7 via Claude Code
change nxykkpyystmtrmxlmsuzmrzuswxkluno
commit 29520dd57d5eac877b1bdfd800ae02332ca0f65a
author Alpha Chen <alpha@kejadlen.dev>
date
parent ozvwlvuw
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index c406e76..9c81657 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -19,9 +19,9 @@ use crate::fennel::Fennel;
 pub struct Job {
     pub id: String,
     pub inputs: Vec<String>,
-    /// 1-indexed line in the source where `(ci:job …)` was called.
-    /// `0` means the line could not be determined.
-    pub line: u32,
+    /// Span covering the `(ci:job …)` call site. Used as the label
+    /// location for both per-job and post-graph diagnostics.
+    pub(crate) span: SourceSpan,
     /// The job's run function from the Lua VM.
     /// Stored for future execution — not yet called.
     #[expect(dead_code)]
@@ -53,7 +53,7 @@ impl Job {
         Ok(Self {
             id,
             inputs,
-            line,
+            span,
             run_fn,
         })
     }
@@ -202,7 +202,11 @@ fn span_for_line(source: &str, line: u32) -> SourceSpan {
 #[derive(Debug, thiserror::Error, miette::Diagnostic)]
 pub enum ValidationError {
     #[error("Cycle detected among jobs: {}", cycle_jobs.join(", "))]
-    Cycle { cycle_jobs: Vec<String> },
+    Cycle {
+        cycle_jobs: Vec<String>,
+        #[label(collection, "in cycle")]
+        spans: Vec<SourceSpan>,
+    },
 
     #[error(
         "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
@@ -214,7 +218,11 @@ pub enum ValidationError {
     },
 
     #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
-    Unreachable { job_id: String },
+    Unreachable {
+        job_id: String,
+        #[label("declared here")]
+        span: SourceSpan,
+    },
 
     #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
     ReservedSlash {
@@ -276,9 +284,14 @@ fn validate_post_graph(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationEr
         if !is_cycle {
             continue;
         }
-        let mut cycle_jobs: Vec<String> = scc.iter().map(|&idx| graph[idx].to_string()).collect();
-        cycle_jobs.sort();
-        errors.push(ValidationError::Cycle { cycle_jobs });
+        let mut members: Vec<&Job> = scc
+            .iter()
+            .filter_map(|&idx| jobs.iter().find(|j| j.id == graph[idx]))
+            .collect();
+        members.sort_by(|a, b| a.id.cmp(&b.id));
+        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.
@@ -307,6 +320,7 @@ fn validate_post_graph(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationEr
         if !found_source {
             errors.push(ValidationError::Unreachable {
                 job_id: job.id.clone(),
+                span: job.span,
             });
         }
     }
@@ -365,7 +379,11 @@ mod tests {
 
 (ci:job :sixth [:quire/push] (fn [_] nil))";
         let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("load should succeed");
-        let lines: Vec<u32> = pipeline.jobs().iter().map(|j| j.line).collect();
+        let lines: Vec<usize> = pipeline
+            .jobs()
+            .iter()
+            .map(|j| 1 + source[..j.span.offset()].matches('\n').count())
+            .collect();
         assert_eq!(lines, vec![2, 3, 6]);
     }
 
@@ -416,7 +434,7 @@ mod tests {
         );
         let errs = validate_post_graph(&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()))),
+            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:?}"
         );
     }
@@ -435,7 +453,7 @@ mod tests {
         let cycle_errs: Vec<&Vec<String>> = errs
             .iter()
             .filter_map(|e| match e {
-                ValidationError::Cycle { cycle_jobs } => Some(cycle_jobs),
+                ValidationError::Cycle { cycle_jobs, .. } => Some(cycle_jobs),
                 _ => None,
             })
             .collect();
@@ -505,7 +523,7 @@ mod tests {
         let errs = validate_post_graph(&jobs).unwrap_err();
         assert!(
             errs.iter().any(
-                |e| matches!(e, ValidationError::Unreachable { job_id } if job_id == "orphan")
+                |e| matches!(e, ValidationError::Unreachable { job_id, .. } if job_id == "orphan")
             ),
             "should report unreachable job 'orphan': {errs:?}"
         );