1mod detect;
2pub mod learn;
3pub mod mining;
4mod model;
5mod persist;
6mod reflector;
7pub mod runtime;
8
9pub use detect::{
10 DetectedError, detect_error_pattern, is_correlatable_command, normalize_error_signature,
11};
12pub use model::{
13 ErrorEntry, FixEntry, Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, GotchaStats,
14 GotchaStore, PendingError, SessionErrorLog,
15};
16pub use persist::{load_universal_gotchas, save_universal_gotchas};
17pub use reflector::{ReflectionInsight, fold_into_playbook, format_ledger, reflect};
18pub use runtime::record_shell_outcome;
19
20use chrono::{DateTime, Utc};
21use detect::command_base;
22use model::{DECAY_ARCHIVE_THRESHOLD, MAX_PENDING, MAX_SESSION_LOGS, gotcha_id};
23
24impl GotchaStore {
25 pub fn detect_error(
28 &mut self,
29 output: &str,
30 command: &str,
31 exit_code: i32,
32 files_touched: &[String],
33 session_id: &str,
34 ) -> bool {
35 self.pending_errors.retain(|p| !p.is_expired());
36
37 let Some(detected) = detect_error_pattern(output, command, exit_code) else {
38 return false;
39 };
40
41 let signature = normalize_error_signature(&detected.raw_message);
42 let snippet = output.chars().take(500).collect::<String>();
43
44 self.pending_errors.push(PendingError {
45 error_signature: signature.clone(),
46 category: detected.category,
47 severity: detected.severity,
48 command: command.to_string(),
49 exit_code,
50 files_at_error: files_touched.to_vec(),
51 timestamp: Utc::now(),
52 raw_snippet: snippet,
53 session_id: session_id.to_string(),
54 });
55
56 if self.pending_errors.len() > MAX_PENDING {
57 self.pending_errors.remove(0);
58 }
59
60 self.log_error(session_id, &signature, command);
61 self.stats.total_errors_detected += 1;
62 true
63 }
64
65 pub fn try_resolve_pending(
66 &mut self,
67 command: &str,
68 files_touched: &[String],
69 session_id: &str,
70 ) -> Option<Gotcha> {
71 self.pending_errors.retain(|p| !p.is_expired());
72
73 let cmd_base = command_base(command);
74 let idx = self
75 .pending_errors
76 .iter()
77 .position(|p| command_base(&p.command) == cmd_base)?;
78
79 let pending = self.pending_errors.remove(idx);
80
81 let changed_files: Vec<String> = files_touched
82 .iter()
83 .filter(|f| !pending.files_at_error.contains(f))
84 .cloned()
85 .collect();
86
87 let resolution = if changed_files.is_empty() {
88 format!("Fixed after re-running {cmd_base}")
89 } else {
90 format!("Fixed by editing: {}", changed_files.join(", "))
91 };
92
93 let mut gotcha = Gotcha::new(
94 pending.category,
95 pending.severity,
96 &pending.error_signature,
97 &resolution,
98 GotchaSource::AutoDetected {
99 command: command.to_string(),
100 exit_code: pending.exit_code,
101 },
102 session_id,
103 );
104 gotcha.file_patterns.clone_from(&changed_files);
105
106 self.add_or_merge(gotcha.clone());
107 self.log_fix(
108 session_id,
109 &pending.error_signature,
110 &resolution,
111 &changed_files,
112 );
113 self.stats.total_fixes_correlated += 1;
114 self.updated_at = Utc::now();
115
116 Some(gotcha)
117 }
118
119 pub fn report_gotcha(
122 &mut self,
123 trigger: &str,
124 resolution: &str,
125 category: &str,
126 severity: &str,
127 session_id: &str,
128 ) -> Option<&Gotcha> {
129 let cat = GotchaCategory::from_str_loose(category);
130 let sev = match severity.to_lowercase().as_str() {
131 "critical" => GotchaSeverity::Critical,
132 "info" => GotchaSeverity::Info,
133 _ => GotchaSeverity::Warning,
134 };
135 let id = gotcha_id(trigger, &cat);
136 let gotcha = Gotcha::new(
137 cat,
138 sev,
139 trigger,
140 resolution,
141 GotchaSource::AgentReported {
142 session_id: session_id.to_string(),
143 },
144 session_id,
145 );
146 self.add_or_merge(gotcha);
147 self.updated_at = Utc::now();
148 self.gotchas.iter().find(|g| g.id == id)
149 }
150
151 fn add_or_merge(&mut self, new: Gotcha) {
154 if let Some(existing) = self.gotchas.iter_mut().find(|g| g.id == new.id) {
155 existing.merge_with(&new);
156 } else {
157 self.gotchas.push(new);
158 if self.gotchas.len() > self.capacity {
159 self.gotchas.sort_by(|a, b| {
160 b.confidence
161 .partial_cmp(&a.confidence)
162 .unwrap_or(std::cmp::Ordering::Equal)
163 });
164 self.gotchas.truncate(self.capacity);
165 }
166 }
167 }
168
169 fn log_error(&mut self, session_id: &str, signature: &str, command: &str) {
172 let log = self.get_or_create_session_log(session_id);
173 log.errors.push(ErrorEntry {
174 signature: signature.to_string(),
175 command: command.to_string(),
176 timestamp: Utc::now(),
177 });
178 }
179
180 fn log_fix(&mut self, session_id: &str, error_sig: &str, resolution: &str, files: &[String]) {
181 let log = self.get_or_create_session_log(session_id);
182 log.fixes.push(FixEntry {
183 error_signature: error_sig.to_string(),
184 resolution: resolution.to_string(),
185 files_changed: files.to_vec(),
186 timestamp: Utc::now(),
187 });
188 }
189
190 fn get_or_create_session_log(&mut self, session_id: &str) -> &mut SessionErrorLog {
191 if !self.error_log.iter().any(|l| l.session_id == session_id) {
192 self.error_log.push(SessionErrorLog {
193 session_id: session_id.to_string(),
194 timestamp: Utc::now(),
195 errors: Vec::new(),
196 fixes: Vec::new(),
197 });
198 if self.error_log.len() > MAX_SESSION_LOGS {
199 self.error_log.remove(0);
200 }
201 }
202 self.error_log
203 .iter_mut()
204 .find(|l| l.session_id == session_id)
205 .expect("session log must exist after push")
206 }
207
208 pub fn cross_session_boost(&mut self) {
209 let mut sig_sessions: std::collections::HashMap<String, Vec<String>> =
210 std::collections::HashMap::new();
211
212 for log in &self.error_log {
213 for err in &log.errors {
214 sig_sessions
215 .entry(err.signature.clone())
216 .or_default()
217 .push(log.session_id.clone());
218 }
219 }
220
221 for gotcha in &mut self.gotchas {
222 if let Some(sessions) = sig_sessions.get(&gotcha.trigger) {
223 let unique: Vec<String> = sessions
224 .iter()
225 .filter(|s| !gotcha.session_ids.contains(s))
226 .cloned()
227 .collect();
228 if !unique.is_empty() {
229 let boost = 0.15 * unique.len() as f32;
230 gotcha.confidence = (gotcha.confidence + boost).min(0.95);
231 for s in unique {
232 gotcha.session_ids.push(s);
233 }
234 gotcha.source = GotchaSource::CrossSessionCorrelated {
235 sessions: gotcha.session_ids.clone(),
236 };
237 }
238 }
239 }
240 }
241
242 pub fn apply_decay(&mut self) {
245 let now = Utc::now();
246 let mut decayed = 0u64;
247
248 for gotcha in &mut self.gotchas {
249 let days_since = (now - gotcha.last_seen).num_days().max(0) as f32;
250 if days_since < 1.0 {
251 continue;
252 }
253 let base_rate = gotcha
254 .decay_rate_override
255 .unwrap_or_else(|| gotcha.source.decay_rate());
256 let occurrence_factor = 1.0 / (1.0 + gotcha.occurrences as f32 * 0.1);
257 let decay = base_rate * occurrence_factor * (days_since / 7.0);
258 gotcha.confidence = (gotcha.confidence - decay).max(0.0);
259 }
260
261 let before = self.gotchas.len();
262 self.gotchas.retain(|g| {
263 g.confidence >= DECAY_ARCHIVE_THRESHOLD && g.expires_at.is_none_or(|at| at > now)
264 });
265 decayed += (before - self.gotchas.len()) as u64;
266
267 self.stats.gotchas_decayed += decayed;
268 }
269
270 #[must_use]
277 pub fn promotable(&self) -> Vec<&Gotcha> {
278 let mut v: Vec<&Gotcha> = self.gotchas.iter().filter(|g| g.is_promotable()).collect();
279 v.sort_by(|a, b| {
280 b.confidence
281 .partial_cmp(&a.confidence)
282 .unwrap_or(std::cmp::Ordering::Equal)
283 });
284 v
285 }
286
287 pub fn check_promotions(&mut self) -> Vec<(String, String, String, f32)> {
288 let mut promoted = Vec::new();
289 for gotcha in &self.gotchas {
290 if gotcha.is_promotable() {
291 promoted.push((
292 gotcha.category.to_string(),
293 gotcha.trigger.clone(),
294 gotcha.resolution.clone(),
295 gotcha.confidence,
296 ));
297 }
298 }
299 self.stats.gotchas_promoted += promoted.len() as u64;
300 promoted
301 }
302
303 pub fn extract_universal(&self) -> Vec<Gotcha> {
306 self.gotchas
307 .iter()
308 .filter(|g| {
309 g.category == GotchaCategory::Platform
310 && g.occurrences >= 10
311 && g.session_ids.len() >= 5
312 })
313 .cloned()
314 .collect()
315 }
316
317 pub fn top_relevant(&self, files_touched: &[String], limit: usize) -> Vec<&Gotcha> {
320 let mut scored: Vec<(&Gotcha, f32)> = self
321 .gotchas
322 .iter()
323 .map(|g| (g, relevance_score(g, files_touched)))
324 .filter(|(_, s)| *s > 0.5)
325 .collect();
326
327 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
328 scored.into_iter().take(limit).map(|(g, _)| g).collect()
329 }
330
331 pub fn format_injection_block(&self, files_touched: &[String]) -> String {
332 let relevant = self.top_relevant(files_touched, 7);
333 if relevant.is_empty() {
334 return String::new();
335 }
336
337 let mut lines = Vec::with_capacity(relevant.len() + 2);
338 lines.push("--- PROJECT GOTCHAS (do NOT repeat these mistakes) ---".to_string());
339
340 for g in &relevant {
341 let prefix = g.severity.prefix();
342 let label = g.category.short_label();
343 let sessions = g.session_ids.len();
344 let age = format_age(g.last_seen);
345 let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
346 let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
347
348 let source_hint = match &g.source {
349 GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
350 GotchaSource::CrossSessionCorrelated { .. } => {
351 format!(", across {sessions} sessions")
352 }
353 GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
354 GotchaSource::Promoted { .. } => ", proven".to_string(),
355 };
356
357 let prevented = if g.prevented_count > 0 {
358 format!(", prevented {}x", g.prevented_count)
359 } else {
360 String::new()
361 };
362
363 lines.push(format!("[{prefix}{label}] {trigger}"));
364 lines.push(format!(
365 " FIX: {} (seen {}x{}{}, {})",
366 resolution, g.occurrences, source_hint, prevented, age
367 ));
368 }
369
370 lines.push("---".to_string());
371 crate::core::sanitize::fence_content("project_gotchas", &lines.join("\n"))
372 }
373
374 pub fn add_universal(&mut self, gotcha: Gotcha) {
377 if !self.gotchas.iter().any(|g| g.id == gotcha.id) {
378 self.gotchas.push(gotcha);
379 }
380 }
381
382 pub fn mark_prevented(&mut self, gotcha_id: &str) {
383 if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
384 g.prevented_count += 1;
385 g.confidence = (g.confidence + 0.05).min(0.99);
386 self.stats.total_prevented += 1;
387 }
388 }
389
390 pub fn format_list(&self) -> String {
393 if self.gotchas.is_empty() {
394 return "No gotchas recorded for this project.".to_string();
395 }
396
397 let mut out = Vec::new();
398 out.push(format!(" {} active gotchas\n", self.gotchas.len()));
399
400 let mut sorted = self.gotchas.clone();
401 sorted.sort_by(|a, b| {
402 b.confidence
403 .partial_cmp(&a.confidence)
404 .unwrap_or(std::cmp::Ordering::Equal)
405 });
406
407 for g in &sorted {
408 let prefix = g.severity.prefix();
409 let label = g.category.short_label();
410 let conf = (g.confidence * 100.0) as u32;
411 let source = match &g.source {
412 GotchaSource::AutoDetected { .. } => "auto",
413 GotchaSource::AgentReported { .. } => "agent",
414 GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
415 GotchaSource::Promoted { .. } => "promoted",
416 };
417 out.push(format!(
418 " [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
419 truncate_str(&g.trigger, 60),
420 g.occurrences,
421 g.session_ids.len(),
422 ));
423 out.push(format!(
424 " FIX: {}",
425 truncate_str(&g.resolution, 70)
426 ));
427 if g.prevented_count > 0 {
428 out.push(format!(" Prevented: {}x", g.prevented_count));
429 }
430 out.push(String::new());
431 }
432
433 out.push(format!(
434 " Stats: {} errors detected | {} fixes correlated | {} prevented",
435 self.stats.total_errors_detected,
436 self.stats.total_fixes_correlated,
437 self.stats.total_prevented,
438 ));
439
440 out.join("\n")
441 }
442}
443
444pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
449 let mut score: f32 = 0.0;
450
451 score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
452
453 let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
454 score += 5.0 * (-hours_ago / 168.0).exp();
455
456 let overlap = gotcha
457 .file_patterns
458 .iter()
459 .filter(|fp| {
460 files_touched
461 .iter()
462 .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
463 })
464 .count();
465 score += overlap as f32 * 3.0;
466
467 score *= gotcha.severity.multiplier();
468
469 if gotcha.session_ids.len() >= 3 {
470 score *= 1.3;
471 }
472
473 if gotcha.prevented_count > 0 {
474 score *= 1.2;
475 }
476
477 score
478}
479
480fn format_age(dt: DateTime<Utc>) -> String {
485 let diff = Utc::now() - dt;
486 let hours = diff.num_hours();
487 if hours < 1 {
488 format!("{}m ago", diff.num_minutes().max(1))
489 } else if hours < 24 {
490 format!("{hours}h ago")
491 } else {
492 format!("{}d ago", diff.num_days())
493 }
494}
495
496fn truncate_str(s: &str, max: usize) -> String {
497 if s.len() <= max {
498 s.to_string()
499 } else {
500 format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
501 }
502}
503
504#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn detect_cargo_error() {
514 let output = r"error[E0507]: cannot move out of `self.field` which is behind a shared reference
515 --> src/server.rs:42:13";
516 let result = detect_error_pattern(output, "cargo build", 1);
517 assert!(result.is_some());
518 let d = result.unwrap();
519 assert_eq!(d.category, GotchaCategory::Build);
520 assert_eq!(d.severity, GotchaSeverity::Critical);
521 assert!(d.raw_message.contains("E0507"));
522 }
523
524 #[test]
525 fn detect_npm_error() {
526 let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
527 let result = detect_error_pattern(output, "npm install", 1);
528 assert!(result.is_some());
529 assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
530 }
531
532 #[test]
533 fn detect_python_traceback() {
534 let output = "Traceback (most recent call last):\n File \"app.py\", line 5\nImportError: No module named 'flask'";
535 let result = detect_error_pattern(output, "python app.py", 1);
536 assert!(result.is_some());
537 assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
538 }
539
540 #[test]
541 fn detect_typescript_error() {
542 let output =
543 "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
544 let result = detect_error_pattern(output, "npx tsc", 1);
545 assert!(result.is_some());
546 assert_eq!(result.unwrap().category, GotchaCategory::Build);
547 }
548
549 #[test]
550 fn detect_go_error() {
551 let output = "./main.go:15:2: undefined: SomeFunc";
552 let result = detect_error_pattern(output, "go build", 1);
553 assert!(result.is_some());
554 }
555
556 #[test]
557 fn detect_jest_failure() {
558 let output = "FAIL src/app.test.ts\n TypeError: Cannot read properties of undefined";
559 let result = detect_error_pattern(output, "npx jest", 1);
560 assert!(result.is_some());
561 assert_eq!(result.unwrap().category, GotchaCategory::Test);
562 }
563
564 #[test]
565 fn no_false_positive_on_success() {
566 let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
567 let result = detect_error_pattern(output, "cargo build --release", 0);
568 assert!(result.is_none());
569 }
570
571 #[test]
572 fn normalize_signature_strips_paths() {
573 let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
574 let sig = normalize_error_signature(raw);
575 assert!(!sig.contains("/Users/foo"));
576 assert!(sig.contains("E0507"));
577 assert!(sig.contains(":_:_"));
578 }
579
580 #[test]
581 fn gotcha_store_add_and_merge() {
582 let mut store = GotchaStore::new("testhash");
583 let g1 = Gotcha::new(
584 GotchaCategory::Build,
585 GotchaSeverity::Critical,
586 "error E0507",
587 "use clone",
588 GotchaSource::AutoDetected {
589 command: "cargo build".into(),
590 exit_code: 1,
591 },
592 "s1",
593 );
594 store.add_or_merge(g1.clone());
595 assert_eq!(store.gotchas.len(), 1);
596
597 let g2 = Gotcha::new(
598 GotchaCategory::Build,
599 GotchaSeverity::Critical,
600 "error E0507",
601 "use ref pattern",
602 GotchaSource::AutoDetected {
603 command: "cargo build".into(),
604 exit_code: 1,
605 },
606 "s2",
607 );
608 store.add_or_merge(g2);
609 assert_eq!(store.gotchas.len(), 1);
610 assert_eq!(store.gotchas[0].occurrences, 2);
611 assert_eq!(store.gotchas[0].session_ids.len(), 2);
612 }
613
614 #[test]
615 fn gotcha_store_detect_and_resolve() {
616 let mut store = GotchaStore::new("testhash");
617
618 let error_output = "error[E0507]: cannot move out of `self.name`";
619 let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
620 assert!(detected);
621 assert_eq!(store.pending_errors.len(), 1);
622
623 let resolved =
624 store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
625 assert!(resolved.is_some());
626 assert_eq!(store.gotchas.len(), 1);
627 assert!(store.gotchas[0].resolution.contains("src/main.rs"));
628 }
629
630 #[test]
631 fn agent_report_gotcha() {
632 let mut store = GotchaStore::new("testhash");
633 let g = store
634 .report_gotcha(
635 "Use thiserror not anyhow",
636 "Derive thiserror::Error in library code",
637 "convention",
638 "warning",
639 "s1",
640 )
641 .expect("gotcha should be retained in empty store");
642 assert_eq!(g.confidence, 0.9);
643 assert_eq!(g.category, GotchaCategory::Convention);
644 }
645
646 #[test]
647 fn decay_reduces_confidence() {
648 let mut store = GotchaStore::new("testhash");
649 let mut g = Gotcha::new(
650 GotchaCategory::Build,
651 GotchaSeverity::Warning,
652 "test error",
653 "test fix",
654 GotchaSource::AutoDetected {
655 command: "test".into(),
656 exit_code: 1,
657 },
658 "s1",
659 );
660 g.last_seen = Utc::now() - chrono::Duration::days(30);
661 g.confidence = 0.5;
662 store.gotchas.push(g);
663
664 store.apply_decay();
665 assert!(store.gotchas[0].confidence < 0.5);
666 }
667
668 #[test]
669 fn decay_archives_low_confidence() {
670 let mut store = GotchaStore::new("testhash");
671 let mut g = Gotcha::new(
672 GotchaCategory::Build,
673 GotchaSeverity::Info,
674 "old error",
675 "old fix",
676 GotchaSource::AutoDetected {
677 command: "test".into(),
678 exit_code: 1,
679 },
680 "s1",
681 );
682 g.last_seen = Utc::now() - chrono::Duration::days(90);
683 g.confidence = 0.16;
684 store.gotchas.push(g);
685
686 store.apply_decay();
687 assert!(store.gotchas.is_empty());
688 }
689
690 #[test]
693 fn decay_honours_expiry_and_rate_override() {
694 let gotcha = |trigger: &str| {
695 let mut g = Gotcha::new(
696 GotchaCategory::Build,
697 GotchaSeverity::Warning,
698 trigger,
699 "fix",
700 GotchaSource::AutoDetected {
701 command: "test".into(),
702 exit_code: 1,
703 },
704 "s1",
705 );
706 g.last_seen = Utc::now() - chrono::Duration::days(14);
707 g.confidence = 0.5;
708 g
709 };
710 let mut store = GotchaStore::new("testhash");
711 let mut expired = gotcha("expired");
712 expired.expires_at = Some(Utc::now() - chrono::Duration::minutes(1));
713 let mut frozen = gotcha("frozen");
714 frozen.decay_rate_override = Some(0.0);
715 store.gotchas.extend([expired, frozen, gotcha("default")]);
716
717 store.apply_decay();
718
719 let left: Vec<(&str, f32)> = store
720 .gotchas
721 .iter()
722 .map(|g| (g.trigger.as_str(), g.confidence))
723 .collect();
724 assert_eq!(left.len(), 2, "{left:?}");
725 assert_eq!(left[0], ("frozen", 0.5));
726 assert!(left[1].0 == "default" && left[1].1 < 0.5);
727 }
728
729 #[test]
730 fn store_keeps_at_most_its_capacity_dropping_lowest_confidence() {
731 let mut store = GotchaStore::new("testhash");
732 store.capacity = 2;
733 for (i, confidence) in [0.9_f32, 0.3, 0.6].into_iter().enumerate() {
734 let mut g = Gotcha::new(
735 GotchaCategory::Build,
736 GotchaSeverity::Warning,
737 &format!("error {i}"),
738 "fix",
739 GotchaSource::AutoDetected {
740 command: "test".into(),
741 exit_code: 1,
742 },
743 "s1",
744 );
745 g.confidence = confidence;
746 store.add_or_merge(g);
747 }
748 let kept: Vec<f32> = store.gotchas.iter().map(|g| g.confidence).collect();
749 assert_eq!(kept, vec![0.9, 0.6]);
750 }
751
752 #[test]
753 fn relevance_score_higher_for_recent() {
754 let recent = Gotcha::new(
755 GotchaCategory::Build,
756 GotchaSeverity::Critical,
757 "error A",
758 "fix A",
759 GotchaSource::AutoDetected {
760 command: "test".into(),
761 exit_code: 1,
762 },
763 "s1",
764 );
765 let mut old = recent.clone();
766 old.last_seen = Utc::now() - chrono::Duration::days(14);
767
768 let score_recent = relevance_score(&recent, &[]);
769 let score_old = relevance_score(&old, &[]);
770 assert!(score_recent > score_old);
771 }
772
773 #[test]
774 fn relevance_score_file_overlap_boost() {
775 let mut g = Gotcha::new(
776 GotchaCategory::Build,
777 GotchaSeverity::Warning,
778 "error B",
779 "fix B",
780 GotchaSource::AutoDetected {
781 command: "test".into(),
782 exit_code: 1,
783 },
784 "s1",
785 );
786 g.file_patterns = vec!["src/server.rs".to_string()];
787
788 let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
789 let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
790 assert!(with_overlap > without_overlap);
791 }
792
793 #[test]
794 fn cross_session_boost_increases_confidence() {
795 let mut store = GotchaStore::new("testhash");
796 let mut g = Gotcha::new(
797 GotchaCategory::Build,
798 GotchaSeverity::Critical,
799 "recurring error",
800 "recurring fix",
801 GotchaSource::AutoDetected {
802 command: "cargo build".into(),
803 exit_code: 1,
804 },
805 "s1",
806 );
807 g.confidence = 0.6;
808 store.gotchas.push(g);
809
810 store.error_log.push(SessionErrorLog {
811 session_id: "s2".into(),
812 timestamp: Utc::now(),
813 errors: vec![ErrorEntry {
814 signature: "recurring error".into(),
815 command: "cargo build".into(),
816 timestamp: Utc::now(),
817 }],
818 fixes: vec![],
819 });
820 store.error_log.push(SessionErrorLog {
821 session_id: "s3".into(),
822 timestamp: Utc::now(),
823 errors: vec![ErrorEntry {
824 signature: "recurring error".into(),
825 command: "cargo build".into(),
826 timestamp: Utc::now(),
827 }],
828 fixes: vec![],
829 });
830
831 store.cross_session_boost();
832 assert!(store.gotchas[0].confidence > 0.6);
833 assert!(store.gotchas[0].session_ids.len() >= 3);
834 }
835
836 #[test]
837 fn promotion_criteria() {
838 let mut g = Gotcha::new(
839 GotchaCategory::Convention,
840 GotchaSeverity::Warning,
841 "use thiserror",
842 "derive thiserror::Error",
843 GotchaSource::AgentReported {
844 session_id: "s1".into(),
845 },
846 "s1",
847 );
848 g.confidence = 0.95;
849 g.occurrences = 6;
850 g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
851 g.prevented_count = 3;
852 assert!(g.is_promotable());
853
854 g.occurrences = 2;
855 assert!(!g.is_promotable());
856 }
857
858 #[test]
859 fn promotable_keeps_only_proven_and_sorts_by_confidence() {
860 let mut store = GotchaStore::new("testhash");
863 let mut strong = Gotcha::new(
864 GotchaCategory::Convention,
865 GotchaSeverity::Warning,
866 "use thiserror",
867 "derive thiserror::Error",
868 GotchaSource::AgentReported {
869 session_id: "s1".into(),
870 },
871 "s1",
872 );
873 strong.confidence = 0.92;
874 strong.occurrences = 6;
875 strong.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
876 strong.prevented_count = 3;
877
878 let mut strongest = strong.clone();
879 strongest.trigger = "always run fmt".into();
880 strongest.confidence = 0.98;
881
882 let mut weak = strong.clone();
884 weak.trigger = "unproven".into();
885 weak.confidence = 0.99;
886 weak.prevented_count = 0;
887
888 store.gotchas = vec![strong, weak, strongest];
889 let promotable = store.promotable();
890 assert_eq!(promotable.len(), 2, "the unproven gotcha is excluded");
891 assert_eq!(
892 promotable[0].trigger, "always run fmt",
893 "highest confidence first"
894 );
895 }
896
897 #[test]
898 fn format_injection_block_empty() {
899 let store = GotchaStore::new("testhash");
900 {
901 let found = &store.format_injection_block(&[]);
902 assert!(found.is_empty(), "{found:?}");
903 }
904 }
905
906 #[test]
907 fn format_injection_block_with_gotchas() {
908 let mut store = GotchaStore::new("testhash");
909 store.add_or_merge(Gotcha::new(
910 GotchaCategory::Build,
911 GotchaSeverity::Critical,
912 "cargo E0507",
913 "use clone",
914 GotchaSource::AutoDetected {
915 command: "cargo build".into(),
916 exit_code: 1,
917 },
918 "s1",
919 ));
920
921 let block = store.format_injection_block(&[]);
922 assert!(block.contains("PROJECT GOTCHAS"));
923 assert!(block.contains("cargo E0507"));
924 assert!(block.contains("use clone"));
925 }
926
927 #[test]
928 fn truncate_str_utf8_umlaut_no_panic() {
929 let result = truncate_str("Fehler in Datei übergrößetest.rs", 20);
930 assert!(result.len() <= 23); assert!(result.ends_with("..."));
932 }
933
934 #[test]
935 fn truncate_str_utf8_emoji_no_panic() {
936 let result = truncate_str("Error 🔥 in module 🧪 test", 15);
937 assert!(result.ends_with("..."));
938 }
939
940 #[test]
941 fn truncate_str_short_utf8_unchanged() {
942 let input = "Ölüm";
943 assert_eq!(truncate_str(input, 20), input);
944 }
945
946 #[test]
947 fn correlatable_command_gate() {
948 for cmd in [
949 "cargo build",
950 "cargo test --release",
951 "npm install",
952 "pnpm run build",
953 "python app.py",
954 "go build ./...",
955 "npx tsc --noEmit",
956 "poetry run pytest -q",
957 "docker build .",
958 "git rebase main",
959 ] {
960 assert!(is_correlatable_command(cmd), "should correlate: {cmd}");
961 }
962 for cmd in ["ls -la", "cat README.md", "echo hi", "cd rust", "pwd"] {
963 assert!(!is_correlatable_command(cmd), "should skip: {cmd}");
964 }
965 }
966}