Skip to main content

lean_ctx/core/gotcha_tracker/
mining.rs

1//! Offline trace mining: distill recurring error signatures from a directory of
2//! `.jsonl` transcript/log files (e.g. agent transcripts, CI logs).
3//!
4//! The live loop ([`record_shell_outcome`](super::record_shell_outcome)) learns
5//! from commands run *through* lean-ctx. This module bootstraps that loop from
6//! history: it scans past transcripts for the same unambiguous error markers the
7//! shell detector recognizes (`error[E####]`, `error TS####`, `Traceback`,
8//! `npm ERR!`, `panicked at`, …) and ranks the signatures that recur across the
9//! most sessions — the project's persistent pain points.
10//!
11//! Deterministic and high-precision by design: only structured error markers
12//! match (never free prose), each file counts as one "session", and the output
13//! is read-only — it surfaces signatures for review, it never mutates state.
14
15use std::collections::BTreeMap;
16use std::path::Path;
17
18use regex::Regex;
19
20use super::normalize_error_signature;
21
22/// A recurring error signature distilled from mined transcripts.
23#[derive(Debug, Clone, PartialEq)]
24pub struct MinedSignature {
25    pub signature: String,
26    /// Total matches across all files.
27    pub occurrences: usize,
28    /// Distinct files (sessions) the signature appeared in.
29    pub sessions: usize,
30}
31
32/// High-precision, command-agnostic error markers. Anchored to tokens that do
33/// not occur in ordinary prose, so matching transcript text never false-positives
34/// on a discussion *about* errors.
35fn signature_patterns() -> Vec<Regex> {
36    [
37        r"error\[E\d{4}\][^\n]*",         // Rust / rustc
38        r"error TS\d{4}[^\n]*",           // TypeScript / tsc
39        r"panicked at [^\n]*",            // Rust panic
40        r"npm ERR![^\n]*",                // npm
41        r"ModuleNotFoundError[^\n]*",     // Python
42        r"ImportError: [^\n]*",           // Python
43        r"undefined: [A-Za-z_][\w]*",     // Go
44        r"undefined reference to [^\n]*", // C/C++ linker
45    ]
46    .iter()
47    .filter_map(|p| Regex::new(p).ok())
48    .collect()
49}
50
51/// Extract normalized error signatures from a single blob of text. Pure and
52/// deterministic; returns one entry per match (callers aggregate counts).
53pub fn extract_error_signatures(text: &str) -> Vec<String> {
54    extract_with(text, &signature_patterns())
55}
56
57fn extract_with(text: &str, patterns: &[Regex]) -> Vec<String> {
58    let mut out = Vec::new();
59    for re in patterns {
60        for m in re.find_iter(text) {
61            let sig = normalize_error_signature(m.as_str());
62            if !sig.is_empty() {
63                out.push(sig);
64            }
65        }
66    }
67    out
68}
69
70/// Collect every string value in a parsed JSON line, so mining works regardless
71/// of the transcript's exact schema (message text lives under different keys
72/// across tools). Deterministic depth-first traversal.
73fn collect_strings(value: &serde_json::Value, out: &mut String) {
74    match value {
75        serde_json::Value::String(s) => {
76            out.push_str(s);
77            out.push('\n');
78        }
79        serde_json::Value::Array(arr) => {
80            for v in arr {
81                collect_strings(v, out);
82            }
83        }
84        serde_json::Value::Object(map) => {
85            for v in map.values() {
86                collect_strings(v, out);
87            }
88        }
89        _ => {}
90    }
91}
92
93/// Mine all `.jsonl` files in `dir` for recurring error signatures. Each file is
94/// treated as one session; signatures are ranked by session reach, then total
95/// occurrences, then lexically — fully deterministic.
96pub fn mine_jsonl_dir(dir: &Path) -> Vec<MinedSignature> {
97    let patterns = signature_patterns();
98
99    // Stable file order so traversal is deterministic regardless of FS ordering.
100    let mut files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
101        Ok(rd) => rd
102            .flatten()
103            .map(|e| e.path())
104            .filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
105            .collect(),
106        Err(_) => return Vec::new(),
107    };
108    files.sort();
109
110    // signature -> (total occurrences, set of file indices it appeared in)
111    let mut occ: BTreeMap<String, usize> = BTreeMap::new();
112    let mut sess: BTreeMap<String, usize> = BTreeMap::new();
113
114    for path in &files {
115        let Ok(content) = std::fs::read_to_string(path) else {
116            continue;
117        };
118        let mut seen_in_file: std::collections::BTreeSet<String> =
119            std::collections::BTreeSet::new();
120        for line in content.lines() {
121            let line = line.trim();
122            if line.is_empty() {
123                continue;
124            }
125            // Pull text out of structured JSON when possible; fall back to the
126            // raw line so plain-text logs still mine.
127            let text = match serde_json::from_str::<serde_json::Value>(line) {
128                Ok(v) => {
129                    let mut buf = String::new();
130                    collect_strings(&v, &mut buf);
131                    buf
132                }
133                Err(_) => line.to_string(),
134            };
135            for sig in extract_with(&text, &patterns) {
136                *occ.entry(sig.clone()).or_insert(0) += 1;
137                seen_in_file.insert(sig);
138            }
139        }
140        for sig in seen_in_file {
141            *sess.entry(sig).or_insert(0) += 1;
142        }
143    }
144
145    let mut result: Vec<MinedSignature> = occ
146        .into_iter()
147        .map(|(signature, occurrences)| {
148            let sessions = sess.get(&signature).copied().unwrap_or(0);
149            MinedSignature {
150                signature,
151                occurrences,
152                sessions,
153            }
154        })
155        .collect();
156
157    result.sort_by(|a, b| {
158        b.sessions
159            .cmp(&a.sessions)
160            .then_with(|| b.occurrences.cmp(&a.occurrences))
161            .then_with(|| a.signature.cmp(&b.signature))
162    });
163    result
164}
165
166/// Render a mining report. `min_sessions` hides one-off signatures so only
167/// genuinely recurring pain points show.
168pub fn format_mining_report(mined: &[MinedSignature], min_sessions: usize) -> String {
169    let recurring: Vec<&MinedSignature> = mined
170        .iter()
171        .filter(|m| m.sessions >= min_sessions)
172        .collect();
173
174    if recurring.is_empty() {
175        return "No recurring error signatures found in the mined transcripts.".to_string();
176    }
177
178    let mut out = format!(
179        "=== Recurring errors across sessions ({} signature(s)) ===\n",
180        recurring.len()
181    );
182    for m in recurring {
183        out.push_str(&format!(
184            "  [{} sessions, {}x] {}\n",
185            m.sessions, m.occurrences, m.signature
186        ));
187    }
188    out.push_str("\nThese recur across past sessions — capture fixes with ctx_knowledge or address the root cause.\n");
189    out
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn extracts_known_error_markers() {
198        let text = "thread 'main' panicked at src/x.rs:1:1\n\
199                    error[E0382]: borrow of moved value: `x`\n\
200                    src/a.ts:3:1 - error TS2304: Cannot find name 'foo'.\n\
201                    ModuleNotFoundError: No module named 'flask'";
202        let sigs = extract_error_signatures(text);
203        assert!(sigs.iter().any(|s| s.contains("E0382")));
204        assert!(sigs.iter().any(|s| s.contains("TS2304")));
205        assert!(sigs.iter().any(|s| s.contains("panicked at")));
206        assert!(sigs.iter().any(|s| s.contains("ModuleNotFoundError")));
207    }
208
209    #[test]
210    fn ignores_prose_without_markers() {
211        let text = "We discussed the error handling strategy and how to fix the failed build.";
212        assert!(
213            extract_error_signatures(text).is_empty(),
214            "discussion about errors must not match"
215        );
216    }
217
218    #[test]
219    fn mines_recurring_signature_across_files() {
220        let dir = tempfile::tempdir().expect("tempdir");
221        // Two separate "sessions" both hitting the same compile error.
222        std::fs::write(
223            dir.path().join("s1.jsonl"),
224            "{\"role\":\"assistant\",\"content\":\"error[E0382]: borrow of moved value: x\"}\n",
225        )
226        .unwrap();
227        std::fs::write(
228            dir.path().join("s2.jsonl"),
229            "{\"role\":\"user\",\"text\":\"error[E0382]: borrow of moved value: x\"}\n",
230        )
231        .unwrap();
232        // A non-jsonl file must be ignored.
233        std::fs::write(dir.path().join("notes.txt"), "error[E9999]: ignore me").unwrap();
234
235        let mined = mine_jsonl_dir(dir.path());
236        let top = mined
237            .iter()
238            .find(|m| m.signature.contains("E0382"))
239            .expect("E0382 mined");
240        assert_eq!(top.sessions, 2, "seen across both transcript files");
241        assert_eq!(top.occurrences, 2);
242        assert!(
243            !mined.iter().any(|m| m.signature.contains("E9999")),
244            "non-.jsonl files are not mined"
245        );
246
247        let report = format_mining_report(&mined, 2);
248        assert!(report.contains("E0382"));
249        assert!(report.contains("2 sessions"));
250    }
251
252    #[test]
253    fn report_hides_one_off_signatures() {
254        let mined = vec![MinedSignature {
255            signature: "error[E0001]: once".to_string(),
256            occurrences: 1,
257            sessions: 1,
258        }];
259        let report = format_mining_report(&mined, 2);
260        assert!(report.contains("No recurring error signatures"));
261    }
262
263    #[test]
264    fn mining_is_deterministic() {
265        let dir = tempfile::tempdir().expect("tempdir");
266        std::fs::write(
267            dir.path().join("a.jsonl"),
268            "{\"content\":\"error[E0382]: moved\"}\n{\"content\":\"error TS2304: missing\"}\n",
269        )
270        .unwrap();
271        assert_eq!(mine_jsonl_dir(dir.path()), mine_jsonl_dir(dir.path()));
272    }
273}