feat: major improvements — layout, cycles, integrations, usage examples, tests #1
@@ -49,6 +49,14 @@ pub fn init_project(root: &str, out: &str) -> Result<()> {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
<!-- ARCHDOC:BEGIN section=integrations -->
|
||||||
|
> Generated. Do not edit inside this block.
|
||||||
|
<AUTO: detected integrations by category>
|
||||||
|
<!-- ARCHDOC:END section=integrations -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rails / Tooling
|
## Rails / Tooling
|
||||||
<!-- ARCHDOC:BEGIN section=rails -->
|
<!-- ARCHDOC:BEGIN section=rails -->
|
||||||
> Generated. Do not edit inside this block.
|
> Generated. Do not edit inside this block.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub fn print_generate_summary(model: &ProjectModel) {
|
|||||||
if model.symbols.values().any(|s| s.integrations_flags.http) { v.push("HTTP"); }
|
if model.symbols.values().any(|s| s.integrations_flags.http) { v.push("HTTP"); }
|
||||||
if model.symbols.values().any(|s| s.integrations_flags.db) { v.push("DB"); }
|
if model.symbols.values().any(|s| s.integrations_flags.db) { v.push("DB"); }
|
||||||
if model.symbols.values().any(|s| s.integrations_flags.queue) { v.push("Queue"); }
|
if model.symbols.values().any(|s| s.integrations_flags.queue) { v.push("Queue"); }
|
||||||
|
if model.symbols.values().any(|s| s.integrations_flags.storage) { v.push("Storage"); }
|
||||||
|
if model.symbols.values().any(|s| s.integrations_flags.ai) { v.push("AI/ML"); }
|
||||||
v
|
v
|
||||||
};
|
};
|
||||||
if !integrations.is_empty() {
|
if !integrations.is_empty() {
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ pub struct IntegrationFlags {
|
|||||||
pub http: bool,
|
pub http: bool,
|
||||||
pub db: bool,
|
pub db: bool,
|
||||||
pub queue: bool,
|
pub queue: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub storage: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ai: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -364,40 +364,59 @@ impl PythonAnalyzer {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_integrations(&self, body: &[Stmt], config: &Config) -> crate::model::IntegrationFlags {
|
fn detect_integrations(&self, _body: &[Stmt], _config: &Config) -> crate::model::IntegrationFlags {
|
||||||
|
// Integration detection is now done at module level in resolve_symbols
|
||||||
|
// based on actual imports, not AST body debug strings
|
||||||
|
crate::model::IntegrationFlags {
|
||||||
|
http: false,
|
||||||
|
db: false,
|
||||||
|
queue: false,
|
||||||
|
storage: false,
|
||||||
|
ai: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect integrations for a module based on its actual imports
|
||||||
|
fn detect_module_integrations(&self, imports: &[Import], config: &Config) -> crate::model::IntegrationFlags {
|
||||||
let mut flags = crate::model::IntegrationFlags {
|
let mut flags = crate::model::IntegrationFlags {
|
||||||
http: false,
|
http: false,
|
||||||
db: false,
|
db: false,
|
||||||
queue: false,
|
queue: false,
|
||||||
|
storage: false,
|
||||||
|
ai: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config.analysis.detect_integrations {
|
if !config.analysis.detect_integrations {
|
||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
let body_str = format!("{:?}", body);
|
// Build a set of all import names (both module names and their parts)
|
||||||
|
let import_names: Vec<String> = imports.iter().flat_map(|imp| {
|
||||||
|
let mut names = vec![imp.module_name.clone()];
|
||||||
|
// Also add individual parts: "from minio import Minio" -> module_name is "minio.Minio"
|
||||||
|
for part in imp.module_name.split('.') {
|
||||||
|
names.push(part.to_lowercase());
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}).collect();
|
||||||
|
|
||||||
for pattern in &config.analysis.integration_patterns {
|
for pattern in &config.analysis.integration_patterns {
|
||||||
if pattern.type_ == "http" {
|
for lib in &pattern.patterns {
|
||||||
for lib in &pattern.patterns {
|
let lib_lower = lib.to_lowercase();
|
||||||
if body_str.contains(lib) {
|
let matched = import_names.iter().any(|name| {
|
||||||
flags.http = true;
|
let name_lower = name.to_lowercase();
|
||||||
break;
|
name_lower.contains(&lib_lower)
|
||||||
}
|
});
|
||||||
}
|
if matched {
|
||||||
} else if pattern.type_ == "db" {
|
match pattern.type_.as_str() {
|
||||||
for lib in &pattern.patterns {
|
"http" => flags.http = true,
|
||||||
if body_str.contains(lib) {
|
"db" => flags.db = true,
|
||||||
flags.db = true;
|
"queue" => flags.queue = true,
|
||||||
break;
|
"storage" => flags.storage = true,
|
||||||
}
|
"ai" => flags.ai = true,
|
||||||
}
|
_ => {}
|
||||||
} else if pattern.type_ == "queue" {
|
|
||||||
for lib in &pattern.patterns {
|
|
||||||
if body_str.contains(lib) {
|
|
||||||
flags.queue = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,15 +629,28 @@ impl PythonAnalyzer {
|
|||||||
imports: parsed_module.imports.iter().map(|i| i.module_name.clone()).collect(),
|
imports: parsed_module.imports.iter().map(|i| i.module_name.clone()).collect(),
|
||||||
outbound_modules: Vec::new(),
|
outbound_modules: Vec::new(),
|
||||||
inbound_files: Vec::new(),
|
inbound_files: Vec::new(),
|
||||||
symbols: parsed_module.symbols.iter().map(|s| s.id.clone()).collect(),
|
symbols: parsed_module.symbols.iter().map(|s| format!("{}::{}", module_id, s.id)).collect(),
|
||||||
file_purpose,
|
file_purpose,
|
||||||
};
|
};
|
||||||
project_model.files.insert(file_id.clone(), file_doc);
|
project_model.files.insert(file_id.clone(), file_doc);
|
||||||
|
|
||||||
|
// Detect integrations based on actual imports
|
||||||
|
let module_integrations = self.detect_module_integrations(&parsed_module.imports, &self.config);
|
||||||
|
let mut module_symbol_ids = Vec::new();
|
||||||
for mut symbol in parsed_module.symbols.clone() {
|
for mut symbol in parsed_module.symbols.clone() {
|
||||||
symbol.module_id = module_id.clone();
|
symbol.module_id = module_id.clone();
|
||||||
symbol.file_id = file_id.clone();
|
symbol.file_id = file_id.clone();
|
||||||
project_model.symbols.insert(symbol.id.clone(), symbol);
|
// Make symbol ID unique by prefixing with module
|
||||||
|
let unique_id = format!("{}::{}", module_id, symbol.id);
|
||||||
|
symbol.id = unique_id.clone();
|
||||||
|
// Apply module-level integration flags to all symbols
|
||||||
|
symbol.integrations_flags.http |= module_integrations.http;
|
||||||
|
symbol.integrations_flags.db |= module_integrations.db;
|
||||||
|
symbol.integrations_flags.queue |= module_integrations.queue;
|
||||||
|
symbol.integrations_flags.storage |= module_integrations.storage;
|
||||||
|
symbol.integrations_flags.ai |= module_integrations.ai;
|
||||||
|
module_symbol_ids.push(unique_id.clone());
|
||||||
|
project_model.symbols.insert(unique_id, symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use __init__.py docstring for module doc_summary, or file docstring for single-file modules
|
// Use __init__.py docstring for module doc_summary, or file docstring for single-file modules
|
||||||
@@ -638,7 +670,7 @@ impl PythonAnalyzer {
|
|||||||
doc_summary,
|
doc_summary,
|
||||||
outbound_modules: Vec::new(),
|
outbound_modules: Vec::new(),
|
||||||
inbound_modules: Vec::new(),
|
inbound_modules: Vec::new(),
|
||||||
symbols: parsed_module.symbols.iter().map(|s| s.id.clone()).collect(),
|
symbols: module_symbol_ids,
|
||||||
};
|
};
|
||||||
project_model.modules.insert(module_id, module);
|
project_model.modules.insert(module_id, module);
|
||||||
}
|
}
|
||||||
@@ -787,7 +819,8 @@ impl PythonAnalyzer {
|
|||||||
if let Some(symbol) = project_model.symbols.get_mut(symbol_id) {
|
if let Some(symbol) = project_model.symbols.get_mut(symbol_id) {
|
||||||
symbol.metrics.fan_in = *fan_in;
|
symbol.metrics.fan_in = *fan_in;
|
||||||
symbol.metrics.fan_out = *fan_out;
|
symbol.metrics.fan_out = *fan_out;
|
||||||
symbol.metrics.is_critical = *fan_in > 10 || *fan_out > 10;
|
symbol.metrics.is_critical = *fan_in > self.config.thresholds.critical_fan_in
|
||||||
|
|| *fan_out > self.config.thresholds.critical_fan_out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ impl Renderer {
|
|||||||
{{#each queue_integrations}}
|
{{#each queue_integrations}}
|
||||||
- {{{this}}}
|
- {{{this}}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
|
### Storage Integrations
|
||||||
|
{{#each storage_integrations}}
|
||||||
|
- {{{this}}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
### AI/ML Integrations
|
||||||
|
{{#each ai_integrations}}
|
||||||
|
- {{{this}}}
|
||||||
|
{{/each}}
|
||||||
<!-- ARCHDOC:END section=integrations -->
|
<!-- ARCHDOC:END section=integrations -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -222,6 +232,20 @@ impl Renderer {
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if has_storage_integrations}}
|
||||||
|
### Storage Integrations
|
||||||
|
{{#each storage_symbols}}
|
||||||
|
- {{{this}}}
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if has_ai_integrations}}
|
||||||
|
### AI/ML Integrations
|
||||||
|
{{#each ai_symbols}}
|
||||||
|
- {{{this}}}
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
{{#each usage_examples}}
|
{{#each usage_examples}}
|
||||||
@@ -238,6 +262,8 @@ impl Renderer {
|
|||||||
let mut db_integrations = Vec::new();
|
let mut db_integrations = Vec::new();
|
||||||
let mut http_integrations = Vec::new();
|
let mut http_integrations = Vec::new();
|
||||||
let mut queue_integrations = Vec::new();
|
let mut queue_integrations = Vec::new();
|
||||||
|
let mut storage_integrations = Vec::new();
|
||||||
|
let mut ai_integrations = Vec::new();
|
||||||
|
|
||||||
for (symbol_id, symbol) in &model.symbols {
|
for (symbol_id, symbol) in &model.symbols {
|
||||||
if symbol.integrations_flags.db {
|
if symbol.integrations_flags.db {
|
||||||
@@ -249,9 +275,15 @@ impl Renderer {
|
|||||||
if symbol.integrations_flags.queue {
|
if symbol.integrations_flags.queue {
|
||||||
queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
||||||
}
|
}
|
||||||
|
if symbol.integrations_flags.storage {
|
||||||
|
storage_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
||||||
|
}
|
||||||
|
if symbol.integrations_flags.ai {
|
||||||
|
ai_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine project name: config > directory name > fallback
|
// Determine project name: config > pyproject.toml > directory name > fallback
|
||||||
let project_name = config
|
let project_name = config
|
||||||
.and_then(|c| {
|
.and_then(|c| {
|
||||||
if c.project.name.is_empty() {
|
if c.project.name.is_empty() {
|
||||||
@@ -260,6 +292,36 @@ impl Renderer {
|
|||||||
Some(c.project.name.clone())
|
Some(c.project.name.clone())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
// Try pyproject.toml
|
||||||
|
config.and_then(|c| {
|
||||||
|
let pyproject_path = std::path::Path::new(&c.project.root).join("pyproject.toml");
|
||||||
|
std::fs::read_to_string(&pyproject_path).ok().and_then(|content| {
|
||||||
|
// Simple TOML parsing for [project] name = "..."
|
||||||
|
let mut in_project = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed == "[project]" {
|
||||||
|
in_project = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
in_project = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_project && trimmed.starts_with("name") {
|
||||||
|
if let Some(val) = trimmed.split('=').nth(1) {
|
||||||
|
let name = val.trim().trim_matches('"').trim_matches('\'');
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
config.map(|c| {
|
config.map(|c| {
|
||||||
std::path::Path::new(&c.project.root)
|
std::path::Path::new(&c.project.root)
|
||||||
@@ -273,14 +335,31 @@ impl Renderer {
|
|||||||
|
|
||||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
// Collect layout items for template
|
// Collect layout items grouped by top-level directory
|
||||||
let mut layout_items = Vec::new();
|
let mut dir_files: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
||||||
for file_doc in model.files.values() {
|
for file_doc in model.files.values() {
|
||||||
let purpose = file_doc.file_purpose.as_deref().unwrap_or("Source file");
|
let path = file_doc.path.strip_prefix("./").unwrap_or(&file_doc.path);
|
||||||
|
let top_dir = path.split('/').next().unwrap_or(path);
|
||||||
|
// If file is at root level (no '/'), use the filename itself
|
||||||
|
let top = if path.contains('/') {
|
||||||
|
format!("{}/", top_dir)
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
};
|
||||||
|
dir_files.entry(top).or_default().push(path.to_string());
|
||||||
|
}
|
||||||
|
let mut layout_items = Vec::new();
|
||||||
|
for (dir, files) in &dir_files {
|
||||||
|
let file_count = files.len();
|
||||||
|
let purpose = if dir.ends_with('/') {
|
||||||
|
format!("{} files", file_count)
|
||||||
|
} else {
|
||||||
|
"Root file".to_string()
|
||||||
|
};
|
||||||
layout_items.push(serde_json::json!({
|
layout_items.push(serde_json::json!({
|
||||||
"path": file_doc.path,
|
"path": dir,
|
||||||
"purpose": purpose,
|
"purpose": purpose,
|
||||||
"link": format!("docs/architecture/files/{}.md", sanitize_for_link(&file_doc.path))
|
"link": format!("docs/architecture/files/{}.md", sanitize_for_link(dir.trim_end_matches('/')))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +422,8 @@ impl Renderer {
|
|||||||
"db_integrations": db_integrations,
|
"db_integrations": db_integrations,
|
||||||
"http_integrations": http_integrations,
|
"http_integrations": http_integrations,
|
||||||
"queue_integrations": queue_integrations,
|
"queue_integrations": queue_integrations,
|
||||||
|
"storage_integrations": storage_integrations,
|
||||||
|
"ai_integrations": ai_integrations,
|
||||||
"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,
|
"modules": modules_list,
|
||||||
@@ -380,6 +461,8 @@ impl Renderer {
|
|||||||
let mut db_symbols = Vec::new();
|
let mut db_symbols = Vec::new();
|
||||||
let mut http_symbols = Vec::new();
|
let mut http_symbols = Vec::new();
|
||||||
let mut queue_symbols = Vec::new();
|
let mut queue_symbols = Vec::new();
|
||||||
|
let mut storage_symbols = Vec::new();
|
||||||
|
let mut ai_symbols = Vec::new();
|
||||||
|
|
||||||
for symbol_id in &module.symbols {
|
for symbol_id in &module.symbols {
|
||||||
if let Some(symbol) = model.symbols.get(symbol_id) {
|
if let Some(symbol) = model.symbols.get(symbol_id) {
|
||||||
@@ -392,6 +475,12 @@ impl Renderer {
|
|||||||
if symbol.integrations_flags.queue {
|
if symbol.integrations_flags.queue {
|
||||||
queue_symbols.push(symbol.qualname.clone());
|
queue_symbols.push(symbol.qualname.clone());
|
||||||
}
|
}
|
||||||
|
if symbol.integrations_flags.storage {
|
||||||
|
storage_symbols.push(symbol.qualname.clone());
|
||||||
|
}
|
||||||
|
if symbol.integrations_flags.ai {
|
||||||
|
ai_symbols.push(symbol.qualname.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,9 +514,33 @@ impl Renderer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
SymbolKind::Class => {
|
SymbolKind::Class => {
|
||||||
|
// Find __init__ method to get constructor args
|
||||||
|
let init_name = format!("{}.__init__", short_name);
|
||||||
|
let init_args = module.symbols.iter()
|
||||||
|
.find_map(|sid| {
|
||||||
|
model.symbols.get(sid).and_then(|s| {
|
||||||
|
if s.qualname == init_name || s.id == init_name {
|
||||||
|
// Extract args from __init__ signature
|
||||||
|
let args = s.signature
|
||||||
|
.find('(')
|
||||||
|
.and_then(|start| s.signature.rfind(')').map(|end| (start, end)))
|
||||||
|
.map(|(st, en)| &s.signature[st+1..en])
|
||||||
|
.unwrap_or("");
|
||||||
|
let clean = args.split(',')
|
||||||
|
.map(|a| a.split(':').next().unwrap_or("").split('=').next().unwrap_or("").trim())
|
||||||
|
.filter(|a| !a.is_empty() && *a != "self" && *a != "cls" && !a.starts_with('*'))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
Some(clean)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
usage_examples.push(format!(
|
usage_examples.push(format!(
|
||||||
"from {} import {}\ninstance = {}()",
|
"from {} import {}\ninstance = {}({})",
|
||||||
module_id, short_name, short_name
|
module_id, short_name, short_name, init_args
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
SymbolKind::Method => {
|
SymbolKind::Method => {
|
||||||
@@ -451,9 +564,13 @@ impl Renderer {
|
|||||||
"has_db_integrations": !db_symbols.is_empty(),
|
"has_db_integrations": !db_symbols.is_empty(),
|
||||||
"has_http_integrations": !http_symbols.is_empty(),
|
"has_http_integrations": !http_symbols.is_empty(),
|
||||||
"has_queue_integrations": !queue_symbols.is_empty(),
|
"has_queue_integrations": !queue_symbols.is_empty(),
|
||||||
|
"has_storage_integrations": !storage_symbols.is_empty(),
|
||||||
|
"has_ai_integrations": !ai_symbols.is_empty(),
|
||||||
"db_symbols": db_symbols,
|
"db_symbols": db_symbols,
|
||||||
"http_symbols": http_symbols,
|
"http_symbols": http_symbols,
|
||||||
"queue_symbols": queue_symbols,
|
"queue_symbols": queue_symbols,
|
||||||
|
"storage_symbols": storage_symbols,
|
||||||
|
"ai_symbols": ai_symbols,
|
||||||
"usage_examples": usage_examples,
|
"usage_examples": usage_examples,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -466,6 +583,8 @@ impl Renderer {
|
|||||||
let mut db_integrations = Vec::new();
|
let mut db_integrations = Vec::new();
|
||||||
let mut http_integrations = Vec::new();
|
let mut http_integrations = Vec::new();
|
||||||
let mut queue_integrations = Vec::new();
|
let mut queue_integrations = Vec::new();
|
||||||
|
let mut storage_integrations = Vec::new();
|
||||||
|
let mut ai_integrations = Vec::new();
|
||||||
|
|
||||||
for (symbol_id, symbol) in &model.symbols {
|
for (symbol_id, symbol) in &model.symbols {
|
||||||
if symbol.integrations_flags.db {
|
if symbol.integrations_flags.db {
|
||||||
@@ -477,6 +596,12 @@ impl Renderer {
|
|||||||
if symbol.integrations_flags.queue {
|
if symbol.integrations_flags.queue {
|
||||||
queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
||||||
}
|
}
|
||||||
|
if symbol.integrations_flags.storage {
|
||||||
|
storage_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
||||||
|
}
|
||||||
|
if symbol.integrations_flags.ai {
|
||||||
|
ai_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare data for integrations section
|
// Prepare data for integrations section
|
||||||
@@ -484,6 +609,8 @@ impl Renderer {
|
|||||||
"db_integrations": db_integrations,
|
"db_integrations": db_integrations,
|
||||||
"http_integrations": http_integrations,
|
"http_integrations": http_integrations,
|
||||||
"queue_integrations": queue_integrations,
|
"queue_integrations": queue_integrations,
|
||||||
|
"storage_integrations": storage_integrations,
|
||||||
|
"ai_integrations": ai_integrations,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a smaller template just for the integrations section
|
// Create a smaller template just for the integrations section
|
||||||
@@ -503,6 +630,16 @@ impl Renderer {
|
|||||||
{{#each queue_integrations}}
|
{{#each queue_integrations}}
|
||||||
- {{{this}}}
|
- {{{this}}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
|
### Storage Integrations
|
||||||
|
{{#each storage_integrations}}
|
||||||
|
- {{{this}}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
### AI/ML Integrations
|
||||||
|
{{#each ai_integrations}}
|
||||||
|
- {{{this}}}
|
||||||
|
{{/each}}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let mut handlebars = Handlebars::new();
|
let mut handlebars = Handlebars::new();
|
||||||
@@ -519,15 +656,30 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_layout_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
pub fn render_layout_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
||||||
// Collect layout information from files
|
// Collect layout items grouped by top-level directory
|
||||||
let mut layout_items = Vec::new();
|
let mut dir_files: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
||||||
|
|
||||||
for file_doc in model.files.values() {
|
for file_doc in model.files.values() {
|
||||||
let purpose = file_doc.file_purpose.as_deref().unwrap_or("Source file");
|
let path = file_doc.path.strip_prefix("./").unwrap_or(&file_doc.path);
|
||||||
|
let top_dir = path.split('/').next().unwrap_or(path);
|
||||||
|
let top = if path.contains('/') {
|
||||||
|
format!("{}/", top_dir)
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
};
|
||||||
|
dir_files.entry(top).or_default().push(path.to_string());
|
||||||
|
}
|
||||||
|
let mut layout_items = Vec::new();
|
||||||
|
for (dir, files) in &dir_files {
|
||||||
|
let file_count = files.len();
|
||||||
|
let purpose = if dir.ends_with('/') {
|
||||||
|
format!("{} files", file_count)
|
||||||
|
} else {
|
||||||
|
"Root file".to_string()
|
||||||
|
};
|
||||||
layout_items.push(serde_json::json!({
|
layout_items.push(serde_json::json!({
|
||||||
"path": file_doc.path,
|
"path": dir,
|
||||||
"purpose": purpose,
|
"purpose": purpose,
|
||||||
"link": format!("docs/architecture/files/{}.md", sanitize_for_link(&file_doc.path))
|
"link": format!("docs/architecture/files/{}.md", sanitize_for_link(dir.trim_end_matches('/')))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +798,7 @@ impl Renderer {
|
|||||||
|
|
||||||
### Module Cycles
|
### Module Cycles
|
||||||
{{#each cycles}}
|
{{#each cycles}}
|
||||||
- {{{this}}}
|
- {{{cycle_path}}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
@@ -659,15 +811,30 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_layout_md(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
pub fn render_layout_md(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
||||||
// Collect layout information from files
|
// Collect layout items grouped by top-level directory
|
||||||
let mut layout_items = Vec::new();
|
let mut dir_files: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
||||||
|
|
||||||
for file_doc in model.files.values() {
|
for file_doc in model.files.values() {
|
||||||
let purpose = file_doc.file_purpose.as_deref().unwrap_or("Source file");
|
let path = file_doc.path.strip_prefix("./").unwrap_or(&file_doc.path);
|
||||||
|
let top_dir = path.split('/').next().unwrap_or(path);
|
||||||
|
let top = if path.contains('/') {
|
||||||
|
format!("{}/", top_dir)
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
};
|
||||||
|
dir_files.entry(top).or_default().push(path.to_string());
|
||||||
|
}
|
||||||
|
let mut layout_items = Vec::new();
|
||||||
|
for (dir, files) in &dir_files {
|
||||||
|
let file_count = files.len();
|
||||||
|
let purpose = if dir.ends_with('/') {
|
||||||
|
format!("{} files", file_count)
|
||||||
|
} else {
|
||||||
|
"Root file".to_string()
|
||||||
|
};
|
||||||
layout_items.push(serde_json::json!({
|
layout_items.push(serde_json::json!({
|
||||||
"path": file_doc.path,
|
"path": dir,
|
||||||
"purpose": purpose,
|
"purpose": purpose,
|
||||||
"link": format!("files/{}.md", sanitize_for_link(&file_doc.path))
|
"link": format!("files/{}.md", sanitize_for_link(dir.trim_end_matches('/')))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,6 +889,8 @@ impl Renderer {
|
|||||||
"http": symbol.integrations_flags.http,
|
"http": symbol.integrations_flags.http,
|
||||||
"db": symbol.integrations_flags.db,
|
"db": symbol.integrations_flags.db,
|
||||||
"queue": symbol.integrations_flags.queue,
|
"queue": symbol.integrations_flags.queue,
|
||||||
|
"storage": symbol.integrations_flags.storage,
|
||||||
|
"ai": symbol.integrations_flags.ai,
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"fan_in": symbol.metrics.fan_in,
|
"fan_in": symbol.metrics.fan_in,
|
||||||
@@ -764,6 +933,8 @@ impl Renderer {
|
|||||||
- HTTP: {{#if integrations.http}}yes{{else}}no{{/if}}
|
- HTTP: {{#if integrations.http}}yes{{else}}no{{/if}}
|
||||||
- DB: {{#if integrations.db}}yes{{else}}no{{/if}}
|
- DB: {{#if integrations.db}}yes{{else}}no{{/if}}
|
||||||
- Queue/Tasks: {{#if integrations.queue}}yes{{else}}no{{/if}}
|
- Queue/Tasks: {{#if integrations.queue}}yes{{else}}no{{/if}}
|
||||||
|
- Storage: {{#if integrations.storage}}yes{{else}}no{{/if}}
|
||||||
|
- AI/ML: {{#if integrations.ai}}yes{{else}}no{{/if}}
|
||||||
<!-- ARCHDOC:END section=integrations -->
|
<!-- ARCHDOC:END section=integrations -->
|
||||||
|
|
||||||
#### Risk / impact
|
#### Risk / impact
|
||||||
|
|||||||
@@ -98,17 +98,17 @@ fn test_enhanced_analysis_with_integrations() {
|
|||||||
assert!(found_advanced_module);
|
assert!(found_advanced_module);
|
||||||
|
|
||||||
// Check that we found the UserService class with DB integration
|
// Check that we found the UserService class with DB integration
|
||||||
let user_service_symbol = project_model.symbols.values().find(|s| s.id == "UserService");
|
let user_service_symbol = project_model.symbols.values().find(|s| s.id.ends_with("::UserService"));
|
||||||
assert!(user_service_symbol.is_some());
|
assert!(user_service_symbol.is_some());
|
||||||
assert_eq!(user_service_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Class);
|
assert_eq!(user_service_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Class);
|
||||||
|
|
||||||
// Check that we found the NotificationService class with queue integration
|
// Check that we found the NotificationService class with queue integration
|
||||||
let notification_service_symbol = project_model.symbols.values().find(|s| s.id == "NotificationService");
|
let notification_service_symbol = project_model.symbols.values().find(|s| s.id.ends_with("::NotificationService"));
|
||||||
assert!(notification_service_symbol.is_some());
|
assert!(notification_service_symbol.is_some());
|
||||||
assert_eq!(notification_service_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Class);
|
assert_eq!(notification_service_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Class);
|
||||||
|
|
||||||
// Check that we found the fetch_external_user_data function with HTTP integration
|
// Check that we found the fetch_external_user_data function with HTTP integration
|
||||||
let fetch_external_user_data_symbol = project_model.symbols.values().find(|s| s.id == "fetch_external_user_data");
|
let fetch_external_user_data_symbol = project_model.symbols.values().find(|s| s.id.ends_with("::fetch_external_user_data"));
|
||||||
assert!(fetch_external_user_data_symbol.is_some());
|
assert!(fetch_external_user_data_symbol.is_some());
|
||||||
assert_eq!(fetch_external_user_data_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Function);
|
assert_eq!(fetch_external_user_data_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Function);
|
||||||
|
|
||||||
|
|||||||
@@ -90,12 +90,12 @@ fn test_simple_project_generation() {
|
|||||||
assert!(found_example_module);
|
assert!(found_example_module);
|
||||||
|
|
||||||
// Check that we found the Calculator class
|
// Check that we found the Calculator class
|
||||||
let calculator_symbol = project_model.symbols.values().find(|s| s.id == "Calculator");
|
let calculator_symbol = project_model.symbols.values().find(|s| s.id.ends_with("::Calculator"));
|
||||||
assert!(calculator_symbol.is_some());
|
assert!(calculator_symbol.is_some());
|
||||||
assert_eq!(calculator_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Class);
|
assert_eq!(calculator_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Class);
|
||||||
|
|
||||||
// Check that we found the process_numbers function
|
// Check that we found the process_numbers function
|
||||||
let process_numbers_symbol = project_model.symbols.values().find(|s| s.id == "process_numbers");
|
let process_numbers_symbol = project_model.symbols.values().find(|s| s.id.ends_with("::process_numbers"));
|
||||||
assert!(process_numbers_symbol.is_some());
|
assert!(process_numbers_symbol.is_some());
|
||||||
assert_eq!(process_numbers_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Function);
|
assert_eq!(process_numbers_symbol.unwrap().kind, archdoc_core::model::SymbolKind::Function);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! Integration detection tests for ArchDoc
|
//! Integration detection tests for ArchDoc
|
||||||
//!
|
//!
|
||||||
//! These tests verify that the integration detection functionality works correctly.
|
//! These tests verify that the integration detection functionality works correctly.
|
||||||
|
//! Integration detection now happens at module level during resolve_symbols,
|
||||||
|
//! based on actual imports rather than AST body inspection.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -8,11 +10,12 @@ use archdoc_core::{Config, python_analyzer::PythonAnalyzer};
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_http_integration_detection() {
|
fn test_http_integration_detection() {
|
||||||
let config = Config::default();
|
let mut config = Config::default();
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
config.project.root = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
config.python.src_roots = vec![".".to_string()];
|
||||||
let analyzer = PythonAnalyzer::new(config);
|
let analyzer = PythonAnalyzer::new(config);
|
||||||
|
|
||||||
// Create a temporary Python file with HTTP integration
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let temp_file = temp_dir.path().join("test.py");
|
let temp_file = temp_dir.path().join("test.py");
|
||||||
let python_code = r#"
|
let python_code = r#"
|
||||||
import requests
|
import requests
|
||||||
@@ -23,16 +26,16 @@ def fetch_data():
|
|||||||
"#;
|
"#;
|
||||||
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
||||||
|
|
||||||
// Parse the module
|
|
||||||
let parsed_module = analyzer.parse_module(&temp_file)
|
let parsed_module = analyzer.parse_module(&temp_file)
|
||||||
.expect("Failed to parse module");
|
.expect("Failed to parse module");
|
||||||
|
|
||||||
// Check that we found the function
|
let model = analyzer.resolve_symbols(&[parsed_module])
|
||||||
assert_eq!(parsed_module.symbols.len(), 1);
|
.expect("Failed to resolve symbols");
|
||||||
let symbol = &parsed_module.symbols[0];
|
|
||||||
assert_eq!(symbol.id, "fetch_data");
|
// Find the symbol (now prefixed with module id)
|
||||||
|
let symbol = model.symbols.values().find(|s| s.qualname == "fetch_data")
|
||||||
|
.expect("fetch_data symbol not found");
|
||||||
|
|
||||||
// Check that HTTP integration is detected
|
|
||||||
assert!(symbol.integrations_flags.http);
|
assert!(symbol.integrations_flags.http);
|
||||||
assert!(!symbol.integrations_flags.db);
|
assert!(!symbol.integrations_flags.db);
|
||||||
assert!(!symbol.integrations_flags.queue);
|
assert!(!symbol.integrations_flags.queue);
|
||||||
@@ -40,11 +43,12 @@ def fetch_data():
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_db_integration_detection() {
|
fn test_db_integration_detection() {
|
||||||
let config = Config::default();
|
let mut config = Config::default();
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
config.project.root = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
config.python.src_roots = vec![".".to_string()];
|
||||||
let analyzer = PythonAnalyzer::new(config);
|
let analyzer = PythonAnalyzer::new(config);
|
||||||
|
|
||||||
// Create a temporary Python file with DB integration
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let temp_file = temp_dir.path().join("test.py");
|
let temp_file = temp_dir.path().join("test.py");
|
||||||
let python_code = r#"
|
let python_code = r#"
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -57,16 +61,15 @@ def get_user(user_id):
|
|||||||
"#;
|
"#;
|
||||||
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
||||||
|
|
||||||
// Parse the module
|
|
||||||
let parsed_module = analyzer.parse_module(&temp_file)
|
let parsed_module = analyzer.parse_module(&temp_file)
|
||||||
.expect("Failed to parse module");
|
.expect("Failed to parse module");
|
||||||
|
|
||||||
// Check that we found the function
|
let model = analyzer.resolve_symbols(&[parsed_module])
|
||||||
assert_eq!(parsed_module.symbols.len(), 1);
|
.expect("Failed to resolve symbols");
|
||||||
let symbol = &parsed_module.symbols[0];
|
|
||||||
assert_eq!(symbol.id, "get_user");
|
let symbol = model.symbols.values().find(|s| s.qualname == "get_user")
|
||||||
|
.expect("get_user symbol not found");
|
||||||
|
|
||||||
// Check that DB integration is detected
|
|
||||||
assert!(!symbol.integrations_flags.http);
|
assert!(!symbol.integrations_flags.http);
|
||||||
assert!(symbol.integrations_flags.db);
|
assert!(symbol.integrations_flags.db);
|
||||||
assert!(!symbol.integrations_flags.queue);
|
assert!(!symbol.integrations_flags.queue);
|
||||||
@@ -74,11 +77,12 @@ def get_user(user_id):
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_queue_integration_detection() {
|
fn test_queue_integration_detection() {
|
||||||
let config = Config::default();
|
let mut config = Config::default();
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
config.project.root = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
config.python.src_roots = vec![".".to_string()];
|
||||||
let analyzer = PythonAnalyzer::new(config);
|
let analyzer = PythonAnalyzer::new(config);
|
||||||
|
|
||||||
// Create a temporary Python file with queue integration
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let temp_file = temp_dir.path().join("test.py");
|
let temp_file = temp_dir.path().join("test.py");
|
||||||
let python_code = r#"
|
let python_code = r#"
|
||||||
import redis
|
import redis
|
||||||
@@ -89,16 +93,15 @@ def process_job(job_data):
|
|||||||
"#;
|
"#;
|
||||||
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
||||||
|
|
||||||
// Parse the module
|
|
||||||
let parsed_module = analyzer.parse_module(&temp_file)
|
let parsed_module = analyzer.parse_module(&temp_file)
|
||||||
.expect("Failed to parse module");
|
.expect("Failed to parse module");
|
||||||
|
|
||||||
// Check that we found the function
|
let model = analyzer.resolve_symbols(&[parsed_module])
|
||||||
assert_eq!(parsed_module.symbols.len(), 1);
|
.expect("Failed to resolve symbols");
|
||||||
let symbol = &parsed_module.symbols[0];
|
|
||||||
assert_eq!(symbol.id, "process_job");
|
let symbol = model.symbols.values().find(|s| s.qualname == "process_job")
|
||||||
|
.expect("process_job symbol not found");
|
||||||
|
|
||||||
// Check that queue integration is detected
|
|
||||||
assert!(!symbol.integrations_flags.http);
|
assert!(!symbol.integrations_flags.http);
|
||||||
assert!(!symbol.integrations_flags.db);
|
assert!(!symbol.integrations_flags.db);
|
||||||
assert!(symbol.integrations_flags.queue);
|
assert!(symbol.integrations_flags.queue);
|
||||||
@@ -106,11 +109,12 @@ def process_job(job_data):
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_no_integration_detection() {
|
fn test_no_integration_detection() {
|
||||||
let config = Config::default();
|
let mut config = Config::default();
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
config.project.root = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
config.python.src_roots = vec![".".to_string()];
|
||||||
let analyzer = PythonAnalyzer::new(config);
|
let analyzer = PythonAnalyzer::new(config);
|
||||||
|
|
||||||
// Create a temporary Python file with no integrations
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let temp_file = temp_dir.path().join("test.py");
|
let temp_file = temp_dir.path().join("test.py");
|
||||||
let python_code = r#"
|
let python_code = r#"
|
||||||
def calculate_sum(a, b):
|
def calculate_sum(a, b):
|
||||||
@@ -118,17 +122,16 @@ def calculate_sum(a, b):
|
|||||||
"#;
|
"#;
|
||||||
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
fs::write(&temp_file, python_code).expect("Failed to write test file");
|
||||||
|
|
||||||
// Parse the module
|
|
||||||
let parsed_module = analyzer.parse_module(&temp_file)
|
let parsed_module = analyzer.parse_module(&temp_file)
|
||||||
.expect("Failed to parse module");
|
.expect("Failed to parse module");
|
||||||
|
|
||||||
// Check that we found the function
|
let model = analyzer.resolve_symbols(&[parsed_module])
|
||||||
assert_eq!(parsed_module.symbols.len(), 1);
|
.expect("Failed to resolve symbols");
|
||||||
let symbol = &parsed_module.symbols[0];
|
|
||||||
assert_eq!(symbol.id, "calculate_sum");
|
let symbol = model.symbols.values().find(|s| s.qualname == "calculate_sum")
|
||||||
|
.expect("calculate_sum symbol not found");
|
||||||
|
|
||||||
// Check that no integrations are detected
|
|
||||||
assert!(!symbol.integrations_flags.http);
|
assert!(!symbol.integrations_flags.http);
|
||||||
assert!(!symbol.integrations_flags.db);
|
assert!(!symbol.integrations_flags.db);
|
||||||
assert!(!symbol.integrations_flags.queue);
|
assert!(!symbol.integrations_flags.queue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ fn test_render_with_integrations() {
|
|||||||
db: true,
|
db: true,
|
||||||
http: false,
|
http: false,
|
||||||
queue: false,
|
queue: false,
|
||||||
|
storage: false,
|
||||||
|
ai: false,
|
||||||
},
|
},
|
||||||
metrics: SymbolMetrics {
|
metrics: SymbolMetrics {
|
||||||
fan_in: 0,
|
fan_in: 0,
|
||||||
@@ -54,6 +56,8 @@ fn test_render_with_integrations() {
|
|||||||
db: false,
|
db: false,
|
||||||
http: true,
|
http: true,
|
||||||
queue: false,
|
queue: false,
|
||||||
|
storage: false,
|
||||||
|
ai: false,
|
||||||
},
|
},
|
||||||
metrics: SymbolMetrics {
|
metrics: SymbolMetrics {
|
||||||
fan_in: 0,
|
fan_in: 0,
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ No tooling information available.
|
|||||||
|
|
||||||
| Module | Symbols | Inbound | Outbound | Link |
|
| Module | Symbols | Inbound | Outbound | Link |
|
||||||
|--------|---------|---------|----------|------|
|
|--------|---------|---------|----------|------|
|
||||||
| core | 6 | 0 | 0 | [details](docs/architecture/modules/core.md) |
|
|
||||||
| utils | 4 | 0 | 0 | [details](docs/architecture/modules/utils.md) |
|
| utils | 4 | 0 | 0 | [details](docs/architecture/modules/utils.md) |
|
||||||
| src | 0 | 0 | 0 | [details](docs/architecture/modules/src.md) |
|
| src | 0 | 0 | 0 | [details](docs/architecture/modules/src.md) |
|
||||||
|
| core | 6 | 0 | 0 | [details](docs/architecture/modules/core.md) |
|
||||||
<!-- ARCHDOC:END section=modules_index -->
|
<!-- ARCHDOC:END section=modules_index -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user