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