1use crate::commands::{validate_directory, DirectoryValidation};
15use crate::frontmatter::{yaml_load_config, Yaml};
16use crate::output::relationship_sarif_parts;
17use crate::portfolio::portfolio_from_corpus;
18use crate::relationships::{
19 corpus_items, relationship_severity, validate_relationships, RelationshipValidation,
20};
21use crate::review::{review_from_portfolio, ReviewReport, PRIORITY_BROKEN_RELATIONSHIP};
22use crate::validate::find_config_file;
23
24pub const ENFORCEMENT_BLOCKING: &str = "blocking";
25pub const ENFORCEMENT_ADVISORY: &str = "advisory";
26
27pub const SOURCE_VALIDATE: &str = "validate";
28pub const SOURCE_RELATIONSHIPS: &str = "relationships";
29pub const SOURCE_REVIEW: &str = "review";
30pub const SOURCE_SENTRY: &str = "sentry";
31
32#[derive(Debug)]
39pub struct MalformedConfig {
40 pub config_path: String,
41 pub reason: String,
42}
43
44impl MalformedConfig {
45 pub fn message(&self) -> String {
46 format!(
47 "malformed repository config {}: {}",
48 self.config_path, self.reason
49 )
50 }
51}
52
53#[derive(Debug, Default)]
60pub struct EnforcementPolicy {
61 pub blocking: Vec<String>,
62 pub advisory: Vec<String>,
63 pub off: Vec<String>,
64}
65
66impl EnforcementPolicy {
67 fn classify(&self, code: &str, default: &'static str) -> Option<&'static str> {
68 if self.off.iter().any(|c| c == code) {
69 return None;
70 }
71 if self.blocking.iter().any(|c| c == code) {
72 return Some(ENFORCEMENT_BLOCKING);
73 }
74 if self.advisory.iter().any(|c| c == code) {
75 return Some(ENFORCEMENT_ADVISORY);
76 }
77 Some(default)
78 }
79}
80
81fn parse_config_pairs(
92 config_path: &std::path::Path,
93) -> Result<Option<Vec<(Yaml, Yaml)>>, MalformedConfig> {
94 let display = config_path.to_string_lossy().into_owned();
95 let text = std::fs::read_to_string(config_path).map_err(|e| MalformedConfig {
96 config_path: display.clone(),
97 reason: format!("invalid YAML: {e}"),
98 })?;
99 match yaml_load_config(&text) {
100 Ok(Yaml::Map(pairs)) => Ok(Some(pairs)),
101 Ok(_) => Ok(None),
102 Err(problem) => Err(MalformedConfig {
103 config_path: display,
104 reason: format!("invalid YAML: {problem}"),
105 }),
106 }
107}
108
109fn yaml_get<'a>(pairs: &'a [(Yaml, Yaml)], name: &str) -> Option<&'a Yaml> {
110 pairs.iter().find_map(|(k, v)| match k {
111 Yaml::Str(s) if s == name => Some(v),
112 _ => None,
113 })
114}
115
116fn parse_code_list(
119 config_path: &std::path::Path,
120 value: Option<&Yaml>,
121 where_: &str,
122) -> Result<Vec<String>, MalformedConfig> {
123 let malformed = || MalformedConfig {
124 config_path: config_path.to_string_lossy().into_owned(),
125 reason: format!("'{where_}' must be a list of finding-code strings"),
126 };
127 match value {
128 None | Some(Yaml::Null) => Ok(Vec::new()),
129 Some(Yaml::List(items)) => {
130 let mut out = Vec::with_capacity(items.len());
131 for item in items {
132 match item {
133 Yaml::Str(s) => out.push(s.clone()),
134 _ => return Err(malformed()),
135 }
136 }
137 Ok(out)
138 }
139 Some(_) => Err(malformed()),
140 }
141}
142
143pub fn load_enforcement_policy(start_dir: &str) -> Result<EnforcementPolicy, MalformedConfig> {
148 let Some(config_path) = find_config_file(start_dir) else {
149 return Ok(EnforcementPolicy::default());
150 };
151 let Some(pairs) = parse_config_pairs(&config_path)? else {
152 return Ok(EnforcementPolicy::default());
153 };
154 let section = match yaml_get(&pairs, "enforcement") {
155 None | Some(Yaml::Null) => return Ok(EnforcementPolicy::default()),
156 Some(Yaml::Map(section)) => section,
157 Some(_) => {
158 return Err(MalformedConfig {
159 config_path: config_path.to_string_lossy().into_owned(),
160 reason: "'enforcement' must be a mapping".to_string(),
161 })
162 }
163 };
164 let blocking = parse_code_list(&config_path, yaml_get(section, "blocking"), "enforcement.blocking")?;
165 let advisory = parse_code_list(&config_path, yaml_get(section, "advisory"), "enforcement.advisory")?;
166 let off_value = yaml_get(section, "off").or_else(|| {
167 section
168 .iter()
169 .find_map(|(k, v)| matches!(k, Yaml::Bool(false)).then_some(v))
170 });
171 let off = parse_code_list(&config_path, off_value, "enforcement.off")?;
172 Ok(EnforcementPolicy {
173 blocking,
174 advisory,
175 off,
176 })
177}
178
179fn yaml_key_display(key: &Yaml) -> String {
182 match key {
183 Yaml::Str(s) => s.clone(),
184 Yaml::Bool(true) => "True".to_string(),
185 Yaml::Bool(false) => "False".to_string(),
186 Yaml::Int(i) => i.to_string(),
187 Yaml::Null => "None".to_string(),
188 other => format!("{other:?}"),
189 }
190}
191
192fn check_severity_map(
195 config_path: &std::path::Path,
196 value: Option<&Yaml>,
197 where_: &str,
198 allowed: &[&str],
199) -> Result<(), MalformedConfig> {
200 let section = match value {
201 None | Some(Yaml::Null) => return Ok(()),
202 Some(Yaml::Map(section)) => section,
203 Some(_) => {
204 return Err(MalformedConfig {
205 config_path: config_path.to_string_lossy().into_owned(),
206 reason: format!("'{where_}' must be a mapping"),
207 })
208 }
209 };
210 for (name, sev) in section {
211 let sev_text = match sev {
212 Yaml::Bool(false) => Some("off".to_string()),
213 Yaml::Bool(true) => Some("on".to_string()),
214 Yaml::Str(s) => Some(s.clone()),
215 _ => None,
216 };
217 let ok = matches!(name, Yaml::Str(_))
218 && sev_text
219 .as_deref()
220 .map(|s| allowed.contains(&s))
221 .unwrap_or(false);
222 if !ok {
223 return Err(MalformedConfig {
224 config_path: config_path.to_string_lossy().into_owned(),
225 reason: format!(
226 "'{where_}.{}' must map a name to one of {}",
227 yaml_key_display(name),
228 allowed.join(", ")
229 ),
230 });
231 }
232 }
233 Ok(())
234}
235
236pub fn check_overrides(start_dir: &str) -> Result<(), MalformedConfig> {
242 let Some(config_path) = find_config_file(start_dir) else {
243 return Ok(());
244 };
245 let Some(pairs) = parse_config_pairs(&config_path)? else {
246 return Ok(());
247 };
248 let section = match yaml_get(&pairs, "validation") {
249 None | Some(Yaml::Null) => return Ok(()),
250 Some(Yaml::Map(section)) => section,
251 Some(_) => {
252 return Err(MalformedConfig {
253 config_path: config_path.to_string_lossy().into_owned(),
254 reason: "'validation' must be a mapping".to_string(),
255 })
256 }
257 };
258 check_severity_map(
259 &config_path,
260 yaml_get(section, "rules"),
261 "validation.rules",
262 &["error", "warning", "off"],
263 )?;
264 check_severity_map(
265 &config_path,
266 yaml_get(section, "types"),
267 "validation.types",
268 &["error", "warning"],
269 )
270}
271
272#[derive(Debug)]
277pub struct GateFinding {
278 pub source: &'static str,
279 pub code: String,
280 pub severity: String,
281 pub enforcement: &'static str,
282 pub path: String,
283 pub line: Option<i64>,
284 pub message: String,
285}
286
287pub struct GateReport {
288 pub directory: String,
289 pub recursive: bool,
290 pub findings: Vec<GateFinding>,
291 pub code_coverage: Option<CodeCoverage>,
292}
293
294pub struct CodeCoverage {
295 pub live_decisions: usize,
296 pub classified_decisions: usize,
297 pub unclassified_decisions: usize,
298 pub eligible_decisions: usize,
299 pub constrained_decisions: usize,
300 pub active_rules: usize,
301 pub corpus_adoption_percent: f64,
302 pub eligible_coverage_percent: f64,
303}
304
305impl GateReport {
306 pub fn blocking(&self) -> Vec<&GateFinding> {
307 self.findings
308 .iter()
309 .filter(|f| f.enforcement == ENFORCEMENT_BLOCKING)
310 .collect()
311 }
312
313 pub fn advisory(&self) -> Vec<&GateFinding> {
314 self.findings
315 .iter()
316 .filter(|f| f.enforcement == ENFORCEMENT_ADVISORY)
317 .collect()
318 }
319
320 pub fn ok(&self) -> bool {
322 self.blocking().is_empty()
323 }
324}
325
326type RawFinding = (String, String, String, Option<i64>, String);
332
333fn validate_findings(result: &DirectoryValidation) -> Vec<RawFinding> {
336 let mut out = Vec::new();
337 for file in &result.files {
338 for issue in &file.issues {
339 out.push((
340 issue.code.clone(),
341 issue.severity.to_string(),
342 file.path.clone(),
343 issue.line,
344 issue.message.clone(),
345 ));
346 }
347 }
348 if let Some(okf) = &result.okf {
349 for finding in &okf.findings {
350 out.push((
351 finding.code.clone(),
352 finding.severity.clone(),
353 finding.path.clone(),
354 None,
355 finding.message.clone(),
356 ));
357 }
358 }
359 out
360}
361
362pub fn build_gate(directory: &str, recursive: bool) -> Result<GateReport, MalformedConfig> {
366 build_gate_with_code(directory, recursive, None)
367}
368
369pub struct CodeGateOptions<'a> {
370 pub repository: &'a str,
371 pub base: Option<&'a str>,
372 pub full_tree: bool,
373}
374
375pub fn build_gate_with_code(
376 directory: &str,
377 recursive: bool,
378 code: Option<CodeGateOptions<'_>>,
379) -> Result<GateReport, MalformedConfig> {
380 let policy = load_enforcement_policy(directory)?;
384 check_overrides(directory)?;
385
386 let validation = validate_directory(directory, recursive);
387 let relationships: RelationshipValidation = validate_relationships(directory, recursive);
388 let items = corpus_items(directory, recursive);
389 let portfolio = portfolio_from_corpus(directory, &items, recursive);
390 let review: ReviewReport = review_from_portfolio(directory, portfolio, recursive);
391
392 let mut findings: Vec<GateFinding> = Vec::new();
393 let mut code_coverage = None;
394 let mut add = |source: &'static str,
395 code: String,
396 severity: String,
397 path: String,
398 line: Option<i64>,
399 message: String,
400 default: &'static str| {
401 if let Some(enforcement) = policy.classify(&code, default) {
402 findings.push(GateFinding {
403 source,
404 code,
405 severity,
406 enforcement,
407 path,
408 line,
409 message,
410 });
411 }
412 };
413
414 for (code, severity, path, line, message) in validate_findings(&validation) {
417 let default = if severity == "error" {
418 ENFORCEMENT_BLOCKING
419 } else {
420 ENFORCEMENT_ADVISORY
421 };
422 add(SOURCE_VALIDATE, code, severity, path, line, message, default);
423 }
424
425 for issue in &relationships.issues {
429 let (message, uri) = relationship_sarif_parts(issue);
430 let severity = relationship_severity(&issue.code).to_string();
431 add(
432 SOURCE_RELATIONSHIPS,
433 issue.code.clone(),
434 severity,
435 uri,
436 None,
437 message,
438 ENFORCEMENT_BLOCKING,
439 );
440 }
441
442 for issue in &review.issues {
445 let message = if issue.action.is_empty() {
446 issue.message.clone()
447 } else {
448 format!("{} \u{2014} {}", issue.message, issue.action)
449 };
450 let default = if issue.priority <= PRIORITY_BROKEN_RELATIONSHIP {
451 ENFORCEMENT_BLOCKING
452 } else {
453 ENFORCEMENT_ADVISORY
454 };
455 add(
456 SOURCE_REVIEW,
457 issue.code.clone(),
458 issue.severity.clone(),
459 issue.path.clone(),
460 None,
461 message,
462 default,
463 );
464 }
465
466 if let Some(options) = code {
467 match crate::sentry::analyze(
468 directory,
469 options.repository,
470 recursive,
471 options.base,
472 options.full_tree,
473 ) {
474 Ok(report) => {
475 code_coverage = Some(CodeCoverage {
476 live_decisions: report.live_decisions,
477 classified_decisions: report.classified_decisions,
478 unclassified_decisions: report.unclassified_decisions(),
479 eligible_decisions: report.eligible_decisions,
480 constrained_decisions: report.constrained_decisions,
481 active_rules: report.active_rules,
482 corpus_adoption_percent: report.corpus_adoption_percent(),
483 eligible_coverage_percent: report.eligible_coverage_percent(),
484 });
485 for finding in report.findings {
486 add(
487 SOURCE_SENTRY,
488 finding.code.to_string(),
489 "error".to_string(),
490 finding.path,
491 finding.line,
492 finding.message,
493 ENFORCEMENT_BLOCKING,
494 );
495 }
496 }
497 Err(message) => {
498 add(
499 SOURCE_SENTRY,
500 crate::sentry::INVALID_CONSTRAINT.to_string(),
501 "error".to_string(),
502 directory.to_string(),
503 None,
504 message,
505 ENFORCEMENT_BLOCKING,
506 );
507 }
508 }
509 }
510
511 findings.sort_by(|a, b| {
512 a.path
513 .cmp(&b.path)
514 .then(a.line.unwrap_or(0).cmp(&b.line.unwrap_or(0)))
515 .then(a.source.cmp(b.source))
516 .then(a.code.cmp(&b.code))
517 .then(a.message.cmp(&b.message))
518 });
519 Ok(GateReport {
520 directory: directory.to_string(),
521 recursive,
522 findings,
523 code_coverage,
524 })
525}