feat: implement all P1/P2/P3/P4 improvements from issue backlog
Some checks failed
CI / Check / Test (push) Failing after 1m33s
Architecture Docs / Generate diagrams (push) Successful in 3m21s

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:
2026-06-17 09:50:50 +02:00
parent 27197062eb
commit fdd85011a4
42 changed files with 2767 additions and 92 deletions

View 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");
}