Skip to main content

rac_engine/
sentry.rs

1//! Deterministic decision-to-code enforcement.
2//!
3//! Constraints live in a decision artifact's `## Code Constraints` fenced
4//! YAML block. This module deliberately has no network or model integration:
5//! repository bytes, corpus bytes, and an optional git diff are the complete
6//! input.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11
12use globset::Glob;
13use regex::Regex;
14use serde::Deserialize;
15
16use crate::parse::{Artifact, Issue};
17use crate::relationships::{corpus_items, CorpusItem};
18use crate::resolve::artifact_status;
19use crate::spec::spec_for;
20
21pub const CODE_VIOLATION: &str = "code-constraint-violation";
22pub const CODE_EMPTY_MATCH: &str = "code-constraint-empty-match";
23pub const CODE_UNSUPPORTED_LANGUAGE: &str = "code-constraint-unsupported-language";
24pub const MALFORMED_CONSTRAINTS: &str = "malformed-code-constraints";
25pub const UNSUPPORTED_VERSION: &str = "unsupported-code-constraints-version";
26pub const INVALID_CONSTRAINT: &str = "invalid-code-constraint";
27pub const DUPLICATE_RULE_ID: &str = "duplicate-code-constraint-id";
28
29#[derive(Debug, Clone, Deserialize)]
30#[serde(deny_unknown_fields)]
31struct ConstraintDocument {
32    version: u64,
33    #[serde(default = "default_eligibility")]
34    eligibility: Eligibility,
35    #[serde(default)]
36    rules: Vec<ConstraintRule>,
37    reason: Option<String>,
38}
39
40#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42enum Eligibility {
43    Eligible,
44    Ineligible,
45}
46
47fn default_eligibility() -> Eligibility {
48    // Compatibility with the v1 shape shipped in 0.25.1: a document that
49    // already carries rules was necessarily declaring itself eligible.
50    Eligibility::Eligible
51}
52
53#[derive(Debug, Clone, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct ConstraintRule {
56    id: String,
57    kind: RuleKind,
58    path_glob: String,
59    pattern: String,
60    message: Option<String>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64#[serde(rename_all = "snake_case")]
65enum RuleKind {
66    ForbidPattern,
67    RequirePattern,
68    ForbidImport,
69}
70
71#[derive(Debug, Clone)]
72pub struct SentryFinding {
73    pub code: &'static str,
74    pub decision_path: String,
75    pub rule_id: Option<String>,
76    pub path: String,
77    pub line: Option<i64>,
78    pub message: String,
79}
80
81#[derive(Debug)]
82pub struct SentryReport {
83    pub corpus: String,
84    pub repository: String,
85    pub base: Option<String>,
86    pub full_tree: bool,
87    pub live_decisions: usize,
88    pub classified_decisions: usize,
89    pub eligible_decisions: usize,
90    pub constrained_decisions: usize,
91    pub active_rules: usize,
92    pub findings: Vec<SentryFinding>,
93}
94
95impl SentryReport {
96    pub fn ok(&self) -> bool {
97        self.findings.is_empty()
98    }
99
100    pub fn corpus_adoption_percent(&self) -> f64 {
101        if self.live_decisions == 0 {
102            0.0
103        } else {
104            self.constrained_decisions as f64 * 100.0 / self.live_decisions as f64
105        }
106    }
107
108    pub fn eligible_coverage_percent(&self) -> f64 {
109        if self.eligible_decisions == 0 {
110            0.0
111        } else {
112            self.constrained_decisions as f64 * 100.0 / self.eligible_decisions as f64
113        }
114    }
115
116    pub fn unclassified_decisions(&self) -> usize {
117        self.live_decisions
118            .saturating_sub(self.classified_decisions)
119    }
120}
121
122fn is_live_decision(item: &CorpusItem) -> bool {
123    if item.spec.map(|s| s.name.as_str()) != Some("decision") {
124        return false;
125    }
126    !matches!(
127        artifact_status(&item.artifact)
128            .trim()
129            .to_ascii_lowercase()
130            .as_str(),
131        "superseded" | "deprecated"
132    )
133}
134
135fn fenced_yaml(section: &str) -> Result<&str, &'static str> {
136    let trimmed = section.trim();
137    let body = trimmed
138        .strip_prefix("```yaml")
139        .or_else(|| trimmed.strip_prefix("```yml"))
140        .ok_or("expected exactly one fenced yaml block")?;
141    let body = body
142        .strip_suffix("```")
143        .ok_or("unterminated fenced yaml block")?;
144    if body.contains("\n```") {
145        return Err("expected exactly one fenced yaml block");
146    }
147    Ok(body.trim_matches('\n'))
148}
149
150fn valid_rule_id(id: &str) -> bool {
151    let mut chars = id.chars();
152    matches!(chars.next(), Some('a'..='z'))
153        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
154        && !id.ends_with('-')
155        && !id.contains("--")
156}
157
158fn safe_glob(value: &str) -> bool {
159    !value.is_empty()
160        && !value.contains('\\')
161        && !Path::new(value).is_absolute()
162        && !Path::new(value)
163            .components()
164            .any(|part| part == Component::ParentDir)
165}
166
167fn raw_constraint_section(artifact: &Artifact) -> Result<Option<String>, &'static str> {
168    let text = match std::fs::read_to_string(&artifact.product.source_path) {
169        Ok(text) => text,
170        Err(_) => return Ok(artifact.section("code constraints").map(str::to_string)),
171    };
172    let heading = Regex::new(r"(?i)^##[ \t]+code[ \t]+constraints[ \t]*#*[ \t]*$").unwrap();
173    let any_h2 = Regex::new(r"^##(?:[ \t]|$)").unwrap();
174    let lines: Vec<&str> = text.lines().collect();
175    let starts: Vec<usize> = lines
176        .iter()
177        .enumerate()
178        .filter_map(|(index, line)| heading.is_match(line).then_some(index))
179        .collect();
180    match starts.as_slice() {
181        [] => Ok(None),
182        [_] => {
183            let start = starts[0] + 1;
184            let end = (start..lines.len())
185                .find(|index| any_h2.is_match(lines[*index]))
186                .unwrap_or(lines.len());
187            Ok(Some(lines[start..end].join("\n")))
188        }
189        _ => Err("more than one Code Constraints section"),
190    }
191}
192
193fn parse_document(item: &CorpusItem) -> Result<Option<ConstraintDocument>, Box<SentryFinding>> {
194    let section = raw_constraint_section(&item.artifact).map_err(|problem| {
195        Box::new(SentryFinding {
196            code: MALFORMED_CONSTRAINTS,
197            decision_path: item.path.clone(),
198            rule_id: None,
199            path: item.path.clone(),
200            line: None,
201            message: problem.to_string(),
202        })
203    })?;
204    let Some(section) = section else {
205        return Ok(None);
206    };
207    let yaml = fenced_yaml(&section).map_err(|problem| {
208        Box::new(SentryFinding {
209            code: MALFORMED_CONSTRAINTS,
210            decision_path: item.path.clone(),
211            rule_id: None,
212            path: item.path.clone(),
213            line: None,
214            message: problem.to_string(),
215        })
216    })?;
217    let document: ConstraintDocument = serde_yaml::from_str(yaml).map_err(|error| {
218        Box::new(SentryFinding {
219            code: MALFORMED_CONSTRAINTS,
220            decision_path: item.path.clone(),
221            rule_id: None,
222            path: item.path.clone(),
223            line: None,
224            message: format!("invalid code-constraint YAML: {error}"),
225        })
226    })?;
227    if document.version != 1 {
228        return Err(Box::new(SentryFinding {
229            code: UNSUPPORTED_VERSION,
230            decision_path: item.path.clone(),
231            rule_id: None,
232            path: item.path.clone(),
233            line: None,
234            message: format!(
235                "unsupported code-constraint version {}; supported version is 1",
236                document.version
237            ),
238        }));
239    }
240    if document.eligibility == Eligibility::Ineligible {
241        if !document.rules.is_empty() {
242            return Err(invalid(
243                item,
244                None,
245                "ineligible decisions must not declare rules",
246            ));
247        }
248        if document
249            .reason
250            .as_deref()
251            .is_none_or(|reason| reason.trim().is_empty())
252        {
253            return Err(invalid(
254                item,
255                None,
256                "ineligible decisions must state a non-empty reason",
257            ));
258        }
259    }
260    if document
261        .reason
262        .as_deref()
263        .is_some_and(|reason| reason.trim().is_empty())
264    {
265        return Err(invalid(item, None, "reason must not be empty"));
266    }
267    let mut ids = BTreeSet::new();
268    for rule in &document.rules {
269        if !valid_rule_id(&rule.id) {
270            return Err(invalid(item, Some(&rule.id), "invalid rule id"));
271        }
272        if !ids.insert(rule.id.clone()) {
273            return Err(Box::new(SentryFinding {
274                code: DUPLICATE_RULE_ID,
275                decision_path: item.path.clone(),
276                rule_id: Some(rule.id.clone()),
277                path: item.path.clone(),
278                line: None,
279                message: format!("duplicate code-constraint id '{}'", rule.id),
280            }));
281        }
282        if !safe_glob(&rule.path_glob) {
283            return Err(invalid(
284                item,
285                Some(&rule.id),
286                "path_glob must be repository-relative and contain no '..' component",
287            ));
288        }
289        if Glob::new(&rule.path_glob).is_err() {
290            return Err(invalid(item, Some(&rule.id), "invalid path_glob"));
291        }
292        if rule.pattern.is_empty() || Regex::new(&rule.pattern).is_err() {
293            return Err(invalid(item, Some(&rule.id), "invalid regular expression"));
294        }
295        if rule
296            .message
297            .as_ref()
298            .is_some_and(|message| message.is_empty())
299        {
300            return Err(invalid(item, Some(&rule.id), "message must not be empty"));
301        }
302    }
303    Ok(Some(document))
304}
305
306/// Structural validation for `decided validate`: syntax and rule contracts do
307/// not require a repository tree and therefore fail the ordinary corpus gate
308/// even when code enforcement was not requested.
309pub fn validate_artifact(artifact: &Artifact) -> Vec<Issue> {
310    let item = CorpusItem {
311        path: String::new(),
312        artifact: artifact.clone(),
313        spec: spec_for("decision"),
314    };
315    match parse_document(&item) {
316        Err(finding) => vec![Issue::new("error", finding.code, finding.message, None)],
317        _ => Vec::new(),
318    }
319}
320
321fn invalid(item: &CorpusItem, rule_id: Option<&str>, message: &str) -> Box<SentryFinding> {
322    Box::new(SentryFinding {
323        code: INVALID_CONSTRAINT,
324        decision_path: item.path.clone(),
325        rule_id: rule_id.map(str::to_string),
326        path: item.path.clone(),
327        line: None,
328        message: message.to_string(),
329    })
330}
331
332fn collect_files(root: &Path, dir: &Path, output: &mut Vec<(String, PathBuf)>) {
333    let Ok(entries) = std::fs::read_dir(dir) else {
334        return;
335    };
336    let mut entries: Vec<_> = entries.flatten().collect();
337    entries.sort_by_key(|entry| entry.file_name());
338    for entry in entries {
339        let path = entry.path();
340        let name = entry.file_name();
341        if name == ".git" {
342            continue;
343        }
344        let Ok(file_type) = entry.file_type() else {
345            continue;
346        };
347        if file_type.is_dir() && !file_type.is_symlink() {
348            collect_files(root, &path, output);
349        } else if file_type.is_file() {
350            if let Ok(relative) = path.strip_prefix(root) {
351                output.push((relative.to_string_lossy().replace('\\', "/"), path));
352            }
353        }
354    }
355}
356
357fn changed_lines(
358    repository: &Path,
359    base: &str,
360) -> Result<BTreeMap<String, BTreeSet<usize>>, String> {
361    let output = Command::new("git")
362        .arg("-C")
363        .arg(repository)
364        .args(["diff", "--unified=0", "--no-ext-diff", base, "--"])
365        .output()
366        .map_err(|error| format!("could not run git diff: {error}"))?;
367    if !output.status.success() {
368        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
369    }
370    let text = String::from_utf8_lossy(&output.stdout);
371    let mut result: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
372    let mut path: Option<String> = None;
373    for line in text.lines() {
374        if let Some(value) = line.strip_prefix("+++ b/") {
375            path = Some(value.to_string());
376        } else if let Some(hunk) = line.strip_prefix("@@ -").and_then(|s| s.split(" +").nth(1)) {
377            let range = hunk.split(" @@").next().unwrap_or(hunk);
378            let mut fields = range.split(',');
379            let start = fields
380                .next()
381                .and_then(|v| v.parse::<usize>().ok())
382                .unwrap_or(0);
383            let count = fields
384                .next()
385                .and_then(|v| v.parse::<usize>().ok())
386                .unwrap_or(1);
387            if let Some(path) = &path {
388                result
389                    .entry(path.clone())
390                    .or_default()
391                    .extend(start..start.saturating_add(count));
392            }
393        }
394    }
395    Ok(result)
396}
397
398fn import_targets(extension: &str, text: &str) -> Option<Vec<(usize, String)>> {
399    let mut targets = Vec::new();
400    match extension {
401        "py" => {
402            let import = Regex::new(r"^\s*import\s+([A-Za-z0-9_., ]+)").unwrap();
403            let from = Regex::new(r"^\s*from\s+([A-Za-z0-9_.]+)\s+import\b").unwrap();
404            for (index, line) in text.lines().enumerate() {
405                if let Some(captures) = from.captures(line) {
406                    targets.push((index + 1, captures[1].to_string()));
407                } else if let Some(captures) = import.captures(line) {
408                    for target in captures[1].split(',') {
409                        targets.push((
410                            index + 1,
411                            target.split_whitespace().next().unwrap_or("").to_string(),
412                        ));
413                    }
414                }
415            }
416        }
417        "rs" => {
418            let use_re = Regex::new(r"^\s*use\s+([^;]+)").unwrap();
419            for (index, line) in text.lines().enumerate() {
420                if let Some(captures) = use_re.captures(line) {
421                    targets.push((index + 1, captures[1].trim().to_string()));
422                }
423            }
424        }
425        "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => {
426            let from = Regex::new(r#"\bfrom\s+['"]([^'"]+)['"]"#).unwrap();
427            let side_effect = Regex::new(r#"^\s*import\s+['"]([^'"]+)['"]"#).unwrap();
428            let require = Regex::new(r#"\brequire\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap();
429            for (index, line) in text.lines().enumerate() {
430                for captures in from
431                    .captures_iter(line)
432                    .chain(side_effect.captures_iter(line))
433                    .chain(require.captures_iter(line))
434                {
435                    targets.push((index + 1, captures[1].to_string()));
436                }
437            }
438        }
439        _ => return None,
440    }
441    Some(targets)
442}
443
444pub fn analyze(
445    corpus: &str,
446    repository: &str,
447    recursive: bool,
448    base: Option<&str>,
449    full_tree: bool,
450) -> Result<SentryReport, String> {
451    let repository_path = Path::new(repository);
452    if !repository_path.is_dir() {
453        return Err(format!("not a directory: {repository}"));
454    }
455    if !full_tree && base.is_none() {
456        return Err("a diff base is required unless --full is supplied".to_string());
457    }
458    let changed = if full_tree {
459        None
460    } else {
461        Some(changed_lines(repository_path, base.unwrap())?)
462    };
463    let items = corpus_items(corpus, recursive);
464    let live: Vec<&CorpusItem> = items.iter().filter(|item| is_live_decision(item)).collect();
465    let mut documents = Vec::new();
466    let mut findings = Vec::new();
467    for item in &live {
468        match parse_document(item) {
469            Ok(Some(document)) => documents.push((*item, document)),
470            Ok(None) => {}
471            Err(finding) => findings.push(*finding),
472        }
473    }
474
475    let mut files = Vec::new();
476    collect_files(repository_path, repository_path, &mut files);
477    for (item, document) in &documents {
478        for rule in &document.rules {
479            let matcher = Glob::new(&rule.path_glob).unwrap().compile_matcher();
480            let selected: Vec<_> = files
481                .iter()
482                .filter(|(relative, _)| matcher.is_match(relative))
483                .filter(|(relative, _)| {
484                    full_tree
485                        || changed
486                            .as_ref()
487                            .is_some_and(|set| set.contains_key(relative))
488                })
489                .collect();
490            if matches!(rule.kind, RuleKind::RequirePattern) && selected.is_empty() {
491                // A diff-scoped gate only evaluates files changed by the pull
492                // request. An unrelated change is outside this rule's scope,
493                // not evidence that the required repository contract vanished.
494                // Full-tree certification remains fail-closed when the glob
495                // itself selects nothing.
496                if full_tree {
497                    findings.push(rule_finding(
498                        item,
499                        rule,
500                        CODE_EMPTY_MATCH,
501                        &item.path,
502                        None,
503                        format!("rule '{}' selected no files", rule.id),
504                    ));
505                }
506                continue;
507            }
508            let pattern = Regex::new(&rule.pattern).unwrap();
509            for (relative, absolute) in selected {
510                let text = match std::fs::read_to_string(absolute) {
511                    Ok(text) => text,
512                    Err(_) => {
513                        findings.push(rule_finding(
514                            item,
515                            rule,
516                            CODE_UNSUPPORTED_LANGUAGE,
517                            relative,
518                            None,
519                            "selected source file is not readable UTF-8".to_string(),
520                        ));
521                        continue;
522                    }
523                };
524                match rule.kind {
525                    RuleKind::ForbidPattern => {
526                        let mut line_starts = vec![0usize];
527                        line_starts.extend(
528                            text.bytes()
529                                .enumerate()
530                                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
531                        );
532                        for matched in pattern.find_iter(&text) {
533                            let start_line =
534                                line_starts.partition_point(|start| *start <= matched.start());
535                            let end_offset = matched.end().saturating_sub(1);
536                            let end_line =
537                                line_starts.partition_point(|start| *start <= end_offset);
538                            let touches_change = full_tree
539                                || changed
540                                    .as_ref()
541                                    .and_then(|set| set.get(relative))
542                                    .is_some_and(|lines| {
543                                        (start_line..=end_line).any(|line| lines.contains(&line))
544                                    });
545                            if touches_change {
546                                findings.push(rule_finding(
547                                    item,
548                                    rule,
549                                    CODE_VIOLATION,
550                                    relative,
551                                    Some(start_line as i64),
552                                    rule.message.clone().unwrap_or_else(|| {
553                                        format!("forbidden pattern matched rule '{}'", rule.id)
554                                    }),
555                                ));
556                            }
557                        }
558                    }
559                    RuleKind::RequirePattern => {
560                        if !pattern.is_match(&text) {
561                            findings.push(rule_finding(
562                                item,
563                                rule,
564                                CODE_VIOLATION,
565                                relative,
566                                None,
567                                rule.message.clone().unwrap_or_else(|| {
568                                    format!("required pattern missing for rule '{}'", rule.id)
569                                }),
570                            ));
571                        }
572                    }
573                    RuleKind::ForbidImport => {
574                        let extension = absolute
575                            .extension()
576                            .and_then(|value| value.to_str())
577                            .unwrap_or("");
578                        let Some(imports) = import_targets(extension, &text) else {
579                            findings.push(rule_finding(
580                                item,
581                                rule,
582                                CODE_UNSUPPORTED_LANGUAGE,
583                                relative,
584                                None,
585                                format!("no deterministic import adapter for '.{extension}'"),
586                            ));
587                            continue;
588                        };
589                        for (line, target) in imports {
590                            if pattern.is_match(&target)
591                                && (full_tree
592                                    || changed
593                                        .as_ref()
594                                        .and_then(|set| set.get(relative))
595                                        .is_some_and(|lines| lines.contains(&line)))
596                            {
597                                findings.push(rule_finding(
598                                    item,
599                                    rule,
600                                    CODE_VIOLATION,
601                                    relative,
602                                    Some(line as i64),
603                                    rule.message.clone().unwrap_or_else(|| {
604                                        format!(
605                                            "forbidden import '{target}' matched rule '{}'",
606                                            rule.id
607                                        )
608                                    }),
609                                ));
610                            }
611                        }
612                    }
613                }
614            }
615        }
616    }
617    findings.sort_by(|a, b| {
618        a.path
619            .cmp(&b.path)
620            .then(a.line.unwrap_or(0).cmp(&b.line.unwrap_or(0)))
621            .then(a.decision_path.cmp(&b.decision_path))
622            .then(a.rule_id.cmp(&b.rule_id))
623    });
624    Ok(SentryReport {
625        corpus: corpus.to_string(),
626        repository: repository.to_string(),
627        base: base.map(str::to_string),
628        full_tree,
629        live_decisions: live.len(),
630        classified_decisions: documents.len(),
631        eligible_decisions: documents
632            .iter()
633            .filter(|(_, document)| document.eligibility == Eligibility::Eligible)
634            .count(),
635        constrained_decisions: documents
636            .iter()
637            .filter(|(_, document)| {
638                document.eligibility == Eligibility::Eligible && !document.rules.is_empty()
639            })
640            .count(),
641        active_rules: documents
642            .iter()
643            .filter(|(_, document)| document.eligibility == Eligibility::Eligible)
644            .map(|(_, document)| document.rules.len())
645            .sum(),
646        findings,
647    })
648}
649
650fn rule_finding(
651    item: &CorpusItem,
652    rule: &ConstraintRule,
653    code: &'static str,
654    path: &str,
655    line: Option<i64>,
656    message: String,
657) -> SentryFinding {
658    SentryFinding {
659        code,
660        decision_path: item.path.clone(),
661        rule_id: Some(rule.id.clone()),
662        path: path.to_string(),
663        line,
664        message,
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use std::fs;
672
673    #[test]
674    fn rule_ids_are_kebab_case() {
675        assert!(valid_rule_id("no-hard-delete"));
676        assert!(!valid_rule_id("NoHardDelete"));
677        assert!(!valid_rule_id("no--delete"));
678    }
679
680    #[test]
681    fn import_adapters_extract_targets() {
682        assert_eq!(
683            import_targets("py", "from sqlalchemy.orm import Session\nimport httpx\n").unwrap(),
684            vec![(1, "sqlalchemy.orm".to_string()), (2, "httpx".to_string())]
685        );
686        assert_eq!(
687            import_targets("rs", "use crate::domain::User;\n").unwrap(),
688            vec![(1, "crate::domain::User".to_string())]
689        );
690    }
691
692    #[test]
693    fn full_tree_enforces_constraint_and_reports_coverage() {
694        let root = std::env::temp_dir().join(format!(
695            "decided-sentry-{}-{}",
696            std::process::id(),
697            std::thread::current().name().unwrap_or("test")
698        ));
699        let corpus = root.join("decisions");
700        let source = root.join("src");
701        fs::create_dir_all(&corpus).unwrap();
702        fs::create_dir_all(&source).unwrap();
703        fs::write(
704            corpus.join("adr-001.md"),
705            "# ADR-001: Retain users\n\n## Status\n\nAccepted\n\n## Context\n\nDeletion loses audit history.\n\n## Decision\n\nUse soft deletion.\n\n## Consequences\n\nRows remain recoverable.\n\n## Code Constraints\n\n```yaml\nversion: 1\nrules:\n  - id: no-hard-delete\n    kind: forbid_pattern\n    path_glob: \"src/**/*.sql\"\n    pattern: \"DELETE\\\\s+FROM\\\\s+users\"\n```\n",
706        )
707        .unwrap();
708        fs::write(
709            corpus.join("adr-002.md"),
710            "# ADR-002: Product language\n\n## Status\n\nAccepted\n\n## Context\n\nThe product needs a stable voice.\n\n## Decision\n\nUse direct language.\n\n## Consequences\n\nCopy remains subject to human review.\n\n## Code Constraints\n\n```yaml\nversion: 1\neligibility: ineligible\nreason: \"Product voice requires human review, not a source-code rule.\"\n```\n",
711        )
712        .unwrap();
713        fs::write(
714            corpus.join("adr-003.md"),
715            "# ADR-003: Unclassified\n\n## Status\n\nAccepted\n\n## Context\n\nThis historical decision has not been classified.\n\n## Decision\n\nKeep its status explicit.\n\n## Consequences\n\nSentry reports it as unclassified.\n",
716        )
717        .unwrap();
718        fs::write(source.join("users.sql"), "DELETE FROM users;\n").unwrap();
719
720        let report = analyze(
721            corpus.to_str().unwrap(),
722            root.to_str().unwrap(),
723            true,
724            None,
725            true,
726        )
727        .unwrap();
728        assert_eq!(report.live_decisions, 3);
729        assert_eq!(report.classified_decisions, 2);
730        assert_eq!(report.eligible_decisions, 1);
731        assert_eq!(report.constrained_decisions, 1, "{:#?}", report.findings);
732        assert_eq!(report.active_rules, 1);
733        assert_eq!(report.unclassified_decisions(), 1);
734        assert!((report.corpus_adoption_percent() - 100.0 / 3.0).abs() < f64::EPSILON);
735        assert_eq!(report.eligible_coverage_percent(), 100.0);
736        assert_eq!(report.findings.len(), 1);
737        assert_eq!(report.findings[0].code, CODE_VIOLATION);
738
739        fs::remove_dir_all(root).unwrap();
740    }
741
742    #[test]
743    fn diff_mode_reports_only_added_violation_lines() {
744        let root = std::env::temp_dir().join(format!("decided-sentry-diff-{}", std::process::id()));
745        let corpus = root.join("decisions");
746        let source = root.join("src");
747        let _ = fs::remove_dir_all(&root);
748        fs::create_dir_all(&corpus).unwrap();
749        fs::create_dir_all(&source).unwrap();
750        fs::write(
751            corpus.join("adr-001.md"),
752            "# ADR-001: Retain users\n\n## Status\n\nAccepted\n\n## Context\n\nDeletion loses audit history.\n\n## Decision\n\nUse soft deletion.\n\n## Consequences\n\nRows remain recoverable.\n\n## Code Constraints\n\n```yaml\nversion: 1\nrules:\n  - id: no-hard-delete\n    kind: forbid_pattern\n    path_glob: \"src/**/*.sql\"\n    pattern: \"DELETE\\\\s+FROM\\\\s+users\"\n```\n",
753        )
754        .unwrap();
755        fs::write(source.join("users.sql"), "SELECT * FROM users;\n").unwrap();
756        let git = |args: &[&str]| {
757            let status = Command::new("git")
758                .arg("-C")
759                .arg(&root)
760                .args(args)
761                .status()
762                .unwrap();
763            assert!(status.success(), "git {args:?}");
764        };
765        git(&["init", "-q"]);
766        git(&["add", "."]);
767        git(&[
768            "-c",
769            "user.name=As Decided",
770            "-c",
771            "user.email=tests@asdecided.com",
772            "commit",
773            "-qm",
774            "base",
775        ]);
776        fs::write(
777            source.join("users.sql"),
778            "SELECT * FROM users;\nDELETE FROM users;\n",
779        )
780        .unwrap();
781
782        let report = analyze(
783            corpus.to_str().unwrap(),
784            root.to_str().unwrap(),
785            true,
786            Some("HEAD"),
787            false,
788        )
789        .unwrap();
790        assert_eq!(report.findings.len(), 1);
791        assert_eq!(report.findings[0].line, Some(2));
792
793        fs::remove_dir_all(root).unwrap();
794    }
795
796    #[test]
797    fn diff_mode_skips_require_rule_when_no_matching_file_changed() {
798        let root =
799            std::env::temp_dir().join(format!("decided-sentry-require-{}", std::process::id()));
800        let corpus = root.join("decisions");
801        let source = root.join("src");
802        let docs = root.join("docs");
803        let _ = fs::remove_dir_all(&root);
804        fs::create_dir_all(&corpus).unwrap();
805        fs::create_dir_all(&source).unwrap();
806        fs::create_dir_all(&docs).unwrap();
807        fs::write(
808            corpus.join("adr-001.md"),
809            "# ADR-001: Audit entry point\n\n## Status\n\nAccepted\n\n## Context\n\nThe audit entry point is required.\n\n## Decision\n\nKeep it public.\n\n## Consequences\n\nCallers have one stable entry point.\n\n## Code Constraints\n\n```yaml\nversion: 1\nrules:\n  - id: audit-entry-point\n    kind: require_pattern\n    path_glob: \"src/audit.rs\"\n    pattern: \"pub fn audit\"\n```\n",
810        )
811        .unwrap();
812        fs::write(source.join("audit.rs"), "pub fn audit() {}\n").unwrap();
813        fs::write(docs.join("guide.md"), "Initial guide.\n").unwrap();
814        let git = |args: &[&str]| {
815            let status = Command::new("git")
816                .arg("-C")
817                .arg(&root)
818                .args(args)
819                .status()
820                .unwrap();
821            assert!(status.success(), "git {args:?}");
822        };
823        git(&["init", "-q"]);
824        git(&["add", "."]);
825        git(&[
826            "-c",
827            "user.name=As Decided",
828            "-c",
829            "user.email=tests@asdecided.com",
830            "commit",
831            "-qm",
832            "base",
833        ]);
834        fs::write(docs.join("guide.md"), "Updated guide.\n").unwrap();
835
836        let report = analyze(
837            corpus.to_str().unwrap(),
838            root.to_str().unwrap(),
839            true,
840            Some("HEAD"),
841            false,
842        )
843        .unwrap();
844        assert!(report.findings.is_empty(), "{:#?}", report.findings);
845
846        fs::remove_dir_all(root).unwrap();
847    }
848}