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,11 @@
[package]
name = "archlens-d2"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,173 @@
use archlens_domain::{
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
};
pub struct D2Renderer {
level: DiagramLevel,
}
impl Default for D2Renderer {
fn default() -> Self {
Self::new()
}
}
impl D2Renderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
}
impl DiagramRenderer for D2Renderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => render_type(graph),
DiagramLevel::Module => render_module(graph),
DiagramLevel::Project => render_project(graph),
};
let file = RenderedFile::new("diagram.d2", &content)?;
Ok(RenderOutput::single(file))
}
}
fn sanitize(name: &str) -> String {
name.replace("::", "_").replace(['-', ' '], "_")
}
fn render_type(graph: &CodeGraph) -> String {
let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module();
// Grouped by module
for (module, elements) in &by_module {
let mod_id = sanitize(module);
lines.push(format!("{mod_id}: {{"));
for el in elements {
let el_id = sanitize(el.name());
lines.push(format!(" {el_id}: {{"));
lines.push(" shape: class".to_string());
for field in el.fields() {
lines.push(format!(" {field}"));
}
for method in el.methods() {
let method_display = method.trim_start_matches(['+', '-']);
lines.push(format!(
" {}()",
method_display.split('(').next().unwrap_or(method_display)
));
}
lines.push(" }".to_string());
}
lines.push("}".to_string());
}
// Ungrouped elements
for el in &ungrouped {
let el_id = sanitize(el.name());
lines.push(format!("{el_id}: {{"));
lines.push(" shape: class".to_string());
lines.push("}".to_string());
}
// Relationships
for rel in graph.relationships() {
use archlens_domain::RelationshipKind;
let src = sanitize(rel.source());
let tgt = sanitize(rel.target());
let arrow = match rel.kind() {
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
RelationshipKind::Composition => format!("{src} -> {tgt}"),
RelationshipKind::Import => continue,
};
lines.push(arrow);
}
lines.join("\n")
}
fn render_module(graph: &CodeGraph) -> String {
use archlens_domain::RelationshipKind;
use std::collections::{HashMap, HashSet};
let mut lines = Vec::new();
let mut modules: HashSet<String> = HashSet::new();
let mut name_to_module: HashMap<&str, &str> = HashMap::new();
for el in graph.elements() {
if let Some(m) = el.module() {
modules.insert(m.as_str().to_string());
name_to_module.insert(el.qualified_name(), m.as_str());
name_to_module.insert(el.name(), m.as_str());
}
}
for module in &modules {
let id = sanitize(module);
lines.push(format!("{id}: {module}"));
}
let mut edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let src_mod = name_to_module.get(rel.source());
let tgt_mod = name_to_module.get(rel.target());
if let (Some(s), Some(t)) = (src_mod, tgt_mod)
&& s != t
{
edges.insert((s.to_string(), t.to_string()));
}
}
for (src, tgt) in &edges {
lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt)));
}
lines.join("\n")
}
fn render_project(graph: &CodeGraph) -> String {
use archlens_domain::RelationshipKind;
use std::collections::HashMap;
let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module();
for (module, elements) in &by_module {
let mod_id = sanitize(module);
lines.push(format!("{mod_id}: {{"));
for el in elements {
lines.push(format!(" {}: {}", sanitize(el.name()), el.name()));
}
lines.push("}".to_string());
}
for el in &ungrouped {
lines.push(format!("{}: {}", sanitize(el.name()), el.name()));
}
let name_to_id: HashMap<&str, String> = graph
.elements()
.iter()
.map(|e| (e.name(), sanitize(e.name())))
.collect();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
{
lines.push(format!("{src} -> {tgt}"));
}
}
lines.join("\n")
}

View File

@@ -0,0 +1,2 @@
mod d2_renderer;
pub use d2_renderer::D2Renderer;

View File

@@ -0,0 +1,110 @@
use archlens_d2::D2Renderer;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
RelationshipKind, ports::DiagramRenderer,
};
fn make_el(name: &str, module: Option<&str>) -> CodeElement {
let mut el = CodeElement::new(
name,
CodeElementKind::Class,
FilePath::new(&format!("src/{name}.rs")).unwrap(),
1,
)
.unwrap();
if let Some(m) = module {
el = el.with_module(ModuleName::new(m).unwrap());
}
el
}
#[test]
fn type_level_emits_class_shapes() {
let mut graph = CodeGraph::new();
graph.add_element(make_el("OrderService", Some("App")));
graph.add_element(make_el("Order", Some("Domain")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let renderer = D2Renderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(
content.contains("shape: class"),
"expected class shape: {content}"
);
assert!(
content.contains("OrderService"),
"expected OrderService: {content}"
);
assert!(content.contains("Order"), "expected Order: {content}");
}
#[test]
fn module_level_emits_module_nodes_and_edges() {
let mut graph = CodeGraph::new();
graph.add_element(make_el("Service", Some("App")));
graph.add_element(make_el("Order", Some("Domain")));
graph.add_relationship(
Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let renderer = D2Renderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("App"), "expected App module: {content}");
assert!(
content.contains("Domain"),
"expected Domain module: {content}"
);
assert!(
content.contains("->"),
"expected dependency arrow: {content}"
);
}
#[test]
fn project_level_groups_by_module() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"my-api",
CodeElementKind::Project,
FilePath::new("api/pyproject.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Backend").unwrap()),
);
graph.add_element(
CodeElement::new(
"my-commons",
CodeElementKind::Project,
FilePath::new("commons/pyproject.toml").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("my-api", "my-commons", RelationshipKind::Composition).unwrap(),
);
let renderer = D2Renderer::with_level(DiagramLevel::Project);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(
content.contains("Backend"),
"expected Backend group: {content}"
);
assert!(
content.contains("my-api") || content.contains("my_api"),
"expected my-api: {content}"
);
assert!(content.contains("->"), "expected dep arrow: {content}");
}