Skip to main content

borg_trace/
fingerprint.rs

1//! Deterministic fingerprints: the same error seen by two drones (or two
2//! sessions weeks apart) must hash the same, regardless of line numbers,
3//! temp paths, ids or durations.
4
5use std::hash::{DefaultHasher, Hash, Hasher};
6use std::sync::LazyLock;
7
8use regex::Regex;
9
10static KEYWORD: LazyLock<Regex> =
11    LazyLock::new(|| Regex::new(r"(?i)\b(error|failed|failure|panicked|exception|traceback|not found|no such|cannot|denied)\b").unwrap());
12static PATHLIKE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\w.~-]*(/[\w.@+-]+)+/?").unwrap());
13static HEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[0-9a-fA-F]{6,}\b").unwrap());
14static NUM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").unwrap());
15static WS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
16
17fn hash<T: Hash>(t: T) -> u64 {
18    let mut h = DefaultHasher::new();
19    t.hash(&mut h);
20    h.finish()
21}
22
23/// Normalize one error line so incidental details don't split fingerprints.
24pub fn normalize_error_line(line: &str) -> String {
25    let s = PATHLIKE.replace_all(line, "P");
26    let s = HEX.replace_all(&s, "H");
27    let s = NUM.replace_all(&s, "N");
28    let s = WS.replace_all(&s, " ");
29    s.trim().to_lowercase().chars().take(NORMAL_CHARS).collect()
30}
31
32/// The most characters of a normalized error line that enter its fingerprint. Protocol: stored
33/// scars are keyed by the fingerprint, so a change splits every one learned before.
34const NORMAL_CHARS: usize = 160;
35/// The most characters of the error line a scar keeps and shows. Display: one line.
36const LINE_CHARS: usize = 200;
37
38static FAILURE: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(r"(?m)^(Traceback \(most recent call last\)|FAILED|FAIL:|ERROR:|error(\[\w+\])?:|\w*(Error|Exception): |thread '.*' panicked|npm ERR!|fatal: )")
40        .unwrap()
41});
42
43/// A command can fail behind a pipe (`pytest | tail` exits 0), so judge by
44/// output too: tracebacks, test-runner failures, compiler errors.
45pub fn looks_failed(output: &str) -> bool {
46    FAILURE.is_match(output)
47}
48
49/// Lines that name the actual error (exception type with message, compiler error, fatal…).
50static SPECIFIC: LazyLock<Regex> = LazyLock::new(|| {
51    Regex::new(r"^\s*(?:E\s+)?(?:[\w.]*(?:Error|Exception|Exit)\b\s*:\s*\S|error(?:\[\w+\])?:\s*\S|fatal:\s*\S|npm ERR!\s*\S|.*\bcommand not found\b|.*\bNo such file or directory\b|.*\bnot found\b.*)").unwrap()
52});
53/// Headers and summaries that say *that* something failed, not *what*.
54static GENERIC: LazyLock<Regex> = LazyLock::new(|| {
55    Regex::new(r"(?i)^\s*(traceback \(most recent call last\)|build failed|failure: build failed|\* what went wrong|exit code \d+|failed \(|error: process completed|> task .* failed|\d+ (tests? )?failed|error:?\s*$)").unwrap()
56});
57/// Messages from the agent harness, not from the command.
58static HARNESS: LazyLock<Regex> = LazyLock::new(|| {
59    Regex::new(r"(?i)(permission to use \w+ has been denied|claude code is running in|<tool_use_error>|user (rejected|doesn't want)|interrupted by user)")
60        .unwrap()
61});
62
63/// The most specific error line of a failed command's output: the last line naming the error
64/// (Python tracebacks end with it), else the first error-looking line that is not a generic
65/// header. None for harness messages (permission denials, rejections).
66pub fn error_line(output: &str) -> Option<String> {
67    if HARNESS.is_match(output) {
68        return None;
69    }
70    let specific = output.lines().rev().find(|l| SPECIFIC.is_match(l) && !GENERIC.is_match(l));
71    specific.or_else(|| output.lines().find(|l| KEYWORD.is_match(l) && !GENERIC.is_match(l))).map(|l| l.trim().chars().take(LINE_CHARS).collect())
72}
73
74/// Fingerprint of the most specific error line (see `error_line`).
75pub fn error_fingerprint(output: &str) -> Option<u64> {
76    error_line(output).map(|l| normalize_error_line(&l)).filter(|s| s.len() >= 8).map(hash)
77}
78
79/// Test failures say the code under development is not done yet; they are not reusable knowledge.
80static TEST_FAILURE: LazyLock<Regex> = LazyLock::new(|| {
81    Regex::new(
82        r"(?i)(assertion(failed)?error|assert(ion)? failed|test(s)? failed|failed tests|expected:? .* but (was|got)|panicked at .*assert|\btests? .* FAILED$)",
83    )
84    .unwrap()
85});
86
87/// Whether an error line is a failing test assertion rather than an environment/tooling error.
88pub fn is_test_failure(line: &str) -> bool {
89    TEST_FAILURE.is_match(line)
90}
91
92/// The invocation structure of a command: program, subcommand, env assignments and flags,
93/// without test selectors, paths or quoted values. Two commands that differ only in which
94/// tests/files they target have the same invocation.
95pub fn invocation(cmd: &str) -> String {
96    let mut out = Vec::new();
97    let mut skip_next = false;
98    for t in cmd.split_whitespace() {
99        if skip_next {
100            skip_next = false;
101            continue;
102        }
103        if matches!(t, "--tests" | "-k" | "-p" | "--package" | "-m" | "--filter" | "-t") {
104            skip_next = true;
105            continue;
106        }
107        if t.contains('=') && !t.starts_with('-') && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
108            out.push(t.to_string()); // env assignment: part of how the tool must be run
109        } else if t.starts_with('-') {
110            out.push(t.split('=').next().unwrap_or(t).to_string());
111        } else if out.len() < 2 && !t.contains('/') && !t.contains('\'') && !t.contains('"') {
112            out.push(t.to_string());
113        }
114    }
115    out.join(" ")
116}
117
118const TOOLS: &[&str] = &[
119    "gradle",
120    "gradlew",
121    "mvn",
122    "mvnw",
123    "cargo",
124    "npm",
125    "pnpm",
126    "yarn",
127    "npx",
128    "pytest",
129    "uv",
130    "pip",
131    "pip3",
132    "poetry",
133    "go",
134    "make",
135    "docker",
136    "kubectl",
137    "terraform",
138    "gh",
139    "java",
140    "javac",
141    "node",
142    "deno",
143    "bun",
144    "set",
145    "source",
146    "lake",
147    "tsc",
148];
149
150/// Whether a command shape is a reusable tool invocation (build, test, package, env sourcing),
151/// as opposed to an ad-hoc script or a one-off inspection that teaches nothing to the next drone.
152pub fn is_tool_shape(shape: &str) -> bool {
153    let mut it = shape.split_whitespace();
154    let prog = it.next().unwrap_or("").trim_start_matches("./");
155    let sub = it.next().unwrap_or("");
156    TOOLS.contains(&prog) || prog.ends_with("gradlew") || prog.ends_with("mvnw") || (prog.starts_with("python") && TOOLS.contains(&sub))
157}
158
159/// Program plus subcommand (`cargo test`, `npm run`, `git status`), the
160/// unit at which "how do I build/test this repo" knowledge transfers.
161pub fn command_shape(cmd: &str) -> String {
162    let first = cmd
163        .split(['\n', ';', '|'])
164        .flat_map(|seg| seg.split("&&"))
165        .map(str::trim)
166        .find(|seg| !seg.is_empty() && !seg.starts_with("cd ") && !seg.starts_with('#'))
167        .unwrap_or("");
168    let mut toks = first.split_whitespace().filter(|t| !t.contains('=') || t.starts_with('-'));
169    let prog = toks.next().unwrap_or("").rsplit('/').next().unwrap_or("").to_string();
170    match toks.find(|t| !t.starts_with('-')) {
171        Some(sub) if sub.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':') => format!("{prog} {sub}"),
172        _ => prog,
173    }
174}
175
176const READERS: &[&str] = &["cat", "sed", "head", "tail", "nl", "bat", "less", "awk", "wc", "jq", "rg", "grep"];
177
178/// True when every segment of the command just reads or lists files.
179pub fn only_reads(cmd: &str) -> bool {
180    cmd.split(['\n', ';', '|'])
181        .flat_map(|s| s.split("&&"))
182        .filter_map(|seg| seg.split_whitespace().next())
183        .map(|p| p.rsplit('/').next().unwrap_or(p))
184        .all(|p| READERS.contains(&p) || matches!(p, "cd" | "ls" | "echo" | "printf" | "find" | "tree" | "pwd"))
185}
186
187/// Files a shell command pulls into context (`cat a.rs`, `sed -n 1,80p src/x.rs | …`).
188/// Agents read through Bash far more often than through the Read tool, so
189/// recall-on-touch must see these too.
190pub fn bash_read_paths(cmd: &str) -> Vec<String> {
191    let mut out = Vec::new();
192    for seg in cmd.split(['\n', ';', '|']).flat_map(|s| s.split("&&")) {
193        let mut toks = seg.split_whitespace();
194        let Some(prog) = toks.next().map(|p| p.rsplit('/').next().unwrap_or(p)) else { continue };
195        if !READERS.contains(&prog) {
196            continue;
197        }
198        let toks: Vec<&str> = toks.collect();
199        // Skip the pattern argument of grep/rg/sed/awk scripts.
200        let skip_first = matches!(prog, "rg" | "grep" | "sed" | "awk" | "jq");
201        let takes_arg: &[&str] = match prog {
202            "head" | "tail" => &["-n", "-c"],
203            "rg" | "grep" => &["-e", "-f", "-A", "-B", "-C", "-m", "-g", "-t", "--type", "--glob"],
204            "sed" | "awk" => &["-e", "-f", "-v"],
205            _ => &[],
206        };
207        let mut first_positional = true;
208        for (i, t) in toks.iter().enumerate() {
209            if t.starts_with('-') || t.starts_with('>') || t.starts_with('<') || t.starts_with('$') {
210                continue;
211            }
212            if i > 0 && takes_arg.contains(&toks[i - 1]) {
213                // `-e PATTERN` / `-f SCRIPT` supply the pattern, so no positional one follows.
214                first_positional &= !matches!(toks[i - 1], "-e" | "-f");
215                continue;
216            }
217            if first_positional && skip_first {
218                first_positional = false;
219                continue;
220            }
221            first_positional = false;
222            let t = t.trim_matches(|c| c == '"' || c == '\'');
223            let looks_like_file = t
224                .rsplit('/')
225                .next()
226                .is_some_and(|base| base.contains('.') && !base.starts_with('.') || base.chars().all(|c| c.is_ascii_uppercase() || c == '_') && base.len() > 3);
227            if looks_like_file && !t.contains(['*', '?', '{', '(', '`', '\\', '=']) && !t.ends_with(".") {
228                out.push(t.to_string());
229            }
230        }
231    }
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn failure_behind_pipe() {
241        assert!(looks_failed("  File \"x.py\", line 3\nValueError: naive datetime"));
242        assert!(looks_failed("Ran 3 tests\n\nFAILED (errors=1)"));
243        assert!(looks_failed("error[E0425]: cannot find value"));
244        assert!(!looks_failed("Ran 3 tests in 0.001s\n\nOK"));
245        assert!(only_reads("cat a.py | head; ls src"));
246        assert!(!only_reads("cat a.py; python3 -m unittest | tail"));
247    }
248
249    #[test]
250    fn shell_reads() {
251        assert_eq!(bash_read_paths("sed -n '1,80p' src/main.rs | head"), vec!["src/main.rs"]);
252        assert_eq!(bash_read_paths("cat Cargo.toml README.md"), vec!["Cargo.toml", "README.md"]);
253        assert_eq!(bash_read_paths("rg -n 'fn parse' src/lib.rs"), vec!["src/lib.rs"]);
254        assert_eq!(bash_read_paths("cargo test && ls src"), Vec::<String>::new());
255        assert_eq!(bash_read_paths("head -n 20 /abs/x.py 2>/dev/null"), vec!["/abs/x.py"]);
256    }
257
258    #[test]
259    fn same_error_different_details() {
260        let a = "error[E0425]: cannot find value `x` in /tmp/a1/src/main.rs:12:5";
261        let b = "error[E0425]: cannot find value `x` in /home/user/r/src/main.rs:99:1";
262        assert_eq!(error_fingerprint(a), error_fingerprint(b));
263        assert_ne!(error_fingerprint(a), error_fingerprint("error: linker `cc` not found"));
264        assert_eq!(error_fingerprint("all good\nok"), None);
265    }
266
267    #[test]
268    fn specific_error_line_wins() {
269        let py = "Traceback (most recent call last):\n  File \"x.py\", line 3\nModuleNotFoundError: No module named 'aiortc'";
270        assert_eq!(error_line(py).unwrap(), "ModuleNotFoundError: No module named 'aiortc'");
271        let other = "Traceback (most recent call last):\n  File \"y.py\", line 9\nKeyError: 'id'";
272        assert_ne!(error_fingerprint(py), error_fingerprint(other), "generic header must not collide");
273        assert_eq!(error_line("Permission to use Bash has been denied because Claude Code is running in don't ask mode"), None);
274        assert!(is_tool_shape("./gradlew test") && is_tool_shape("cargo test") && !is_tool_shape("python3") && !is_tool_shape("grep"));
275    }
276
277    #[test]
278    fn command_shapes() {
279        assert_eq!(command_shape("cd foo && cargo test -p x -- --nocapture"), "cargo test");
280        assert_eq!(command_shape("RUST_LOG=debug /usr/bin/npm run build"), "npm run");
281        assert_eq!(command_shape("ls -la"), "ls");
282        assert_eq!(command_shape("git status | head"), "git status");
283    }
284}