Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ pub struct HotspotV1 {
pub incoming: u64,
pub outgoing: u64,
pub total: u64,
/// Commits touching `file` in the last 90 days.
pub churn: u64,
/// Ranking key: `total * (churn + 1)`.
pub score: u64,
}

#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-contracts/src/retrieval/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ fn primitive_read_description(operation: &str) -> &'static str {
"circular" => {
"Report file-level dependency cycles, largest first, each bounded to its listed members with its true size stated."
}
"hotspots" => "Rank symbols by total incoming plus outgoing graph relations.",
"hotspots" => "Rank symbols by graph relations weighted by file churn.",
"unmounted_files" => {
"Find source files on disk that no compiler, bundler, or test runner reaches from its entry points, per ecosystem, with each ecosystem's verdict and blind spots."
}
Expand Down
4 changes: 3 additions & 1 deletion crates/tracedecay-mcp-catalog/src/definitions/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ pub(super) fn def_hotspots(input_schema: Value) -> ToolDefinition {
def(
"tracedecay_hotspots",
"Hotspots",
"Find symbols with the highest connectivity (most incoming + outgoing edges).",
"Find churn-weighted hotspots: symbols ranked by `score` = `total` (incoming + outgoing \
edges) x (`churn` + 1), where `churn` counts commits touching the symbol's file in the \
last 90 days.",
input_schema,
)
}
Expand Down
85 changes: 41 additions & 44 deletions crates/tracedecay-mcp/src/handlers/analysis/hotspots.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
//! `tracedecay_hotspots`, churn-weighted complexity ranking.
//! `tracedecay_hotspots`, churn-weighted connectivity ranking.

use std::sync::LazyLock;

use tracedecay_code_extraction::LanguageRegistry;
use tracedecay_contracts::retrieval::{HotspotV1, HotspotsResultV1, HotspotsSurfaceRequestV1};
use tracedecay_runtime_core::git::churn::file_churn;

use super::*;

/// Manifest keys (`package.json`, `Cargo.toml`) are indexed for module
/// resolution; they have no call edges and are not code hotspots.
static EXTRACTORS: LazyLock<LanguageRegistry> = LazyLock::new(LanguageRegistry::new);

const CHURN_WINDOW_DAYS: u32 = 90;

