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    ) -> 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 hash_project(root: &str) -> String {
1004    let mut hasher = DefaultHasher::new();
1005    root.hash(&mut hasher);
1006    format!("{:016x}", hasher.finish())
1007}
1008
1009fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
1010    let mut hasher = DefaultHasher::new();
1011    trigger.hash(&mut hasher);
1012    category.short_label().hash(&mut hasher);
1013    format!("{:016x}", hasher.finish())
1014}
1015
1016fn command_base(cmd: &str) -> String {
1017    let parts: Vec<&str> = cmd.split_whitespace().collect();
1018    if parts.len() >= 2 {
1019        format!("{} {}", parts[0], parts[1])
1020    } else {
1021        parts.first().unwrap_or(&"").to_string()
1022    }
1023}
1024
1025fn extract_pattern(text: &str, pattern: &str) -> Option<String> {
1026    let re = regex::Regex::new(pattern).ok()?;
1027    re.find(text).map(|m| m.as_str().to_string())
1028}
1029
1030fn extract_first_error_line(output: &str) -> String {
1031    for line in output.lines() {
1032        let ll = line.to_lowercase();
1033        if ll.contains("error") || ll.contains("failed") || ll.contains("traceback") {
1034            let trimmed = line.trim();
1035            if trimmed.len() > 200 {
1036                return trimmed[..200].to_string();
1037            }
1038            return trimmed.to_string();
1039        }
1040    }
1041    output.lines().next().unwrap_or("unknown error").to_string()
1042}
1043
1044fn regex_replace(text: &str, pattern: &str, replacement: &str) -> String {
1045    match regex::Regex::new(pattern) {
1046        Ok(re) => re.replace_all(text, replacement).to_string(),
1047        Err(_) => text.to_string(),
1048    }
1049}
1050
1051fn format_age(dt: DateTime<Utc>) -> String {
1052    let diff = Utc::now() - dt;
1053    let hours = diff.num_hours();
1054    if hours < 1 {
1055        format!("{}m ago", diff.num_minutes().max(1))
1056    } else if hours < 24 {
1057        format!("{}h ago", hours)
1058    } else {
1059        format!("{}d ago", diff.num_days())
1060    }
1061}
1062
1063fn truncate_str(s: &str, max: usize) -> String {
1064    if s.len() <= max {
1065        s.to_string()
1066    } else {
1067        format!("{}...", &s[..max.saturating_sub(3)])
1068    }
1069}
1070
1071// ---------------------------------------------------------------------------
1072// Tests
1073// ---------------------------------------------------------------------------
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    #[test]
1080    fn detect_cargo_error() {
1081        let output = r#"error[E0507]: cannot move out of `self.field` which is behind a shared reference
1082   --> src/server.rs:42:13"#;
1083        let result = detect_error_pattern(output, "cargo build", 1);
1084        assert!(result.is_some());
1085        let d = result.unwrap();
1086        assert_eq!(d.category, GotchaCategory::Build);
1087        assert_eq!(d.severity, GotchaSeverity::Critical);
1088        assert!(d.raw_message.contains("E0507"));
1089    }
1090
1091    #[test]
1092    fn detect_npm_error() {
1093        let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
1094        let result = detect_error_pattern(output, "npm install", 1);
1095        assert!(result.is_some());
1096        assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
1097    }
1098
1099    #[test]
1100    fn detect_python_traceback() {
1101        let output = "Traceback (most recent call last):\n  File \"app.py\", line 5\nImportError: No module named 'flask'";
1102        let result = detect_error_pattern(output, "python app.py", 1);
1103        assert!(result.is_some());
1104        assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
1105    }
1106
1107    #[test]
1108    fn detect_typescript_error() {
1109        let output =
1110            "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
1111        let result = detect_error_pattern(output, "npx tsc", 1);
1112        assert!(result.is_some());
1113        assert_eq!(result.unwrap().category, GotchaCategory::Build);
1114    }
1115
1116    #[test]
1117    fn detect_go_error() {
1118        let output = "./main.go:15:2: undefined: SomeFunc";
1119        let result = detect_error_pattern(output, "go build", 1);
1120        assert!(result.is_some());
1121    }
1122
1123    #[test]
1124    fn detect_jest_failure() {
1125        let output = "FAIL src/app.test.ts\n  TypeError: Cannot read properties of undefined";
1126        let result = detect_error_pattern(output, "npx jest", 1);
1127        assert!(result.is_some());
1128        assert_eq!(result.unwrap().category, GotchaCategory::Test);
1129    }
1130
1131    #[test]
1132    fn no_false_positive_on_success() {
1133        let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
1134        let result = detect_error_pattern(output, "cargo build --release", 0);
1135        assert!(result.is_none());
1136    }
1137
1138    #[test]
1139    fn normalize_signature_strips_paths() {
1140        let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
1141        let sig = normalize_error_signature(raw);
1142        assert!(!sig.contains("/Users/foo"));
1143        assert!(sig.contains("E0507"));
1144        assert!(sig.contains(":_:_"));
1145    }
1146
1147    #[test]
1148    fn gotcha_store_add_and_merge() {
1149        let mut store = GotchaStore::new("testhash");
1150        let g1 = Gotcha::new(
1151            GotchaCategory::Build,
1152            GotchaSeverity::Critical,
1153            "error E0507",
1154            "use clone",
1155            GotchaSource::AutoDetected {
1156                command: "cargo build".into(),
1157                exit_code: 1,
1158            },
1159            "s1",
1160        );
1161        store.add_or_merge(g1.clone());
1162        assert_eq!(store.gotchas.len(), 1);
1163
1164        let g2 = Gotcha::new(
1165            GotchaCategory::Build,
1166            GotchaSeverity::Critical,
1167            "error E0507",
1168            "use ref pattern",
1169            GotchaSource::AutoDetected {
1170                command: "cargo build".into(),
1171                exit_code: 1,
1172            },
1173            "s2",
1174        );
1175        store.add_or_merge(g2);
1176        assert_eq!(store.gotchas.len(), 1);
1177        assert_eq!(store.gotchas[0].occurrences, 2);
1178        assert_eq!(store.gotchas[0].session_ids.len(), 2);
1179    }
1180
1181    #[test]
1182    fn gotcha_store_detect_and_resolve() {
1183        let mut store = GotchaStore::new("testhash");
1184
1185        let error_output = "error[E0507]: cannot move out of `self.name`";
1186        let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
1187        assert!(detected);
1188        assert_eq!(store.pending_errors.len(), 1);
1189
1190        let resolved =
1191            store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
1192        assert!(resolved.is_some());
1193        assert_eq!(store.gotchas.len(), 1);
1194        assert!(store.gotchas[0].resolution.contains("src/main.rs"));
1195    }
1196
1197    #[test]
1198    fn agent_report_gotcha() {
1199        let mut store = GotchaStore::new("testhash");
1200        let g = store
1201            .report_gotcha(
1202                "Use thiserror not anyhow",
1203                "Derive thiserror::Error in library code",
1204                "convention",
1205                "warning",
1206                "s1",
1207            )
1208            .expect("gotcha should be retained in empty store");
1209        assert_eq!(g.confidence, 0.9);
1210        assert_eq!(g.category, GotchaCategory::Convention);
1211    }
1212
1213    #[test]
1214    fn decay_reduces_confidence() {
1215        let mut store = GotchaStore::new("testhash");
1216        let mut g = Gotcha::new(
1217            GotchaCategory::Build,
1218            GotchaSeverity::Warning,
1219            "test error",
1220            "test fix",
1221            GotchaSource::AutoDetected {
1222                command: "test".into(),
1223                exit_code: 1,
1224            },
1225            "s1",
1226        );
1227        g.last_seen = Utc::now() - chrono::Duration::days(30);
1228        g.confidence = 0.5;
1229        store.gotchas.push(g);
1230
1231        store.apply_decay();
1232        assert!(store.gotchas[0].confidence < 0.5);
1233    }
1234
1235    #[test]
1236    fn decay_archives_low_confidence() {
1237        let mut store = GotchaStore::new("testhash");
1238        let mut g = Gotcha::new(
1239            GotchaCategory::Build,
1240            GotchaSeverity::Info,
1241            "old error",
1242            "old fix",
1243            GotchaSource::AutoDetected {
1244                command: "test".into(),
1245                exit_code: 1,
1246            },
1247            "s1",
1248        );
1249        g.last_seen = Utc::now() - chrono::Duration::days(90);
1250        g.confidence = 0.16;
1251        store.gotchas.push(g);
1252
1253        store.apply_decay();
1254        assert!(store.gotchas.is_empty());
1255    }
1256
1257    #[test]
1258    fn relevance_score_higher_for_recent() {
1259        let recent = Gotcha::new(
1260            GotchaCategory::Build,
1261            GotchaSeverity::Critical,
1262            "error A",
1263            "fix A",
1264            GotchaSource::AutoDetected {
1265                command: "test".into(),
1266                exit_code: 1,
1267            },
1268            "s1",
1269        );
1270        let mut old = recent.clone();
1271        old.last_seen = Utc::now() - chrono::Duration::days(14);
1272
1273        let score_recent = relevance_score(&recent, &[]);
1274        let score_old = relevance_score(&old, &[]);
1275        assert!(score_recent > score_old);
1276    }
1277
1278    #[test]
1279    fn relevance_score_file_overlap_boost() {
1280        let mut g = Gotcha::new(
1281            GotchaCategory::Build,
1282            GotchaSeverity::Warning,
1283            "error B",
1284            "fix B",
1285            GotchaSource::AutoDetected {
1286                command: "test".into(),
1287                exit_code: 1,
1288            },
1289            "s1",
1290        );
1291        g.file_patterns = vec!["src/server.rs".to_string()];
1292
1293        let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
1294        let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
1295        assert!(with_overlap > without_overlap);
1296    }
1297
1298    #[test]
1299    fn cross_session_boost_increases_confidence() {
1300        let mut store = GotchaStore::new("testhash");
1301        let mut g = Gotcha::new(
1302            GotchaCategory::Build,
1303            GotchaSeverity::Critical,
1304            "recurring error",
1305            "recurring fix",
1306            GotchaSource::AutoDetected {
1307                command: "cargo build".into(),
1308                exit_code: 1,
1309            },
1310            "s1",
1311        );
1312        g.confidence = 0.6;
1313        store.gotchas.push(g);
1314
1315        store.error_log.push(SessionErrorLog {
1316            session_id: "s2".into(),
1317            timestamp: Utc::now(),
1318            errors: vec![ErrorEntry {
1319                signature: "recurring error".into(),
1320                command: "cargo build".into(),
1321                timestamp: Utc::now(),
1322            }],
1323            fixes: vec![],
1324        });
1325        store.error_log.push(SessionErrorLog {
1326            session_id: "s3".into(),
1327            timestamp: Utc::now(),
1328            errors: vec![ErrorEntry {
1329                signature: "recurring error".into(),
1330                command: "cargo build".into(),
1331                timestamp: Utc::now(),
1332            }],
1333            fixes: vec![],
1334        });
1335
1336        store.cross_session_boost();
1337        assert!(store.gotchas[0].confidence > 0.6);
1338        assert!(store.gotchas[0].session_ids.len() >= 3);
1339    }
1340
1341    #[test]
1342    fn promotion_criteria() {
1343        let mut g = Gotcha::new(
1344            GotchaCategory::Convention,
1345            GotchaSeverity::Warning,
1346            "use thiserror",
1347            "derive thiserror::Error",
1348            GotchaSource::AgentReported {
1349                session_id: "s1".into(),
1350            },
1351            "s1",
1352        );
1353        g.confidence = 0.95;
1354        g.occurrences = 6;
1355        g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
1356        g.prevented_count = 3;
1357        assert!(g.is_promotable());
1358
1359        g.occurrences = 2;
1360        assert!(!g.is_promotable());
1361    }
1362
1363    #[test]
1364    fn format_injection_block_empty() {
1365        let store = GotchaStore::new("testhash");
1366        assert!(store.format_injection_block(&[]).is_empty());
1367    }
1368
1369    #[test]
1370    fn format_injection_block_with_gotchas() {
1371        let mut store = GotchaStore::new("testhash");
1372        store.add_or_merge(Gotcha::new(
1373            GotchaCategory::Build,
1374            GotchaSeverity::Critical,
1375            "cargo E0507",
1376            "use clone",
1377            GotchaSource::AutoDetected {
1378                command: "cargo build".into(),
1379                exit_code: 1,
1380            },
1381            "s1",
1382        ));
1383
1384        let block = store.format_injection_block(&[]);
1385        assert!(block.contains("PROJECT GOTCHAS"));
1386        assert!(block.contains("cargo E0507"));
1387        assert!(block.contains("use clone"));
1388    }
1389}