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
13 changes: 7 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions crates/rpg-mcp/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,30 @@ pub(crate) struct AnalyzeHealthParams {
/// Jaccard similarity threshold for semantic clone detection (default: 0.6).
pub(crate) semantic_similarity_threshold: Option<f64>,
}

/// Parameters for the `detect_cycles` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct DetectCyclesParams {
/// Maximum number of cycles to return (default: all). Use to limit output.
pub(crate) max_cycles: Option<usize>,
/// Minimum cycle length to report (default: 2). Use 3+ to skip trivial 2-cycles.
pub(crate) min_cycle_length: Option<usize>,
/// Maximum cycle length to detect (default: 20, prevents exponential blowup)
pub(crate) max_cycle_length: Option<usize>,
/// Filter to specific hierarchy areas (comma-separated, e.g., "Navigation,Parser").
/// Case-sensitive. Matches the L1 area in hierarchy_path.
pub(crate) area: Option<String>,
/// Sort cycles by: "length" (default, ascending — shortest cycles first),
/// "file_count" (most files involved first), "entity_count" (descending — longest cycles first).
pub(crate) sort_by: Option<String>,
/// Include file paths in output (default: true)
pub(crate) include_files: Option<bool>,
/// Only show cycles that span multiple files (default: false).
/// Filters out cycles where all entities are in the same file.
pub(crate) cross_file_only: Option<bool>,
/// Only show cycles that span multiple hierarchy areas (default: false).
/// Filters out cycles where all entities belong to the same L1 area.
pub(crate) cross_area_only: Option<bool>,
/// Ignore .rpgignore rules and include all files (default: false)
pub(crate) ignore_rpgignore: Option<bool>,
}
25 changes: 25 additions & 0 deletions crates/rpg-mcp/src/prompts/server_instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,31 @@ unstable modules, and duplicated code.
- **impact_radius**: BFS reachability with edge paths. Answers "what depends on X?" in one call. Traverses DataFlow edges for data lineage analysis
- **plan_change**: Change planning — find relevant entities, dependency-safe modification order, impact radius, and related tests
- **analyze_health**: Architectural health analysis — instability, centrality, god objects, duplication detection (token + semantic)
- **detect_cycles**: Find circular dependencies in the codebase. First call returns summary + area breakdown. Use filters to get cycle details.
- **rpg_info**: Get codebase overview, statistics, and inter-area connectivity
- **update_rpg**: Incrementally update after code changes
- **reload_rpg**: Reload graph from disk

## CYCLE DETECTION

Use `detect_cycles` to find circular dependencies — architectural smells where A→B→C→A.

**First call (no params):** Returns summary statistics, length distribution, area breakdown. Shows `next_step` prompting to call with filters.

**Parameters (all optional, JSON object):**
| Parameter | Type | Description |
|-----------|------|-------------|
| `area` | string | Filter to area(s), comma-separated. Example: `"Navigation"` or `"Parser,Core"` |
| `max_cycles` | number | Limit cycles returned. Example: `10` |
| `min_cycle_length` | number | Skip trivial cycles. Example: `3` (skips 2-cycles) |
| `max_cycle_length` | number | Max cycle length. Default: `20` |
| `cross_file_only` | boolean | Only cross-file cycles. Example: `true` |
| `cross_area_only` | boolean | Only cross-area cycles. Example: `true` |
| `sort_by` | string | Sort key: `"length"`, `"file_count"`, or `"entity_count"` |
| `include_files` | boolean | Include file paths. Default: `true` |
| `ignore_rpgignore` | boolean | Include ignored files. Default: `false` |

**Usage flow:**
1. Call `detect_cycles` with no params → get summary + area breakdown
2. Use `next_step` as guide → call again with filters
3. Example: `{"area": "Navigation", "max_cycles": 10}`
193 changes: 193 additions & 0 deletions crates/rpg-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2916,6 +2916,199 @@ impl RpgServer {
rpg_nav::toon::format_health_report(&report)
))
}

