2026-08-22 21:43:23 -07:00
//! 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 < String > ) {
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 ( ":<LINE>:<COL>" . into ()))
}
[ l , head ] if l . chars (). all ( | x | x . is_ascii_digit ()) && ! l . is_empty () => {
( head , Some ( ":<LINE>" . 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 {
"<ADDR>" . to_string ()
} else if is_timestamp ( core ) {
"<TS>" . to_string ()
} else if is_duration ( core ) {
"<DUR>" . to_string ()
} else if is_hex_sha ( core ) {
"<SHA>" . 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! ( "<PATH>/ {base} " ),
_ => "<PATH>" . 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 ::< Vec < _ >> ()
. 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 {
"<PATH>" . to_string ()
} else {
normalise_token ( tok )
}
})
. collect ::< Vec < _ >> ()
. 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 " ) {
// "<prefix> code <TOKEN>" 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 < Signature > {
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 < char > = 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 < String > ,
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 < Hit > {
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 );
2026-09-14 23:25:05 +09:00
if s >= floor && best . is_none_or ( | ( bs , _ ) | s > bs ) {
2026-08-22 21:43:23 -07:00
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 < Lesson > {
let mut out : Vec < Lesson > = 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 < String > = 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 (),
2026-09-14 23:25:05 +09:00
"" => "unknown" . into (),
2026-08-22 21:43:23 -07:00
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 < String > = 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 ();
2026-09-14 23:25:05 +09:00
sorted . sort_by_key ( | a | std ::cmp ::Reverse ( a . seen ));
2026-08-22 21:43:23 -07:00
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
));
2026-09-14 23:25:05 +09:00
s . push_str ( & format! ( "- signature: ` {} ` \n " , & l . sig_sha [ .. 12 ]));
2026-08-22 21:43:23 -07:00
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 ( "<TS>" ), "{a}" );
assert! ( a . contains ( "<PATH>/main.rs:<LINE>:<COL>" ), "{a}" );
assert! ( a . contains ( "<DUR>" ), "{a}" );
assert! ( a . contains ( "<SHA>" ), "{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 \n npm ERR! ERESOLVE unable to resolve dependency tree \n npm 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 \n npm ERR! ERESOLVE unable to resolve dependency tree \n npm 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 \n error: could not compile `foo` \n error[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 \n npm 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" ),
];
2026-09-14 23:25:05 +09:00
let ls = derive_lessons ( & events , tool_of_cmd );
2026-08-22 21:43:23 -07:00
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 )];
2026-09-14 23:25:05 +09:00
assert! ( derive_lessons ( & events , tool_of_cmd ). is_empty ());
2026-08-22 21:43:23 -07:00
}
#[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 (),
};
2026-09-14 23:25:05 +09:00
assert_eq! ( lookup ( & exact , std ::slice ::from_ref ( & l ), 0.5 ). unwrap (). tier , Tier ::Exact );
2026-08-22 21:43:23 -07:00
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 ());
}
}