2 Commits

Author SHA1 Message Date
8e72f140d2 Merge pull request 'feat: major improvements — layout, cycles, integrations, usage examples, tests' (#1) from feature/improvements-v2 into main
Reviewed-on: #1
2026-02-15 11:21:46 +03:00
a3ee003947 fix: all 4 archdoc issues — cycles, layout, integrations, usage examples
1. Module Cycles: properly format cycle paths as A → B → C → A
2. Repository layout: group by top-level directory with file counts
3. Integration detection: match patterns against import names (substring),
   add Storage and AI/ML categories to all templates and summary
4. Usage examples: extract __init__ required params for class constructors

Also fix golden test to use ends_with for module-prefixed symbol IDs.
2026-02-15 11:14:42 +03:00
10 changed files with 314 additions and 89 deletions

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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)]

View File

@@ -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;
} }
} }

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,16 +122,15 @@ 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);

View File

@@ -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,

View File

@@ -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 -->
--- ---