From 4da22d94c7fe6a8665e932d6fd67185b57d61a5a Mon Sep 17 00:00:00 2001 From: Mark Stuart Date: Mon, 2 Nov 2026 02:33:54 +0000 Subject: [PATCH] fix: preserve learned patterns and source analysis semantics --- src/git/decision_parser.rs | 11 +++++---- src/graph/analyzer.rs | 11 +++++---- src/sessions/patterns.rs | 31 ++++++++++++------------ tests/nov2_regressions.rs | 49 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 tests/nov2_regressions.rs diff --git a/src/git/decision_parser.rs b/src/git/decision_parser.rs index 7ec1b0a..eb85b09 100644 --- a/src/git/decision_parser.rs +++ b/src/git/decision_parser.rs @@ -39,22 +39,23 @@ impl DecisionParser { pub fn parse_message(&self, message: &str) -> Vec { let mut decisions = Vec::new(); + let normalized = message.to_lowercase(); // Check if message contains decision keywords let has_decision = self .decision_keywords .iter() - .any(|re| re.is_match(&message.to_lowercase())); + .any(|re| re.is_match(&normalized)); if !has_decision { return decisions; } // Extract decision type - let decision_type = if message.contains("migrat") { + let decision_type = if normalized.contains("migrat") { "migration" - } else if message.contains("arch:") || message.contains("architecture") { + } else if normalized.contains("arch:") || normalized.contains("architecture") { "architecture" - } else if message.contains("refactor:") { + } else if normalized.contains("refactor:") { "refactoring" } else { "general" @@ -86,7 +87,7 @@ impl DecisionParser { fn extract_migration_pair(message: &str) -> (Option, String) { // Pattern: "from X to Y" or "migrate from X to Y" - let from_to_re = Regex::new(r"from\s+(\w+)\s+to\s+(\w+)").unwrap(); + let from_to_re = Regex::new(r"(?i)from\s+(\w+)\s+to\s+(\w+)").unwrap(); if let Some(caps) = from_to_re.captures(message) { let from = caps.get(1).map(|m| m.as_str().to_string()); diff --git a/src/graph/analyzer.rs b/src/graph/analyzer.rs index c25e6c2..de3a2ae 100644 --- a/src/graph/analyzer.rs +++ b/src/graph/analyzer.rs @@ -51,8 +51,10 @@ fn source_aliases(file_path: &str) -> Vec { ) { aliases.push(path.with_extension("").to_string_lossy().into_owned()); } - if matches!(extension, Some("ts" | "tsx" | "js")) - && path.file_stem().and_then(|stem| stem.to_str()) == Some("index") + if (matches!(extension, Some("ts" | "tsx" | "js")) + && path.file_stem().and_then(|stem| stem.to_str()) == Some("index")) + || (extension == Some("rs") + && path.file_stem().and_then(|stem| stem.to_str()) == Some("mod")) { if let Some(parent) = path.parent() { aliases.push(parent.to_string_lossy().into_owned()); @@ -168,7 +170,7 @@ impl DependencyGraph { return self.node_map.get(&with_ext).copied(); } } - for ext in &["/index.ts", "/index.js", "/index.tsx"] { + for ext in &["/index.ts", "/index.js", "/index.tsx", "/mod.rs"] { let with_index = format!("{}{}", resolved, ext); if self.source_paths.contains(&with_index) { return self.node_map.get(&with_index).copied(); @@ -315,7 +317,8 @@ impl DependencyGraph { return normalize_path(&joined.to_string_lossy()); } - import_path.to_string() + let joined = source_dir.join(source_stem).join(import_path); + normalize_path(&joined.to_string_lossy()) } /// What does this file depend on? (direct) diff --git a/src/sessions/patterns.rs b/src/sessions/patterns.rs index f3a8d1b..2d6c9b5 100644 --- a/src/sessions/patterns.rs +++ b/src/sessions/patterns.rs @@ -45,20 +45,11 @@ impl PatternLibrary { /// Record an observation of a pattern key. pub fn observe(&mut self, key: &str) { - *self.observations.entry(key.to_string()).or_insert(0) += 1; - } - - /// Record an observation with an example. - pub fn observe_with_example(&mut self, key: &str, example: &str) { - self.observe(key); - - let count = *self.observations.get(key).unwrap_or(&1); + let count = self.observations.entry(key.to_string()).or_insert(0); + *count += 1; + let count = *count; let confidence = Self::calc_confidence(count); - if let Some(pattern) = self.patterns.iter_mut().find(|p| p.key == key) { - if pattern.examples.len() < 10 { - pattern.examples.push(example.to_string()); - } pattern.observation_count = count; pattern.confidence = confidence; } else { @@ -67,13 +58,23 @@ impl PatternLibrary { pattern_type, key: key.to_string(), description, - observation_count: 1, - confidence: 0.1, - examples: vec![example.to_string()], + observation_count: count, + confidence, + examples: Vec::new(), }); } } + /// Record an observation with an example. + pub fn observe_with_example(&mut self, key: &str, example: &str) { + self.observe(key); + if let Some(pattern) = self.patterns.iter_mut().find(|p| p.key == key) { + if pattern.examples.len() < 10 { + pattern.examples.push(example.to_string()); + } + } + } + fn classify_pattern(&self, key: &str) -> (PatternType, String) { if key.starts_with("naming:") { ( diff --git a/tests/nov2_regressions.rs b/tests/nov2_regressions.rs new file mode 100644 index 0000000..1a12787 --- /dev/null +++ b/tests/nov2_regressions.rs @@ -0,0 +1,49 @@ +use code_memory::git::decision_parser::DecisionParser; +use code_memory::graph::analyzer::DependencyGraph; +use code_memory::sessions::PatternLibrary; + +#[test] +fn migration_keywords_are_case_insensitive_without_losing_technology_case() { + let decisions = DecisionParser::new() + .parse_message("Decided to Migrate FROM REST TO GraphQL\nKeep typed queries."); + assert_eq!(decisions.len(), 1); + assert_eq!(decisions[0].decision_type, "migration"); + assert_eq!(decisions[0].from.as_deref(), Some("REST")); + assert_eq!(decisions[0].to, "GraphQL"); +} + +#[test] +fn plain_observations_are_visible_to_pattern_queries() { + let mut library = PatternLibrary::new(); + for _ in 0..6 { + library.observe("naming:rust:snake_case"); + } + let patterns = library.confident_patterns(0.5); + assert_eq!(patterns.len(), 1); + assert_eq!(patterns[0].observation_count, 6); + assert!(patterns[0].examples.is_empty()); + library.observe_with_example("naming:rust:snake_case", "fn hello_world()"); + assert_eq!(library.all_patterns()[0].observation_count, 7); + assert_eq!(library.all_patterns()[0].examples, ["fn hello_world()"]); +} + +#[test] +fn rust_child_modules_resolve_in_flat_and_directory_layouts() { + for (parent, child) in [ + ("src/foo.rs", "src/foo/child.rs"), + ("src/foo/mod.rs", "src/foo/child.rs"), + ("src/lib.rs", "src/child/mod.rs"), + ] { + for child_first in [false, true] { + let mut graph = DependencyGraph::new(); + if child_first { + graph.add_file(child, "pub fn helper() {}\n"); + } + graph.add_file(parent, "mod child;\n"); + if !child_first { + graph.add_file(child, "pub fn helper() {}\n"); + } + assert_eq!(graph.depends_on(parent), [child], "{parent} -> {child}"); + } + } +}