feat: implement all P1/P2/P3/P4 improvements from issue backlog
P1 correctness: - filter test files by default (--include-tests to opt in) - per-module diagrams show cross-module dependency arrows - qualified type names (Module::TypeName) fix false edges from duplicate names P2 output richness: - method parameter types and return types in class diagrams (Rust + Python) - Python pyproject.toml project analyzer (--level project for monorepos) P3 unique value: - boundary rules in archlens.toml ([rules] allow/deny, --strict enforcement) P4 nice to have: - dependency weight labels on module arrows (--no-weights to disable) - --watch mode with 500ms debounce - D2 renderer adapter (--format d2) - interactive self-contained HTML viewer (--format html) - git-aware incremental analysis (--since <ref>)
This commit is contained in:
13
crates/adapters/python-project/Cargo.toml
Normal file
13
crates/adapters/python-project/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "archlens-python-project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
archlens-domain.workspace = true
|
||||
toml.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
3
crates/adapters/python-project/src/lib.rs
Normal file
3
crates/adapters/python-project/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod python_project_analyzer;
|
||||
|
||||
pub use python_project_analyzer::PythonProjectAnalyzer;
|
||||
151
crates/adapters/python-project/src/python_project_analyzer.rs
Normal file
151
crates/adapters/python-project/src/python_project_analyzer.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use archlens_domain::{
|
||||
CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, Relationship, RelationshipKind,
|
||||
ports::ProjectAnalyzer,
|
||||
};
|
||||
|
||||
pub struct PythonProjectAnalyzer;
|
||||
|
||||
impl Default for PythonProjectAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PythonProjectAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
// PEP 621 format
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ProjectSection {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
// Poetry format
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PoetrySection {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
dependencies: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ToolSection {
|
||||
#[serde(default)]
|
||||
poetry: PoetrySection,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PyprojectToml {
|
||||
project: Option<ProjectSection>,
|
||||
#[serde(default)]
|
||||
tool: ToolSection,
|
||||
}
|
||||
|
||||
fn extract_dep_name(dep: &str) -> &str {
|
||||
dep.split(&['>', '<', '=', '!', '[', ';', ' '][..])
|
||||
.next()
|
||||
.unwrap_or(dep)
|
||||
.trim()
|
||||
}
|
||||
|
||||
fn normalize(name: &str) -> String {
|
||||
name.to_lowercase().replace(['-', '.'], "_")
|
||||
}
|
||||
|
||||
impl ProjectAnalyzer for PythonProjectAnalyzer {
|
||||
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError> {
|
||||
// 1. Scan immediate subdirectories for pyproject.toml
|
||||
let entries = std::fs::read_dir(root).map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
|
||||
let mut packages: Vec<(String, String, Vec<String>)> = Vec::new(); // (dir, name, deps)
|
||||
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let pyproject = path.join("pyproject.toml");
|
||||
if !pyproject.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&pyproject)
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
let parsed: PyprojectToml =
|
||||
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
|
||||
|
||||
let dir_name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Try PEP 621 [project] first, then Poetry [tool.poetry]
|
||||
let (name, deps) = if let Some(proj) = parsed.project {
|
||||
let name = proj.name.unwrap_or_else(|| dir_name.clone());
|
||||
let deps: Vec<String> = proj
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|d| extract_dep_name(d).to_string())
|
||||
.collect();
|
||||
(name, deps)
|
||||
} else if let Some(pname) = parsed.tool.poetry.name {
|
||||
let deps: Vec<String> = parsed
|
||||
.tool
|
||||
.poetry
|
||||
.dependencies
|
||||
.keys()
|
||||
.filter(|k| k.as_str() != "python")
|
||||
.cloned()
|
||||
.collect();
|
||||
(pname, deps)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
packages.push((dir_name, name, deps));
|
||||
}
|
||||
|
||||
let known: HashSet<String> = packages
|
||||
.iter()
|
||||
.map(|(_, name, _)| normalize(name))
|
||||
.collect();
|
||||
|
||||
let mut graph = CodeGraph::new();
|
||||
|
||||
for (dir, name, _) in &packages {
|
||||
let file_path = FilePath::new(&format!("{}/pyproject.toml", dir))
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
let element = CodeElement::new(name, CodeElementKind::Project, file_path, 1)?;
|
||||
graph.add_element(element);
|
||||
}
|
||||
|
||||
for (_, pkg_name, deps) in &packages {
|
||||
for dep in deps {
|
||||
let dep_norm = normalize(dep);
|
||||
if known.contains(&dep_norm) && dep_norm != normalize(pkg_name) {
|
||||
// find the canonical name (original casing) of the dep
|
||||
if let Some((_, canonical, _)) =
|
||||
packages.iter().find(|(_, n, _)| normalize(n) == dep_norm)
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(pkg_name, canonical, RelationshipKind::Composition)
|
||||
{
|
||||
graph.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
}
|
||||
165
crates/adapters/python-project/tests/python_project_tests.rs
Normal file
165
crates/adapters/python-project/tests/python_project_tests.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use std::fs;
|
||||
|
||||
use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer};
|
||||
use archlens_python_project::PythonProjectAnalyzer;
|
||||
|
||||
fn create_monorepo(dir: &std::path::Path) {
|
||||
fs::create_dir_all(dir.join("api")).unwrap();
|
||||
fs::create_dir_all(dir.join("commons")).unwrap();
|
||||
fs::create_dir_all(dir.join("worker")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("api/pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
name = "my-api"
|
||||
dependencies = [
|
||||
"my-commons>=1.0",
|
||||
"fastapi",
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("commons/pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
name = "my-commons"
|
||||
dependencies = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("worker/pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
name = "my-worker"
|
||||
dependencies = [
|
||||
"my-commons>=1.0",
|
||||
"celery",
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_python_packages_as_project_elements() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
assert_eq!(graph.elements().len(), 3);
|
||||
assert!(
|
||||
graph
|
||||
.elements()
|
||||
.iter()
|
||||
.all(|e| e.kind() == CodeElementKind::Project)
|
||||
);
|
||||
|
||||
let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect();
|
||||
assert!(names.contains(&"my-api"));
|
||||
assert!(names.contains(&"my-commons"));
|
||||
assert!(names.contains(&"my-worker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_intra_project_dependencies_from_pep621() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
let deps: Vec<(&str, &str)> = graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
deps.contains(&("my-api", "my-commons")),
|
||||
"missing api->commons: {deps:?}"
|
||||
);
|
||||
assert!(
|
||||
deps.contains(&("my-worker", "my-commons")),
|
||||
"missing worker->commons: {deps:?}"
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.all(|r| r.kind() == RelationshipKind::Composition)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_external_dependencies() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect();
|
||||
assert!(!targets.contains(&"fastapi"), "fastapi should be excluded");
|
||||
assert!(!targets.contains(&"celery"), "celery should be excluded");
|
||||
}
|
||||
|
||||
fn create_poetry_monorepo(dir: &std::path::Path) {
|
||||
fs::create_dir_all(dir.join("api")).unwrap();
|
||||
fs::create_dir_all(dir.join("commons")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("api/pyproject.toml"),
|
||||
r#"
|
||||
[tool.poetry]
|
||||
name = "my-api"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
my-commons = {path = "../commons"}
|
||||
httpx = "^0.27"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("commons/pyproject.toml"),
|
||||
r#"
|
||||
[tool.poetry]
|
||||
name = "my-commons"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_intra_project_dependencies_from_poetry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_poetry_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
assert_eq!(graph.elements().len(), 2);
|
||||
let deps: Vec<(&str, &str)> = graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.collect();
|
||||
assert!(
|
||||
deps.contains(&("my-api", "my-commons")),
|
||||
"missing api->commons: {deps:?}"
|
||||
);
|
||||
|
||||
let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect();
|
||||
assert!(!targets.contains(&"httpx"), "httpx should be excluded");
|
||||
}
|
||||
Reference in New Issue
Block a user