#[tracing::instrument(name = "mcp.analysis.hotspots.total", level = "trace", skip_all)]
pub(super) async fn compute_hotspots(
graph: &tracedecay_graph_query::VerifiedGraphQuery,
Expand All @@ -29,52 +32,46 @@ pub(super) async fn compute_hotspots(
(symbols, edges)
}
};
let (symbols, incoming, outgoing) = {
let churn = file_churn(graph.project_root()?, CHURN_WINDOW_DAYS).await?;
let mut hotspots: Vec<HotspotV1> = {
let _span = tracing::trace_span!("mcp.analysis.hotspots.compute").entered();
{
let mut incoming = HashMap::<SymbolOccurrenceId, u64>::new();
let mut outgoing = HashMap::<SymbolOccurrenceId, u64>::new();
for edge in edges {
*outgoing.entry(edge.from_occurrence).or_default() += 1;
*incoming.entry(edge.to_occurrence).or_default() += 1;
}
symbols.retain(|symbol| !EXTRACTORS.is_configuration_file(&symbol.path));
symbols.sort_by(|left, right| {
let left_total = incoming
.get(&left.occurrence)
.copied()
.unwrap_or(0)
.saturating_add(outgoing.get(&left.occurrence).copied().unwrap_or(0));
let right_total = incoming
.get(&right.occurrence)
.copied()
.unwrap_or(0)
.saturating_add(outgoing.get(&right.occurrence).copied().unwrap_or(0));
right_total
.cmp(&left_total)
.then_with(|| left.occurrence.cmp(&right.occurrence))
});
symbols.truncate(limit);
(symbols, incoming, outgoing)
let mut incoming = HashMap::<SymbolOccurrenceId, u64>::new();
let mut outgoing = HashMap::<SymbolOccurrenceId, u64>::new();
for edge in edges {
*outgoing.entry(edge.from_occurrence).or_default() += 1;
*incoming.entry(edge.to_occurrence).or_default() += 1;
}
symbols.retain(|symbol| !EXTRACTORS.is_configuration_file(&symbol.path));
symbols
.into_iter()
.map(|symbol| {
let incoming = incoming.get(&symbol.occurrence).copied().unwrap_or(0);
let outgoing = outgoing.get(&symbol.occurrence).copied().unwrap_or(0);
let total = incoming.saturating_add(outgoing);
let churn = churn.get(&symbol.path).copied().unwrap_or(0) as u64;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
HotspotV1 {
id: symbol.occurrence.as_str().to_owned(),
name: symbol.metadata.simple_name,
kind: symbol.metadata.kind,
file: symbol.path,
line: user_line(symbol.metadata.start_line),
incoming,
outgoing,
total,
churn,
score: total.saturating_mul(churn.saturating_add(1)),
}
})
.collect()
};
let hotspots: Vec<HotspotV1> = symbols
.into_iter()
.map(|symbol| {
let incoming = incoming.get(&symbol.occurrence).copied().unwrap_or(0);
let outgoing = outgoing.get(&symbol.occurrence).copied().unwrap_or(0);
HotspotV1 {
id: symbol.occurrence.as_str().to_owned(),
name: symbol.metadata.simple_name,
kind: symbol.metadata.kind,
file: symbol.path,
line: user_line(symbol.metadata.start_line),
incoming,
outgoing,
total: incoming + outgoing,
}
})
.collect();
hotspots.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| right.total.cmp(&left.total))
.then_with(|| left.id.cmp(&right.id))
});
hotspots.truncate(limit);
let touched_files = unique_file_paths(hotspots.iter().map(|hotspot| hotspot.file.as_str()));
Ok(graph_tool_completion(
GraphToolResultV1::Hotspots(HotspotsResultV1 {
Expand Down
37 changes: 36 additions & 1 deletion crates/tracedecay-runtime-core/src/git/churn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use tracedecay_domain::errors::{Result, TraceDecayError};
use super::{GitCommandBounds, bounded_git_output, try_git_program};
use crate::git_repository::{GitRepositoryAuthority, GitRepositoryError};

/// Counts commits touching each exact UTF-8 Git path during the requested window.
/// Counts commits touching each exact UTF-8 path, relative to `project_root`
/// (which may sit below the repository root), during the requested window.
/// Missing/unborn repositories have no history. Unreadable history and paths that
/// cannot be represented by the graph's string identity are errors, not zero churn.
#[tracing::instrument(name = "runtime_core.git.file_churn", level = "trace", skip_all)]
Expand Down Expand Up @@ -62,6 +63,7 @@ fn read_file_churn(
"log",
"--format=",
"--name-only",
"--relative",
"-z",
&format!("--since={days} days ago"),
],
Expand Down Expand Up @@ -166,6 +168,39 @@ mod tests {
}
}

#[test]
fn subdirectory_project_keys_paths_relative_to_itself() {
let dir = tempfile::tempdir().unwrap();
let repository = dir.path();
let project = repository.join("packages/app");
let git = |args: &[&str]| {
let output = std::process::Command::new(try_git_program().unwrap())
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(args)
.current_dir(repository)
.output()
.unwrap();
assert!(output.status.success(), "{args:?}: {:?}", output.stderr);
};
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/main.ts"), "one").unwrap();
std::fs::write(project.join("src/util.ts"), "one").unwrap();
std::fs::write(repository.join("outside.ts"), "one").unwrap();
git(&["init", "--quiet"]);
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "first"]);
std::fs::write(project.join("src/main.ts"), "two").unwrap();
std::fs::write(repository.join("outside.ts"), "two").unwrap();
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "second"]);

let counts = read_file_churn(&project, 90, &GitCommandBounds::default()).unwrap();
assert_eq!(
counts,
HashMap::from([("src/main.ts".to_owned(), 2), ("src/util.ts".to_owned(), 1)])
);
}

