Skip to main content

ci_bill/
findings.rs

1use std::ffi::OsStr;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use crate::money;
6use crate::usage::UsageRow;
7use crate::workflow::{
8    load_action, locate_action, repository_root, scan_yaml, ActionStep, Job, LocalAction, Matrix,
9    RemoteWorkflow, ReusableCall, ReusableSource, RunsOn, ScanError, Workflow,
10};
11
12/// Why a workflow or job was flagged.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14pub enum FindingKind {
15    /// Lint-class job on a macOS or Windows runner.
16    LintOnRunner,
17    /// Job has no `timeout-minutes`.
18    MissingTimeout,
19    /// `pull_request` or `pull_request_target` without cancel-in-progress.
20    PullRequestConcurrency,
21    /// Matrix expands to at least six billed jobs, or an axis length is an expression.
22    MatrixJobs,
23    /// The same mapping key is written more than once. The later value is kept.
24    DuplicateKey,
25    /// A usage row disagrees with quantity * unit price, or with gross - discount.
26    InvoiceMismatch,
27    /// Job calls `./.github/workflows/<file>` and that file is not in the scan.
28    CalleeNotFound,
29    /// A reusable workflow calls back into a workflow already being expanded.
30    ReusableCycle,
31    /// Remote reusable workflow. Shown on the `uses:` line when the cache has no file.
32    RemoteWorkflow,
33    /// Job-level `uses:` is not a local or remote reusable workflow.
34    InvalidUses,
35    /// Step `uses: ./path` and that directory has no `action.yml` or `action.yaml`.
36    ActionNotFound,
37    /// A composite action calls an action already being expanded.
38    ActionCycle,
39    /// Extra execution of the same job inside one workflow run.
40    RerunWaste,
41    /// Minutes an earlier run still billed after a later run of the same
42    /// workflow had started on the same branch or pull request.
43    OverlapSavings,
44}
45
46impl FindingKind {
47    /// Short id used in the text report.
48    pub fn code(self) -> &'static str {
49        match self {
50            Self::LintOnRunner => "F1",
51            Self::MissingTimeout => "F2",
52            Self::PullRequestConcurrency => "F3",
53            Self::MatrixJobs => "F4",
54            Self::DuplicateKey => "F8",
55            Self::InvoiceMismatch => "F9",
56            Self::CalleeNotFound => "F6",
57            Self::ReusableCycle => "F7",
58            Self::RemoteWorkflow => "F10",
59            Self::InvalidUses => "F11",
60            Self::ActionNotFound => "F12",
61            Self::ActionCycle => "F13",
62            Self::RerunWaste => "F5",
63            Self::OverlapSavings => "F3$",
64        }
65    }
66}
67
68/// How `savings` was priced.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum SavingsMethod {
71    /// Paid minutes minus the same minutes at the Linux rate.
72    LinuxRate,
73    /// One stuck run: 360 minutes times the runner rate times the matrix size.
74    StuckRun,
75    /// Minutes cancel-in-progress would have stopped billing.
76    Overlap,
77    /// Billed minutes above the raw runtime, at the runner rate.
78    Rounding,
79    /// Extra execution of a job inside one workflow run.
80    Rerun,
81    /// The finding does not change the bill. Savings are zero.
82    #[default]
83    Recorded,
84}
85
86impl SavingsMethod {
87    /// Stable name stored on the finding and in JSON.
88    pub fn as_str(self) -> &'static str {
89        match self {
90            Self::LinuxRate => "linux_rate",
91            Self::StuckRun => "stuck_run",
92            Self::Overlap => "overlap",
93            Self::Rounding => "rounding",
94            Self::Rerun => "rerun",
95            Self::Recorded => "recorded",
96        }
97    }
98}
99
100/// One cause, pointing at a file line.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Finding {
103    pub path: std::path::PathBuf,
104    pub line: usize,
105    pub kind: FindingKind,
106    pub message: String,
107    /// Set on invoice findings so the row stays on its repository workflow.
108    pub repository: Option<String>,
109    /// Workflow path this finding is listed under.
110    ///
111    /// Invoice findings set this together with `repository`. Findings copied
112    /// from a local reusable workflow set it to the caller
113    /// (`.github/workflows/<file>`) and leave `repository` empty. `path` and
114    /// `line` stay on the callee file.
115    pub workflow_path: Option<String>,
116    /// USD this finding would avoid.
117    pub savings: money::Micros,
118    /// Rule that produced `savings`.
119    pub method: SavingsMethod,
120    /// `runs-on` line when `line` points at an action step instead.
121    pub(crate) anchor: Option<usize>,
122}
123
124impl Finding {
125    /// A finding with zero savings until [`crate::savings::apply`] prices it.
126    pub(crate) fn record(
127        path: impl Into<PathBuf>,
128        line: usize,
129        kind: FindingKind,
130        message: impl Into<String>,
131    ) -> Self {
132        Self {
133            path: path.into(),
134            line,
135            kind,
136            message: message.into(),
137            repository: None,
138            workflow_path: None,
139            savings: money::Micros::ZERO,
140            method: SavingsMethod::Recorded,
141            anchor: None,
142        }
143    }
144
145    pub(crate) fn placed(
146        path: impl Into<PathBuf>,
147        line: usize,
148        kind: FindingKind,
149        message: impl Into<String>,
150        repository: Option<String>,
151        workflow_path: Option<String>,
152    ) -> Self {
153        let mut finding = Self::record(path, line, kind, message);
154        finding.repository = repository;
155        finding.workflow_path = workflow_path;
156        finding
157    }
158}
159
160const LINT_TOOLS: [&str; 5] = ["lint", "fmt", "clippy", "eslint", "prettier"];
161
162/// Findings for the scanned workflows, ordered by path, line, and kind.
163///
164/// A job `uses: ./.github/workflows/<file>.yml` (or `.yaml`) loads that file
165/// from this scan. A job `uses: owner/repo/.github/workflows/<file>.yml@ref`
166/// loads `<owner>/<repo>/<path>@<ref>` from the runs cache. Findings for the
167/// callee are listed on the caller. The finding file and line stay on the
168/// callee. A missing local file, a call cycle, an invalid uses value, and a
169/// remote call with no cache file are findings on the `uses:` line. Costs stay
170/// on the caller.
171///
172/// A step `uses: ./path` loads `action.yml` or `action.yaml` from that
173/// directory under the repository root. Composite steps count toward lint
174/// detection. The finding file and line are the action step. A missing
175/// directory or action file is a finding. Docker and Node actions stay on the
176/// step and are not expanded. Invalid action YAML is an error.
177pub fn findings(workflows: &[Workflow]) -> Result<Vec<Finding>, ScanError> {
178    findings_with_cache(workflows, None)
179}
180
181/// [`findings`] plus remote reusable workflows stored under `runs`.
182///
183/// `runs` is the `--runs` directory. A missing callee file is a finding. An
184/// unreadable or invalid cache file is an error and names that file.
185pub fn findings_with_cache(
186    workflows: &[Workflow],
187    runs: Option<&Path>,
188) -> Result<Vec<Finding>, ScanError> {
189    let mut found = Vec::new();
190    for workflow in workflows {
191        emit_workflow_findings(&mut found, workflow, None)?;
192        let mut stack = vec![CallFrame {
193            path: workflow.path.clone(),
194            label: display_name(&workflow.path).to_string(),
195        }];
196        let expand = Expand {
197            workflows,
198            root: workflow,
199            caller_key: usage_key(&workflow.path),
200            runs,
201            repo: None,
202        };
203        expand_calls(&mut found, workflow, &mut stack, &expand)?;
204    }
205    dedupe_findings(&mut found);
206    found.sort_by(cmp_finding);
207    Ok(found)
208}
209
210fn emit_workflow_findings(
211    found: &mut Vec<Finding>,
212    workflow: &Workflow,
213    caller_key: Option<&str>,
214) -> Result<(), ScanError> {
215    let start = found.len();
216    if pulls_without_cancel(workflow) {
217        if let Some(line) = workflow.on_line {
218            push(
219                found,
220                &workflow.path,
221                line,
222                FindingKind::PullRequestConcurrency,
223                pr_cancel_message(workflow),
224            );
225        }
226    }
227    for dup in &workflow.duplicate_keys {
228        push(
229            found,
230            &workflow.path,
231            dup.second,
232            FindingKind::DuplicateKey,
233            &duplicate_message(&dup.key, dup.first, dup.second),
234        );
235    }
236    for job in &workflow.jobs {
237        if let Some(invalid) = &job.invalid_uses {
238            push_at(
239                found,
240                &workflow.path,
241                invalid.line,
242                FindingKind::InvalidUses,
243                &invalid_uses_message(&job.name, &invalid.detail),
244                Some(usage_key(&workflow.path)),
245            );
246        }
247        if job.uses.is_none() && job.invalid_uses.is_none() {
248            note_job(found, &workflow.path, job);
249            note_local_actions(found, workflow, job)?;
250        }
251    }
252    if let Some(key) = caller_key {
253        for item in &mut found[start..] {
254            item.workflow_path = Some(key.to_string());
255        }
256    }
257    Ok(())
258}
259
260fn duplicate_message(key: &str, first: usize, second: usize) -> String {
261    format!("duplicate key {key} (line {first} and {second})")
262}
263
264struct CallFrame {
265    path: PathBuf,
266    label: String,
267}
268
269struct Expand<'a> {
270    workflows: &'a [Workflow],
271    root: &'a Workflow,
272    caller_key: String,
273    runs: Option<&'a Path>,
274    /// Set while expanding a file loaded from the runs cache.
275    repo: Option<RemoteWorkflow>,
276}
277
278impl Expand<'_> {
279    fn with_repo(&self, repo: RemoteWorkflow) -> Expand<'_> {
280        Expand {
281            workflows: self.workflows,
282            root: self.root,
283            caller_key: self.caller_key.clone(),
284            runs: self.runs,
285            repo: Some(repo),
286        }
287    }
288}
289
290/// What to record when a cache file is absent.
291#[derive(Clone, Copy, PartialEq, Eq)]
292enum CacheMiss {
293    /// Remote call: list the job, the uses line, and the full uses text.
294    ShowJob,
295    /// Local call inside a cached repo: the callee file is not in the cache.
296    NotFound,
297}
298
299/// Walk `uses:` from `current`, attributing every callee finding to `root`.
300///
301/// The stack is the chain of files already entered. A call back into that
302/// chain records a cycle and returns, so a loop cannot run forever.
303fn expand_calls(
304    found: &mut Vec<Finding>,
305    current: &Workflow,
306    stack: &mut Vec<CallFrame>,
307    exp: &Expand<'_>,
308) -> Result<(), ScanError> {
309    for job in &current.jobs {
310        let Some(call) = &job.uses else {
311            continue;
312        };
313        if let Some(matrix) = &job.matrix {
314            if let Some(message) = matrix_message(matrix) {
315                push_at(
316                    found,
317                    &current.path,
318                    matrix.line,
319                    FindingKind::MatrixJobs,
320                    &message,
321                    Some(exp.caller_key.clone()),
322                );
323            }
324        }
325        let site = CallSite { current, job, call };
326        match &call.source {
327            ReusableSource::Local => expand_local(found, site, stack, exp)?,
328            ReusableSource::Remote(remote) => {
329                enter_cached(found, site, stack, exp, remote, CacheMiss::ShowJob)?;
330            }
331        }
332    }
333    Ok(())
334}
335
336#[derive(Clone, Copy)]
337struct CallSite<'a> {
338    current: &'a Workflow,
339    job: &'a Job,
340    call: &'a ReusableCall,
341}
342
343fn expand_local(
344    found: &mut Vec<Finding>,
345    site: CallSite<'_>,
346    stack: &mut Vec<CallFrame>,
347    exp: &Expand<'_>,
348) -> Result<(), ScanError> {
349    let CallSite { current, job, call } = site;
350    if let Some(repo) = &exp.repo {
351        let remote = RemoteWorkflow {
352            owner: repo.owner.clone(),
353            repo: repo.repo.clone(),
354            workflow_path: format!(".github/workflows/{}", call.file_name),
355            reference: repo.reference.clone(),
356        };
357        return enter_cached(
358            found,
359            CallSite { current, job, call },
360            stack,
361            exp,
362            &remote,
363            CacheMiss::NotFound,
364        );
365    }
366    match find_callee(exp.workflows, &call.file_name) {
367        None => push_at(
368            found,
369            &current.path,
370            call.line,
371            FindingKind::CalleeNotFound,
372            &missing_callee_message(&job.name, &call.spec),
373            Some(exp.caller_key.clone()),
374        ),
375        Some(callee) => {
376            if stack.iter().any(|frame| frame.path == callee.path) {
377                push_at(
378                    found,
379                    &current.path,
380                    call.line,
381                    FindingKind::ReusableCycle,
382                    &cycle_message(stack, display_name(&callee.path)),
383                    Some(exp.caller_key.clone()),
384                );
385                return Ok(());
386            }
387            emit_workflow_findings(found, callee, Some(&exp.caller_key))?;
388            stack.push(CallFrame {
389                path: callee.path.clone(),
390                label: display_name(&callee.path).to_string(),
391            });
392            expand_calls(found, callee, stack, exp)?;
393            stack.pop();
394        }
395    }
396    Ok(())
397}
398
399fn enter_cached(
400    found: &mut Vec<Finding>,
401    site: CallSite<'_>,
402    stack: &mut Vec<CallFrame>,
403    exp: &Expand<'_>,
404    remote: &RemoteWorkflow,
405    miss: CacheMiss,
406) -> Result<(), ScanError> {
407    let CallSite { current, job, call } = site;
408    let Some(runs) = exp.runs else {
409        note_cache_miss(found, site, exp, miss);
410        return Ok(());
411    };
412    let Some(path) = find_cached_file(runs, &remote.cache_relative())? else {
413        note_cache_miss(found, site, exp, miss);
414        return Ok(());
415    };
416    if stack.iter().any(|frame| frame.path == path) {
417        push_at(
418            found,
419            &current.path,
420            call.line,
421            FindingKind::ReusableCycle,
422            &cycle_message(stack, &call.spec),
423            Some(exp.caller_key.clone()),
424        );
425        return Ok(());
426    }
427    let callee = load_cached(&path)?;
428    let before = found.len();
429    emit_workflow_findings(found, &callee, Some(&exp.caller_key))?;
430    stack.push(CallFrame {
431        path: callee.path.clone(),
432        label: call.spec.clone(),
433    });
434    let nested = exp.with_repo(remote.clone());
435    expand_calls(found, &callee, stack, &nested)?;
436    stack.pop();
437    if miss == CacheMiss::ShowJob && found.len() == before {
438        show_remote_job(found, CallSite { current, job, call }, exp);
439    }
440    Ok(())
441}
442
443fn note_cache_miss(
444    found: &mut Vec<Finding>,
445    site: CallSite<'_>,
446    exp: &Expand<'_>,
447    miss: CacheMiss,
448) {
449    match miss {
450        CacheMiss::ShowJob => show_remote_job(found, site, exp),
451        CacheMiss::NotFound => push_at(
452            found,
453            &site.current.path,
454            site.call.line,
455            FindingKind::CalleeNotFound,
456            &missing_callee_message(&site.job.name, &site.call.spec),
457            Some(exp.caller_key.clone()),
458        ),
459    }
460}
461
462fn show_remote_job(found: &mut Vec<Finding>, site: CallSite<'_>, exp: &Expand<'_>) {
463    push_at(
464        found,
465        &site.current.path,
466        site.call.line,
467        FindingKind::RemoteWorkflow,
468        &remote_job_message(&site.job.name, &site.call.spec),
469        Some(exp.caller_key.clone()),
470    );
471}
472
473fn remote_job_message(job: &str, spec: &str) -> String {
474    format!("job {job} uses {spec}")
475}
476
477fn invalid_uses_message(job: &str, detail: &str) -> String {
478    format!("invalid reusable workflow uses: job {job} uses {detail}")
479}
480
481fn find_cached_file(runs: &Path, relative: &Path) -> Result<Option<PathBuf>, ScanError> {
482    if !runs.is_dir() {
483        return Ok(None);
484    }
485    let exact = runs.join(relative);
486    if exact.is_file() {
487        return Ok(Some(exact));
488    }
489    let Some(parts) = relative_parts(relative) else {
490        return Ok(None);
491    };
492    let mut current = runs.to_path_buf();
493    for (index, part) in parts.iter().enumerate() {
494        let want_file = index + 1 == parts.len();
495        match child_ignore_ascii_case(&current, part, want_file)? {
496            Some(next) => current = next,
497            None => return Ok(None),
498        }
499    }
500    if current.is_file() {
501        Ok(Some(current))
502    } else {
503        Ok(None)
504    }
505}
506
507fn relative_parts(relative: &Path) -> Option<Vec<std::ffi::OsString>> {
508    let mut parts = Vec::new();
509    for component in relative.components() {
510        match component {
511            Component::Normal(part) => parts.push(part.to_os_string()),
512            Component::CurDir => {}
513            Component::Prefix(_) | Component::RootDir | Component::ParentDir => return None,
514        }
515    }
516    Some(parts)
517}
518
519fn child_ignore_ascii_case(
520    dir: &Path,
521    name: &OsStr,
522    want_file: bool,
523) -> Result<Option<PathBuf>, ScanError> {
524    let entries = fs::read_dir(dir).map_err(|_| ScanError::directory(dir))?;
525    let mut exact = None;
526    let mut folded = None;
527    for entry in entries {
528        let entry = entry.map_err(|_| ScanError::directory(dir))?;
529        let entry_name = entry.file_name();
530        if !entry_name.eq_ignore_ascii_case(name) {
531            continue;
532        }
533        let path = entry.path();
534        let matches_kind = if want_file {
535            path.is_file()
536        } else {
537            path.is_dir()
538        };
539        if !matches_kind {
540            continue;
541        }
542        if entry_name == name {
543            exact = Some(path);
544        } else if folded.is_none() {
545            folded = Some(path);
546        }
547    }
548    Ok(exact.or(folded))
549}
550
551fn load_cached(path: &Path) -> Result<Workflow, ScanError> {
552    let text = fs::read_to_string(path).map_err(|_| ScanError::file(path))?;
553    scan_yaml(path, &text)
554}
555
556fn missing_callee_message(job: &str, spec: &str) -> String {
557    format!("callee not found: job {job} uses {spec}")
558}
559
560fn cycle_message(stack: &[CallFrame], next: &str) -> String {
561    let mut names: Vec<&str> = stack.iter().map(|frame| frame.label.as_str()).collect();
562    names.push(next);
563    let chain = names.join(" -> ");
564    format!("reusable workflow cycle: {chain}")
565}
566
567fn display_name(path: &Path) -> &str {
568    file_name(path).unwrap_or("workflow")
569}
570
571fn usage_key(path: &Path) -> String {
572    format!(".github/workflows/{}", display_name(path))
573}
574
575fn file_name(path: &Path) -> Option<&str> {
576    path.file_name().and_then(|name| name.to_str())
577}
578
579fn find_callee<'a>(workflows: &'a [Workflow], file_name_wanted: &str) -> Option<&'a Workflow> {
580    if let Some(exact) = workflows
581        .iter()
582        .find(|workflow| file_name(&workflow.path) == Some(file_name_wanted))
583    {
584        return Some(exact);
585    }
586    workflows.iter().find(|workflow| {
587        file_name(&workflow.path).is_some_and(|name| name.eq_ignore_ascii_case(file_name_wanted))
588    })
589}
590
591fn dedupe_findings(found: &mut Vec<Finding>) {
592    let mut kept: Vec<Finding> = Vec::new();
593    for item in found.drain(..) {
594        if !kept.iter().any(|other| same_recorded(other, &item)) {
595            kept.push(item);
596        }
597    }
598    *found = kept;
599}
600
601fn same_recorded(left: &Finding, right: &Finding) -> bool {
602    left.path == right.path
603        && left.line == right.line
604        && left.kind == right.kind
605        && left.message == right.message
606        && left.repository == right.repository
607        && left.workflow_path == right.workflow_path
608}
609
610/// Invoice check for every usage row.
611///
612/// `gross` must match `quantity * applied_cost_per_quantity` after that product
613/// is rounded to micros. One micro of slack covers a half-micro tie. `net` must
614/// equal `gross - discount` exactly. The CSV `net` is left unchanged.
615///
616/// One finding per mismatched row. The message names the SKU, the date, and
617/// both sides of each failed comparison.
618pub fn invoice_findings(path: &Path, rows: &[UsageRow]) -> Vec<Finding> {
619    let mut found = Vec::new();
620    for row in rows {
621        let Some(message) = mismatch_message(row) else {
622            continue;
623        };
624        found.push(Finding::placed(
625            path,
626            usize::try_from(row.line).unwrap_or(usize::MAX),
627            FindingKind::InvoiceMismatch,
628            message,
629            Some(row.repository.clone()),
630            Some(row.workflow_path.clone()),
631        ));
632    }
633    found
634}
635
636/// YAML findings plus invoice findings, ordered by path, line, kind, and message.
637///
638/// `None` means the workflow directory was not scanned and no row mismatched.
639/// An empty scan stays `Some`, so the report can tell those cases apart.
640pub fn combine(yaml: Option<Vec<Finding>>, invoice: Vec<Finding>) -> Option<Vec<Finding>> {
641    let mut all = match yaml {
642        Some(items) => items,
643        None if invoice.is_empty() => return None,
644        None => Vec::new(),
645    };
646    all.extend(invoice);
647    all.sort_by(cmp_finding);
648    Some(all)
649}
650
651/// Append findings and keep path, line, kind, and message order.
652///
653/// An empty `extra` leaves `base` as it was, including `None`.
654pub fn append(base: Option<Vec<Finding>>, extra: Vec<Finding>) -> Option<Vec<Finding>> {
655    if extra.is_empty() {
656        return base;
657    }
658    let mut all = base.unwrap_or_default();
659    all.extend(extra);
660    all.sort_by(cmp_finding);
661    Some(all)
662}
663
664fn cmp_finding(left: &Finding, right: &Finding) -> std::cmp::Ordering {
665    (&left.path, left.line, left.kind, left.message.as_str()).cmp(&(
666        &right.path,
667        right.line,
668        right.kind,
669        right.message.as_str(),
670    ))
671}
672
673/// Absolute slack after the product has been rounded to micros.
674const GROSS_TOLERANCE_MICROS: i128 = 1;
675
676fn mismatch_message(row: &UsageRow) -> Option<String> {
677    let mut parts = Vec::new();
678    let expected_gross = money::rounded_product(row.quantity, row.applied_cost_per_quantity);
679    if (i128::from(row.gross.0) - expected_gross).abs() > GROSS_TOLERANCE_MICROS {
680        parts.push(format!(
681            "gross {} != quantity * applied_cost_per_quantity {}",
682            row.gross.to_decimal_string(),
683            money::format_micros(expected_gross),
684        ));
685    }
686    let expected_net = i128::from(row.gross.0) - i128::from(row.discount.0);
687    if expected_net != i128::from(row.net.0) {
688        parts.push(format!(
689            "net {} != gross - discount {}",
690            row.net.to_decimal_string(),
691            money::format_micros(expected_net),
692        ));
693    }
694    if parts.is_empty() {
695        return None;
696    }
697    Some(format!(
698        "invoice row mismatch: sku {}, date {}, {}",
699        debug_cell(&row.sku),
700        debug_cell(&row.date),
701        parts.join(", ")
702    ))
703}
704
705/// Quoted cell text so an empty SKU or date stays visible.
706fn debug_cell(text: &str) -> String {
707    format!("{text:?}")
708}
709
710/// Workflow-level cancel, or cancel on any job, covers the workflow.
711fn pulls_without_cancel(workflow: &Workflow) -> bool {
712    let pulls = workflow.has_pull_request || workflow.has_pull_request_target;
713    pulls && !workflow_cancels(workflow)
714}
715
716fn workflow_cancels(workflow: &Workflow) -> bool {
717    workflow.cancel_in_progress || workflow.jobs.iter().any(|job| job.cancel_in_progress)
718}
719
720fn pr_cancel_message(workflow: &Workflow) -> &'static str {
721    if workflow.has_pull_request {
722        "pull_request without concurrency cancel-in-progress"
723    } else {
724        "pull_request_target without concurrency cancel-in-progress"
725    }
726}
727
728fn note_job(found: &mut Vec<Finding>, path: &Path, job: &Job) {
729    if let Some(runs_on) = &job.runs_on {
730        note_runner(found, path, job, runs_on);
731    }
732    if !job.has_timeout_minutes {
733        push(
734            found,
735            path,
736            job.line,
737            FindingKind::MissingTimeout,
738            "default timeout is 360 minutes",
739        );
740    }
741    if let Some(matrix) = &job.matrix {
742        if let Some(message) = matrix_message(matrix) {
743            push(found, path, matrix.line, FindingKind::MatrixJobs, &message);
744        }
745    }
746}
747
748struct ActionFrame {
749    relative: String,
750    spec: String,
751}
752
753/// Load `uses: ./path` steps. Composite steps join lint detection.
754///
755/// Docker and Node actions are left as the step that names them. A missing
756/// directory or action file is a finding. A cycle stops and is a finding.
757fn note_local_actions(
758    found: &mut Vec<Finding>,
759    workflow: &Workflow,
760    job: &Job,
761) -> Result<(), ScanError> {
762    let mut stack = Vec::new();
763    for step in &job.steps {
764        if let Some(action) = &step.local_action {
765            load_local_action(found, workflow, job, &workflow.path, action, &mut stack)?;
766        }
767    }
768    Ok(())
769}
770
771fn load_local_action(
772    found: &mut Vec<Finding>,
773    workflow: &Workflow,
774    job: &Job,
775    from: &Path,
776    action: &LocalAction,
777    stack: &mut Vec<ActionFrame>,
778) -> Result<(), ScanError> {
779    if stack.iter().any(|frame| frame.relative == action.relative) {
780        push_action(
781            found,
782            workflow,
783            from,
784            action.line,
785            FindingKind::ActionCycle,
786            &action_cycle_message(stack, &action.spec),
787        );
788        return Ok(());
789    }
790    let Some(root) = repository_root(&workflow.path) else {
791        push_missing_action(found, workflow, job, from, action);
792        return Ok(());
793    };
794    let Some(path) = locate_action(&root, &action.relative)? else {
795        push_missing_action(found, workflow, job, from, action);
796        return Ok(());
797    };
798    let parsed = load_action(&path)?;
799    if !parsed.using.trim().eq_ignore_ascii_case("composite") {
800        return Ok(());
801    }
802    stack.push(ActionFrame {
803        relative: action.relative.clone(),
804        spec: action.spec.clone(),
805    });
806    for step in &parsed.steps {
807        if let Some(line) = action_lint_line(step) {
808            note_action_runner(found, workflow, job, &path, line);
809        }
810        if let Some(nested) = &step.local_action {
811            load_local_action(found, workflow, job, &path, nested, stack)?;
812        }
813    }
814    stack.pop();
815    Ok(())
816}
817
818fn action_lint_line(step: &ActionStep) -> Option<usize> {
819    if step.run_line.is_some() && mentions_lint_tool(&step.run_text) {
820        return step.run_line;
821    }
822    if step.name_line.is_some() && mentions_lint_tool(&step.name) {
823        return step.name_line;
824    }
825    None
826}
827
828fn note_action_runner(
829    found: &mut Vec<Finding>,
830    workflow: &Workflow,
831    job: &Job,
832    action_path: &Path,
833    line: usize,
834) {
835    let Some(runs_on) = &job.runs_on else {
836        return;
837    };
838    if self_hosted(&runs_on.labels) {
839        return;
840    }
841    let anchor = runs_on.line;
842    let start = found.len();
843    let (macos, windows) = label_set_os(&runs_on.labels);
844    if macos {
845        push_action(
846            found,
847            workflow,
848            action_path,
849            line,
850            FindingKind::LintOnRunner,
851            "lint on macOS",
852        );
853    }
854    if windows {
855        push_action(
856            found,
857            workflow,
858            action_path,
859            line,
860            FindingKind::LintOnRunner,
861            "lint on Windows",
862        );
863    }
864    if let Some(matrix) = &job.matrix {
865        for hit in matrix_runner_hits(runs_on, matrix) {
866            if hit.macos {
867                push_action(
868                    found,
869                    workflow,
870                    action_path,
871                    line,
872                    FindingKind::LintOnRunner,
873                    &axis_lint_message("macOS", runs_on.line, &hit.axis, hit.line),
874                );
875            }
876            if hit.windows {
877                push_action(
878                    found,
879                    workflow,
880                    action_path,
881                    line,
882                    FindingKind::LintOnRunner,
883                    &axis_lint_message("Windows", runs_on.line, &hit.axis, hit.line),
884                );
885            }
886        }
887    }
888    for item in &mut found[start..] {
889        if item.kind == FindingKind::LintOnRunner {
890            item.anchor = Some(anchor);
891        }
892    }
893}
894
895fn push_missing_action(
896    found: &mut Vec<Finding>,
897    workflow: &Workflow,
898    job: &Job,
899    from: &Path,
900    action: &LocalAction,
901) {
902    push_action(
903        found,
904        workflow,
905        from,
906        action.line,
907        FindingKind::ActionNotFound,
908        &missing_action_message(&job.name, &action.spec),
909    );
910}
911
912fn push_action(
913    found: &mut Vec<Finding>,
914    workflow: &Workflow,
915    path: &Path,
916    line: usize,
917    kind: FindingKind,
918    message: &str,
919) {
920    push_at(
921        found,
922        path,
923        line,
924        kind,
925        message,
926        Some(usage_key(&workflow.path)),
927    );
928}
929
930fn missing_action_message(job: &str, spec: &str) -> String {
931    format!("action not found: job {job} uses {spec}")
932}
933
934fn action_cycle_message(stack: &[ActionFrame], next: &str) -> String {
935    let mut names: Vec<&str> = stack.iter().map(|frame| frame.spec.as_str()).collect();
936    names.push(next);
937    format!("composite action cycle: {}", names.join(" -> "))
938}
939
940/// Six or more concrete jobs, a finite upper bound of at least six, or any
941/// expression axis whose lower bound is at least one.
942fn matrix_message(matrix: &Matrix) -> Option<String> {
943    if let Some(hi) = matrix.jobs_hi {
944        if hi < 6 {
945            return None;
946        }
947        let noun = if hi == 1 { "job" } else { "jobs" };
948        return Some(format!(
949            "matrix runs between {} and {hi} {noun}; each job is billed in whole minutes",
950            matrix.jobs
951        ));
952    }
953    let wide = matrix.jobs >= 6 || (matrix.at_least && matrix.jobs > 0);
954    if !wide {
955        return None;
956    }
957    let noun = if matrix.jobs == 1 { "job" } else { "jobs" };
958    let message = if matrix.at_least {
959        format!(
960            "matrix runs at least {} {noun}; each job is billed in whole minutes",
961            matrix.jobs
962        )
963    } else {
964        format!(
965            "matrix runs {} {noun}; each job is billed in whole minutes",
966            matrix.jobs
967        )
968    };
969    Some(message)
970}
971
972fn job_is_lint(job: &Job) -> bool {
973    mentions_lint_tool(&job.name)
974        || job.display_name.as_deref().is_some_and(mentions_lint_tool)
975        || mentions_lint_tool(&job.steps_text)
976}
977
978fn mentions_lint_tool(text: &str) -> bool {
979    text.split(|character: char| !character.is_ascii_alphanumeric())
980        .any(|token| {
981            LINT_TOOLS
982                .iter()
983                .any(|tool| token.eq_ignore_ascii_case(tool))
984        })
985}
986
987fn note_runner(found: &mut Vec<Finding>, path: &Path, job: &Job, runs_on: &RunsOn) {
988    if !job_is_lint(job) || self_hosted(&runs_on.labels) {
989        return;
990    }
991    let (macos, windows) = label_set_os(&runs_on.labels);
992    if macos {
993        push(
994            found,
995            path,
996            runs_on.line,
997            FindingKind::LintOnRunner,
998            "lint on macOS",
999        );
1000    }
1001    if windows {
1002        push(
1003            found,
1004            path,
1005            runs_on.line,
1006            FindingKind::LintOnRunner,
1007            "lint on Windows",
1008        );
1009    }
1010    let Some(matrix) = &job.matrix else {
1011        return;
1012    };
1013    for hit in matrix_runner_hits(runs_on, matrix) {
1014        if hit.macos {
1015            push(
1016                found,
1017                path,
1018                runs_on.line,
1019                FindingKind::LintOnRunner,
1020                &axis_lint_message("macOS", runs_on.line, &hit.axis, hit.line),
1021            );
1022        }
1023        if hit.windows {
1024            push(
1025                found,
1026                path,
1027                runs_on.line,
1028                FindingKind::LintOnRunner,
1029                &axis_lint_message("Windows", runs_on.line, &hit.axis, hit.line),
1030            );
1031        }
1032    }
1033}
1034
1035fn axis_lint_message(os: &str, runs_on_line: usize, axis: &str, axis_line: usize) -> String {
1036    format!("lint on {os} (runs-on line {runs_on_line}, axis {axis} line {axis_line})")
1037}
1038
1039struct AxisHit {
1040    macos: bool,
1041    windows: bool,
1042    axis: String,
1043    line: usize,
1044}
1045
1046fn matrix_runner_hits(runs_on: &RunsOn, matrix: &crate::workflow::Matrix) -> Vec<AxisHit> {
1047    let mut hits = Vec::new();
1048    for name in runs_on_matrix_names(runs_on) {
1049        let Some(axis) = matrix.axes.iter().find(|axis| axis.name == name) else {
1050            continue;
1051        };
1052        let mut macos = false;
1053        let mut windows = false;
1054        for labels in &axis.values {
1055            let (axis_macos, axis_windows) = label_set_os(labels);
1056            macos |= axis_macos;
1057            windows |= axis_windows;
1058        }
1059        if macos || windows {
1060            hits.push(AxisHit {
1061                macos,
1062                windows,
1063                axis: axis.name.clone(),
1064                line: axis.line,
1065            });
1066        }
1067    }
1068    hits
1069}
1070
1071fn runs_on_matrix_names(runs_on: &RunsOn) -> Vec<String> {
1072    let mut names = matrix_axis_names(&runs_on.value);
1073    for label in &runs_on.labels {
1074        for name in matrix_axis_names(label) {
1075            push_unique(&mut names, name);
1076        }
1077    }
1078    names
1079}
1080
1081fn push_unique(names: &mut Vec<String>, name: String) {
1082    if !names.iter().any(|item| item == &name) {
1083        names.push(name);
1084    }
1085}
1086
1087/// `matrix.name` and `matrix['name']` / `matrix["name"]`, including inside a longer scalar.
1088fn matrix_axis_names(text: &str) -> Vec<String> {
1089    let chars: Vec<char> = text.chars().collect();
1090    let mut names = Vec::new();
1091    let mut index = 0;
1092    while index < chars.len() {
1093        if !starts_with_matrix(&chars, index) {
1094            index += 1;
1095            continue;
1096        }
1097        let boundary = index == 0 || !is_name_char(chars[index - 1]);
1098        let after = index + MATRIX.len();
1099        if !boundary {
1100            index += 1;
1101            continue;
1102        }
1103        if let Some(end) = dotted_axis_end(&chars, after) {
1104            push_unique(&mut names, chars[after + 1..end].iter().collect());
1105            index = end;
1106            continue;
1107        }
1108        if let Some((name, end)) = bracket_axis(&chars, after) {
1109            push_unique(&mut names, name);
1110            index = end;
1111            continue;
1112        }
1113        index += 1;
1114    }
1115    names
1116}
1117
1118const MATRIX: &[char] = &['m', 'a', 't', 'r', 'i', 'x'];
1119
1120fn starts_with_matrix(chars: &[char], index: usize) -> bool {
1121    index + MATRIX.len() <= chars.len() && chars[index..index + MATRIX.len()] == MATRIX[..]
1122}
1123
1124fn is_name_char(character: char) -> bool {
1125    character.is_ascii_alphanumeric() || character == '_'
1126}
1127
1128fn dotted_axis_end(chars: &[char], after_matrix: usize) -> Option<usize> {
1129    if after_matrix >= chars.len() || chars[after_matrix] != '.' {
1130        return None;
1131    }
1132    let start = after_matrix + 1;
1133    if start >= chars.len() || !(chars[start].is_ascii_alphabetic() || chars[start] == '_') {
1134        return None;
1135    }
1136    let mut end = start + 1;
1137    while end < chars.len() && (is_name_char(chars[end]) || chars[end] == '-') {
1138        end += 1;
1139    }
1140    while end > start && chars[end - 1] == '-' {
1141        end -= 1;
1142    }
1143    if end == start {
1144        None
1145    } else {
1146        Some(end)
1147    }
1148}
1149
1150fn bracket_axis(chars: &[char], after_matrix: usize) -> Option<(String, usize)> {
1151    let mut index = skip_ws(chars, after_matrix);
1152    if index >= chars.len() || chars[index] != '[' {
1153        return None;
1154    }
1155    index = skip_ws(chars, index + 1);
1156    if index >= chars.len() {
1157        return None;
1158    }
1159    let quote = chars[index];
1160    if quote != '\'' && quote != '"' {
1161        return None;
1162    }
1163    index += 1;
1164    let start = index;
1165    while index < chars.len() && chars[index] != quote {
1166        index += 1;
1167    }
1168    if index >= chars.len() {
1169        return None;
1170    }
1171    let name: String = chars[start..index].iter().collect();
1172    index = skip_ws(chars, index + 1);
1173    if name.is_empty() || index >= chars.len() || chars[index] != ']' {
1174        return None;
1175    }
1176    Some((name, index + 1))
1177}
1178
1179fn skip_ws(chars: &[char], mut index: usize) -> usize {
1180    while index < chars.len() && chars[index].is_whitespace() {
1181        index += 1;
1182    }
1183    index
1184}
1185
1186fn self_hosted(labels: &[String]) -> bool {
1187    labels
1188        .iter()
1189        .any(|label| label.eq_ignore_ascii_case("self-hosted"))
1190}
1191
1192/// A label set that contains `self-hosted` is never a paid macOS or Windows runner.
1193fn label_set_os(labels: &[String]) -> (bool, bool) {
1194    if self_hosted(labels) {
1195        return (false, false);
1196    }
1197    let mut macos = false;
1198    let mut windows = false;
1199    for label in labels {
1200        let label = label.to_ascii_lowercase();
1201        if label == "macos" || label.starts_with("macos-") {
1202            macos = true;
1203        } else if label == "windows" || label.starts_with("windows-") {
1204            windows = true;
1205        }
1206    }
1207    (macos, windows)
1208}
1209
1210fn push(found: &mut Vec<Finding>, path: &Path, line: usize, kind: FindingKind, message: &str) {
1211    push_at(found, path, line, kind, message, None);
1212}
1213
1214fn push_at(
1215    found: &mut Vec<Finding>,
1216    path: &Path,
1217    line: usize,
1218    kind: FindingKind,
1219    message: &str,
1220    workflow_path: Option<String>,
1221) {
1222    found.push(Finding::placed(
1223        path,
1224        line,
1225        kind,
1226        message,
1227        None,
1228        workflow_path,
1229    ));
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235    use crate::money::Micros;
1236    use crate::rank::rank;
1237    use crate::report::{render, render_json};
1238    use crate::usage::{read_usage, UsageRow};
1239    use crate::workflow::{scan_dir, scan_yaml, RemoteWorkflow, ReusableSource, Workflow};
1240    use std::fs;
1241    use std::path::{Path, PathBuf};
1242
1243    fn analyze(yaml: &str) -> Vec<Finding> {
1244        let workflow = scan_yaml(Path::new("fixture.yml"), yaml).expect("valid fixture");
1245        findings(&[workflow]).expect("findings")
1246    }
1247
1248    fn of_kind(found: &[Finding], kind: FindingKind) -> Vec<&Finding> {
1249        found.iter().filter(|item| item.kind == kind).collect()
1250    }
1251
1252    #[test]
1253    fn f1_flags_lint_tools_on_macos_and_windows() {
1254        let cases = [
1255            ("lint", "macos-latest", "lint on macOS"),
1256            ("fmt", "windows-latest", "lint on Windows"),
1257            ("clippy", "macos-14", "lint on macOS"),
1258            ("eslint", "Windows-2022", "lint on Windows"),
1259            ("prettier", "macOS", "lint on macOS"),
1260        ];
1261        for (name, runner, message) in cases {
1262            let yaml = format!(
1263                "jobs:\n  {name}:\n    runs-on: {runner}\n    steps:\n      - run: echo hi\n"
1264            );
1265            let found = analyze(&yaml);
1266            let f1 = of_kind(&found, FindingKind::LintOnRunner);
1267            assert_eq!(f1.len(), 1, "{name} on {runner}: {found:?}");
1268            assert_eq!(f1[0].line, 3, "{name}");
1269            assert_eq!(f1[0].message, message, "{name}");
1270            assert_eq!(f1[0].path, Path::new("fixture.yml"));
1271        }
1272
1273        let yaml = r#"jobs:
1274  build:
1275    name: Check
1276    runs-on: [self-hosted, macos]
1277    steps:
1278      - run: npx prettier --check .
1279  package:
1280    runs-on: windows-latest
1281    steps:
1282      - name: Style
1283        run: cargo clippy -- -D warnings
1284"#;
1285        let found = analyze(yaml);
1286        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1287        assert_eq!(f1.len(), 1, "{found:?}");
1288        assert_eq!(f1[0].line, 8);
1289        assert_eq!(f1[0].message, "lint on Windows");
1290        assert!(
1291            f1.iter().all(|item| item.line != 4),
1292            "self-hosted label set is not paid macOS: {found:?}"
1293        );
1294    }
1295
1296    #[test]
1297    fn f1_ignores_ubuntu_and_non_lint_jobs() {
1298        let yaml = r#"jobs:
1299  lint:
1300    runs-on: ubuntu-latest
1301    steps:
1302      - run: cargo fmt --check
1303  test:
1304    runs-on: macos-latest
1305    steps:
1306      - run: cargo test
1307  hosted:
1308    runs-on: [self-hosted, linux]
1309    steps:
1310      - run: cargo clippy -- -D warnings
1311  formatted:
1312    runs-on: windows-latest
1313    steps:
1314      - run: echo formatted
1315"#;
1316        let found = analyze(yaml);
1317        assert!(
1318            of_kind(&found, FindingKind::LintOnRunner).is_empty(),
1319            "{found:?}"
1320        );
1321    }
1322
1323    #[test]
1324    fn f2_flags_jobs_without_timeout_only() {
1325        let yaml = r#"jobs:
1326  slow:
1327    runs-on: ubuntu-latest
1328    steps:
1329      - timeout-minutes: 5
1330        run: cargo test
1331  fast:
1332    runs-on: ubuntu-latest
1333    timeout-minutes: 10
1334    steps:
1335      - run: cargo test
1336"#;
1337        let found = analyze(yaml);
1338        let f2 = of_kind(&found, FindingKind::MissingTimeout);
1339        assert_eq!(f2.len(), 1, "{found:?}");
1340        assert_eq!(f2[0].line, 2);
1341        assert_eq!(f2[0].message, "default timeout is 360 minutes");
1342        assert_eq!(f2[0].path, Path::new("fixture.yml"));
1343    }
1344
1345    #[test]
1346    fn f3_flags_pull_request_without_cancel() {
1347        let positive = r#"on:
1348  pull_request:
1349jobs:
1350  test:
1351    runs-on: ubuntu-latest
1352    timeout-minutes: 5
1353    steps:
1354      - run: cargo test
1355"#;
1356        let found = analyze(positive);
1357        let f3 = of_kind(&found, FindingKind::PullRequestConcurrency);
1358        assert_eq!(f3.len(), 1, "{found:?}");
1359        assert_eq!(f3[0].line, 1);
1360        assert_eq!(
1361            f3[0].message,
1362            "pull_request without concurrency cancel-in-progress"
1363        );
1364        assert!(of_kind(&found, FindingKind::MissingTimeout).is_empty());
1365
1366        let inline = analyze(
1367            "on: [push, pull_request]\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
1368        );
1369        let f3 = of_kind(&inline, FindingKind::PullRequestConcurrency);
1370        assert_eq!(f3.len(), 1, "{inline:?}");
1371        assert_eq!(f3[0].line, 1);
1372    }
1373
1374    #[test]
1375    fn f3_ignores_cancel_and_non_pull_request() {
1376        let cancelled = r#"on:
1377  pull_request:
1378concurrency:
1379  group: ci
1380  cancel-in-progress: true
1381jobs:
1382  test:
1383    runs-on: ubuntu-latest
1384    timeout-minutes: 5
1385    steps:
1386      - run: cargo test
1387"#;
1388        let found = analyze(cancelled);
1389        assert!(
1390            of_kind(&found, FindingKind::PullRequestConcurrency).is_empty(),
1391            "{found:?}"
1392        );
1393
1394        let push_only =
1395            "on: push\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
1396        let found = analyze(push_only);
1397        assert!(of_kind(&found, FindingKind::PullRequestConcurrency).is_empty());
1398        assert!(of_kind(&found, FindingKind::MissingTimeout).is_empty());
1399    }
1400
1401    #[test]
1402    fn ci_fixture_has_no_f2_or_f3() {
1403        let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/.github/workflows");
1404        let workflows = scan_dir(&dir).unwrap();
1405        let ci = workflows
1406            .into_iter()
1407            .find(|workflow| {
1408                workflow.path.file_name().and_then(|name| name.to_str()) == Some("ci.yml")
1409            })
1410            .expect("ci.yml fixture");
1411        let found = findings(&[ci]).expect("findings");
1412        assert!(
1413            found.iter().all(|item| {
1414                !matches!(
1415                    item.kind,
1416                    FindingKind::MissingTimeout | FindingKind::PullRequestConcurrency
1417                )
1418            }),
1419            "{found:?}"
1420        );
1421        assert!(found.is_empty(), "{found:?}");
1422    }
1423
1424    #[test]
1425    fn f1_f2_f3_apply_to_flow_style() {
1426        let flagged = "on: {pull_request: {}}\njobs: {lint: {runs-on: macos-latest, steps: [{run: cargo fmt --check}]}}\n";
1427        let found = analyze(flagged);
1428        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1429        assert_eq!(f1.len(), 1, "{found:?}");
1430        assert_eq!(f1[0].line, 2);
1431        assert_eq!(f1[0].message, "lint on macOS");
1432        let f2 = of_kind(&found, FindingKind::MissingTimeout);
1433        assert_eq!(f2.len(), 1, "{found:?}");
1434        assert_eq!(f2[0].line, 2);
1435        assert_eq!(f2[0].message, "default timeout is 360 minutes");
1436        let f3 = of_kind(&found, FindingKind::PullRequestConcurrency);
1437        assert_eq!(f3.len(), 1, "{found:?}");
1438        assert_eq!(f3[0].line, 1);
1439        assert_eq!(
1440            f3[0].message,
1441            "pull_request without concurrency cancel-in-progress"
1442        );
1443
1444        let windows = "on: [push, pull_request]\njobs: {lint: {runs-on: windows-latest, timeout-minutes: 5, steps: [{run: cargo clippy -- -D warnings}]}}\n";
1445        let found = analyze(windows);
1446        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1447        assert_eq!(f1.len(), 1, "{found:?}");
1448        assert_eq!(f1[0].line, 2);
1449        assert_eq!(f1[0].message, "lint on Windows");
1450        assert_eq!(
1451            of_kind(&found, FindingKind::PullRequestConcurrency).len(),
1452            1,
1453            "{found:?}"
1454        );
1455        assert_eq!(
1456            of_kind(&found, FindingKind::PullRequestConcurrency)[0].line,
1457            1
1458        );
1459        assert!(
1460            of_kind(&found, FindingKind::MissingTimeout).is_empty(),
1461            "{found:?}"
1462        );
1463
1464        let clean = "on: {pull_request: {}}\nconcurrency: {group: ci, cancel-in-progress: true}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 5, steps: [{run: cargo test}]}}\n";
1465        let found = analyze(clean);
1466        assert!(found.is_empty(), "{found:?}");
1467
1468        let ubuntu_lint = "on: push\njobs: {lint: {runs-on: ubuntu-latest, timeout-minutes: 1, steps: [{run: cargo fmt --check}]}}\n";
1469        let found = analyze(ubuntu_lint);
1470        assert!(found.is_empty(), "{found:?}");
1471
1472        let map_runner = "jobs:\n  lint:\n    runs-on: {group: large, labels: [self-hosted, macos]}\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n";
1473        let found = analyze(map_runner);
1474        assert!(
1475            of_kind(&found, FindingKind::LintOnRunner).is_empty(),
1476            "self-hosted label set is not paid macOS: {found:?}"
1477        );
1478        assert!(
1479            of_kind(&found, FindingKind::MissingTimeout).is_empty(),
1480            "{found:?}"
1481        );
1482
1483        let block_map = "jobs:\n  prettier:\n    runs-on:\n      group: large\n      labels:\n        - self-hosted\n        - windows\n    timeout-minutes: 5\n    steps:\n      - run: npx prettier --check .\n";
1484        let found = analyze(block_map);
1485        assert!(
1486            of_kind(&found, FindingKind::LintOnRunner).is_empty(),
1487            "self-hosted label set is not paid Windows: {found:?}"
1488        );
1489
1490        let merged = "defaults: &defaults\n  runs-on: ubuntu-latest\n  timeout-minutes: 10\n  steps:\n    - run: cargo test\non: pull_request\njobs:\n  lint:\n    <<: *defaults\n    runs-on: macos-latest\n    steps:\n      - run: cargo clippy -- -D warnings\n";
1491        let found = analyze(merged);
1492        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1493        assert_eq!(f1.len(), 1, "{found:?}");
1494        assert_eq!(f1[0].line, 10);
1495        assert_eq!(f1[0].message, "lint on macOS");
1496        assert!(
1497            of_kind(&found, FindingKind::MissingTimeout).is_empty(),
1498            "{found:?}"
1499        );
1500        let f3 = of_kind(&found, FindingKind::PullRequestConcurrency);
1501        assert_eq!(f3.len(), 1, "{found:?}");
1502        assert_eq!(f3[0].line, 6);
1503
1504        let inherited = "defaults: &defaults\n  name: clippy\n  runs-on: macos-latest\njobs:\n  build:\n    <<: *defaults\n";
1505        let found = analyze(inherited);
1506        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1507        assert_eq!(f1.len(), 1, "{found:?}");
1508        assert_eq!(f1[0].line, 3);
1509        assert_eq!(f1[0].message, "lint on macOS");
1510        let f2 = of_kind(&found, FindingKind::MissingTimeout);
1511        assert_eq!(f2.len(), 1, "{found:?}");
1512        assert_eq!(f2[0].line, 5);
1513    }
1514
1515    fn matrix_case(yaml: &str) -> (Vec<Finding>, Matrix) {
1516        let workflow = scan_yaml(Path::new("fixture.yml"), yaml).expect("valid fixture");
1517        let matrix = workflow.jobs[0].matrix.clone().expect("matrix");
1518        let found = findings(&[workflow]).expect("findings");
1519        (found, matrix)
1520    }
1521
1522    fn f4_message(jobs: u64) -> String {
1523        format!("matrix runs {jobs} jobs; each job is billed in whole minutes")
1524    }
1525
1526    fn f4_at_least(jobs: u64) -> String {
1527        let noun = if jobs == 1 { "job" } else { "jobs" };
1528        format!("matrix runs at least {jobs} {noun}; each job is billed in whole minutes")
1529    }
1530
1531    #[test]
1532    fn f4_flags_two_by_three() {
1533        let yaml = "jobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n";
1534        let (found, matrix) = matrix_case(yaml);
1535        assert_eq!(matrix.jobs, 6);
1536        assert!(!matrix.at_least);
1537        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1538        assert_eq!(f4.len(), 1, "{found:?}");
1539        assert_eq!(f4[0].line, 6);
1540        assert_eq!(f4[0].path, Path::new("fixture.yml"));
1541        assert_eq!(f4[0].message, f4_message(6));
1542        assert!(!f4[0].message.contains("at least"));
1543        assert!(of_kind(&found, FindingKind::MissingTimeout).is_empty());
1544        assert!(of_kind(&found, FindingKind::LintOnRunner).is_empty());
1545
1546        let block = "jobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os:\n          - ubuntu-latest\n          - windows-latest\n        node:\n          - 14\n          - 16\n          - 18\n";
1547        let (found, matrix) = matrix_case(block);
1548        assert_eq!(matrix.jobs, 6);
1549        assert!(!matrix.at_least);
1550        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1551        assert_eq!(f4.len(), 1, "{found:?}");
1552        assert_eq!(f4[0].line, 6);
1553        assert_eq!(f4[0].message, f4_message(6));
1554
1555        let bill = crate::rank::Bill {
1556            total_net: crate::money::Micros::ZERO,
1557            total_minutes: crate::money::Micros::ZERO,
1558            other_products_net: crate::money::Micros::ZERO,
1559            period_from: None,
1560            period_to: None,
1561            repositories: Vec::new(),
1562        };
1563        let text = crate::report::render(&[], &bill, Some(&found), None);
1564        assert!(
1565            text.contains(
1566                "F4 fixture.yml:6: matrix runs 6 jobs; each job is billed in whole minutes"
1567            ),
1568            "{text}"
1569        );
1570        let json = crate::report::render_json(&[], &bill, Some(&found), None, None, &[]);
1571        let value: serde_json::Value = serde_json::from_str(&json).expect("json");
1572        assert_eq!(value["unattached_findings"][0]["id"], "F4");
1573        assert_eq!(value["unattached_findings"][0]["line"], 6);
1574        assert_eq!(value["unattached_findings"][0]["file"], "fixture.yml");
1575        assert_eq!(value["unattached_findings"][0]["message"], f4_message(6));
1576    }
1577
1578    #[test]
1579    fn f4_ignores_two_by_two() {
1580        let yaml = "jobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n";
1581        let (found, matrix) = matrix_case(yaml);
1582        assert_eq!(matrix.jobs, 4);
1583        assert!(!matrix.at_least);
1584        assert!(
1585            of_kind(&found, FindingKind::MatrixJobs).is_empty(),
1586            "{found:?}"
1587        );
1588        assert!(found.is_empty(), "{found:?}");
1589    }
1590
1591    #[test]
1592    fn f4_ignores_two_by_three_with_one_exclude() {
1593        let yaml = "jobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        exclude:\n          - os: ubuntu-latest\n            node: 14\n";
1594        let (found, matrix) = matrix_case(yaml);
1595        assert_eq!(matrix.line, 6);
1596        assert_eq!(matrix.jobs, 5);
1597        assert!(!matrix.at_least);
1598        assert!(
1599            of_kind(&found, FindingKind::MatrixJobs).is_empty(),
1600            "{found:?}"
1601        );
1602
1603        let flow = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        exclude: [{os: ubuntu-latest, node: 14}]\n";
1604        let (found, matrix) = matrix_case(flow);
1605        assert_eq!(matrix.jobs, 5);
1606        assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1607    }
1608
1609    #[test]
1610    fn f4_flags_two_by_two_plus_two_new_includes() {
1611        let yaml = "jobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n        include:\n          - os: macos-latest\n            node: 18\n          - os: macos-latest\n            node: 20\n";
1612        let (found, matrix) = matrix_case(yaml);
1613        assert_eq!(matrix.jobs, 6);
1614        assert!(!matrix.at_least);
1615        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1616        assert_eq!(f4.len(), 1, "{found:?}");
1617        assert_eq!(f4[0].line, 6);
1618        assert_eq!(f4[0].path, Path::new("fixture.yml"));
1619        assert_eq!(f4[0].message, f4_message(6));
1620
1621        let flow = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n        include: [{os: macos-latest, node: 18}, {os: macos-latest, node: 20}]\n";
1622        let (found, matrix) = matrix_case(flow);
1623        assert_eq!(matrix.jobs, 6);
1624        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1625        assert_eq!(f4.len(), 1, "{found:?}");
1626        assert_eq!(f4[0].line, 5);
1627        assert_eq!(f4[0].message, f4_message(6));
1628    }
1629
1630    #[test]
1631    fn f4_expression_axis_says_at_least() {
1632        let wide = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        extra: ${{ fromJSON(needs.setup.outputs.extra) }}\n";
1633        let (found, matrix) = matrix_case(wide);
1634        assert_eq!(matrix.jobs, 6);
1635        assert!(matrix.at_least);
1636        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1637        assert_eq!(f4.len(), 1, "{found:?}");
1638        assert_eq!(f4[0].line, 5);
1639        assert_eq!(f4[0].message, f4_at_least(6));
1640
1641        let rest = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: ${{ fromJSON(needs.setup.outputs.node) }}\n";
1642        let (found, matrix) = matrix_case(rest);
1643        assert_eq!(matrix.jobs, 2);
1644        assert!(matrix.at_least);
1645        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1646        assert_eq!(f4.len(), 1, "{found:?}");
1647        assert_eq!(f4[0].line, 5);
1648        assert_eq!(f4[0].message, f4_at_least(2));
1649
1650        let quoted = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: \"${{ fromJSON(needs.setup.outputs.node) }}\"\n";
1651        let (found, matrix) = matrix_case(quoted);
1652        assert_eq!(matrix.jobs, 2);
1653        assert!(matrix.at_least);
1654        assert_eq!(
1655            of_kind(&found, FindingKind::MatrixJobs)[0].message,
1656            f4_at_least(2)
1657        );
1658
1659        let only = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        node: ${{ fromJSON(needs.setup.outputs.node) }}\n";
1660        let (found, matrix) = matrix_case(only);
1661        assert_eq!(matrix.jobs, 1);
1662        assert!(matrix.at_least);
1663        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1664        assert_eq!(f4.len(), 1, "{found:?}");
1665        assert_eq!(f4[0].message, f4_at_least(1));
1666
1667        let list_item = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, \"${{ matrix.extra }}\"]\n        node: [14, 16, 18]\n";
1668        let (found, matrix) = matrix_case(list_item);
1669        assert_eq!(matrix.jobs, 6);
1670        assert!(!matrix.at_least);
1671        assert_eq!(
1672            of_kind(&found, FindingKind::MatrixJobs)[0].message,
1673            f4_message(6)
1674        );
1675    }
1676
1677    #[test]
1678    fn f4_flow_style_matrix_uses_matrix_line() {
1679        let inline = "jobs: {build: {timeout-minutes: 5, strategy: {matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}}}}\n";
1680        let (found, matrix) = matrix_case(inline);
1681        assert_eq!(matrix.jobs, 6);
1682        assert!(!matrix.at_least);
1683        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1684        assert_eq!(f4.len(), 1, "{found:?}");
1685        assert_eq!(f4[0].line, 1);
1686        assert_eq!(f4[0].message, f4_message(6));
1687
1688        let split = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}\n";
1689        let (found, matrix) = matrix_case(split);
1690        assert_eq!(matrix.line, 5);
1691        assert_eq!(matrix.jobs, 6);
1692        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1693        assert_eq!(f4.len(), 1, "{found:?}");
1694        assert_eq!(f4[0].line, 5);
1695        assert_eq!(f4[0].path, Path::new("fixture.yml"));
1696        assert_eq!(f4[0].message, f4_message(6));
1697    }
1698
1699    #[test]
1700    fn f4_expanding_include_and_partial_exclude_keep_real_counts() {
1701        let expanded = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        include:\n          - os: ubuntu-latest\n            node: 14\n            npm: 6\n";
1702        let (found, matrix) = matrix_case(expanded);
1703        assert_eq!(matrix.jobs, 6);
1704        assert_eq!(
1705            of_kind(&found, FindingKind::MatrixJobs)[0].message,
1706            f4_message(6)
1707        );
1708
1709        let partial = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [macos-latest, windows-latest]\n        version: [12, 14, 16]\n        environment: [staging, production]\n        exclude:\n          - os: macos-latest\n            version: 12\n            environment: production\n          - os: windows-latest\n            version: 16\n";
1710        let (found, matrix) = matrix_case(partial);
1711        assert_eq!(matrix.jobs, 9);
1712        assert!(!matrix.at_least);
1713        assert_eq!(
1714            of_kind(&found, FindingKind::MatrixJobs)[0].message,
1715            f4_message(9)
1716        );
1717
1718        let added_back = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n        exclude:\n          - os: ubuntu-latest\n            node: 14\n        include:\n          - os: ubuntu-latest\n            node: 14\n            npm: 6\n";
1719        let (found, matrix) = matrix_case(added_back);
1720        assert_eq!(matrix.jobs, 4);
1721        assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1722    }
1723
1724    #[test]
1725    fn f4_only_flags_the_wide_matrix_job() {
1726        let yaml = "jobs:\n  small:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n  wide:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n  lint:\n    runs-on: macos-latest\n    strategy:\n      matrix:\n        node: [14, 16, 18, 20, 22, 24]\n";
1727        let workflow = scan_yaml(Path::new("fixture.yml"), yaml).expect("valid");
1728        assert_eq!(workflow.jobs[0].matrix.as_ref().unwrap().jobs, 4);
1729        assert_eq!(workflow.jobs[1].matrix.as_ref().unwrap().jobs, 6);
1730        assert_eq!(workflow.jobs[2].matrix.as_ref().unwrap().jobs, 6);
1731        let found = findings(&[workflow]).expect("findings");
1732        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1733        assert_eq!(f4.len(), 2, "{found:?}");
1734        assert_eq!(f4[0].line, 11);
1735        assert_eq!(f4[0].message, f4_message(6));
1736        assert_eq!(f4[1].line, 17);
1737        assert_eq!(f4[1].message, f4_message(6));
1738        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1739        assert_eq!(f1.len(), 1, "{found:?}");
1740        assert_eq!(f1[0].line, 15);
1741        assert_eq!(f1[0].message, "lint on macOS");
1742        let f2 = of_kind(&found, FindingKind::MissingTimeout);
1743        assert_eq!(f2.len(), 1, "{found:?}");
1744        assert_eq!(f2[0].line, 14);
1745    }
1746
1747    fn f4_between(lo: u64, hi: u64) -> String {
1748        let noun = if hi == 1 { "job" } else { "jobs" };
1749        format!("matrix runs between {lo} and {hi} {noun}; each job is billed in whole minutes")
1750    }
1751
1752    fn f8(key: &str, first: usize, second: usize) -> String {
1753        format!("duplicate key {key} (line {first} and {second})")
1754    }
1755
1756    #[test]
1757    fn f8_reports_the_later_runs_on_value() {
1758        let yaml = "jobs:\n  lint:\n    runs-on: ubuntu-latest\n    runs-on: macos-latest\n    steps:\n      - run: cargo fmt --check\n";
1759        let found = analyze(yaml);
1760        let f8s = of_kind(&found, FindingKind::DuplicateKey);
1761        assert_eq!(f8s.len(), 1, "{found:?}");
1762        assert_eq!(f8s[0].line, 4);
1763        assert_eq!(f8s[0].message, f8("runs-on", 3, 4));
1764        let f1 = of_kind(&found, FindingKind::LintOnRunner);
1765        assert_eq!(f1.len(), 1, "{found:?}");
1766        assert_eq!(f1[0].line, 4);
1767        assert_eq!(f1[0].message, "lint on macOS");
1768
1769        let crlf = analyze(&yaml.replace('\n', "\r\n"));
1770        let f8s = of_kind(&crlf, FindingKind::DuplicateKey);
1771        assert_eq!(f8s[0].message, f8("runs-on", 3, 4));
1772        assert_eq!(
1773            of_kind(&crlf, FindingKind::LintOnRunner)[0].message,
1774            "lint on macOS"
1775        );
1776
1777        let reversed = analyze(
1778            "jobs:\n  lint:\n    runs-on: macos-latest\n    runs-on: ubuntu-latest\n    steps:\n      - run: cargo fmt --check\n",
1779        );
1780        assert_eq!(
1781            of_kind(&reversed, FindingKind::DuplicateKey)[0].message,
1782            f8("runs-on", 3, 4)
1783        );
1784        assert!(of_kind(&reversed, FindingKind::LintOnRunner).is_empty());
1785
1786        let capitals = analyze(
1787            "jobs:\n  lint:\n    runs-on: ubuntu-latest\n    runs-on: MacOS-latest\n    steps:\n      - run: cargo fmt --check\n",
1788        );
1789        assert_eq!(
1790            of_kind(&capitals, FindingKind::LintOnRunner)[0].message,
1791            "lint on macOS"
1792        );
1793        assert_eq!(
1794            of_kind(&capitals, FindingKind::DuplicateKey)[0].message,
1795            f8("runs-on", 3, 4)
1796        );
1797
1798        let flow = analyze(
1799            "jobs:\n  lint: {\n    runs-on: ubuntu-latest,\n    runs-on: macos-latest,\n    steps: [{run: cargo fmt --check}]\n  }\n",
1800        );
1801        assert_eq!(
1802            of_kind(&flow, FindingKind::DuplicateKey)[0].message,
1803            f8("runs-on", 3, 4)
1804        );
1805        assert_eq!(
1806            of_kind(&flow, FindingKind::LintOnRunner)[0].message,
1807            "lint on macOS"
1808        );
1809
1810        let quoted = analyze(
1811            "jobs:\n  lint:\n    \"runs-on\": \"ubuntu-latest\"\n    \"runs-on\": \"macos-latest\"\n    steps:\n      - run: cargo fmt --check\n",
1812        );
1813        assert_eq!(
1814            of_kind(&quoted, FindingKind::DuplicateKey)[0].message,
1815            f8("runs-on", 3, 4)
1816        );
1817
1818        let empty_then = analyze(
1819            "jobs:\n  lint:\n    runs-on: \"\"\n    runs-on: macos-latest\n    steps:\n      - run: cargo fmt --check\n",
1820        );
1821        assert_eq!(
1822            of_kind(&empty_then, FindingKind::LintOnRunner)[0].message,
1823            "lint on macOS"
1824        );
1825
1826        let value_then_empty = analyze(
1827            "jobs:\n  lint:\n    runs-on: macos-latest\n    runs-on: \"\"\n    steps:\n      - run: cargo fmt --check\n",
1828        );
1829        assert!(of_kind(&value_then_empty, FindingKind::LintOnRunner).is_empty());
1830        assert_eq!(
1831            of_kind(&value_then_empty, FindingKind::DuplicateKey)[0].message,
1832            f8("runs-on", 3, 4)
1833        );
1834
1835        let third = analyze(
1836            "jobs:\n  lint:\n    runs-on: ubuntu-latest\n    runs-on: windows-latest\n    runs-on: macos-latest\n    steps:\n      - run: cargo fmt --check\n",
1837        );
1838        let f8s = of_kind(&third, FindingKind::DuplicateKey);
1839        assert_eq!(f8s.len(), 2, "{third:?}");
1840        assert_eq!(f8s[0].message, f8("runs-on", 3, 4));
1841        assert_eq!(f8s[1].message, f8("runs-on", 4, 5));
1842        assert_eq!(of_kind(&third, FindingKind::LintOnRunner).len(), 1);
1843
1844        let unchanged = analyze(
1845            "defaults: &defaults\n  runs-on: ubuntu-latest\njobs:\n  lint:\n    <<: *defaults\n    runs-on: macos-latest\n    steps:\n      - run: cargo fmt --check\n",
1846        );
1847        assert!(of_kind(&unchanged, FindingKind::DuplicateKey).is_empty());
1848        assert_eq!(
1849            of_kind(&unchanged, FindingKind::LintOnRunner)[0].message,
1850            "lint on macOS"
1851        );
1852
1853        let explicit_first = analyze(
1854            "defaults: &defaults\n  runs-on: macos-latest\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    <<: *defaults\n    steps:\n      - run: cargo fmt --check\n",
1855        );
1856        assert!(of_kind(&explicit_first, FindingKind::DuplicateKey).is_empty());
1857        assert!(of_kind(&explicit_first, FindingKind::LintOnRunner).is_empty());
1858
1859        let missing = analyze(
1860            "jobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - run: cargo fmt --check\n",
1861        );
1862        assert!(of_kind(&missing, FindingKind::DuplicateKey).is_empty());
1863        assert!(of_kind(&missing, FindingKind::LintOnRunner).is_empty());
1864    }
1865
1866    #[test]
1867    fn f4_dynamic_exclude_says_between_lower_and_upper() {
1868        let yaml = "jobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        exclude: ${{ inputs.rules }}\n";
1869        let (found, matrix) = matrix_case(yaml);
1870        assert_eq!(matrix.jobs, 0);
1871        assert_eq!(matrix.jobs_hi, Some(6));
1872        assert!(!matrix.at_least);
1873        let f4 = of_kind(&found, FindingKind::MatrixJobs);
1874        assert_eq!(f4.len(), 1, "{found:?}");
1875        assert_eq!(f4[0].line, 6);
1876        assert_eq!(f4[0].message, f4_between(0, 6));
1877
1878        let crlf = yaml.replace('\n', "\r\n");
1879        let (found, matrix) = matrix_case(&crlf);
1880        assert_eq!(matrix.jobs_hi, Some(6));
1881        assert_eq!(
1882            of_kind(&found, FindingKind::MatrixJobs)[0].message,
1883            f4_between(0, 6)
1884        );
1885
1886        let added = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        exclude: ${{ inputs.rules }}\n        include:\n          - os: macos-latest\n            node: 20\n";
1887        let (found, matrix) = matrix_case(added);
1888        assert_eq!(matrix.jobs, 1);
1889        assert_eq!(matrix.jobs_hi, Some(7));
1890        assert_eq!(
1891            of_kind(&found, FindingKind::MatrixJobs)[0].message,
1892            f4_between(1, 7)
1893        );
1894
1895        let narrow = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n        exclude: ${{ inputs.rules }}\n";
1896        let (found, matrix) = matrix_case(narrow);
1897        assert_eq!(matrix.jobs, 0);
1898        assert_eq!(matrix.jobs_hi, Some(4));
1899        assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1900
1901        let chain = "jobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        include:\n          - os: macos-latest\n          - os: macos-latest\n            npm: \"6\"\n";
1902        let (found, matrix) = matrix_case(chain);
1903        assert_eq!(matrix.jobs, 1);
1904        assert!(matrix.jobs_hi.is_none());
1905        assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1906    }
1907
1908    fn usd(text: &str) -> crate::money::Micros {
1909        crate::money::Micros::parse(text).unwrap()
1910    }
1911
1912    fn usage_row(
1913        sku: &str,
1914        date: &str,
1915        quantity: &str,
1916        price: &str,
1917        gross: &str,
1918        discount: &str,
1919        net: &str,
1920    ) -> UsageRow {
1921        UsageRow {
1922            line: 2,
1923            date: date.to_string(),
1924            product: "actions".to_string(),
1925            sku: sku.to_string(),
1926            quantity: usd(quantity),
1927            unit_type: "minutes".to_string(),
1928            applied_cost_per_quantity: usd(price),
1929            gross: usd(gross),
1930            discount: usd(discount),
1931            net: usd(net),
1932            username: String::new(),
1933            organization: "example-org".to_string(),
1934            repository: "example-org/app".to_string(),
1935            workflow_path: ".github/workflows/lint.yml".to_string(),
1936        }
1937    }
1938
1939    #[test]
1940    fn invoice_accepts_118_minutes_at_0_062() {
1941        let row = usage_row(
1942            "actions_macos",
1943            "2026-09-01",
1944            "118",
1945            "0.062",
1946            "7.316",
1947            "0",
1948            "7.316",
1949        );
1950        let found = invoice_findings(Path::new("usage.csv"), std::slice::from_ref(&row));
1951        assert!(found.is_empty(), "{found:?}");
1952        assert_eq!(row.net, usd("7.316"));
1953        assert_eq!(
1954            crate::money::rounded_product(row.quantity, row.applied_cost_per_quantity),
1955            row.gross.0 as i128
1956        );
1957    }
1958
1959    #[test]
1960    fn invoice_allows_one_micro_and_flags_two() {
1961        let exact = usage_row(
1962            "actions_macos",
1963            "2026-09-01",
1964            "118",
1965            "0.062",
1966            "7.316",
1967            "0",
1968            "7.316",
1969        );
1970        for (gross, expect_finding) in [
1971            ("7.315999", false),
1972            ("7.316000", false),
1973            ("7.316001", false),
1974            ("7.315998", true),
1975            ("7.316002", true),
1976        ] {
1977            let mut row = exact.clone();
1978            row.gross = usd(gross);
1979            row.net = usd(gross);
1980            let found = invoice_findings(Path::new("usage.csv"), &[row]);
1981            assert_eq!(found.is_empty(), !expect_finding, "{gross} {found:?}");
1982            if expect_finding {
1983                assert_eq!(found[0].kind.code(), "F9");
1984                assert!(
1985                    found[0].message.starts_with("invoice row mismatch:"),
1986                    "{found:?}"
1987                );
1988                assert!(
1989                    found[0].message.contains("sku \"actions_macos\""),
1990                    "{found:?}"
1991                );
1992                assert!(
1993                    found[0].message.contains("date \"2026-09-01\""),
1994                    "{found:?}"
1995                );
1996                assert!(
1997                    found[0].message.contains(&format!(
1998                        "gross {gross} != quantity * applied_cost_per_quantity 7.316000"
1999                    )),
2000                    "{found:?}"
2001                );
2002                assert!(!found[0].message.contains("gross - discount"), "{found:?}");
2003            }
2004        }
2005    }
2006
2007    #[test]
2008    fn invoice_rounds_a_half_micro_before_the_tolerance() {
2009        // 0.5 * 0.000001 = 0.5 micros, which rounds to 1 micro (0.000001).
2010        let price = "0.000001";
2011        for (gross, net, flagged) in [
2012            ("0.000000", "0.000000", false),
2013            ("0.000001", "0.000001", false),
2014            ("0.000002", "0.000002", false),
2015            ("0.000003", "0.000003", true),
2016            ("-0.000001", "-0.000001", true),
2017        ] {
2018            let row = usage_row(
2019                "actions_storage",
2020                "2026-09-02",
2021                "0.5",
2022                price,
2023                gross,
2024                "0",
2025                net,
2026            );
2027            let found = invoice_findings(Path::new("usage.csv"), &[row]);
2028            assert_eq!(found.is_empty(), !flagged, "{gross} {found:?}");
2029        }
2030
2031        let negative = usage_row(
2032            "actions_storage",
2033            "2026-09-02",
2034            "-0.5",
2035            price,
2036            "-0.000001",
2037            "0",
2038            "-0.000001",
2039        );
2040        assert!(invoice_findings(Path::new("usage.csv"), &[negative]).is_empty());
2041    }
2042
2043    #[test]
2044    fn invoice_net_must_equal_gross_minus_discount() {
2045        let matched = usage_row(
2046            "actions_macos",
2047            "2026-09-01",
2048            "118",
2049            "0.062",
2050            "7.316",
2051            "1",
2052            "6.316",
2053        );
2054        assert!(invoice_findings(Path::new("usage.csv"), &[matched]).is_empty());
2055
2056        let off_by_one = usage_row(
2057            "actions_macos",
2058            "2026-09-01",
2059            "118",
2060            "0.062",
2061            "7.316",
2062            "0",
2063            "7.315999",
2064        );
2065        let found = invoice_findings(Path::new("usage.csv"), &[off_by_one]);
2066        assert_eq!(found.len(), 1, "{found:?}");
2067        assert!(
2068            found[0]
2069                .message
2070                .contains("net 7.315999 != gross - discount 7.316000"),
2071            "{found:?}"
2072        );
2073        assert!(!found[0].message.contains("quantity *"), "{found:?}");
2074
2075        let both = usage_row(
2076            "Actions_MacOS",
2077            "2026-09-14",
2078            "118",
2079            "0.080",
2080            "7.316",
2081            "1",
2082            "7.316",
2083        );
2084        let found = invoice_findings(Path::new("bill.csv"), &[both]);
2085        assert_eq!(found.len(), 1, "{found:?}");
2086        assert_eq!(found[0].kind, FindingKind::InvoiceMismatch);
2087        assert_eq!(found[0].line, 2);
2088        assert_eq!(found[0].path, Path::new("bill.csv"));
2089        assert_eq!(found[0].repository.as_deref(), Some("example-org/app"));
2090        assert_eq!(
2091            found[0].workflow_path.as_deref(),
2092            Some(".github/workflows/lint.yml")
2093        );
2094        assert_eq!(
2095            found[0].message,
2096            "invoice row mismatch: sku \"Actions_MacOS\", date \"2026-09-14\", gross 7.316000 != quantity * applied_cost_per_quantity 9.440000, net 7.316000 != gross - discount 6.316000"
2097        );
2098    }
2099
2100    #[test]
2101    fn invoice_keeps_an_empty_sku_and_date() {
2102        let row = usage_row("", "", "10", "0.5", "1", "0", "1");
2103        let found = invoice_findings(Path::new("usage.csv"), &[row]);
2104        assert_eq!(found.len(), 1, "{found:?}");
2105        assert!(found[0].message.contains("sku \"\""), "{found:?}");
2106        assert!(found[0].message.contains("date \"\""), "{found:?}");
2107        assert!(
2108            found[0]
2109                .message
2110                .contains("gross 1.000000 != quantity * applied_cost_per_quantity 5.000000"),
2111            "{found:?}"
2112        );
2113    }
2114
2115    #[test]
2116    fn sample_usage_has_no_invoice_mismatch() {
2117        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/usage-sample.csv");
2118        let rows = crate::usage::read_usage(&path).unwrap();
2119        let found = invoice_findings(&path, &rows);
2120        assert!(found.is_empty(), "{found:?}");
2121        assert_eq!(rows[0].line, 2);
2122        assert_eq!(rows[0].quantity, usd("118"));
2123        assert_eq!(rows[0].applied_cost_per_quantity, usd("0.062"));
2124        assert_eq!(rows[0].gross, usd("7.316"));
2125        assert_eq!(rows[0].net, usd("7.316"));
2126    }
2127
2128    fn joined(lines: &[&str]) -> String {
2129        let mut text = lines.join("\n");
2130        text.push('\n');
2131        text
2132    }
2133
2134    fn crlf(text: &str) -> String {
2135        text.replace('\n', "\r\n")
2136    }
2137
2138    fn axis_msg(os: &str, runs_on: usize, axis: &str, axis_line: usize) -> String {
2139        format!("lint on {os} (runs-on line {runs_on}, axis {axis} line {axis_line})")
2140    }
2141
2142    fn assert_f1(found: &[Finding], line: usize, messages: &[&str]) {
2143        let f1 = of_kind(found, FindingKind::LintOnRunner);
2144        let got: Vec<&str> = f1.iter().map(|item| item.message.as_str()).collect();
2145        assert_eq!(got, messages, "{found:?}");
2146        assert!(f1.iter().all(|item| item.line == line), "{found:?}");
2147        assert!(
2148            f1.iter().all(|item| item.path == Path::new("fixture.yml")),
2149            "{found:?}"
2150        );
2151    }
2152
2153    fn assert_no_f1(found: &[Finding]) {
2154        assert!(
2155            of_kind(found, FindingKind::LintOnRunner).is_empty(),
2156            "{found:?}"
2157        );
2158    }
2159
2160    const F3_PR: &str = "pull_request without concurrency cancel-in-progress";
2161    const F3_TARGET: &str = "pull_request_target without concurrency cancel-in-progress";
2162
2163    fn assert_f3(found: &[Finding], line: usize, message: &str) {
2164        let f3 = of_kind(found, FindingKind::PullRequestConcurrency);
2165        assert_eq!(f3.len(), 1, "{found:?}");
2166        assert_eq!(f3[0].line, line, "{found:?}");
2167        assert_eq!(f3[0].message, message, "{found:?}");
2168        assert_eq!(f3[0].path, Path::new("fixture.yml"));
2169    }
2170
2171    fn assert_no_f3(found: &[Finding]) {
2172        assert!(
2173            of_kind(found, FindingKind::PullRequestConcurrency).is_empty(),
2174            "{found:?}"
2175        );
2176    }
2177
2178    #[test]
2179    fn matrix_runs_on_couples_axis_and_include_values() {
2180        let positive = joined(&[
2181            "jobs:",                                       // 1
2182            "  lint:",                                     // 2
2183            "    runs-on: ${{ matrix.os }}",               // 3
2184            "    timeout-minutes: 5",                      // 4
2185            "    steps:",                                  // 5
2186            "      - run: cargo fmt --check",              // 6
2187            "    strategy:",                               // 7
2188            "      matrix:",                               // 8
2189            "        os: [ubuntu-latest, windows-latest]", // 9
2190            "        include:",                            // 10
2191            "          - os: macos-latest",                // 11
2192        ]);
2193        let windows = axis_msg("Windows", 3, "os", 9);
2194        let macos = axis_msg("macOS", 3, "os", 9);
2195        assert_f1(&analyze(&positive), 3, &[&windows, &macos]);
2196        assert_f1(&analyze(&crlf(&positive)), 3, &[&windows, &macos]);
2197
2198        let include_only = joined(&[
2199            "jobs:",                          // 1
2200            "  lint:",                        // 2
2201            "    runs-on: ${{ matrix.os }}",  // 3
2202            "    timeout-minutes: 5",         // 4
2203            "    steps:",                     // 5
2204            "      - run: cargo fmt --check", // 6
2205            "    strategy:",                  // 7
2206            "      matrix:",                  // 8
2207            "        include:",               // 9
2208            "          - os: macos-latest",   // 10
2209            "            npm: \"9\"",         // 11
2210            "          - os: ubuntu-latest",  // 12
2211        ]);
2212        assert_f1(
2213            &analyze(&include_only),
2214            3,
2215            &[&axis_msg("macOS", 3, "os", 10)],
2216        );
2217
2218        let include_first = joined(&[
2219            "jobs:",                          // 1
2220            "  lint:",                        // 2
2221            "    runs-on: ${{ matrix.os }}",  // 3
2222            "    timeout-minutes: 5",         // 4
2223            "    steps:",                     // 5
2224            "      - run: cargo fmt --check", // 6
2225            "    strategy:",                  // 7
2226            "      matrix:",                  // 8
2227            "        include:",               // 9
2228            "          - os: macos-latest",   // 10
2229            "        os: [ubuntu-latest]",    // 11
2230        ]);
2231        assert_f1(
2232            &analyze(&include_first),
2233            3,
2234            &[&axis_msg("macOS", 3, "os", 11)],
2235        );
2236        assert_f1(
2237            &analyze(&crlf(&include_first)),
2238            3,
2239            &[&axis_msg("macOS", 3, "os", 11)],
2240        );
2241
2242        let expression_axis = joined(&[
2243            "jobs:",                          // 1
2244            "  lint:",                        // 2
2245            "    runs-on: ${{ matrix.os }}",  // 3
2246            "    timeout-minutes: 5",         // 4
2247            "    steps:",                     // 5
2248            "      - run: cargo fmt --check", // 6
2249            "    strategy:",                  // 7
2250            "      matrix:",                  // 8
2251            "        include:",               // 9
2252            "          - os: windows-latest", // 10
2253            "        os: ${{ inputs.os }}",   // 11
2254        ]);
2255        assert_f1(
2256            &analyze(&expression_axis),
2257            3,
2258            &[&axis_msg("Windows", 3, "os", 11)],
2259        );
2260
2261        let capitals = joined(&[
2262            "jobs:",                                      // 1
2263            "  lint:",                                    // 2
2264            "    runs-on: ${{ matrix.OS }}",              // 3
2265            "    timeout-minutes: 5",                     // 4
2266            "    steps:",                                 // 5
2267            "      - run: cargo fmt --check",             // 6
2268            "    strategy:",                              // 7
2269            "      matrix:",                              // 8
2270            "        OS: [MacOS-latest, WINDOWS-latest]", // 9
2271            "        include:",                           // 10
2272            "          - OS: ubuntu-latest",              // 11
2273        ]);
2274        let windows = axis_msg("Windows", 3, "OS", 9);
2275        let macos = axis_msg("macOS", 3, "OS", 9);
2276        assert_f1(&analyze(&capitals), 3, &[&windows, &macos]);
2277
2278        let reordered = joined(&[
2279            "jobs:",                          // 1
2280            "  lint:",                        // 2
2281            "    strategy:",                  // 3
2282            "      matrix:",                  // 4
2283            "        os: [macos-latest]",     // 5
2284            "    steps:",                     // 6
2285            "      - run: cargo fmt --check", // 7
2286            "    timeout-minutes: 5",         // 8
2287            "    runs-on: ${{ matrix.os }}",  // 9
2288        ]);
2289        assert_f1(&analyze(&reordered), 9, &[&axis_msg("macOS", 9, "os", 5)]);
2290
2291        let split_values = joined(&[
2292            "jobs:",                                   // 1
2293            "  prettier:",                             // 2
2294            "    runs-on: ${{ matrix.os }}",           // 3
2295            "    timeout-minutes: 5",                  // 4
2296            "    steps:",                              // 5
2297            "      - run: npx prettier --check .",     // 6
2298            "    strategy:",                           // 7
2299            "      matrix:",                           // 8
2300            "        os: [self-hosted, macos-latest]", // 9
2301        ]);
2302        assert_f1(
2303            &analyze(&split_values),
2304            3,
2305            &[&axis_msg("macOS", 3, "os", 9)],
2306        );
2307
2308        let reversed_split = joined(&[
2309            "jobs:",                                    // 1
2310            "  lint:",                                  // 2
2311            "    runs-on: ${{ matrix.os }}",            // 3
2312            "    timeout-minutes: 5",                   // 4
2313            "    steps:",                               // 5
2314            "      - run: cargo clippy -- -D warnings", // 6
2315            "    strategy:",                            // 7
2316            "      matrix:",                            // 8
2317            "        os: [WINDOWS-2022, Self-Hosted]",  // 9
2318        ]);
2319        assert_f1(
2320            &analyze(&reversed_split),
2321            3,
2322            &[&axis_msg("Windows", 3, "os", 9)],
2323        );
2324
2325        let map_value = joined(&[
2326            "jobs:",                                    // 1
2327            "  lint:",                                  // 2
2328            "    runs-on: ${{ matrix.os }}",            // 3
2329            "    timeout-minutes: 5",                   // 4
2330            "    steps:",                               // 5
2331            "      - run: cargo fmt --check",           // 6
2332            "    strategy:",                            // 7
2333            "      matrix:",                            // 8
2334            "        os:",                              // 9
2335            "          - group: large",                 // 10
2336            "            labels: [self-hosted, macos]", // 11
2337            "          - group: large",                 // 12
2338            "            labels: [windows-latest]",     // 13
2339        ]);
2340        assert_f1(&analyze(&map_value), 3, &[&axis_msg("Windows", 3, "os", 9)]);
2341
2342        let excluded = joined(&[
2343            "jobs:",                                     // 1
2344            "  lint:",                                   // 2
2345            "    runs-on: ${{ matrix.os }}",             // 3
2346            "    timeout-minutes: 5",                    // 4
2347            "    steps:",                                // 5
2348            "      - run: cargo fmt --check",            // 6
2349            "    strategy:",                             // 7
2350            "      matrix:",                             // 8
2351            "        os: [ubuntu-latest, macos-latest]", // 9
2352            "        exclude:",                          // 10
2353            "          - os: macos-latest",              // 11
2354        ]);
2355        assert_f1(&analyze(&excluded), 3, &[&axis_msg("macOS", 3, "os", 9)]);
2356
2357        let flow = joined(&[
2358            "jobs: {",                                      // 1
2359            "  lint: {",                                    // 2
2360            "    runs-on: \"${{ matrix.os }}\",",           // 3
2361            "    timeout-minutes: 5,",                      // 4
2362            "    steps: [{run: cargo fmt --check}],",       // 5
2363            "    strategy: {",                              // 6
2364            "      matrix: {",                              // 7
2365            "        os: [ubuntu-latest, windows-latest],", // 8
2366            "        include: [{os: macos-latest}]",        // 9
2367            "      }",                                      // 10
2368            "    }",                                        // 11
2369            "  }",                                          // 12
2370            "}",                                            // 13
2371        ]);
2372        let windows = axis_msg("Windows", 3, "os", 8);
2373        let macos = axis_msg("macOS", 3, "os", 8);
2374        assert_f1(&analyze(&flow), 3, &[&windows, &macos]);
2375        assert_f1(&analyze(&crlf(&flow)), 3, &[&windows, &macos]);
2376
2377        let ubuntu = joined(&[
2378            "jobs:",                                     // 1
2379            "  lint:",                                   // 2
2380            "    runs-on: ${{ matrix.os }}",             // 3
2381            "    timeout-minutes: 5",                    // 4
2382            "    steps:",                                // 5
2383            "      - run: cargo fmt --check",            // 6
2384            "    strategy:",                             // 7
2385            "      matrix:",                             // 8
2386            "        os: [ubuntu-latest, ubuntu-22.04]", // 9
2387            "        include:",                          // 10
2388            "          - os: ubuntu-24.04",              // 11
2389        ]);
2390        assert_no_f1(&analyze(&ubuntu));
2391        assert_no_f1(&analyze(&crlf(&ubuntu)));
2392
2393        let nested_hosted = joined(&[
2394            "jobs:",                              // 1
2395            "  lint:",                            // 2
2396            "    runs-on: ${{ matrix.os }}",      // 3
2397            "    timeout-minutes: 5",             // 4
2398            "    steps:",                         // 5
2399            "      - run: cargo fmt --check",     // 6
2400            "    strategy:",                      // 7
2401            "      matrix:",                      // 8
2402            "        os:",                        // 9
2403            "          - [self-hosted, macos]",   // 10
2404            "          - [Self-Hosted, Windows]", // 11
2405        ]);
2406        assert_no_f1(&analyze(&nested_hosted));
2407
2408        let include_hosted = joined(&[
2409            "jobs:",                                  // 1
2410            "  lint:",                                // 2
2411            "    runs-on: ${{ matrix.os }}",          // 3
2412            "    timeout-minutes: 5",                 // 4
2413            "    steps:",                             // 5
2414            "      - run: cargo fmt --check",         // 6
2415            "    strategy:",                          // 7
2416            "      matrix:",                          // 8
2417            "        include:",                       // 9
2418            "          - os: [self-hosted, macos]",   // 10
2419            "          - os: [windows, Self-Hosted]", // 11
2420        ]);
2421        assert_no_f1(&analyze(&include_hosted));
2422
2423        let not_lint = joined(&[
2424            "jobs:",                                      // 1
2425            "  test:",                                    // 2
2426            "    runs-on: ${{ matrix.os }}",              // 3
2427            "    timeout-minutes: 5",                     // 4
2428            "    steps:",                                 // 5
2429            "      - run: cargo test",                    // 6
2430            "    strategy:",                              // 7
2431            "      matrix:",                              // 8
2432            "        os: [macos-latest, windows-latest]", // 9
2433        ]);
2434        assert_no_f1(&analyze(&not_lint));
2435
2436        let other_axis = joined(&[
2437            "jobs:",                                      // 1
2438            "  lint:",                                    // 2
2439            "    runs-on: ${{ matrix.node }}",            // 3
2440            "    timeout-minutes: 5",                     // 4
2441            "    steps:",                                 // 5
2442            "      - run: cargo fmt --check",             // 6
2443            "    strategy:",                              // 7
2444            "      matrix:",                              // 8
2445            "        os: [macos-latest, windows-latest]", // 9
2446            "        node: ['14', '16']",                 // 10
2447        ]);
2448        assert_no_f1(&analyze(&other_axis));
2449
2450        let wrong_case = joined(&[
2451            "jobs:",                                      // 1
2452            "  lint:",                                    // 2
2453            "    runs-on: ${{ matrix.os }}",              // 3
2454            "    timeout-minutes: 5",                     // 4
2455            "    steps:",                                 // 5
2456            "      - run: cargo fmt --check",             // 6
2457            "    strategy:",                              // 7
2458            "      matrix:",                              // 8
2459            "        OS: [MacOS-latest, WINDOWS-latest]", // 9
2460        ]);
2461        assert_no_f1(&analyze(&wrong_case));
2462
2463        let missing = [
2464            joined(&[
2465                "jobs:",
2466                "  lint:",
2467                "    runs-on: ${{ matrix.os }}",
2468                "    timeout-minutes: 5",
2469                "    steps:",
2470                "      - run: cargo fmt --check",
2471            ]),
2472            joined(&[
2473                "jobs:",
2474                "  lint:",
2475                "    runs-on: ${{ matrix.os }}",
2476                "    timeout-minutes: 5",
2477                "    steps:",
2478                "      - run: cargo fmt --check",
2479                "    strategy:",
2480                "      matrix: {}",
2481            ]),
2482            joined(&[
2483                "jobs:",
2484                "  lint:",
2485                "    runs-on: ${{ matrix.os }}",
2486                "    timeout-minutes: 5",
2487                "    steps:",
2488                "      - run: cargo fmt --check",
2489                "    strategy:",
2490                "      matrix:",
2491                "        os: []",
2492            ]),
2493            joined(&[
2494                "jobs:",
2495                "  lint:",
2496                "    runs-on: ${{ matrix.os }}",
2497                "    timeout-minutes: 5",
2498                "    steps:",
2499                "      - run: cargo fmt --check",
2500                "    strategy:",
2501                "      matrix:",
2502                "        os: ${{ inputs.os }}",
2503            ]),
2504            joined(&[
2505                "jobs:",
2506                "  lint:",
2507                "    runs-on: \"\"",
2508                "    timeout-minutes: 5",
2509                "    steps:",
2510                "      - run: cargo fmt --check",
2511                "    strategy:",
2512                "      matrix:",
2513                "        os: [macos-latest]",
2514            ]),
2515            joined(&[
2516                "jobs:",
2517                "  lint:",
2518                "    timeout-minutes: 5",
2519                "    steps:",
2520                "      - run: cargo fmt --check",
2521                "    strategy:",
2522                "      matrix:",
2523                "        os: [macos-latest, windows-latest]",
2524            ]),
2525            joined(&[
2526                "jobs:",
2527                "  lint:",
2528                "    runs-on: ${{ matrix.os }}",
2529                "    timeout-minutes: 5",
2530                "    steps:",
2531                "      - run: cargo fmt --check",
2532                "    strategy:",
2533                "      matrix:",
2534                "        include: []",
2535            ]),
2536            joined(&[
2537                "jobs:",
2538                "  lint:",
2539                "    runs-on: ${{ matrix.missing }}",
2540                "    timeout-minutes: 5",
2541                "    steps:",
2542                "      - run: cargo fmt --check",
2543                "    strategy:",
2544                "      matrix:",
2545                "        os: [macos-latest]",
2546            ]),
2547            joined(&[
2548                "jobs:",
2549                "  lint:",
2550                "    runs-on: ${{ matrix.os }}",
2551                "    timeout-minutes: 5",
2552                "    steps:",
2553                "      - run: cargo fmt --check",
2554                "    strategy:",
2555                "      matrix:",
2556                "        include:",
2557                "          - os: \"\"",
2558            ]),
2559            String::new(),
2560            "{}\n".to_string(),
2561        ];
2562        for yaml in missing {
2563            assert_no_f1(&analyze(&yaml));
2564            assert_no_f1(&analyze(&crlf(&yaml)));
2565        }
2566
2567        let flow_negative = joined(&[
2568            "jobs: {",
2569            "  lint: {",
2570            "    runs-on: \"${{ matrix.os }}\",",
2571            "    timeout-minutes: 5,",
2572            "    steps: [{run: cargo fmt --check}],",
2573            "    strategy: {matrix: {os: [ubuntu-latest]}}",
2574            "  }",
2575            "}",
2576        ]);
2577        assert_no_f1(&analyze(&flow_negative));
2578    }
2579
2580    #[test]
2581    fn matrix_reference_inside_text_couples_that_axis() {
2582        let inside = joined(&[
2583            "jobs:",                                        // 1
2584            "  fmt:",                                       // 2
2585            "    runs-on: ${{ format('{0}', matrix.os) }}", // 3
2586            "    timeout-minutes: 5",                       // 4
2587            "    steps:",                                   // 5
2588            "      - run: cargo fmt --check",               // 6
2589            "    strategy:",                                // 7
2590            "      matrix:",                                // 8
2591            "        os: [ubuntu-latest, macos-14]",        // 9
2592        ]);
2593        assert_f1(&analyze(&inside), 3, &[&axis_msg("macOS", 3, "os", 9)]);
2594        assert_f1(
2595            &analyze(&crlf(&inside)),
2596            3,
2597            &[&axis_msg("macOS", 3, "os", 9)],
2598        );
2599
2600        let both = joined(&[
2601            "jobs:",                                                 // 1
2602            "  lint:",                                               // 2
2603            "    runs-on: pre-${{ matrix.os }}-${{ matrix.image }}", // 3
2604            "    timeout-minutes: 5",                                // 4
2605            "    steps:",                                            // 5
2606            "      - run: cargo fmt --check",                        // 6
2607            "    strategy:",                                         // 7
2608            "      matrix:",                                         // 8
2609            "        os: [ubuntu-latest]",                           // 9
2610            "        image: [macos-latest, windows-latest]",         // 10
2611        ]);
2612        let windows = axis_msg("Windows", 3, "image", 10);
2613        let macos = axis_msg("macOS", 3, "image", 10);
2614        assert_f1(&analyze(&both), 3, &[&windows, &macos]);
2615
2616        let bracket = joined(&[
2617            "jobs:",                            // 1
2618            "  eslint:",                        // 2
2619            "    runs-on: ${{ matrix['os'] }}", // 3
2620            "    timeout-minutes: 5",           // 4
2621            "    steps:",                       // 5
2622            "      - run: npx eslint .",        // 6
2623            "    strategy:",                    // 7
2624            "      matrix:",                    // 8
2625            "        os: [windows-latest]",     // 9
2626        ]);
2627        assert_f1(&analyze(&bracket), 3, &[&axis_msg("Windows", 3, "os", 9)]);
2628
2629        let spaced = joined(&[
2630            "jobs:",                                // 1
2631            "  lint:",                              // 2
2632            "    name: Clippy",                     // 3
2633            "    runs-on: ${{ matrix[ \"OS\" ] }}", // 4
2634            "    timeout-minutes: 5",               // 5
2635            "    steps:",                           // 6
2636            "      - run: echo hi",                 // 7
2637            "    strategy:",                        // 8
2638            "      matrix:",                        // 9
2639            "        OS: [MacOS]",                  // 10
2640        ]);
2641        assert_f1(&analyze(&spaced), 4, &[&axis_msg("macOS", 4, "OS", 10)]);
2642
2643        let extra = joined(&[
2644            "jobs:",                          // 1
2645            "  lint:",                        // 2
2646            "    runs-on:",                   // 3
2647            "      - ubuntu-latest",          // 4
2648            "      - ${{ matrix.extra }}",    // 5
2649            "    timeout-minutes: 5",         // 6
2650            "    steps:",                     // 7
2651            "      - run: cargo fmt --check", // 8
2652            "    strategy:",                  // 9
2653            "      matrix:",                  // 10
2654            "        extra: [windows]",       // 11
2655        ]);
2656        assert_f1(&analyze(&extra), 3, &[&axis_msg("Windows", 3, "extra", 11)]);
2657
2658        let hosted_list = joined(&[
2659            "jobs:",                                      // 1
2660            "  lint:",                                    // 2
2661            "    runs-on:",                               // 3
2662            "      - ${{ matrix.os }}",                   // 4
2663            "      - self-hosted",                        // 5
2664            "    timeout-minutes: 5",                     // 6
2665            "    steps:",                                 // 7
2666            "      - run: cargo fmt --check",             // 8
2667            "    strategy:",                              // 9
2668            "      matrix:",                              // 10
2669            "        os: [macos-latest, windows-latest]", // 11
2670        ]);
2671        assert_no_f1(&analyze(&hosted_list));
2672        assert_no_f1(&analyze(&crlf(&hosted_list)));
2673
2674        let not_matrix = joined(&[
2675            "jobs:",                            // 1
2676            "  lint:",                          // 2
2677            "    runs-on: ${{ notmatrix.os }}", // 3
2678            "    timeout-minutes: 5",           // 4
2679            "    steps:",                       // 5
2680            "      - run: cargo fmt --check",   // 6
2681            "    strategy:",                    // 7
2682            "      matrix:",                    // 8
2683            "        os: [macos-latest]",       // 9
2684        ]);
2685        assert_no_f1(&analyze(&not_matrix));
2686
2687        let flow = joined(&[
2688            "jobs: {",                                                     // 1
2689            "  lint: {",                                                   // 2
2690            "    runs-on: \"runner-${{ matrix.os }}\",",                   // 3
2691            "    timeout-minutes: 5,",                                     // 4
2692            "    steps: [{run: cargo fmt --check}],",                      // 5
2693            "    strategy: {matrix: {os: [ubuntu-latest, MacOS-latest]}}", // 6
2694            "  }",                                                         // 7
2695            "}",                                                           // 8
2696        ]);
2697        assert_f1(&analyze(&flow), 3, &[&axis_msg("macOS", 3, "os", 6)]);
2698
2699        let flow_negative = joined(&[
2700            "jobs: {",
2701            "  lint: {",
2702            "    runs-on: \"${{ matrix.node }}\",",
2703            "    timeout-minutes: 5,",
2704            "    steps: [{run: cargo fmt --check}],",
2705            "    strategy: {matrix: {os: [macos-latest], node: ['14']}}",
2706            "  }",
2707            "}",
2708        ]);
2709        assert_no_f1(&analyze(&flow_negative));
2710        assert_no_f1(&analyze(&crlf(&flow_negative)));
2711    }
2712
2713    #[test]
2714    fn self_hosted_label_set_is_not_paid_macos_or_windows() {
2715        let negative = [
2716            "jobs:\n  lint:\n    runs-on: [self-hosted, macos]\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2717            "jobs:\n  lint:\n    runs-on: [macos, self-hosted]\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2718            "jobs:\n  lint:\n    runs-on: [Self-Hosted, MacOS]\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2719            "jobs:\n  prettier:\n    runs-on: [WINDOWS, SELF-HOSTED]\n    timeout-minutes: 5\n    steps:\n      - run: npx prettier --check .\n",
2720            "jobs:\n  lint:\n    runs-on: [windows-latest, Self-Hosted]\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2721            "jobs:\n  lint:\n    runs-on:\n      - self-hosted\n      - windows\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2722            "jobs:\n  lint:\n    runs-on:\n      group: large\n      labels:\n        - windows\n        - self-hosted\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2723            "jobs:\n  lint:\n    runs-on: {group: large, labels: [self-hosted, macos]}\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2724            "jobs:\n  lint:\n    runs-on: {group: macos}\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2725            "jobs:\n  lint:\n    runs-on: []\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2726            "jobs:\n  lint:\n    runs-on: \"\"\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2727            "jobs:\n  lint:\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
2728            "jobs: {lint: {runs-on: [self-hosted, macos], timeout-minutes: 5, steps: [{run: cargo fmt --check}]}}\n",
2729            "jobs: {lint: {runs-on: {group: large, labels: [macos, Self-Hosted]}, timeout-minutes: 5, steps: [{run: cargo fmt --check}]}}\n",
2730        ];
2731        for yaml in negative {
2732            assert_no_f1(&analyze(yaml));
2733            assert_no_f1(&analyze(&crlf(yaml)));
2734        }
2735
2736        let paid = joined(&[
2737            "jobs:",                          // 1
2738            "  lint:",                        // 2
2739            "    runs-on: [MacOS-14]",        // 3
2740            "    timeout-minutes: 5",         // 4
2741            "    steps:",                     // 5
2742            "      - run: cargo fmt --check", // 6
2743        ]);
2744        assert_f1(&analyze(&paid), 3, &["lint on macOS"]);
2745        assert_f1(&analyze(&crlf(&paid)), 3, &["lint on macOS"]);
2746
2747        let both = joined(&[
2748            "jobs:",                                       // 1
2749            "  lint:",                                     // 2
2750            "    runs-on: [macos-latest, windows-latest]", // 3
2751            "    timeout-minutes: 5",                      // 4
2752            "    steps:",                                  // 5
2753            "      - run: cargo fmt --check",              // 6
2754        ]);
2755        assert_f1(&analyze(&both), 3, &["lint on Windows", "lint on macOS"]);
2756
2757        let exact = joined(&[
2758            "jobs:",                  // 1
2759            "  eslint:",              // 2
2760            "    runs-on: Windows",   // 3
2761            "    timeout-minutes: 5", // 4
2762            "    steps:",             // 5
2763            "      - run: echo hi",   // 6
2764        ]);
2765        assert_f1(&analyze(&exact), 3, &["lint on Windows"]);
2766
2767        let map = joined(&[
2768            "jobs:",                          // 1
2769            "  lint:",                        // 2
2770            "    runs-on:",                   // 3
2771            "      group: large",             // 4
2772            "      labels: [macos-latest]",   // 5
2773            "    timeout-minutes: 5",         // 6
2774            "    steps:",                     // 7
2775            "      - run: cargo fmt --check", // 8
2776        ]);
2777        assert_f1(&analyze(&map), 3, &["lint on macOS"]);
2778
2779        let flow = "jobs: {lint: {runs-on: {group: large, labels: [macos-latest, windows]}, timeout-minutes: 5, steps: [{run: cargo fmt --check}]}}\n";
2780        assert_f1(&analyze(flow), 1, &["lint on Windows", "lint on macOS"]);
2781        assert_f1(
2782            &analyze(&crlf(flow)),
2783            1,
2784            &["lint on Windows", "lint on macOS"],
2785        );
2786    }
2787
2788    fn pr_with_cancel(value: &str) -> String {
2789        format!("on: pull_request\nconcurrency:\n  group: ci\n  cancel-in-progress: {value}\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 1\n    steps:\n      - run: cargo test\n")
2790    }
2791
2792    fn job_with_cancel(value: &str) -> String {
2793        format!("on: pull_request\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 1\n    concurrency:\n      group: ci\n      cancel-in-progress: {value}\n    steps:\n      - run: cargo test\n")
2794    }
2795
2796    #[test]
2797    fn cancel_expression_counts_as_true_for_f3() {
2798        let cases = [
2799            (r"${{ github.event_name == 'pull_request' }}", true),
2800            (r"${{github.event_name=='pull_request'}}", true),
2801            (r#"${{ github.event_name == "pull_request" }}"#, true),
2802            (r"${{  github.event_name   ==   'pull_request'  }}", true),
2803            (r"${{ GitHub.Event_Name == 'PULL_REQUEST' }}", true),
2804            (r"${{ github.event_name == 'pull_request_target' }}", true),
2805            (r#"${{ github.event_name == "Pull_Request_Target" }}"#, true),
2806            (
2807                r"${{ github.event_name == 'pull_request' || github.event_name == 'push' }}",
2808                true,
2809            ),
2810            (r"${{ false || github.event_name == 'pull_request' }}", true),
2811            (r"${{ (github.event_name == 'pull_request') }}", true),
2812            ("(github.event_name == 'pull_request')", true),
2813            ("true", true),
2814            ("TRUE", true),
2815            ("True", true),
2816            (r"${{ true }}", true),
2817            ("false", false),
2818            ("FALSE", false),
2819            (r"${{ false }}", false),
2820            (r"${{ github.event_name == 'push' }}", false),
2821            (r"${{ github.event_name != 'pull_request' }}", false),
2822            (r"${{ github.event_name == 'pull_request_review' }}", false),
2823            (
2824                r"${{ github.event_name == 'pull_request' && false }}",
2825                false,
2826            ),
2827            ("yes", false),
2828            ("", false),
2829            ("~", false),
2830            ("null", false),
2831        ];
2832        for (value, enables) in cases {
2833            for yaml in [
2834                pr_with_cancel(value),
2835                crlf(&pr_with_cancel(value)),
2836                job_with_cancel(value),
2837                crlf(&job_with_cancel(value)),
2838            ] {
2839                let found = analyze(&yaml);
2840                if enables {
2841                    assert_no_f3(&found);
2842                } else {
2843                    assert_f3(&found, 1, F3_PR);
2844                }
2845            }
2846        }
2847
2848        let folded = "on: pull_request\nconcurrency:\n  cancel-in-progress: >-\n    ${{ github.event_name == 'pull_request' }}\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 1\n";
2849        assert_no_f3(&analyze(folded));
2850        assert_no_f3(&analyze(&crlf(folded)));
2851
2852        let literal_block = "on: pull_request\nconcurrency:\n  cancel-in-progress: |\n    ${{ github.event_name == \"pull_request\" }}\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2853        assert_no_f3(&analyze(literal_block));
2854
2855        let flow = "on: {pull_request: {}}\nconcurrency: {group: ci, cancel-in-progress: \"${{ github.event_name == 'pull_request' }}\"}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 1, steps: [{run: cargo test}]}}\n";
2856        assert_no_f3(&analyze(flow));
2857        assert_no_f3(&analyze(&crlf(flow)));
2858
2859        let flow_job = "on: {pull_request: {}}\njobs: {test: {concurrency: {cancel-in-progress: \"${{  github.event_name == \\\"pull_request\\\"  }}\"}, runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
2860        assert_no_f3(&analyze(flow_job));
2861
2862        let flow_false = "on: [pull_request]\njobs: {test: {concurrency: {cancel-in-progress: false}, timeout-minutes: 1, runs-on: ubuntu-latest}}\n";
2863        assert_f3(&analyze(flow_false), 1, F3_PR);
2864        assert_f3(&analyze(&crlf(flow_false)), 1, F3_PR);
2865
2866        let missing_key = "on: pull_request\nconcurrency:\n  group: ci\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2867        assert_f3(&analyze(missing_key), 1, F3_PR);
2868
2869        let empty_map = "on: pull_request\nconcurrency: {}\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2870        assert_f3(&analyze(empty_map), 1, F3_PR);
2871        assert_f3(&analyze(&crlf(empty_map)), 1, F3_PR);
2872
2873        let empty_value = "on: pull_request\nconcurrency:\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2874        assert_f3(&analyze(empty_value), 1, F3_PR);
2875
2876        let group_only = "on: pull_request\nconcurrency: ci\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2877        assert_f3(&analyze(group_only), 1, F3_PR);
2878
2879        let job_group_only = "on: pull_request\njobs:\n  test:\n    concurrency:\n      group: ci\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2880        assert_f3(&analyze(job_group_only), 1, F3_PR);
2881
2882        let last_wins = "on: pull_request\nconcurrency:\n  cancel-in-progress: false\n  cancel-in-progress: ${{ github.event_name == 'pull_request' }}\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2883        let found = analyze(last_wins);
2884        assert_no_f3(&found);
2885        assert_eq!(of_kind(&found, FindingKind::DuplicateKey).len(), 1);
2886
2887        let last_false = "on: pull_request\nconcurrency:\n  cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n  cancel-in-progress: false\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2888        let found = analyze(last_false);
2889        assert_f3(&found, 1, F3_PR);
2890        assert_eq!(of_kind(&found, FindingKind::DuplicateKey).len(), 1);
2891
2892        let job_last_true = "on: pull_request\njobs:\n  test:\n    concurrency:\n      cancel-in-progress: false\n      cancel-in-progress: true\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2893        assert_no_f3(&analyze(job_last_true));
2894        assert_no_f3(&analyze(&crlf(job_last_true)));
2895    }
2896
2897    #[test]
2898    fn job_level_concurrency_counts_for_f3() {
2899        let one_job = joined(&[
2900            "on: pull_request",               // 1
2901            "jobs:",                          // 2
2902            "  test:",                        // 3
2903            "    runs-on: ubuntu-latest",     // 4
2904            "    timeout-minutes: 1",         // 5
2905            "    concurrency:",               // 6
2906            "      group: ci",                // 7
2907            "      cancel-in-progress: true", // 8
2908            "    steps:",                     // 9
2909            "      - run: cargo test",        // 10
2910        ]);
2911        assert_no_f3(&analyze(&one_job));
2912        assert_no_f3(&analyze(&crlf(&one_job)));
2913
2914        let concurrency_first = joined(&[
2915            "on: pull_request",
2916            "jobs:",
2917            "  test:",
2918            "    concurrency:",
2919            "      cancel-in-progress: TRUE",
2920            "    runs-on: ubuntu-latest",
2921            "    timeout-minutes: 1",
2922        ]);
2923        assert_no_f3(&analyze(&concurrency_first));
2924
2925        let one_of_two = joined(&[
2926            "on: pull_request",
2927            "jobs:",
2928            "  lint:",
2929            "    runs-on: ubuntu-latest",
2930            "    timeout-minutes: 1",
2931            "    concurrency:",
2932            "      cancel-in-progress: true",
2933            "    steps:",
2934            "      - run: cargo test",
2935            "  build:",
2936            "    runs-on: ubuntu-latest",
2937            "    timeout-minutes: 1",
2938            "    steps:",
2939            "      - run: cargo test",
2940        ]);
2941        assert_no_f3(&analyze(&one_of_two));
2942        assert_no_f3(&analyze(&crlf(&one_of_two)));
2943
2944        let workflow_false_job_true = "on: pull_request\nconcurrency:\n  cancel-in-progress: false\njobs:\n  test:\n    concurrency:\n      cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2945        assert_no_f3(&analyze(workflow_false_job_true));
2946
2947        let workflow_true_job_false = "on: pull_request\nconcurrency:\n  cancel-in-progress: true\njobs:\n  test:\n    concurrency:\n      cancel-in-progress: false\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
2948        assert_no_f3(&analyze(workflow_true_job_false));
2949        assert_no_f3(&analyze(&crlf(workflow_true_job_false)));
2950
2951        let anchored = "on: pull_request\ndefaults: &job\n  concurrency:\n    cancel-in-progress: true\n  timeout-minutes: 5\n  runs-on: ubuntu-latest\njobs:\n  test:\n    <<: *job\n    steps:\n      - run: cargo test\n";
2952        assert_no_f3(&analyze(anchored));
2953
2954        let flow = "on: {pull_request: {}}\njobs: {test: {concurrency: {group: ci, cancel-in-progress: true}, runs-on: ubuntu-latest, timeout-minutes: 1, steps: [{run: cargo test}]}}\n";
2955        assert_no_f3(&analyze(flow));
2956        assert_no_f3(&analyze(&crlf(flow)));
2957
2958        let job_false = joined(&[
2959            "on: pull_request",
2960            "jobs:",
2961            "  test:",
2962            "    concurrency:",
2963            "      cancel-in-progress: false",
2964            "    runs-on: ubuntu-latest",
2965            "    timeout-minutes: 1",
2966        ]);
2967        assert_f3(&analyze(&job_false), 1, F3_PR);
2968        assert_f3(&analyze(&crlf(&job_false)), 1, F3_PR);
2969
2970        let neither = joined(&[
2971            "on: pull_request",
2972            "jobs:",
2973            "  lint:",
2974            "    runs-on: ubuntu-latest",
2975            "    timeout-minutes: 1",
2976            "  build:",
2977            "    runs-on: ubuntu-latest",
2978            "    timeout-minutes: 1",
2979        ]);
2980        assert_f3(&analyze(&neither), 1, F3_PR);
2981
2982        let no_jobs = "on: pull_request\n";
2983        assert_f3(&analyze(no_jobs), 1, F3_PR);
2984        assert_f3(&analyze(&crlf(no_jobs)), 1, F3_PR);
2985
2986        let empty_jobs = "on: pull_request\njobs: {}\n";
2987        assert_f3(&analyze(empty_jobs), 1, F3_PR);
2988
2989        let flow_false = "on: [pull_request]\njobs: {lint: {concurrency: {cancel-in-progress: false}, timeout-minutes: 1}, test: {runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
2990        assert_f3(&analyze(flow_false), 1, F3_PR);
2991    }
2992
2993    #[test]
2994    fn pull_request_target_triggers_f3() {
2995        let positive = [
2996            "on: pull_request_target\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 1\n",
2997            "on: \"pull_request_target\"\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
2998            "on: 'PULL_REQUEST_TARGET'\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
2999            "on: [push, pull_request_target]\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
3000            "on: [pull_request_target, push]\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
3001            "on: [Push, Pull_Request_Target]\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
3002            "on:\n  pull_request_target:\n    types: [opened]\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
3003            "on: {pull_request_target: {}}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 1}}\n",
3004            "on: PULL_REQUEST_TARGET\n",
3005        ];
3006        for yaml in positive {
3007            assert_f3(&analyze(yaml), 1, F3_TARGET);
3008            assert_f3(&analyze(&crlf(yaml)), 1, F3_TARGET);
3009        }
3010
3011        let both = "on:\n  pull_request_target:\n  pull_request:\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
3012        assert_f3(&analyze(both), 1, F3_PR);
3013        assert_f3(&analyze(&crlf(both)), 1, F3_PR);
3014
3015        let both_flow = "on: [pull_request, pull_request_target]\n";
3016        assert_f3(&analyze(both_flow), 1, F3_PR);
3017
3018        let both_order = "on: {pull_request_target: {}, pull_request: {}}\n";
3019        assert_f3(&analyze(both_order), 1, F3_PR);
3020
3021        let cancelled = "on: pull_request_target\nconcurrency:\n  group: ci\n  cancel-in-progress: true\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 1\n";
3022        assert_no_f3(&analyze(cancelled));
3023        assert_no_f3(&analyze(&crlf(cancelled)));
3024
3025        let expression = "on: pull_request_target\nconcurrency:\n  cancel-in-progress: ${{ github.event_name == 'pull_request' }}\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
3026        assert_no_f3(&analyze(expression));
3027
3028        let target_expression = "on: PULL_REQUEST_TARGET\nconcurrency:\n  cancel-in-progress: ${{  github.event_name == \"pull_request_target\"  }}\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n";
3029        assert_no_f3(&analyze(target_expression));
3030        assert_no_f3(&analyze(&crlf(target_expression)));
3031
3032        let job_level = "on: {pull_request_target: {types: [opened]}}\njobs: {test: {concurrency: {cancel-in-progress: true}, runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
3033        assert_no_f3(&analyze(job_level));
3034        assert_no_f3(&analyze(&crlf(job_level)));
3035
3036        let negative = [
3037            "on: push\njobs:\n  test:\n    timeout-minutes: 1\n    runs-on: ubuntu-latest\n",
3038            "on: pull_request_review\n",
3039            "on: workflow_dispatch\n",
3040            "on: [push, workflow_dispatch]\n",
3041            "on:\n",
3042            "on: {}\n",
3043            "on: []\n",
3044            "on: \"\"\n",
3045            "jobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 1\n",
3046            "",
3047            "{}\n",
3048            "on: {push: null, workflow_dispatch: {}}\nconcurrency: {cancel-in-progress: false}\n",
3049        ];
3050        for yaml in negative {
3051            assert_no_f3(&analyze(yaml));
3052            assert_no_f3(&analyze(&crlf(yaml)));
3053        }
3054
3055        let flow_positive = "on: {pull_request_target: {types: [opened, synchronize]}}\njobs: {lint: {runs-on: ubuntu-latest, timeout-minutes: 5, steps: [{run: cargo test}]}}\n";
3056        assert_f3(&analyze(flow_positive), 1, F3_TARGET);
3057
3058        let flow_cancelled = "on: [pull_request_target]\nconcurrency: {cancel-in-progress: \"${{ github.event_name == 'pull_request_target' }}\"}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
3059        assert_no_f3(&analyze(flow_cancelled));
3060        assert_no_f3(&analyze(&crlf(flow_cancelled)));
3061    }
3062
3063    const LINT_CALLEE: &str = "\
3064name: Lint
3065on: workflow_call
3066jobs:
3067  lint:
3068    runs-on: macos-latest
3069    timeout-minutes: 10
3070    steps:
3071      - run: cargo fmt --check
3072";
3073
3074    const MATRIX_CALLEE: &str = "\
3075name: Wide
3076on: workflow_call
3077jobs:
3078  build:
3079    runs-on: ubuntu-latest
3080    timeout-minutes: 5
3081    strategy:
3082      matrix:
3083        os: [ubuntu-latest, windows-latest]
3084        node: [14, 16, 18]
3085    steps:
3086      - run: echo build
3087";
3088
3089    fn workflows_dir(name: &str) -> PathBuf {
3090        let root = std::env::temp_dir().join("ci-bill-b4a").join(name);
3091        let _ = fs::remove_dir_all(&root);
3092        let dir = root.join(".github").join("workflows");
3093        fs::create_dir_all(&dir).unwrap();
3094        dir
3095    }
3096
3097    fn write_workflow(dir: &Path, name: &str, text: &str, crlf_file: bool, bom: bool) {
3098        let mut body = if crlf_file {
3099            text.replace('\n', "\r\n")
3100        } else {
3101            text.to_string()
3102        };
3103        if bom {
3104            body.insert(0, '\u{feff}');
3105        }
3106        fs::write(dir.join(name), body).unwrap();
3107    }
3108
3109    fn load_dir(dir: &Path) -> (Vec<Workflow>, Vec<Finding>) {
3110        let workflows = scan_dir(dir).expect("scan workflows");
3111        let found = findings(&workflows).expect("findings");
3112        (workflows, found)
3113    }
3114
3115    fn job_named<'a>(workflows: &'a [Workflow], file: &str, job: &str) -> &'a crate::workflow::Job {
3116        let workflow = workflows
3117            .iter()
3118            .find(|item| item.path.file_name().and_then(|name| name.to_str()) == Some(file))
3119            .unwrap_or_else(|| panic!("missing {file}"));
3120        workflow
3121            .jobs
3122            .iter()
3123            .find(|item| item.name == job)
3124            .unwrap_or_else(|| panic!("missing job {job} in {file}"))
3125    }
3126
3127    fn caller_key(file: &str) -> String {
3128        format!(".github/workflows/{file}")
3129    }
3130
3131    fn attributed<'a>(
3132        found: &'a [Finding],
3133        caller_file: &str,
3134        kind: FindingKind,
3135    ) -> Vec<&'a Finding> {
3136        let key = caller_key(caller_file);
3137        found
3138            .iter()
3139            .filter(|item| item.kind == kind && item.workflow_path.as_deref() == Some(key.as_str()))
3140            .collect()
3141    }
3142
3143    fn usage(path: &str, net: &str, price: &str) -> UsageRow {
3144        UsageRow {
3145            line: 2,
3146            date: "2026-09-01".into(),
3147            product: "actions".into(),
3148            sku: "actions_linux".into(),
3149            quantity: Micros::parse("10").unwrap(),
3150            unit_type: "minutes".into(),
3151            applied_cost_per_quantity: Micros::parse(price).unwrap(),
3152            gross: Micros::parse(net).unwrap(),
3153            discount: Micros::ZERO,
3154            net: Micros::parse(net).unwrap(),
3155            username: "ci".into(),
3156            organization: "example-org".into(),
3157            repository: "example-org/app".into(),
3158            workflow_path: path.into(),
3159        }
3160    }
3161
3162    fn report_for(found: &[Finding], rows: &[UsageRow]) -> (String, serde_json::Value) {
3163        let ranking = rank(rows, None).unwrap();
3164        let text = render(&ranking.workflows, &ranking.bill, Some(found), None);
3165        let json: serde_json::Value = serde_json::from_str(
3166            render_json(
3167                &ranking.workflows,
3168                &ranking.bill,
3169                Some(found),
3170                None,
3171                None,
3172                &[],
3173            )
3174            .trim_end(),
3175        )
3176        .expect("json report");
3177        (text, json)
3178    }
3179
3180    fn section_after<'a>(text: &'a str, label: &str) -> &'a str {
3181        let start = text
3182            .find(label)
3183            .unwrap_or_else(|| panic!("missing {label} in {text}"));
3184        &text[start..]
3185    }
3186
3187    fn assert_no_timeout_on_call(found: &[Finding], workflow_file: &str, job_line: usize) {
3188        assert!(
3189            found.iter().all(|item| {
3190                !(item.kind == FindingKind::MissingTimeout
3191                    && item.line == job_line
3192                    && item.path.ends_with(workflow_file))
3193            }),
3194            "uses job must not be flagged for the default timeout: {found:?}"
3195        );
3196    }
3197
3198    #[test]
3199    fn local_callee_macos_lint_is_f1_under_caller() {
3200        let callee_runs_on = 5;
3201        let cases = [
3202            ("lint-plain", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\n", false, false, 5),
3203            ("lint-yaml", "caller.yaml", "lint.yaml", "uses: ./.github/workflows/lint.yaml\n", false, false, 5),
3204            ("lint-with", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith:\n  extra: \"1\"\n", false, false, 5),
3205            ("lint-secrets-inherit", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nsecrets: inherit\n", false, false, 5),
3206            ("lint-secrets-map", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nsecrets:\n  token: value\n", false, false, 5),
3207            ("lint-with-and-secrets", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith:\n  extra: \"1\"\nsecrets: inherit\n", false, false, 5),
3208            ("lint-empty-with-secrets", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith: {}\nsecrets: {}\n", false, false, 5),
3209            ("lint-secrets-before-uses", "caller.yml", "lint.yml", "secrets: inherit\nwith:\n  extra: \"1\"\nuses: ./.github/workflows/lint.yml\n", false, false, 8),
3210            ("lint-with-before-uses", "caller.yml", "lint.yml", "with:\n  extra: \"1\"\nuses: ./.github/workflows/lint.yml\nsecrets:\n  token: value\n", false, false, 7),
3211            ("lint-quoted", "caller.yml", "lint.yml", "uses: \"./.github/workflows/lint.yml\"\n", false, false, 5),
3212            ("lint-single-quoted", "caller.yml", "lint.yml", "uses: './.github/workflows/lint.yml'\n", false, false, 5),
3213            ("lint-capitals", "caller.yml", "lint.yml", "uses: ./.GitHub/Workflows/lint.YML\n", false, false, 5),
3214            ("lint-backslash", "caller.yml", "lint.yml", "uses: ./.github\\workflows\\lint.yml\n", false, false, 5),
3215            ("lint-dot-segments", "caller.yml", "lint.yml", "uses: ./.github/workflows/../workflows/./lint.yml\n", false, false, 5),
3216            ("lint-double-slash", "caller.yml", "lint.yml", "uses: ./.github//workflows/lint.yml\n", false, false, 5),
3217            ("lint-crlf", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith:\n  extra: \"1\"\nsecrets: inherit\n", true, false, 5),
3218            ("lint-bom", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\n", false, true, 5),
3219            ("lint-block-scalar", "caller.yml", "lint.yml", "uses: |\n  ./.github/workflows/lint.yml\n", false, false, 5),
3220        ];
3221        for (name, caller_file, callee_file, job_body, crlf_file, bom, uses_line) in cases {
3222            let dir = workflows_dir(name);
3223            let caller = format!("name: Caller\non: push\njobs:\n  call-lint:\n{job_body}");
3224            let caller = indent_job_body(&caller);
3225            write_workflow(&dir, caller_file, &caller, crlf_file, false);
3226            write_workflow(&dir, callee_file, LINT_CALLEE, crlf_file, bom);
3227            let (workflows, found) = load_dir(&dir);
3228            let call = job_named(&workflows, caller_file, "call-lint");
3229            assert!(call.uses.is_some(), "{name}");
3230            assert_eq!(call.uses.as_ref().unwrap().line, uses_line, "{name}");
3231            assert_no_timeout_on_call(&found, caller_file, call.line);
3232            let f1 = attributed(&found, caller_file, FindingKind::LintOnRunner);
3233            assert_eq!(f1.len(), 1, "{name}: {found:?}");
3234            assert_eq!(f1[0].line, callee_runs_on, "{name}");
3235            assert!(
3236                f1[0].path.ends_with(callee_file),
3237                "{name}: {}",
3238                f1[0].path.display()
3239            );
3240            assert_eq!(f1[0].message, "lint on macOS", "{name}");
3241            let rows = [usage(&caller_key(caller_file), "0.06", "0.006")];
3242            let (text, json) = report_for(&found, &rows);
3243            let label = format!("example-org/app .github/workflows/{caller_file}");
3244            let section = section_after(&text, &label);
3245            let needle = format!(
3246                "F1 {}:{}: lint on macOS",
3247                crate::display::display_path(&f1[0].path),
3248                callee_runs_on
3249            );
3250            assert!(section.contains(&needle), "{name}: {text}");
3251            assert!(!text.contains("Workflows without usage"), "{name}: {text}");
3252            assert_eq!(
3253                json["workflows"][0]["path"],
3254                caller_key(caller_file),
3255                "{name}"
3256            );
3257            assert_eq!(json["workflows"][0]["net"], "0.060000", "{name}");
3258            assert_eq!(json["workflows"].as_array().unwrap().len(), 1, "{name}");
3259            assert!(
3260                json["unattached_findings"].as_array().unwrap().is_empty(),
3261                "{name}"
3262            );
3263            let listed = json["workflows"][0]["findings"].as_array().unwrap();
3264            assert!(
3265                listed.iter().any(|item| {
3266                    item["id"] == "F1"
3267                        && item["line"] == callee_runs_on
3268                        && item["message"] == "lint on macOS"
3269                        && item["file"].as_str().unwrap().ends_with(callee_file)
3270                }),
3271                "{name}: {listed:?}"
3272            );
3273        }
3274
3275        let flow_dir = workflows_dir("lint-flow");
3276        write_workflow(
3277            &flow_dir,
3278            "caller.yml",
3279            "on: push\njobs: {call-lint: {uses: ./.github/workflows/lint.yml, with: {extra: \"1\"}, secrets: inherit}}\n",
3280            false,
3281            false,
3282        );
3283        write_workflow(
3284            &flow_dir,
3285            "lint.yml",
3286            "on: workflow_call\njobs: {lint: {runs-on: macos-latest, timeout-minutes: 10, steps: [{run: cargo fmt --check}]}}\n",
3287            false,
3288            false,
3289        );
3290        let (workflows, found) = load_dir(&flow_dir);
3291        let call = job_named(&workflows, "caller.yml", "call-lint");
3292        let callee = job_named(&workflows, "lint.yml", "lint");
3293        let runs_on = callee.runs_on.as_ref().unwrap().line;
3294        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3295        assert_eq!(f1.len(), 1, "{found:?}");
3296        assert_eq!(f1[0].line, runs_on);
3297        assert_eq!(
3298            call.uses.as_ref().unwrap().spec,
3299            "./.github/workflows/lint.yml"
3300        );
3301        let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
3302        assert!(text.contains("lint on macOS"), "{text}");
3303        assert!(text.contains(&format!("lint.yml:{runs_on}:")), "{text}");
3304        assert!(!text.contains("Workflows without usage"), "{text}");
3305
3306        let nested = workflows_dir("lint-nested");
3307        write_workflow(
3308            &nested,
3309            "caller.yml",
3310            "name: Caller\non: push\njobs:\n  call-mid:\n    uses: ./.github/workflows/mid.yml\n    secrets: inherit\n",
3311            false,
3312            false,
3313        );
3314        write_workflow(
3315            &nested,
3316            "mid.yml",
3317            "name: Mid\non: workflow_call\njobs:\n  forward:\n    with:\n      extra: \"1\"\n    secrets:\n      token: value\n    uses: ./.github/workflows/lint.yml\n",
3318            false,
3319            false,
3320        );
3321        write_workflow(&nested, "lint.yml", LINT_CALLEE, false, false);
3322        let (workflows, found) = load_dir(&nested);
3323        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3324        assert_eq!(f1.len(), 1, "{found:?}");
3325        assert_eq!(f1[0].line, callee_runs_on);
3326        assert!(f1[0].path.ends_with("lint.yml"), "{}", f1[0].path.display());
3327        assert_no_timeout_on_call(
3328            &found,
3329            "caller.yml",
3330            job_named(&workflows, "caller.yml", "call-mid").line,
3331        );
3332        assert_no_timeout_on_call(
3333            &found,
3334            "mid.yml",
3335            job_named(&workflows, "mid.yml", "forward").line,
3336        );
3337        let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
3338        assert!(
3339            section_after(&text, "example-org/app .github/workflows/caller.yml")
3340                .contains("lint.yml:5: lint on macOS"),
3341            "{text}"
3342        );
3343        assert!(!text.contains("Workflows without usage"), "{text}");
3344
3345        let anchor = workflows_dir("lint-anchor");
3346        write_workflow(
3347            &anchor,
3348            "caller.yml",
3349            "call: &call\n  uses: ./.github/workflows/missing.yml\njobs:\n  call-lint:\n    <<: *call\n    uses: ./.github/workflows/lint.yml\n    secrets: inherit\n",
3350            false,
3351            false,
3352        );
3353        write_workflow(&anchor, "lint.yml", LINT_CALLEE, false, false);
3354        let (workflows, found) = load_dir(&anchor);
3355        let call = job_named(&workflows, "caller.yml", "call-lint");
3356        assert_eq!(
3357            call.uses.as_ref().unwrap().spec,
3358            "./.github/workflows/lint.yml"
3359        );
3360        assert!(
3361            attributed(&found, "caller.yml", FindingKind::CalleeNotFound).is_empty(),
3362            "{found:?}"
3363        );
3364        assert!(
3365            attributed(&found, "caller.yml", FindingKind::DuplicateKey).is_empty(),
3366            "{found:?}"
3367        );
3368        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3369        assert_eq!(f1.len(), 1, "{found:?}");
3370        assert_eq!(f1[0].message, "lint on macOS");
3371
3372        let windows = workflows_dir("lint-windows");
3373        write_workflow(
3374            &windows,
3375            "caller.yml",
3376            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: ./.github/workflows/lint.yml\n",
3377            false,
3378            false,
3379        );
3380        write_workflow(
3381            &windows,
3382            "lint.yml",
3383            "name: Lint\non: workflow_call\njobs:\n  fmt:\n    runs-on: windows-latest\n    timeout-minutes: 10\n    steps:\n      - run: cargo fmt --check\n",
3384            false,
3385            false,
3386        );
3387        let (_, found) = load_dir(&windows);
3388        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3389        assert_eq!(f1.len(), 1, "{found:?}");
3390        assert_eq!(f1[0].line, 5);
3391        assert_eq!(f1[0].message, "lint on Windows");
3392
3393        let hosted = workflows_dir("lint-self-hosted");
3394        write_workflow(
3395            &hosted,
3396            "caller.yml",
3397            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: ./.github/workflows/lint.yml\n",
3398            false,
3399            false,
3400        );
3401        write_workflow(
3402            &hosted,
3403            "lint.yml",
3404            "name: Lint\non: workflow_call\njobs:\n  lint:\n    runs-on: [self-hosted, macos]\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n",
3405            false,
3406            false,
3407        );
3408        let (_, found) = load_dir(&hosted);
3409        assert!(
3410            attributed(&found, "caller.yml", FindingKind::LintOnRunner).is_empty(),
3411            "{found:?}"
3412        );
3413        assert!(
3414            attributed(&found, "caller.yml", FindingKind::CalleeNotFound).is_empty(),
3415            "{found:?}"
3416        );
3417
3418        let step = workflows_dir("lint-step-uses");
3419        write_workflow(
3420            &step,
3421            "caller.yml",
3422            "name: Caller\non: push\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: ./.github/workflows/lint.yml\n",
3423            false,
3424            false,
3425        );
3426        write_workflow(&step, "lint.yml", LINT_CALLEE, false, false);
3427        let (workflows, found) = load_dir(&step);
3428        assert!(job_named(&workflows, "caller.yml", "lint").uses.is_none());
3429        assert!(
3430            attributed(&found, "caller.yml", FindingKind::LintOnRunner).is_empty(),
3431            "{found:?}"
3432        );
3433        assert!(
3434            found
3435                .iter()
3436                .all(|item| item.kind != FindingKind::CalleeNotFound),
3437            "{found:?}"
3438        );
3439
3440        let rows = vec![
3441            usage(".github/workflows/caller.yml", "0.06", "0.006"),
3442            usage(".github/workflows/lint.yml", "1.00", "0.100"),
3443        ];
3444        let dir = workflows_dir("lint-both-rows");
3445        write_workflow(
3446            &dir,
3447            "caller.yml",
3448            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: ./.github/workflows/lint.yml\n    with:\n      extra: \"1\"\n    secrets: inherit\n",
3449            false,
3450            false,
3451        );
3452        write_workflow(&dir, "lint.yml", LINT_CALLEE, false, false);
3453        let (_, found) = load_dir(&dir);
3454        let ranking = rank(&rows, None).unwrap();
3455        assert_eq!(ranking.workflows.len(), 2);
3456        let caller = ranking
3457            .workflows
3458            .iter()
3459            .find(|item| item.workflow_path == ".github/workflows/caller.yml")
3460            .unwrap();
3461        let lint = ranking
3462            .workflows
3463            .iter()
3464            .find(|item| item.workflow_path == ".github/workflows/lint.yml")
3465            .unwrap();
3466        assert_eq!(caller.net, Micros::parse("0.06").unwrap());
3467        assert_eq!(lint.net, Micros::parse("1.00").unwrap());
3468        let (text, json) = report_for(&found, &rows);
3469        let caller_at = text
3470            .find("example-org/app .github/workflows/caller.yml")
3471            .unwrap();
3472        let lint_at = text
3473            .find("example-org/app .github/workflows/lint.yml")
3474            .unwrap();
3475        assert!(lint_at < caller_at, "{text}");
3476        let lint_section = &text[lint_at..caller_at];
3477        let caller_section = &text[caller_at..];
3478        assert!(lint_section.contains("1.00"), "{lint_section}");
3479        assert!(caller_section.contains("0.06"), "{caller_section}");
3480        assert!(
3481            caller_section.contains("lint.yml:5: lint on macOS"),
3482            "{text}"
3483        );
3484        assert!(lint_section.contains("lint.yml:5: lint on macOS"), "{text}");
3485        assert!(!caller_section.contains("1.00"), "{caller_section}");
3486        assert_eq!(json["workflows"].as_array().unwrap().len(), 2);
3487        let caller_json = json["workflows"]
3488            .as_array()
3489            .unwrap()
3490            .iter()
3491            .find(|item| item["path"] == ".github/workflows/caller.yml")
3492            .unwrap();
3493        assert_eq!(caller_json["net"], "0.060000");
3494        assert!(caller_json["findings"]
3495            .as_array()
3496            .unwrap()
3497            .iter()
3498            .any(|item| item["id"] == "F1" && item["line"] == 5));
3499    }
3500
3501    /// Indent every line under the job key by four spaces.
3502    ///
3503    /// Nested lines in `body` already carry their own two-space indent, so
3504    /// they land six spaces in, under a job key that is two spaces in.
3505    fn indent_job_body(text: &str) -> String {
3506        let mut out = String::new();
3507        let mut job_started = false;
3508        for line in text.lines() {
3509            if !job_started {
3510                out.push_str(line);
3511                out.push('\n');
3512                if line.starts_with("  ")
3513                    && !line.starts_with("    ")
3514                    && line.trim_end().ends_with(':')
3515                {
3516                    job_started = true;
3517                }
3518                continue;
3519            }
3520            if line.is_empty() {
3521                out.push('\n');
3522                continue;
3523            }
3524            out.push_str("    ");
3525            out.push_str(line);
3526            out.push('\n');
3527        }
3528        out
3529    }
3530
3531    #[test]
3532    fn missing_local_callee_is_visible_at_the_uses_line() {
3533        let cases = [
3534            (
3535                "miss-yml",
3536                "caller.yml",
3537                "uses: ./.github/workflows/missing.yml\n",
3538                false,
3539                5,
3540                "call-lint",
3541                "./.github/workflows/missing.yml",
3542            ),
3543            (
3544                "miss-yaml",
3545                "caller.yaml",
3546                "uses: ./.github/workflows/missing.yaml\n",
3547                false,
3548                5,
3549                "call-lint",
3550                "./.github/workflows/missing.yaml",
3551            ),
3552            (
3553                "miss-with",
3554                "caller.yml",
3555                "uses: ./.github/workflows/missing.yml\nwith:\n  extra: \"1\"\n",
3556                false,
3557                5,
3558                "call-lint",
3559                "./.github/workflows/missing.yml",
3560            ),
3561            (
3562                "miss-secrets",
3563                "caller.yml",
3564                "secrets: inherit\nuses: ./.github/workflows/missing.yml\n",
3565                false,
3566                6,
3567                "call-lint",
3568                "./.github/workflows/missing.yml",
3569            ),
3570            (
3571                "miss-both",
3572                "caller.yml",
3573                "with: {}\nsecrets: {}\nuses: ./.github/workflows/gone.yaml\n",
3574                false,
3575                7,
3576                "call-lint",
3577                "./.github/workflows/gone.yaml",
3578            ),
3579            (
3580                "miss-crlf",
3581                "caller.yml",
3582                "uses: ./.github/workflows/missing.yml\nsecrets:\n  token: value\n",
3583                true,
3584                5,
3585                "call-lint",
3586                "./.github/workflows/missing.yml",
3587            ),
3588            (
3589                "miss-capitals",
3590                "caller.yml",
3591                "uses: ./.GitHub/Workflows/Missing.YML\n",
3592                false,
3593                5,
3594                "call-lint",
3595                "./.GitHub/Workflows/Missing.YML",
3596            ),
3597            (
3598                "miss-quoted",
3599                "caller.yml",
3600                "uses: \"./.github/workflows/missing.yml\"\n",
3601                false,
3602                5,
3603                "call-lint",
3604                "./.github/workflows/missing.yml",
3605            ),
3606            (
3607                "miss-empty-file-name-not",
3608                "caller.yml",
3609                "uses: ./.github/workflows/no-such.yml\nwith:\nsecrets: inherit\n",
3610                false,
3611                5,
3612                "call-lint",
3613                "./.github/workflows/no-such.yml",
3614            ),
3615        ];
3616        for (name, caller_file, job_body, crlf_file, uses_line, job, spec) in cases {
3617            let dir = workflows_dir(name);
3618            let caller = format!("name: Caller\non: push\njobs:\n  {job}:\n{job_body}");
3619            let caller = indent_job_body(&caller);
3620            write_workflow(&dir, caller_file, &caller, crlf_file, false);
3621            let (workflows, found) = load_dir(&dir);
3622            let call = job_named(&workflows, caller_file, job);
3623            assert_eq!(
3624                call.uses.as_ref().unwrap().line,
3625                uses_line,
3626                "{name}\n{caller}"
3627            );
3628            assert_eq!(call.uses.as_ref().unwrap().spec, spec, "{name}");
3629            let missing = attributed(&found, caller_file, FindingKind::CalleeNotFound);
3630            assert_eq!(missing.len(), 1, "{name}: {found:?}");
3631            assert_eq!(missing[0].line, uses_line, "{name}");
3632            assert!(missing[0].path.ends_with(caller_file), "{name}");
3633            assert!(
3634                missing[0].message.starts_with("callee not found"),
3635                "{name}: {}",
3636                missing[0].message
3637            );
3638            assert!(
3639                missing[0].message.contains(job),
3640                "{name}: {}",
3641                missing[0].message
3642            );
3643            assert!(
3644                missing[0].message.contains(spec),
3645                "{name}: {}",
3646                missing[0].message
3647            );
3648            assert_eq!(missing[0].kind.code(), "F6");
3649            let (text, json) =
3650                report_for(&found, &[usage(&caller_key(caller_file), "0.06", "0.006")]);
3651            let section = section_after(
3652                &text,
3653                &format!("example-org/app .github/workflows/{caller_file}"),
3654            );
3655            let needle = format!(
3656                "F6 {}:{}: {}",
3657                crate::display::display_path(&missing[0].path),
3658                uses_line,
3659                missing[0].message
3660            );
3661            assert!(section.contains(&needle), "{name}: {text}");
3662            assert!(section.contains(job), "{name}: {text}");
3663            assert!(!text.contains("Workflows without usage"), "{name}: {text}");
3664            let listed = json["workflows"][0]["findings"].as_array().unwrap();
3665            assert_eq!(listed[0]["id"], "F6", "{name}");
3666            assert_eq!(listed[0]["line"], uses_line, "{name}");
3667            assert!(
3668                listed[0]["message"]
3669                    .as_str()
3670                    .unwrap()
3671                    .contains("callee not found"),
3672                "{name}"
3673            );
3674            assert!(
3675                json["unattached_findings"].as_array().unwrap().is_empty(),
3676                "{name}"
3677            );
3678        }
3679
3680        let nested = workflows_dir("miss-nested");
3681        write_workflow(
3682            &nested,
3683            "caller.yml",
3684            "name: Caller\non: push\njobs:\n  call-mid:\n    uses: ./.github/workflows/mid.yml\n    with:\n      extra: \"1\"\n",
3685            false,
3686            false,
3687        );
3688        write_workflow(
3689            &nested,
3690            "mid.yml",
3691            "name: Mid\non: workflow_call\njobs:\n  forward:\n    secrets: inherit\n    uses: ./.github/workflows/missing.yml\n",
3692            true,
3693            false,
3694        );
3695        let (workflows, found) = load_dir(&nested);
3696        let forward = job_named(&workflows, "mid.yml", "forward");
3697        let missing = attributed(&found, "caller.yml", FindingKind::CalleeNotFound);
3698        assert_eq!(missing.len(), 1, "{found:?}");
3699        assert_eq!(missing[0].line, forward.uses.as_ref().unwrap().line);
3700        assert!(
3701            missing[0].path.ends_with("mid.yml"),
3702            "{}",
3703            missing[0].path.display()
3704        );
3705        assert!(
3706            missing[0].message.contains("callee not found"),
3707            "{}",
3708            missing[0].message
3709        );
3710        assert!(
3711            missing[0].message.contains("forward"),
3712            "{}",
3713            missing[0].message
3714        );
3715        let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
3716        assert!(
3717            section_after(&text, "example-org/app .github/workflows/caller.yml")
3718                .contains("callee not found"),
3719            "{text}"
3720        );
3721        assert!(
3722            text.contains(&format!("mid.yml:{}:", forward.uses.as_ref().unwrap().line)),
3723            "{text}"
3724        );
3725        assert!(!text.contains("Workflows without usage"), "{text}");
3726
3727        let empty = workflows_dir("miss-empty-callee");
3728        write_workflow(
3729            &empty,
3730            "caller.yml",
3731            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: ./.github/workflows/empty.yml\n    secrets: inherit\n",
3732            false,
3733            false,
3734        );
3735        write_workflow(
3736            &empty,
3737            "empty.yml",
3738            "name: Empty\non: workflow_call\n",
3739            false,
3740            false,
3741        );
3742        let (workflows, found) = load_dir(&empty);
3743        assert!(job_named(&workflows, "caller.yml", "call-lint")
3744            .uses
3745            .is_some());
3746        assert!(
3747            found
3748                .iter()
3749                .all(|item| item.kind != FindingKind::CalleeNotFound),
3750            "{found:?}"
3751        );
3752
3753        let ignored = workflows_dir("miss-not-local");
3754        write_workflow(
3755            &ignored,
3756            "caller.yml",
3757            "name: Caller\non: push\njobs:\n  blank:\n    uses: \"\"\n    timeout-minutes: 1\n  remote:\n    uses: owner/repo/.github/workflows/x.yml@main\n    timeout-minutes: 1\n  action:\n    uses: actions/checkout@v4\n    timeout-minutes: 1\n  other:\n    uses: ./foo.yml\n    timeout-minutes: 1\n  list:\n    uses:\n      - ./.github/workflows/lint.yml\n    timeout-minutes: 1\n",
3758            false,
3759            false,
3760        );
3761        let (_, found) = load_dir(&ignored);
3762        assert!(
3763            found
3764                .iter()
3765                .all(|item| item.kind != FindingKind::CalleeNotFound),
3766            "{found:?}"
3767        );
3768        assert!(
3769            found
3770                .iter()
3771                .all(|item| item.kind != FindingKind::ReusableCycle),
3772            "{found:?}"
3773        );
3774    }
3775
3776    #[test]
3777    fn reusable_cycle_stops_with_a_finding() {
3778        let pairs = [
3779            ("cycle-yml", "a.yml", "b.yml", false),
3780            ("cycle-yaml", "a.yaml", "b.yaml", false),
3781            ("cycle-crlf", "a.yml", "b.yml", true),
3782        ];
3783        for (name, a_file, b_file, crlf_file) in pairs {
3784            let dir = workflows_dir(name);
3785            let a = format!(
3786                "name: A\non: push\njobs:\n  call-b:\n    uses: ./.github/workflows/{b_file}\n    with:\n      extra: \"1\"\n    secrets: inherit\n"
3787            );
3788            let b = format!(
3789                "name: B\non: workflow_call\njobs:\n  lint:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - run: cargo fmt --check\n  call-a:\n    secrets:\n      token: value\n    uses: ./.github/workflows/{a_file}\n"
3790            );
3791            write_workflow(&dir, a_file, &a, crlf_file, false);
3792            write_workflow(&dir, b_file, &b, crlf_file, false);
3793            let (workflows, found) = load_dir(&dir);
3794            assert!(found.len() < 12, "{name}: {found:?}");
3795            let call_b = job_named(&workflows, a_file, "call-b");
3796            let call_a = job_named(&workflows, b_file, "call-a");
3797            let cycles = attributed(&found, a_file, FindingKind::ReusableCycle);
3798            assert_eq!(cycles.len(), 1, "{name}: {found:?}");
3799            assert_eq!(cycles[0].line, call_a.uses.as_ref().unwrap().line, "{name}");
3800            assert!(cycles[0].path.ends_with(b_file), "{name}");
3801            assert_eq!(cycles[0].kind.code(), "F7");
3802            assert!(
3803                cycles[0].message.contains("reusable workflow cycle"),
3804                "{name}"
3805            );
3806            assert!(
3807                cycles[0].message.contains(a_file),
3808                "{name}: {}",
3809                cycles[0].message
3810            );
3811            assert!(
3812                cycles[0].message.contains(b_file),
3813                "{name}: {}",
3814                cycles[0].message
3815            );
3816            assert!(cycles[0].message.contains(" -> "), "{name}");
3817            let f1 = attributed(&found, a_file, FindingKind::LintOnRunner);
3818            assert_eq!(f1.len(), 1, "{name}: {found:?}");
3819            assert!(f1[0].path.ends_with(b_file), "{name}");
3820            assert_eq!(f1[0].message, "lint on macOS");
3821            assert_no_timeout_on_call(&found, a_file, call_b.line);
3822            assert_no_timeout_on_call(&found, b_file, call_a.line);
3823            let (text, json) = report_for(&found, &[usage(&caller_key(a_file), "0.06", "0.006")]);
3824            let section = section_after(
3825                &text,
3826                &format!("example-org/app .github/workflows/{a_file}"),
3827            );
3828            assert!(
3829                section.contains("reusable workflow cycle"),
3830                "{name}: {text}"
3831            );
3832            assert!(section.contains("lint on macOS"), "{name}: {text}");
3833            assert!(section.contains(&format!("{b_file}:")), "{name}: {text}");
3834            let listed = json["workflows"][0]["findings"].as_array().unwrap();
3835            assert!(
3836                listed.iter().any(|item| item["id"] == "F7"),
3837                "{name}: {listed:?}"
3838            );
3839            assert!(
3840                listed
3841                    .iter()
3842                    .any(|item| item["id"] == "F1"
3843                        && item["file"].as_str().unwrap().ends_with(b_file)),
3844                "{name}"
3845            );
3846        }
3847
3848        let self_dir = workflows_dir("cycle-self");
3849        write_workflow(
3850            &self_dir,
3851            "loop.yml",
3852            "name: Loop\non: push\njobs:\n  again:\n    uses: ./.github/workflows/loop.yml\n    secrets: inherit\n",
3853            false,
3854            false,
3855        );
3856        let (workflows, found) = load_dir(&self_dir);
3857        assert!(found.len() < 6, "{found:?}");
3858        let again = job_named(&workflows, "loop.yml", "again");
3859        let cycles = attributed(&found, "loop.yml", FindingKind::ReusableCycle);
3860        assert_eq!(cycles.len(), 1, "{found:?}");
3861        assert_eq!(cycles[0].line, again.uses.as_ref().unwrap().line);
3862        assert!(
3863            cycles[0].message.contains("loop.yml -> loop.yml"),
3864            "{}",
3865            cycles[0].message
3866        );
3867        let (text, _) = report_for(&found, &[usage(&caller_key("loop.yml"), "0.06", "0.006")]);
3868        assert!(
3869            section_after(&text, "example-org/app .github/workflows/loop.yml")
3870                .contains("reusable workflow cycle"),
3871            "{text}"
3872        );
3873
3874        let three = workflows_dir("cycle-three");
3875        write_workflow(
3876            &three,
3877            "a.yml",
3878            "name: A\non: push\njobs:\n  call-b:\n    uses: ./.github/workflows/b.yml\n",
3879            false,
3880            false,
3881        );
3882        write_workflow(
3883            &three,
3884            "b.yml",
3885            "name: B\non: workflow_call\njobs:\n  call-c:\n    uses: ./.github/workflows/c.yml\n    with:\n      extra: \"1\"\n",
3886            false,
3887            false,
3888        );
3889        write_workflow(
3890            &three,
3891            "c.yml",
3892            "name: C\non: workflow_call\njobs:\n  lint:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - run: cargo fmt --check\n  call-b:\n    uses: ./.github/workflows/b.yml\n    secrets: inherit\n",
3893            false,
3894            false,
3895        );
3896        let (_, found) = load_dir(&three);
3897        assert!(found.len() < 16, "{found:?}");
3898        let cycles = attributed(&found, "a.yml", FindingKind::ReusableCycle);
3899        assert_eq!(cycles.len(), 1, "{found:?}");
3900        assert!(
3901            cycles[0].path.ends_with("c.yml"),
3902            "{}",
3903            cycles[0].path.display()
3904        );
3905        assert!(cycles[0].message.contains("b.yml"), "{}", cycles[0].message);
3906        assert!(cycles[0].message.contains("c.yml"), "{}", cycles[0].message);
3907        let f1 = attributed(&found, "a.yml", FindingKind::LintOnRunner);
3908        assert_eq!(f1.len(), 1, "{found:?}");
3909        assert!(f1[0].path.ends_with("c.yml"));
3910        let (text, _) = report_for(&found, &[usage(&caller_key("a.yml"), "0.06", "0.006")]);
3911        assert!(
3912            section_after(&text, "example-org/app .github/workflows/a.yml")
3913                .contains("reusable workflow cycle"),
3914            "{text}"
3915        );
3916        assert!(text.contains("lint on macOS"), "{text}");
3917
3918        let flow = workflows_dir("cycle-flow");
3919        write_workflow(
3920            &flow,
3921            "a.yml",
3922            "on: push\njobs: {call-b: {uses: ./.github/workflows/b.yml, secrets: inherit}}\n",
3923            false,
3924            false,
3925        );
3926        write_workflow(
3927            &flow,
3928            "b.yml",
3929            "on: workflow_call\njobs: {call-a: {with: {extra: \"1\"}, uses: ./.github/workflows/a.yml}}\n",
3930            false,
3931            false,
3932        );
3933        let (_, found) = load_dir(&flow);
3934        assert!(found.len() < 8, "{found:?}");
3935        let cycles = attributed(&found, "a.yml", FindingKind::ReusableCycle);
3936        assert_eq!(cycles.len(), 1, "{found:?}");
3937        assert!(cycles[0].message.contains("a.yml"), "{}", cycles[0].message);
3938        assert!(cycles[0].message.contains("b.yml"), "{}", cycles[0].message);
3939    }
3940
3941    #[test]
3942    fn callee_matrix_is_reported_under_caller() {
3943        let canonical_matrix_line = 8;
3944        let cases = [
3945            (
3946                "matrix-plain",
3947                "caller.yml",
3948                "wide.yml",
3949                "uses: ./.github/workflows/wide.yml\n",
3950                false,
3951            ),
3952            (
3953                "matrix-yaml",
3954                "caller.yaml",
3955                "wide.yaml",
3956                "uses: ./.github/workflows/wide.yaml\n",
3957                false,
3958            ),
3959            (
3960                "matrix-with-secrets",
3961                "caller.yml",
3962                "wide.yml",
3963                "secrets: inherit\nwith:\n  extra: \"1\"\nuses: ./.github/workflows/wide.yml\n",
3964                false,
3965            ),
3966            (
3967                "matrix-crlf",
3968                "caller.yml",
3969                "wide.yml",
3970                "uses: ./.github/workflows/wide.yml\nwith: {}\nsecrets: {}\n",
3971                true,
3972            ),
3973            (
3974                "matrix-capitals",
3975                "caller.yml",
3976                "wide.yml",
3977                "uses: ./.GitHub/Workflows/wide.YML\n",
3978                false,
3979            ),
3980            (
3981                "matrix-quoted",
3982                "caller.yml",
3983                "wide.yml",
3984                "uses: \"./.github/workflows/wide.yml\"\n",
3985                false,
3986            ),
3987        ];
3988        for (name, caller_file, callee_file, job_body, crlf_file) in cases {
3989            let dir = workflows_dir(name);
3990            let caller = format!("name: Caller\non: push\njobs:\n  call-wide:\n{job_body}");
3991            write_workflow(
3992                &dir,
3993                caller_file,
3994                &indent_job_body(&caller),
3995                crlf_file,
3996                false,
3997            );
3998            write_workflow(&dir, callee_file, MATRIX_CALLEE, crlf_file, false);
3999            let (workflows, found) = load_dir(&dir);
4000            let matrix_line = job_named(&workflows, callee_file, "build")
4001                .matrix
4002                .as_ref()
4003                .unwrap()
4004                .line;
4005            if !crlf_file && job_body.starts_with("uses:") {
4006                assert_eq!(matrix_line, canonical_matrix_line, "{name}");
4007            }
4008            let f4 = attributed(&found, caller_file, FindingKind::MatrixJobs);
4009            assert_eq!(f4.len(), 1, "{name}: {found:?}");
4010            assert_eq!(f4[0].line, matrix_line, "{name}");
4011            assert!(f4[0].path.ends_with(callee_file), "{name}");
4012            assert_eq!(f4[0].message, f4_message(6), "{name}");
4013            assert_eq!(f4[0].kind.code(), "F4");
4014            let (text, json) =
4015                report_for(&found, &[usage(&caller_key(caller_file), "0.06", "0.006")]);
4016            let section = section_after(
4017                &text,
4018                &format!("example-org/app .github/workflows/{caller_file}"),
4019            );
4020            let needle = format!(
4021                "F4 {}:{}: {}",
4022                crate::display::display_path(&f4[0].path),
4023                matrix_line,
4024                f4_message(6)
4025            );
4026            assert!(section.contains(&needle), "{name}: {text}");
4027            assert!(!text.contains("Workflows without usage"), "{name}: {text}");
4028            assert_eq!(json["workflows"][0]["net"], "0.060000", "{name}");
4029            let listed = json["workflows"][0]["findings"].as_array().unwrap();
4030            assert!(
4031                listed.iter().any(|item| {
4032                    item["id"] == "F4"
4033                        && item["line"] == matrix_line
4034                        && item["message"] == f4_message(6)
4035                        && item["file"].as_str().unwrap().ends_with(callee_file)
4036                }),
4037                "{name}: {listed:?}"
4038            );
4039        }
4040
4041        let narrow = workflows_dir("matrix-narrow");
4042        write_workflow(
4043            &narrow,
4044            "caller.yml",
4045            "name: Caller\non: push\njobs:\n  call-wide:\n    uses: ./.github/workflows/wide.yml\n    secrets: inherit\n",
4046            false,
4047            false,
4048        );
4049        write_workflow(
4050            &narrow,
4051            "wide.yml",
4052            "name: Wide\non: workflow_call\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16]\n",
4053            false,
4054            false,
4055        );
4056        let (workflows, found) = load_dir(&narrow);
4057        assert_eq!(
4058            job_named(&workflows, "wide.yml", "build")
4059                .matrix
4060                .as_ref()
4061                .unwrap()
4062                .jobs,
4063            4
4064        );
4065        assert!(
4066            attributed(&found, "caller.yml", FindingKind::MatrixJobs).is_empty(),
4067            "{found:?}"
4068        );
4069        assert!(
4070            attributed(&found, "caller.yml", FindingKind::CalleeNotFound).is_empty(),
4071            "{found:?}"
4072        );
4073
4074        let flow = workflows_dir("matrix-flow");
4075        write_workflow(
4076            &flow,
4077            "caller.yml",
4078            "on: push\njobs: {call-wide: {uses: ./.github/workflows/wide.yml, with: {extra: \"1\"}, secrets: inherit}}\n",
4079            false,
4080            false,
4081        );
4082        write_workflow(
4083            &flow,
4084            "wide.yml",
4085            "on: workflow_call\njobs: {build: {runs-on: ubuntu-latest, timeout-minutes: 5, strategy: {matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}}}}\n",
4086            false,
4087            false,
4088        );
4089        let (workflows, found) = load_dir(&flow);
4090        let matrix_line = job_named(&workflows, "wide.yml", "build")
4091            .matrix
4092            .as_ref()
4093            .unwrap()
4094            .line;
4095        let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4096        assert_eq!(f4.len(), 1, "{found:?}");
4097        assert_eq!(f4[0].line, matrix_line);
4098        assert_eq!(f4[0].message, f4_message(6));
4099        let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4100        assert!(text.contains(&format!("wide.yml:{matrix_line}:")), "{text}");
4101        assert!(text.contains(&f4_message(6)), "{text}");
4102        assert!(!text.contains("Workflows without usage"), "{text}");
4103
4104        let at_least = workflows_dir("matrix-at-least");
4105        write_workflow(
4106            &at_least,
4107            "caller.yml",
4108            "name: Caller\non: push\njobs:\n  call-wide:\n    uses: ./.github/workflows/wide.yml\n",
4109            true,
4110            false,
4111        );
4112        write_workflow(
4113            &at_least,
4114            "wide.yml",
4115            "name: Wide\non: workflow_call\njobs:\n  build:\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: ${{ fromJSON(needs.setup.outputs.node) }}\n",
4116            true,
4117            false,
4118        );
4119        let (workflows, found) = load_dir(&at_least);
4120        let matrix_line = job_named(&workflows, "wide.yml", "build")
4121            .matrix
4122            .as_ref()
4123            .unwrap()
4124            .line;
4125        let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4126        assert_eq!(f4.len(), 1, "{found:?}");
4127        assert_eq!(f4[0].line, matrix_line);
4128        assert_eq!(f4[0].message, f4_at_least(2));
4129        let (text, json) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4130        assert!(
4131            section_after(&text, "example-org/app .github/workflows/caller.yml")
4132                .contains(&f4_at_least(2)),
4133            "{text}"
4134        );
4135        assert_eq!(
4136            json["workflows"][0]["findings"][0]["message"],
4137            f4_at_least(2)
4138        );
4139
4140        let between = workflows_dir("matrix-between");
4141        write_workflow(
4142            &between,
4143            "caller.yml",
4144            "name: Caller\non: push\njobs:\n  call-wide:\n    uses: ./.github/workflows/wide.yml\n    with:\n      extra: \"1\"\n    secrets: inherit\n",
4145            false,
4146            false,
4147        );
4148        write_workflow(
4149            &between,
4150            "wide.yml",
4151            "name: Wide\non: workflow_call\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n        exclude: ${{ inputs.rules }}\n",
4152            false,
4153            false,
4154        );
4155        let (workflows, found) = load_dir(&between);
4156        let matrix_line = job_named(&workflows, "wide.yml", "build")
4157            .matrix
4158            .as_ref()
4159            .unwrap()
4160            .line;
4161        let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4162        assert_eq!(f4.len(), 1, "{found:?}");
4163        assert_eq!(f4[0].line, matrix_line);
4164        assert_eq!(f4[0].message, f4_between(0, 6));
4165        let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4166        assert!(text.contains(&f4_between(0, 6)), "{text}");
4167        assert!(!text.contains("Workflows without usage"), "{text}");
4168
4169        let nested = workflows_dir("matrix-nested");
4170        write_workflow(
4171            &nested,
4172            "caller.yml",
4173            "name: Caller\non: push\njobs:\n  call-mid:\n    uses: ./.github/workflows/mid.yml\n",
4174            false,
4175            false,
4176        );
4177        write_workflow(
4178            &nested,
4179            "mid.yml",
4180            "name: Mid\non: workflow_call\njobs:\n  forward:\n    uses: ./.github/workflows/wide.yml\n    secrets: inherit\n",
4181            false,
4182            false,
4183        );
4184        write_workflow(&nested, "wide.yml", MATRIX_CALLEE, false, false);
4185        let (workflows, found) = load_dir(&nested);
4186        let matrix_line = job_named(&workflows, "wide.yml", "build")
4187            .matrix
4188            .as_ref()
4189            .unwrap()
4190            .line;
4191        let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4192        assert_eq!(f4.len(), 1, "{found:?}");
4193        assert_eq!(f4[0].line, matrix_line);
4194        assert!(f4[0].path.ends_with("wide.yml"));
4195        assert_eq!(f4[0].message, f4_message(6));
4196        let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4197        assert!(
4198            section_after(&text, "example-org/app .github/workflows/caller.yml")
4199                .contains("wide.yml:8:"),
4200            "{text}"
4201        );
4202        assert!(!text.contains("Workflows without usage"), "{text}");
4203
4204        let caller_matrix = workflows_dir("matrix-on-caller");
4205        write_workflow(
4206            &caller_matrix,
4207            "caller.yml",
4208            "name: Caller\non: push\njobs:\n  call-wide:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        node: [14, 16, 18]\n    uses: ./.github/workflows/wide.yml\n    secrets: inherit\n",
4209            false,
4210            false,
4211        );
4212        write_workflow(
4213            &caller_matrix,
4214            "wide.yml",
4215            "name: Wide\non: workflow_call\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - run: echo build\n",
4216            false,
4217            false,
4218        );
4219        let (workflows, found) = load_dir(&caller_matrix);
4220        let call = job_named(&workflows, "caller.yml", "call-wide");
4221        let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4222        assert_eq!(f4.len(), 1, "{found:?}");
4223        assert_eq!(f4[0].line, call.matrix.as_ref().unwrap().line);
4224        assert!(f4[0].path.ends_with("caller.yml"));
4225        assert_eq!(f4[0].message, f4_message(6));
4226    }
4227
4228    #[test]
4229    fn caller_costs_stay_on_the_caller_csv_path() {
4230        let dir = workflows_dir("costs-stay");
4231        write_workflow(
4232            &dir,
4233            "caller.yml",
4234            "name: Caller\non: [push, pull_request]\njobs:\n  call-lint:\n    uses: ./.github/workflows/lint.yml\n    with:\n      extra: \"1\"\n    secrets: inherit\n",
4235            true,
4236            false,
4237        );
4238        write_workflow(&dir, "lint.yml", LINT_CALLEE, true, false);
4239        let csv = dir.join("usage.csv");
4240        fs::write(
4241            &csv,
4242            "\
4243workflow_path,net_amount,gross_amount,discount_amount,quantity,unit_type,sku,product,date,repository,organization,username,applied_cost_per_quantity\r\n\
4244.github/workflows/caller.yml,0.06,0.06,0,10,Minutes,actions_linux,Actions,2026-09-01,example-org/app,example-org,ci,0.006\r\n\
4245.github/workflows/lint.yml,1.00,1.00,0,10,minutes,actions_macos,actions,2026-09-30,example-org/app,example-org,ci,0.100\r\n",
4246        )
4247        .unwrap();
4248        let rows = read_usage(&csv).unwrap();
4249        let (_, found) = load_dir(&dir);
4250        let ranking = rank(&rows, None).unwrap();
4251        assert_eq!(ranking.workflows.len(), 2);
4252        assert_eq!(ranking.bill.period_from.as_deref(), Some("2026-09-01"));
4253        assert_eq!(ranking.bill.period_to.as_deref(), Some("2026-09-30"));
4254        let caller = ranking
4255            .workflows
4256            .iter()
4257            .find(|item| item.workflow_path == ".github/workflows/caller.yml")
4258            .unwrap();
4259        let lint = ranking
4260            .workflows
4261            .iter()
4262            .find(|item| item.workflow_path == ".github/workflows/lint.yml")
4263            .unwrap();
4264        assert_eq!(caller.net, Micros::parse("0.06").unwrap());
4265        assert_eq!(caller.gross, Micros::parse("0.06").unwrap());
4266        assert_eq!(lint.net, Micros::parse("1.00").unwrap());
4267        assert_ne!(caller.net, lint.net);
4268        let (text, json) = report_for(&found, &rows);
4269        let caller_at = text
4270            .find("example-org/app .github/workflows/caller.yml")
4271            .unwrap();
4272        let lint_at = text
4273            .find("example-org/app .github/workflows/lint.yml")
4274            .unwrap();
4275        assert!(lint_at < caller_at, "{text}");
4276        assert!(text[caller_at..].contains("0.06"), "{text}");
4277        assert!(
4278            text[caller_at..].contains("lint.yml:5: lint on macOS"),
4279            "{text}"
4280        );
4281        assert!(!text[caller_at..].contains("1.00"), "{text}");
4282        let caller_json = json["workflows"]
4283            .as_array()
4284            .unwrap()
4285            .iter()
4286            .find(|item| item["path"] == ".github/workflows/caller.yml")
4287            .unwrap();
4288        assert_eq!(caller_json["net"], "0.060000");
4289        assert_eq!(caller_json["gross"], "0.060000");
4290    }
4291
4292    struct RemoteCase {
4293        name: &'static str,
4294        caller_file: &'static str,
4295        job_body: &'static str,
4296        spec: &'static str,
4297        relative: &'static str,
4298        crlf: bool,
4299        bom: bool,
4300    }
4301
4302    fn remote_cases() -> Vec<RemoteCase> {
4303        let path = "acme/tools/.github/workflows/lint.yml@v1";
4304        vec![
4305            RemoteCase {
4306                name: "tag",
4307                caller_file: "caller.yml",
4308                job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\n",
4309                spec: path,
4310                relative: path,
4311                crlf: false,
4312                bom: false,
4313            },
4314            RemoteCase {
4315                name: "yaml-ext",
4316                caller_file: "caller.yaml",
4317                job_body: "uses: acme/tools/.github/workflows/lint.yaml@v1\n",
4318                spec: "acme/tools/.github/workflows/lint.yaml@v1",
4319                relative: "acme/tools/.github/workflows/lint.yaml@v1",
4320                crlf: false,
4321                bom: false,
4322            },
4323            RemoteCase {
4324                name: "tag-dotted",
4325                caller_file: "caller.yml",
4326                job_body: "uses: acme/tools/.github/workflows/lint.yml@v1.2.3\n",
4327                spec: "acme/tools/.github/workflows/lint.yml@v1.2.3",
4328                relative: "acme/tools/.github/workflows/lint.yml@v1.2.3",
4329                crlf: false,
4330                bom: false,
4331            },
4332            RemoteCase {
4333                name: "branch",
4334                caller_file: "caller.yml",
4335                job_body: "uses: acme/tools/.github/workflows/lint.yml@main\n",
4336                spec: "acme/tools/.github/workflows/lint.yml@main",
4337                relative: "acme/tools/.github/workflows/lint.yml@main",
4338                crlf: false,
4339                bom: false,
4340            },
4341            RemoteCase {
4342                name: "branch-slash",
4343                caller_file: "caller.yml",
4344                job_body: "uses: acme/tools/.github/workflows/lint.yml@feature/widget\n",
4345                spec: "acme/tools/.github/workflows/lint.yml@feature/widget",
4346                relative: "acme/tools/.github/workflows/lint.yml@feature/widget",
4347                crlf: false,
4348                bom: false,
4349            },
4350            RemoteCase {
4351                name: "refs-heads",
4352                caller_file: "caller.yml",
4353                job_body: "uses: acme/tools/.github/workflows/lint.yml@refs/heads/main\n",
4354                spec: "acme/tools/.github/workflows/lint.yml@refs/heads/main",
4355                relative: "acme/tools/.github/workflows/lint.yml@refs/heads/main",
4356                crlf: false,
4357                bom: false,
4358            },
4359            RemoteCase {
4360                name: "sha",
4361                caller_file: "caller.yml",
4362                job_body: "uses: acme/tools/.github/workflows/lint.yml@0123456789abcdef0123456789abcdef01234567\n",
4363                spec: "acme/tools/.github/workflows/lint.yml@0123456789abcdef0123456789abcdef01234567",
4364                relative: "acme/tools/.github/workflows/lint.yml@0123456789abcdef0123456789abcdef01234567",
4365                crlf: false,
4366                bom: false,
4367            },
4368            RemoteCase {
4369                name: "short-sha",
4370                caller_file: "caller.yml",
4371                job_body: "uses: acme/tools/.github/workflows/lint.yml@abc1234\n",
4372                spec: "acme/tools/.github/workflows/lint.yml@abc1234",
4373                relative: "acme/tools/.github/workflows/lint.yml@abc1234",
4374                crlf: false,
4375                bom: false,
4376            },
4377            RemoteCase {
4378                name: "with-after",
4379                caller_file: "caller.yml",
4380                job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\nwith:\n  extra: \"1\"\n",
4381                spec: path,
4382                relative: path,
4383                crlf: false,
4384                bom: false,
4385            },
4386            RemoteCase {
4387                name: "secrets-before",
4388                caller_file: "caller.yml",
4389                job_body: "secrets: inherit\nwith:\n  extra: \"1\"\nuses: acme/tools/.github/workflows/lint.yml@v1\n",
4390                spec: path,
4391                relative: path,
4392                crlf: false,
4393                bom: false,
4394            },
4395            RemoteCase {
4396                name: "with-and-secrets",
4397                caller_file: "caller.yml",
4398                job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\nwith:\n  extra: \"1\"\nsecrets: inherit\n",
4399                spec: path,
4400                relative: path,
4401                crlf: false,
4402                bom: false,
4403            },
4404            RemoteCase {
4405                name: "empty-with-secrets",
4406                caller_file: "caller.yml",
4407                job_body: "with: {}\nsecrets: {}\nuses: acme/tools/.github/workflows/lint.yml@v1\n",
4408                spec: path,
4409                relative: path,
4410                crlf: false,
4411                bom: false,
4412            },
4413            RemoteCase {
4414                name: "secrets-map-before",
4415                caller_file: "caller.yml",
4416                job_body: "secrets:\n  token: value\nuses: acme/tools/.github/workflows/lint.yml@v1\nwith:\n  extra: \"1\"\n",
4417                spec: path,
4418                relative: path,
4419                crlf: false,
4420                bom: false,
4421            },
4422            RemoteCase {
4423                name: "double-quoted",
4424                caller_file: "caller.yml",
4425                job_body: "uses: \"acme/tools/.github/workflows/lint.yml@v1\"\n",
4426                spec: path,
4427                relative: path,
4428                crlf: false,
4429                bom: false,
4430            },
4431            RemoteCase {
4432                name: "single-quoted",
4433                caller_file: "caller.yml",
4434                job_body: "uses: 'acme/tools/.github/workflows/lint.yml@v1'\n",
4435                spec: path,
4436                relative: path,
4437                crlf: false,
4438                bom: false,
4439            },
4440            RemoteCase {
4441                name: "capitals",
4442                caller_file: "caller.yml",
4443                job_body: "uses: Acme/Tools/.GitHub/Workflows/Lint.YML@V1\n",
4444                spec: "Acme/Tools/.GitHub/Workflows/Lint.YML@V1",
4445                relative: "Acme/Tools/.GitHub/Workflows/Lint.YML@V1",
4446                crlf: false,
4447                bom: false,
4448            },
4449            RemoteCase {
4450                name: "backslash",
4451                caller_file: "caller.yml",
4452                job_body: "uses: acme/tools/.github\\workflows\\lint.yml@v1\n",
4453                spec: "acme/tools/.github\\workflows\\lint.yml@v1",
4454                relative: path,
4455                crlf: false,
4456                bom: false,
4457            },
4458            RemoteCase {
4459                name: "crlf",
4460                caller_file: "caller.yml",
4461                job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\nsecrets: inherit\nwith:\n  extra: \"1\"\n",
4462                spec: path,
4463                relative: path,
4464                crlf: true,
4465                bom: false,
4466            },
4467            RemoteCase {
4468                name: "bom",
4469                caller_file: "caller.yml",
4470                job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\n",
4471                spec: path,
4472                relative: path,
4473                crlf: false,
4474                bom: true,
4475            },
4476            RemoteCase {
4477                name: "block-scalar",
4478                caller_file: "caller.yml",
4479                job_body: "uses: |\n  acme/tools/.github/workflows/lint.yml@v1\n",
4480                spec: path,
4481                relative: path,
4482                crlf: false,
4483                bom: false,
4484            },
4485        ]
4486    }
4487
4488    fn b4b_dirs(name: &str) -> (PathBuf, PathBuf) {
4489        let root = std::env::temp_dir().join("ci-bill-b4b").join(name);
4490        let _ = fs::remove_dir_all(&root);
4491        let workflows = root.join(".github").join("workflows");
4492        let runs = root.join("runs");
4493        fs::create_dir_all(&workflows).unwrap();
4494        fs::create_dir_all(&runs).unwrap();
4495        (workflows, runs)
4496    }
4497
4498    /// Whether `dir` tells `a.txt` and `A.TXT` apart.
4499    fn filesystem_is_case_sensitive(dir: &Path) -> bool {
4500        std::fs::create_dir_all(dir).unwrap();
4501        let probe = dir.join("case-probe.tmp");
4502        std::fs::write(&probe, "").unwrap();
4503        let sensitive = !dir.join("CASE-PROBE.TMP").exists();
4504        std::fs::remove_file(&probe).unwrap();
4505        sensitive
4506    }
4507
4508    fn path_from_slash(relative: &str) -> PathBuf {
4509        relative
4510            .split('/')
4511            .filter(|part| !part.is_empty())
4512            .collect()
4513    }
4514
4515    fn write_cache(runs: &Path, relative: &str, text: &str, crlf_file: bool, bom: bool) {
4516        let path = runs.join(path_from_slash(relative));
4517        if let Some(parent) = path.parent() {
4518            fs::create_dir_all(parent).unwrap();
4519        }
4520        let mut body = if crlf_file {
4521            text.replace('\n', "\r\n")
4522        } else {
4523            text.to_string()
4524        };
4525        if bom {
4526            body.insert(0, '\u{feff}');
4527        }
4528        fs::write(path, body).unwrap();
4529    }
4530
4531    fn caller_yaml(job_body: &str) -> String {
4532        indent_job_body(&format!(
4533            "name: Caller\non: push\njobs:\n  call-lint:\n{job_body}"
4534        ))
4535    }
4536
4537    fn uses_key_line(yaml: &str) -> usize {
4538        yaml.lines()
4539            .position(|line| line.contains("uses:"))
4540            .unwrap_or_else(|| panic!("no uses key in {yaml}"))
4541            + 1
4542    }
4543
4544    fn remote_of(job: &crate::workflow::Job) -> RemoteWorkflow {
4545        match &job.uses.as_ref().expect("uses").source {
4546            ReusableSource::Remote(remote) => remote.clone(),
4547            ReusableSource::Local => panic!("expected a remote reusable workflow"),
4548        }
4549    }
4550
4551    fn assert_no_limitation(text: &str) {
4552        let lower = text.to_ascii_lowercase();
4553        assert!(!lower.contains("not supported"), "{text}");
4554        assert!(!lower.contains("unsupported"), "{text}");
4555        assert!(!lower.contains("skipped"), "{text}");
4556        assert!(!lower.contains("not implemented"), "{text}");
4557    }
4558
4559    fn assert_costs_stay_on_caller(
4560        found: &[Finding],
4561        caller_file: &str,
4562        needle: &str,
4563    ) -> (String, serde_json::Value) {
4564        let rows = [
4565            usage(&caller_key(caller_file), "0.06", "0.006"),
4566            usage(".github/workflows/other.yml", "1.00", "0.100"),
4567        ];
4568        let ranking = rank(&rows, None).unwrap();
4569        assert_eq!(ranking.workflows.len(), 2);
4570        let caller = ranking
4571            .workflows
4572            .iter()
4573            .find(|item| item.workflow_path == caller_key(caller_file))
4574            .unwrap();
4575        let other = ranking
4576            .workflows
4577            .iter()
4578            .find(|item| item.workflow_path == ".github/workflows/other.yml")
4579            .unwrap();
4580        assert_eq!(caller.net, Micros::parse("0.06").unwrap());
4581        assert_eq!(other.net, Micros::parse("1.00").unwrap());
4582        assert_eq!(
4583            ranking.bill.total_net,
4584            Micros::parse("1.06").unwrap(),
4585            "remote calls must not add or move invoice net"
4586        );
4587        let (text, json) = report_for(found, &rows);
4588        assert_no_limitation(&text);
4589        assert_no_limitation(&json.to_string());
4590        let label = format!("example-org/app {}", caller_key(caller_file));
4591        let other_label = "example-org/app .github/workflows/other.yml";
4592        let other_at = text
4593            .find(other_label)
4594            .unwrap_or_else(|| panic!("missing {other_label} in {text}"));
4595        let caller_at = text
4596            .find(&label)
4597            .unwrap_or_else(|| panic!("missing {label} in {text}"));
4598        assert!(other_at < caller_at, "{text}");
4599        assert!(
4600            !text[other_at..caller_at].contains(needle),
4601            "{needle} landed on the other workflow:\n{text}"
4602        );
4603        assert!(
4604            text[caller_at..].contains(needle),
4605            "missing {needle} under the caller:\n{text}"
4606        );
4607        assert!(!text.contains("Workflows without usage"), "{text}");
4608        let caller_json = json["workflows"]
4609            .as_array()
4610            .unwrap()
4611            .iter()
4612            .find(|item| item["path"] == caller_key(caller_file))
4613            .unwrap();
4614        assert_eq!(caller_json["net"], "0.060000");
4615        assert!(
4616            json["unattached_findings"].as_array().unwrap().is_empty(),
4617            "{json}"
4618        );
4619        (text, json)
4620    }
4621
4622    #[test]
4623    fn remote_without_cache_shows_the_job_at_the_uses_line() {
4624        for case in remote_cases() {
4625            let (dir, runs) = b4b_dirs(&format!("miss-{}", case.name));
4626            let yaml = caller_yaml(case.job_body);
4627            let expected_line = uses_key_line(&yaml);
4628            write_workflow(&dir, case.caller_file, &yaml, case.crlf, case.bom);
4629            for runs_arg in [None, Some(runs.as_path())] {
4630                let scanned = scan_dir(&dir).expect("scan");
4631                let found = findings_with_cache(&scanned, runs_arg).expect("findings");
4632                let call = job_named(&scanned, case.caller_file, "call-lint");
4633                assert_eq!(call.uses.as_ref().unwrap().spec, case.spec, "{}", case.name);
4634                assert_eq!(
4635                    call.uses.as_ref().unwrap().line,
4636                    expected_line,
4637                    "{}",
4638                    case.name
4639                );
4640                assert_eq!(
4641                    remote_of(call).cache_relative(),
4642                    path_from_slash(case.relative),
4643                    "{}",
4644                    case.name
4645                );
4646                assert!(call.invalid_uses.is_none(), "{}", case.name);
4647                assert_no_timeout_on_call(&found, case.caller_file, call.line);
4648                let shown = attributed(&found, case.caller_file, FindingKind::RemoteWorkflow);
4649                assert_eq!(shown.len(), 1, "{}: {found:?}", case.name);
4650                assert_eq!(shown[0].line, expected_line, "{}", case.name);
4651                assert_eq!(shown[0].kind.code(), "F10");
4652                assert_eq!(
4653                    shown[0].message,
4654                    format!("job call-lint uses {}", case.spec),
4655                    "{}",
4656                    case.name
4657                );
4658                assert!(shown[0].path.ends_with(case.caller_file), "{}", case.name);
4659                assert!(
4660                    found
4661                        .iter()
4662                        .all(|item| item.kind != FindingKind::CalleeNotFound),
4663                    "{}: {found:?}",
4664                    case.name
4665                );
4666                let needle = format!(
4667                    "F10 {}:{}: job call-lint uses {}",
4668                    crate::display::display_path(&shown[0].path),
4669                    expected_line,
4670                    case.spec
4671                );
4672                let (_text, json) = assert_costs_stay_on_caller(&found, case.caller_file, &needle);
4673                let listed = json["workflows"]
4674                    .as_array()
4675                    .unwrap()
4676                    .iter()
4677                    .find(|item| item["path"] == caller_key(case.caller_file))
4678                    .unwrap()["findings"]
4679                    .as_array()
4680                    .unwrap()
4681                    .clone();
4682                assert!(
4683                    listed.iter().any(|item| {
4684                        item["id"] == "F10"
4685                            && item["line"] == expected_line
4686                            && item["message"] == format!("job call-lint uses {}", case.spec)
4687                    }),
4688                    "{}: {listed:?}",
4689                    case.name
4690                );
4691            }
4692        }
4693
4694        let (dir, _) = b4b_dirs("miss-flow");
4695        let flow = "name: Caller\non: push\njobs: {call-lint: {secrets: inherit, with: {extra: \"1\"}, uses: acme/tools/.github/workflows/lint.yml@v1}}\n";
4696        write_workflow(&dir, "caller.yml", flow, false, false);
4697        let (workflows, found) = load_cached_pair(&dir, None);
4698        let call = job_named(&workflows, "caller.yml", "call-lint");
4699        let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
4700        assert_eq!(shown.len(), 1, "{found:?}");
4701        assert_eq!(shown[0].line, call.uses.as_ref().unwrap().line);
4702        assert_eq!(
4703            shown[0].message,
4704            "job call-lint uses acme/tools/.github/workflows/lint.yml@v1"
4705        );
4706        assert_eq!(shown[0].line, uses_key_line(flow));
4707
4708        let (dir, _) = b4b_dirs("miss-two-jobs");
4709        write_workflow(
4710            &dir,
4711            "caller.yml",
4712            "name: Caller\non: push\njobs:\n  first:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n  second:\n    with: {}\n    uses: other/repo/.github/workflows/build.yaml@release/1.2\n",
4713            false,
4714            false,
4715        );
4716        let (workflows, found) = load_cached_pair(&dir, None);
4717        let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
4718        assert_eq!(shown.len(), 2, "{found:?}");
4719        let first = job_named(&workflows, "caller.yml", "first");
4720        let second = job_named(&workflows, "caller.yml", "second");
4721        assert!(shown.iter().any(|item| {
4722            item.line == first.uses.as_ref().unwrap().line
4723                && item.message == "job first uses acme/tools/.github/workflows/lint.yml@v1"
4724        }));
4725        assert!(shown.iter().any(|item| {
4726            item.line == second.uses.as_ref().unwrap().line
4727                && item.message
4728                    == "job second uses other/repo/.github/workflows/build.yaml@release/1.2"
4729        }));
4730        assert_eq!(
4731            remote_of(second).cache_relative(),
4732            path_from_slash("other/repo/.github/workflows/build.yaml@release/1.2")
4733        );
4734    }
4735
4736    fn load_cached_pair(dir: &Path, runs: Option<&Path>) -> (Vec<Workflow>, Vec<Finding>) {
4737        let workflows = scan_dir(dir).expect("scan workflows");
4738        let found = findings_with_cache(&workflows, runs).expect("findings");
4739        (workflows, found)
4740    }
4741
4742    #[test]
4743    fn remote_cache_file_puts_callee_findings_under_the_caller() {
4744        for case in remote_cases() {
4745            let (dir, runs) = b4b_dirs(&format!("hit-{}", case.name));
4746            let yaml = caller_yaml(case.job_body);
4747            write_workflow(&dir, case.caller_file, &yaml, case.crlf, case.bom);
4748            write_cache(&runs, case.relative, LINT_CALLEE, case.crlf, case.bom);
4749            let (workflows, found) = load_cached_pair(&dir, Some(&runs));
4750            let call = job_named(&workflows, case.caller_file, "call-lint");
4751            assert_eq!(call.uses.as_ref().unwrap().spec, case.spec, "{}", case.name);
4752            assert_no_timeout_on_call(&found, case.caller_file, call.line);
4753            let f1 = attributed(&found, case.caller_file, FindingKind::LintOnRunner);
4754            assert_eq!(f1.len(), 1, "{}: {found:?}", case.name);
4755            assert_eq!(f1[0].line, 5, "{}", case.name);
4756            assert_eq!(f1[0].message, "lint on macOS", "{}", case.name);
4757            assert_eq!(f1[0].kind.code(), "F1");
4758            assert!(
4759                f1[0].path.ends_with(path_from_slash(case.relative)),
4760                "{}: {}",
4761                case.name,
4762                f1[0].path.display()
4763            );
4764            assert!(
4765                attributed(&found, case.caller_file, FindingKind::RemoteWorkflow).is_empty(),
4766                "{} still listed the call as uncached: {found:?}",
4767                case.name
4768            );
4769            assert!(
4770                found
4771                    .iter()
4772                    .all(|item| item.kind != FindingKind::CalleeNotFound),
4773                "{}",
4774                case.name
4775            );
4776            let needle = format!(
4777                "F1 {}:5: lint on macOS",
4778                crate::display::display_path(&f1[0].path)
4779            );
4780            let (_text, json) = assert_costs_stay_on_caller(&found, case.caller_file, &needle);
4781            let listed = json["workflows"]
4782                .as_array()
4783                .unwrap()
4784                .iter()
4785                .find(|item| item["path"] == caller_key(case.caller_file))
4786                .unwrap()["findings"]
4787                .as_array()
4788                .unwrap()
4789                .clone();
4790            assert!(
4791                listed.iter().any(|item| {
4792                    item["id"] == "F1"
4793                        && item["line"] == 5
4794                        && item["message"] == "lint on macOS"
4795                        && item["file"]
4796                            .as_str()
4797                            .unwrap()
4798                            .ends_with(case.relative.rsplit('/').next().unwrap())
4799                }),
4800                "{}: {listed:?}",
4801                case.name
4802            );
4803        }
4804
4805        let (dir, runs) = b4b_dirs("hit-flow");
4806        let flow = "on: push\njobs: {call-lint: {uses: acme/tools/.github/workflows/lint.yml@v1, secrets: inherit, with: {extra: \"1\"}}}\n";
4807        write_workflow(&dir, "caller.yml", flow, true, false);
4808        write_cache(
4809            &runs,
4810            "acme/tools/.github/workflows/lint.yml@v1",
4811            LINT_CALLEE,
4812            true,
4813            true,
4814        );
4815        let (_, found) = load_cached_pair(&dir, Some(&runs));
4816        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4817        assert_eq!(f1.len(), 1, "{found:?}");
4818        assert_eq!(f1[0].line, 5);
4819        assert_eq!(f1[0].message, "lint on macOS");
4820
4821        let (dir, runs) = b4b_dirs("hit-casefold");
4822        write_workflow(
4823            &dir,
4824            "caller.yml",
4825            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: Acme/Tools/.GitHub/Workflows/Lint.YML@V1\n",
4826            false,
4827            false,
4828        );
4829        write_cache(
4830            &runs,
4831            "acme/tools/.github/workflows/lint.yml@v1",
4832            LINT_CALLEE,
4833            false,
4834            false,
4835        );
4836        let (_, found) = load_cached_pair(&dir, Some(&runs));
4837        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4838        assert_eq!(f1.len(), 1, "{found:?}");
4839        // On a case-insensitive filesystem (default on macOS and Windows) the
4840        // caller's own spelling already opens the cached file, so the path keeps
4841        // that spelling; compare without case.
4842        let shown = f1[0].path.to_string_lossy().to_lowercase();
4843        let expected = path_from_slash("acme/tools/.github/workflows/lint.yml@v1");
4844        assert!(
4845            shown.ends_with(&*expected.to_string_lossy()),
4846            "{}",
4847            f1[0].path.display()
4848        );
4849
4850        // Two cache files that differ only in case cannot both exist on a
4851        // case-insensitive filesystem (default on macOS and Windows), so the
4852        // exact-spelling-wins case only runs where they can.
4853        let (dir, runs) = b4b_dirs("hit-exact-wins");
4854        if filesystem_is_case_sensitive(&runs) {
4855            write_workflow(
4856                &dir,
4857                "caller.yml",
4858                "name: Caller\non: push\njobs:\n  call-lint:\n    uses: Acme/Tools/.GitHub/Workflows/Lint.YML@V1\n",
4859                false,
4860                false,
4861            );
4862            write_cache(
4863                &runs,
4864                "Acme/Tools/.GitHub/Workflows/Lint.YML@V1",
4865                LINT_CALLEE,
4866                false,
4867                false,
4868            );
4869            write_cache(
4870                &runs,
4871                "acme/tools/.github/workflows/lint.yml@v1",
4872                "name: Lint\non: workflow_call\njobs:\n  fmt:\n    runs-on: windows-latest\n    timeout-minutes: 10\n    steps:\n      - run: cargo fmt --check\n",
4873                false,
4874                false,
4875            );
4876            let (_, found) = load_cached_pair(&dir, Some(&runs));
4877            let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4878            assert_eq!(f1.len(), 1, "{found:?}");
4879            assert_eq!(f1[0].message, "lint on macOS");
4880            assert!(
4881                f1[0]
4882                    .path
4883                    .ends_with(path_from_slash("Acme/Tools/.GitHub/Workflows/Lint.YML@V1")),
4884                "{}",
4885                f1[0].path.display()
4886            );
4887        }
4888
4889        let bodies = [
4890            (
4891                "windows",
4892                "name: Lint\non: workflow_call\njobs:\n  fmt:\n    runs-on: windows-latest\n    timeout-minutes: 10\n    steps:\n      - run: cargo fmt --check\n",
4893                FindingKind::LintOnRunner,
4894                5,
4895                "lint on Windows",
4896            ),
4897            (
4898                "timeout",
4899                "name: Called\non: workflow_call\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo build\n",
4900                FindingKind::MissingTimeout,
4901                4,
4902                "default timeout is 360 minutes",
4903            ),
4904            (
4905                "pull-request",
4906                "name: Called\non: pull_request\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - run: cargo test\n",
4907                FindingKind::PullRequestConcurrency,
4908                2,
4909                "pull_request without concurrency cancel-in-progress",
4910            ),
4911            (
4912                "matrix",
4913                MATRIX_CALLEE,
4914                FindingKind::MatrixJobs,
4915                8,
4916                "matrix runs 6 jobs; each job is billed in whole minutes",
4917            ),
4918            (
4919                "duplicate",
4920                "name: Called\non: workflow_call\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    runs-on: macos-latest\n    steps:\n      - run: cargo fmt --check\n",
4921                FindingKind::DuplicateKey,
4922                6,
4923                "duplicate key runs-on (line 5 and 6)",
4924            ),
4925        ];
4926        for (name, body, kind, line, message) in bodies {
4927            let (dir, runs) = b4b_dirs(&format!("hit-{name}"));
4928            write_workflow(
4929                &dir,
4930                "caller.yml",
4931                "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
4932                false,
4933                false,
4934            );
4935            write_cache(
4936                &runs,
4937                "acme/tools/.github/workflows/lint.yml@v1",
4938                body,
4939                false,
4940                false,
4941            );
4942            let (workflows, found) = load_cached_pair(&dir, Some(&runs));
4943            let call = job_named(&workflows, "caller.yml", "call-lint");
4944            assert_no_timeout_on_call(&found, "caller.yml", call.line);
4945            let matched = attributed(&found, "caller.yml", kind);
4946            assert_eq!(matched.len(), 1, "{name}: {found:?}");
4947            assert_eq!(matched[0].line, line, "{name}");
4948            assert_eq!(matched[0].message, message, "{name}");
4949            assert!(
4950                matched[0]
4951                    .path
4952                    .ends_with(path_from_slash("acme/tools/.github/workflows/lint.yml@v1")),
4953                "{name}: {}",
4954                matched[0].path.display()
4955            );
4956            let needle = format!(
4957                "{} {}:{}: {message}",
4958                kind.code(),
4959                crate::display::display_path(&matched[0].path),
4960                line
4961            );
4962            let _ = assert_costs_stay_on_caller(&found, "caller.yml", &needle);
4963        }
4964    }
4965
4966    #[test]
4967    fn remote_cache_expands_nested_calls_and_stops_cycles() {
4968        let (dir, runs) = b4b_dirs("nested-remote");
4969        write_workflow(
4970            &dir,
4971            "caller.yml",
4972            "name: Caller\non: push\njobs:\n  call-mid:\n    secrets: inherit\n    uses: acme/tools/.github/workflows/mid.yml@v1\n",
4973            false,
4974            false,
4975        );
4976        write_cache(
4977            &runs,
4978            "acme/tools/.github/workflows/mid.yml@v1",
4979            "name: Mid\non: workflow_call\njobs:\n  forward:\n    with:\n      extra: \"1\"\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
4980            false,
4981            false,
4982        );
4983        write_cache(
4984            &runs,
4985            "acme/tools/.github/workflows/lint.yml@v1",
4986            LINT_CALLEE,
4987            false,
4988            false,
4989        );
4990        let (_, found) = load_cached_pair(&dir, Some(&runs));
4991        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4992        assert_eq!(f1.len(), 1, "{found:?}");
4993        assert_eq!(f1[0].line, 5);
4994        assert!(
4995            f1[0].path.ends_with("lint.yml@v1"),
4996            "{}",
4997            f1[0].path.display()
4998        );
4999        assert!(
5000            attributed(&found, "caller.yml", FindingKind::ReusableCycle).is_empty(),
5001            "{found:?}"
5002        );
5003
5004        let (dir, runs) = b4b_dirs("nested-local-inside-remote");
5005        write_workflow(
5006            &dir,
5007            "caller.yml",
5008            "name: Caller\non: push\njobs:\n  call-mid:\n    uses: acme/tools/.github/workflows/mid.yml@release/1.2\n",
5009            true,
5010            false,
5011        );
5012        write_cache(
5013            &runs,
5014            "acme/tools/.github/workflows/mid.yml@release/1.2",
5015            "name: Mid\non: workflow_call\njobs:\n  forward:\n    uses: ./.github/workflows/lint.yml\n    secrets: inherit\n",
5016            true,
5017            false,
5018        );
5019        write_cache(
5020            &runs,
5021            "acme/tools/.github/workflows/lint.yml@release/1.2",
5022            LINT_CALLEE,
5023            true,
5024            false,
5025        );
5026        let (_, found) = load_cached_pair(&dir, Some(&runs));
5027        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
5028        assert_eq!(f1.len(), 1, "{found:?}");
5029        assert_eq!(f1[0].message, "lint on macOS");
5030        assert!(
5031            f1[0].path.ends_with(path_from_slash(
5032                "acme/tools/.github/workflows/lint.yml@release/1.2"
5033            )),
5034            "{}",
5035            f1[0].path.display()
5036        );
5037
5038        let (dir, runs) = b4b_dirs("nested-local-missing");
5039        write_workflow(
5040            &dir,
5041            "caller.yml",
5042            "name: Caller\non: push\njobs:\n  call-mid:\n    uses: acme/tools/.github/workflows/mid.yml@v1\n",
5043            false,
5044            false,
5045        );
5046        write_cache(
5047            &runs,
5048            "acme/tools/.github/workflows/mid.yml@v1",
5049            "name: Mid\non: workflow_call\njobs:\n  forward:\n    uses: ./.github/workflows/missing.yml\n",
5050            false,
5051            false,
5052        );
5053        let (_, found) = load_cached_pair(&dir, Some(&runs));
5054        let missing = attributed(&found, "caller.yml", FindingKind::CalleeNotFound);
5055        assert_eq!(missing.len(), 1, "{found:?}");
5056        assert!(
5057            missing[0].message.contains("callee not found"),
5058            "{}",
5059            missing[0].message
5060        );
5061        assert!(
5062            missing[0]
5063                .message
5064                .contains("./.github/workflows/missing.yml"),
5065            "{}",
5066            missing[0].message
5067        );
5068        assert!(
5069            missing[0].path.ends_with("mid.yml@v1"),
5070            "{}",
5071            missing[0].path.display()
5072        );
5073        assert_no_limitation(&missing[0].message);
5074
5075        let (dir, runs) = b4b_dirs("cycle-two");
5076        write_workflow(
5077            &dir,
5078            "caller.yml",
5079            "name: Caller\non: push\njobs:\n  call-a:\n    uses: acme/tools/.github/workflows/a.yml@v1\n",
5080            false,
5081            false,
5082        );
5083        let spec_a = "acme/tools/.github/workflows/a.yml@v1";
5084        let spec_b = "acme/tools/.github/workflows/b.yml@v1";
5085        write_cache(
5086            &runs,
5087            spec_a,
5088            &format!(
5089                "name: A\non: workflow_call\njobs:\n  lint:\n    runs-on: macos-latest\n    timeout-minutes: 5\n    steps:\n      - run: cargo fmt --check\n  call-b:\n    uses: {spec_b}\n"
5090            ),
5091            false,
5092            false,
5093        );
5094        write_cache(
5095            &runs,
5096            spec_b,
5097            &format!("name: B\non: workflow_call\njobs:\n  call-a:\n    uses: {spec_a}\n    secrets: inherit\n"),
5098            false,
5099            false,
5100        );
5101        let (_, found) = load_cached_pair(&dir, Some(&runs));
5102        assert!(found.len() < 12, "{found:?}");
5103        let cycles = attributed(&found, "caller.yml", FindingKind::ReusableCycle);
5104        assert_eq!(cycles.len(), 1, "{found:?}");
5105        assert!(cycles[0].message.contains(spec_a), "{}", cycles[0].message);
5106        assert!(cycles[0].message.contains(spec_b), "{}", cycles[0].message);
5107        assert!(cycles[0].message.contains(" -> "), "{}", cycles[0].message);
5108        assert_eq!(cycles[0].kind.code(), "F7");
5109        let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
5110        assert_eq!(f1.len(), 1, "{found:?}");
5111        assert_eq!(f1[0].message, "lint on macOS");
5112
5113        let (dir, runs) = b4b_dirs("cycle-self");
5114        let spec = "acme/tools/.github/workflows/loop.yml@main";
5115        write_workflow(
5116            &dir,
5117            "caller.yml",
5118            &format!("name: Caller\non: push\njobs:\n  again:\n    uses: {spec}\n"),
5119            false,
5120            false,
5121        );
5122        write_cache(
5123            &runs,
5124            spec,
5125            &format!("name: Loop\non: workflow_call\njobs:\n  again:\n    uses: {spec}\n"),
5126            false,
5127            false,
5128        );
5129        let (_, found) = load_cached_pair(&dir, Some(&runs));
5130        assert!(found.len() < 6, "{found:?}");
5131        let cycles = attributed(&found, "caller.yml", FindingKind::ReusableCycle);
5132        assert_eq!(cycles.len(), 1, "{found:?}");
5133        assert!(
5134            cycles[0].message.contains(&format!("{spec} -> {spec}")),
5135            "{}",
5136            cycles[0].message
5137        );
5138    }
5139
5140    #[test]
5141    fn remote_cache_errors_name_the_file_and_keep_a_visible_job() {
5142        let (dir, runs) = b4b_dirs("bad-yaml");
5143        write_workflow(
5144            &dir,
5145            "caller.yml",
5146            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
5147            false,
5148            false,
5149        );
5150        write_cache(
5151            &runs,
5152            "acme/tools/.github/workflows/lint.yml@v1",
5153            "name: Broken\non: push\njobs: [\nUNIQUE_SECRET_TOKEN\n",
5154            false,
5155            false,
5156        );
5157        let scanned = scan_dir(&dir).unwrap();
5158        let err = findings_with_cache(&scanned, Some(&runs)).unwrap_err();
5159        let message = err.to_string();
5160        assert!(message.starts_with("line "), "{message}");
5161        assert!(message.contains("invalid YAML"), "{message}");
5162        assert!(!message.contains("UNIQUE_SECRET_TOKEN"), "{message}");
5163        assert!(
5164            err.path
5165                .ends_with(path_from_slash("acme/tools/.github/workflows/lint.yml@v1")),
5166            "{}",
5167            err.path.display()
5168        );
5169
5170        let (dir, runs) = b4b_dirs("bad-bytes");
5171        write_workflow(
5172            &dir,
5173            "caller.yml",
5174            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
5175            false,
5176            false,
5177        );
5178        let bytes = runs.join(path_from_slash("acme/tools/.github/workflows/lint.yml@v1"));
5179        fs::create_dir_all(bytes.parent().unwrap()).unwrap();
5180        fs::write(&bytes, [0xff, 0xfe, 0xfd]).unwrap();
5181        let scanned = scan_dir(&dir).unwrap();
5182        let err = findings_with_cache(&scanned, Some(&runs)).unwrap_err();
5183        assert_eq!(err.to_string(), "cannot read file");
5184        assert!(!format!("{err} {}", err.path.display()).contains("not supported"));
5185
5186        let (dir, runs) = b4b_dirs("empty-callee");
5187        write_workflow(
5188            &dir,
5189            "caller.yml",
5190            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
5191            false,
5192            false,
5193        );
5194        write_cache(
5195            &runs,
5196            "acme/tools/.github/workflows/lint.yml@v1",
5197            "",
5198            false,
5199            false,
5200        );
5201        let (_, found) = load_cached_pair(&dir, Some(&runs));
5202        let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
5203        assert_eq!(shown.len(), 1, "{found:?}");
5204        assert_eq!(
5205            shown[0].message,
5206            "job call-lint uses acme/tools/.github/workflows/lint.yml@v1"
5207        );
5208        assert_eq!(shown[0].line, 5);
5209
5210        let (dir, runs) = b4b_dirs("dir-callee");
5211        write_workflow(
5212            &dir,
5213            "caller.yml",
5214            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
5215            false,
5216            false,
5217        );
5218        fs::create_dir_all(runs.join(path_from_slash("acme/tools/.github/workflows/lint.yml@v1")))
5219            .unwrap();
5220        let (_, found) = load_cached_pair(&dir, Some(&runs));
5221        let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
5222        assert_eq!(shown.len(), 1, "{found:?}");
5223        assert_eq!(shown[0].line, 5);
5224        assert!(shown[0]
5225            .message
5226            .contains("acme/tools/.github/workflows/lint.yml@v1"));
5227
5228        let (dir, runs) = b4b_dirs("invalid-inside-callee");
5229        write_workflow(
5230            &dir,
5231            "caller.yml",
5232            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
5233            false,
5234            false,
5235        );
5236        write_cache(
5237            &runs,
5238            "acme/tools/.github/workflows/lint.yml@v1",
5239            "name: Called\non: workflow_call\njobs:\n  bad:\n    uses: actions/checkout@v4\n",
5240            false,
5241            false,
5242        );
5243        let (_, found) = load_cached_pair(&dir, Some(&runs));
5244        let invalid = attributed(&found, "caller.yml", FindingKind::InvalidUses);
5245        assert_eq!(invalid.len(), 1, "{found:?}");
5246        assert_eq!(
5247            invalid[0].message,
5248            "invalid reusable workflow uses: job bad uses \"actions/checkout@v4\""
5249        );
5250        assert!(invalid[0].path.ends_with("lint.yml@v1"));
5251    }
5252
5253    struct InvalidCase {
5254        name: &'static str,
5255        yaml: &'static str,
5256        job: &'static str,
5257        detail: &'static str,
5258        crlf: bool,
5259    }
5260
5261    #[test]
5262    fn invalid_uses_text_is_a_finding_and_does_not_panic() {
5263        let cases = [
5264            InvalidCase {
5265                name: "empty",
5266                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: \"\"\n",
5267                job: "call-lint",
5268                detail: "\"\"",
5269                crlf: false,
5270            },
5271            InvalidCase {
5272                name: "blank",
5273                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: \"   \"\n",
5274                job: "call-lint",
5275                detail: "\"\"",
5276                crlf: false,
5277            },
5278            InvalidCase {
5279                name: "missing-ref",
5280                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml\n",
5281                job: "call-lint",
5282                detail: "\"acme/tools/.github/workflows/lint.yml\"",
5283                crlf: false,
5284            },
5285            InvalidCase {
5286                name: "empty-ref",
5287                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@\n",
5288                job: "call-lint",
5289                detail: "\"acme/tools/.github/workflows/lint.yml@\"",
5290                crlf: false,
5291            },
5292            InvalidCase {
5293                name: "bad-extension",
5294                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.txt@v1\n",
5295                job: "call-lint",
5296                detail: "\"acme/tools/.github/workflows/lint.txt@v1\"",
5297                crlf: false,
5298            },
5299            InvalidCase {
5300                name: "action",
5301                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: actions/checkout@v4\n",
5302                job: "call-lint",
5303                detail: "\"actions/checkout@v4\"",
5304                crlf: false,
5305            },
5306            InvalidCase {
5307                name: "action-capitals",
5308                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: ACTIONS/CHECKOUT@V4\n",
5309                job: "call-lint",
5310                detail: "\"ACTIONS/CHECKOUT@V4\"",
5311                crlf: true,
5312            },
5313            InvalidCase {
5314                name: "local-other",
5315                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: ./foo.yml\n",
5316                job: "call-lint",
5317                detail: "\"./foo.yml\"",
5318                crlf: false,
5319            },
5320            InvalidCase {
5321                name: "url",
5322                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: https://github.com/acme/tools/.github/workflows/lint.yml@v1\n",
5323                job: "call-lint",
5324                detail: "\"https://github.com/acme/tools/.github/workflows/lint.yml@v1\"",
5325                crlf: false,
5326            },
5327            InvalidCase {
5328                name: "owner-repo-only",
5329                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools@v1\n",
5330                job: "call-lint",
5331                detail: "\"acme/tools@v1\"",
5332                crlf: false,
5333            },
5334            InvalidCase {
5335                name: "list",
5336                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses:\n      - acme/tools/.github/workflows/lint.yml@v1\n",
5337                job: "call-lint",
5338                detail: "a list",
5339                crlf: false,
5340            },
5341            InvalidCase {
5342                name: "mapping",
5343                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    secrets: inherit\n    uses:\n      path: acme/tools/.github/workflows/lint.yml@v1\n",
5344                job: "call-lint",
5345                detail: "a mapping",
5346                crlf: false,
5347            },
5348            InvalidCase {
5349                name: "flow-list",
5350                yaml: "on: push\njobs: {call-lint: {uses: [acme/tools/.github/workflows/lint.yml@v1]}}\n",
5351                job: "call-lint",
5352                detail: "a list",
5353                crlf: true,
5354            },
5355            InvalidCase {
5356                name: "flow-mapping",
5357                yaml: "on: push\njobs: {call-lint: {uses: {path: acme/tools/.github/workflows/lint.yml@v1}}}\n",
5358                job: "call-lint",
5359                detail: "a mapping",
5360                crlf: false,
5361            },
5362            InvalidCase {
5363                name: "number",
5364                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: 1\n",
5365                job: "call-lint",
5366                detail: "\"1\"",
5367                crlf: false,
5368            },
5369            InvalidCase {
5370                name: "bool",
5371                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: true\n",
5372                job: "call-lint",
5373                detail: "\"true\"",
5374                crlf: false,
5375            },
5376            InvalidCase {
5377                name: "parent-segment",
5378                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/../tools/.github/workflows/lint.yml@v1\n",
5379                job: "call-lint",
5380                detail: "\"acme/../tools/.github/workflows/lint.yml@v1\"",
5381                crlf: false,
5382            },
5383            InvalidCase {
5384                name: "absolute",
5385                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: /acme/tools/.github/workflows/lint.yml@v1\n",
5386                job: "call-lint",
5387                detail: "\"/acme/tools/.github/workflows/lint.yml@v1\"",
5388                crlf: false,
5389            },
5390            InvalidCase {
5391                name: "double-slash",
5392                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github//workflows/lint.yml@v1\n",
5393                job: "call-lint",
5394                detail: "\"acme/tools/.github//workflows/lint.yml@v1\"",
5395                crlf: false,
5396            },
5397            InvalidCase {
5398                name: "ref-dotdot",
5399                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/lint.yml@foo/../../etc\n",
5400                job: "call-lint",
5401                detail: "\"acme/tools/.github/workflows/lint.yml@foo/../../etc\"",
5402                crlf: false,
5403            },
5404            InvalidCase {
5405                name: "missing-file",
5406                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflows/@v1\n",
5407                job: "call-lint",
5408                detail: "\"acme/tools/.github/workflows/@v1\"",
5409                crlf: false,
5410            },
5411            InvalidCase {
5412                name: "singular-workflow",
5413                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: acme/tools/.github/workflow/lint.yml@v1\n",
5414                job: "call-lint",
5415                detail: "\"acme/tools/.github/workflow/lint.yml@v1\"",
5416                crlf: false,
5417            },
5418            InvalidCase {
5419                name: "unicode",
5420                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: \"acmé/tools/.github/workflows/lint.yml@v1\"\n",
5421                job: "call-lint",
5422                detail: "\"acmé/tools/.github/workflows/lint.yml@v1\"",
5423                crlf: false,
5424            },
5425            InvalidCase {
5426                name: "only-at",
5427                yaml: "name: Caller\non: push\njobs:\n  call-lint:\n    uses: \"@v1\"\n",
5428                job: "call-lint",
5429                detail: "\"@v1\"",
5430                crlf: false,
5431            },
5432        ];
5433        for case in cases {
5434            let (dir, runs) = b4b_dirs(&format!("bad-{}", case.name));
5435            write_workflow(&dir, "caller.yml", case.yaml, case.crlf, false);
5436            let caught = std::panic::catch_unwind(|| load_cached_pair(&dir, Some(&runs)));
5437            let (workflows, found) = caught.expect("invalid uses panicked");
5438            let job = job_named(&workflows, "caller.yml", case.job);
5439            assert!(job.uses.is_none(), "{}", case.name);
5440            let invalid = job.invalid_uses.as_ref().expect(case.name);
5441            assert_eq!(invalid.detail, case.detail, "{}", case.name);
5442            assert_eq!(invalid.line, uses_key_line(case.yaml), "{}", case.name);
5443            let matched: Vec<_> = found
5444                .iter()
5445                .filter(|item| {
5446                    item.kind == FindingKind::InvalidUses && item.path.ends_with("caller.yml")
5447                })
5448                .collect();
5449            assert_eq!(matched.len(), 1, "{}: {found:?}", case.name);
5450            assert_eq!(matched[0].line, invalid.line, "{}", case.name);
5451            assert_eq!(matched[0].kind.code(), "F11");
5452            assert_eq!(
5453                matched[0].message,
5454                format!(
5455                    "invalid reusable workflow uses: job {} uses {}",
5456                    case.job, case.detail
5457                ),
5458                "{}",
5459                case.name
5460            );
5461            assert_no_limitation(&matched[0].message);
5462            assert!(
5463                of_kind(&found, FindingKind::MissingTimeout).is_empty(),
5464                "{}: {found:?}",
5465                case.name
5466            );
5467            let needle = format!(
5468                "F11 {}:{}: {}",
5469                crate::display::display_path(&matched[0].path),
5470                matched[0].line,
5471                matched[0].message
5472            );
5473            let _ = assert_costs_stay_on_caller(&found, "caller.yml", &needle);
5474        }
5475
5476        let long = format!("\"{}\"", "x".repeat(4000));
5477        let yaml = format!("name: Caller\non: push\njobs:\n  call-lint:\n    uses: {long}\n");
5478        let (dir, _) = b4b_dirs("bad-long");
5479        write_workflow(&dir, "caller.yml", &yaml, false, false);
5480        let caught = std::panic::catch_unwind(|| load_cached_pair(&dir, None));
5481        let (_, found) = caught.expect("long uses panicked");
5482        let matched = attributed(&found, "caller.yml", FindingKind::InvalidUses);
5483        assert_eq!(matched.len(), 1, "{found:?}");
5484        assert!(matched[0].message.contains(&"x".repeat(4000)));
5485        assert_no_limitation(&matched[0].message);
5486
5487        let nasty = [
5488            "\0\0\0",
5489            "jobs: [\n",
5490            "uses: &\n",
5491            "jobs:\n  bad:\n    uses: \"unterminated",
5492            "a: &a\n  b: *a\n",
5493        ];
5494        for text in nasty {
5495            let parsed = std::panic::catch_unwind(|| scan_yaml(Path::new("odd.yml"), text));
5496            assert!(parsed.is_ok(), "parser panicked on {text:?}");
5497            if let Ok(Ok(workflow)) = parsed {
5498                let found = std::panic::catch_unwind(|| findings(&[workflow]));
5499                assert!(found.is_ok(), "findings panicked on {text:?}");
5500            }
5501        }
5502
5503        let (dir, _) = b4b_dirs("step-uses");
5504        write_workflow(
5505            &dir,
5506            "caller.yml",
5507            "name: Caller\non: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: acme/tools/.github/workflows/lint.yml@v1\n      - uses: actions/checkout@v4\n",
5508            false,
5509            false,
5510        );
5511        let (workflows, found) = load_cached_pair(&dir, None);
5512        let build = job_named(&workflows, "caller.yml", "build");
5513        assert!(build.uses.is_none());
5514        assert!(build.invalid_uses.is_none());
5515        assert!(
5516            of_kind(&found, FindingKind::InvalidUses).is_empty(),
5517            "{found:?}"
5518        );
5519        assert!(
5520            of_kind(&found, FindingKind::RemoteWorkflow).is_empty(),
5521            "{found:?}"
5522        );
5523
5524        let (dir, _) = b4b_dirs("last-key-wins");
5525        write_workflow(
5526            &dir,
5527            "caller.yml",
5528            "name: Caller\non: push\njobs:\n  call-lint:\n    uses: actions/checkout@v4\n    uses: acme/tools/.github/workflows/lint.yml@v1\n",
5529            false,
5530            false,
5531        );
5532        let (workflows, found) = load_cached_pair(&dir, None);
5533        let call = job_named(&workflows, "caller.yml", "call-lint");
5534        assert_eq!(
5535            call.uses.as_ref().unwrap().spec,
5536            "acme/tools/.github/workflows/lint.yml@v1"
5537        );
5538        assert!(call.invalid_uses.is_none());
5539        assert_eq!(
5540            of_kind(&found, FindingKind::DuplicateKey).len(),
5541            1,
5542            "{found:?}"
5543        );
5544        let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
5545        assert_eq!(shown.len(), 1, "{found:?}");
5546        assert!(shown[0]
5547            .message
5548            .contains("acme/tools/.github/workflows/lint.yml@v1"));
5549        assert!(
5550            of_kind(&found, FindingKind::InvalidUses).is_empty(),
5551            "{found:?}"
5552        );
5553    }
5554
5555    fn b4d_root(name: &str) -> PathBuf {
5556        let root = std::env::temp_dir().join("ci-bill-b4d").join(name);
5557        let _ = fs::remove_dir_all(&root);
5558        fs::create_dir_all(root.join(".github").join("workflows")).unwrap();
5559        root
5560    }
5561
5562    fn write_at(path: &Path, text: &str, crlf_file: bool, bom: bool) {
5563        if let Some(parent) = path.parent() {
5564            fs::create_dir_all(parent).unwrap();
5565        }
5566        let mut body = if crlf_file {
5567            text.replace('\n', "\r\n")
5568        } else {
5569            text.to_string()
5570        };
5571        if bom {
5572            body.insert(0, '\u{feff}');
5573        }
5574        fs::write(path, body).unwrap();
5575    }
5576
5577    const BUILD_USES_LINT: &str = "\
5578name: Build
5579on: push
5580jobs:
5581  build:
5582    runs-on: macos-latest
5583    timeout-minutes: 10
5584    steps:
5585      - uses: ./.github/actions/lint
5586";
5587
5588    const COMPOSITE_FMT: &str = "\
5589name: Lint
5590description: Format the tree
5591runs:
5592  using: composite
5593  steps:
5594    - shell: bash
5595      run: cargo fmt --check
5596";
5597
5598    fn scan_repo(root: &Path) -> (Vec<Workflow>, Vec<Finding>) {
5599        let dir = root.join(".github").join("workflows");
5600        let workflows = scan_dir(&dir).expect("scan workflows");
5601        let found = findings(&workflows).expect("findings");
5602        (workflows, found)
5603    }
5604
5605    fn f1_on<'a>(found: &'a [Finding], suffix: &str) -> Vec<&'a Finding> {
5606        found
5607            .iter()
5608            .filter(|item| item.kind == FindingKind::LintOnRunner && item.path.ends_with(suffix))
5609            .collect()
5610    }
5611
5612    #[test]
5613    fn composite_macos_lint_uses_the_action_line() {
5614        let cases = [
5615            ("plain", "caller.yml", "action.yml", BUILD_USES_LINT, COMPOSITE_FMT, false, false, 7, "lint on macOS"),
5616            ("yaml-ext", "caller.yml", "action.yaml", BUILD_USES_LINT, COMPOSITE_FMT, false, false, 7, "lint on macOS"),
5617            ("crlf", "caller.yml", "action.yml", BUILD_USES_LINT, COMPOSITE_FMT, true, false, 7, "lint on macOS"),
5618            ("bom", "caller.yml", "action.yml", BUILD_USES_LINT, COMPOSITE_FMT, false, true, 7, "lint on macOS"),
5619            (
5620                "reordered",
5621                "caller.yml",
5622                "action.yml",
5623                "name: Build\non: push\njobs:\n  build:\n    timeout-minutes: 10\n    steps:\n      - name: Format\n        uses: ./.github/actions/lint\n    runs-on: macos-latest\n",
5624                "name: Lint\nruns:\n  steps:\n    - shell: bash\n      run: cargo fmt --check\n  using: composite\n",
5625                false,
5626                false,
5627                5,
5628                "lint on macOS",
5629            ),
5630            (
5631                "capitals",
5632                "caller.yml",
5633                "action.yml",
5634                "name: Build\non: push\njobs:\n  build:\n    runs-on: MacOS-latest\n    timeout-minutes: 10\n    steps:\n      - USES: ./.github/actions/lint\n",
5635                "name: Lint\nRUNS:\n  USING: Composite\n  STEPS:\n    - SHELL: bash\n      RUN: cargo fmt --check\n",
5636                false,
5637                false,
5638                6,
5639                "lint on macOS",
5640            ),
5641            (
5642                "windows",
5643                "caller.yml",
5644                "action.yml",
5645                "name: Build\non: push\njobs:\n  build:\n    runs-on: windows-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
5646                COMPOSITE_FMT,
5647                false,
5648                false,
5649                7,
5650                "lint on Windows",
5651            ),
5652            (
5653                "windows-arm",
5654                "caller.yml",
5655                "action.yml",
5656                "name: Build\non: push\njobs:\n  build:\n    runs-on: Windows-11-arm\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
5657                COMPOSITE_FMT,
5658                false,
5659                false,
5660                7,
5661                "lint on Windows",
5662            ),
5663            (
5664                "macos-scalar",
5665                "caller.yml",
5666                "action.yml",
5667                "name: Build\non: push\njobs:\n  build:\n    runs-on: macOS\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
5668                COMPOSITE_FMT,
5669                false,
5670                false,
5671                7,
5672                "lint on macOS",
5673            ),
5674            (
5675                "both-os",
5676                "caller.yml",
5677                "action.yml",
5678                "name: Build\non: push\njobs:\n  build:\n    runs-on: [macos-latest, windows-latest]\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
5679                COMPOSITE_FMT,
5680                false,
5681                false,
5682                7,
5683                "lint on macOS",
5684            ),
5685            (
5686                "quoted",
5687                "caller.yml",
5688                "action.yml",
5689                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: \"./.github/actions/lint\"\n",
5690                COMPOSITE_FMT,
5691                false,
5692                false,
5693                7,
5694                "lint on macOS",
5695            ),
5696            (
5697                "single-quoted",
5698                "caller.yml",
5699                "action.yml",
5700                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: './.github/actions/lint'\n",
5701                COMPOSITE_FMT,
5702                false,
5703                false,
5704                7,
5705                "lint on macOS",
5706            ),
5707            (
5708                "backslash",
5709                "caller.yml",
5710                "action.yml",
5711                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github\\actions\\lint\n",
5712                COMPOSITE_FMT,
5713                false,
5714                false,
5715                7,
5716                "lint on macOS",
5717            ),
5718            (
5719                "dot-segments",
5720                "caller.yml",
5721                "action.yml",
5722                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/../actions/./lint\n",
5723                COMPOSITE_FMT,
5724                false,
5725                false,
5726                7,
5727                "lint on macOS",
5728            ),
5729            (
5730                "double-slash",
5731                "caller.yml",
5732                "action.yml",
5733                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github//actions/lint/\n",
5734                COMPOSITE_FMT,
5735                false,
5736                false,
5737                7,
5738                "lint on macOS",
5739            ),
5740            (
5741                "explicit-action-file",
5742                "caller.yml",
5743                "action.yml",
5744                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint/action.yml\n",
5745                COMPOSITE_FMT,
5746                false,
5747                false,
5748                7,
5749                "lint on macOS",
5750            ),
5751            (
5752                "with-inputs",
5753                "caller.yml",
5754                "action.yml",
5755                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n        with:\n          version: \"1\"\n",
5756                COMPOSITE_FMT,
5757                false,
5758                false,
5759                7,
5760                "lint on macOS",
5761            ),
5762            (
5763                "flow",
5764                "caller.yml",
5765                "action.yml",
5766                "on: push\njobs: {build: {runs-on: macos-latest, timeout-minutes: 10, steps: [{uses: ./.github/actions/lint}]}}\n",
5767                "name: Lint\nruns: {using: composite, steps: [{shell: bash, run: cargo fmt --check}]}\n",
5768                false,
5769                false,
5770                2,
5771                "lint on macOS",
5772            ),
5773            (
5774                "flow-crlf",
5775                "caller.yml",
5776                "action.yml",
5777                "on: push\njobs: {build: {runs-on: macos-latest, timeout-minutes: 10, steps: [{uses: ./.github/actions/lint}]}}\n",
5778                "name: Lint\nruns: {using: composite, steps: [{shell: bash, run: cargo fmt --check}]}\n",
5779                true,
5780                false,
5781                2,
5782                "lint on macOS",
5783            ),
5784            (
5785                "name-only",
5786                "caller.yml",
5787                "action.yml",
5788                BUILD_USES_LINT,
5789                "name: Lint\nruns:\n  using: composite\n  steps:\n    - name: prettier\n      shell: bash\n      run: echo ok\n",
5790                false,
5791                false,
5792                5,
5793                "lint on macOS",
5794            ),
5795            (
5796                "multiline-run",
5797                "caller.yml",
5798                "action.yml",
5799                BUILD_USES_LINT,
5800                "name: Lint\nruns:\n  using: composite\n  steps:\n    - shell: bash\n      run: |\n        cargo fmt --check\n        cargo clippy -- -D warnings\n",
5801                false,
5802                false,
5803                6,
5804                "lint on macOS",
5805            ),
5806            (
5807                "run-wins-over-name",
5808                "caller.yml",
5809                "action.yml",
5810                BUILD_USES_LINT,
5811                "name: Lint\nruns:\n  using: composite\n  steps:\n    - name: fmt\n      run: cargo fmt --check\n      shell: bash\n",
5812                false,
5813                false,
5814                6,
5815                "lint on macOS",
5816            ),
5817        ];
5818        for (name, caller, action_file, workflow, action, crlf_file, bom, line, message) in cases {
5819            let root = b4d_root(name);
5820            write_at(
5821                &root.join(".github").join("workflows").join(caller),
5822                workflow,
5823                crlf_file,
5824                false,
5825            );
5826            write_at(
5827                &root
5828                    .join(".github")
5829                    .join("actions")
5830                    .join("lint")
5831                    .join(action_file),
5832                action,
5833                crlf_file,
5834                bom,
5835            );
5836            let (workflows, found) = scan_repo(&root);
5837            let f1 = f1_on(&found, action_file);
5838            assert_eq!(
5839                f1.len(),
5840                if name == "both-os" { 2 } else { 1 },
5841                "{name}: {found:?}"
5842            );
5843            assert!(
5844                f1.iter()
5845                    .any(|item| item.line == line && item.message == message),
5846                "{name}: {found:?}"
5847            );
5848            assert_eq!(
5849                f1[0].workflow_path.as_deref(),
5850                Some(".github/workflows/caller.yml"),
5851                "{name}"
5852            );
5853            assert_no_limitation(&f1[0].message);
5854            let rows = [usage(".github/workflows/caller.yml", "0.62", "0.062")];
5855            let (text, json) = report_for(&found, &rows);
5856            let section = section_after(&text, "example-org/app .github/workflows/caller.yml");
5857            assert!(
5858                section.contains(&format!("{action_file}:{line}: {message}")),
5859                "{name}: {text}"
5860            );
5861            assert!(!text.contains("Workflows without usage"), "{name}: {text}");
5862            assert_eq!(json["workflows"][0]["net"], "0.620000", "{name}");
5863            let listed = json["workflows"][0]["findings"].as_array().unwrap();
5864            assert!(
5865                listed.iter().any(|item| {
5866                    item["id"] == "F1"
5867                        && item["line"] == line
5868                        && item["message"] == message
5869                        && item["file"].as_str().unwrap().ends_with(action_file)
5870                }),
5871                "{name}: {listed:?}"
5872            );
5873            let build = job_named(&workflows, caller, "build");
5874            assert!(
5875                build.steps.iter().any(|step| step.local_action.is_some()),
5876                "{name}"
5877            );
5878            if name == "plain" {
5879                assert_eq!(build.steps[0].name, "./.github/actions/lint");
5880                assert_eq!(build.steps[0].line, 8);
5881            }
5882        }
5883    }
5884
5885    #[test]
5886    fn composite_action_file_name_variants() {
5887        let root = b4d_root("prefer-yml");
5888        let dir = root.join(".github").join("actions").join("lint");
5889        write_at(
5890            &root.join(".github/workflows/caller.yml"),
5891            BUILD_USES_LINT,
5892            false,
5893            false,
5894        );
5895        write_at(
5896            &dir.join("action.yml"),
5897            "name: Quiet\nruns:\n  using: composite\n  steps:\n    - run: echo hi\n      shell: bash\n",
5898            false,
5899            false,
5900        );
5901        write_at(&dir.join("action.yaml"), COMPOSITE_FMT, false, false);
5902        let (_, found) = scan_repo(&root);
5903        assert!(f1_on(&found, "action.yml").is_empty(), "{found:?}");
5904        assert!(f1_on(&found, "action.yaml").is_empty(), "{found:?}");
5905
5906        let root = b4d_root("capital-filename");
5907        write_at(
5908            &root.join(".github/workflows/caller.yml"),
5909            BUILD_USES_LINT,
5910            false,
5911            false,
5912        );
5913        write_at(
5914            &root.join(".github/actions/lint/Action.YML"),
5915            COMPOSITE_FMT,
5916            false,
5917            false,
5918        );
5919        let (_, found) = scan_repo(&root);
5920        let f1 = f1_on(&found, "Action.YML");
5921        assert_eq!(f1.len(), 1, "{found:?}");
5922        assert_eq!(f1[0].line, 7);
5923        assert_eq!(f1[0].message, "lint on macOS");
5924
5925        let root = b4d_root("repo-root");
5926        write_at(
5927            &root.join(".github/workflows/caller.yml"),
5928            "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./\n",
5929            false,
5930            false,
5931        );
5932        write_at(&root.join("action.yml"), COMPOSITE_FMT, false, false);
5933        let (_, found) = scan_repo(&root);
5934        let f1 = f1_on(&found, "action.yml");
5935        assert_eq!(f1.len(), 1, "{found:?}");
5936        assert_eq!(f1[0].line, 7);
5937        assert!(
5938            f1[0].path.parent().unwrap() == root.as_path(),
5939            "{}",
5940            f1[0].path.display()
5941        );
5942    }
5943
5944    #[test]
5945    fn missing_local_action_is_a_finding() {
5946        let cases = [
5947            ("missing-dir", false, "caller.yml", BUILD_USES_LINT, 8, "./.github/actions/lint"),
5948            (
5949                "missing-dir-crlf",
5950                true,
5951                "caller.yml",
5952                BUILD_USES_LINT,
5953                8,
5954                "./.github/actions/lint",
5955            ),
5956            (
5957                "missing-dir-yaml",
5958                false,
5959                "caller.yaml",
5960                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./tools/lint\n",
5961                8,
5962                "./tools/lint",
5963            ),
5964            (
5965                "missing-quoted",
5966                false,
5967                "caller.yml",
5968                "name: Build\non: push\njobs:\n  build:\n    runs-on: MACOS-latest\n    timeout-minutes: 10\n    steps:\n      - uses: \"./.github/actions/lint\"\n",
5969                8,
5970                "./.github/actions/lint",
5971            ),
5972            (
5973                "missing-flow",
5974                false,
5975                "caller.yml",
5976                "on: push\njobs: {build: {runs-on: macos-latest, timeout-minutes: 10, steps: [{uses: ./tools/lint}]}}\n",
5977                2,
5978                "./tools/lint",
5979            ),
5980            (
5981                "missing-backslash",
5982                false,
5983                "caller.yml",
5984                "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./tools\\lint\n",
5985                8,
5986                "./tools\\lint",
5987            ),
5988        ];
5989        for (name, crlf_file, caller, workflow, line, spec) in cases {
5990            let root = b4d_root(name);
5991            write_at(
5992                &root.join(".github").join("workflows").join(caller),
5993                workflow,
5994                crlf_file,
5995                false,
5996            );
5997            let caught = std::panic::catch_unwind(|| scan_repo(&root));
5998            let (_, found) = caught.expect(name);
5999            let missing: Vec<_> = found
6000                .iter()
6001                .filter(|item| item.kind == FindingKind::ActionNotFound)
6002                .collect();
6003            assert_eq!(missing.len(), 1, "{name}: {found:?}");
6004            assert_eq!(missing[0].line, line, "{name}");
6005            assert_eq!(missing[0].kind.code(), "F12");
6006            assert_eq!(
6007                missing[0].message,
6008                format!("action not found: job build uses {spec}"),
6009                "{name}"
6010            );
6011            assert!(missing[0].path.ends_with(caller), "{name}");
6012            assert_no_limitation(&missing[0].message);
6013            assert!(f1_on(&found, "action.yml").is_empty(), "{name}");
6014            let (text, json) = report_for(&found, &[usage(&caller_key(caller), "0.06", "0.006")]);
6015            assert!(text.contains("F12 "), "{name}: {text}");
6016            assert!(text.contains(&missing[0].message), "{name}: {text}");
6017            assert!(!text.contains("Workflows without usage"), "{name}: {text}");
6018            let listed = json["workflows"][0]["findings"].as_array().unwrap();
6019            assert!(
6020                listed.iter().any(|item| item["id"] == "F12"),
6021                "{name}: {listed:?}"
6022            );
6023        }
6024
6025        let root = b4d_root("empty-dir");
6026        write_at(
6027            &root.join(".github/workflows/caller.yml"),
6028            BUILD_USES_LINT,
6029            false,
6030            false,
6031        );
6032        fs::create_dir_all(root.join(".github/actions/lint")).unwrap();
6033        let (_, found) = scan_repo(&root);
6034        let missing = of_kind(&found, FindingKind::ActionNotFound);
6035        assert_eq!(missing.len(), 1, "{found:?}");
6036        assert_eq!(missing[0].line, 8);
6037        assert!(missing[0].message.contains("./.github/actions/lint"));
6038
6039        let root = b4d_root("readme-only");
6040        write_at(
6041            &root.join(".github/workflows/caller.yml"),
6042            BUILD_USES_LINT,
6043            false,
6044            false,
6045        );
6046        write_at(
6047            &root.join(".github/actions/lint/README.md"),
6048            "not an action\n",
6049            false,
6050            false,
6051        );
6052        let (_, found) = scan_repo(&root);
6053        assert_eq!(
6054            of_kind(&found, FindingKind::ActionNotFound).len(),
6055            1,
6056            "{found:?}"
6057        );
6058
6059        let outside = scan_yaml(
6060            Path::new("fixture.yml"),
6061            "jobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 1\n    steps:\n      - uses: ./lint\n",
6062        )
6063        .unwrap();
6064        let found = std::panic::catch_unwind(|| findings(&[outside]));
6065        let found = found.expect("findings panicked").expect("findings");
6066        assert_eq!(
6067            of_kind(&found, FindingKind::ActionNotFound).len(),
6068            1,
6069            "{found:?}"
6070        );
6071        assert!(
6072            of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6073            "{found:?}"
6074        );
6075    }
6076
6077    #[test]
6078    fn composite_without_lint_and_empty_fields_stay_quiet() {
6079        let bodies = [
6080            ("empty-file", ""),
6081            ("name-only", "name: Lint\n"),
6082            ("missing-runs", "name: Lint\ndescription: none\n"),
6083            ("missing-using", "name: Lint\nruns:\n  steps:\n    - run: cargo fmt --check\n"),
6084            ("empty-using", "name: Lint\nruns:\n  using: \"\"\n  steps:\n    - run: cargo fmt --check\n"),
6085            ("using-list", "name: Lint\nruns:\n  using: [composite]\n  steps:\n    - run: cargo fmt --check\n"),
6086            ("empty-steps", "name: Lint\nruns:\n  using: composite\n  steps: []\n"),
6087            ("missing-steps", "name: Lint\nruns:\n  using: composite\n"),
6088            ("null-steps", "name: Lint\nruns:\n  using: composite\n  steps:\n"),
6089            ("empty-run", "name: Lint\nruns:\n  using: composite\n  steps:\n    - run: \"\"\n      shell: bash\n"),
6090            ("null-run", "name: Lint\nruns:\n  using: composite\n  steps:\n    - run: ~\n      name: \"\"\n"),
6091            ("checkout-step", "name: Lint\nruns:\n  using: composite\n  steps:\n    - uses: actions/checkout@v4\n"),
6092            ("echo", "name: Lint\nruns:\n  using: composite\n  steps:\n    - run: echo hi\n      shell: bash\n"),
6093            ("spaced-using", "name: Lint\nruns:\n  using: \" composite \"\n  steps:\n    - run: echo hi\n"),
6094        ];
6095        for (name, body) in bodies {
6096            let root = b4d_root(name);
6097            write_at(
6098                &root.join(".github/workflows/caller.yml"),
6099                BUILD_USES_LINT,
6100                false,
6101                false,
6102            );
6103            write_at(
6104                &root.join(".github/actions/lint/action.yml"),
6105                body,
6106                false,
6107                false,
6108            );
6109            let caught = std::panic::catch_unwind(|| scan_repo(&root));
6110            let (_, found) = caught.expect(name);
6111            assert!(
6112                of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6113                "{name}: {found:?}"
6114            );
6115            assert!(
6116                of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6117                "{name}: {found:?}"
6118            );
6119            for item in &found {
6120                assert_no_limitation(&item.message);
6121            }
6122        }
6123
6124        let negatives = [
6125            ("ubuntu", "ubuntu-latest"),
6126            ("self-hosted", "[self-hosted, macos-latest]"),
6127            (
6128                "self-hosted-flow",
6129                "{group: large, labels: [self-hosted, macos]}",
6130            ),
6131        ];
6132        for (name, runner) in negatives {
6133            let root = b4d_root(name);
6134            let workflow = format!(
6135                "name: Build\non: push\njobs:\n  build:\n    runs-on: {runner}\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n"
6136            );
6137            write_at(
6138                &root.join(".github/workflows/caller.yml"),
6139                &workflow,
6140                false,
6141                false,
6142            );
6143            write_at(
6144                &root.join(".github/actions/lint/action.yml"),
6145                COMPOSITE_FMT,
6146                false,
6147                false,
6148            );
6149            let (_, found) = scan_repo(&root);
6150            assert!(
6151                of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6152                "{name}: {found:?}"
6153            );
6154        }
6155
6156        let root = b4d_root("no-runner");
6157        write_at(
6158            &root.join(".github/workflows/caller.yml"),
6159            "name: Build\non: push\njobs:\n  build:\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
6160            false,
6161            false,
6162        );
6163        write_at(
6164            &root.join(".github/actions/lint/action.yml"),
6165            COMPOSITE_FMT,
6166            false,
6167            false,
6168        );
6169        let (_, found) = scan_repo(&root);
6170        assert!(
6171            of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6172            "{found:?}"
6173        );
6174        assert!(
6175            of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6176            "{found:?}"
6177        );
6178
6179        let root = b4d_root("remote-step");
6180        write_at(
6181            &root.join(".github/workflows/caller.yml"),
6182            "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@v4\n      - uses: docker://alpine\n      - uses: ./.github/workflows/lint.yml\n      - uses: ./../outside\n",
6183            false,
6184            false,
6185        );
6186        let (_, found) = scan_repo(&root);
6187        assert!(
6188            of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6189            "{found:?}"
6190        );
6191        assert!(
6192            of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6193            "{found:?}"
6194        );
6195    }
6196
6197    #[test]
6198    fn docker_and_node_actions_are_shown_not_unpacked() {
6199        let kinds = [
6200            "docker", "Docker", "node12", "node16", "node20", "NODE20", "node24", "node",
6201        ];
6202        for (index, using) in kinds.iter().enumerate() {
6203            let root = b4d_root(&format!("show-{index}"));
6204            write_at(
6205                &root.join(".github/workflows/caller.yml"),
6206                BUILD_USES_LINT,
6207                false,
6208                false,
6209            );
6210            let body = format!(
6211                "name: Tool\nruns:\n  using: {using}\n  image: Dockerfile\n  main: index.js\n  steps:\n    - run: cargo fmt --check\n      shell: bash\n    - uses: ./.github/actions/inner\n"
6212            );
6213            write_at(
6214                &root.join(".github/actions/lint/action.yml"),
6215                &body,
6216                false,
6217                false,
6218            );
6219            write_at(
6220                &root.join(".github/actions/lint/Dockerfile"),
6221                "FROM alpine\nRUN cargo fmt --check\n",
6222                false,
6223                false,
6224            );
6225            write_at(
6226                &root.join(".github/actions/inner/action.yml"),
6227                COMPOSITE_FMT,
6228                false,
6229                false,
6230            );
6231            let (workflows, found) = scan_repo(&root);
6232            assert!(
6233                of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6234                "{using}: {found:?}"
6235            );
6236            assert!(
6237                of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6238                "{using}: {found:?}"
6239            );
6240            let step = &job_named(&workflows, "caller.yml", "build").steps[0];
6241            assert_eq!(step.name, "./.github/actions/lint");
6242            assert!(step.local_action.is_some(), "{using}");
6243            let rows = [usage(".github/workflows/caller.yml", "0.06", "0.006")];
6244            let ranking = rank(&rows, None).unwrap();
6245            let json: serde_json::Value = serde_json::from_str(
6246                render_json(
6247                    &ranking.workflows,
6248                    &ranking.bill,
6249                    Some(&found),
6250                    None,
6251                    None,
6252                    &workflows,
6253                )
6254                .trim_end(),
6255            )
6256            .unwrap();
6257            assert_eq!(
6258                json["workflows"][0]["steps"][0]["name"],
6259                "./.github/actions/lint"
6260            );
6261            assert_eq!(json["workflows"][0]["steps"][0]["line"], 8);
6262            assert!(json["workflows"][0]["findings"]
6263                .as_array()
6264                .unwrap()
6265                .is_empty());
6266        }
6267    }
6268
6269    #[test]
6270    fn nested_composite_lint_points_at_the_inner_action() {
6271        let root = b4d_root("nested");
6272        write_at(
6273            &root.join(".github/workflows/caller.yml"),
6274            BUILD_USES_LINT,
6275            false,
6276            false,
6277        );
6278        write_at(
6279            &root.join(".github/actions/lint/action.yml"),
6280            "name: Outer\nruns:\n  using: composite\n  steps:\n    - uses: ./.github/actions/inner\n",
6281            false,
6282            false,
6283        );
6284        write_at(
6285            &root.join(".github/actions/inner/action.yml"),
6286            "name: Inner\nruns:\n  using: composite\n  steps:\n    - run: cargo fmt --check\n      shell: bash\n",
6287            false,
6288            false,
6289        );
6290        let (_, found) = scan_repo(&root);
6291        let f1 = f1_on(&found, "inner/action.yml");
6292        assert_eq!(f1.len(), 1, "{found:?}");
6293        assert_eq!(f1[0].line, 5);
6294        assert_eq!(f1[0].message, "lint on macOS");
6295        assert_eq!(
6296            f1[0].workflow_path.as_deref(),
6297            Some(".github/workflows/caller.yml")
6298        );
6299        let (text, _) = report_for(
6300            &found,
6301            &[usage(".github/workflows/caller.yml", "0.06", "0.006")],
6302        );
6303        // Reports show paths with `/` on every OS.
6304        assert!(text.contains("inner/action.yml:5: lint on macOS"), "{text}");
6305        assert!(!text.contains("Workflows without usage"), "{text}");
6306
6307        let root = b4d_root("nested-missing");
6308        write_at(
6309            &root.join(".github/workflows/caller.yml"),
6310            BUILD_USES_LINT,
6311            false,
6312            false,
6313        );
6314        write_at(
6315            &root.join(".github/actions/lint/action.yml"),
6316            "name: Outer\nruns:\n  using: composite\n  steps:\n    - uses: ./tools/missing\n",
6317            false,
6318            false,
6319        );
6320        let (_, found) = scan_repo(&root);
6321        let missing = of_kind(&found, FindingKind::ActionNotFound);
6322        assert_eq!(missing.len(), 1, "{found:?}");
6323        assert!(
6324            missing[0].path.ends_with("lint/action.yml"),
6325            "{}",
6326            missing[0].path.display()
6327        );
6328        assert_eq!(missing[0].line, 5);
6329        assert_eq!(
6330            missing[0].message,
6331            "action not found: job build uses ./tools/missing"
6332        );
6333
6334        let root = b4d_root("cycle");
6335        write_at(
6336            &root.join(".github/workflows/caller.yml"),
6337            "name: Build\non: push\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/a\n",
6338            false,
6339            false,
6340        );
6341        write_at(
6342            &root.join(".github/actions/a/action.yml"),
6343            "name: A\nruns:\n  using: composite\n  steps:\n    - uses: ./.github/actions/b\n",
6344            false,
6345            false,
6346        );
6347        write_at(
6348            &root.join(".github/actions/b/action.yml"),
6349            "name: B\nruns:\n  using: composite\n  steps:\n    - run: echo hi\n      shell: bash\n    - uses: ./.github/actions/a\n",
6350            false,
6351            false,
6352        );
6353        let caught = std::panic::catch_unwind(|| scan_repo(&root));
6354        let (_, found) = caught.expect("cycle panicked");
6355        let cycles = of_kind(&found, FindingKind::ActionCycle);
6356        assert_eq!(cycles.len(), 1, "{found:?}");
6357        assert_eq!(cycles[0].kind.code(), "F13");
6358        assert!(
6359            cycles[0].path.ends_with("b/action.yml"),
6360            "{}",
6361            cycles[0].path.display()
6362        );
6363        assert_eq!(cycles[0].line, 7);
6364        assert_eq!(
6365            cycles[0].message,
6366            "composite action cycle: ./.github/actions/a -> ./.github/actions/b -> ./.github/actions/a"
6367        );
6368        assert_no_limitation(&cycles[0].message);
6369    }
6370
6371    #[test]
6372    fn invalid_action_yaml_is_an_error_without_the_body() {
6373        let root = b4d_root("bad-yaml");
6374        write_at(
6375            &root.join(".github/workflows/caller.yml"),
6376            BUILD_USES_LINT,
6377            false,
6378            false,
6379        );
6380        let token = "TOKEN_NOT_IN_ERRORS";
6381        write_at(
6382            &root.join(".github/actions/lint/action.yml"),
6383            &format!("runs: [\n{token}\n"),
6384            false,
6385            false,
6386        );
6387        let dir = root.join(".github/workflows");
6388        let workflows = scan_dir(&dir).unwrap();
6389        let err = findings(&workflows).expect_err("invalid action yaml");
6390        let text = err.to_string();
6391        assert!(text.contains("invalid YAML"), "{text}");
6392        assert!(!text.contains(token), "{text}");
6393        assert!(err.path.ends_with("action.yml"), "{}", err.path.display());
6394    }
6395
6396    #[test]
6397    fn composite_matrix_and_callee_keep_the_action_line() {
6398        let root = b4d_root("matrix");
6399        write_at(
6400            &root.join(".github/workflows/caller.yml"),
6401            "name: Build\non: push\njobs:\n  build:\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 10\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest]\n    steps:\n      - uses: ./.github/actions/lint\n",
6402            false,
6403            false,
6404        );
6405        write_at(
6406            &root.join(".github/actions/lint/action.yml"),
6407            COMPOSITE_FMT,
6408            false,
6409            false,
6410        );
6411        let (_, found) = scan_repo(&root);
6412        let f1 = f1_on(&found, "action.yml");
6413        assert_eq!(f1.len(), 1, "{found:?}");
6414        assert_eq!(f1[0].line, 7);
6415        assert_eq!(
6416            f1[0].message,
6417            "lint on macOS (runs-on line 5, axis os line 9)"
6418        );
6419
6420        let root = b4d_root("two-steps");
6421        write_at(
6422            &root.join(".github/workflows/caller.yml"),
6423            BUILD_USES_LINT,
6424            false,
6425            false,
6426        );
6427        write_at(
6428            &root.join(".github/actions/lint/action.yml"),
6429            "name: Lint\nruns:\n  using: composite\n  steps:\n    - run: cargo fmt --check\n      shell: bash\n    - name: eslint\n      run: echo ok\n      shell: bash\n",
6430            false,
6431            false,
6432        );
6433        let (_, found) = scan_repo(&root);
6434        let f1 = f1_on(&found, "action.yml");
6435        assert_eq!(f1.len(), 2, "{found:?}");
6436        assert!(f1.iter().any(|item| item.line == 5), "{found:?}");
6437        assert!(f1.iter().any(|item| item.line == 7), "{found:?}");
6438
6439        let root = b4d_root("callee");
6440        write_at(
6441            &root.join(".github/workflows/caller.yml"),
6442            "name: Caller\non: push\njobs:\n  call:\n    uses: ./.github/workflows/called.yml\n",
6443            false,
6444            false,
6445        );
6446        write_at(
6447            &root.join(".github/workflows/called.yml"),
6448            "name: Called\non: workflow_call\njobs:\n  build:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
6449            false,
6450            false,
6451        );
6452        write_at(
6453            &root.join(".github/actions/lint/action.yml"),
6454            COMPOSITE_FMT,
6455            false,
6456            false,
6457        );
6458        let (_, found) = scan_repo(&root);
6459        let on_caller = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
6460        assert_eq!(on_caller.len(), 1, "{found:?}");
6461        assert_eq!(on_caller[0].line, 7);
6462        assert!(on_caller[0].path.ends_with("action.yml"));
6463        assert_eq!(on_caller[0].message, "lint on macOS");
6464        let rows = [
6465            usage(".github/workflows/caller.yml", "0.06", "0.006"),
6466            usage(".github/workflows/called.yml", "1.00", "0.100"),
6467        ];
6468        let (text, _) = report_for(&found, &rows);
6469        let caller_at = text
6470            .find("example-org/app .github/workflows/caller.yml")
6471            .unwrap();
6472        assert!(
6473            text[caller_at..].contains("action.yml:7: lint on macOS"),
6474            "{text}"
6475        );
6476        assert!(text[..caller_at].contains("0.06") || text[caller_at..].contains("0.06"));
6477    }
6478
6479    #[test]
6480    fn job_lint_and_composite_lint_are_both_reported() {
6481        let root = b4d_root("both-signals");
6482        write_at(
6483            &root.join(".github/workflows/caller.yml"),
6484            "name: Build\non: push\njobs:\n  lint:\n    runs-on: macos-latest\n    timeout-minutes: 10\n    steps:\n      - uses: ./.github/actions/lint\n",
6485            false,
6486            false,
6487        );
6488        write_at(
6489            &root.join(".github/actions/lint/action.yml"),
6490            COMPOSITE_FMT,
6491            false,
6492            false,
6493        );
6494        let (_, found) = scan_repo(&root);
6495        let f1 = of_kind(&found, FindingKind::LintOnRunner);
6496        assert_eq!(f1.len(), 2, "{found:?}");
6497        assert!(f1
6498            .iter()
6499            .any(|item| item.path.ends_with("caller.yml") && item.line == 5));
6500        assert!(f1
6501            .iter()
6502            .any(|item| item.path.ends_with("action.yml") && item.line == 7));
6503    }
6504}