use crate::config::Level;
use crate::domain::workflow_actions::JobId;
use crate::domain::workflow_parsed::Parsed;
use crate::lint::{Context, Diagnostic, Rule, RuleName};
use std::collections::BTreeSet;
pub struct DanglingReferenceRule;
impl DanglingReferenceRule {
pub fn check_workflow(workflow: &Parsed) -> Vec<Diagnostic> {
let job_ids: BTreeSet<&str> = workflow.jobs.iter().map(|j| j.id.as_str()).collect();
let mut out = Vec::new();
for job in &workflow.jobs {
for needed in &job.needs {
if !job_ids.contains(needed.as_str()) {
let msg = format!(
"job `{}` needs job `{}` which does not exist in this workflow",
job.id, needed
);
out.push(
Diagnostic::new(RuleName::DanglingReference, Level::Error, msg)
.with_workflow(workflow.path.clone())
.with_job(JobId::from(job.id.clone())),
);
}
}
}
out
}
}
impl Rule for DanglingReferenceRule {
fn name(&self) -> RuleName {
RuleName::DanglingReference
}
fn default_level(&self) -> Level {
Level::Error
}
fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
ctx.workflows_full
.iter()
.flat_map(Self::check_workflow)
.collect()
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap and indexing freely"
)]
mod tests {
use super::*;
use crate::domain::workflow_actions::WorkflowPath;
fn parse(content: &str) -> Parsed {
Parsed::from_yaml(WorkflowPath::new(".github/workflows/ci.yml"), content).unwrap()
}
#[test]
fn rule_metadata() {
let r = DanglingReferenceRule;
assert_eq!(r.name(), RuleName::DanglingReference);
assert_eq!(r.default_level(), Level::Error);
}
#[test]
fn needs_nonexistent_job_is_flagged() {
let p = parse(
"on: push
jobs:
build:
steps: []
deploy:
needs: [buld]
steps: []
",
);
let diags = DanglingReferenceRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].level, Level::Error);
assert!(diags[0].message.contains("buld"));
assert_eq!(diags[0].job.as_ref().unwrap().as_str(), "deploy");
}
#[test]
fn needs_scalar_to_real_job_is_clean() {
let p = parse(
"on: push
jobs:
build:
steps: []
test:
needs: build
steps: []
",
);
assert!(DanglingReferenceRule::check_workflow(&p).is_empty());
}
#[test]
fn sequence_with_one_bad_entry_flags_only_the_bad_one() {
let p = parse(
"on: push
jobs:
build:
steps: []
lint:
steps: []
deploy:
needs: [build, lnit]
steps: []
",
);
let diags = DanglingReferenceRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("lnit"));
assert!(!diags[0].message.contains("build"));
}
#[test]
fn job_with_no_needs_is_clean() {
let p = parse("on: push\njobs:\n build:\n steps: []\n");
assert!(DanglingReferenceRule::check_workflow(&p).is_empty());
}
}