#[tool(
description = "Detect circular dependencies (cycles) in the codebase. Cycles are architectural smells where A depends on B, B on C, and C back on A. Returns all detected cycles with their entity chains. First call returns summary + recommendations. Use parameters to filter results."
)]
async fn detect_cycles(
&self,
Parameters(params): Parameters<DetectCyclesParams>,
) -> Result<String, String> {
self.ensure_graph().await?;
let notice = self.staleness_notice().await;
let guard = self.graph.read().await;
let graph = guard.as_ref().unwrap();

let cross_file_only = params.cross_file_only.unwrap_or(false);
let cross_area_only = params.cross_area_only.unwrap_or(false);
let max_cycles = params.max_cycles.unwrap_or(usize::MAX);
let area = params.area.clone();
let sort_by = params
.sort_by
.clone()
.unwrap_or_else(|| "length".to_string());

let config = rpg_nav::cycles::CycleConfig {
max_cycles,
max_cycle_length: params.max_cycle_length.unwrap_or(20),
min_cycle_length: params.min_cycle_length.unwrap_or(2),
area,
sort_by,
include_files: params.include_files.unwrap_or(true),
cross_file_only,
cross_area_only,
ignore_rpgignore: params.ignore_rpgignore.unwrap_or(false),
project_root: Some(self.project_root.clone()),
};

let report = rpg_nav::cycles::detect_cycles(graph, &config);

// TOON-optimized output: https://github.com/toon-format/toon

let mut output = format!("{}# Circular Dependencies\n\n", notice);

// Summary: inline object (compact TOON)
// Format: cycles: {total: N, entities: N, files: N, areas: N, cross_file: N, cross_area: N}
output.push_str(&format!(
"cycles: {{total: {}, entities: {}, files: {}, areas: {}, cross_file: {}, cross_area: {}}}\n\n",
report.cycle_count,
report.entities_in_cycles,
report.files_in_cycles,
report.areas_in_cycles,
report.cross_file_count,
report.cross_area_count
));

if report.cycle_count > 0 {
// Length distribution: compact key=value pairs
output.push_str(&format!(
"length_dist: len2={} len3={} len4={} len5+={}\n\n",
report.length_distribution.length_2,
report.length_distribution.length_3,
report.length_distribution.length_4,
report.length_distribution.length_5_plus
));

// Area breakdown: TOON tabular format
// Format: area_breakdown[N]{area,cycles,len2,len3,len4+,files}:
// AreaName,cycles,len2,len3,len4+,files
if !report.area_breakdown.is_empty() {
output.push_str(&format!(
"area_breakdown[{}]{{area,cycles,len2,len3,len4+,files}}:\n",
report.area_breakdown.len()
));
for area in &report.area_breakdown {
output.push_str(&format!(
" {},{},{},{},{},{}\n",
area.area,
area.cycle_count,
area.length_2,
area.length_3,
area.length_4_plus,
area.file_count
));
}
output.push('\n');
}
}

// If no filters applied, show available areas and next step
let has_filters = params.max_cycles.is_some()
|| params.min_cycle_length.unwrap_or(2) > 2
|| params.area.is_some()
|| params.cross_file_only.unwrap_or(false)
|| params.cross_area_only.unwrap_or(false)
|| params.sort_by.is_some();

// Option 1: TOON-ify available_areas with count
if !has_filters {
// Only show areas that have cycles (from area_breakdown, sorted by cycle count).
// Showing all hierarchy areas including cycle-free ones is misleading noise.
let areas: Vec<String> = report
.area_breakdown
.iter()
.map(|ab| ab.area.clone())
.collect();
if !areas.is_empty() {
output.push_str(&format!("available_areas[{}]: ", areas.len()));
output.push_str(&format!("{}\n\n", areas.join(",")));
}
output.push_str(
"---\nnext_step: Use area/max_cycles/cross_file_only/cross_area_only to filter\n",
);
return Ok(output);
}

// Option 2: Show active filters when filters are applied
output.push_str(&format!(
"filters: {{area: {}, max_cycles: {}, cross_file: {}, cross_area: {}}}\n\n",
params.area.as_deref().unwrap_or("-"),
params
.max_cycles
.map(|n| n.to_string())
.unwrap_or_else(|| "-".to_string()),
cross_file_only,
params.cross_area_only.unwrap_or(false)
));

// Cycles: TOON tabular with file:entity format
// Format: cycles[N]{chain,len,files}:
// file:entity->file:entity->file:entity,len=N,files=file1,file2
let display_cycles: Vec<_> = report.cycles.iter().take(max_cycles).collect();

// Handle edge case: max_cycles = 0
if max_cycles == 0 {
output.push_str("cycles[0]: (none requested)\n");
} else {
output.push_str(&format!(
"cycles[{}]{{chain,len,files}}:\n",
display_cycles.len()
));

for cycle in &display_cycles {
// Convert chain to file:entity format for clarity
let chain: Vec<String> = cycle
.cycle
.iter()
.map(|id| {
if let Some(entity) = graph.entities.get(id) {
let filename = entity
.file
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
format!("{}:{}", filename, entity.name)
} else {
id.clone()
}
})
.collect();
let chain_str = chain.join("->");

// Extract just filenames from full paths
let files_str: Vec<String> = cycle
.files
.iter()
.map(|f| {
std::path::Path::new(f)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| f.clone())
})
.collect();
let files_display = files_str.join(",");

output.push_str(&format!(
" {},len={},{}\n",
chain_str, cycle.length, files_display
));
}
}

// Only show "...and N more" if max_cycles > 0 and there are actually more cycles
if max_cycles > 0 && report.cycle_count > max_cycles {
output.push_str(&format!(
"\n... and {} more. Use max_cycles to limit.\n",
report.cycle_count - max_cycles
));
}

output.push_str(
"\n---\nnext_step: Use area/max_cycles/cross_file_only/cross_area_only to filter\n",
);

Ok(output)
}
}

impl RpgServer {
Expand Down
Loading