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:
11
crates/adapters/d2/Cargo.toml
Normal file
11
crates/adapters/d2/Cargo.toml
Normal 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
|
||||
173
crates/adapters/d2/src/d2_renderer.rs
Normal file
173
crates/adapters/d2/src/d2_renderer.rs
Normal 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")
|
||||
}
|
||||
2
crates/adapters/d2/src/lib.rs
Normal file
2
crates/adapters/d2/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod d2_renderer;
|
||||
pub use d2_renderer::D2Renderer;
|
||||
110
crates/adapters/d2/tests/d2_renderer_tests.rs
Normal file
110
crates/adapters/d2/tests/d2_renderer_tests.rs
Normal 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}");
|
||||
}
|
||||
Reference in New Issue
Block a user