Compare commits
4 Commits
d9457018fd
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c93cbfb3a | |||
| 0396a53e0c | |||
| 0617f24744 | |||
| 1229235ac7 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@
|
||||
PLANS/
|
||||
target/
|
||||
.wtismycode/
|
||||
docs/
|
||||
ARCHITECTURE.md
|
||||
|
||||
@@ -19,14 +19,13 @@ fn detect_project_name(root: &str) -> String {
|
||||
in_project = false;
|
||||
continue;
|
||||
}
|
||||
if in_project && trimmed.starts_with("name") {
|
||||
if let Some(val) = trimmed.split('=').nth(1) {
|
||||
if in_project && trimmed.starts_with("name")
|
||||
&& let Some(val) = trimmed.split('=').nth(1) {
|
||||
let name = val.trim().trim_matches('"').trim_matches('\'');
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.9.11+spec-1.1.0"
|
||||
toml = "0.9.11"
|
||||
tracing = "0.1"
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -114,8 +114,8 @@ fn deduplicate_cycles(cycles: Vec<Vec<String>>) -> Vec<Vec<String>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Edges, Module, ProjectModel};
|
||||
use std::collections::HashMap;
|
||||
use crate::model::{Module, ProjectModel};
|
||||
|
||||
|
||||
fn make_module(id: &str, outbound: Vec<&str>) -> Module {
|
||||
Module {
|
||||
|
||||
@@ -23,7 +23,7 @@ pub use model::ProjectModel;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
|
||||
@@ -180,11 +180,10 @@ impl PackageClassifier {
|
||||
|
||||
fn load_pypi_cache(&mut self, dir: &str) {
|
||||
let cache_path = Path::new(dir).join("pypi.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&cache_path) {
|
||||
if let Ok(cache) = serde_json::from_str::<HashMap<String, Option<PackageCategory>>>(&content) {
|
||||
if let Ok(content) = std::fs::read_to_string(&cache_path)
|
||||
&& let Ok(cache) = serde_json::from_str::<HashMap<String, Option<PackageCategory>>>(&content) {
|
||||
self.pypi_cache = cache;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pypi_lookup(&self, package_name: &str) -> Option<PackageCategory> {
|
||||
@@ -209,11 +208,10 @@ impl PackageClassifier {
|
||||
// Check classifiers
|
||||
if let Some(classifiers) = info.get("classifiers").and_then(|c: &serde_json::Value| c.as_array()) {
|
||||
for classifier in classifiers {
|
||||
if let Some(s) = classifier.as_str() {
|
||||
if let Some(cat) = classify_from_pypi_classifier(s) {
|
||||
if let Some(s) = classifier.as_str()
|
||||
&& let Some(cat) = classify_from_pypi_classifier(s) {
|
||||
return Some(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -868,11 +868,20 @@ impl PythonAnalyzer {
|
||||
}
|
||||
|
||||
for parsed_module in parsed_modules {
|
||||
let module_id = self.compute_module_path(&parsed_module.path);
|
||||
for call in &parsed_module.calls {
|
||||
let callee_expr = call.callee_expr.clone();
|
||||
// Qualify from_id with module to match symbol IDs (module::symbol)
|
||||
let from_id = format!("{}::{}", module_id, call.caller_symbol);
|
||||
|
||||
// Try to resolve callee to a qualified symbol ID
|
||||
// If callee_expr is "module.func", try to find it as "resolved_module::func"
|
||||
let to_id = self.resolve_callee_to_symbol_id(
|
||||
&call.callee_expr, &module_id, project_model
|
||||
);
|
||||
|
||||
let edge = crate::model::Edge {
|
||||
from_id: call.caller_symbol.clone(),
|
||||
to_id: callee_expr,
|
||||
from_id,
|
||||
to_id,
|
||||
edge_type: crate::model::EdgeType::SymbolCall,
|
||||
meta: None,
|
||||
};
|
||||
@@ -883,6 +892,34 @@ impl PythonAnalyzer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a callee expression to a qualified symbol ID.
|
||||
/// E.g., "SomeClass.method" or "func" -> "module::func"
|
||||
fn resolve_callee_to_symbol_id(&self, callee_expr: &str, from_module: &str, model: &ProjectModel) -> String {
|
||||
// First try: exact match as qualified ID in the same module
|
||||
let same_module_id = format!("{}::{}", from_module, callee_expr);
|
||||
if model.symbols.contains_key(&same_module_id) {
|
||||
return same_module_id;
|
||||
}
|
||||
|
||||
// Try: callee might be "func" and exist in another module via imports
|
||||
// Check all symbols for a match on the bare name
|
||||
let parts: Vec<&str> = callee_expr.splitn(2, '.').collect();
|
||||
let bare_name = parts[0];
|
||||
|
||||
// Look through imports of from_module to find resolved target
|
||||
if let Some(module) = model.modules.get(from_module) {
|
||||
for outbound in &module.outbound_modules {
|
||||
let candidate = format!("{}::{}", outbound, bare_name);
|
||||
if model.symbols.contains_key(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return qualified with current module
|
||||
same_module_id
|
||||
}
|
||||
|
||||
/// Check if a class symbol is a simple data container (dataclass-like).
|
||||
/// A class is considered a dataclass if it has ≤2 methods (typically __init__ and __repr__/__str__).
|
||||
fn is_dataclass_like(symbol_id: &str, project_model: &ProjectModel) -> bool {
|
||||
|
||||
@@ -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
|
||||
<!-- ARCHDOC:BEGIN section=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}}
|
||||
<!-- ARCHDOC:END section=modules_index -->
|
||||
|
||||
@@ -242,16 +249,22 @@ impl Renderer {
|
||||
|
||||
pub fn render_architecture_md(&self, model: &ProjectModel, config: Option<&Config>) -> Result<String, anyhow::Error> {
|
||||
// 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();
|
||||
for cat_name in &category_order {
|
||||
if let Some(pkgs) = model.classified_integrations.get(*cat_name) {
|
||||
if !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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +294,13 @@ impl Renderer {
|
||||
in_project = false;
|
||||
continue;
|
||||
}
|
||||
if in_project && trimmed.starts_with("name") {
|
||||
if let Some(val) = trimmed.split('=').nth(1) {
|
||||
if in_project && trimmed.starts_with("name")
|
||||
&& 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
|
||||
})
|
||||
@@ -335,38 +347,32 @@ impl Renderer {
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect module items for template
|
||||
let mut modules_list = Vec::new();
|
||||
for (module_id, module) in &model.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
|
||||
let mut high_fan_in = Vec::new();
|
||||
let mut high_fan_out = Vec::new();
|
||||
// Collect critical points as tuples (count, symbol_id, is_critical) for sorting
|
||||
let mut fan_in_tuples: Vec<(usize, &str, bool)> = Vec::new();
|
||||
let mut fan_out_tuples: Vec<(usize, &str, bool)> = Vec::new();
|
||||
for (symbol_id, symbol) in &model.symbols {
|
||||
if symbol.metrics.fan_in > 5 {
|
||||
high_fan_in.push(serde_json::json!({
|
||||
"symbol": symbol_id,
|
||||
"count": symbol.metrics.fan_in,
|
||||
"critical": symbol.metrics.is_critical,
|
||||
}));
|
||||
fan_in_tuples.push((symbol.metrics.fan_in, symbol_id, symbol.metrics.is_critical));
|
||||
}
|
||||
if symbol.metrics.fan_out > 5 {
|
||||
high_fan_out.push(serde_json::json!({
|
||||
"symbol": symbol_id,
|
||||
"count": symbol.metrics.fan_out,
|
||||
"critical": symbol.metrics.is_critical,
|
||||
}));
|
||||
fan_out_tuples.push((symbol.metrics.fan_out, symbol_id, symbol.metrics.is_critical));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count descending
|
||||
fan_in_tuples.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
fan_out_tuples.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
let high_fan_in: Vec<_> = fan_in_tuples.iter().map(|(count, sym, crit)| {
|
||||
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
|
||||
}).collect();
|
||||
let high_fan_out: Vec<_> = fan_out_tuples.iter().map(|(count, sym, crit)| {
|
||||
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
|
||||
}).collect();
|
||||
|
||||
let cycles: Vec<_> = cycle_detector::detect_cycles(model)
|
||||
.iter()
|
||||
.map(|cycle| {
|
||||
@@ -394,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,
|
||||
@@ -547,16 +553,21 @@ impl Renderer {
|
||||
}
|
||||
|
||||
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();
|
||||
for cat_name in &category_order {
|
||||
if let Some(pkgs) = model.classified_integrations.get(*cat_name) {
|
||||
if !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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,31 +651,23 @@ impl Renderer {
|
||||
}
|
||||
|
||||
pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
||||
// Collect module information
|
||||
let mut modules = Vec::new();
|
||||
let module_groups = Self::build_module_groups(model);
|
||||
|
||||
for (module_id, module) in &model.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}}
|
||||
"#;
|
||||
|
||||
@@ -677,27 +680,29 @@ impl Renderer {
|
||||
}
|
||||
|
||||
pub fn render_critical_points_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
||||
// Collect critical points information
|
||||
let mut high_fan_in = Vec::new();
|
||||
let mut high_fan_out = Vec::new();
|
||||
// Collect and sort critical points by count descending
|
||||
let mut fan_in_items: Vec<(usize, &str, bool)> = Vec::new();
|
||||
let mut fan_out_items: Vec<(usize, &str, bool)> = Vec::new();
|
||||
|
||||
for (symbol_id, symbol) in &model.symbols {
|
||||
if symbol.metrics.fan_in > 5 { // Threshold for high fan-in
|
||||
high_fan_in.push(serde_json::json!({
|
||||
"symbol": symbol_id,
|
||||
"count": symbol.metrics.fan_in,
|
||||
"critical": symbol.metrics.is_critical,
|
||||
}));
|
||||
if symbol.metrics.fan_in > 5 {
|
||||
fan_in_items.push((symbol.metrics.fan_in, symbol_id, symbol.metrics.is_critical));
|
||||
}
|
||||
if symbol.metrics.fan_out > 5 { // Threshold for high fan-out
|
||||
high_fan_out.push(serde_json::json!({
|
||||
"symbol": symbol_id,
|
||||
"count": symbol.metrics.fan_out,
|
||||
"critical": symbol.metrics.is_critical,
|
||||
}));
|
||||
if symbol.metrics.fan_out > 5 {
|
||||
fan_out_items.push((symbol.metrics.fan_out, symbol_id, symbol.metrics.is_critical));
|
||||
}
|
||||
}
|
||||
|
||||
fan_in_items.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
fan_out_items.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
let high_fan_in: Vec<_> = fan_in_items.iter().map(|(count, sym, crit)| {
|
||||
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
|
||||
}).collect();
|
||||
let high_fan_out: Vec<_> = fan_out_items.iter().map(|(count, sym, crit)| {
|
||||
serde_json::json!({"symbol": sym, "count": count, "critical": crit})
|
||||
}).collect();
|
||||
|
||||
// Prepare data for critical points section
|
||||
let data = serde_json::json!({
|
||||
"high_fan_in": high_fan_in,
|
||||
@@ -805,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<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> {
|
||||
// Find the symbol in the project model
|
||||
let symbol = model.symbols.get(symbol_id)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! These tests verify that the caching functionality works correctly.
|
||||
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use wtismycode_core::{Config, python_analyzer::PythonAnalyzer};
|
||||
|
||||
76
wtismycode-core/tests/callee_resolution.rs
Normal file
76
wtismycode-core/tests/callee_resolution.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Tests for resolve_callee_to_symbol_id functionality
|
||||
//!
|
||||
//! Verifies that call expressions are correctly resolved to qualified symbol IDs.
|
||||
|
||||
use std::path::Path;
|
||||
use wtismycode_core::{Config, scanner::FileScanner, python_analyzer::PythonAnalyzer};
|
||||
|
||||
#[test]
|
||||
fn test_resolve_callee_to_symbol_id() {
|
||||
let config_path = "tests/golden/test_project/wtismycode.toml";
|
||||
let config = Config::load_from_file(Path::new(config_path)).expect("Failed to load config");
|
||||
let project_root = Path::new("tests/golden/test_project");
|
||||
let scanner = FileScanner::new(config.clone());
|
||||
let python_files = scanner.scan_python_files(project_root).expect("Failed to scan");
|
||||
let analyzer = PythonAnalyzer::new(config);
|
||||
|
||||
let mut parsed_modules = Vec::new();
|
||||
for file_path in python_files {
|
||||
parsed_modules.push(analyzer.parse_module(&file_path).expect("Failed to parse"));
|
||||
}
|
||||
|
||||
let model = analyzer.resolve_symbols(&parsed_modules).expect("Failed to resolve");
|
||||
|
||||
// Verify that symbol call edges exist and have been resolved
|
||||
assert!(!model.edges.symbol_call_edges.is_empty(), "Should have symbol call edges");
|
||||
|
||||
// Check that at least some edges reference known symbols (resolved correctly)
|
||||
let resolved_count = model.edges.symbol_call_edges.iter()
|
||||
.filter(|edge| model.symbols.contains_key(&edge.to_id))
|
||||
.count();
|
||||
|
||||
println!("Total call edges: {}", model.edges.symbol_call_edges.len());
|
||||
println!("Resolved to known symbols: {}", resolved_count);
|
||||
|
||||
// At least some calls should resolve to known symbols
|
||||
assert!(resolved_count > 0, "At least some calls should resolve to known symbol IDs");
|
||||
|
||||
// Verify that same-module calls are resolved with module:: prefix
|
||||
for edge in &model.edges.symbol_call_edges {
|
||||
assert!(edge.from_id.contains("::"), "from_id should be qualified: {}", edge.from_id);
|
||||
// to_id should also be qualified (module::symbol format)
|
||||
assert!(edge.to_id.contains("::"), "to_id should be qualified: {}", edge.to_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_callee_resolution_cross_module() {
|
||||
let config_path = "tests/golden/test_project/wtismycode.toml";
|
||||
let config = Config::load_from_file(Path::new(config_path)).expect("Failed to load config");
|
||||
let project_root = Path::new("tests/golden/test_project");
|
||||
let scanner = FileScanner::new(config.clone());
|
||||
let python_files = scanner.scan_python_files(project_root).expect("Failed to scan");
|
||||
let analyzer = PythonAnalyzer::new(config);
|
||||
|
||||
let mut parsed_modules = Vec::new();
|
||||
for file_path in python_files {
|
||||
parsed_modules.push(analyzer.parse_module(&file_path).expect("Failed to parse"));
|
||||
}
|
||||
|
||||
let model = analyzer.resolve_symbols(&parsed_modules).expect("Failed to resolve");
|
||||
|
||||
// Check that modules have outbound/inbound relationships
|
||||
let modules_with_outbound = model.modules.values()
|
||||
.filter(|m| !m.outbound_modules.is_empty())
|
||||
.count();
|
||||
|
||||
println!("Modules with outbound deps: {}", modules_with_outbound);
|
||||
|
||||
// Verify fan-in/fan-out metrics were computed
|
||||
let symbols_with_metrics = model.symbols.values()
|
||||
.filter(|s| s.metrics.fan_in > 0 || s.metrics.fan_out > 0)
|
||||
.count();
|
||||
|
||||
println!("Symbols with non-zero metrics: {}", symbols_with_metrics);
|
||||
assert!(symbols_with_metrics > 0, "Some symbols should have fan-in or fan-out > 0");
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
//! These tests verify that the enhanced analysis functionality works correctly
|
||||
//! with complex code that includes integrations, calls, and docstrings.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use wtismycode_core::{Config, scanner::FileScanner, python_analyzer::PythonAnalyzer};
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ fn test_cycle_detection_no_cycles() {
|
||||
|
||||
#[test]
|
||||
fn test_renderer_produces_output() {
|
||||
let config = Config::default();
|
||||
let _config = Config::default();
|
||||
let model = ProjectModel::new();
|
||||
let renderer = Renderer::new();
|
||||
let result = renderer.render_architecture_md(&model, None);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
mod test_utils;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use wtismycode_core::{Config, scanner::FileScanner, python_analyzer::PythonAnalyzer};
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
//! Test utilities for golden tests
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Read a file and return its contents
|
||||
#[allow(dead_code)]
|
||||
pub fn read_test_file(path: &str) -> String {
|
||||
fs::read_to_string(path).expect(&format!("Failed to read test file: {}", path))
|
||||
fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read test file: {}", path))
|
||||
}
|
||||
|
||||
/// Write content to a file for testing
|
||||
#[allow(dead_code)]
|
||||
pub fn write_test_file(path: &str, content: &str) {
|
||||
fs::write(path, content).expect(&format!("Failed to write test file: {}", path))
|
||||
fs::write(path, content).unwrap_or_else(|_| panic!("Failed to write test file: {}", path))
|
||||
}
|
||||
|
||||
/// Compare two strings and panic if they don't match
|
||||
#[allow(dead_code)]
|
||||
pub fn assert_strings_equal(actual: &str, expected: &str, message: &str) {
|
||||
if actual != expected {
|
||||
panic!("{}: Strings do not match\nActual:\n{}\nExpected:\n{}", message, actual, expected);
|
||||
|
||||
@@ -35,7 +35,7 @@ fn test_project_analysis() {
|
||||
|
||||
// Integration flags are now set during resolve_symbols, not parse_module
|
||||
// So we resolve and check there
|
||||
let project_model = analyzer.resolve_symbols(&[core_module.clone()]).unwrap();
|
||||
let project_model = analyzer.resolve_symbols(std::slice::from_ref(&core_module)).unwrap();
|
||||
let db_integration_found = project_model.symbols.values().any(|s| s.integrations_flags.db);
|
||||
let http_integration_found = project_model.symbols.values().any(|s| s.integrations_flags.http);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user