1use std::collections::HashMap;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CommandClass {
14 CatLike,
16 GrepLike,
18}
19
20#[derive(Debug, Clone, PartialEq)]
25pub enum Decision {
26 Allow,
28 Deny { file_key: String, reason: String },
30 AlreadyConsulted { context: String },
32 Advisory { context: String },
34 Liability { staleness: f32, context: String },
36 Tombstone,
38 NoRecord,
40 NotFileRead,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum HookEvent {
48 Hit { key: String },
50 Miss { key: String },
52 BlockedUnconsultedRead { key: String },
54 CodexShellBlocked { key: String },
56 ComplianceHit { key: String },
58}
59
60pub struct EnforcementInput {
62 pub rel_path: String,
64 pub file_record: Option<serde_json::Value>,
66 pub gotcha_records: HashMap<String, serde_json::Value>,
68 pub already_consulted: bool,
70}
71
72pub struct EnforcementResult {
74 pub decision: Decision,
75 pub events: Vec<HookEvent>,
76}
77
78const CAT_LIKE: &[&str] = &["cat", "less", "head", "tail", "bat"];
81const GREP_LIKE: &[&str] = &["grep", "rg", "sed", "awk"];
82
83fn matches_command_word(trimmed: &str, word: &str) -> bool {
86 if trimmed.len() < word.len() {
87 return false;
88 }
89 if !trimmed.starts_with(word) {
90 return false;
91 }
92 if trimmed.len() == word.len() {
93 return true;
94 }
95 trimmed.as_bytes()[word.len()].is_ascii_whitespace()
96}
97
98pub fn classify_command(cmd: &str) -> Option<CommandClass> {
100 let trimmed = cmd.trim_start();
101 for &word in CAT_LIKE {
102 if matches_command_word(trimmed, word) {
103 return Some(CommandClass::CatLike);
104 }
105 }
106 for &word in GREP_LIKE {
107 if matches_command_word(trimmed, word) {
108 return Some(CommandClass::GrepLike);
109 }
110 }
111 None
112}
113
114pub fn extract_file_path(cmd: &str, class: CommandClass) -> Option<String> {
125 let trimmed = cmd.trim_start();
126
127 let cmd_part = split_at_shell_operator(trimmed);
129
130 match class {
131 CommandClass::CatLike => {
132 if let Some(q) = extract_first_double_quoted(cmd_part) {
133 return Some(q);
134 }
135 positional_arg(cmd_part, true)
136 }
137 CommandClass::GrepLike => {
138 if let Some(q) = extract_last_double_quoted(cmd_part) {
139 return Some(q);
140 }
141 positional_arg(cmd_part, false).map(|s| {
142 s.trim_start_matches('\'')
144 .trim_end_matches('\'')
145 .to_string()
146 })
147 }
148 }
149}
150
151fn split_at_shell_operator(s: &str) -> &str {
154 let bytes = s.as_bytes();
155 let mut i = 0;
156 while i < bytes.len() {
157 match bytes[i] {
158 b'|' => {
159 return &s[..i];
161 }
162 b';' => return &s[..i],
163 b'&' if i + 1 < bytes.len() && bytes[i + 1] == b'&' => {
164 return &s[..i];
165 }
166 b'"' => {
167 i += 1;
169 while i < bytes.len() && bytes[i] != b'"' {
170 i += 1;
171 }
172 }
173 b'\'' => {
174 i += 1;
175 while i < bytes.len() && bytes[i] != b'\'' {
176 i += 1;
177 }
178 }
179 _ => {}
180 }
181 i += 1;
182 }
183 s
184}
185
186fn extract_first_double_quoted(s: &str) -> Option<String> {
188 let start = s.find('"')? + 1;
189 let end = s[start..].find('"')? + start;
190 let inner = &s[start..end];
191 if inner.is_empty() {
192 None
193 } else {
194 Some(inner.to_string())
195 }
196}
197
198fn extract_last_double_quoted(s: &str) -> Option<String> {
200 let mut last: Option<String> = None;
201 let mut pos = 0;
202 while pos < s.len() {
203 if let Some(offset) = s[pos..].find('"') {
204 let abs_start = pos + offset + 1;
205 if let Some(end_offset) = s[abs_start..].find('"') {
206 let inner = &s[abs_start..abs_start + end_offset];
207 if !inner.is_empty() {
208 last = Some(inner.to_string());
209 }
210 pos = abs_start + end_offset + 1;
211 } else {
212 break;
213 }
214 } else {
215 break;
216 }
217 }
218 last
219}
220
221fn positional_arg(cmd_part: &str, first: bool) -> Option<String> {
223 let words: Vec<&str> = cmd_part.split_whitespace().collect();
224 if words.len() < 2 {
225 return None;
226 }
227 let args: Vec<&str> = words[1..]
228 .iter()
229 .filter(|w| !w.starts_with('-'))
230 .copied()
231 .collect();
232 if args.is_empty() {
233 return None;
234 }
235 let picked = if first { args[0] } else { args[args.len() - 1] };
236 if picked.is_empty() {
237 None
238 } else {
239 Some(picked.to_string())
240 }
241}
242
243pub const MAX_APPLY_PATCH_FILES: usize = 50;
250
251pub fn extract_apply_patch_files(patch: &str) -> Vec<String> {
277 const MARKERS: &[&str] = &[
278 "*** Update File: ",
279 "*** Add File: ",
280 "*** Delete File: ",
281 "*** Move to: ",
282 ];
283 let mut files: Vec<String> = Vec::new();
284 for line in patch.lines() {
285 for marker in MARKERS {
286 if let Some(rest) = line.strip_prefix(marker) {
287 let path = rest.trim();
288 if !path.is_empty() && !files.iter().any(|f| f == path) {
289 files.push(path.to_string());
290 }
291 break;
292 }
293 }
294 }
295 files
296}
297
298pub fn normalize_path(file_path: &str, repo_root: Option<&str>) -> String {
306 let stripped = match repo_root {
307 Some(root) => file_path
308 .strip_prefix(root)
309 .and_then(|s| s.strip_prefix('/'))
310 .unwrap_or(file_path),
311 None => file_path,
312 };
313
314 let mut components: Vec<&str> = Vec::new();
315 for part in stripped.split('/') {
316 match part {
317 "" | "." => continue,
318 ".." => {
319 if components.pop().is_none() {
320 return stripped.to_string();
323 }
324 }
325 c => components.push(c),
326 }
327 }
328
329 if components.is_empty() {
330 ".".to_string()
331 } else {
332 components.join("/")
333 }
334}
335
336pub fn evaluate(input: &EnforcementInput) -> EnforcementResult {
343 let file_key = format!("file:{}", input.rel_path);
344
345 let file_record = match &input.file_record {
347 Some(r) if r.is_object() => r,
348 _ => {
349 return EnforcementResult {
350 decision: Decision::NoRecord,
351 events: vec![HookEvent::Miss { key: file_key }],
352 };
353 }
354 };
355
356 let confidence = json_f32(file_record, "/confidence/value");
358 let quality = json_f32(file_record, "/quality/value");
359 let staleness = json_f32(file_record, "/staleness/value");
360 let staleness_tier = json_str(file_record, "/staleness/tier");
361
362 if staleness_tier == "tombstone" {
364 return EnforcementResult {
365 decision: Decision::Tombstone,
366 events: vec![],
367 };
368 }
369
370 if staleness_tier == "liability" {
372 return EnforcementResult {
373 decision: Decision::Liability {
374 staleness,
375 context: format!(
376 "WARNING: STALE record for {} is a liability (staleness {:.2}). \
377 Read the file directly — the cached record is too stale to trust.",
378 input.rel_path, staleness
379 ),
380 },
381 events: vec![HookEvent::Hit { key: file_key }],
382 };
383 }
384
385 let purpose = json_str(file_record, "/value");
387 let mut context_lines: Vec<String> = Vec::new();
388 if !purpose.is_empty() {
389 context_lines.push(format!("Purpose: {purpose}"));
390 }
391
392 let mut deny_signal = false;
393 let gotcha_keys = json_string_array(file_record, "/payload/gotcha_keys");
394
395 for gkey in &gotcha_keys {
396 let grec = match input.gotcha_records.get(gkey.as_str()) {
397 Some(r) if r.is_object() => r,
398 _ => continue,
399 };
400
401 let confirmed = json_bool(grec, "/payload/confirmed");
402 let gconfidence = json_f32(grec, "/confidence/value");
403 let gquality = json_f32(grec, "/quality/value");
404 let rule = json_str(grec, "/value");
405
406 if confirmed && gconfidence >= 0.6 && gquality >= 0.4 {
412 deny_signal = true;
413 if !rule.is_empty() {
414 context_lines.push(format!("\u{26a0} {rule}"));
415 }
416 }
417 }
418
419 if staleness >= 0.4 {
421 context_lines.push(format!(
422 "Warning: record staleness {staleness:.2} — verify critical details."
423 ));
424 }
425
426 {
428 let blast_tier = json_str(file_record, "/payload/blast_radius/tier");
429 if blast_tier == "high" || blast_tier == "critical" {
430 let blast_direct = file_record
431 .pointer("/payload/blast_radius/direct")
432 .and_then(|v| v.as_u64())
433 .unwrap_or(0);
434 context_lines.push(format!(
435 "\u{26a0} Blast radius: {blast_direct} direct importers ({blast_tier}) — modify carefully"
436 ));
437 }
438 }
439
440 if deny_signal {
442 if input.already_consulted {
443 let context = if context_lines.is_empty() {
444 format!(
445 "Gotcha exists for {} — proceed with awareness",
446 input.rel_path
447 )
448 } else {
449 context_lines.join("\n")
450 };
451 return EnforcementResult {
455 decision: Decision::AlreadyConsulted { context },
456 events: vec![HookEvent::ComplianceHit { key: file_key }],
457 };
458 }
459
460 let safe_path = input.rel_path.replace('\\', "\\\\").replace('"', "\\\"");
461 let staleness_note = if staleness >= 0.4 {
462 format!(" (staleness {staleness:.2} — verify critical details)")
463 } else {
464 String::new()
465 };
466
467 return EnforcementResult {
468 decision: Decision::Deny {
469 file_key: file_key.clone(),
470 reason: format!(
471 "[mati] Confirmed gotcha on {safe_path} — \
472 call mem_get(\"file:{safe_path}\") and read the record \
473 before accessing this file.{staleness_note}"
474 ),
475 },
476 events: vec![HookEvent::BlockedUnconsultedRead { key: file_key }],
477 };
478 }
479
480 if confidence >= 0.3 && quality >= 0.4 {
482 let context = if context_lines.is_empty() {
483 format!(
484 "Record exists for {} — confidence {confidence:.2}",
485 input.rel_path
486 )
487 } else {
488 context_lines.join("\n")
489 };
490 return EnforcementResult {
491 decision: Decision::Advisory { context },
492 events: vec![HookEvent::Hit { key: file_key }],
493 };
494 }
495
496 EnforcementResult {
498 decision: Decision::Allow,
499 events: vec![],
500 }
501}
502
503fn json_f32(val: &serde_json::Value, pointer: &str) -> f32 {
506 val.pointer(pointer)
507 .and_then(|v| v.as_f64())
508 .map(|f| f as f32)
509 .unwrap_or(0.0)
510}
511
512fn json_str(val: &serde_json::Value, pointer: &str) -> String {
513 val.pointer(pointer)
514 .and_then(|v| v.as_str())
515 .unwrap_or("")
516 .to_string()
517}
518
519fn json_bool(val: &serde_json::Value, pointer: &str) -> bool {
520 val.pointer(pointer)
521 .and_then(|v| v.as_bool())
522 .unwrap_or(false)
523}
524
525fn json_string_array(val: &serde_json::Value, pointer: &str) -> Vec<String> {
526 val.pointer(pointer)
527 .and_then(|v| v.as_array())
528 .map(|arr| {
529 arr.iter()
530 .filter_map(|v| v.as_str().map(|s| s.to_string()))
531 .collect()
532 })
533 .unwrap_or_default()
534}
535
536#[cfg(test)]
539mod tests {
540 use super::*;
541 use serde_json::json;
542
543 #[test]
546 fn apply_patch_single_update() {
547 let patch =
548 "*** Begin Patch\n*** Update File: src/main.rs\n@@\n-old\n+new\n*** End Patch\n";
549 assert_eq!(extract_apply_patch_files(patch), vec!["src/main.rs"]);
550 }
551
552 #[test]
553 fn apply_patch_multi_file_add_update_delete() {
554 let patch = "*** Begin Patch\n\
555 *** Update File: src/a.rs\n@@\n+x\n\
556 *** Add File: src/b.rs\n+y\n\
557 *** Delete File: src/c.rs\n\
558 *** End Patch\n";
559 assert_eq!(
560 extract_apply_patch_files(patch),
561 vec!["src/a.rs", "src/b.rs", "src/c.rs"]
562 );
563 }
564
565 #[test]
566 fn apply_patch_rename_includes_source_and_destination() {
567 let patch =
568 "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n+x\n*** End Patch\n";
569 assert_eq!(
570 extract_apply_patch_files(patch),
571 vec!["src/old.rs", "src/new.rs"]
572 );
573 }
574
575 #[test]
576 fn apply_patch_ignores_marker_inside_diff_body() {
577 let patch = "*** Begin Patch\n\
581 *** Update File: src/real.rs\n@@\n\
582 +*** Update File: src/fake.rs\n\
583 + *** Add File: src/also_fake.rs\n\
584 *** End Patch\n";
585 assert_eq!(extract_apply_patch_files(patch), vec!["src/real.rs"]);
586 }
587
588 #[test]
589 fn apply_patch_dedups_repeated_path() {
590 let patch =
591 "*** Begin Patch\n*** Update File: src/a.rs\n*** Update File: src/a.rs\n*** End Patch\n";
592 assert_eq!(extract_apply_patch_files(patch), vec!["src/a.rs"]);
593 }
594
595 #[test]
596 fn apply_patch_empty_or_no_markers() {
597 assert!(extract_apply_patch_files("").is_empty());
598 assert!(extract_apply_patch_files("just some text\nno markers here").is_empty());
599 assert!(extract_apply_patch_files("*** Begin Patch\n*** End Patch\n").is_empty());
600 }
601
602 #[test]
603 fn apply_patch_trims_trailing_whitespace() {
604 let patch = "*** Update File: src/spaced.rs \n";
605 assert_eq!(extract_apply_patch_files(patch), vec!["src/spaced.rs"]);
606 }
607
608 #[test]
611 fn classify_cat() {
612 assert_eq!(
613 classify_command("cat src/main.rs"),
614 Some(CommandClass::CatLike)
615 );
616 }
617
618 #[test]
619 fn classify_head_with_flag() {
620 assert_eq!(
621 classify_command("head -n 10 file.rs"),
622 Some(CommandClass::CatLike)
623 );
624 }
625
626 #[test]
627 fn classify_leading_whitespace() {
628 assert_eq!(classify_command(" cat file"), Some(CommandClass::CatLike));
629 }
630
631 #[test]
632 fn classify_less() {
633 assert_eq!(
634 classify_command("less README.md"),
635 Some(CommandClass::CatLike)
636 );
637 }
638
639 #[test]
640 fn classify_tail() {
641 assert_eq!(
642 classify_command("tail -f log.txt"),
643 Some(CommandClass::CatLike)
644 );
645 }
646
647 #[test]
648 fn classify_bat() {
649 assert_eq!(
650 classify_command("bat src/lib.rs"),
651 Some(CommandClass::CatLike)
652 );
653 }
654
655 #[test]
656 fn classify_grep() {
657 assert_eq!(
658 classify_command("grep -rn pattern src/"),
659 Some(CommandClass::GrepLike)
660 );
661 }
662
663 #[test]
664 fn classify_rg() {
665 assert_eq!(
666 classify_command("rg TODO src/"),
667 Some(CommandClass::GrepLike)
668 );
669 }
670
671 #[test]
672 fn classify_sed() {
673 assert_eq!(
674 classify_command("sed -i 's/a/b/' file.rs"),
675 Some(CommandClass::GrepLike)
676 );
677 }
678
679 #[test]
680 fn classify_awk() {
681 assert_eq!(
682 classify_command("awk '{print $1}' file.rs"),
683 Some(CommandClass::GrepLike)
684 );
685 }
686
687 #[test]
688 fn classify_ls_is_none() {
689 assert_eq!(classify_command("ls -la"), None);
690 }
691
692 #[test]
693 fn classify_cd_is_none() {
694 assert_eq!(classify_command("cd /tmp"), None);
695 }
696
697 #[test]
698 fn classify_catch_is_none() {
699 assert_eq!(classify_command("catch errors"), None);
700 }
701
702 #[test]
703 fn classify_catalog_is_none() {
704 assert_eq!(classify_command("catalog"), None);
705 }
706
707 #[test]
708 fn classify_grep_bare_is_none() {
709 assert_eq!(classify_command("grep"), Some(CommandClass::GrepLike));
711 }
712
713 #[test]
716 fn extract_cat_simple() {
717 assert_eq!(
718 extract_file_path("cat src/main.rs", CommandClass::CatLike),
719 Some("src/main.rs".into())
720 );
721 }
722
723 #[test]
724 fn extract_cat_with_flag() {
725 assert_eq!(
726 extract_file_path("cat -n src/main.rs", CommandClass::CatLike),
727 Some("src/main.rs".into())
728 );
729 }
730
731 #[test]
732 fn extract_cat_quoted_path() {
733 assert_eq!(
734 extract_file_path(r#"cat "path with spaces/file.rs""#, CommandClass::CatLike),
735 Some("path with spaces/file.rs".into())
736 );
737 }
738
739 #[test]
740 fn extract_cat_with_pipe() {
741 assert_eq!(
742 extract_file_path("cat file.rs | grep foo", CommandClass::CatLike),
743 Some("file.rs".into())
744 );
745 }
746
747 #[test]
748 fn extract_cat_with_semicolon() {
749 assert_eq!(
750 extract_file_path("cat file.rs; echo done", CommandClass::CatLike),
751 Some("file.rs".into())
752 );
753 }
754
755 #[test]
756 fn extract_cat_with_and() {
757 assert_eq!(
758 extract_file_path("cat file.rs && echo ok", CommandClass::CatLike),
759 Some("file.rs".into())
760 );
761 }
762
763 #[test]
764 fn extract_grep_last_arg() {
765 assert_eq!(
766 extract_file_path("grep -rn pattern src/main.rs", CommandClass::GrepLike),
767 Some("src/main.rs".into())
768 );
769 }
770
771 #[test]
772 fn extract_grep_quoted_file() {
773 assert_eq!(
774 extract_file_path(r#"grep pattern "src/main.rs""#, CommandClass::GrepLike),
775 Some("src/main.rs".into())
776 );
777 }
778
779 #[test]
780 fn extract_grep_strips_single_quotes() {
781 assert_eq!(
782 extract_file_path("grep 'pattern' file.rs", CommandClass::GrepLike),
783 Some("file.rs".into())
784 );
785 }
786
787 #[test]
788 fn extract_no_args() {
789 assert_eq!(extract_file_path("cat", CommandClass::CatLike), None);
790 }
791
792 #[test]
793 fn extract_only_flags() {
794 assert_eq!(extract_file_path("cat -n -v", CommandClass::CatLike), None);
795 }
796
797 #[test]
800 fn normalize_strips_prefix() {
801 assert_eq!(
802 normalize_path("/home/user/project/src/main.rs", Some("/home/user/project")),
803 "src/main.rs"
804 );
805 }
806
807 #[test]
808 fn normalize_dot_slash() {
809 assert_eq!(normalize_path("./src/main.rs", None), "src/main.rs");
810 }
811
812 #[test]
813 fn normalize_dotdot() {
814 assert_eq!(normalize_path("src/../src/main.rs", None), "src/main.rs");
815 }
816
817 #[test]
818 fn normalize_already_relative() {
819 assert_eq!(normalize_path("src/main.rs", None), "src/main.rs");
820 }
821
822 #[test]
823 fn normalize_no_repo_root() {
824 assert_eq!(
825 normalize_path("/abs/path/file.rs", None),
826 "abs/path/file.rs"
827 );
828 }
829
830 #[test]
831 fn normalize_trailing_slash_root() {
832 assert_eq!(
834 normalize_path("/project/src/file.rs", Some("/project")),
835 "src/file.rs"
836 );
837 }
838
839 #[test]
840 fn normalize_leading_dotdot_returns_unchanged() {
841 assert_eq!(normalize_path("../other/file.rs", None), "../other/file.rs");
843 }
844
845 #[test]
846 fn normalize_deep_dotdot_escape_returns_unchanged() {
847 assert_eq!(normalize_path("foo/../../bar.rs", None), "foo/../../bar.rs");
848 }
849
850 #[test]
851 fn normalize_dotdot_within_scope_ok() {
852 assert_eq!(normalize_path("src/../lib/file.rs", None), "lib/file.rs");
854 }
855
856 fn make_file_record(
859 confidence: f32,
860 quality: f32,
861 staleness: f32,
862 staleness_tier: &str,
863 gotcha_keys: &[&str],
864 ) -> serde_json::Value {
865 json!({
866 "value": "Test file purpose",
867 "confidence": { "value": confidence },
868 "quality": { "value": quality },
869 "staleness": { "value": staleness, "tier": staleness_tier },
870 "payload": {
871 "gotcha_keys": gotcha_keys,
872 }
873 })
874 }
875
876 fn make_gotcha(confirmed: bool, confidence: f32, quality: f32) -> serde_json::Value {
877 json!({
878 "value": "Do not use unwrap here",
879 "confidence": { "value": confidence },
880 "quality": { "value": quality },
881 "payload": { "confirmed": confirmed }
882 })
883 }
884
885 #[test]
886 fn eval_no_record() {
887 let input = EnforcementInput {
888 rel_path: "src/main.rs".into(),
889 file_record: None,
890 gotcha_records: HashMap::new(),
891 already_consulted: false,
892 };
893 let result = evaluate(&input);
894 assert_eq!(result.decision, Decision::NoRecord);
895 assert_eq!(result.events.len(), 1);
896 assert!(matches!(&result.events[0], HookEvent::Miss { key } if key == "file:src/main.rs"));
897 }
898
899 #[test]
900 fn eval_tombstone() {
901 let input = EnforcementInput {
902 rel_path: "src/old.rs".into(),
903 file_record: Some(make_file_record(0.8, 0.5, 0.95, "tombstone", &[])),
904 gotcha_records: HashMap::new(),
905 already_consulted: false,
906 };
907 let result = evaluate(&input);
908 assert_eq!(result.decision, Decision::Tombstone);
909 assert!(result.events.is_empty());
910 }
911
912 #[test]
913 fn eval_liability() {
914 let input = EnforcementInput {
915 rel_path: "src/stale.rs".into(),
916 file_record: Some(make_file_record(0.8, 0.5, 0.85, "liability", &[])),
917 gotcha_records: HashMap::new(),
918 already_consulted: false,
919 };
920 let result = evaluate(&input);
921 assert!(
922 matches!(&result.decision, Decision::Liability { staleness, .. } if *staleness > 0.8)
923 );
924 assert_eq!(result.events.len(), 1);
925 assert!(matches!(&result.events[0], HookEvent::Hit { .. }));
926 }
927
928 #[test]
929 fn eval_confirmed_gotcha_denies() {
930 let mut gotchas = HashMap::new();
931 gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
932
933 let input = EnforcementInput {
934 rel_path: "src/main.rs".into(),
935 file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
936 gotcha_records: gotchas,
937 already_consulted: false,
938 };
939 let result = evaluate(&input);
940 assert!(matches!(&result.decision, Decision::Deny { .. }));
941 assert!(matches!(
942 &result.events[0],
943 HookEvent::BlockedUnconsultedRead { key } if key == "file:src/main.rs"
944 ));
945 }
946
947 #[test]
948 fn eval_unconfirmed_gotcha_allows() {
949 let mut gotchas = HashMap::new();
950 gotchas.insert("gotcha:test".to_string(), make_gotcha(false, 0.7, 0.5));
951
952 let input = EnforcementInput {
953 rel_path: "src/main.rs".into(),
954 file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
955 gotcha_records: gotchas,
956 already_consulted: false,
957 };
958 let result = evaluate(&input);
959 match &result.decision {
963 Decision::Advisory { context } => assert!(
964 !context.contains("Do not use unwrap here"),
965 "unconfirmed gotcha rule leaked into injected context: {context:?}"
966 ),
967 other => panic!("expected Advisory, got {other:?}"),
968 }
969 }
970
971 #[test]
972 fn eval_low_confidence_gotcha_allows() {
973 let mut gotchas = HashMap::new();
974 gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.4, 0.5));
975
976 let input = EnforcementInput {
977 rel_path: "src/main.rs".into(),
978 file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
979 gotcha_records: gotchas,
980 already_consulted: false,
981 };
982 let result = evaluate(&input);
983 assert!(matches!(&result.decision, Decision::Advisory { .. }));
984 }
985
986 #[test]
987 fn eval_low_quality_gotcha_allows() {
988 let mut gotchas = HashMap::new();
989 gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.2));
990
991 let input = EnforcementInput {
992 rel_path: "src/main.rs".into(),
993 file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
994 gotcha_records: gotchas,
995 already_consulted: false,
996 };
997 let result = evaluate(&input);
998 assert!(matches!(&result.decision, Decision::Advisory { .. }));
999 }
1000
1001 #[test]
1002 fn eval_consulted_downgrades_deny() {
1003 let mut gotchas = HashMap::new();
1004 gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
1005
1006 let input = EnforcementInput {
1007 rel_path: "src/main.rs".into(),
1008 file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
1009 gotcha_records: gotchas,
1010 already_consulted: true,
1011 };
1012 let result = evaluate(&input);
1013 assert!(matches!(
1014 &result.decision,
1015 Decision::AlreadyConsulted { .. }
1016 ));
1017 assert!(matches!(&result.events[0], HookEvent::ComplianceHit { .. }));
1020 }
1021
1022 #[test]
1023 fn eval_medium_confidence_advisory() {
1024 let input = EnforcementInput {
1025 rel_path: "src/main.rs".into(),
1026 file_record: Some(make_file_record(0.45, 0.5, 0.1, "fresh", &[])),
1027 gotcha_records: HashMap::new(),
1028 already_consulted: false,
1029 };
1030 let result = evaluate(&input);
1031 assert!(matches!(&result.decision, Decision::Advisory { .. }));
1032 assert!(matches!(&result.events[0], HookEvent::Hit { .. }));
1033 }
1034
1035 #[test]
1036 fn eval_low_everything_allows() {
1037 let input = EnforcementInput {
1038 rel_path: "src/main.rs".into(),
1039 file_record: Some(make_file_record(0.1, 0.1, 0.1, "fresh", &[])),
1040 gotcha_records: HashMap::new(),
1041 already_consulted: false,
1042 };
1043 let result = evaluate(&input);
1044 assert_eq!(result.decision, Decision::Allow);
1045 assert!(result.events.is_empty());
1046 }
1047
1048 #[test]
1049 fn eval_staleness_warning_appended() {
1050 let input = EnforcementInput {
1051 rel_path: "src/main.rs".into(),
1052 file_record: Some(make_file_record(0.5, 0.5, 0.5, "stale", &[])),
1053 gotcha_records: HashMap::new(),
1054 already_consulted: false,
1055 };
1056 let result = evaluate(&input);
1057 if let Decision::Advisory { context } = &result.decision {
1058 assert!(context.contains("staleness 0.50"));
1059 } else {
1060 panic!("expected Advisory, got {:?}", result.decision);
1061 }
1062 }
1063
1064 #[test]
1065 fn eval_multiple_gotchas_one_deny() {
1066 let mut gotchas = HashMap::new();
1067 gotchas.insert("gotcha:safe".to_string(), make_gotcha(false, 0.7, 0.5));
1068 gotchas.insert("gotcha:danger".to_string(), make_gotcha(true, 0.8, 0.6));
1069
1070 let input = EnforcementInput {
1071 rel_path: "src/main.rs".into(),
1072 file_record: Some(make_file_record(
1073 0.7,
1074 0.5,
1075 0.1,
1076 "fresh",
1077 &["gotcha:safe", "gotcha:danger"],
1078 )),
1079 gotcha_records: gotchas,
1080 already_consulted: false,
1081 };
1082 let result = evaluate(&input);
1083 assert!(matches!(&result.decision, Decision::Deny { .. }));
1084 }
1085
1086 #[test]
1087 fn eval_deny_includes_staleness_note() {
1088 let mut gotchas = HashMap::new();
1089 gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
1090
1091 let input = EnforcementInput {
1092 rel_path: "src/main.rs".into(),
1093 file_record: Some(make_file_record(0.7, 0.5, 0.5, "stale", &["gotcha:test"])),
1094 gotcha_records: gotchas,
1095 already_consulted: false,
1096 };
1097 let result = evaluate(&input);
1098 if let Decision::Deny { reason, .. } = &result.decision {
1099 assert!(reason.contains("staleness"));
1100 } else {
1101 panic!("expected Deny");
1102 }
1103 }
1104
1105 #[test]
1106 fn eval_invalid_json_allows() {
1107 let input = EnforcementInput {
1108 rel_path: "src/main.rs".into(),
1109 file_record: Some(json!("not an object")),
1110 gotcha_records: HashMap::new(),
1111 already_consulted: false,
1112 };
1113 let result = evaluate(&input);
1114 assert_eq!(result.decision, Decision::NoRecord);
1116 }
1117
1118 #[test]
1119 fn eval_never_produces_fail_open() {
1120 let cases: Vec<EnforcementInput> = vec![
1124 EnforcementInput {
1125 rel_path: "x".into(),
1126 file_record: None,
1127 gotcha_records: HashMap::new(),
1128 already_consulted: false,
1129 },
1130 EnforcementInput {
1131 rel_path: "x".into(),
1132 file_record: Some(json!(null)),
1133 gotcha_records: HashMap::new(),
1134 already_consulted: false,
1135 },
1136 EnforcementInput {
1137 rel_path: "x".into(),
1138 file_record: Some(json!({})),
1139 gotcha_records: HashMap::new(),
1140 already_consulted: false,
1141 },
1142 ];
1143 for input in cases {
1144 let result = evaluate(&input);
1145 assert!(matches!(
1148 result.decision,
1149 Decision::Allow
1150 | Decision::Deny { .. }
1151 | Decision::AlreadyConsulted { .. }
1152 | Decision::Advisory { .. }
1153 | Decision::Liability { .. }
1154 | Decision::Tombstone
1155 | Decision::NoRecord
1156 | Decision::NotFileRead
1157 ));
1158 }
1159 }
1160
1161 #[test]
1162 fn eval_context_includes_purpose_and_rules() {
1163 let mut gotchas = HashMap::new();
1164 gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
1165
1166 let input = EnforcementInput {
1167 rel_path: "src/main.rs".into(),
1168 file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
1169 gotcha_records: gotchas,
1170 already_consulted: true,
1171 };
1172 let result = evaluate(&input);
1173 if let Decision::AlreadyConsulted { context } = &result.decision {
1174 assert!(context.contains("Purpose: Test file purpose"));
1175 assert!(context.contains("Do not use unwrap here"));
1176 } else {
1177 panic!("expected AlreadyConsulted, got {:?}", result.decision);
1178 }
1179 }
1180
1181 #[test]
1182 fn eval_blast_radius_warning_for_critical_file() {
1183 let mut file_record = make_file_record(0.5, 0.5, 0.1, "fresh", &[]);
1184 file_record
1186 .as_object_mut()
1187 .unwrap()
1188 .get_mut("payload")
1189 .unwrap()
1190 .as_object_mut()
1191 .unwrap()
1192 .insert(
1193 "blast_radius".into(),
1194 json!({ "direct": 45, "transitive": 10, "score": 48.0, "tier": "critical" }),
1195 );
1196
1197 let input = EnforcementInput {
1198 rel_path: "src/core.rs".into(),
1199 file_record: Some(file_record),
1200 gotcha_records: HashMap::new(),
1201 already_consulted: false,
1202 };
1203 let result = evaluate(&input);
1204 if let Decision::Advisory { context } = &result.decision {
1205 assert!(
1206 context.contains("Blast radius"),
1207 "advisory context must include blast radius warning, got: {context}"
1208 );
1209 assert!(context.contains("45"), "warning must include direct count");
1210 assert!(context.contains("critical"), "warning must include tier");
1211 } else {
1212 panic!("expected Advisory, got {:?}", result.decision);
1213 }
1214 }
1215
1216 #[test]
1217 fn eval_no_blast_warning_for_low_file() {
1218 let mut file_record = make_file_record(0.5, 0.5, 0.1, "fresh", &[]);
1219 file_record
1220 .as_object_mut()
1221 .unwrap()
1222 .get_mut("payload")
1223 .unwrap()
1224 .as_object_mut()
1225 .unwrap()
1226 .insert(
1227 "blast_radius".into(),
1228 json!({ "direct": 2, "transitive": 0, "score": 2.0, "tier": "low" }),
1229 );
1230
1231 let input = EnforcementInput {
1232 rel_path: "src/leaf.rs".into(),
1233 file_record: Some(file_record),
1234 gotcha_records: HashMap::new(),
1235 already_consulted: false,
1236 };
1237 let result = evaluate(&input);
1238 if let Decision::Advisory { context } = &result.decision {
1239 assert!(
1240 !context.contains("Blast radius"),
1241 "low blast radius file should NOT have warning, got: {context}"
1242 );
1243 } else {
1244 panic!("expected Advisory, got {:?}", result.decision);
1245 }
1246 }
1247}