Skip to main content

rac_engine/
gate.rs

1//! Policy-aware unified enforcement (`decided.services.gate`, v0.21.14 /
2//! ADR-049) plus the STRICT `.decided/config.yaml` loaders it alone consumes
3//! (`decided.services.init.load_enforcement_policy` / `load_overrides`, the
4//! raising paths).
5//!
6//! `decided gate` composes validation, relationship integrity, and review over
7//! one corpus, then classifies every finding as blocking or advisory under
8//! the corpus enforcement policy. The other commands keep the engine's
9//! lenient config readers (`validate::load_overrides` skips malformed
10//! entries); the gate is the one surface where a malformed config is an
11//! operational error — `decided: malformed repository config <path>: <reason>`
12//! on stderr, exit 1 (`MalformedRepositoryConfig` in `cmd_gate`).
13
14use 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// ---------------------------------------------------------------------------
33// MalformedRepositoryConfig (decided.services.init)
34// ---------------------------------------------------------------------------
35
36/// The gate's operational config error. `message()` is the oracle's
37/// `str(MalformedRepositoryConfig)` shape; `cmd_gate` prefixes `decided: `.
38#[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// ---------------------------------------------------------------------------
54// EnforcementPolicy (decided.services.gate.EnforcementPolicy)
55// ---------------------------------------------------------------------------
56
57/// Finding codes mapped to an enforcement class (ADR-049). Precedence:
58/// `off` (suppress, None) -> `blocking` -> `advisory` -> the caller default.
59#[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
81// ---------------------------------------------------------------------------
82// Strict config loading (the raising `decided.services.init` paths)
83// ---------------------------------------------------------------------------
84
85/// `_parse_config_yaml`: read + YAML-parse the nearest config; a parse
86/// failure is `invalid YAML: <problem>` (the oracle embeds PyYAML's exact
87/// exception prose here — stderr is out of parity scope, so the engine's
88/// own problem text stands in). Returns the top-level mapping pairs, or
89/// None when the root is not a mapping (the loaders treat a non-mapping
90/// root as "no section", matching `data.get(...) if isinstance(data, dict)`).
91fn 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
116/// `_parse_code_list`: absent/null -> empty; anything but a list of strings
117/// is a malformed config.
118fn 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
143/// `load_enforcement_policy(start_dir)` — the STRICT reader `build_gate`
144/// consumes. YAML 1.1 resolves a bare `off` key to Bool(false); both key
145/// spellings are accepted, `off` winning (`section["off"] if "off" in
146/// section else section.get(False)`).
147pub 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
179/// A Yaml key rendered the way Python's f-string would render the parsed
180/// value inside `'validation.rules.<name>'` — best effort, stderr-only.
181fn 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
192/// `_parse_severity_map` (strict): every entry must map a string name to an
193/// allowed severity; YAML 1.1 bools coerce to `off`/`on` first.
194fn 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
236/// The STRICT face of `load_overrides` (ADR-053): `build_gate` validates the
237/// `validation` section shape and raises where the oracle raises. The
238/// override VALUES applied to the pipeline come from the engine's lenient
239/// loader inside `validate_directory` — identical whenever this check
240/// passes (the lenient reader only ever drops entries this one rejects).
241pub 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// ---------------------------------------------------------------------------
273// GateFinding / GateReport
274// ---------------------------------------------------------------------------
275
276#[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    /// True when nothing is blocking — advisory findings never fail the gate.
321    pub fn ok(&self) -> bool {
322        self.blocking().is_empty()
323    }
324}
325
326// ---------------------------------------------------------------------------
327// build_gate
328// ---------------------------------------------------------------------------
329
330/// One normalized validate finding: `(code, severity, path, line, message)`.
331type RawFinding = (String, String, String, Option<i64>, String);
332
333/// One normalized `(code, severity, path, line, message)` per validate
334/// finding: per-file issues (line-anchored) then OKF findings (file-level).
335fn 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
362/// `build_gate(directory, recursive)` — run validation, relationships, and
363/// review, then enforce the corpus policy. Findings the policy turns `off`
364/// are dropped; the rest sort by `(path, line or 0, source, code, message)`.
365pub 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    // The oracle raises from load_enforcement_policy first, then
381    // load_overrides — mirror that order so a doubly-malformed config
382    // reports the enforcement error.
383    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    // Validate: an "error" fails validation, so it is blocking by default;
415    // warnings and OKF info findings are advisory.
416    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    // Relationships: every issue fails `--validate` today, so blocking by
426    // default. Message and (percent-encoded) path come from the shared SARIF
427    // result builder so the two surfaces can never drift.
428    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    // Review: priority 1-2 findings fail review today, so they are blocking
443    // by default; advisory priorities (3+) are advisory.
444    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}