//! Failure lessons: capture, normalise, match, materialise. //! //! Follows Claude Code's file conventions on purpose. Lessons materialise as //! `SKILL.md` files and a `CLAUDE.md` fragment, so the filesystem is the API and //! no client integration is required -- Claude Code, pi and anything else that //! reads those conventions get the memory for free. //! //! What we add over hand-written CLAUDE.md is authorship: lessons are captured //! from real failures and their observed resolutions, and they carry provenance. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashSet; // --------------------------------------------------------------------------- // Events -- the authoritative append-only record // --------------------------------------------------------------------------- /// One observed command execution. Appended to the JSONL log, never mutated. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Event { pub ts: String, pub cwd: String, pub cmd: String, pub exit: i32, /// Tail of combined output. Capped at capture time. pub output: String, } impl Event { /// Commands are compared after dropping volatile arguments, so that /// `kubectl apply -f /tmp/abc123.yaml` pairs with a later retry. pub fn cmd_key(&self) -> String { normalise_cmd(&self.cmd) } } // --------------------------------------------------------------------------- // Normalisation // --------------------------------------------------------------------------- /// Remove ANSI SGR sequences. Coloured output otherwise hashes differently /// depending on whether a TTY was attached. pub fn strip_ansi(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if c == '\u{1b}' { // CSI introducer '[' is itself inside the final-byte range, so it // must be consumed before scanning for the terminator. if chars.peek() == Some(&'[') { chars.next(); } // parameter bytes 0x30-0x3f, intermediates 0x20-0x2f, final 0x40-0x7e for c2 in chars.by_ref() { if ('\u{40}'..='\u{7e}').contains(&c2) { break; } } } else { out.push(c); } } out } fn is_hex_sha(s: &str) -> bool { // Bias against matching: require length and at least two digits, so that // English words made of hex letters ("deadbeef" is rare, "facade" is not) // are left alone. Under-normalising costs a tier-1 miss; over-normalising // costs a confident wrong answer. let n = s.len(); if !(7..=40).contains(&n) { return false; } if !s.chars().all(|c| c.is_ascii_hexdigit()) { return false; } s.chars().filter(|c| c.is_ascii_digit()).count() >= 2 } fn is_timestamp(s: &str) -> bool { let b = s.as_bytes(); // ISO-8601-ish: 4 digits, '-', ... with a 'T' if b.len() >= 10 && b[..4].iter().all(|c| c.is_ascii_digit()) && b[4] == b'-' && s.contains('T') { return true; } // bare epoch seconds / millis if (10 == b.len() || 13 == b.len()) && b.iter().all(|c| c.is_ascii_digit()) { return true; } false } fn is_duration(s: &str) -> bool { let b = s.as_bytes(); if b.is_empty() || !b[0].is_ascii_digit() { return false; } let unit_tail = s.ends_with("ms") || s.ends_with('s') || s.ends_with('m') || s.ends_with('h') || s.ends_with("\u{b5}s"); if !unit_tail { return false; } s.chars() .all(|c| c.is_ascii_digit() || c == '.' || c.is_ascii_alphabetic()) } /// Split a trailing `:LINE` or `:LINE:COL` off a token. fn split_line_col(tok: &str) -> (&str, Option) { let parts: Vec<&str> = tok.rsplitn(3, ':').collect(); match parts.as_slice() { [c, l, head] if c.chars().all(|x| x.is_ascii_digit()) && l.chars().all(|x| x.is_ascii_digit()) && !c.is_empty() && !l.is_empty() => { (head, Some("::".into())) } [l, head] if l.chars().all(|x| x.is_ascii_digit()) && !l.is_empty() => { (head, Some(":".into())) } _ => (tok, None), } } fn normalise_token(tok: &str) -> String { let (core, suffix) = split_line_col(tok); let repl = if core.starts_with("0x") && core.len() > 2 { "".to_string() } else if is_timestamp(core) { "".to_string() } else if is_duration(core) { "".to_string() } else if is_hex_sha(core) { "".to_string() } else if core.contains('/') && core.len() > 3 { // Keep the basename: which file failed is meaningful, the workspace // prefix it sat under is not. match core.rsplit_once('/') { Some((_, base)) if !base.is_empty() => format!("/{base}"), _ => "".to_string(), } } else { core.to_string() }; match suffix { Some(s) => format!("{repl}{s}"), None => repl, } } /// Reduce a line to a form that is stable across runs of the same failure. /// /// Deliberately does NOT touch bare integers: `exit status 1` and /// `exit status 137` must stay distinguishable, or OOM collides with a test /// failure. pub fn normalise(raw: &str) -> String { strip_ansi(raw) .split_whitespace() .map(normalise_token) .collect::>() .join(" ") } /// Normalise a *command line* for identity comparison. /// /// Harsher than [`normalise`], which keeps basenames because knowing which file /// failed to compile is meaningful. For a command, the opposite holds: applying /// `np-x7f2.yaml` then `np-a91c.yaml` is the same action on a regenerated temp /// file, and keeping the basename stops the pair from ever being found. pub fn normalise_cmd(cmd: &str) -> String { strip_ansi(cmd) .split_whitespace() .map(|tok| { let (core, _) = split_line_col(tok); if core.contains('/') && core.len() > 3 { "".to_string() } else { normalise_token(tok) } }) .collect::>() .join(" ") } // --------------------------------------------------------------------------- // Signature extraction // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Signature { pub tool: String, /// The original error line, for display. pub raw: String, pub normalised: String, pub sig_sha: String, /// Which rule fired. The debugging surface for the whole tier. pub rule: String, } fn markers(tool: &str) -> &'static [&'static str] { match tool { "npm" | "pnpm" | "yarn" => &["npm ERR!", "ERR_", "error "], "cargo" | "rust" => &["error[", "error:", "panicked at"], "go" => &["panic:", "undefined:", "cannot use", "error:"], "kubectl" | "k8s" => &["error:", "Error from server", "Unable to connect"], "github-actions" | "gha" => &["##[error]", "Error:", "error:"], "docker" => &["ERROR:", "failed to", "Error response from daemon"], "terraform" => &["Error:", "\u{2502} Error:"], _ => &[], } } const GENERIC_MARKERS: &[&str] = &[ "error:", "Error:", "ERROR", "ERR!", "FAILED", "fatal:", "panic:", "Exception", ]; /// A bare error-code declaration such as `npm ERR! code ERESOLVE`, which /// prefixes the descriptive line rather than replacing it. /// /// Found by fixture: some runs emit it and some do not, so anchoring here /// splits one failure into two signatures and dilutes `seen`. fn is_code_declaration(line: &str) -> bool { let t = line.trim(); if let Some(idx) = t.find(" code ") { // " code " with nothing after the token let rest = t[idx + 6..].trim(); return !rest.is_empty() && !rest.contains(' '); } false } /// Lines that are consequences of an earlier failure. Anchoring on these keys /// the lesson to a symptom of a symptom -- and the last line of a GitHub /// Actions log is identical across every failure it has ever produced. fn is_cascade(line: &str) -> bool { const SUPPRESS: &[&str] = &[ "##[error]Process completed with exit code", "make: ***", "npm ERR! A complete log of this run", "error: could not compile", "error: build failed", "FAILED (", "Error: Process completed", "exit status", ]; let t = line.trim(); SUPPRESS.iter().any(|s| t.starts_with(s) || t.contains(s)) } /// Extract the first error that is not a consequence of another. /// /// Falls back to the last non-empty line for unknown tools: a worse signature /// is still a signature, and failing because a tool is unrecognised is useless /// in exactly the moment someone needs an answer. pub fn extract(tool: &str, output: &str) -> Option { let clean = strip_ansi(output); let lines: Vec<&str> = clean.lines().map(|l| l.trim_end()).collect(); let tool_markers = markers(tool); let mut found: Option<(String, &'static str)> = None; let skip = |l: &str| l.trim().is_empty() || is_cascade(l) || is_code_declaration(l); for line in lines.iter() { if skip(line) { continue; } if tool_markers.iter().any(|m| line.contains(m)) { found = Some((line.trim().to_string(), "tool-rule")); break; } } if found.is_none() { for line in lines.iter() { if skip(line) { continue; } if GENERIC_MARKERS.iter().any(|m| line.contains(m)) { found = Some((line.trim().to_string(), "generic-marker")); break; } } } if found.is_none() { let last = lines.iter().rev().find(|l| !l.trim().is_empty())?; found = Some((last.trim().to_string(), "last-line-fallback")); } let (raw, rule) = found?; let normalised = normalise(&raw); if normalised.is_empty() { return None; } // Tool is part of identity: `exit status 1` means different things // in different tools. let mut h = Sha256::new(); h.update(tool.as_bytes()); h.update(b"\n"); h.update(normalised.as_bytes()); let sig_sha = hex(&h.finalize()); Some(Signature { tool: tool.to_string(), raw, normalised, sig_sha, rule: rule.to_string(), }) } fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } // --------------------------------------------------------------------------- // Similarity -- tier 2 without embeddings // --------------------------------------------------------------------------- fn trigrams(s: &str) -> HashSet<[char; 3]> { let cs: Vec = s.to_lowercase().chars().collect(); let mut set = HashSet::new(); for w in cs.windows(3) { set.insert([w[0], w[1], w[2]]); } set } /// Jaccard similarity over character trigrams. Deterministic, no model, no /// index. Good enough to decide whether embeddings are worth adding -- if this /// never misses, the vector store is unjustified. pub fn similarity(a: &str, b: &str) -> f32 { let (ta, tb) = (trigrams(a), trigrams(b)); if ta.is_empty() || tb.is_empty() { return 0.0; } let inter = ta.intersection(&tb).count() as f32; let union = ta.union(&tb).count() as f32; inter / union } // --------------------------------------------------------------------------- // Lessons // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Confidence { /// Derived mechanically from a fail -> success pair. Might be coincidence. Inferred, /// A human kept it. Outranks inferred at equal similarity. Confirmed, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Lesson { pub sig_sha: String, pub tool: String, pub raw: String, pub normalised: String, /// Commands observed between the failure and the next success. pub resolution: Vec, pub seen: u32, pub last_seen: String, pub cwd: String, pub confidence: Confidence, } #[derive(Debug, Clone, PartialEq)] pub enum Tier { /// Exact signature match: this precise failure happened here before. Exact, /// Similar signature: something like it happened. Similar(f32), } #[derive(Debug, Clone)] pub struct Hit { pub lesson: Lesson, pub tier: Tier, } /// Look a failure up against known lessons. /// /// Abstention is a first-class outcome. An agent acts on the top result, so /// a plausible-but-wrong lesson is worse than silence -- it turns a confused /// agent into a confident one going the wrong way. pub fn lookup(sig: &Signature, lessons: &[Lesson], floor: f32) -> Option { if let Some(l) = lessons.iter().find(|l| l.sig_sha == sig.sig_sha) { return Some(Hit { lesson: l.clone(), tier: Tier::Exact, }); } let mut best: Option<(f32, &Lesson)> = None; for l in lessons.iter().filter(|l| l.tool == sig.tool) { let s = similarity(&sig.normalised, &l.normalised); if s >= floor && best.map_or(true, |(bs, _)| s > bs) { best = Some((s, l)); } } best.map(|(s, l)| Hit { lesson: l.clone(), tier: Tier::Similar(s), }) } /// Pair failures with the next success of the same command in the same /// directory. The commands in between are the candidate resolution. /// /// Mechanical and self-labelling: no model, no human prompt. Noisy, which is /// why everything it produces is `Inferred`. pub fn derive_lessons(events: &[Event], tool_of: impl Fn(&str) -> String) -> Vec { let mut out: Vec = Vec::new(); for (i, ev) in events.iter().enumerate() { if ev.exit == 0 { continue; } let key = ev.cmd_key(); // find the next success of the same command in the same cwd let Some(succ_idx) = events .iter() .enumerate() .skip(i + 1) .find(|(_, e)| e.exit == 0 && e.cwd == ev.cwd && e.cmd_key() == key) .map(|(j, _)| j) else { continue; }; let resolution: Vec = events[i + 1..succ_idx] .iter() .filter(|e| e.cwd == ev.cwd && e.exit == 0) .map(|e| e.cmd.clone()) .filter(|c| !is_opaque_action(c)) .collect(); if resolution.is_empty() { // Either a bare retry (flaky, not a lesson) or a delta consisting // only of opaque actions, which teaches nothing. continue; } let tool = tool_of(&ev.cmd); let Some(sig) = extract(&tool, &ev.output) else { continue; }; if let Some(existing) = out.iter_mut().find(|l| l.sig_sha == sig.sig_sha) { existing.seen += 1; existing.last_seen = ev.ts.clone(); // Prefer the most recent resolution: if the same failure recurred, // whatever was done last is the version that stuck. existing.resolution = resolution; continue; } out.push(Lesson { sig_sha: sig.sig_sha, tool, raw: sig.raw, normalised: sig.normalised, resolution, seen: 1, last_seen: ev.ts.clone(), cwd: ev.cwd.clone(), confidence: Confidence::Inferred, }); } out } /// Commands that record that a human did something, without recording what. /// /// `vim package.json` is a true observation and a useless lesson. Filtering /// these is the difference between "someone edited a file" and an actionable /// resolution. A pair whose entire delta is opaque yields no lesson at all -- /// abstention again, at write time. fn is_opaque_action(cmd: &str) -> bool { let first = cmd.split_whitespace().next().unwrap_or(""); let base = first.rsplit('/').next().unwrap_or(first); matches!( base, "vim" | "vi" | "nvim" | "nano" | "emacs" | "code" | "subl" | "open" | "cd" | "ls" | "cat" | "less" | "tail" | "head" | "pwd" | "echo" | "clear" | "which" | "man" ) || cmd.trim() == "git status" } /// Guess the tool from a command line. pub fn tool_of_cmd(cmd: &str) -> String { let first = cmd.split_whitespace().next().unwrap_or(""); let base = first.rsplit('/').next().unwrap_or(first); match base { "npm" | "pnpm" | "yarn" => "npm".into(), "cargo" => "cargo".into(), "go" => "go".into(), "kubectl" | "k" => "kubectl".into(), "docker" | "podman" => "docker".into(), "terraform" | "tofu" => "terraform".into(), other if other.is_empty() => "unknown".into(), other => other.to_string(), } } // --------------------------------------------------------------------------- // Materialisation -- Claude Code conventions // --------------------------------------------------------------------------- /// Render lessons for one tool as a SKILL.md. /// /// The `description` field is the load-bearing part: it lists the error strings /// this skill explains, so a harness doing progressive disclosure matches on /// symptoms rather than on prose. This is a hand-rule symptom projection -- /// the same job M3.7.8 gives an LLM, done for free at materialise time. pub fn render_skill(tool: &str, lessons: &[Lesson]) -> String { let mut triggers: Vec = lessons .iter() .map(|l| { let t = l.raw.trim(); let t: String = t.chars().take(90).collect(); t.replace('"', "'") }) .collect(); triggers.sort(); triggers.dedup(); let mut s = String::new(); s.push_str("---\n"); s.push_str(&format!("name: {tool}-failures\n")); s.push_str("description: >\n"); s.push_str(&format!( " Past {tool} failures seen in this workspace and what resolved them.\n" )); s.push_str(" Use when a "); s.push_str(tool); s.push_str(" command fails, or when output contains any of:\n"); for t in triggers.iter().take(12) { s.push_str(&format!(" \"{t}\";\n")); } s.push_str("---\n\n"); s.push_str(&format!("# {tool} failures\n\n")); s.push_str("Generated by `mem materialize`. Edit freely -- edits mark a lesson\n"); s.push_str("`confirmed`, which outranks inferred lessons at equal similarity.\n\n"); let mut sorted: Vec<&Lesson> = lessons.iter().collect(); sorted.sort_by(|a, b| b.seen.cmp(&a.seen)); for l in sorted { s.push_str(&format!("## {}\n\n", l.raw.trim())); s.push_str(&format!( "- seen: {} | last: {} | confidence: {:?}\n", l.seen, l.last_seen, l.confidence )); s.push_str(&format!("- signature: `{}`\n", l.sig_sha[..12].to_string())); s.push_str("- resolved by:\n"); for r in &l.resolution { s.push_str(&format!(" ```\n {r}\n ```\n")); } if l.seen >= 3 { s.push_str( "- **recurring** -- this has bitten us repeatedly. Prefer fixing the\n root cause over reapplying the workaround.\n", ); } s.push('\n'); } s } /// Render the compact block for injection at failure time. /// /// Hard-capped, because every injected token displaces the task. Two hundred /// tokens of "you hit this in July, fix was X" beats a page of adjacent docs. pub fn render_injection(hit: &Hit, max_chars: usize) -> String { let l = &hit.lesson; let header = match hit.tier { Tier::Exact => format!( "MEMORY (exact match, seen {}x, last {}):", l.seen, l.last_seen ), Tier::Similar(s) => format!("MEMORY (similar failure, {:.0}% match):", s * 100.0), }; let mut s = format!("{header}\n {}\n resolved by:\n", l.raw.trim()); for r in &l.resolution { s.push_str(&format!(" {r}\n")); } if l.seen >= 3 { s.push_str(" NOTE: recurring - consider fixing the root cause.\n"); } if s.len() > max_chars { s.truncate(max_chars); s.push_str("...\n"); } s } #[cfg(test)] mod tests { use super::*; #[test] fn strips_ansi() { assert_eq!(strip_ansi("\u{1b}[31merror\u{1b}[0m: x"), "error: x"); } #[test] fn normalises_volatiles_but_keeps_exit_codes() { let a = normalise("at 2026-08-21T10:02:11.482Z /home/runner/work/o/r/src/main.rs:42:5 took 4m21s sha 9f3ab12c4d"); assert!(a.contains(""), "{a}"); assert!(a.contains("/main.rs::"), "{a}"); assert!(a.contains(""), "{a}"); assert!(a.contains(""), "{a}"); // meaning-bearing numbers survive let b = normalise("exit status 137"); assert!(b.contains("137"), "{b}"); assert_ne!(normalise("exit status 137"), normalise("exit status 1")); } #[test] fn same_failure_different_runs_same_hash() { let run1 = "2026-08-01T10:00:00Z Run 4821\nnpm ERR! ERESOLVE unable to resolve dependency tree\nnpm ERR! A complete log of this run can be found in: /home/runner/.npm/_logs/x.log\n##[error]Process completed with exit code 1"; let run2 = "2026-09-14T22:31:07Z Run 9903\nnpm ERR! ERESOLVE unable to resolve dependency tree\nnpm ERR! A complete log of this run can be found in: /Users/rock/.npm/_logs/y.log\n##[error]Process completed with exit code 1"; let a = extract("npm", run1).unwrap(); let b = extract("npm", run2).unwrap(); assert_eq!(a.sig_sha, b.sig_sha); assert_eq!(a.rule, "tool-rule"); } #[test] fn different_failures_differ() { let a = extract("npm", "npm ERR! ERESOLVE unable to resolve dependency tree").unwrap(); let b = extract("npm", "npm ERR! 404 Not Found - GET https://registry.npmjs.org/nope").unwrap(); assert_ne!(a.sig_sha, b.sig_sha); } #[test] fn cascade_lines_are_skipped() { let log = "##[error]Process completed with exit code 1\nerror: could not compile `foo`\nerror[E0308]: mismatched types"; let s = extract("cargo", log).unwrap(); assert!(s.raw.contains("E0308"), "picked cascade line: {}", s.raw); } #[test] fn code_declaration_does_not_split_a_failure() { // Found by fixture: run A emits the `code` line, run C does not. let with = "npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree"; let without = "npm ERR! ERESOLVE unable to resolve dependency tree"; assert_eq!( extract("npm", with).unwrap().sig_sha, extract("npm", without).unwrap().sig_sha ); } #[test] fn cmd_key_ignores_temp_file_names() { let a = Event { ts: "t".into(), cwd: "/w".into(), cmd: "kubectl apply -f /tmp/np-x7f2.yaml".into(), exit: 1, output: String::new(), }; let b = Event { cmd: "kubectl apply -f /tmp/np-a91c.yaml".into(), ..a.clone() }; assert_eq!(a.cmd_key(), b.cmd_key()); // but a genuinely different action must not collide let c = Event { cmd: "kubectl delete -f /tmp/np-a91c.yaml".into(), ..a.clone() }; assert_ne!(a.cmd_key(), c.cmd_key()); } #[test] fn error_lines_still_keep_basenames() { // The cmd_key fix must not leak into error normalisation: which file // failed to compile is meaningful. assert!(normalise("error at /w/src/main.rs:4:2").contains("main.rs")); } #[test] fn tool_is_part_of_identity() { let a = extract("npm", "error: boom").unwrap(); let b = extract("cargo", "error: boom").unwrap(); assert_ne!(a.sig_sha, b.sig_sha); } #[test] fn unknown_tool_falls_back() { let s = extract("frobnicate", "something went sideways").unwrap(); assert_eq!(s.rule, "last-line-fallback"); } #[test] fn derives_lesson_from_fail_then_success() { let ev = |ts: &str, cmd: &str, exit: i32, out: &str| Event { ts: ts.into(), cwd: "/w".into(), cmd: cmd.into(), exit, output: out.into(), }; let events = vec![ ev("t1", "npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"), ev("t2", "npm pkg set overrides.react=19", 0, ""), ev("t3", "npm ci", 0, "ok"), ]; let ls = derive_lessons(&events, |c| tool_of_cmd(c)); assert_eq!(ls.len(), 1); assert_eq!(ls[0].resolution, vec!["npm pkg set overrides.react=19"]); assert_eq!(ls[0].confidence, Confidence::Inferred); } #[test] fn opaque_edits_do_not_become_a_resolution() { let ev = |cmd: &str, exit: i32, out: &str| Event { ts: "t".into(), cwd: "/w".into(), cmd: cmd.into(), exit, output: out.into(), }; // only an editor between fail and success -> no lesson let only_vim = vec![ ev("npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"), ev("vim package.json", 0, ""), ev("npm ci", 0, "ok"), ]; assert!(derive_lessons(&only_vim, tool_of_cmd).is_empty()); // a real command survives, and the editor is dropped from it let mixed = vec![ ev("npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"), ev("vim package.json", 0, ""), ev("npm pkg set overrides.react=19", 0, ""), ev("npm ci", 0, "ok"), ]; let ls = derive_lessons(&mixed, tool_of_cmd); assert_eq!(ls[0].resolution, vec!["npm pkg set overrides.react=19"]); } #[test] fn failed_attempts_are_not_the_resolution() { let ev = |cmd: &str, exit: i32, out: &str| Event { ts: "t".into(), cwd: "/w".into(), cmd: cmd.into(), exit, output: out.into(), }; let events = vec![ ev("cargo build", 1, "error[E0308]: mismatched types"), ev("cargo fix --broken", 1, "error: no"), ev("cargo add serde", 0, ""), ev("cargo build", 0, "ok"), ]; let ls = derive_lessons(&events, tool_of_cmd); assert_eq!(ls[0].resolution, vec!["cargo add serde"]); } #[test] fn bare_retry_is_not_a_lesson() { let ev = |cmd: &str, exit: i32| Event { ts: "t".into(), cwd: "/w".into(), cmd: cmd.into(), exit, output: "error: flaky".into(), }; let events = vec![ev("npm ci", 1), ev("npm ci", 0)]; assert!(derive_lessons(&events, |c| tool_of_cmd(c)).is_empty()); } #[test] fn lookup_prefers_exact_then_abstains() { let l = Lesson { sig_sha: "abc".into(), tool: "npm".into(), raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(), normalised: "npm ERR! ERESOLVE unable to resolve dependency tree".into(), resolution: vec!["npm ci --legacy-peer-deps".into()], seen: 2, last_seen: "t".into(), cwd: "/w".into(), confidence: Confidence::Inferred, }; let exact = Signature { tool: "npm".into(), raw: "x".into(), normalised: "x".into(), sig_sha: "abc".into(), rule: "r".into(), }; assert_eq!(lookup(&exact, &[l.clone()], 0.5).unwrap().tier, Tier::Exact); let unrelated = Signature { tool: "npm".into(), raw: "y".into(), normalised: "totally different disk full message".into(), sig_sha: "zzz".into(), rule: "r".into(), }; assert!( lookup(&unrelated, &[l], 0.5).is_none(), "must abstain rather than return a weak match" ); } #[test] fn similar_wording_still_matches() { let l = Lesson { sig_sha: "abc".into(), tool: "npm".into(), raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(), normalised: "npm ERR! ERESOLVE unable to resolve dependency tree".into(), resolution: vec!["npm ci --legacy-peer-deps".into()], seen: 1, last_seen: "t".into(), cwd: "/w".into(), confidence: Confidence::Inferred, }; let sig = extract("npm", "npm ERR! ERESOLVE could not resolve dependency tree").unwrap(); let hit = lookup(&sig, &[l], 0.5).expect("should match on wording drift"); assert!(matches!(hit.tier, Tier::Similar(s) if s > 0.5)); } #[test] fn skill_description_lists_symptoms_not_summary() { let l = Lesson { sig_sha: "abc123def456".into(), tool: "npm".into(), raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(), normalised: "n".into(), resolution: vec!["npm ci --legacy-peer-deps".into()], seen: 3, last_seen: "t".into(), cwd: "/w".into(), confidence: Confidence::Inferred, }; let md = render_skill("npm", &[l]); assert!(md.starts_with("---\n")); assert!(md.contains("name: npm-failures")); // the trigger string, not a paraphrase assert!(md.contains("ERESOLVE unable to resolve dependency tree")); assert!(md.contains("recurring")); } #[test] fn injection_is_capped() { let l = Lesson { sig_sha: "a".into(), tool: "npm".into(), raw: "npm ERR! boom".into(), normalised: "n".into(), resolution: vec!["x".repeat(500)], seen: 1, last_seen: "t".into(), cwd: "/w".into(), confidence: Confidence::Inferred, }; let out = render_injection(&Hit { lesson: l, tier: Tier::Exact }, 200); assert!(out.len() <= 204, "len {}", out.len()); } }