Skip to main content

lean_ctx/core/
gotcha_tracker.rs

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; // 15 minutes
11const 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// ---------------------------------------------------------------------------
18// Core types
19// ---------------------------------------------------------------------------
20
21#[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// ---------------------------------------------------------------------------
192// Pending errors (in-memory, not persisted)
193// ---------------------------------------------------------------------------
194
195#[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// ---------------------------------------------------------------------------
215// Session error log (for cross-session correlation)
216// ---------------------------------------------------------------------------
217
218#[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// ---------------------------------------------------------------------------
242// Stats
243// ---------------------------------------------------------------------------
244
245#[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// ---------------------------------------------------------------------------
255// GotchaStore
256// ---------------------------------------------------------------------------
257
258#[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    // -- Detection ----------------------------------------------------------
311
312    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    // -- Agent-reported -----------------------------------------------------
405
406    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    // -- Add / Merge --------------------------------------------------------
437
438    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    // -- Cross-Session ------------------------------------------------------
455
456    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    // -- Decay --------------------------------------------------------------
528
529    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    // -- Promotion ----------------------------------------------------------
553
554    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    // -- Universal Gotchas --------------------------------------------------
571
572    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    // -- Relevance scoring --------------------------------------------------
585
586    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    // -- Prevention tracking ------------------------------------------------
640
641    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    // -- CLI ----------------------------------------------------------------
650
651    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
709// ---------------------------------------------------------------------------
710// Error pattern detection
711// ---------------------------------------------------------------------------
712
713pub 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    // Rust / Cargo
724    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    // npm / pnpm / yarn
749    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    // Node.js
762    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    // Python
781    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    // Go
796    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    // TypeScript / tsc
807    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    // Docker
818    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    // Git
830    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    // pytest
843    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    // Jest / Vitest
853    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    // Make / CMake
864    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    // Generic: non-zero exit + substantial stderr
876    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
892// ---------------------------------------------------------------------------
893// Signature normalization
894// ---------------------------------------------------------------------------
895
896pub fn normalize_error_signature(raw: &str) -> String {
897    let mut sig = raw.to_string();
898
899    // Remove absolute paths: /Users/foo/bar/src/main.rs -> src/main.rs
900    sig = regex_replace(&sig, r"(/[A-Za-z][\w.-]*/)+", "");
901
902    // Remove Windows paths: C:\Users\foo\bar\ -> ""
903    sig = regex_replace(&sig, r"[A-Z]:\\[\w\\.-]+\\", "");
904
905    // Remove line:col numbers: :42:13 -> :_:_
906    sig = regex_replace(&sig, r":\d+:\d+", ":_:_");
907    sig = regex_replace(&sig, r"line \d+", "line _");
908
909    // Collapse whitespace
910    sig = regex_replace(&sig, r"\s+", " ");
911
912    // Truncate
913    if sig.len() > 200 {
914        sig.truncate(200);
915    }
916
917    sig.trim().to_string()
918}
919
920// ---------------------------------------------------------------------------
921// Relevance scoring
922// ---------------------------------------------------------------------------
923
924pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
925    let mut score: f32 = 0.0;
926
927    // 1. Basis: occurrences * confidence (max 10)
928    score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
929
930    // 2. Recency boost (exponential decay, half-life 1 week)
931    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    // 3. File overlap
935    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    // 4. Severity multiplier
947    score *= gotcha.severity.multiplier();
948
949    // 5. Cross-session bonus
950    if gotcha.session_ids.len() >= 3 {
951        score *= 1.3;
952    }
953
954    // 6. Prevention track record
955    if gotcha.prevented_count > 0 {
956        score *= 1.2;
957    }
958
959    score
960}
961
962// ---------------------------------------------------------------------------
963// Universal gotchas (cross-project)
964// ---------------------------------------------------------------------------
965
966pub 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
990// ---------------------------------------------------------------------------
991// Helpers
992// ---------------------------------------------------------------------------
993
994fn 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// ---------------------------------------------------------------------------
1071// Tests
1072// ---------------------------------------------------------------------------
1073
1074#[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}