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
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@
|
|||||||
PLANS/
|
PLANS/
|
||||||
target/
|
target/
|
||||||
.wtismycode/
|
.wtismycode/
|
||||||
|
docs/
|
||||||
|
ARCHITECTURE.md
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::cycle_detector;
|
|||||||
use crate::model::{ProjectModel, SymbolKind};
|
use crate::model::{ProjectModel, SymbolKind};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use handlebars::Handlebars;
|
use handlebars::Handlebars;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
fn sanitize_for_link(filename: &str) -> String {
|
fn sanitize_for_link(filename: &str) -> String {
|
||||||
let cleaned = filename.strip_prefix("./").unwrap_or(filename);
|
let cleaned = filename.strip_prefix("./").unwrap_or(filename);
|
||||||
@@ -107,10 +108,16 @@ impl Renderer {
|
|||||||
## Modules index
|
## Modules index
|
||||||
<!-- ARCHDOC:BEGIN section=modules_index -->
|
<!-- ARCHDOC:BEGIN section=modules_index -->
|
||||||
> Generated. Do not edit inside this block.
|
> 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}}
|
{{#each modules}}
|
||||||
| {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) |
|
| {{{name}}} | {{{tag}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) |
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<!-- ARCHDOC:END section=modules_index -->
|
<!-- ARCHDOC:END section=modules_index -->
|
||||||
|
|
||||||
@@ -242,14 +249,21 @@ impl Renderer {
|
|||||||
|
|
||||||
pub fn render_architecture_md(&self, model: &ProjectModel, config: Option<&Config>) -> Result<String, anyhow::Error> {
|
pub fn render_architecture_md(&self, model: &ProjectModel, config: Option<&Config>) -> Result<String, anyhow::Error> {
|
||||||
// Build integration sections from classified_integrations
|
// 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<String>)> = 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<serde_json::Value> = Vec::new();
|
let mut integration_sections: Vec<serde_json::Value> = Vec::new();
|
||||||
for cat_name in &category_order {
|
for (cat_name, pkgs) in &sorted_categories {
|
||||||
if let Some(pkgs) = model.classified_integrations.get(*cat_name)
|
if !pkgs.is_empty() {
|
||||||
&& !pkgs.is_empty() {
|
let mut sorted_pkgs = pkgs.to_vec();
|
||||||
|
sorted_pkgs.sort();
|
||||||
integration_sections.push(serde_json::json!({
|
integration_sections.push(serde_json::json!({
|
||||||
"category": cat_name,
|
"category": cat_name,
|
||||||
"packages": pkgs,
|
"packages": sorted_pkgs,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,19 +347,8 @@ impl Renderer {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect module items for template (sorted alphabetically)
|
// Collect module items grouped by top-level directory
|
||||||
let mut modules_list = Vec::new();
|
let module_groups = Self::build_module_groups(model);
|
||||||
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 critical points as tuples (count, symbol_id, is_critical) for sorting
|
// 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_in_tuples: Vec<(usize, &str, bool)> = Vec::new();
|
||||||
@@ -397,7 +400,7 @@ impl Renderer {
|
|||||||
"integration_sections": integration_sections,
|
"integration_sections": integration_sections,
|
||||||
"rails_summary": "\n\nNo tooling information available.\n",
|
"rails_summary": "\n\nNo tooling information available.\n",
|
||||||
"layout_items": layout_items,
|
"layout_items": layout_items,
|
||||||
"modules": modules_list,
|
"module_groups": module_groups,
|
||||||
"high_fan_in": high_fan_in,
|
"high_fan_in": high_fan_in,
|
||||||
"high_fan_out": high_fan_out,
|
"high_fan_out": high_fan_out,
|
||||||
"cycles": cycles,
|
"cycles": cycles,
|
||||||
@@ -550,14 +553,20 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_integrations_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
pub fn render_integrations_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
||||||
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<String>)> = 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<serde_json::Value> = Vec::new();
|
let mut integration_sections: Vec<serde_json::Value> = Vec::new();
|
||||||
for cat_name in &category_order {
|
for (cat_name, pkgs) in &sorted_categories {
|
||||||
if let Some(pkgs) = model.classified_integrations.get(*cat_name)
|
if !pkgs.is_empty() {
|
||||||
&& !pkgs.is_empty() {
|
let mut sorted_pkgs = pkgs.to_vec();
|
||||||
|
sorted_pkgs.sort();
|
||||||
integration_sections.push(serde_json::json!({
|
integration_sections.push(serde_json::json!({
|
||||||
"category": cat_name,
|
"category": cat_name,
|
||||||
"packages": pkgs,
|
"packages": sorted_pkgs,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,33 +651,23 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
||||||
// Collect module information (sorted alphabetically)
|
let module_groups = Self::build_module_groups(model);
|
||||||
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 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!({
|
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#"
|
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}}
|
{{#each modules}}
|
||||||
| {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) |
|
| {{{name}}} | {{{tag}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) |
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
{{/each}}
|
{{/each}}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
@@ -811,6 +810,76 @@ impl Renderer {
|
|||||||
.map_err(|e| anyhow::anyhow!("Failed to render layout.md: {}", e))
|
.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<serde_json::Value> {
|
||||||
|
let mut groups: BTreeMap<String, Vec<serde_json::Value>> = 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<String, anyhow::Error> {
|
pub fn render_symbol_details(&self, model: &ProjectModel, symbol_id: &str) -> Result<String, anyhow::Error> {
|
||||||
// Find the symbol in the project model
|
// Find the symbol in the project model
|
||||||
let symbol = model.symbols.get(symbol_id)
|
let symbol = model.symbols.get(symbol_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user