1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::path::PathBuf;
6
7const MAX_GOTCHAS: usize = 100;
8const MAX_SESSION_LOGS: usize = 20;
9const MAX_PENDING: usize = 10;
10const PENDING_TIMEOUT_SECS: i64 = 900; const DECAY_ARCHIVE_THRESHOLD: f32 = 0.15;
12const PROMOTION_CONFIDENCE: f32 = 0.9;
13const PROMOTION_OCCURRENCES: u32 = 5;
14const PROMOTION_SESSIONS: usize = 3;
15const PROMOTION_PREVENTED: u32 = 2;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub enum GotchaCategory {
23 Build,
24 Test,
25 Config,
26 Runtime,
27 Dependency,
28 Platform,
29 Convention,
30 Security,
31}
32
33impl GotchaCategory {
34 pub fn from_str_loose(s: &str) -> Self {
35 match s.to_lowercase().as_str() {
36 "build" | "compile" => Self::Build,
37 "test" => Self::Test,
38 "config" | "configuration" => Self::Config,
39 "runtime" => Self::Runtime,
40 "dependency" | "dep" | "deps" => Self::Dependency,
41 "platform" | "os" => Self::Platform,
42 "convention" | "style" | "lint" => Self::Convention,
43 "security" | "sec" => Self::Security,
44 _ => Self::Convention,
45 }
46 }
47
48 pub fn short_label(&self) -> &'static str {
49 match self {
50 Self::Build => "build",
51 Self::Test => "test",
52 Self::Config => "config",
53 Self::Runtime => "runtime",
54 Self::Dependency => "dep",
55 Self::Platform => "platform",
56 Self::Convention => "conv",
57 Self::Security => "sec",
58 }
59 }
60}
61
62impl std::fmt::Display for GotchaCategory {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(self.short_label())
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub enum GotchaSeverity {
70 Critical,
71 Warning,
72 Info,
73}
74
75impl GotchaSeverity {
76 pub fn multiplier(&self) -> f32 {
77 match self {
78 Self::Critical => 1.5,
79 Self::Warning => 1.0,
80 Self::Info => 0.7,
81 }
82 }
83
84 pub fn prefix(&self) -> &'static str {
85 match self {
86 Self::Critical => "!",
87 Self::Warning => "!",
88 Self::Info => "",
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub enum GotchaSource {
95 AutoDetected { command: String, exit_code: i32 },
96 AgentReported { session_id: String },
97 CrossSessionCorrelated { sessions: Vec<String> },
98 Promoted { from_knowledge_key: String },
99}
100
101impl GotchaSource {
102 pub fn decay_rate(&self) -> f32 {
103 match self {
104 Self::Promoted { .. } => 0.01,
105 Self::AgentReported { .. } => 0.02,
106 Self::CrossSessionCorrelated { .. } => 0.03,
107 Self::AutoDetected { .. } => 0.05,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct Gotcha {
114 pub id: String,
115 pub category: GotchaCategory,
116 pub severity: GotchaSeverity,
117 pub trigger: String,
118 pub resolution: String,
119 pub file_patterns: Vec<String>,
120 pub occurrences: u32,
121 pub session_ids: Vec<String>,
122 pub first_seen: DateTime<Utc>,
123 pub last_seen: DateTime<Utc>,
124 pub confidence: f32,
125 pub source: GotchaSource,
126 pub prevented_count: u32,
127 pub tags: Vec<String>,
128}
129
130impl Gotcha {
131 pub fn new(
132 category: GotchaCategory,
133 severity: GotchaSeverity,
134 trigger: &str,
135 resolution: &str,
136 source: GotchaSource,
137 session_id: &str,
138 ) -> Self {
139 let now = Utc::now();
140 let confidence = match &source {
141 GotchaSource::AgentReported { .. } => 0.9,
142 GotchaSource::CrossSessionCorrelated { .. } => 0.85,
143 GotchaSource::AutoDetected { .. } => 0.6,
144 GotchaSource::Promoted { .. } => 0.95,
145 };
146 Self {
147 id: gotcha_id(trigger, &category),
148 category,
149 severity,
150 trigger: trigger.to_string(),
151 resolution: resolution.to_string(),
152 file_patterns: Vec::new(),
153 occurrences: 1,
154 session_ids: vec![session_id.to_string()],
155 first_seen: now,
156 last_seen: now,
157 confidence,
158 source,
159 prevented_count: 0,
160 tags: Vec::new(),
161 }
162 }
163
164 pub fn merge_with(&mut self, other: &Gotcha) {
165 self.occurrences += other.occurrences;
166 for sid in &other.session_ids {
167 if !self.session_ids.contains(sid) {
168 self.session_ids.push(sid.clone());
169 }
170 }
171 for fp in &other.file_patterns {
172 if !self.file_patterns.contains(fp) {
173 self.file_patterns.push(fp.clone());
174 }
175 }
176 if other.last_seen > self.last_seen {
177 self.last_seen = other.last_seen;
178 self.resolution = other.resolution.clone();
179 }
180 self.confidence = self.confidence.max(other.confidence);
181 }
182
183 pub fn is_promotable(&self) -> bool {
184 self.confidence >= PROMOTION_CONFIDENCE
185 && self.occurrences >= PROMOTION_OCCURRENCES
186 && self.session_ids.len() >= PROMOTION_SESSIONS
187 && self.prevented_count >= PROMOTION_PREVENTED
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct PendingError {
197 pub error_signature: String,
198 pub category: GotchaCategory,
199 pub severity: GotchaSeverity,
200 pub command: String,
201 pub exit_code: i32,
202 pub files_at_error: Vec<String>,
203 pub timestamp: DateTime<Utc>,
204 pub raw_snippet: String,
205 pub session_id: String,
206}
207
208impl PendingError {
209 pub fn is_expired(&self) -> bool {
210 (Utc::now() - self.timestamp).num_seconds() > PENDING_TIMEOUT_SECS
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct SessionErrorLog {
220 pub session_id: String,
221 pub timestamp: DateTime<Utc>,
222 pub errors: Vec<ErrorEntry>,
223 pub fixes: Vec<FixEntry>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ErrorEntry {
228 pub signature: String,
229 pub command: String,
230 pub timestamp: DateTime<Utc>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FixEntry {
235 pub error_signature: String,
236 pub resolution: String,
237 pub files_changed: Vec<String>,
238 pub timestamp: DateTime<Utc>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, Default)]
246pub struct GotchaStats {
247 pub total_errors_detected: u64,
248 pub total_fixes_correlated: u64,
249 pub total_prevented: u64,
250 pub gotchas_promoted: u64,
251 pub gotchas_decayed: u64,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct GotchaStore {
260 pub project_hash: String,
261 pub gotchas: Vec<Gotcha>,
262 #[serde(default)]
263 pub error_log: Vec<SessionErrorLog>,
264 #[serde(default)]
265 pub stats: GotchaStats,
266 pub updated_at: DateTime<Utc>,
267
268 #[serde(skip)]
269 pub pending_errors: Vec<PendingError>,
270}
271
272impl GotchaStore {
273 pub fn new(project_hash: &str) -> Self {
274 Self {
275 project_hash: project_hash.to_string(),
276 gotchas: Vec::new(),
277 error_log: Vec::new(),
278 stats: GotchaStats::default(),
279 updated_at: Utc::now(),
280 pending_errors: Vec::new(),
281 }
282 }
283
284 pub fn load(project_root: &str) -> Self {
285 let hash = crate::core::project_hash::hash_project_root(project_root);
286 let path = gotcha_path(&hash);
287 if let Ok(content) = std::fs::read_to_string(&path) {
288 if let Ok(mut store) = serde_json::from_str::<GotchaStore>(&content) {
289 store.apply_decay();
290 store.pending_errors = Vec::new();
291 return store;
292 }
293 }
294 Self::new(&hash)
295 }
296
297 pub fn save(&self, project_root: &str) -> Result<(), String> {
298 let hash = crate::core::project_hash::hash_project_root(project_root);
299 let path = gotcha_path(&hash);
300 if let Some(parent) = path.parent() {
301 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
302 }
303 let tmp = path.with_extension("tmp");
304 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
305 std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
306 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
307 Ok(())
308 }
309
310 pub fn detect_error(
313 &mut self,
314 output: &str,
315 command: &str,
316 exit_code: i32,
317 files_touched: &[String],
318 session_id: &str,
319 ) -> bool {
320 self.pending_errors.retain(|p| !p.is_expired());
321
322 let Some(detected) = detect_error_pattern(output, command, exit_code) else {
323 return false;
324 };
325
326 let signature = normalize_error_signature(&detected.raw_message);
327 let snippet = output.chars().take(500).collect::<String>();
328
329 self.pending_errors.push(PendingError {
330 error_signature: signature.clone(),
331 category: detected.category,
332 severity: detected.severity,
333 command: command.to_string(),
334 exit_code,
335 files_at_error: files_touched.to_vec(),
336 timestamp: Utc::now(),
337 raw_snippet: snippet,
338 session_id: session_id.to_string(),
339 });
340
341 if self.pending_errors.len() > MAX_PENDING {
342 self.pending_errors.remove(0);
343 }
344
345 self.log_error(session_id, &signature, command);
346 self.stats.total_errors_detected += 1;
347 true
348 }
349
350 pub fn try_resolve_pending(
351 &mut self,
352 command: &str,
353 files_touched: &[String],
354 session_id: &str,
355 ) -> Option<Gotcha> {
356 self.pending_errors.retain(|p| !p.is_expired());
357
358 let cmd_base = command_base(command);
359 let idx = self
360 .pending_errors
361 .iter()
362 .position(|p| command_base(&p.command) == cmd_base)?;
363
364 let pending = self.pending_errors.remove(idx);
365
366 let changed_files: Vec<String> = files_touched
367 .iter()
368 .filter(|f| !pending.files_at_error.contains(f))
369 .cloned()
370 .collect();
371
372 let resolution = if changed_files.is_empty() {
373 format!("Fixed after re-running {}", cmd_base)
374 } else {
375 format!("Fixed by editing: {}", changed_files.join(", "))
376 };
377
378 let mut gotcha = Gotcha::new(
379 pending.category,
380 pending.severity,
381 &pending.error_signature,
382 &resolution,
383 GotchaSource::AutoDetected {
384 command: command.to_string(),
385 exit_code: pending.exit_code,
386 },
387 session_id,
388 );
389 gotcha.file_patterns = changed_files.clone();
390
391 self.add_or_merge(gotcha.clone());
392 self.log_fix(
393 session_id,
394 &pending.error_signature,
395 &resolution,
396 &changed_files,
397 );
398 self.stats.total_fixes_correlated += 1;
399 self.updated_at = Utc::now();
400
401 Some(gotcha)
402 }
403
404 pub fn report_gotcha(
407 &mut self,
408 trigger: &str,
409 resolution: &str,
410 category: &str,
411 severity: &str,
412 session_id: &str,
413 ) -> Option<&Gotcha> {
414 let cat = GotchaCategory::from_str_loose(category);
415 let sev = match severity.to_lowercase().as_str() {
416 "critical" => GotchaSeverity::Critical,
417 "info" => GotchaSeverity::Info,
418 _ => GotchaSeverity::Warning,
419 };
420 let id = gotcha_id(trigger, &cat);
421 let gotcha = Gotcha::new(
422 cat,
423 sev,
424 trigger,
425 resolution,
426 GotchaSource::AgentReported {
427 session_id: session_id.to_string(),
428 },
429 session_id,
430 );
431 self.add_or_merge(gotcha);
432 self.updated_at = Utc::now();
433 self.gotchas.iter().find(|g| g.id == id)
434 }
435
436 fn add_or_merge(&mut self, new: Gotcha) {
439 if let Some(existing) = self.gotchas.iter_mut().find(|g| g.id == new.id) {
440 existing.merge_with(&new);
441 } else {
442 self.gotchas.push(new);
443 if self.gotchas.len() > MAX_GOTCHAS {
444 self.gotchas.sort_by(|a, b| {
445 b.confidence
446 .partial_cmp(&a.confidence)
447 .unwrap_or(std::cmp::Ordering::Equal)
448 });
449 self.gotchas.truncate(MAX_GOTCHAS);
450 }
451 }
452 }
453
454 fn log_error(&mut self, session_id: &str, signature: &str, command: &str) {
457 let log = self.get_or_create_session_log(session_id);
458 log.errors.push(ErrorEntry {
459 signature: signature.to_string(),
460 command: command.to_string(),
461 timestamp: Utc::now(),
462 });
463 }
464
465 fn log_fix(&mut self, session_id: &str, error_sig: &str, resolution: &str, files: &[String]) {
466 let log = self.get_or_create_session_log(session_id);
467 log.fixes.push(FixEntry {
468 error_signature: error_sig.to_string(),
469 resolution: resolution.to_string(),
470 files_changed: files.to_vec(),
471 timestamp: Utc::now(),
472 });
473 }
474
475 fn get_or_create_session_log(&mut self, session_id: &str) -> &mut SessionErrorLog {
476 if !self.error_log.iter().any(|l| l.session_id == session_id) {
477 self.error_log.push(SessionErrorLog {
478 session_id: session_id.to_string(),
479 timestamp: Utc::now(),
480 errors: Vec::new(),
481 fixes: Vec::new(),
482 });
483 if self.error_log.len() > MAX_SESSION_LOGS {
484 self.error_log.remove(0);
485 }
486 }
487 self.error_log
488 .iter_mut()
489 .find(|l| l.session_id == session_id)
490 .expect("session log must exist after push")
491 }
492
493 pub fn cross_session_boost(&mut self) {
494 let mut sig_sessions: std::collections::HashMap<String, Vec<String>> =
495 std::collections::HashMap::new();
496
497 for log in &self.error_log {
498 for err in &log.errors {
499 sig_sessions
500 .entry(err.signature.clone())
501 .or_default()
502 .push(log.session_id.clone());
503 }
504 }
505
506 for gotcha in &mut self.gotchas {
507 if let Some(sessions) = sig_sessions.get(&gotcha.trigger) {
508 let unique: Vec<String> = sessions
509 .iter()
510 .filter(|s| !gotcha.session_ids.contains(s))
511 .cloned()
512 .collect();
513 if !unique.is_empty() {
514 let boost = 0.15 * unique.len() as f32;
515 gotcha.confidence = (gotcha.confidence + boost).min(0.95);
516 for s in unique {
517 gotcha.session_ids.push(s);
518 }
519 gotcha.source = GotchaSource::CrossSessionCorrelated {
520 sessions: gotcha.session_ids.clone(),
521 };
522 }
523 }
524 }
525 }
526
527 pub fn apply_decay(&mut self) {
530 let now = Utc::now();
531 let mut decayed = 0u64;
532
533 for gotcha in &mut self.gotchas {
534 let days_since = (now - gotcha.last_seen).num_days().max(0) as f32;
535 if days_since < 1.0 {
536 continue;
537 }
538 let base_rate = gotcha.source.decay_rate();
539 let occurrence_factor = 1.0 / (1.0 + gotcha.occurrences as f32 * 0.1);
540 let decay = base_rate * occurrence_factor * (days_since / 7.0);
541 gotcha.confidence = (gotcha.confidence - decay).max(0.0);
542 }
543
544 let before = self.gotchas.len();
545 self.gotchas
546 .retain(|g| g.confidence >= DECAY_ARCHIVE_THRESHOLD);
547 decayed += (before - self.gotchas.len()) as u64;
548
549 self.stats.gotchas_decayed += decayed;
550 }
551
552 pub fn check_promotions(&mut self) -> Vec<(String, String, String, f32)> {
555 let mut promoted = Vec::new();
556 for gotcha in &self.gotchas {
557 if gotcha.is_promotable() {
558 promoted.push((
559 gotcha.category.to_string(),
560 gotcha.trigger.clone(),
561 gotcha.resolution.clone(),
562 gotcha.confidence,
563 ));
564 }
565 }
566 self.stats.gotchas_promoted += promoted.len() as u64;
567 promoted
568 }
569
570 pub fn extract_universal(&self) -> Vec<Gotcha> {
573 self.gotchas
574 .iter()
575 .filter(|g| {
576 g.category == GotchaCategory::Platform
577 && g.occurrences >= 10
578 && g.session_ids.len() >= 5
579 })
580 .cloned()
581 .collect()
582 }
583
584 pub fn top_relevant(&self, files_touched: &[String], limit: usize) -> Vec<&Gotcha> {
587 let mut scored: Vec<(&Gotcha, f32)> = self
588 .gotchas
589 .iter()
590 .map(|g| (g, relevance_score(g, files_touched)))
591 .filter(|(_, s)| *s > 0.5)
592 .collect();
593
594 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
595 scored.into_iter().take(limit).map(|(g, _)| g).collect()
596 }
597
598 pub fn format_injection_block(&self, files_touched: &[String]) -> String {
599 let relevant = self.top_relevant(files_touched, 7);
600 if relevant.is_empty() {
601 return String::new();
602 }
603
604 let mut lines = Vec::with_capacity(relevant.len() + 2);
605 lines.push("--- PROJECT GOTCHAS (do NOT repeat these mistakes) ---".to_string());
606
607 for g in &relevant {
608 let prefix = g.severity.prefix();
609 let label = g.category.short_label();
610 let sessions = g.session_ids.len();
611 let age = format_age(g.last_seen);
612 let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
613 let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
614
615 let source_hint = match &g.source {
616 GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
617 GotchaSource::CrossSessionCorrelated { .. } => {
618 format!(", across {} sessions", sessions)
619 }
620 GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
621 GotchaSource::Promoted { .. } => ", proven".to_string(),
622 };
623
624 let prevented = if g.prevented_count > 0 {
625 format!(", prevented {}x", g.prevented_count)
626 } else {
627 String::new()
628 };
629
630 lines.push(format!("[{prefix}{label}] {trigger}"));
631 lines.push(format!(
632 " FIX: {} (seen {}x{}{}, {})",
633 resolution, g.occurrences, source_hint, prevented, age
634 ));
635 }
636
637 lines.push("---".to_string());
638 crate::core::sanitize::fence_content("project_gotchas", &lines.join("\n"))
639 }
640
641 pub fn mark_prevented(&mut self, gotcha_id: &str) {
644 if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
645 g.prevented_count += 1;
646 g.confidence = (g.confidence + 0.05).min(0.99);
647 self.stats.total_prevented += 1;
648 }
649 }
650
651 pub fn format_list(&self) -> String {
654 if self.gotchas.is_empty() {
655 return "No gotchas recorded for this project.".to_string();
656 }
657
658 let mut out = Vec::new();
659 out.push(format!(" {} active gotchas\n", self.gotchas.len()));
660
661 let mut sorted = self.gotchas.clone();
662 sorted.sort_by(|a, b| {
663 b.confidence
664 .partial_cmp(&a.confidence)
665 .unwrap_or(std::cmp::Ordering::Equal)
666 });
667
668 for g in &sorted {
669 let prefix = g.severity.prefix();
670 let label = g.category.short_label();
671 let conf = (g.confidence * 100.0) as u32;
672 let source = match &g.source {
673 GotchaSource::AutoDetected { .. } => "auto",
674 GotchaSource::AgentReported { .. } => "agent",
675 GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
676 GotchaSource::Promoted { .. } => "promoted",
677 };
678 out.push(format!(
679 " [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
680 truncate_str(&g.trigger, 60),
681 g.occurrences,
682 g.session_ids.len(),
683 ));
684 out.push(format!(
685 " FIX: {}",
686 truncate_str(&g.resolution, 70)
687 ));
688 if g.prevented_count > 0 {
689 out.push(format!(" Prevented: {}x", g.prevented_count));
690 }
691 out.push(String::new());
692 }
693
694 out.push(format!(
695 " Stats: {} errors detected | {} fixes correlated | {} prevented",
696 self.stats.total_errors_detected,
697 self.stats.total_fixes_correlated,
698 self.stats.total_prevented,
699 ));
700
701 out.join("\n")
702 }
703
704 pub fn clear(&mut self) {
705 self.gotchas.clear();
706 self.pending_errors.clear();
707 self.updated_at = Utc::now();
708 }
709}
710
711pub struct DetectedError {
716 pub category: GotchaCategory,
717 pub severity: GotchaSeverity,
718 pub raw_message: String,
719}
720
721pub fn detect_error_pattern(output: &str, command: &str, exit_code: i32) -> Option<DetectedError> {
722 let cmd_lower = command.to_lowercase();
723 let out_lower = output.to_lowercase();
724
725 if cmd_lower.starts_with("cargo ") || cmd_lower.contains("rustc") {
727 if let Some(msg) = extract_pattern(output, r"error\[E\d{4}\]: .+") {
728 return Some(DetectedError {
729 category: GotchaCategory::Build,
730 severity: GotchaSeverity::Critical,
731 raw_message: msg,
732 });
733 }
734 if out_lower.contains("cannot find") || out_lower.contains("mismatched types") {
735 return Some(DetectedError {
736 category: GotchaCategory::Build,
737 severity: GotchaSeverity::Critical,
738 raw_message: extract_first_error_line(output),
739 });
740 }
741 if out_lower.contains("test result: failed") || out_lower.contains("failures:") {
742 return Some(DetectedError {
743 category: GotchaCategory::Test,
744 severity: GotchaSeverity::Critical,
745 raw_message: extract_first_error_line(output),
746 });
747 }
748 }
749
750 if (cmd_lower.starts_with("npm ")
752 || cmd_lower.starts_with("pnpm ")
753 || cmd_lower.starts_with("yarn "))
754 && (out_lower.contains("err!") || out_lower.contains("eresolve"))
755 {
756 return Some(DetectedError {
757 category: GotchaCategory::Dependency,
758 severity: GotchaSeverity::Critical,
759 raw_message: extract_first_error_line(output),
760 });
761 }
762
763 if cmd_lower.starts_with("node ") || cmd_lower.contains("tsx ") || cmd_lower.contains("ts-node")
765 {
766 for pat in &[
767 "syntaxerror",
768 "typeerror",
769 "referenceerror",
770 "cannot find module",
771 ] {
772 if out_lower.contains(pat) {
773 return Some(DetectedError {
774 category: GotchaCategory::Runtime,
775 severity: GotchaSeverity::Critical,
776 raw_message: extract_first_error_line(output),
777 });
778 }
779 }
780 }
781
782 if (cmd_lower.starts_with("python")
784 || cmd_lower.starts_with("pip ")
785 || cmd_lower.starts_with("uv "))
786 && (out_lower.contains("traceback")
787 || out_lower.contains("importerror")
788 || out_lower.contains("modulenotfounderror"))
789 {
790 return Some(DetectedError {
791 category: GotchaCategory::Runtime,
792 severity: GotchaSeverity::Critical,
793 raw_message: extract_first_error_line(output),
794 });
795 }
796
797 if cmd_lower.starts_with("go ")
799 && (out_lower.contains("cannot use") || out_lower.contains("undefined:"))
800 {
801 return Some(DetectedError {
802 category: GotchaCategory::Build,
803 severity: GotchaSeverity::Critical,
804 raw_message: extract_first_error_line(output),
805 });
806 }
807
808 if cmd_lower.contains("tsc") || cmd_lower.contains("typescript") {
810 if let Some(msg) = extract_pattern(output, r"TS\d{4}: .+") {
811 return Some(DetectedError {
812 category: GotchaCategory::Build,
813 severity: GotchaSeverity::Critical,
814 raw_message: msg,
815 });
816 }
817 }
818
819 if cmd_lower.starts_with("docker ")
821 && out_lower.contains("error")
822 && (out_lower.contains("failed to") || out_lower.contains("copy failed"))
823 {
824 return Some(DetectedError {
825 category: GotchaCategory::Build,
826 severity: GotchaSeverity::Critical,
827 raw_message: extract_first_error_line(output),
828 });
829 }
830
831 if cmd_lower.starts_with("git ")
833 && (out_lower.contains("conflict")
834 || out_lower.contains("rejected")
835 || out_lower.contains("diverged"))
836 {
837 return Some(DetectedError {
838 category: GotchaCategory::Config,
839 severity: GotchaSeverity::Warning,
840 raw_message: extract_first_error_line(output),
841 });
842 }
843
844 if cmd_lower.contains("pytest") && (out_lower.contains("failed") || out_lower.contains("error"))
846 {
847 return Some(DetectedError {
848 category: GotchaCategory::Test,
849 severity: GotchaSeverity::Critical,
850 raw_message: extract_first_error_line(output),
851 });
852 }
853
854 if (cmd_lower.contains("jest") || cmd_lower.contains("vitest"))
856 && (out_lower.contains("fail") || out_lower.contains("typeerror"))
857 {
858 return Some(DetectedError {
859 category: GotchaCategory::Test,
860 severity: GotchaSeverity::Critical,
861 raw_message: extract_first_error_line(output),
862 });
863 }
864
865 if (cmd_lower.starts_with("make") || cmd_lower.contains("cmake"))
867 && out_lower.contains("error")
868 && (out_lower.contains("undefined reference") || out_lower.contains("no rule"))
869 {
870 return Some(DetectedError {
871 category: GotchaCategory::Build,
872 severity: GotchaSeverity::Critical,
873 raw_message: extract_first_error_line(output),
874 });
875 }
876
877 if exit_code != 0
879 && output.len() > 50
880 && (out_lower.contains("error")
881 || out_lower.contains("fatal")
882 || out_lower.contains("failed"))
883 {
884 return Some(DetectedError {
885 category: GotchaCategory::Runtime,
886 severity: GotchaSeverity::Warning,
887 raw_message: extract_first_error_line(output),
888 });
889 }
890
891 None
892}
893
894pub fn normalize_error_signature(raw: &str) -> String {
899 let mut sig = raw.to_string();
900
901 sig = regex_replace(&sig, r"(/[A-Za-z][\w.-]*/)+", "");
903
904 sig = regex_replace(&sig, r"[A-Z]:\\[\w\\.-]+\\", "");
906
907 sig = regex_replace(&sig, r":\d+:\d+", ":_:_");
909 sig = regex_replace(&sig, r"line \d+", "line _");
910
911 sig = regex_replace(&sig, r"\s+", " ");
913
914 if sig.len() > 200 {
916 sig.truncate(200);
917 }
918
919 sig.trim().to_string()
920}
921
922pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
927 let mut score: f32 = 0.0;
928
929 score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
931
932 let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
934 score += 5.0 * (-hours_ago / 168.0).exp();
935
936 let overlap = gotcha
938 .file_patterns
939 .iter()
940 .filter(|fp| {
941 files_touched
942 .iter()
943 .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
944 })
945 .count();
946 score += overlap as f32 * 3.0;
947
948 score *= gotcha.severity.multiplier();
950
951 if gotcha.session_ids.len() >= 3 {
953 score *= 1.3;
954 }
955
956 if gotcha.prevented_count > 0 {
958 score *= 1.2;
959 }
960
961 score
962}
963
964pub fn load_universal_gotchas() -> Vec<Gotcha> {
969 let dir = match crate::core::data_dir::lean_ctx_data_dir() {
970 Ok(d) => d,
971 Err(_) => return Vec::new(),
972 };
973 let path = dir.join("universal-gotchas.json");
974 if let Ok(content) = std::fs::read_to_string(&path) {
975 serde_json::from_str(&content).unwrap_or_default()
976 } else {
977 Vec::new()
978 }
979}
980
981pub fn save_universal_gotchas(gotchas: &[Gotcha]) -> Result<(), String> {
982 let dir = crate::core::data_dir::lean_ctx_data_dir()?;
983 let path = dir.join("universal-gotchas.json");
984 let tmp = path.with_extension("tmp");
985 let json = serde_json::to_string_pretty(gotchas).map_err(|e| e.to_string())?;
986 std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
987 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
988 Ok(())
989}
990
991fn gotcha_path(project_hash: &str) -> PathBuf {
996 crate::core::data_dir::lean_ctx_data_dir()
997 .unwrap_or_else(|_| PathBuf::from("."))
998 .join("knowledge")
999 .join(project_hash)
1000 .join("gotchas.json")
1001}
1002
1003fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
1004 let mut hasher = DefaultHasher::new();
1005 trigger.hash(&mut hasher);
1006 category.short_label().hash(&mut hasher);
1007 format!("{:016x}", hasher.finish())
1008}
1009
1010fn command_base(cmd: &str) -> String {
1011 let parts: Vec<&str> = cmd.split_whitespace().collect();
1012 if parts.len() >= 2 {
1013 format!("{} {}", parts[0], parts[1])
1014 } else {
1015 parts.first().unwrap_or(&"").to_string()
1016 }
1017}
1018
1019fn extract_pattern(text: &str, pattern: &str) -> Option<String> {
1020 let re = regex::Regex::new(pattern).ok()?;
1021 re.find(text).map(|m| m.as_str().to_string())
1022}
1023
1024fn extract_first_error_line(output: &str) -> String {
1025 for line in output.lines() {
1026 let ll = line.to_lowercase();
1027 if ll.contains("error") || ll.contains("failed") || ll.contains("traceback") {
1028 let trimmed = line.trim();
1029 if trimmed.len() > 200 {
1030 return trimmed[..200].to_string();
1031 }
1032 return trimmed.to_string();
1033 }
1034 }
1035 output.lines().next().unwrap_or("unknown error").to_string()
1036}
1037
1038fn regex_replace(text: &str, pattern: &str, replacement: &str) -> String {
1039 match regex::Regex::new(pattern) {
1040 Ok(re) => re.replace_all(text, replacement).to_string(),
1041 Err(_) => text.to_string(),
1042 }
1043}
1044
1045fn format_age(dt: DateTime<Utc>) -> String {
1046 let diff = Utc::now() - dt;
1047 let hours = diff.num_hours();
1048 if hours < 1 {
1049 format!("{}m ago", diff.num_minutes().max(1))
1050 } else if hours < 24 {
1051 format!("{}h ago", hours)
1052 } else {
1053 format!("{}d ago", diff.num_days())
1054 }
1055}
1056
1057fn truncate_str(s: &str, max: usize) -> String {
1058 if s.len() <= max {
1059 s.to_string()
1060 } else {
1061 format!("{}...", &s[..max.saturating_sub(3)])
1062 }
1063}
1064
1065#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 #[test]
1074 fn detect_cargo_error() {
1075 let output = r#"error[E0507]: cannot move out of `self.field` which is behind a shared reference
1076 --> src/server.rs:42:13"#;
1077 let result = detect_error_pattern(output, "cargo build", 1);
1078 assert!(result.is_some());
1079 let d = result.unwrap();
1080 assert_eq!(d.category, GotchaCategory::Build);
1081 assert_eq!(d.severity, GotchaSeverity::Critical);
1082 assert!(d.raw_message.contains("E0507"));
1083 }
1084
1085 #[test]
1086 fn detect_npm_error() {
1087 let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
1088 let result = detect_error_pattern(output, "npm install", 1);
1089 assert!(result.is_some());
1090 assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
1091 }
1092
1093 #[test]
1094 fn detect_python_traceback() {
1095 let output = "Traceback (most recent call last):\n File \"app.py\", line 5\nImportError: No module named 'flask'";
1096 let result = detect_error_pattern(output, "python app.py", 1);
1097 assert!(result.is_some());
1098 assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
1099 }
1100
1101 #[test]
1102 fn detect_typescript_error() {
1103 let output =
1104 "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
1105 let result = detect_error_pattern(output, "npx tsc", 1);
1106 assert!(result.is_some());
1107 assert_eq!(result.unwrap().category, GotchaCategory::Build);
1108 }
1109
1110 #[test]
1111 fn detect_go_error() {
1112 let output = "./main.go:15:2: undefined: SomeFunc";
1113 let result = detect_error_pattern(output, "go build", 1);
1114 assert!(result.is_some());
1115 }
1116
1117 #[test]
1118 fn detect_jest_failure() {
1119 let output = "FAIL src/app.test.ts\n TypeError: Cannot read properties of undefined";
1120 let result = detect_error_pattern(output, "npx jest", 1);
1121 assert!(result.is_some());
1122 assert_eq!(result.unwrap().category, GotchaCategory::Test);
1123 }
1124
1125 #[test]
1126 fn no_false_positive_on_success() {
1127 let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
1128 let result = detect_error_pattern(output, "cargo build --release", 0);
1129 assert!(result.is_none());
1130 }
1131
1132 #[test]
1133 fn normalize_signature_strips_paths() {
1134 let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
1135 let sig = normalize_error_signature(raw);
1136 assert!(!sig.contains("/Users/foo"));
1137 assert!(sig.contains("E0507"));
1138 assert!(sig.contains(":_:_"));
1139 }
1140
1141 #[test]
1142 fn gotcha_store_add_and_merge() {
1143 let mut store = GotchaStore::new("testhash");
1144 let g1 = Gotcha::new(
1145 GotchaCategory::Build,
1146 GotchaSeverity::Critical,
1147 "error E0507",
1148 "use clone",
1149 GotchaSource::AutoDetected {
1150 command: "cargo build".into(),
1151 exit_code: 1,
1152 },
1153 "s1",
1154 );
1155 store.add_or_merge(g1.clone());
1156 assert_eq!(store.gotchas.len(), 1);
1157
1158 let g2 = Gotcha::new(
1159 GotchaCategory::Build,
1160 GotchaSeverity::Critical,
1161 "error E0507",
1162 "use ref pattern",
1163 GotchaSource::AutoDetected {
1164 command: "cargo build".into(),
1165 exit_code: 1,
1166 },
1167 "s2",
1168 );
1169 store.add_or_merge(g2);
1170 assert_eq!(store.gotchas.len(), 1);
1171 assert_eq!(store.gotchas[0].occurrences, 2);
1172 assert_eq!(store.gotchas[0].session_ids.len(), 2);
1173 }
1174
1175 #[test]
1176 fn gotcha_store_detect_and_resolve() {
1177 let mut store = GotchaStore::new("testhash");
1178
1179 let error_output = "error[E0507]: cannot move out of `self.name`";
1180 let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
1181 assert!(detected);
1182 assert_eq!(store.pending_errors.len(), 1);
1183
1184 let resolved =
1185 store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
1186 assert!(resolved.is_some());
1187 assert_eq!(store.gotchas.len(), 1);
1188 assert!(store.gotchas[0].resolution.contains("src/main.rs"));
1189 }
1190
1191 #[test]
1192 fn agent_report_gotcha() {
1193 let mut store = GotchaStore::new("testhash");
1194 let g = store
1195 .report_gotcha(
1196 "Use thiserror not anyhow",
1197 "Derive thiserror::Error in library code",
1198 "convention",
1199 "warning",
1200 "s1",
1201 )
1202 .expect("gotcha should be retained in empty store");
1203 assert_eq!(g.confidence, 0.9);
1204 assert_eq!(g.category, GotchaCategory::Convention);
1205 }
1206
1207 #[test]
1208 fn decay_reduces_confidence() {
1209 let mut store = GotchaStore::new("testhash");
1210 let mut g = Gotcha::new(
1211 GotchaCategory::Build,
1212 GotchaSeverity::Warning,
1213 "test error",
1214 "test fix",
1215 GotchaSource::AutoDetected {
1216 command: "test".into(),
1217 exit_code: 1,
1218 },
1219 "s1",
1220 );
1221 g.last_seen = Utc::now() - chrono::Duration::days(30);
1222 g.confidence = 0.5;
1223 store.gotchas.push(g);
1224
1225 store.apply_decay();
1226 assert!(store.gotchas[0].confidence < 0.5);
1227 }
1228
1229 #[test]
1230 fn decay_archives_low_confidence() {
1231 let mut store = GotchaStore::new("testhash");
1232 let mut g = Gotcha::new(
1233 GotchaCategory::Build,
1234 GotchaSeverity::Info,
1235 "old error",
1236 "old fix",
1237 GotchaSource::AutoDetected {
1238 command: "test".into(),
1239 exit_code: 1,
1240 },
1241 "s1",
1242 );
1243 g.last_seen = Utc::now() - chrono::Duration::days(90);
1244 g.confidence = 0.16;
1245 store.gotchas.push(g);
1246
1247 store.apply_decay();
1248 assert!(store.gotchas.is_empty());
1249 }
1250
1251 #[test]
1252 fn relevance_score_higher_for_recent() {
1253 let recent = Gotcha::new(
1254 GotchaCategory::Build,
1255 GotchaSeverity::Critical,
1256 "error A",
1257 "fix A",
1258 GotchaSource::AutoDetected {
1259 command: "test".into(),
1260 exit_code: 1,
1261 },
1262 "s1",
1263 );
1264 let mut old = recent.clone();
1265 old.last_seen = Utc::now() - chrono::Duration::days(14);
1266
1267 let score_recent = relevance_score(&recent, &[]);
1268 let score_old = relevance_score(&old, &[]);
1269 assert!(score_recent > score_old);
1270 }
1271
1272 #[test]
1273 fn relevance_score_file_overlap_boost() {
1274 let mut g = Gotcha::new(
1275 GotchaCategory::Build,
1276 GotchaSeverity::Warning,
1277 "error B",
1278 "fix B",
1279 GotchaSource::AutoDetected {
1280 command: "test".into(),
1281 exit_code: 1,
1282 },
1283 "s1",
1284 );
1285 g.file_patterns = vec!["src/server.rs".to_string()];
1286
1287 let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
1288 let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
1289 assert!(with_overlap > without_overlap);
1290 }
1291
1292 #[test]
1293 fn cross_session_boost_increases_confidence() {
1294 let mut store = GotchaStore::new("testhash");
1295 let mut g = Gotcha::new(
1296 GotchaCategory::Build,
1297 GotchaSeverity::Critical,
1298 "recurring error",
1299 "recurring fix",
1300 GotchaSource::AutoDetected {
1301 command: "cargo build".into(),
1302 exit_code: 1,
1303 },
1304 "s1",
1305 );
1306 g.confidence = 0.6;
1307 store.gotchas.push(g);
1308
1309 store.error_log.push(SessionErrorLog {
1310 session_id: "s2".into(),
1311 timestamp: Utc::now(),
1312 errors: vec![ErrorEntry {
1313 signature: "recurring error".into(),
1314 command: "cargo build".into(),
1315 timestamp: Utc::now(),
1316 }],
1317 fixes: vec![],
1318 });
1319 store.error_log.push(SessionErrorLog {
1320 session_id: "s3".into(),
1321 timestamp: Utc::now(),
1322 errors: vec![ErrorEntry {
1323 signature: "recurring error".into(),
1324 command: "cargo build".into(),
1325 timestamp: Utc::now(),
1326 }],
1327 fixes: vec![],
1328 });
1329
1330 store.cross_session_boost();
1331 assert!(store.gotchas[0].confidence > 0.6);
1332 assert!(store.gotchas[0].session_ids.len() >= 3);
1333 }
1334
1335 #[test]
1336 fn promotion_criteria() {
1337 let mut g = Gotcha::new(
1338 GotchaCategory::Convention,
1339 GotchaSeverity::Warning,
1340 "use thiserror",
1341 "derive thiserror::Error",
1342 GotchaSource::AgentReported {
1343 session_id: "s1".into(),
1344 },
1345 "s1",
1346 );
1347 g.confidence = 0.95;
1348 g.occurrences = 6;
1349 g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
1350 g.prevented_count = 3;
1351 assert!(g.is_promotable());
1352
1353 g.occurrences = 2;
1354 assert!(!g.is_promotable());
1355 }
1356
1357 #[test]
1358 fn format_injection_block_empty() {
1359 let store = GotchaStore::new("testhash");
1360 assert!(store.format_injection_block(&[]).is_empty());
1361 }
1362
1363 #[test]
1364 fn format_injection_block_with_gotchas() {
1365 let mut store = GotchaStore::new("testhash");
1366 store.add_or_merge(Gotcha::new(
1367 GotchaCategory::Build,
1368 GotchaSeverity::Critical,
1369 "cargo E0507",
1370 "use clone",
1371 GotchaSource::AutoDetected {
1372 command: "cargo build".into(),
1373 exit_code: 1,
1374 },
1375 "s1",
1376 ));
1377
1378 let block = store.format_injection_block(&[]);
1379 assert!(block.contains("PROJECT GOTCHAS"));
1380 assert!(block.contains("cargo E0507"));
1381 assert!(block.contains("use clone"));
1382 }
1383}