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

@@ -7,6 +7,7 @@ use archlens_domain::{
pub struct MermaidRenderer {
level: DiagramLevel,
show_weights: bool,
}
impl Default for MermaidRenderer {
@@ -19,11 +20,24 @@ impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
show_weights: true,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
Self {
level,
show_weights: true,
}
}
pub fn with_weights(mut self, show: bool) -> Self {
self.show_weights = show;
self
}
fn display_name(qualified: &str) -> &str {
qualified.split("::").last().unwrap_or(qualified)
}
fn format_element_name(element: &CodeElement) -> String {
@@ -89,7 +103,9 @@ impl MermaidRenderer {
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
let src = Self::display_name(rel.source());
let tgt = Self::display_name(rel.target());
let key = format!("{} {} {}", src, arrow, tgt);
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
@@ -136,10 +152,15 @@ impl MermaidRenderer {
for element in graph.elements() {
if let Some(module) = element.module() {
// Index both bare name and qualified name for lookup
name_to_modules
.entry(element.name())
.or_default()
.insert(module.as_str());
name_to_modules
.entry(element.qualified_name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str())
@@ -156,7 +177,7 @@ impl MermaidRenderer {
lines.push(format!(" {module}[{module}]"));
}
let mut module_edges: HashSet<(String, String)> = HashSet::new();
let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
@@ -168,7 +189,7 @@ impl MermaidRenderer {
&& modules.contains(&target_mod)
&& *src != target_mod
{
module_edges.insert((src.clone(), target_mod));
*module_edges.entry((src.clone(), target_mod)).or_insert(0) += 1;
}
}
_ => {
@@ -176,7 +197,9 @@ impl MermaidRenderer {
&& modules.contains(rel.target())
&& rel.source() != rel.target()
{
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
*module_edges
.entry((rel.source().to_string(), rel.target().to_string()))
.or_insert(0) += 1;
continue;
}
@@ -190,7 +213,9 @@ impl MermaidRenderer {
}
for tgt_mod in tgt_set {
if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
*module_edges
.entry((src_mod.to_string(), tgt_mod.to_string()))
.or_insert(0) += 1;
}
}
}
@@ -199,8 +224,18 @@ impl MermaidRenderer {
}
}
for (source, target) in &module_edges {
lines.push(format!(" {source} --> {target}"));
for ((source, target), count) in &module_edges {
let arrow = if self.show_weights {
let label = if *count == 1 {
r#"|"1 dep"|"#.to_string()
} else {
format!(r#"|"{count} deps"|"#)
};
format!("--{label}")
} else {
"-->".to_string()
};
lines.push(format!(" {source} {arrow} {target}"));
}
lines.join("\n")
@@ -249,4 +284,43 @@ impl DiagramRenderer for MermaidRenderer {
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
fn append_cross_module_deps(
&self,
content: &str,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
if deps.is_empty() {
return content.to_string();
}
let src_id = format!(
"{}_module",
module.as_str().to_lowercase().replace('-', "_")
);
let mut extra = format!(
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
module.as_str()
);
for (dep_mod, count) in deps {
let dep_id = format!(
"{}_module",
dep_mod.as_str().to_lowercase().replace('-', "_")
);
extra.push_str(&format!(
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
dep_mod.as_str()
));
let label = if *count == 1 {
"1 dep".to_string()
} else {
format!("{count} deps")
};
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
}
format!("{content}\n{extra}")
}
}