fix: sort modules index and critical points, fix per-symbol fan-in/fan-out metrics

- Fix fan-in/fan-out always being 0: qualify edge from/to IDs with module name
- Add resolve_callee_to_symbol_id for cross-module call resolution
- Sort modules index alphabetically in ARCHITECTURE.md
- Sort high fan-in/fan-out tables by count descending
- Fix duplicate unsorted render methods (render_modules_index_section, render_critical_points_section)
This commit is contained in:
2026-02-15 13:01:23 +03:00
parent 1229235ac7
commit 0617f24744
2 changed files with 81 additions and 35 deletions

View File

@@ -868,11 +868,20 @@ impl PythonAnalyzer {
}
for parsed_module in parsed_modules {
let module_id = self.compute_module_path(&parsed_module.path);
for call in &parsed_module.calls {
let callee_expr = call.callee_expr.clone();
// Qualify from_id with module to match symbol IDs (module::symbol)
let from_id = format!("{}::{}", module_id, call.caller_symbol);
// Try to resolve callee to a qualified symbol ID
// If callee_expr is "module.func", try to find it as "resolved_module::func"
let to_id = self.resolve_callee_to_symbol_id(
&call.callee_expr, &module_id, project_model
);
let edge = crate::model::Edge {
from_id: call.caller_symbol.clone(),
to_id: callee_expr,
from_id,
to_id,
edge_type: crate::model::EdgeType::SymbolCall,
meta: None,
};
@@ -883,6 +892,34 @@ impl PythonAnalyzer {
Ok(())
}
/// Resolve a callee expression to a qualified symbol ID.
/// E.g., "SomeClass.method" or "func" -> "module::func"
fn resolve_callee_to_symbol_id(&self, callee_expr: &str, from_module: &str, model: &ProjectModel) -> String {
// First try: exact match as qualified ID in the same module
let same_module_id = format!("{}::{}", from_module, callee_expr);
if model.symbols.contains_key(&same_module_id) {
return same_module_id;
}
// Try: callee might be "func" and exist in another module via imports
// Check all symbols for a match on the bare name
let parts: Vec<&str> = callee_expr.splitn(2, '.').collect();
let bare_name = parts[0];
// Look through imports of from_module to find resolved target
if let Some(module) = model.modules.get(from_module) {
for outbound in &module.outbound_modules {
let candidate = format!("{}::{}", outbound, bare_name);
if model.symbols.contains_key(&candidate) {
return candidate;
}
}
}
// Fallback: return qualified with current module
same_module_id
}
/// Check if a class symbol is a simple data container (dataclass-like).
/// A class is considered a dataclass if it has ≤2 methods (typically __init__ and __repr__/__str__).
fn is_dataclass_like(symbol_id: &str, project_model: &ProjectModel) -> bool {

View File

@@ -333,9 +333,11 @@ impl Renderer {
}));
}
// Collect module items for template
// Collect module items for template (sorted alphabetically)
let mut modules_list = Vec::new();
for (module_id, module) in &model.modules {
let mut sorted_modules: Vec<_> = model.modules.iter().collect();
sorted_modules.sort_by(|(a, _), (b, _)| a.cmp(b));
for (module_id, module) in sorted_modules {
modules_list.push(serde_json::json!({
"name": module_id,
"symbol_count": module.symbols.len(),
@@ -345,26 +347,29 @@ impl Renderer {
}));
}
// Collect critical points
let mut high_fan_in = Vec::new();
let mut high_fan_out = Vec::new();
// Collect critical points as tuples (count, symbol_id, is_critical) for sorting
let mut fan_in_tuples: Vec<(usize, &str, bool)> = Vec::new();
let mut fan_out_tuples: Vec<(usize, &str, bool)> = Vec::new();
for (symbol_id, symbol) in &model.symbols {
if symbol.metrics.fan_in > 5 {
high_fan_in.push(serde_json::json!({
"symbol": symbol_id,
"count": symbol.metrics.fan_in,
"critical": symbol.metrics.is_critical,
}));
fan_in_tuples.push((symbol.metrics.fan_in, symbol_id, symbol.metrics.is_critical));
}
if symbol.metrics.fan_out > 5 {
high_fan_out.push(serde_json::json!({
"symbol": symbol_id,
"count": symbol.metrics.fan_out,
"critical": symbol.metrics.is_critical,
}));
fan_out_tuples.push((symbol.metrics.fan_out, symbol_id, symbol.metrics.is_critical));
}
}
// Sort by count descending
fan_in_tuples.sort_by(|a, b| b.0.cmp(&a.0));
fan_out_tuples.sort_by(|a, b| b.0.cmp(&a.0));
let high_fan_in: Vec<_> = fan_in_tuples.iter().map(|(count, sym, crit)| {
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
}).collect();
let high_fan_out: Vec<_> = fan_out_tuples.iter().map(|(count, sym, crit)| {
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
}).collect();
let cycles: Vec<_> = cycle_detector::detect_cycles(model)
.iter()
.map(|cycle| {
@@ -637,10 +642,12 @@ impl Renderer {
}
pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect module information
// Collect module information (sorted alphabetically)
let mut modules = Vec::new();
let mut sorted_modules: Vec<_> = model.modules.iter().collect();
sorted_modules.sort_by(|(a, _), (b, _)| a.cmp(b));
for (module_id, module) in &model.modules {
for (module_id, module) in sorted_modules {
modules.push(serde_json::json!({
"name": module_id,
"symbol_count": module.symbols.len(),
@@ -674,27 +681,29 @@ impl Renderer {
}
pub fn render_critical_points_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect critical points information
let mut high_fan_in = Vec::new();
let mut high_fan_out = Vec::new();
// Collect and sort critical points by count descending
let mut fan_in_items: Vec<(usize, &str, bool)> = Vec::new();
let mut fan_out_items: Vec<(usize, &str, bool)> = Vec::new();
for (symbol_id, symbol) in &model.symbols {
if symbol.metrics.fan_in > 5 { // Threshold for high fan-in
high_fan_in.push(serde_json::json!({
"symbol": symbol_id,
"count": symbol.metrics.fan_in,
"critical": symbol.metrics.is_critical,
}));
if symbol.metrics.fan_in > 5 {
fan_in_items.push((symbol.metrics.fan_in, symbol_id, symbol.metrics.is_critical));
}
if symbol.metrics.fan_out > 5 { // Threshold for high fan-out
high_fan_out.push(serde_json::json!({
"symbol": symbol_id,
"count": symbol.metrics.fan_out,
"critical": symbol.metrics.is_critical,
}));
if symbol.metrics.fan_out > 5 {
fan_out_items.push((symbol.metrics.fan_out, symbol_id, symbol.metrics.is_critical));
}
}
fan_in_items.sort_by(|a, b| b.0.cmp(&a.0));
fan_out_items.sort_by(|a, b| b.0.cmp(&a.0));
let high_fan_in: Vec<_> = fan_in_items.iter().map(|(count, sym, crit)| {
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
}).collect();
let high_fan_out: Vec<_> = fan_out_items.iter().map(|(count, sym, crit)| {
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
}).collect();
// Prepare data for critical points section
let data = serde_json::json!({
"high_fan_in": high_fan_in,