/// Report whether `directory`'s filesystem accepts a name that is not
/// valid UTF-8.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5179,9 +5179,7 @@ fn assert_exact_hotspot(
name: &str,
file: &str,
line: u64,
incoming: u64,
outgoing: u64,
total: u64,
[incoming, outgoing, total, churn, score]: [u64; 5],
) {
let id = row["id"]
.as_str()
Expand All @@ -5198,6 +5196,8 @@ fn assert_exact_hotspot(
"incoming": incoming,
"outgoing": outgoing,
"total": total,
"churn": churn,
"score": score,
}),
"{row}"
);
Expand All @@ -5218,17 +5218,17 @@ fn hotspots(payload: &Value) -> &[Value] {
fn assert_chain_ranking(payload: &Value) {
let rows = hotspots(payload);
assert_eq!(rows.len(), 4, "{payload}");
assert_exact_hotspot(&rows[0], "mid", "src/calls.ts", 9, 1, 1, 2);
assert_exact_hotspot(&rows[3], "quiet", "src/calls.ts", 1, 0, 0, 0);
assert_exact_hotspot(&rows[0], "mid", "src/calls.ts", 9, [1, 1, 2, 1, 4]);
assert_exact_hotspot(&rows[3], "quiet", "src/calls.ts", 1, [0, 0, 0, 1, 0]);
let mut tied = [rows[1].clone(), rows[2].clone()];
tied.sort_by(|left, right| {
left["name"]
.as_str()
.unwrap_or("")
.cmp(right["name"].as_str().unwrap_or(""))
});
assert_exact_hotspot(&tied[0], "hub", "src/calls.ts", 13, 0, 1, 1);
assert_exact_hotspot(&tied[1], "leaf", "src/calls.ts", 5, 1, 0, 1);
assert_exact_hotspot(&tied[0], "hub", "src/calls.ts", 13, [0, 1, 1, 1, 2]);
assert_exact_hotspot(&tied[1], "leaf", "src/calls.ts", 5, [1, 0, 1, 1, 2]);
assert!(
rows.windows(2)
.all(|pair| pair[0]["total"].as_u64() >= pair[1]["total"].as_u64()),
Expand All @@ -5239,7 +5239,7 @@ fn assert_chain_ranking(payload: &Value) {
fn assert_fanout_page(payload: &Value, expected_count: usize) {
let rows = hotspots(payload);
assert_eq!(rows.len(), expected_count, "{payload}");
assert_exact_hotspot(&rows[0], "hub", "src/fanout.ts", 1, 101, 0, 101);
assert_exact_hotspot(&rows[0], "hub", "src/fanout.ts", 1, [101, 0, 101, 1, 202]);
let mut seen = Vec::new();
for row in rows.iter().skip(1) {
let name = row["name"]
Expand All @@ -5251,7 +5251,7 @@ fn assert_fanout_page(payload: &Value, expected_count: usize) {
.parse()
.unwrap_or_else(|_| panic!("caller index missing: {row}"));
assert!(index < 101, "caller outside the fixture: {row}");
assert_exact_hotspot(row, name, "src/fanout.ts", index + 2, 0, 1, 1);
assert_exact_hotspot(row, name, "src/fanout.ts", index + 2, [0, 1, 1, 1, 2]);
seen.push(index);
}
seen.sort_unstable();
Expand Down Expand Up @@ -5308,7 +5308,7 @@ fn assert_clamped_truncation(payload: &Value) {
&array[..=end]
)
});
assert_exact_hotspot(&first, "hub", "src/fanout.ts", 1, 101, 0, 101);
assert_exact_hotspot(&first, "hub", "src/fanout.ts", 1, [101, 0, 101, 1, 202]);

let handle = payload["handle"]
.as_str()
Expand Down Expand Up @@ -5364,14 +5364,14 @@ async fn hotspots_ranks_symbols_by_edge_degree_and_clamps_limit() {
let chain_one_payload = parse_body(&chain_limit_one);
let one = hotspots(&chain_one_payload);
assert_eq!(one.len(), 1, "{chain_one_payload}");
assert_exact_hotspot(&one[0], "mid", "src/calls.ts", 9, 1, 1, 2);
assert_exact_hotspot(&one[0], "mid", "src/calls.ts", 9, [1, 1, 2, 1, 4]);
assert_savings_footer(&chain_limit_one, CHAIN_SOURCE.len());

let mid_id = one[0]["id"].as_str().expect("mid occurrence id").to_owned();
assert_eq!(
body_text(&chain_markdown),
format!(
"freshness: fresh\n**hotspot_count:** 1\n\n## hotspots\n- **mid**\n **kind:** function\n **file:** src/calls.ts\n **line:** 9\n **id:** `{mid_id}`\n **incoming:** 1\n **outgoing:** 1\n **total:** 2\n"
"freshness: fresh\n**hotspot_count:** 1\n\n## hotspots\n- **mid**\n **kind:** function\n **file:** src/calls.ts\n **line:** 9\n **id:** `{mid_id}`\n **churn:** 1\n **incoming:** 1\n **outgoing:** 1\n **score:** 4\n **total:** 2\n"
)
);
assert_savings_footer(&chain_markdown, CHAIN_SOURCE.len());
Expand Down Expand Up @@ -5405,13 +5405,97 @@ async fn hotspots_ranks_symbols_by_edge_degree_and_clamps_limit() {
let fanout_one_payload = parse_body(&fanout_one);
let fanout_top = hotspots(&fanout_one_payload);
assert_eq!(fanout_top.len(), 1, "{fanout_one_payload}");
assert_exact_hotspot(&fanout_top[0], "hub", "src/fanout.ts", 1, 101, 0, 101);
assert_exact_hotspot(
&fanout_top[0],
"hub",
"src/fanout.ts",
1,
[101, 0, 101, 1, 202],
);
assert_savings_footer(&fanout_one, fanout_bytes);

assert_clamped_truncation(&parse_body(&fanout_capped));
assert_savings_footer(&fanout_capped, fanout_bytes);
}

/// `stable.ts` holds the best-connected symbol but is committed once;
/// `churned.ts` is committed three times, so its moderately connected `mid`
/// must outrank the stable `hub`.
const STABLE_SOURCE: &str = "\
export function hub(): number {\n\
return 1;\n\
}\n\
\n\
export function a(): number {\n\
return hub();\n\
}\n\
\n\
export function b(): number {\n\
return hub();\n\
}\n\
\n\
export function c(): number {\n\
return hub();\n\
}\n\
";

const CHURNED_SOURCE: &str = "\
export function leaf(): number {\n\
return 1;\n\
}\n\
\n\
export function mid(): number {\n\
return leaf();\n\
}\n\
\n\
export function top(): number {\n\
return mid();\n\
}\n\
";

fn commit_all(project: &Path, message: &str) {
git_run(project, &["add", "."]);
git_run(
project,
&[
"-c",
"user.name=TraceDecay Tests",
"-c",
"user.email=tests@tracedecay.invalid",
"commit",
"--quiet",
"-m",
message,
],
);
}

#[tokio::test]
async fn hotspots_weights_connectivity_by_file_churn() {
let dir = test_temp_dir();
let root = dir.path().join("project");
write_package(&root, "hotspots-churn");
fs::write(root.join("src/stable.ts"), STABLE_SOURCE).unwrap();
fs::write(root.join("src/churned.ts"), CHURNED_SOURCE).unwrap();
git_run(&root, &["init", "--quiet"]);
commit_all(&root, "fixture");
for revision in 1..=2 {
let mut source = fs::read_to_string(root.join("src/churned.ts")).unwrap();
writeln!(source, "// revision {revision}").unwrap();
fs::write(root.join("src/churned.ts"), source).unwrap();
commit_all(&root, &format!("touch churned.ts {revision}"));
}
let host = init_test_project(&root).await;
let result = call_hotspots(&host, json!({"format": "json", "limit": 2})).await;
close_test_graph(host).await;

let payload = parse_body(&result);
let rows = hotspots(&payload);
assert_eq!(rows.len(), 2, "{payload}");
assert_exact_hotspot(&rows[0], "mid", "src/churned.ts", 5, [1, 1, 2, 3, 8]);
assert_exact_hotspot(&rows[1], "hub", "src/stable.ts", 1, [3, 0, 3, 1, 6]);
}

// Literal `tracedecay_recursion` results from production MCP `tools/call`.
//
// `length` is the number of call edges in the cycle. The chain repeats its
Expand Down
2 changes: 1 addition & 1 deletion docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,7 @@ When running as an MCP server, tracedecay exposes typed operations that AI agent
| `tracedecay_recursion` | Detect recursive and mutually-recursive call cycles. |
| `tracedecay_complexity` | Rank functions by composite complexity score, including cyclomatic complexity from the AST. |
| `tracedecay_god_class` | Find classes with the most members, candidates for decomposition. |
| `tracedecay_hotspots` | Find the most connected symbols (highest call count). These are high-risk areas. |
| `tracedecay_hotspots` | Rank symbols by connectivity (incoming + outgoing edges) weighted by file churn over the last 90 days. These are high-risk areas. |
| `tracedecay_doc_coverage` | Find public symbols missing documentation. |

### Health & quality signals
Expand Down
2 changes: 1 addition & 1 deletion plugin/chatgpt-extension/embedded/server.mjs

Large diffs are not rendered by default.

Loading
Loading