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

@@ -72,20 +72,11 @@ impl CodeGraph {
}
pub fn resolve_relationships(self) -> CodeGraph {
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
let all_type_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
let qualified_names: HashSet<&str> =
self.elements.iter().map(|e| e.qualified_name()).collect();
for element in &self.elements {
file_types
.entry(element.file_path().as_str().to_string())
.or_default()
.insert(element.name().to_string());
name_modules
.entry(element.name())
.or_default()
.insert(element.module().map(|m| m.as_str()));
}
// Also keep bare name lookup for import relationships and unqualified fallback
let all_bare_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
let mut resolved = CodeGraph::new();
for element in &self.elements {
@@ -97,24 +88,11 @@ impl CodeGraph {
resolved.add_relationship(rel.clone());
}
_ => {
if !all_type_names.contains(rel.source())
|| !all_type_names.contains(rel.target())
{
continue;
}
if let Some(src_file) = rel.source_file() {
let file_key = src_file.as_str().to_string();
if let Some(types_in_file) = file_types.get(&file_key)
&& types_in_file.contains(rel.target())
{
resolved.add_relationship(rel.clone());
continue;
}
}
let tgt_modules = &name_modules[rel.target()];
if tgt_modules.len() == 1 {
let src_ok = qualified_names.contains(rel.source())
|| all_bare_names.contains(rel.source());
let tgt_ok = qualified_names.contains(rel.target())
|| all_bare_names.contains(rel.target());
if src_ok && tgt_ok {
resolved.add_relationship(rel.clone());
}
}
@@ -152,6 +130,129 @@ impl CodeGraph {
filtered
}
pub fn qualify(self) -> CodeGraph {
// Build lookup: bare name -> Vec<qualified_name> (for disambiguation)
let mut name_to_qualified: HashMap<&str, Vec<String>> = HashMap::new();
for element in &self.elements {
let qn = match element.module() {
Some(m) => format!("{}::{}", m.as_str(), element.name()),
None => element.name().to_string(),
};
name_to_qualified
.entry(element.name())
.or_default()
.push(qn);
}
// Build lookup: file_path -> qualified source names in that file
let mut file_to_qualified: HashMap<&str, Vec<String>> = HashMap::new();
for element in &self.elements {
let qn = match element.module() {
Some(m) => format!("{}::{}", m.as_str(), element.name()),
None => element.name().to_string(),
};
file_to_qualified
.entry(element.file_path().as_str())
.or_default()
.push(qn);
}
let mut qualified = CodeGraph::new();
// 1. Qualify element names
for element in &self.elements {
let qn = match element.module() {
Some(m) => format!("{}::{}", m.as_str(), element.name()),
None => element.name().to_string(),
};
qualified.add_element(element.clone().with_qualified_name(qn));
}
// 2. Rewrite relationship source/target
for rel in &self.relationships {
// Qualify source: find the qualified name of the source in its file
let src_qualified = rel
.source_file()
.and_then(|f| file_to_qualified.get(f.as_str()))
.and_then(|qns| {
qns.iter().find(|qn| {
qn.ends_with(&format!("::{}", rel.source())) || *qn == rel.source()
})
})
.cloned()
.unwrap_or_else(|| {
// Fall back: unambiguous lookup
name_to_qualified
.get(rel.source())
.filter(|v| v.len() == 1)
.and_then(|v| v.first())
.cloned()
.unwrap_or_else(|| rel.source().to_string())
});
// Qualify target: prefer same module as source when ambiguous
let src_module = src_qualified.split("::").next().unwrap_or("");
let tgt_qualified = match name_to_qualified.get(rel.target()) {
Some(candidates) if candidates.len() == 1 => candidates[0].clone(),
Some(candidates) => {
// Prefer same module as source
candidates
.iter()
.find(|qn| qn.starts_with(&format!("{}::", src_module)))
.cloned()
.unwrap_or_else(|| rel.target().to_string())
}
None => rel.target().to_string(),
};
let new_rel = Relationship::new(&src_qualified, &tgt_qualified, rel.kind())
.unwrap_or_else(|_| rel.clone());
let new_rel = if let Some(f) = rel.source_file() {
new_rel.with_source_file(f.clone())
} else {
new_rel
};
qualified.add_relationship(new_rel);
}
qualified
}
pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> {
let module_element_qnames: HashSet<&str> = self
.elements
.iter()
.filter(|e| e.module().is_some_and(|m| m == module))
.map(|e| e.qualified_name())
.collect();
let target_module_of: HashMap<&str, Option<&ModuleName>> = self
.elements
.iter()
.map(|e| (e.qualified_name(), e.module()))
.collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
for rel in &self.relationships {
if !module_element_qnames.contains(rel.source()) {
continue;
}
if module_element_qnames.contains(rel.target()) {
continue;
}
if let Some(Some(target_mod)) = target_module_of.get(rel.target()) {
*counts.entry(target_mod.as_str()).or_insert(0) += 1;
}
}
let mut result: Vec<(ModuleName, usize)> = counts
.into_iter()
.filter_map(|(name, count)| ModuleName::new(name).ok().map(|m| (m, count)))
.collect();
result.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
result
}
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
let filtered_elements: Vec<CodeElement> = self
.elements
@@ -160,12 +261,15 @@ impl CodeGraph {
.cloned()
.collect();
let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect();
let element_qnames: HashSet<&str> = filtered_elements
.iter()
.map(|e| e.qualified_name())
.collect();
let filtered_relationships: Vec<Relationship> = self
.relationships
.iter()
.filter(|r| element_names.contains(r.source()) && element_names.contains(r.target()))
.filter(|r| element_qnames.contains(r.source()) && element_qnames.contains(r.target()))
.cloned()
.collect();

View File

@@ -3,6 +3,7 @@ use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility};
#[derive(Debug, Clone)]
pub struct CodeElement {
name: String,
qualified_name: Option<String>,
kind: CodeElementKind,
file_path: FilePath,
line: usize,
@@ -27,6 +28,7 @@ impl CodeElement {
}
Ok(Self {
name: trimmed.to_string(),
qualified_name: None,
kind,
file_path,
line,
@@ -59,10 +61,19 @@ impl CodeElement {
self
}
pub fn with_qualified_name(mut self, qn: String) -> Self {
self.qualified_name = Some(qn);
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn qualified_name(&self) -> &str {
self.qualified_name.as_deref().unwrap_or(&self.name)
}
pub fn kind(&self) -> CodeElementKind {
self.kind
}

View File

@@ -11,4 +11,5 @@ pub use error::DomainError;
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile};

View File

@@ -3,4 +3,8 @@ use crate::{AnalysisConfig, DomainError, OutputConfig};
pub trait ConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError>;
fn load_output_config(&self) -> Result<OutputConfig, DomainError>;
fn load_rules(&self) -> (Vec<String>, Vec<String>) {
(Vec::new(), Vec::new())
}
}

View File

@@ -1,5 +1,15 @@
use crate::{CodeGraph, DomainError, RenderOutput};
use crate::{CodeGraph, DomainError, ModuleName, RenderOutput};
pub trait DiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
fn append_cross_module_deps(
&self,
content: &str,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
let _ = (module, deps);
content.to_string()
}
}

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use crate::DiagramLevel;
@@ -8,6 +8,8 @@ pub struct AnalysisConfig {
level: DiagramLevel,
module_mappings: HashMap<String, String>,
scope: Option<String>,
include_tests: bool,
changed_files: Option<HashSet<String>>,
}
impl AnalysisConfig {
@@ -46,6 +48,24 @@ impl AnalysisConfig {
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
pub fn with_include_tests(mut self, include: bool) -> Self {
self.include_tests = include;
self
}
pub fn include_tests(&self) -> bool {
self.include_tests
}
pub fn with_changed_files(mut self, files: HashSet<String>) -> Self {
self.changed_files = Some(files);
self
}
pub fn changed_files(&self) -> Option<&HashSet<String>> {
self.changed_files.as_ref()
}
}
impl Default for AnalysisConfig {
@@ -55,6 +75,8 @@ impl Default for AnalysisConfig {
level: DiagramLevel::Module,
module_mappings: HashMap::new(),
scope: None,
include_tests: false,
changed_files: None,
}
}
}

View File

@@ -1,4 +1,5 @@
pub mod analysis;
pub mod graph;
pub mod output;
pub mod rules;
pub mod source;

View File

@@ -0,0 +1,28 @@
pub struct BoundaryRule {
source: String,
target: String,
}
impl BoundaryRule {
pub fn parse(s: &str) -> Option<Self> {
let (src, tgt) = s.split_once("-->")?;
let source = src.trim().to_string();
let target = tgt.trim().to_string();
if source.is_empty() || target.is_empty() {
return None;
}
Some(Self { source, target })
}
pub fn source(&self) -> &str {
&self.source
}
pub fn target(&self) -> &str {
&self.target
}
pub fn matches(&self, src_module: &str, tgt_module: &str) -> bool {
self.source == src_module && self.target == tgt_module
}
}

View File

@@ -0,0 +1,52 @@
mod boundary_rule;
mod rule_violation;
pub use boundary_rule::BoundaryRule;
pub use rule_violation::{RuleKind, RuleViolation};
use std::collections::HashSet;
use crate::{CodeGraph, RelationshipKind};
pub fn check_boundary_rules(
graph: &CodeGraph,
allow: &[BoundaryRule],
deny: &[BoundaryRule],
) -> Vec<RuleViolation> {
if allow.is_empty() && deny.is_empty() {
return Vec::new();
}
// Build qualified-name → module lookup
let qname_to_module: std::collections::HashMap<&str, &str> = graph
.elements()
.iter()
.filter_map(|e| e.module().map(|m| (e.qualified_name(), m.as_str())))
.collect();
// Collect unique cross-module edges
let mut edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let src_mod = qname_to_module.get(rel.source()).copied();
let tgt_mod = qname_to_module.get(rel.target()).copied();
if let (Some(s), Some(t)) = (src_mod, tgt_mod)
&& s != t
{
edges.insert((s.to_string(), t.to_string()));
}
}
let mut violations = Vec::new();
for (src, tgt) in &edges {
if deny.iter().any(|r| r.matches(src, tgt)) {
violations.push(RuleViolation::new(src, tgt, RuleKind::Denied));
} else if !allow.is_empty() && !allow.iter().any(|r| r.matches(src, tgt)) {
violations.push(RuleViolation::new(src, tgt, RuleKind::NotAllowed));
}
}
violations
}

View File

@@ -0,0 +1,45 @@
pub enum RuleKind {
Denied,
NotAllowed,
}
pub struct RuleViolation {
source_module: String,
target_module: String,
kind: RuleKind,
}
impl RuleViolation {
pub fn new(source_module: &str, target_module: &str, kind: RuleKind) -> Self {
Self {
source_module: source_module.to_string(),
target_module: target_module.to_string(),
kind,
}
}
pub fn source_module(&self) -> &str {
&self.source_module
}
pub fn target_module(&self) -> &str {
&self.target_module
}
pub fn kind(&self) -> &RuleKind {
&self.kind
}
pub fn message(&self) -> String {
match self.kind {
RuleKind::Denied => format!(
"Denied dependency: {} --> {}",
self.source_module, self.target_module
),
RuleKind::NotAllowed => format!(
"Dependency not in allow list: {} --> {}",
self.source_module, self.target_module
),
}
}
}