1use 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 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(§ion).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
306pub 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 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}