use crate::config::Level;
use crate::domain::file::parsed::{Job, ParsedWorkflow, Step};
use crate::domain::file::site::{JobId, StepIndex};
use crate::lint::{Context, Diagnostic, Rule, RuleName};
use crate::regex::static_regex;
use std::collections::BTreeSet;
pub struct InvalidExpressionRule;
static_regex!(
REF_RE,
r"(?:^|[^A-Za-z0-9_.])(needs|steps)\.([A-Za-z_][A-Za-z0-9_-]*)(?:\.outputs\.([A-Za-z_][A-Za-z0-9_-]*))?"
);
static_regex!(EXPR_SPAN_RE, r"\$\{\{(.*?)\}\}");
impl InvalidExpressionRule {
pub fn check_workflow(workflow: &ParsedWorkflow) -> Vec<Diagnostic> {
let mut out = Vec::new();
for job in &workflow.jobs {
let needs: BTreeSet<&str> = job.needs.iter().map(String::as_str).collect();
let mut declared_ids: BTreeSet<&str> = BTreeSet::new();
for (idx, step) in job.steps.iter().enumerate() {
for message in step_findings(workflow, job, &needs, &declared_ids, step) {
let mut diag =
Diagnostic::new(RuleName::InvalidExpression, Level::Error, message)
.with_workflow(workflow.path.clone())
.with_job(JobId::from(job.id.clone()));
if let Ok(si) = StepIndex::try_from(idx) {
diag = diag.with_step(si);
}
out.push(diag);
}
if let Some(id) = &step.id {
declared_ids.insert(id.as_str());
}
}
}
out
}
}
fn step_findings(
workflow: &ParsedWorkflow,
job: &Job,
needs: &BTreeSet<&str>,
declared_ids: &BTreeSet<&str>,
step: &Step,
) -> Vec<String> {
let mut out = Vec::new();
let mut scan = |text: &str| {
for span in EXPR_SPAN_RE.captures_iter(text) {
let inner = &span[1];
for cap in REF_RE.captures_iter(inner) {
if let Some(message) = resolve_ref(workflow, job, needs, declared_ids, &cap) {
out.push(message);
}
}
}
};
if let Some(if_cond) = &step.if_cond {
scan(if_cond);
}
for v in step.with.values() {
scan(v.as_str());
}
for v in step.env.values() {
scan(v.as_str());
}
if let Some(run) = &step.run {
scan(run);
}
out
}
fn resolve_ref(
workflow: &ParsedWorkflow,
job: &Job,
needs: &BTreeSet<&str>,
declared_ids: &BTreeSet<&str>,
cap: ®ex::Captures,
) -> Option<String> {
let context = &cap[1];
let id = &cap[2];
let output_key = cap.get(3).map(|m| m.as_str());
match context {
"needs" => resolve_needs(workflow, job, needs, id, output_key),
"steps" => resolve_steps(job, declared_ids, id),
_ => None,
}
}
fn resolve_needs(
workflow: &ParsedWorkflow,
job: &Job,
needs: &BTreeSet<&str>,
id: &str,
output_key: Option<&str>,
) -> Option<String> {
if !needs.contains(id) {
return Some(format!(
"job `{}` references `needs.{id}` but `{id}` is not in its `needs:` list — the reference resolves to nothing at run time",
job.id
));
}
let key = output_key?;
let producer = workflow.jobs.iter().find(|j| j.id == id)?;
if producer.outputs.is_empty() || producer.outputs.contains_key(key) {
return None;
}
Some(format!(
"job `{}` references `needs.{id}.outputs.{key}` but job `{id}` declares no `{key}` output — the reference resolves to nothing at run time",
job.id
))
}
fn resolve_steps(job: &Job, declared_ids: &BTreeSet<&str>, id: &str) -> Option<String> {
if declared_ids.contains(id) {
return None;
}
Some(format!(
"job `{}` references `steps.{id}` but no earlier step declares `id: {id}` — the reference resolves to nothing at run time",
job.id
))
}
impl Rule for InvalidExpressionRule {
fn name(&self) -> RuleName {
RuleName::InvalidExpression
}
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::file::parsed::Parsed;
use crate::domain::file::site::WorkflowPath;
fn parse(content: &str) -> ParsedWorkflow {
Parsed::from_yaml(WorkflowPath::new(".github/workflows/x.yml"), content).unwrap()
}
#[test]
fn rule_metadata() {
let r = InvalidExpressionRule;
assert_eq!(r.name(), RuleName::InvalidExpression);
assert_eq!(r.default_level(), Level::Error);
}
#[test]
fn expression_reads_undeclared_needs_job_is_flagged() {
let p = parse(
"on: push
jobs:
validate:
outputs:
id: ${{ steps.x.outputs.id }}
steps: []
compose:
steps: []
pr:
needs: [compose]
steps:
- run: echo ${{ needs.validate.outputs.id }}
",
);
let diags = InvalidExpressionRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].level, Level::Error);
assert!(diags[0].message.contains("needs.validate"));
assert_eq!(diags[0].job.as_ref().unwrap().as_str(), "pr");
}
#[test]
fn expression_reads_nonexistent_step_id_is_flagged() {
let p = parse(
"on: push
jobs:
build:
steps:
- run: echo ${{ steps.upload.outputs.artifact-id }}
",
);
let diags = InvalidExpressionRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("steps.upload"));
}
#[test]
fn expression_reads_valid_step_id_is_clean() {
let p = parse(
"on: push
jobs:
build:
steps:
- id: upload
run: echo hi
- run: echo ${{ steps.upload.outputs.artifact-id }}
",
);
assert!(InvalidExpressionRule::check_workflow(&p).is_empty());
}
#[test]
fn expression_reads_later_step_id_is_flagged() {
let p = parse(
"on: push
jobs:
build:
steps:
- run: echo ${{ steps.upload.outputs.x }}
- id: upload
run: echo hi
",
);
let diags = InvalidExpressionRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("steps.upload"));
}
#[test]
fn expression_reads_nonexistent_job_output_key_is_flagged() {
let p = parse(
"on: push
jobs:
build:
outputs:
sha: ${{ steps.x.outputs.sha }}
steps: []
deploy:
needs: [build]
steps:
- run: echo ${{ needs.build.outputs.shaa }}
",
);
let diags = InvalidExpressionRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("shaa"));
assert_eq!(diags[0].job.as_ref().unwrap().as_str(), "deploy");
}
#[test]
fn expression_reads_valid_job_output_key_is_clean() {
let p = parse(
"on: push
jobs:
build:
outputs:
sha: ${{ steps.x.outputs.sha }}
steps: []
deploy:
needs: [build]
steps:
- run: echo ${{ needs.build.outputs.sha }}
",
);
assert!(InvalidExpressionRule::check_workflow(&p).is_empty());
}
#[test]
fn output_key_of_reusable_workflow_job_is_not_flagged() {
let p = parse(
"on: push
jobs:
release:
uses: ./.github/workflows/release.yml
notify:
needs: [release]
steps:
- run: echo ${{ needs.release.outputs.url }}
",
);
assert!(InvalidExpressionRule::check_workflow(&p).is_empty());
}
#[test]
fn dynamic_reference_is_not_flagged() {
let p = parse(
"on: push
jobs:
build:
steps:
- run: echo ${{ needs[matrix.target].outputs.x }}
",
);
assert!(InvalidExpressionRule::check_workflow(&p).is_empty());
}
#[test]
fn out_of_scope_contexts_are_not_flagged() {
let p = parse(
"on: push
jobs:
build:
steps:
- run: echo ${{ env.FLUTTER_VERSION_PATH }} ${{ matrix.os }}
",
);
assert!(InvalidExpressionRule::check_workflow(&p).is_empty());
}
#[test]
fn bare_needs_without_declared_needs_is_flagged() {
let p = parse(
"on: push
jobs:
build:
steps: []
deploy:
steps:
- run: echo ${{ needs.build.outputs.x }}
",
);
let diags = InvalidExpressionRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("needs.build"));
}
#[test]
fn longer_identifier_is_not_mistaken_for_a_prefix() {
let p = parse(
"on: push
jobs:
build:
steps: []
deploy:
needs: [build]
steps:
- run: echo ${{ needs.build-extra.outputs.x }}
",
);
let diags = InvalidExpressionRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("build-extra"));
}
}