From 2334220cfbd8a7422d009f788af66765e29880a8 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 1 May 2026 11:52:12 +0200 Subject: [PATCH 1/5] feat: per-language rule overrides on RuleConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RuleConfig.language_overrides — a per-language map of optional threshold overrides for the per-file rules: function complexity, cognitive complexity, file lines, function lines, fan-in/out for files and functions, and large-file threshold. Resolution order is: language override > plugin field > global. Languages whose idioms make a global threshold either too strict or too lax (Rust match arms vs flat Python conditionals, for example) can now declare their own caps without loosening the global ones. --- crates/raysense-core/src/health.rs | 179 ++++++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 14 deletions(-) diff --git a/crates/raysense-core/src/health.rs b/crates/raysense-core/src/health.rs index a8f0bbe..ef36436 100644 --- a/crates/raysense-core/src/health.rs +++ b/crates/raysense-core/src/health.rs @@ -242,6 +242,26 @@ pub struct RuleConfig { pub max_call_hotspot_findings: usize, pub max_upward_layer_violations: usize, pub no_tests_detected: bool, + /// Per-language overrides keyed by `language_name` (case-insensitive). + /// Each override field, when set, takes precedence over the matching + /// global field for files in that language. Useful when one language's + /// idioms make a global threshold either too strict or too lax (e.g. + /// Rust `match` arms inflate cyclomatic vs Python's flat conditionals). + pub language_overrides: BTreeMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct LanguageRuleOverride { + pub max_function_complexity: Option, + pub max_cognitive_complexity: Option, + pub max_file_lines: Option, + pub max_function_lines: Option, + pub high_file_fan_in: Option, + pub high_file_fan_out: Option, + pub large_file_lines: Option, + pub high_function_fan_in: Option, + pub high_function_fan_out: Option, } impl Default for RuleConfig { @@ -271,6 +291,7 @@ impl Default for RuleConfig { max_call_hotspot_findings: 5, max_upward_layer_violations: 0, no_tests_detected: true, + language_overrides: BTreeMap::new(), } } } @@ -1121,10 +1142,24 @@ fn file_coupling_metrics( } } + let fan_out_limit = |metric: &FileCouplingMetric| -> usize { + report + .files + .get(metric.file_id) + .map(|file| high_file_fan_out_limit(file, config)) + .unwrap_or(config.rules.high_file_fan_out) + }; + let fan_in_limit = |metric: &FileCouplingMetric| -> usize { + report + .files + .get(metric.file_id) + .map(|file| high_file_fan_in_limit(file, config)) + .unwrap_or(config.rules.high_file_fan_in) + }; let mut god_files: Vec = coupling .iter() .filter(|metric| { - metric.fan_out >= config.rules.high_file_fan_out && !is_package_index_path(&metric.path) + metric.fan_out >= fan_out_limit(metric) && !is_package_index_path(&metric.path) }) .cloned() .collect(); @@ -1134,7 +1169,7 @@ fn file_coupling_metrics( let mut unstable_hotspots: Vec = coupling .iter() .filter(|metric| { - metric.fan_in >= config.rules.high_file_fan_in + metric.fan_in >= fan_in_limit(metric) && !is_package_index_path(&metric.path) && ratio(metric.fan_out, metric.fan_in + metric.fan_out) >= 0.15 }) @@ -3191,7 +3226,12 @@ fn rules( } for hotspot in hotspots { - if hotspot.fan_in >= rules.high_file_fan_in { + let limit = report + .files + .get(hotspot.file_id) + .map(|file| high_file_fan_in_limit(file, config)) + .unwrap_or(rules.high_file_fan_in); + if hotspot.fan_in >= limit { findings.push(RuleFinding { severity: if rules.no_god_files { RuleSeverity::Warning @@ -3245,7 +3285,7 @@ fn rules( let mut large_files: Vec<_> = report .files .iter() - .filter(|file| file.lines >= rules.large_file_lines) + .filter(|file| file.lines >= large_file_lines_limit(file, config)) .collect(); large_files.sort_by(|a, b| b.lines.cmp(&a.lines).then_with(|| a.path.cmp(&b.path))); @@ -3301,11 +3341,25 @@ fn rules( }); } + let function_threshold_in = |function: &FunctionCallMetric| -> usize { + report + .files + .get(function.file_id) + .map(|file| high_function_fan_in_limit(file, config)) + .unwrap_or(rules.high_function_fan_in) + }; + let function_threshold_out = |function: &FunctionCallMetric| -> usize { + report + .files + .get(function.file_id) + .map(|file| high_function_fan_out_limit(file, config)) + .unwrap_or(rules.high_function_fan_out) + }; for function in metrics .calls .top_called_functions .iter() - .filter(|function| function.calls >= rules.high_function_fan_in) + .filter(|function| function.calls >= function_threshold_in(function)) .take(rules.max_call_hotspot_findings) { findings.push(RuleFinding { @@ -3323,7 +3377,7 @@ fn rules( .calls .top_calling_functions .iter() - .filter(|function| function.calls >= rules.high_function_fan_out) + .filter(|function| function.calls >= function_threshold_out(function)) .take(rules.max_call_hotspot_findings) { findings.push(RuleFinding { @@ -3358,32 +3412,80 @@ fn rules( findings } +fn language_override_for<'a>( + file: &FileFact, + config: &'a RaysenseConfig, +) -> Option<&'a LanguageRuleOverride> { + config + .rules + .language_overrides + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(&file.language_name)) + .map(|(_, value)| value) +} + fn function_complexity_limit(file: &FileFact, config: &RaysenseConfig) -> usize { - plugin_for_file(file, config) - .and_then(|plugin| plugin.max_function_complexity) + language_override_for(file, config) + .and_then(|o| o.max_function_complexity) + .or_else(|| plugin_for_file(file, config).and_then(|plugin| plugin.max_function_complexity)) .unwrap_or(config.rules.max_function_complexity) } fn cognitive_complexity_limit(file: &FileFact, config: &RaysenseConfig) -> usize { - plugin_for_file(file, config) - .and_then(|plugin| plugin.max_cognitive_complexity) + language_override_for(file, config) + .and_then(|o| o.max_cognitive_complexity) + .or_else(|| { + plugin_for_file(file, config).and_then(|plugin| plugin.max_cognitive_complexity) + }) .unwrap_or(config.rules.max_cognitive_complexity) } fn file_line_limit(file: &FileFact, config: &RaysenseConfig) -> Option { - plugin_for_file(file, config) - .and_then(|plugin| plugin.max_file_lines) + language_override_for(file, config) + .and_then(|o| o.max_file_lines) + .or_else(|| plugin_for_file(file, config).and_then(|plugin| plugin.max_file_lines)) .or_else(|| (config.rules.max_file_lines > 0).then_some(config.rules.max_file_lines)) } fn function_line_limit(file: &FileFact, config: &RaysenseConfig) -> Option { - plugin_for_file(file, config) - .and_then(|plugin| plugin.max_function_lines) + language_override_for(file, config) + .and_then(|o| o.max_function_lines) + .or_else(|| plugin_for_file(file, config).and_then(|plugin| plugin.max_function_lines)) .or_else(|| { (config.rules.max_function_lines > 0).then_some(config.rules.max_function_lines) }) } +fn high_file_fan_in_limit(file: &FileFact, config: &RaysenseConfig) -> usize { + language_override_for(file, config) + .and_then(|o| o.high_file_fan_in) + .unwrap_or(config.rules.high_file_fan_in) +} + +fn high_file_fan_out_limit(file: &FileFact, config: &RaysenseConfig) -> usize { + language_override_for(file, config) + .and_then(|o| o.high_file_fan_out) + .unwrap_or(config.rules.high_file_fan_out) +} + +fn large_file_lines_limit(file: &FileFact, config: &RaysenseConfig) -> usize { + language_override_for(file, config) + .and_then(|o| o.large_file_lines) + .unwrap_or(config.rules.large_file_lines) +} + +fn high_function_fan_in_limit(file: &FileFact, config: &RaysenseConfig) -> usize { + language_override_for(file, config) + .and_then(|o| o.high_function_fan_in) + .unwrap_or(config.rules.high_function_fan_in) +} + +fn high_function_fan_out_limit(file: &FileFact, config: &RaysenseConfig) -> usize { + language_override_for(file, config) + .and_then(|o| o.high_function_fan_out) + .unwrap_or(config.rules.high_function_fan_out) +} + fn plugin_for_file<'a>( file: &FileFact, config: &'a RaysenseConfig, @@ -5278,6 +5380,55 @@ order = 2 assert!(pairs.is_empty()); } + #[test] + fn language_override_wins_over_global_for_complexity_limit() { + let py_file = file(0, "src/util.py"); + let rs_file = file(1, "src/lib.rs"); + let mut config = RaysenseConfig::default(); + config.rules.max_function_complexity = 50; // global ceiling + config.rules.language_overrides.insert( + "python".to_string(), + LanguageRuleOverride { + max_function_complexity: Some(8), + ..LanguageRuleOverride::default() + }, + ); + // Need to set the language_name on the test files since `file()` factory + // uses Language::Rust by default — make a Python-named one. + let mut py = py_file; + py.language_name = "python".to_string(); + + assert_eq!(function_complexity_limit(&py, &config), 8); + assert_eq!(function_complexity_limit(&rs_file, &config), 50); + } + + #[test] + fn language_override_falls_through_to_plugin_then_global() { + let mut file_a = file(0, "src/a.go"); + file_a.language_name = "go".to_string(); + let mut file_b = file(1, "src/b.go"); + file_b.language_name = "go".to_string(); + let mut config = RaysenseConfig::default(); + config.rules.max_file_lines = 1000; + // Plugin sets a Go-specific limit of 600. + config.scan.plugins.push(LanguagePluginConfig { + name: "go".to_string(), + max_file_lines: Some(600), + ..LanguagePluginConfig::default() + }); + // No language_overrides entry → plugin should win. + assert_eq!(file_line_limit(&file_a, &config), Some(600)); + // Add a language override of 200 → it wins over the plugin. + config.rules.language_overrides.insert( + "go".to_string(), + LanguageRuleOverride { + max_file_lines: Some(200), + ..LanguageRuleOverride::default() + }, + ); + assert_eq!(file_line_limit(&file_b, &config), Some(200)); + } + #[test] fn temporal_hotspots_skip_zero_risk() { let mut file_commits: BTreeMap = BTreeMap::new(); From f03bf1768d8bbce981aaa1103726a6031566857a Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 1 May 2026 11:54:37 +0200 Subject: [PATCH 2/5] feat: surface temporal hotspots, file ages, change coupling, inheritance as memory tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four splayed tables to RayMemory: temporal_hotspots, file_ages, change_coupling, and inheritance (one row per (type, base) pair derived from TypeFact.bases). Coupling strength is stored as integer milli to fit the i64 column type — divide by 1000 on read to recover the [0, 1] Jaccard. RayMemory.summary and save_splayed wire them through end-to-end. --- crates/raysense-memory/src/lib.rs | 159 ++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/crates/raysense-memory/src/lib.rs b/crates/raysense-memory/src/lib.rs index 813bdbd..6a804a9 100644 --- a/crates/raysense-memory/src/lib.rs +++ b/crates/raysense-memory/src/lib.rs @@ -90,6 +90,10 @@ pub struct MemorySummary { pub module_edges: TableSummary, pub changed_files: TableSummary, pub file_ownership: TableSummary, + pub temporal_hotspots: TableSummary, + pub file_ages: TableSummary, + pub change_coupling: TableSummary, + pub inheritance: TableSummary, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -189,6 +193,10 @@ pub struct RayMemory { module_edges: RayObject, changed_files: RayObject, file_ownership: RayObject, + temporal_hotspots: RayObject, + file_ages: RayObject, + change_coupling: RayObject, + inheritance: RayObject, } impl RayMemory { @@ -217,6 +225,10 @@ impl RayMemory { module_edges: build_module_edges_table(&health)?, changed_files: build_changed_files_table(&health)?, file_ownership: build_file_ownership_table(&health)?, + temporal_hotspots: build_temporal_hotspots_table(&health)?, + file_ages: build_file_ages_table(&health)?, + change_coupling: build_change_coupling_table(&health)?, + inheritance: build_inheritance_table(report)?, }) } @@ -235,6 +247,10 @@ impl RayMemory { module_edges: table_summary(self.module_edges.as_ptr()), changed_files: table_summary(self.changed_files.as_ptr()), file_ownership: table_summary(self.file_ownership.as_ptr()), + temporal_hotspots: table_summary(self.temporal_hotspots.as_ptr()), + file_ages: table_summary(self.file_ages.as_ptr()), + change_coupling: table_summary(self.change_coupling.as_ptr()), + inheritance: table_summary(self.inheritance.as_ptr()), } } @@ -264,6 +280,20 @@ impl RayMemory { dir, &sym_path, )?; + self.save_table( + "temporal_hotspots", + self.temporal_hotspots.as_ptr(), + dir, + &sym_path, + )?; + self.save_table("file_ages", self.file_ages.as_ptr(), dir, &sym_path)?; + self.save_table( + "change_coupling", + self.change_coupling.as_ptr(), + dir, + &sym_path, + )?; + self.save_table("inheritance", self.inheritance.as_ptr(), dir, &sym_path)?; Ok(()) } @@ -1461,6 +1491,120 @@ fn build_module_edges_table(health: &HealthSummary) -> Result Result { + let rows = health.metrics.evolution.temporal_hotspots.len(); + let hotspots = &health.metrics.evolution.temporal_hotspots; + table( + 4, + [ + ( + "path", + str_vec(rows, hotspots.iter().map(|h| h.path.clone()))?, + ), + ( + "commits", + i64_vec(rows, hotspots.iter().map(|h| h.commits as i64))?, + ), + ( + "max_complexity", + i64_vec(rows, hotspots.iter().map(|h| h.max_complexity as i64))?, + ), + ( + "risk_score", + i64_vec(rows, hotspots.iter().map(|h| h.risk_score as i64))?, + ), + ], + ) +} + +fn build_file_ages_table(health: &HealthSummary) -> Result { + let rows = health.metrics.evolution.file_ages.len(); + let ages = &health.metrics.evolution.file_ages; + table( + 5, + [ + ("path", str_vec(rows, ages.iter().map(|a| a.path.clone()))?), + ( + "first_commit_unix", + i64_vec(rows, ages.iter().map(|a| a.first_commit_unix))?, + ), + ( + "last_commit_unix", + i64_vec(rows, ages.iter().map(|a| a.last_commit_unix))?, + ), + ( + "age_days", + i64_vec(rows, ages.iter().map(|a| a.age_days as i64))?, + ), + ( + "last_changed_days", + i64_vec(rows, ages.iter().map(|a| a.last_changed_days as i64))?, + ), + ], + ) +} + +fn build_change_coupling_table(health: &HealthSummary) -> Result { + let rows = health.metrics.evolution.change_coupling.len(); + let pairs = &health.metrics.evolution.change_coupling; + table( + 4, + [ + ("left", str_vec(rows, pairs.iter().map(|p| p.left.clone()))?), + ( + "right", + str_vec(rows, pairs.iter().map(|p| p.right.clone()))?, + ), + ( + "co_commits", + i64_vec(rows, pairs.iter().map(|p| p.co_commits as i64))?, + ), + ( + // Stored as integer milli to fit the i64-only column type; + // divide by 1000 on read to recover the [0, 1] Jaccard. + "coupling_strength_milli", + i64_vec( + rows, + pairs + .iter() + .map(|p| (p.coupling_strength * 1000.0).round() as i64), + )?, + ), + ], + ) +} + +fn build_inheritance_table(report: &ScanReport) -> Result { + let rows: Vec<(usize, &str, &str)> = report + .types + .iter() + .flat_map(|type_fact| { + type_fact + .bases + .iter() + .map(move |base| (type_fact.type_id, type_fact.name.as_str(), base.as_str())) + }) + .collect(); + let n = rows.len(); + table( + 3, + [ + ( + "type_id", + i64_vec(n, rows.iter().map(|(id, _, _)| *id as i64))?, + ), + ( + "name", + str_vec(n, rows.iter().map(|(_, name, _)| (*name).to_string()))?, + ), + ( + "base", + str_vec(n, rows.iter().map(|(_, _, base)| (*base).to_string()))?, + ), + ], + ) +} + fn build_changed_files_table(health: &HealthSummary) -> Result { let rows = health.metrics.evolution.top_changed_files.len(); table( @@ -1596,6 +1740,21 @@ mod tests { assert_eq!(summary.rules.columns, 4); assert_eq!(summary.module_edges.columns, 3); assert_eq!(summary.changed_files.columns, 2); + let health = raysense_core::compute_health(&report); + assert_eq!(summary.temporal_hotspots.columns, 4); + assert_eq!( + summary.temporal_hotspots.rows as usize, + health.metrics.evolution.temporal_hotspots.len(), + ); + assert_eq!(summary.file_ages.columns, 5); + assert_eq!(summary.change_coupling.columns, 4); + let inheritance_rows: usize = report + .types + .iter() + .map(|type_fact| type_fact.bases.len()) + .sum(); + assert_eq!(summary.inheritance.columns, 3); + assert_eq!(summary.inheritance.rows as usize, inheritance_rows); } #[test] From af0a5de544101a5c441df16fc962c58ddcd663f0 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 1 May 2026 11:58:05 +0200 Subject: [PATCH 3/5] feat: cache health summary in MCP state with declarative invalidation Adds an MCP-level health cache keyed by (root, config-signature) and a HEALTH_INVALIDATING_TOOLS list. Read tools (health, hotspots, architecture, evolution, dsm) now consult the cache first; mutating tools (session_start/end, rescan, what_if, baseline_save, config_write, plugin mutations) clear it before they run. Repeated reads in the same MCP session no longer re-scan. --- crates/raysense-cli/src/mcp.rs | 135 ++++++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 20 deletions(-) diff --git a/crates/raysense-cli/src/mcp.rs b/crates/raysense-cli/src/mcp.rs index 0eb590f..44d5eef 100644 --- a/crates/raysense-cli/src/mcp.rs +++ b/crates/raysense-cli/src/mcp.rs @@ -43,7 +43,31 @@ struct McpState { last_path: Option, last_config: Option, baseline: Option, -} + cached_health: Option, +} + +struct HealthCache { + root: PathBuf, + signature: String, + report_root: PathBuf, + health: raysense_core::HealthSummary, +} + +/// Tools that mutate scan inputs (config, baselines, plugins, sessions) or +/// the on-disk repo state must clear the cached health before they run, so +/// the next read tool re-scans. +const HEALTH_INVALIDATING_TOOLS: &[&str] = &[ + "raysense_session_start", + "raysense_session_end", + "raysense_rescan", + "raysense_what_if", + "raysense_baseline_save", + "raysense_config_write", + "raysense_plugin_add", + "raysense_plugin_add_standard", + "raysense_plugin_sync", + "raysense_plugin_remove", +]; pub fn run() -> Result<()> { let stdin = io::stdin(); @@ -374,16 +398,20 @@ fn call_tool(params: &Value, state: &mut McpState) -> Result { .cloned() .unwrap_or_else(|| json!({})); + if HEALTH_INVALIDATING_TOOLS.contains(&name) { + state.cached_health = None; + } + match name { "raysense_config_read" => read_config_tool(&args), "raysense_config_write" => write_config_tool(&args), - "raysense_health" => health_tool(&args), + "raysense_health" => health_tool(&args, state), "raysense_scan" => scan_tool(&args), "raysense_edges" => edges_tool(&args), - "raysense_hotspots" => hotspots_tool(&args), + "raysense_hotspots" => hotspots_tool(&args, state), "raysense_rules" => rules_tool(&args), "raysense_module_edges" => module_edges_tool(&args), - "raysense_architecture" => architecture_tool(&args), + "raysense_architecture" => architecture_tool(&args, state), "raysense_coupling" => coupling_tool(&args), "raysense_cycles" => cycles_tool(&args), "raysense_hottest" => hottest_tool(&args), @@ -393,8 +421,8 @@ fn call_tool(params: &Value, state: &mut McpState) -> Result { "raysense_session_end" => session_end_tool(&args, state), "raysense_rescan" => rescan_tool(&args, state), "raysense_check_rules" => check_rules_tool(&args), - "raysense_evolution" => evolution_tool(&args), - "raysense_dsm" => dsm_tool(&args), + "raysense_evolution" => evolution_tool(&args, state), + "raysense_dsm" => dsm_tool(&args, state), "raysense_test_gaps" => test_gaps_tool(&args), "raysense_visualize" => visualize_tool(&args), "raysense_sarif" => sarif_tool(&args), @@ -443,14 +471,11 @@ fn write_config_tool(args: &Value) -> Result { })) } -fn health_tool(args: &Value) -> Result { - let root = root_arg(args)?; - let config = effective_config(args, &root)?; - let report = scan_path_with_config(&root, &config)?; - let health = compute_health_with_config(&report, &config); +fn health_tool(args: &Value, state: &mut McpState) -> Result { + let (root, health) = health_from_args_cached(args, state)?; Ok(json!({ - "root": report.snapshot.root, + "root": root, "health": health })) } @@ -529,8 +554,8 @@ fn edges_tool(args: &Value) -> Result { })) } -fn hotspots_tool(args: &Value) -> Result { - let (root, health) = health_from_args(args)?; +fn hotspots_tool(args: &Value, state: &mut McpState) -> Result { + let (root, health) = health_from_args_cached(args, state)?; let limit = limit_arg(args, 100)?; Ok(json!({ @@ -565,8 +590,8 @@ fn module_edges_tool(args: &Value) -> Result { })) } -fn architecture_tool(args: &Value) -> Result { - let (root, health) = health_from_args(args)?; +fn architecture_tool(args: &Value, state: &mut McpState) -> Result { + let (root, health) = health_from_args_cached(args, state)?; let limit = limit_arg(args, 100)?; Ok(json!({ @@ -815,8 +840,8 @@ fn check_rules_tool(args: &Value) -> Result { })) } -fn evolution_tool(args: &Value) -> Result { - let (root, health) = health_from_args(args)?; +fn evolution_tool(args: &Value, state: &mut McpState) -> Result { + let (root, health) = health_from_args_cached(args, state)?; let limit = limit_arg(args, 100)?; Ok(json!({ "root": root, @@ -836,8 +861,8 @@ fn evolution_tool(args: &Value) -> Result { })) } -fn dsm_tool(args: &Value) -> Result { - let (root, health) = health_from_args(args)?; +fn dsm_tool(args: &Value, state: &mut McpState) -> Result { + let (root, health) = health_from_args_cached(args, state)?; let limit = limit_arg(args, 100)?; Ok(json!({ "root": root, @@ -1490,6 +1515,41 @@ fn health_from_args(args: &Value) -> Result<(PathBuf, raysense_core::HealthSumma Ok((report.snapshot.root, health)) } +/// Cached variant of `health_from_args` — stores the most-recent +/// `(root, config)` health on the state and returns it on subsequent calls +/// without re-scanning, until a tool in `HEALTH_INVALIDATING_TOOLS` runs. +fn health_from_args_cached( + args: &Value, + state: &mut McpState, +) -> Result<(PathBuf, raysense_core::HealthSummary)> { + let root = root_arg(args)?; + let config = effective_config(args, &root)?; + let signature = config_signature(&root, &config); + if let Some(cached) = &state.cached_health { + if cached.root == root && cached.signature == signature { + return Ok((cached.report_root.clone(), cached.health.clone())); + } + } + let report = scan_path_with_config(&root, &config)?; + let health = compute_health_with_config(&report, &config); + let report_root = report.snapshot.root; + state.cached_health = Some(HealthCache { + root: root.clone(), + signature, + report_root: report_root.clone(), + health: health.clone(), + }); + Ok((report_root, health)) +} + +/// Stable signature of the effective config for cache-key purposes. Falls +/// back to the empty string if serialization fails — a cache miss is always +/// safe. +fn config_signature(root: &Path, config: &RaysenseConfig) -> String { + let payload = serde_json::to_string(config).unwrap_or_default(); + format!("{}::{}", root.display(), payload) +} + fn effective_config(args: &Value, root: &Path) -> Result { if args.get("config").is_some() { config_arg(args) @@ -2375,4 +2435,39 @@ mod tests { 0 ); } + + #[test] + fn health_cache_populates_and_invalidates() { + let mut state = McpState::default(); + assert!(state.cached_health.is_none(), "fresh state has no cache"); + + let args = json!({"root": env!("CARGO_MANIFEST_DIR")}); + let _ = health_from_args_cached(&args, &mut state).unwrap(); + assert!(state.cached_health.is_some(), "cache populated after read"); + + let signature_before = state + .cached_health + .as_ref() + .map(|c| c.signature.clone()) + .unwrap(); + let _ = health_from_args_cached(&args, &mut state).unwrap(); + let signature_after = state + .cached_health + .as_ref() + .map(|c| c.signature.clone()) + .unwrap(); + assert_eq!( + signature_before, signature_after, + "second call must reuse the cached signature, not invalidate it", + ); + + // Simulate a mutating tool by clearing the cache the way call_tool would. + if HEALTH_INVALIDATING_TOOLS.contains(&"raysense_rescan") { + state.cached_health = None; + } + assert!( + state.cached_health.is_none(), + "invalidating tool must drop the cache", + ); + } } From 0d18a514aac6a3b6a9cd29a6818d22773617585c Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 1 May 2026 12:00:11 +0200 Subject: [PATCH 4/5] feat: tree-sitter base-class extraction for Python and TypeScript Adds an AST-driven type extractor that walks class_definition (Python) and class_declaration (TypeScript) nodes, picking up bases from the language-specific child fields (Python superclasses, TypeScript class_heritage / extends_clause / implements_clause). When tree-sitter parses cleanly the AST result wins; line-based extraction stays as a fallback for languages without grammar support and merges any plugin-driven abstract-base flags. Multiline class declarations and generic type arguments now produce accurate bases without confusing the line parser. --- crates/raysense-core/src/scanner.rs | 169 +++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/crates/raysense-core/src/scanner.rs b/crates/raysense-core/src/scanner.rs index 2f2a2a0..48f3b07 100644 --- a/crates/raysense-core/src/scanner.rs +++ b/crates/raysense-core/src/scanner.rs @@ -192,7 +192,13 @@ pub fn scan_path_with_config( calls.push(call.clone()); } - for mut type_fact in extract_types(file_id, &file_fact, &content, plugin.as_ref()) { + let line_types = extract_types(file_id, &file_fact, &content, plugin.as_ref()); + let merged_types = merge_tree_sitter_types_with_line_types( + extract_tree_sitter_types(file_id, &content, language), + line_types, + plugin.as_ref(), + ); + for mut type_fact in merged_types { type_fact.type_id = types.len(); types.push(type_fact); } @@ -2034,6 +2040,140 @@ fn extract_types( /// `class Foo(Bar, Baz):` (Python) /// Returns identifiers stripped of access keywords (`public`, `virtual`, /// `protected`, `private`). +/// Tree-sitter-driven type extraction for languages where the grammar +/// carries inheritance directly on the class node. When tree-sitter rejects +/// the file (parse error, unknown grammar) the caller falls back to the +/// line-based parser. Returns `None` to mean "no tree available — use the +/// line parser instead." +fn extract_tree_sitter_types( + file_id: usize, + content: &str, + language: Language, +) -> Option> { + let ts_language = match language { + Language::Python => tree_sitter_python::LANGUAGE.into(), + Language::TypeScript => tree_sitter_typescript::LANGUAGE_TSX.into(), + _ => return None, + }; + let mut parser = Parser::new(); + parser.set_language(&ts_language).ok()?; + let tree = parser.parse(content, None)?; + let root = tree.root_node(); + if root.has_error() { + return None; + } + let mut out = Vec::new(); + collect_tree_sitter_types(file_id, content, root, &mut out); + Some(out) +} + +fn collect_tree_sitter_types( + file_id: usize, + content: &str, + node: Node<'_>, + out: &mut Vec, +) { + let kind = node.kind(); + let is_class = matches!(kind, "class_definition" | "class_declaration"); + if is_class { + let name = node + .child_by_field_name("name") + .and_then(|n| node_text(content, n)) + .unwrap_or_default(); + if !name.is_empty() { + let bases = base_classes_from_class_node(content, node); + out.push(TypeFact { + type_id: 0, + file_id, + name, + is_abstract: false, + line: node.start_position().row + 1, + bases, + }); + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_tree_sitter_types(file_id, content, child, out); + } +} + +fn base_classes_from_class_node(content: &str, node: Node<'_>) -> Vec { + let mut bases = Vec::new(); + if let Some(superclasses) = node.child_by_field_name("superclasses") { + // Python: `class Foo(Bar, Baz):` — `superclasses` is the argument_list. + let mut cursor = superclasses.walk(); + for child in superclasses.children(&mut cursor) { + if matches!(child.kind(), "identifier" | "attribute") { + if let Some(text) = node_text(content, child) { + bases.push(text); + } + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "class_heritage" { + // TypeScript: `class Foo extends Bar implements Baz, Qux {}`. + let mut hcursor = child.walk(); + for clause in child.children(&mut hcursor) { + let mut ccursor = clause.walk(); + for sub in clause.children(&mut ccursor) { + if matches!(sub.kind(), "identifier" | "type_identifier") { + if let Some(text) = node_text(content, sub) { + bases.push(text); + } + } + } + } + } + } + bases.sort(); + bases.dedup(); + bases +} + +/// When tree-sitter produces type facts, prefer them over the line-based +/// ones for matching `(name, line)` and merge their bases. Carry over +/// `is_abstract` from the line parser since it knows about plugin-configured +/// abstract markers and bases. +fn merge_tree_sitter_types_with_line_types( + ts_types: Option>, + line_types: Vec, + plugin: Option<&LanguagePluginConfig>, +) -> Vec { + let Some(ts_types) = ts_types else { + return line_types; + }; + let mut out = ts_types; + for ts_type in &mut out { + let abstract_by_base = plugin.is_some_and(|plugin| { + !plugin.abstract_base_classes.is_empty() + && ts_type.bases.iter().any(|base| { + plugin + .abstract_base_classes + .iter() + .any(|known| known == base) + }) + }); + if abstract_by_base { + ts_type.is_abstract = true; + } + if let Some(line_match) = line_types + .iter() + .find(|t| t.name == ts_type.name && t.line == ts_type.line) + { + ts_type.is_abstract = ts_type.is_abstract || line_match.is_abstract; + for base in &line_match.bases { + if !ts_type.bases.contains(base) { + ts_type.bases.push(base.clone()); + } + } + } + } + out +} + fn extract_base_class_names(line: &str) -> Vec { const TERMINATORS: &[char] = &['{', ';', '\n']; const STOP_KEYWORDS: &[&str] = &[" extends ", " implements ", " with "]; @@ -3616,6 +3756,33 @@ int run(void) { ); } + #[test] + fn tree_sitter_extracts_python_class_bases() { + let content = "class Dog(Animal, Mammal):\n pass\n"; + let types = extract_tree_sitter_types(0, content, Language::Python).unwrap(); + let dog = types.iter().find(|t| t.name == "Dog").unwrap(); + assert_eq!(dog.bases, vec!["Animal".to_string(), "Mammal".to_string()]); + assert_eq!(dog.line, 1); + } + + #[test] + fn tree_sitter_extracts_typescript_class_extends_and_implements() { + let content = "class Foo extends Bar implements Baz, Qux {}\n"; + let types = extract_tree_sitter_types(0, content, Language::TypeScript).unwrap(); + let foo = types.iter().find(|t| t.name == "Foo").unwrap(); + assert!(foo.bases.contains(&"Bar".to_string())); + assert!(foo.bases.contains(&"Baz".to_string())); + assert!(foo.bases.contains(&"Qux".to_string())); + } + + #[test] + fn tree_sitter_returns_none_for_languages_without_grammar_support() { + // C is supported by tree-sitter but not by the type-extraction + // dispatch — falls through and returns None. + let content = "struct S { int x; };\n"; + assert!(extract_tree_sitter_types(0, content, Language::C).is_none()); + } + #[test] fn extract_types_marks_abstract_when_base_matches_plugin_config() { let file = FileFact { From e9c90e0e86ed03309e463d86d733d7bbf9902600 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 1 May 2026 12:02:47 +0200 Subject: [PATCH 5/5] feat: render file-level dependency edges as SVG overlay in visualization Adds an absolutely-positioned SVG overlay above the file grid that draws lines between cell centers for each adjacency edge under the active edge filter. Edges are off by default (toggle in the controls) to avoid clutter on large repos. Lines are color-coded by edge type (imports / calls / inherits) and dim when a file is selected unless they participate in its upstream or downstream route. Resize, focus changes, and edge-filter changes all re-render the overlay. --- crates/raysense-cli/src/lib.rs | 76 +++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/raysense-cli/src/lib.rs b/crates/raysense-cli/src/lib.rs index 64843e8..6522996 100644 --- a/crates/raysense-cli/src/lib.rs +++ b/crates/raysense-cli/src/lib.rs @@ -1151,8 +1151,12 @@ table{{border-collapse:collapse;width:100%;margin-top:16px}}td,th{{border-bottom + +
{}
+ +