Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions crates/raysense-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,8 +1151,12 @@ table{{border-collapse:collapse;width:100%;margin-top:16px}}td,th{{border-bottom
<option value="calls">calls</option>
<option value="inherits">inherits</option>
</select>
<label for="show-edges"><input type="checkbox" id="show-edges">show edges</label>
</div>
<div class="files-area">
<div class="grid" id="files-grid">{}</div>
<svg id="file-edges" class="overlay" aria-hidden="true"></svg>
</div>
<aside id="file-detail" class="detail" hidden>
<button type="button" id="file-detail-close">close</button>
<h3 id="file-detail-title"></h3>
Expand All @@ -1171,6 +1175,13 @@ table{{border-collapse:collapse;width:100%;margin-top:16px}}td,th{{border-bottom
<style>
.file.dim{{opacity:.18}}.file.upstream{{outline:2px solid #f0a040}}
.file.downstream{{outline:2px solid #4ec0a8}}.file.selected{{outline:3px solid #ffd86b}}
.files-area{{position:relative}}
.overlay{{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1}}
.overlay line{{stroke:#78a6d8;opacity:.45;stroke-width:1}}
.overlay line.imports{{stroke:#78a6d8}}
.overlay line.calls{{stroke:#4ec0a8}}
.overlay line.inherits{{stroke:#f0a040}}
.overlay line.dim{{opacity:.08}}
</style>
<script>
(function() {{
Expand Down Expand Up @@ -1320,16 +1331,75 @@ table{{border-collapse:collapse;width:100%;margin-top:16px}}td,th{{border-bottom
' out, ' +
entry.imports_in.length + '/' + entry.calls_in.length + '/' + entry.inherits_in.length + ' in');
}}
var overlay = document.getElementById('file-edges');
var showEdgesToggle = document.getElementById('show-edges');
function visibleCenter(el, areaRect) {{
if (!el || el.style.display === 'none') return null;
var r = el.getBoundingClientRect();
return [r.left + r.width / 2 - areaRect.left, r.top + r.height / 2 - areaRect.top];
}}
function renderEdges() {{
if (!overlay) return;
overlay.innerHTML = '';
if (!showEdgesToggle || !showEdgesToggle.checked) return;
var area = overlay.parentElement;
var areaRect = area.getBoundingClientRect();
overlay.setAttribute('width', areaRect.width);
overlay.setAttribute('height', areaRect.height);
var filter = edgeFilterSelect ? edgeFilterSelect.value : 'all';
var types = filter === 'all'
? ['imports', 'calls', 'inherits']
: [filter];
var down = selectedPath ? reachable(selectedPath, 'out') : null;
var up = selectedPath ? reachable(selectedPath, 'in') : null;
adjacency.forEach(function(entry) {{
var fromCell = cellsByPath[entry.path];
var fromCenter = visibleCenter(fromCell, areaRect);
if (!fromCenter) return;
types.forEach(function(type) {{
(entry[type + '_out'] || []).forEach(function(toPath) {{
var toCell = cellsByPath[toPath];
var toCenter = visibleCenter(toCell, areaRect);
if (!toCenter) return;
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', fromCenter[0]);
line.setAttribute('y1', fromCenter[1]);
line.setAttribute('x2', toCenter[0]);
line.setAttribute('y2', toCenter[1]);
var classes = type;
if (selectedPath) {{
var inRoute = (entry.path === selectedPath || toPath === selectedPath ||
(down && (down[entry.path] || down[toPath])) ||
(up && (up[entry.path] || up[toPath])));
if (!inRoute) classes += ' dim';
}}
line.setAttribute('class', classes);
overlay.appendChild(line);
}});
}});
}});
}}
if (showEdgesToggle) {{
showEdgesToggle.addEventListener('change', renderEdges);
}}
window.addEventListener('resize', renderEdges);
colorSelect.addEventListener('change', function() {{ recolor(colorSelect.value); }});
if (focusModeSelect) {{
focusModeSelect.addEventListener('change', rebuildFocusValues);
focusValueSelect.addEventListener('change', applyFocus);
focusModeSelect.addEventListener('change', function() {{
rebuildFocusValues();
renderEdges();
}});
focusValueSelect.addEventListener('change', function() {{
applyFocus();
renderEdges();
}});
rebuildFocusValues();
}}
if (edgeFilterSelect) {{
edgeFilterSelect.addEventListener('change', function() {{
highlightRoutes();
if (focusModeSelect.value === 'impact') applyFocus();
renderEdges();
}});
}}
cells.forEach(function(el) {{
Expand All @@ -1348,6 +1418,7 @@ table{{border-collapse:collapse;width:100%;margin-top:16px}}td,th{{border-bottom
detail.hidden = false;
highlightRoutes();
if (focusModeSelect.value === 'impact') applyFocus();
renderEdges();
}});
}});
if (closeBtn) {{
Expand All @@ -1356,6 +1427,7 @@ table{{border-collapse:collapse;width:100%;margin-top:16px}}td,th{{border-bottom
selectedPath = null;
highlightRoutes();
if (focusModeSelect.value === 'impact') applyFocus();
renderEdges();
}});
}}
}})();
Expand Down
135 changes: 115 additions & 20 deletions crates/raysense-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,31 @@ struct McpState {
last_path: Option<PathBuf>,
last_config: Option<RaysenseConfig>,
baseline: Option<ProjectBaseline>,
}
cached_health: Option<HealthCache>,
}

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();
Expand Down Expand Up @@ -374,16 +398,20 @@ fn call_tool(params: &Value, state: &mut McpState) -> Result<Value> {
.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),
Expand All @@ -393,8 +421,8 @@ fn call_tool(params: &Value, state: &mut McpState) -> Result<Value> {
"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),
Expand Down Expand Up @@ -443,14 +471,11 @@ fn write_config_tool(args: &Value) -> Result<Value> {
}))
}

fn health_tool(args: &Value) -> Result<Value> {
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<Value> {
let (root, health) = health_from_args_cached(args, state)?;

Ok(json!({
"root": report.snapshot.root,
"root": root,
"health": health
}))
}
Expand Down Expand Up @@ -529,8 +554,8 @@ fn edges_tool(args: &Value) -> Result<Value> {
}))
}

fn hotspots_tool(args: &Value) -> Result<Value> {
let (root, health) = health_from_args(args)?;
fn hotspots_tool(args: &Value, state: &mut McpState) -> Result<Value> {
let (root, health) = health_from_args_cached(args, state)?;
let limit = limit_arg(args, 100)?;

Ok(json!({
Expand Down Expand Up @@ -565,8 +590,8 @@ fn module_edges_tool(args: &Value) -> Result<Value> {
}))
}

fn architecture_tool(args: &Value) -> Result<Value> {
let (root, health) = health_from_args(args)?;
fn architecture_tool(args: &Value, state: &mut McpState) -> Result<Value> {
let (root, health) = health_from_args_cached(args, state)?;
let limit = limit_arg(args, 100)?;

Ok(json!({
Expand Down Expand Up @@ -815,8 +840,8 @@ fn check_rules_tool(args: &Value) -> Result<Value> {
}))
}

fn evolution_tool(args: &Value) -> Result<Value> {
let (root, health) = health_from_args(args)?;
fn evolution_tool(args: &Value, state: &mut McpState) -> Result<Value> {
let (root, health) = health_from_args_cached(args, state)?;
let limit = limit_arg(args, 100)?;
Ok(json!({
"root": root,
Expand All @@ -836,8 +861,8 @@ fn evolution_tool(args: &Value) -> Result<Value> {
}))
}

fn dsm_tool(args: &Value) -> Result<Value> {
let (root, health) = health_from_args(args)?;
fn dsm_tool(args: &Value, state: &mut McpState) -> Result<Value> {
let (root, health) = health_from_args_cached(args, state)?;
let limit = limit_arg(args, 100)?;
Ok(json!({
"root": root,
Expand Down Expand Up @@ -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<RaysenseConfig> {
if args.get("config").is_some() {
config_arg(args)
Expand Down Expand Up @@ -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",
);
}
}
Loading