1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! Matrix strategy auditor for GitHub Actions
//!
//! Checks for:
//! - Large matrix strategies (>9 combinations) without `fail-fast: false` → Warning, PC022
//! - Empty `matrix: include:` entries → Info
use crate::error::Result;
use crate::models::{rule_codes, Issue, Pipeline, Provider, Severity};
use serde_yaml::Value;
/// Audit a pipeline for matrix strategy configurations.
pub fn audit(pipeline: &Pipeline) -> Result<Vec<Issue>> {
let mut issues = Vec::new();
// Matrix is a GitHub Actions feature
if pipeline.provider != Provider::GitHubActions {
return Ok(issues);
}
let doc: Value = match serde_yaml::from_str(&pipeline.source) {
Ok(d) => d,
Err(_) => return Ok(issues),
};
if let Some(jobs) = doc.get("jobs").and_then(|j| j.as_mapping()) {
for (job_id_val, job_val) in jobs {
let job_id = match job_id_val.as_str() {
Some(s) => s,
None => continue,
};
let job_map = match job_val.as_mapping() {
Some(m) => m,
None => continue,
};
// Check for strategy.matrix
if let Some(strategy) = job_map.get("strategy").and_then(|v| v.as_mapping()) {
if let Some(matrix) = strategy.get("matrix").and_then(|v| v.as_mapping()) {
// Count matrix combinations
let combination_count = count_matrix_combinations(matrix);
// Check for fail-fast: false
let has_fail_fast_false =
strategy.get("fail-fast").and_then(|v| v.as_bool()) == Some(false);
// PC022: Large matrix without fail-fast: false
if combination_count > 9 && !has_fail_fast_false {
let (line, col) = pipeline.find_job_line(job_id, "matrix");
issues.push(Issue::for_job_with_code(
Severity::Warning,
&format!(
"Job '{}' has a matrix with {} combinations but no 'fail-fast: false'",
job_id, combination_count
),
job_id,
line,
col,
Some(
"Add 'fail-fast: false' to the strategy to prevent cancelling other matrix jobs on failure"
.to_string(),
),
rule_codes::LARGE_MATRIX_NO_FAILFAST,
));
}
// Check for empty matrix: include entries
if let Some(include) = matrix.get("include").and_then(|v| v.as_sequence()) {
if include.is_empty() {
let (line, col) = pipeline.find_job_line(job_id, "include");
issues.push(Issue::for_job_with_code(
Severity::Info,
&format!("Job '{}' has an empty 'matrix: include:' list", job_id),
job_id,
line,
col,
None,
rule_codes::LARGE_MATRIX_NO_FAILFAST,
));
}
}
}
}
}
}
Ok(issues)
}
/// Count the total number of matrix combinations by computing the product of all dimensions.
/// If no dimensions are found (e.g., only `include:`), returns 0.
fn count_matrix_combinations(matrix: &serde_yaml::Mapping) -> usize {
let mut count: usize = 1;
for (key, value) in matrix {
let key_str = match key.as_str() {
Some(s) => s,
None => continue,
};
// Skip special keys
if key_str == "include" || key_str == "exclude" {
continue;
}
if let Some(arr) = value.as_sequence() {
let len = arr.len();
if len == 0 {
return 0;
}
count = count.saturating_mul(len);
}
}
// If no dimensions were found (count still 1 and no dimension keys exist)
if count == 1
&& !matrix.keys().any(|k| {
k.as_str()
.map(|s| s != "include" && s != "exclude")
.unwrap_or(false)
})
{
return 0;
}
count
}