Skip to main content

lean_ctx/core/gotcha_tracker/
mod.rs

1mod detect;
2pub mod learn;
3pub mod mining;
4mod model;
5mod persist;
6mod reflector;
7mod runtime;
8
9pub use detect::{
10    DetectedError, detect_error_pattern, is_correlatable_command, normalize_error_signature,
11};
12pub use model::{
13    ErrorEntry, FixEntry, Gotcha, GotchaCategory, GotchaSeverity, GotchaSource, GotchaStats,
14    GotchaStore, PendingError, SessionErrorLog,
15};
16pub use persist::{load_universal_gotchas, save_universal_gotchas};
17pub use reflector::{ReflectionInsight, fold_into_playbook, format_ledger, reflect};
18pub use runtime::record_shell_outcome;
19
20use chrono::{DateTime, Utc};
21use detect::command_base;
22use model::{DECAY_ARCHIVE_THRESHOLD, MAX_GOTCHAS, MAX_PENDING, MAX_SESSION_LOGS, gotcha_id};
23
24impl GotchaStore {
25    // -- Detection ----------------------------------------------------------
26
27    pub fn detect_error(
28        &mut self,
29        output: &str,
30        command: &str,
31        exit_code: i32,
32        files_touched: &[String],
33        session_id: &str,
34    ) -> bool {
35        self.pending_errors.retain(|p| !p.is_expired());
36
37        let Some(detected) = detect_error_pattern(output, command, exit_code) else {
38            return false;
39        };
40
41        let signature = normalize_error_signature(&detected.raw_message);
42        let snippet = output.chars().take(500).collect::<String>();
43
44        self.pending_errors.push(PendingError {
45            error_signature: signature.clone(),
46            category: detected.category,
47            severity: detected.severity,
48            command: command.to_string(),
49            exit_code,
50            files_at_error: files_touched.to_vec(),
51            timestamp: Utc::now(),
52            raw_snippet: snippet,
53            session_id: session_id.to_string(),
54        });
55
56        if self.pending_errors.len() > MAX_PENDING {
57            self.pending_errors.remove(0);
58        }
59
60        self.log_error(session_id, &signature, command);
61        self.stats.total_errors_detected += 1;
62        true
63    }
64
65    pub fn try_resolve_pending(
66        &mut self,
67        command: &str,
68        files_touched: &[String],
69        session_id: &str,
70    ) -> Option<Gotcha> {
71        self.pending_errors.retain(|p| !p.is_expired());
72
73        let cmd_base = command_base(command);
74        let idx = self
75            .pending_errors
76            .iter()
77            .position(|p| command_base(&p.command) == cmd_base)?;
78
79        let pending = self.pending_errors.remove(idx);
80
81        let changed_files: Vec<String> = files_touched
82            .iter()
83            .filter(|f| !pending.files_at_error.contains(f))
84            .cloned()
85            .collect();
86
87        let resolution = if changed_files.is_empty() {
88            format!("Fixed after re-running {cmd_base}")
89        } else {
90            format!("Fixed by editing: {}", changed_files.join(", "))
91        };
92
93        let mut gotcha = Gotcha::new(
94            pending.category,
95            pending.severity,
96            &pending.error_signature,
97            &resolution,
98            GotchaSource::AutoDetected {
99                command: command.to_string(),
100                exit_code: pending.exit_code,
101            },
102            session_id,
103        );
104        gotcha.file_patterns.clone_from(&changed_files);
105
106        self.add_or_merge(gotcha.clone());
107        self.log_fix(
108            session_id,
109            &pending.error_signature,
110            &resolution,
111            &changed_files,
112        );
113        self.stats.total_fixes_correlated += 1;
114        self.updated_at = Utc::now();
115
116        Some(gotcha)
117    }
118
119    // -- Agent-reported -----------------------------------------------------
120
121    pub fn report_gotcha(
122        &mut self,
123        trigger: &str,
124        resolution: &str,
125        category: &str,
126        severity: &str,
127        session_id: &str,
128    ) -> Option<&Gotcha> {
129        let cat = GotchaCategory::from_str_loose(category);
130        let sev = match severity.to_lowercase().as_str() {
131            "critical" => GotchaSeverity::Critical,
132            "info" => GotchaSeverity::Info,
133            _ => GotchaSeverity::Warning,
134        };
135        let id = gotcha_id(trigger, &cat);
136        let gotcha = Gotcha::new(
137            cat,
138            sev,
139            trigger,
140            resolution,
141            GotchaSource::AgentReported {
142                session_id: session_id.to_string(),
143            },
144            session_id,
145        );
146        self.add_or_merge(gotcha);
147        self.updated_at = Utc::now();
148        self.gotchas.iter().find(|g| g.id == id)
149    }
150
151    // -- Add / Merge --------------------------------------------------------
152
153    fn add_or_merge(&mut self, new: Gotcha) {
154        if let Some(existing) = self.gotchas.iter_mut().find(|g| g.id == new.id) {
155            existing.merge_with(&new);
156        } else {
157            self.gotchas.push(new);
158            if self.gotchas.len() > MAX_GOTCHAS {
159                self.gotchas.sort_by(|a, b| {
160                    b.confidence
161                        .partial_cmp(&a.confidence)
162                        .unwrap_or(std::cmp::Ordering::Equal)
163                });
164                self.gotchas.truncate(MAX_GOTCHAS);
165            }
166        }
167    }
168
169    // -- Cross-Session ------------------------------------------------------
170
171    fn log_error(&mut self, session_id: &str, signature: &str, command: &str) {
172        let log = self.get_or_create_session_log(session_id);
173        log.errors.push(ErrorEntry {
174            signature: signature.to_string(),
175            command: command.to_string(),
176            timestamp: Utc::now(),
177        });
178    }
179
180    fn log_fix(&mut self, session_id: &str, error_sig: &str, resolution: &str, files: &[String]) {
181        let log = self.get_or_create_session_log(session_id);
182        log.fixes.push(FixEntry {
183            error_signature: error_sig.to_string(),
184            resolution: resolution.to_string(),
185            files_changed: files.to_vec(),
186            timestamp: Utc::now(),
187        });
188    }
189
190    fn get_or_create_session_log(&mut self, session_id: &str) -> &mut SessionErrorLog {
191        if !self.error_log.iter().any(|l| l.session_id == session_id) {
192            self.error_log.push(SessionErrorLog {
193                session_id: session_id.to_string(),
194                timestamp: Utc::now(),
195                errors: Vec::new(),
196                fixes: Vec::new(),
197            });
198            if self.error_log.len() > MAX_SESSION_LOGS {
199                self.error_log.remove(0);
200            }
201        }
202        self.error_log
203            .iter_mut()
204            .find(|l| l.session_id == session_id)
205            .expect("session log must exist after push")
206    }
207
208    pub fn cross_session_boost(&mut self) {
209        let mut sig_sessions: std::collections::HashMap<String, Vec<String>> =
210            std::collections::HashMap::new();
211
212        for log in &self.error_log {
213            for err in &log.errors {
214                sig_sessions
215                    .entry(err.signature.clone())
216                    .or_default()
217                    .push(log.session_id.clone());
218            }
219        }
220
221        for gotcha in &mut self.gotchas {
222            if let Some(sessions) = sig_sessions.get(&gotcha.trigger) {
223                let unique: Vec<String> = sessions
224                    .iter()
225                    .filter(|s| !gotcha.session_ids.contains(s))
226                    .cloned()
227                    .collect();
228                if !unique.is_empty() {
229                    let boost = 0.15 * unique.len() as f32;
230                    gotcha.confidence = (gotcha.confidence + boost).min(0.95);
231                    for s in unique {
232                        gotcha.session_ids.push(s);
233                    }
234                    gotcha.source = GotchaSource::CrossSessionCorrelated {
235                        sessions: gotcha.session_ids.clone(),
236                    };
237                }
238            }
239        }
240    }
241
242    // -- Decay --------------------------------------------------------------
243
244    pub fn apply_decay(&mut self) {
245        let now = Utc::now();
246        let mut decayed = 0u64;
247
248        for gotcha in &mut self.gotchas {
249            let days_since = (now - gotcha.last_seen).num_days().max(0) as f32;
250            if days_since < 1.0 {
251                continue;
252            }
253            let base_rate = gotcha.source.decay_rate();
254            let occurrence_factor = 1.0 / (1.0 + gotcha.occurrences as f32 * 0.1);
255            let decay = base_rate * occurrence_factor * (days_since / 7.0);
256            gotcha.confidence = (gotcha.confidence - decay).max(0.0);
257        }
258
259        let before = self.gotchas.len();
260        self.gotchas
261            .retain(|g| g.confidence >= DECAY_ARCHIVE_THRESHOLD);
262        decayed += (before - self.gotchas.len()) as u64;
263
264        self.stats.gotchas_decayed += decayed;
265    }
266
267    // -- Promotion ----------------------------------------------------------
268
269    /// High-value gotchas eligible for promotion into durable project knowledge
270    /// (the loop-weighting bridge, #980): proven across sessions and shown to
271    /// prevent real errors. Read-only companion to [`Self::check_promotions`],
272    /// sorted by confidence so callers can take the strongest first.
273    #[must_use]
274    pub fn promotable(&self) -> Vec<&Gotcha> {
275        let mut v: Vec<&Gotcha> = self.gotchas.iter().filter(|g| g.is_promotable()).collect();
276        v.sort_by(|a, b| {
277            b.confidence
278                .partial_cmp(&a.confidence)
279                .unwrap_or(std::cmp::Ordering::Equal)
280        });
281        v
282    }
283
284    pub fn check_promotions(&mut self) -> Vec<(String, String, String, f32)> {
285        let mut promoted = Vec::new();
286        for gotcha in &self.gotchas {
287            if gotcha.is_promotable() {
288                promoted.push((
289                    gotcha.category.to_string(),
290                    gotcha.trigger.clone(),
291                    gotcha.resolution.clone(),
292                    gotcha.confidence,
293                ));
294            }
295        }
296        self.stats.gotchas_promoted += promoted.len() as u64;
297        promoted
298    }
299
300    // -- Universal Gotchas --------------------------------------------------
301
302    pub fn extract_universal(&self) -> Vec<Gotcha> {
303        self.gotchas
304            .iter()
305            .filter(|g| {
306                g.category == GotchaCategory::Platform
307                    && g.occurrences >= 10
308                    && g.session_ids.len() >= 5
309            })
310            .cloned()
311            .collect()
312    }
313
314    // -- Relevance scoring --------------------------------------------------
315
316    pub fn top_relevant(&self, files_touched: &[String], limit: usize) -> Vec<&Gotcha> {
317        let mut scored: Vec<(&Gotcha, f32)> = self
318            .gotchas
319            .iter()
320            .map(|g| (g, relevance_score(g, files_touched)))
321            .filter(|(_, s)| *s > 0.5)
322            .collect();
323
324        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
325        scored.into_iter().take(limit).map(|(g, _)| g).collect()
326    }
327
328    pub fn format_injection_block(&self, files_touched: &[String]) -> String {
329        let relevant = self.top_relevant(files_touched, 7);
330        if relevant.is_empty() {
331            return String::new();
332        }
333
334        let mut lines = Vec::with_capacity(relevant.len() + 2);
335        lines.push("--- PROJECT GOTCHAS (do NOT repeat these mistakes) ---".to_string());
336
337        for g in &relevant {
338            let prefix = g.severity.prefix();
339            let label = g.category.short_label();
340            let sessions = g.session_ids.len();
341            let age = format_age(g.last_seen);
342            let trigger = crate::core::sanitize::neutralize_metadata(&g.trigger);
343            let resolution = crate::core::sanitize::neutralize_metadata(&g.resolution);
344
345            let source_hint = match &g.source {
346                GotchaSource::AgentReported { .. } => ", agent-confirmed".to_string(),
347                GotchaSource::CrossSessionCorrelated { .. } => {
348                    format!(", across {sessions} sessions")
349                }
350                GotchaSource::AutoDetected { .. } => ", auto-detected".to_string(),
351                GotchaSource::Promoted { .. } => ", proven".to_string(),
352            };
353
354            let prevented = if g.prevented_count > 0 {
355                format!(", prevented {}x", g.prevented_count)
356            } else {
357                String::new()
358            };
359
360            lines.push(format!("[{prefix}{label}] {trigger}"));
361            lines.push(format!(
362                "  FIX: {} (seen {}x{}{}, {})",
363                resolution, g.occurrences, source_hint, prevented, age
364            ));
365        }
366
367        lines.push("---".to_string());
368        crate::core::sanitize::fence_content("project_gotchas", &lines.join("\n"))
369    }
370
371    // -- Prevention tracking ------------------------------------------------
372
373    pub fn add_universal(&mut self, gotcha: Gotcha) {
374        if !self.gotchas.iter().any(|g| g.id == gotcha.id) {
375            self.gotchas.push(gotcha);
376        }
377    }
378
379    pub fn mark_prevented(&mut self, gotcha_id: &str) {
380        if let Some(g) = self.gotchas.iter_mut().find(|g| g.id == gotcha_id) {
381            g.prevented_count += 1;
382            g.confidence = (g.confidence + 0.05).min(0.99);
383            self.stats.total_prevented += 1;
384        }
385    }
386
387    // -- CLI ----------------------------------------------------------------
388
389    pub fn format_list(&self) -> String {
390        if self.gotchas.is_empty() {
391            return "No gotchas recorded for this project.".to_string();
392        }
393
394        let mut out = Vec::new();
395        out.push(format!("  {} active gotchas\n", self.gotchas.len()));
396
397        let mut sorted = self.gotchas.clone();
398        sorted.sort_by(|a, b| {
399            b.confidence
400                .partial_cmp(&a.confidence)
401                .unwrap_or(std::cmp::Ordering::Equal)
402        });
403
404        for g in &sorted {
405            let prefix = g.severity.prefix();
406            let label = g.category.short_label();
407            let conf = (g.confidence * 100.0) as u32;
408            let source = match &g.source {
409                GotchaSource::AutoDetected { .. } => "auto",
410                GotchaSource::AgentReported { .. } => "agent",
411                GotchaSource::CrossSessionCorrelated { .. } => "cross-session",
412                GotchaSource::Promoted { .. } => "promoted",
413            };
414            out.push(format!(
415                "  [{prefix}{label:8}] {} ({}x, {} sessions, {source}, confidence: {conf}%)",
416                truncate_str(&g.trigger, 60),
417                g.occurrences,
418                g.session_ids.len(),
419            ));
420            out.push(format!(
421                "             FIX: {}",
422                truncate_str(&g.resolution, 70)
423            ));
424            if g.prevented_count > 0 {
425                out.push(format!("             Prevented: {}x", g.prevented_count));
426            }
427            out.push(String::new());
428        }
429
430        out.push(format!(
431            "  Stats: {} errors detected | {} fixes correlated | {} prevented",
432            self.stats.total_errors_detected,
433            self.stats.total_fixes_correlated,
434            self.stats.total_prevented,
435        ));
436
437        out.join("\n")
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Relevance scoring
443// ---------------------------------------------------------------------------
444
445pub fn relevance_score(gotcha: &Gotcha, files_touched: &[String]) -> f32 {
446    let mut score: f32 = 0.0;
447
448    score += (gotcha.occurrences as f32 * gotcha.confidence).min(10.0);
449
450    let hours_ago = (Utc::now() - gotcha.last_seen).num_hours().max(0) as f32;
451    score += 5.0 * (-hours_ago / 168.0).exp();
452
453    let overlap = gotcha
454        .file_patterns
455        .iter()
456        .filter(|fp| {
457            files_touched
458                .iter()
459                .any(|ft| ft.contains(fp.as_str()) || fp.contains(ft.as_str()))
460        })
461        .count();
462    score += overlap as f32 * 3.0;
463
464    score *= gotcha.severity.multiplier();
465
466    if gotcha.session_ids.len() >= 3 {
467        score *= 1.3;
468    }
469
470    if gotcha.prevented_count > 0 {
471        score *= 1.2;
472    }
473
474    score
475}
476
477// ---------------------------------------------------------------------------
478// Helpers
479// ---------------------------------------------------------------------------
480
481fn format_age(dt: DateTime<Utc>) -> String {
482    let diff = Utc::now() - dt;
483    let hours = diff.num_hours();
484    if hours < 1 {
485        format!("{}m ago", diff.num_minutes().max(1))
486    } else if hours < 24 {
487        format!("{hours}h ago")
488    } else {
489        format!("{}d ago", diff.num_days())
490    }
491}
492
493fn truncate_str(s: &str, max: usize) -> String {
494    if s.len() <= max {
495        s.to_string()
496    } else {
497        format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
498    }
499}
500
501// ---------------------------------------------------------------------------
502// Tests
503// ---------------------------------------------------------------------------
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn detect_cargo_error() {
511        let output = r"error[E0507]: cannot move out of `self.field` which is behind a shared reference
512   --> src/server.rs:42:13";
513        let result = detect_error_pattern(output, "cargo build", 1);
514        assert!(result.is_some());
515        let d = result.unwrap();
516        assert_eq!(d.category, GotchaCategory::Build);
517        assert_eq!(d.severity, GotchaSeverity::Critical);
518        assert!(d.raw_message.contains("E0507"));
519    }
520
521    #[test]
522    fn detect_npm_error() {
523        let output = "npm ERR! ERESOLVE unable to resolve dependency tree";
524        let result = detect_error_pattern(output, "npm install", 1);
525        assert!(result.is_some());
526        assert_eq!(result.unwrap().category, GotchaCategory::Dependency);
527    }
528
529    #[test]
530    fn detect_python_traceback() {
531        let output = "Traceback (most recent call last):\n  File \"app.py\", line 5\nImportError: No module named 'flask'";
532        let result = detect_error_pattern(output, "python app.py", 1);
533        assert!(result.is_some());
534        assert_eq!(result.unwrap().category, GotchaCategory::Runtime);
535    }
536
537    #[test]
538    fn detect_typescript_error() {
539        let output =
540            "src/index.ts(10,5): error TS2339: Property 'foo' does not exist on type 'Bar'.";
541        let result = detect_error_pattern(output, "npx tsc", 1);
542        assert!(result.is_some());
543        assert_eq!(result.unwrap().category, GotchaCategory::Build);
544    }
545
546    #[test]
547    fn detect_go_error() {
548        let output = "./main.go:15:2: undefined: SomeFunc";
549        let result = detect_error_pattern(output, "go build", 1);
550        assert!(result.is_some());
551    }
552
553    #[test]
554    fn detect_jest_failure() {
555        let output = "FAIL src/app.test.ts\n  TypeError: Cannot read properties of undefined";
556        let result = detect_error_pattern(output, "npx jest", 1);
557        assert!(result.is_some());
558        assert_eq!(result.unwrap().category, GotchaCategory::Test);
559    }
560
561    #[test]
562    fn no_false_positive_on_success() {
563        let output = "Compiling lean-ctx v2.17.2\nFinished release target(s) in 30s";
564        let result = detect_error_pattern(output, "cargo build --release", 0);
565        assert!(result.is_none());
566    }
567
568    #[test]
569    fn normalize_signature_strips_paths() {
570        let raw = "error[E0507]: cannot move out of /Users/foo/project/src/main.rs:42:13";
571        let sig = normalize_error_signature(raw);
572        assert!(!sig.contains("/Users/foo"));
573        assert!(sig.contains("E0507"));
574        assert!(sig.contains(":_:_"));
575    }
576
577    #[test]
578    fn gotcha_store_add_and_merge() {
579        let mut store = GotchaStore::new("testhash");
580        let g1 = Gotcha::new(
581            GotchaCategory::Build,
582            GotchaSeverity::Critical,
583            "error E0507",
584            "use clone",
585            GotchaSource::AutoDetected {
586                command: "cargo build".into(),
587                exit_code: 1,
588            },
589            "s1",
590        );
591        store.add_or_merge(g1.clone());
592        assert_eq!(store.gotchas.len(), 1);
593
594        let g2 = Gotcha::new(
595            GotchaCategory::Build,
596            GotchaSeverity::Critical,
597            "error E0507",
598            "use ref pattern",
599            GotchaSource::AutoDetected {
600                command: "cargo build".into(),
601                exit_code: 1,
602            },
603            "s2",
604        );
605        store.add_or_merge(g2);
606        assert_eq!(store.gotchas.len(), 1);
607        assert_eq!(store.gotchas[0].occurrences, 2);
608        assert_eq!(store.gotchas[0].session_ids.len(), 2);
609    }
610
611    #[test]
612    fn gotcha_store_detect_and_resolve() {
613        let mut store = GotchaStore::new("testhash");
614
615        let error_output = "error[E0507]: cannot move out of `self.name`";
616        let detected = store.detect_error(error_output, "cargo build", 1, &[], "s1");
617        assert!(detected);
618        assert_eq!(store.pending_errors.len(), 1);
619
620        let resolved =
621            store.try_resolve_pending("cargo build --release", &["src/main.rs".into()], "s1");
622        assert!(resolved.is_some());
623        assert_eq!(store.gotchas.len(), 1);
624        assert!(store.gotchas[0].resolution.contains("src/main.rs"));
625    }
626
627    #[test]
628    fn agent_report_gotcha() {
629        let mut store = GotchaStore::new("testhash");
630        let g = store
631            .report_gotcha(
632                "Use thiserror not anyhow",
633                "Derive thiserror::Error in library code",
634                "convention",
635                "warning",
636                "s1",
637            )
638            .expect("gotcha should be retained in empty store");
639        assert_eq!(g.confidence, 0.9);
640        assert_eq!(g.category, GotchaCategory::Convention);
641    }
642
643    #[test]
644    fn decay_reduces_confidence() {
645        let mut store = GotchaStore::new("testhash");
646        let mut g = Gotcha::new(
647            GotchaCategory::Build,
648            GotchaSeverity::Warning,
649            "test error",
650            "test fix",
651            GotchaSource::AutoDetected {
652                command: "test".into(),
653                exit_code: 1,
654            },
655            "s1",
656        );
657        g.last_seen = Utc::now() - chrono::Duration::days(30);
658        g.confidence = 0.5;
659        store.gotchas.push(g);
660
661        store.apply_decay();
662        assert!(store.gotchas[0].confidence < 0.5);
663    }
664
665    #[test]
666    fn decay_archives_low_confidence() {
667        let mut store = GotchaStore::new("testhash");
668        let mut g = Gotcha::new(
669            GotchaCategory::Build,
670            GotchaSeverity::Info,
671            "old error",
672            "old fix",
673            GotchaSource::AutoDetected {
674                command: "test".into(),
675                exit_code: 1,
676            },
677            "s1",
678        );
679        g.last_seen = Utc::now() - chrono::Duration::days(90);
680        g.confidence = 0.16;
681        store.gotchas.push(g);
682
683        store.apply_decay();
684        assert!(store.gotchas.is_empty());
685    }
686
687    #[test]
688    fn relevance_score_higher_for_recent() {
689        let recent = Gotcha::new(
690            GotchaCategory::Build,
691            GotchaSeverity::Critical,
692            "error A",
693            "fix A",
694            GotchaSource::AutoDetected {
695                command: "test".into(),
696                exit_code: 1,
697            },
698            "s1",
699        );
700        let mut old = recent.clone();
701        old.last_seen = Utc::now() - chrono::Duration::days(14);
702
703        let score_recent = relevance_score(&recent, &[]);
704        let score_old = relevance_score(&old, &[]);
705        assert!(score_recent > score_old);
706    }
707
708    #[test]
709    fn relevance_score_file_overlap_boost() {
710        let mut g = Gotcha::new(
711            GotchaCategory::Build,
712            GotchaSeverity::Warning,
713            "error B",
714            "fix B",
715            GotchaSource::AutoDetected {
716                command: "test".into(),
717                exit_code: 1,
718            },
719            "s1",
720        );
721        g.file_patterns = vec!["src/server.rs".to_string()];
722
723        let with_overlap = relevance_score(&g, &["src/server.rs".to_string()]);
724        let without_overlap = relevance_score(&g, &["src/other.rs".to_string()]);
725        assert!(with_overlap > without_overlap);
726    }
727
728    #[test]
729    fn cross_session_boost_increases_confidence() {
730        let mut store = GotchaStore::new("testhash");
731        let mut g = Gotcha::new(
732            GotchaCategory::Build,
733            GotchaSeverity::Critical,
734            "recurring error",
735            "recurring fix",
736            GotchaSource::AutoDetected {
737                command: "cargo build".into(),
738                exit_code: 1,
739            },
740            "s1",
741        );
742        g.confidence = 0.6;
743        store.gotchas.push(g);
744
745        store.error_log.push(SessionErrorLog {
746            session_id: "s2".into(),
747            timestamp: Utc::now(),
748            errors: vec![ErrorEntry {
749                signature: "recurring error".into(),
750                command: "cargo build".into(),
751                timestamp: Utc::now(),
752            }],
753            fixes: vec![],
754        });
755        store.error_log.push(SessionErrorLog {
756            session_id: "s3".into(),
757            timestamp: Utc::now(),
758            errors: vec![ErrorEntry {
759                signature: "recurring error".into(),
760                command: "cargo build".into(),
761                timestamp: Utc::now(),
762            }],
763            fixes: vec![],
764        });
765
766        store.cross_session_boost();
767        assert!(store.gotchas[0].confidence > 0.6);
768        assert!(store.gotchas[0].session_ids.len() >= 3);
769    }
770
771    #[test]
772    fn promotion_criteria() {
773        let mut g = Gotcha::new(
774            GotchaCategory::Convention,
775            GotchaSeverity::Warning,
776            "use thiserror",
777            "derive thiserror::Error",
778            GotchaSource::AgentReported {
779                session_id: "s1".into(),
780            },
781            "s1",
782        );
783        g.confidence = 0.95;
784        g.occurrences = 6;
785        g.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
786        g.prevented_count = 3;
787        assert!(g.is_promotable());
788
789        g.occurrences = 2;
790        assert!(!g.is_promotable());
791    }
792
793    #[test]
794    fn promotable_keeps_only_proven_and_sorts_by_confidence() {
795        // #980 loop-weighting bridge: only gotchas that cleared every threshold
796        // (incl. having prevented real errors) are eligible, strongest first.
797        let mut store = GotchaStore::new("testhash");
798        let mut strong = Gotcha::new(
799            GotchaCategory::Convention,
800            GotchaSeverity::Warning,
801            "use thiserror",
802            "derive thiserror::Error",
803            GotchaSource::AgentReported {
804                session_id: "s1".into(),
805            },
806            "s1",
807        );
808        strong.confidence = 0.92;
809        strong.occurrences = 6;
810        strong.session_ids = vec!["s1".into(), "s2".into(), "s3".into()];
811        strong.prevented_count = 3;
812
813        let mut strongest = strong.clone();
814        strongest.trigger = "always run fmt".into();
815        strongest.confidence = 0.98;
816
817        // Never prevented anything → not promotable despite high confidence.
818        let mut weak = strong.clone();
819        weak.trigger = "unproven".into();
820        weak.confidence = 0.99;
821        weak.prevented_count = 0;
822
823        store.gotchas = vec![strong, weak, strongest];
824        let promotable = store.promotable();
825        assert_eq!(promotable.len(), 2, "the unproven gotcha is excluded");
826        assert_eq!(
827            promotable[0].trigger, "always run fmt",
828            "highest confidence first"
829        );
830    }
831
832    #[test]
833    fn format_injection_block_empty() {
834        let store = GotchaStore::new("testhash");
835        assert!(store.format_injection_block(&[]).is_empty());
836    }
837
838    #[test]
839    fn format_injection_block_with_gotchas() {
840        let mut store = GotchaStore::new("testhash");
841        store.add_or_merge(Gotcha::new(
842            GotchaCategory::Build,
843            GotchaSeverity::Critical,
844            "cargo E0507",
845            "use clone",
846            GotchaSource::AutoDetected {
847                command: "cargo build".into(),
848                exit_code: 1,
849            },
850            "s1",
851        ));
852
853        let block = store.format_injection_block(&[]);
854        assert!(block.contains("PROJECT GOTCHAS"));
855        assert!(block.contains("cargo E0507"));
856        assert!(block.contains("use clone"));
857    }
858
859    #[test]
860    fn truncate_str_utf8_umlaut_no_panic() {
861        let result = truncate_str("Fehler in Datei übergrößetest.rs", 20);
862        assert!(result.len() <= 23); // 20 + "..."
863        assert!(result.ends_with("..."));
864    }
865
866    #[test]
867    fn truncate_str_utf8_emoji_no_panic() {
868        let result = truncate_str("Error 🔥 in module 🧪 test", 15);
869        assert!(result.ends_with("..."));
870    }
871
872    #[test]
873    fn truncate_str_short_utf8_unchanged() {
874        let input = "Ölüm";
875        assert_eq!(truncate_str(input, 20), input);
876    }
877
878    #[test]
879    fn correlatable_command_gate() {
880        for cmd in [
881            "cargo build",
882            "cargo test --release",
883            "npm install",
884            "pnpm run build",
885            "python app.py",
886            "go build ./...",
887            "npx tsc --noEmit",
888            "poetry run pytest -q",
889            "docker build .",
890            "git rebase main",
891        ] {
892            assert!(is_correlatable_command(cmd), "should correlate: {cmd}");
893        }
894        for cmd in ["ls -la", "cat README.md", "echo hi", "cd rust", "pwd"] {
895            assert!(!is_correlatable_command(cmd), "should skip: {cmd}");
896        }
897    }
898}