From 0396a53e0ce96c325cd0589b40103cd0a5f60676 Mon Sep 17 00:00:00 2001 From: Arkasha Date: Sun, 15 Feb 2026 13:06:01 +0300 Subject: [PATCH] feat: directory grouping in module index, filter Internal integrations, sort integrations alphabetically, tag model/dataclass modules - Group modules by top-level directory with collapsible sections and module counts - Filter out 'Internal' category from integrations (cross-module imports are not real integrations) - Sort integration categories and packages alphabetically for consistent output - Add [models], [config], [tests] tags to differentiate module types in the index --- .gitignore | 2 + wtismycode-core/src/renderer.rs | 179 ++++++++++++++++++++++---------- 2 files changed, 126 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 1be3bad..59eb32b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ PLANS/ target/ .wtismycode/ +docs/ +ARCHITECTURE.md diff --git a/wtismycode-core/src/renderer.rs b/wtismycode-core/src/renderer.rs index c747c2c..5b3b124 100644 --- a/wtismycode-core/src/renderer.rs +++ b/wtismycode-core/src/renderer.rs @@ -8,6 +8,7 @@ use crate::cycle_detector; use crate::model::{ProjectModel, SymbolKind}; use chrono::Utc; use handlebars::Handlebars; +use std::collections::BTreeMap; fn sanitize_for_link(filename: &str) -> String { let cleaned = filename.strip_prefix("./").unwrap_or(filename); @@ -107,10 +108,16 @@ impl Renderer { ## Modules index > Generated. Do not edit inside this block. -| Module | Symbols | Inbound | Outbound | Link | -|--------|---------|---------|----------|------| + +{{#each module_groups}} +### {{{group_name}}} ({{{module_count}}} modules) + +| Module | Tag | Symbols | Inbound | Outbound | Link | +|--------|-----|---------|---------|----------|------| {{#each modules}} -| {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) | +| {{{name}}} | {{{tag}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) | +{{/each}} + {{/each}} @@ -242,16 +249,23 @@ impl Renderer { pub fn render_architecture_md(&self, model: &ProjectModel, config: Option<&Config>) -> Result { // Build integration sections from classified_integrations - let category_order = ["HTTP", "Database", "Queue", "Storage", "AI/ML", "Auth", "Testing", "Logging", "Internal", "Third-party"]; + // Filter out "Internal" — those are just cross-module imports, not real integrations + // Sort categories and packages alphabetically for consistent output + let mut sorted_categories: Vec<(&String, &Vec)> = model.classified_integrations.iter() + .filter(|(cat, _)| cat.as_str() != "Internal") + .collect(); + sorted_categories.sort_by_key(|(cat, _)| cat.to_lowercase()); + let mut integration_sections: Vec = Vec::new(); - for cat_name in &category_order { - if let Some(pkgs) = model.classified_integrations.get(*cat_name) - && !pkgs.is_empty() { - integration_sections.push(serde_json::json!({ - "category": cat_name, - "packages": pkgs, - })); - } + for (cat_name, pkgs) in &sorted_categories { + if !pkgs.is_empty() { + let mut sorted_pkgs = pkgs.to_vec(); + sorted_pkgs.sort(); + integration_sections.push(serde_json::json!({ + "category": cat_name, + "packages": sorted_pkgs, + })); + } } // Determine project name: config > pyproject.toml > directory name > fallback @@ -333,19 +347,8 @@ impl Renderer { })); } - // Collect module items for template (sorted alphabetically) - let mut modules_list = 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 sorted_modules { - modules_list.push(serde_json::json!({ - "name": module_id, - "symbol_count": module.symbols.len(), - "inbound_count": module.inbound_modules.len(), - "outbound_count": module.outbound_modules.len(), - "link": format!("docs/architecture/modules/{}.md", sanitize_for_link(module_id)) - })); - } + // Collect module items grouped by top-level directory + let module_groups = Self::build_module_groups(model); // Collect critical points as tuples (count, symbol_id, is_critical) for sorting let mut fan_in_tuples: Vec<(usize, &str, bool)> = Vec::new(); @@ -397,7 +400,7 @@ impl Renderer { "integration_sections": integration_sections, "rails_summary": "\n\nNo tooling information available.\n", "layout_items": layout_items, - "modules": modules_list, + "module_groups": module_groups, "high_fan_in": high_fan_in, "high_fan_out": high_fan_out, "cycles": cycles, @@ -550,16 +553,22 @@ impl Renderer { } pub fn render_integrations_section(&self, model: &ProjectModel) -> Result { - let category_order = ["HTTP", "Database", "Queue", "Storage", "AI/ML", "Auth", "Testing", "Logging", "Internal", "Third-party"]; + // Filter Internal, sort alphabetically + let mut sorted_categories: Vec<(&String, &Vec)> = model.classified_integrations.iter() + .filter(|(cat, _)| cat.as_str() != "Internal") + .collect(); + sorted_categories.sort_by_key(|(cat, _)| cat.to_lowercase()); + let mut integration_sections: Vec = Vec::new(); - for cat_name in &category_order { - if let Some(pkgs) = model.classified_integrations.get(*cat_name) - && !pkgs.is_empty() { - integration_sections.push(serde_json::json!({ - "category": cat_name, - "packages": pkgs, - })); - } + for (cat_name, pkgs) in &sorted_categories { + if !pkgs.is_empty() { + let mut sorted_pkgs = pkgs.to_vec(); + sorted_pkgs.sort(); + integration_sections.push(serde_json::json!({ + "category": cat_name, + "packages": sorted_pkgs, + })); + } } let data = serde_json::json!({ @@ -642,33 +651,23 @@ impl Renderer { } pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result { - // 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)); + let module_groups = Self::build_module_groups(model); - for (module_id, module) in sorted_modules { - modules.push(serde_json::json!({ - "name": module_id, - "symbol_count": module.symbols.len(), - "inbound_count": module.inbound_modules.len(), - "outbound_count": module.outbound_modules.len(), - "link": format!("docs/architecture/modules/{}.md", sanitize_for_link(module_id)) - })); - } - - // Prepare data for modules index section let data = serde_json::json!({ - "modules": modules, + "module_groups": module_groups, }); - // Create a smaller template just for the modules index section let modules_template = r#" -| Module | Symbols | Inbound | Outbound | Link | -|--------|---------|---------|----------|------| +{{#each module_groups}} +### {{{group_name}}} ({{{module_count}}} modules) + +| Module | Tag | Symbols | Inbound | Outbound | Link | +|--------|-----|---------|---------|----------|------| {{#each modules}} -| {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) | +| {{{name}}} | {{{tag}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) | +{{/each}} + {{/each}} "#; @@ -811,6 +810,76 @@ impl Renderer { .map_err(|e| anyhow::anyhow!("Failed to render layout.md: {}", e)) } + /// Build module groups by top-level directory, with tags for model/dataclass modules. + fn build_module_groups(model: &ProjectModel) -> Vec { + let mut groups: BTreeMap> = BTreeMap::new(); + + 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 { + let top_level = module_id.split('.').next().unwrap_or(module_id).to_string(); + + // Determine tag + let tag = Self::classify_module_tag(module_id, module, model); + + let entry = serde_json::json!({ + "name": module_id, + "tag": tag, + "symbol_count": module.symbols.len(), + "inbound_count": module.inbound_modules.len(), + "outbound_count": module.outbound_modules.len(), + "link": format!("docs/architecture/modules/{}.md", sanitize_for_link(module_id)) + }); + groups.entry(top_level).or_default().push(entry); + } + + groups.into_iter().map(|(group_name, modules)| { + let count = modules.len(); + serde_json::json!({ + "group_name": group_name, + "module_count": count, + "modules": modules, + }) + }).collect() + } + + /// Classify a module with a tag: [models], [config], [tests], or empty. + fn classify_module_tag(module_id: &str, module: &crate::model::Module, model: &ProjectModel) -> String { + let parts: Vec<&str> = module_id.split('.').collect(); + let last_part = parts.last().copied().unwrap_or(""); + + // Check if module name suggests models/schemas/dataclasses + if last_part == "models" || last_part == "schemas" || last_part == "types" + || parts.contains(&"models") || parts.contains(&"schemas") { + return "[models]".to_string(); + } + + // Check if most symbols are classes with few methods (dataclass-like) + let class_count = module.symbols.iter() + .filter(|s| model.symbols.get(*s).map(|sym| sym.kind == SymbolKind::Class).unwrap_or(false)) + .count(); + let total = module.symbols.len(); + if class_count > 0 && total > 0 { + // If >50% of top-level symbols are classes and module has few methods per class + let method_count = module.symbols.iter() + .filter(|s| model.symbols.get(*s).map(|sym| sym.kind == SymbolKind::Method).unwrap_or(false)) + .count(); + if class_count as f64 / total as f64 > 0.4 && method_count <= class_count * 3 { + return "[models]".to_string(); + } + } + + if parts.contains(&"tests") || last_part.starts_with("test_") { + return "[tests]".to_string(); + } + if last_part == "config" || last_part == "settings" { + return "[config]".to_string(); + } + + String::new() + } + pub fn render_symbol_details(&self, model: &ProjectModel, symbol_id: &str) -> Result { // Find the symbol in the project model let symbol = model.symbols.get(symbol_id)