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 = hash_project(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 = hash_project(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 ) -> &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).unwrap()
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 .unwrap()
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
613 let source_hint = match &g.source {
614 GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
615 GotchaSource::CrossSessionCorrelated { .. } => {
616 format!(", across {} sessions", sessions)
617 }
618 GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
619 GotchaSource::Promoted { .. } => ", proven".to_string(),
620 };
621
622 let prevented = if g.prevented_count > 0 {
623 format!(", prevented {}x", g.prevented_count)
624 } else {
625 String::new()
626 };
627
628 lines.push(format!("[{prefix}{label}] {}", g.trigger));
629 lines.push(format!(
630 " FIX: {} (seen {}x{}{}, {})",
631 g.resolution, g.occurrences, source_hint, prevented, age
632 ));
633 }
634
635 lines.push("---".to_string());
636 lines.join("\n")
637 }
638
639 pub fn mark_prevented(&mut self, gotcha_id: &str) {
642 if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
643 g.prevented_count += 1;
644 g.confidence = (g.confidence + 0.05).min(0.99);
645 self.stats.total_prevented += 1;
646 }
647 }
648
649 pub fn format_list(&self) -> String {
652 if self.gotchas.is_empty() {
653 return "No gotchas recorded for this project.".to_string();
654 }
655
656 let mut out = Vec::new();
657 out.push(format!(" {} active gotchas\n", self.gotchas.len()));
658
659 let mut sorted = self.gotchas.clone();
660 sorted.sort_by(|a, b| {
661 b.confidence
662 .partial_cmp(&a.confidence)
663 .unwrap_or(std::cmp::Ordering::Equal)
664 });
665
666 for g in &sorted {
667 let prefix = g.severity.prefix();
668 let label = g.category.short_label();
669 let conf = (g.confidence * 100.0) as u32;
670 let source = match &g.source {
671 GotchaSource::AutoDetected { .. } => "auto",
672 GotchaSource::AgentReported { .. } => "agent",
673 GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
674 GotchaSource::Promoted { .. } => "promoted",
675 };
676 out.push(format!(
677 " [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
678 truncate_str(&g.trigger, 60),
679 g.occurrences,
680 g.session_ids.len(),
681 ));
682 out.push(format!(
683 " FIX: {}",
684 truncate_str(&g.resolution, 70)
685 ));
686 if g.prevented_count > 0 {
687 out.push(format!(" Prevented: {}x", g.prevented_count));
688 }
689 out.push(String::new());
690 }
691
692 out.push(format!(
693 " Stats: {} errors detected | {} fixes correlated | {} prevented",
694 self.stats.total_errors_detected,
695 self.stats.total_fixes_correlated,
696 self.stats.total_prevented,
697 ));
698
699 out.join("\n")
700 }
701
702 pub fn clear(&mut self) {
703 self.gotchas.clear();
704 self.pending_errors.clear();
705 self.updated_at = Utc::now();
706 }
707}
708
709pub struct DetectedError {
714 pub category: GotchaCategory,
715 pub severity: GotchaSeverity,
716 pub raw_message: String,
717}
718
719pub fn detect_error_pattern(output: &str, command: &str, exit_code: i32) -> Option<DetectedError> {
720 let cmd_lower = command.to_lowercase();
721 let out_lower = output.to_lowercase();
722
723 if cmd_lower.starts_with("cargo ") || cmd_lower.contains("rustc") {
725 if let Some(msg) = extract_pattern(output, r"error\[E\d{4}\]: .+") {
726 return Some(DetectedError {
727 category: GotchaCategory::Build,
728 severity: GotchaSeverity::Critical,
729 raw_message: msg,
730 });
731 }
732 if out_lower.contains("cannot find") || out_lower.contains("mismatched types") {
733 return Some(DetectedError {
734 category: GotchaCategory::Build,
735 severity: GotchaSeverity::Critical,
736 raw_message: extract_first_error_line(output),
737 });
738 }
739 if out_lower.contains("test result: failed") || out_lower.contains("failures:") {
740 return Some(DetectedError {
741 category: GotchaCategory::Test,
742 severity: GotchaSeverity::Critical,
743 raw_message: extract_first_error_line(output),
744 });
745 }
746 }
747
748 if (cmd_lower.starts_with("npm ")
750 || cmd_lower.starts_with("pnpm ")
751 || cmd_lower.starts_with("yarn "))
752 && (out_lower.contains("err!") || out_lower.contains("eresolve"))
753 {
754 return Some(DetectedError {
755 category: GotchaCategory::Dependency,
756 severity: GotchaSeverity::Critical,
757 raw_message: extract_first_error_line(output),
758 });
759 }
760
761 if cmd_lower.starts_with("node ") || cmd_lower.contains("tsx ") || cmd_lower.contains("ts-node")
763 {
764 for pat in &[
765 "syntaxerror",
766 "typeerror",
767 "referenceerror",
768 "cannot find module",
769 ] {
770 if out_lower.contains(pat) {
771 return Some(DetectedError {
772 category: GotchaCategory::Runtime,
773 severity: GotchaSeverity::Critical,
774 raw_message: extract_first_error_line(output),
775 });
776 }
777 }
778 }
779
780 if (cmd_lower.starts_with("python")
782 || cmd_lower.starts_with("pip ")
783 || cmd_lower.starts_with("uv "))
784 && (out_lower.contains("traceback")
785 || out_lower.contains("importerror")
786 || out_lower.contains("modulenotfounderror"))
787 {
788 return Some(DetectedError {
789 category: GotchaCategory::Runtime,
790 severity: GotchaSeverity::Critical,
791 raw_message: extract_first_error_line(output),
792 });
793 }
794
795 if cmd_lower.starts_with("go ")
797 && (out_lower.contains("cannot use") || out_lower.contains("undefined:"))
798 {
799 return Some(DetectedError {
800 category: GotchaCategory::Build,
801 severity: GotchaSeverity::Critical,
802 raw_message: extract_first_error_line(output),
803 });
804 }
805
806 if cmd_lower.contains("tsc") || cmd_lower.contains("typescript") {
808 if let Some(msg) = extract_pattern(output, r"TS\d{4}: .+") {
809 return Some(DetectedError {
810 category: GotchaCategory::Build,
811 severity: GotchaSeverity::Critical,
812 raw_message: msg,
813 });
814 }
815 }
816
817 if cmd_lower.starts_with("docker ")
819 && out_lower.contains("error")
820 && (out_lower.contains("failed to") || out_lower.contains("copy failed"))
821 {
822 return Some(DetectedError {
823 category: GotchaCategory::Build,
824 severity: GotchaSeverity::Critical,
825 raw_message: extract_first_error_line(output),
826 });
827 }
828
829 if cmd_lower.starts_with("git ")
831 && (out_lower.contains("conflict")
832 || out_lower.contains("rejected")
833 || out_lower.contains("diverged"))
834 {
835 return Some(DetectedError {
836 category: GotchaCategory::Config,
837 severity: GotchaSeverity::Warning,
838 raw_message: extract_first_error_line(output),
839 });
840 }
841
842 if cmd_lower.contains("pytest") && (out_lower.contains("failed") || out_lower.contains("error"))
844 {
845 return Some(DetectedError {
846 category: GotchaCategory::Test,
847 severity: GotchaSeverity::Critical,
848 raw_message: extract_first_error_line(output),
849 });
850 }
851
852 if (cmd_lower.contains("jest") || cmd_lower.contains("vitest"))
854 && (out_lower.contains("fail") || out_lower.contains("typeerror"))
855 {
856 return Some(DetectedError {
857 category: GotchaCategory::Test,
858 severity: GotchaSeverity::Critical,
859 raw_message: extract_first_error_line(output),
860 });
861 }
862
863 if (cmd_lower.starts_with("make") || cmd_lower.contains("cmake"))
865 && out_lower.contains("error")
866 && (out_lower.contains("undefined reference") || out_lower.contains("no rule"))
867 {
868 return Some(DetectedError {
869 category: GotchaCategory::Build,
870 severity: GotchaSeverity::Critical,
871 raw_message: extract_first_error_line(output),
872 });
873 }
874
875 if exit_code != 0
877 && output.len() > 50
878 && (out_lower.contains("error")
879 || out_lower.contains("fatal")
880 || out_lower.contains("failed"))
881 {
882 return Some(DetectedError {
883 category: GotchaCategory::Runtime,
884 severity: GotchaSeverity::Warning,
885 raw_message: extract_first_error_line(output),
886 });
887 }
888
889 None
890}
891
892pub fn normalize_error_signature(raw: &str) -> String {
897 let mut sig = raw.to_string();
898
899 sig = regex_replace(&sig, r"(/[A-Za-z][\w.-]*/)+", "");
901
902 sig = regex_replace(&sig, r"[A-Z]:\\[\w\\.-]+\\", "");
904
905 sig = regex_replace(&sig, r":\d+:\d+", ":_:_");
907 sig = regex_replace(&sig, r"line \d+", "line _");
908
909 sig = regex_replace(&sig, r"\s+", " ");
911
912 if sig.len() > 200 {
914 sig.truncate(200);
915 }
916
917 sig.trim().to_string()
918}
919
920pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
925 let mut score: f32 = 0.0;
926
927 score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
929
930 let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
932 score += 5.0 * (-hours_ago / 168.0).exp();
933
934 let overlap = gotcha
936 .file_patterns
937 .iter()
938 .filter(|fp| {
939 files_touched
940 .iter()
941 .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
942 })
943 .count();
944 score += overlap as f32 * 3.0;
945
946 score *= gotcha.severity.multiplier();
948
949 if gotcha.session_ids.len() >= 3 {
951 score *= 1.3;
952 }
953
954 if gotcha.prevented_count > 0 {
956 score *= 1.2;
957 }
958
959 score
960}
961
962pub fn load_universal_gotchas() -> Vec<Gotcha> {
967 let Some(home) = dirs::home_dir() else {
968 return Vec::new();
969 };
970 let path = home.join(".lean-ctx").join("universal-gotchas.json");
971 if let Ok(content) = std::fs::read_to_string(&path) {
972 serde_json::from_str(&content).unwrap_or_default()
973 } else {
974 Vec::new()
975 }
976}
977
978pub fn save_universal_gotchas(gotchas: &[Gotcha]) -> Result<(), String> {
979 let Some(home) = dirs::home_dir() else {
980 return Err("Cannot determine home directory".into());
981 };
982 let path = home.join(".lean-ctx").join("universal-gotchas.json");
983 let tmp = path.with_extension("tmp");
984 let json = serde_json::to_string_pretty(gotchas).map_err(|e| e.to_string())?;
985 std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
986 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
987 Ok(())
988}
989
990fn gotcha_path(project_hash: &str) -> PathBuf {
995 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
996 home.join(".lean-ctx")
997 .join("knowledge")
998 .join(project_hash)
999 .join("gotchas.json")
1000}
1001
1002fn hash_project(root: &str) -> String {
1003 let mut hasher = DefaultHasher::new();
1004 root.hash(&mut hasher);
1005 format!("{:016x}", hasher.finish())
1006}
1007
1008fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
1009 let mut hasher = DefaultHasher::new();
1010 trigger.hash(&mut hasher);
1011 category.short_label().hash(&mut hasher);
1012 format!("{:016x}", hasher.finish())
1013}
1014
1015fn command_base(cmd: &str) -> String {
1016 let parts: Vec<&str> = cmd.split_whitespace().collect();
1017 if parts.len() >= 2 {
1018 format!("{} {}", parts[0], parts[1])
1019 } else {
1020 parts.first().unwrap_or(&"").to_string()
1021 }
1022}
1023
1024fn extract_pattern(text: &str, pattern: &str) -> Option<String> {
1025 let re = regex::Regex::new(pattern).ok()?;
1026 re.find(text).map(|m| m.as_str().to_string())
1027}
1028
1029fn extract_first_error_line(output: &str) -> String {
1030 for line in output.lines() {
1031 let ll = line.to_lowercase();
1032 if ll.contains("error") || ll.contains("failed") || ll.contains("traceback") {
1033 let trimmed = line.trim();
1034 if trimmed.len() > 200 {
1035 return trimmed[..200].to_string();
1036 }
1037 return trimmed.to_string();
1038 }
1039 }
1040 output.lines().next().unwrap_or("unknown error").to_string()
1041}
1042
1043fn regex_replace(text: &str, pattern: &str, replacement: &str) -> String {
1044 match regex::Regex::new(pattern) {
1045 Ok(re) => re.replace_all(text, replacement).to_string(),
1046 Err(_) => text.to_string(),
1047 }
1048}
1049
1050fn format_age(dt: DateTime<Utc>) -> String {
1051 let diff = Utc::now() - dt;
1052 let hours = diff.num_hours();
1053 if hours < 1 {
1054 format!("{}m ago", diff.num_minutes().max(1))
1055 } else if hours < 24 {
1056 format!("{}h ago", hours)
1057 } else {
1058 format!("{}d ago", diff.num_days())
1059 }
1060}
1061
1062fn truncate_str(s: &str, max: usize) -> String {
1063 if s.len() <= max {
1064 s.to_string()
1065 } else {
1066 format!("{}...", &s[..max.saturating_sub(3)])
1067 }
1068}
1069
1070#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 #[test]
1079 fn detect_cargo_error() {
1080 let output = r#"error[E0507]: cannot move out of `self.field` which is behind a shared reference
1081 --> src/server.rs:42:13"#;
1082 let result = detect_error_pattern(output, "cargo build", 1);
1083 assert!(result.is_some());
1084 let d = result.unwrap();
1085 assert_eq!(d.category, GotchaCategory::Build);
1086 assert_eq!(d.severity, GotchaSeverity::Critical);
1087 assert!(d.raw_message.contains("E0507"));
1088 }
1089
1090 #[test]
1091 fn detect_npm_error() {
1092 let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
1093 let result = detect_error_pattern(output, "npm install", 1);
1094 assert!(result.is_some());
1095 assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
1096 }
1097
1098 #[test]
1099 fn detect_python_traceback() {
1100 let output = "Traceback (most recent call last):\n File \"app.py\", line 5\nImportError: No module named 'flask'";
1101 let result = detect_error_pattern(output, "python app.py", 1);
1102 assert!(result.is_some());
1103 assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
1104 }
1105
1106 #[test]
1107 fn detect_typescript_error() {
1108 let output =
1109 "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
1110 let result = detect_error_pattern(output, "npx tsc", 1);
1111 assert!(result.is_some());
1112 assert_eq!(result.unwrap().category, GotchaCategory::Build);
1113 }
1114
1115 #[test]
1116 fn detect_go_error() {
1117 let output = "./main.go:15:2: undefined: SomeFunc";
1118 let result = detect_error_pattern(output, "go build", 1);
1119 assert!(result.is_some());
1120 }
1121
1122 #[test]
1123 fn detect_jest_failure() {
1124 let output = "FAIL src/app.test.ts\n TypeError: Cannot read properties of undefined";
1125 let result = detect_error_pattern(output, "npx jest", 1);
1126 assert!(result.is_some());
1127 assert_eq!(result.unwrap().category, GotchaCategory::Test);
1128 }
1129
1130 #[test]
1131 fn no_false_positive_on_success() {
1132 let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
1133 let result = detect_error_pattern(output, "cargo build --release", 0);
1134 assert!(result.is_none());
1135 }
1136
1137 #[test]
1138 fn normalize_signature_strips_paths() {
1139 let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
1140 let sig = normalize_error_signature(raw);
1141 assert!(!sig.contains("/Users/foo"));
1142 assert!(sig.contains("E0507"));
1143 assert!(sig.contains(":_:_"));
1144 }
1145
1146 #[test]
1147 fn gotcha_store_add_and_merge() {
1148 let mut store = GotchaStore::new("testhash");
1149 let g1 = Gotcha::new(
1150 GotchaCategory::Build,
1151 GotchaSeverity::Critical,
1152 "error E0507",
1153 "use clone",
1154 GotchaSource::AutoDetected {
1155 command: "cargo build".into(),
1156 exit_code: 1,
1157 },
1158 "s1",
1159 );
1160 store.add_or_merge(g1.clone());
1161 assert_eq!(store.gotchas.len(), 1);
1162
1163 let g2 = Gotcha::new(
1164 GotchaCategory::Build,
1165 GotchaSeverity::Critical,
1166 "error E0507",
1167 "use ref pattern",
1168 GotchaSource::AutoDetected {
1169 command: "cargo build".into(),
1170 exit_code: 1,
1171 },
1172 "s2",
1173 );
1174 store.add_or_merge(g2);
1175 assert_eq!(store.gotchas.len(), 1);
1176 assert_eq!(store.gotchas[0].occurrences, 2);
1177 assert_eq!(store.gotchas[0].session_ids.len(), 2);
1178 }
1179
1180 #[test]
1181 fn gotcha_store_detect_and_resolve() {
1182 let mut store = GotchaStore::new("testhash");
1183
1184 let error_output = "error[E0507]: cannot move out of `self.name`";
1185 let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
1186 assert!(detected);
1187 assert_eq!(store.pending_errors.len(), 1);
1188
1189 let resolved =
1190 store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
1191 assert!(resolved.is_some());
1192 assert_eq!(store.gotchas.len(), 1);
1193 assert!(store.gotchas[0].resolution.contains("src/main.rs"));
1194 }
1195
1196 #[test]
1197 fn agent_report_gotcha() {
1198 let mut store = GotchaStore::new("testhash");
1199 let g = store.report_gotcha(
1200 "Use thiserror not anyhow",
1201 "Derive thiserror::Error in library code",
1202 "convention",
1203 "warning",
1204 "s1",
1205 );
1206 assert_eq!(g.confidence, 0.9);
1207 assert_eq!(g.category, GotchaCategory::Convention);
1208 }
1209
1210 #[test]
1211 fn decay_reduces_confidence() {
1212 let mut store = GotchaStore::new("testhash");
1213 let mut g = Gotcha::new(
1214 GotchaCategory::Build,
1215 GotchaSeverity::Warning,
1216 "test error",
1217 "test fix",
1218 GotchaSource::AutoDetected {
1219 command: "test".into(),
1220 exit_code: 1,
1221 },
1222 "s1",
1223 );
1224 g.last_seen = Utc::now() - chrono::Duration::days(30);
1225 g.confidence = 0.5;
1226 store.gotchas.push(g);
1227
1228 store.apply_decay();
1229 assert!(store.gotchas[0].confidence < 0.5);
1230 }
1231
1232 #[test]
1233 fn decay_archives_low_confidence() {
1234 let mut store = GotchaStore::new("testhash");
1235 let mut g = Gotcha::new(
1236 GotchaCategory::Build,
1237 GotchaSeverity::Info,
1238 "old error",
1239 "old fix",
1240 GotchaSource::AutoDetected {
1241 command: "test".into(),
1242 exit_code: 1,
1243 },
1244 "s1",
1245 );
1246 g.last_seen = Utc::now() - chrono::Duration::days(90);
1247 g.confidence = 0.16;
1248 store.gotchas.push(g);
1249
1250 store.apply_decay();
1251 assert!(store.gotchas.is_empty());
1252 }
1253
1254 #[test]
1255 fn relevance_score_higher_for_recent() {
1256 let recent = Gotcha::new(
1257 GotchaCategory::Build,
1258 GotchaSeverity::Critical,
1259 "error A",
1260 "fix A",
1261 GotchaSource::AutoDetected {
1262 command: "test".into(),
1263 exit_code: 1,
1264 },
1265 "s1",
1266 );
1267 let mut old = recent.clone();
1268 old.last_seen = Utc::now() - chrono::Duration::days(14);
1269
1270 let score_recent = relevance_score(&recent, &[]);
1271 let score_old = relevance_score(&old, &[]);
1272 assert!(score_recent > score_old);
1273 }
1274
1275 #[test]
1276 fn relevance_score_file_overlap_boost() {
1277 let mut g = Gotcha::new(
1278 GotchaCategory::Build,
1279 GotchaSeverity::Warning,
1280 "error B",
1281 "fix B",
1282 GotchaSource::AutoDetected {
1283 command: "test".into(),
1284 exit_code: 1,
1285 },
1286 "s1",
1287 );
1288 g.file_patterns = vec!["src/server.rs".to_string()];
1289
1290 let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
1291 let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
1292 assert!(with_overlap > without_overlap);
1293 }
1294
1295 #[test]
1296 fn cross_session_boost_increases_confidence() {
1297 let mut store = GotchaStore::new("testhash");
1298 let mut g = Gotcha::new(
1299 GotchaCategory::Build,
1300 GotchaSeverity::Critical,
1301 "recurring error",
1302 "recurring fix",
1303 GotchaSource::AutoDetected {
1304 command: "cargo build".into(),
1305 exit_code: 1,
1306 },
1307 "s1",
1308 );
1309 g.confidence = 0.6;
1310 store.gotchas.push(g);
1311
1312 store.error_log.push(SessionErrorLog {
1313 session_id: "s2".into(),
1314 timestamp: Utc::now(),
1315 errors: vec![ErrorEntry {
1316 signature: "recurring error".into(),
1317 command: "cargo build".into(),
1318 timestamp: Utc::now(),
1319 }],
1320 fixes: vec![],
1321 });
1322 store.error_log.push(SessionErrorLog {
1323 session_id: "s3".into(),
1324 timestamp: Utc::now(),
1325 errors: vec![ErrorEntry {
1326 signature: "recurring error".into(),
1327 command: "cargo build".into(),
1328 timestamp: Utc::now(),
1329 }],
1330 fixes: vec![],
1331 });
1332
1333 store.cross_session_boost();
1334 assert!(store.gotchas[0].confidence > 0.6);
1335 assert!(store.gotchas[0].session_ids.len() >= 3);
1336 }
1337
1338 #[test]
1339 fn promotion_criteria() {
1340 let mut g = Gotcha::new(
1341 GotchaCategory::Convention,
1342 GotchaSeverity::Warning,
1343 "use thiserror",
1344 "derive thiserror::Error",
1345 GotchaSource::AgentReported {
1346 session_id: "s1".into(),
1347 },
1348 "s1",
1349 );
1350 g.confidence = 0.95;
1351 g.occurrences = 6;
1352 g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
1353 g.prevented_count = 3;
1354 assert!(g.is_promotable());
1355
1356 g.occurrences = 2;
1357 assert!(!g.is_promotable());
1358 }
1359
1360 #[test]
1361 fn format_injection_block_empty() {
1362 let store = GotchaStore::new("testhash");
1363 assert!(store.format_injection_block(&[]).is_empty());
1364 }
1365
1366 #[test]
1367 fn format_injection_block_with_gotchas() {
1368 let mut store = GotchaStore::new("testhash");
1369 store.add_or_merge(Gotcha::new(
1370 GotchaCategory::Build,
1371 GotchaSeverity::Critical,
1372 "cargo E0507",
1373 "use clone",
1374 GotchaSource::AutoDetected {
1375 command: "cargo build".into(),
1376 exit_code: 1,
1377 },
1378 "s1",
1379 ));
1380
1381 let block = store.format_injection_block(&[]);
1382 assert!(block.contains("PROJECT GOTCHAS"));
1383 assert!(block.contains("cargo E0507"));
1384 assert!(block.contains("use clone"));
1385 }
1386}