lean_ctx/core/gotcha_tracker/
mining.rs1use std::collections::BTreeMap;
16use std::path::Path;
17
18use regex::Regex;
19
20use super::normalize_error_signature;
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct MinedSignature {
25 pub signature: String,
26 pub occurrences: usize,
28 pub sessions: usize,
30}
31
32fn signature_patterns() -> Vec<Regex> {
36 [
37 r"error\[E\d{4}\][^\n]*", r"error TS\d{4}[^\n]*", r"panicked at [^\n]*", r"npm ERR![^\n]*", r"ModuleNotFoundError[^\n]*", r"ImportError: [^\n]*", r"undefined: [A-Za-z_][\w]*", r"undefined reference to [^\n]*", ]
46 .iter()
47 .filter_map(|p| Regex::new(p).ok())
48 .collect()
49}
50
51pub 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
70fn 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
93pub fn mine_jsonl_dir(dir: &Path) -> Vec<MinedSignature> {
97 let patterns = signature_patterns();
98
99 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 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 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
166pub 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 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 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}