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 = 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    // -- 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    ) -> 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    // -- 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            .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    // -- 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            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    // -- Prevention tracking ------------------------------------------------
642
643    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    // -- CLI ----------------------------------------------------------------
652
653    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
711// ---------------------------------------------------------------------------
712// Error pattern detection
713// ---------------------------------------------------------------------------
714
715pub 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    // Rust / Cargo
726    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    // npm / pnpm / yarn
751    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    // Node.js
764    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    // Python
783    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    // Go
798    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    // TypeScript / tsc
809    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    // Docker
820    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    // Git
832    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    // pytest
845    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    // Jest / Vitest
855    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    // Make / CMake
866    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    // Generic: non-zero exit + substantial stderr
878    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
894// ---------------------------------------------------------------------------
895// Signature normalization
896// ---------------------------------------------------------------------------
897
898pub fn normalize_error_signature(raw: &str) -> String {
899    let mut sig = raw.to_string();
900
901    // Remove absolute paths: /Users/foo/bar/src/main.rs -> src/main.rs
902    sig = regex_replace(&sig, r"(/[A-Za-z][\w.-]*/)+", "");
903
904    // Remove Windows paths: C:\Users\foo\bar\ -> ""
905    sig = regex_replace(&sig, r"[A-Z]:\\[\w\\.-]+\\", "");
906
907    // Remove line:col numbers: :42:13 -> :_:_
908    sig = regex_replace(&sig, r":\d+:\d+", ":_:_");
909    sig = regex_replace(&sig, r"line \d+", "line _");
910
911    // Collapse whitespace
912    sig = regex_replace(&sig, r"\s+", " ");
913
914    // Truncate
915    if sig.len() > 200 {
916        sig.truncate(200);
917    }
918
919    sig.trim().to_string()
920}
921
922// ---------------------------------------------------------------------------
923// Relevance scoring
924// ---------------------------------------------------------------------------
925
926pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
927    let mut score: f32 = 0.0;
928
929    // 1. Basis: occurrences * confidence (max 10)
930    score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
931
932    // 2. Recency boost (exponential decay, half-life 1 week)
933    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    // 3. File overlap
937    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    // 4. Severity multiplier
949    score *= gotcha.severity.multiplier();
950
951    // 5. Cross-session bonus
952    if gotcha.session_ids.len() >= 3 {
953        score *= 1.3;
954    }
955
956    // 6. Prevention track record
957    if gotcha.prevented_count > 0 {
958        score *= 1.2;
959    }
960
961    score
962}
963
964// ---------------------------------------------------------------------------
965// Universal gotchas (cross-project)
966// ---------------------------------------------------------------------------
967
968pub 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
991// ---------------------------------------------------------------------------
992// Helpers
993// ---------------------------------------------------------------------------
994
995fn 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// ---------------------------------------------------------------------------
1066// Tests
1067// ---------------------------------------------------------------------------
1068
1069#[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}