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,80 @@
use archlens_domain::{
BoundaryRule, CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship,
RelationshipKind, RuleViolation, check_boundary_rules,
};
fn make_element(name: &str, module: &str) -> CodeElement {
CodeElement::new(
name,
CodeElementKind::Class,
FilePath::new(&format!("src/{name}.rs")).unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new(module).unwrap())
}
fn graph_with_edge(
src_name: &str,
src_module: &str,
tgt_name: &str,
tgt_module: &str,
) -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(make_element(src_name, src_module));
graph.add_element(make_element(tgt_name, tgt_module));
graph.add_relationship(
Relationship::new(src_name, tgt_name, RelationshipKind::Composition).unwrap(),
);
graph.qualify()
}
#[test]
fn boundary_rule_parses_source_and_target() {
let rule = BoundaryRule::parse("Application --> Domain").unwrap();
assert_eq!(rule.source(), "Application");
assert_eq!(rule.target(), "Domain");
}
#[test]
fn check_returns_denied_violation_when_deny_rule_matches_edge() {
let graph = graph_with_edge("Service", "Domain", "Adapter", "Adapters");
let deny = vec![BoundaryRule::parse("Domain --> Adapters").unwrap()];
let violations = check_boundary_rules(&graph, &[], &deny);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].source_module(), "Domain");
assert_eq!(violations[0].target_module(), "Adapters");
assert!(matches!(
violations[0].kind(),
archlens_domain::RuleKind::Denied
));
}
#[test]
fn check_returns_no_violation_when_edge_matches_allow_rule() {
let graph = graph_with_edge("Service", "Application", "Order", "Domain");
let allow = vec![BoundaryRule::parse("Application --> Domain").unwrap()];
let violations = check_boundary_rules(&graph, &allow, &[]);
assert!(
violations.is_empty(),
"expected no violations, got: {}",
violations.len()
);
}
#[test]
fn check_returns_not_allowed_when_edge_absent_from_allow_list() {
let graph = graph_with_edge("Repo", "Adapters", "Order", "Domain");
// Only Application --> Domain is allowed; Adapters --> Domain is not in the list
let allow = vec![BoundaryRule::parse("Application --> Domain").unwrap()];
let violations = check_boundary_rules(&graph, &allow, &[]);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].source_module(), "Adapters");
assert_eq!(violations[0].target_module(), "Domain");
}

View File

@@ -115,6 +115,180 @@ fn subgraph_of_nonexistent_module_is_empty() {
assert!(subgraph.relationships().is_empty());
}
#[test]
fn qualify_sets_qualified_name_on_elements_with_modules() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
graph.add_element(make_element("Orphan", None));
let graph = graph.qualify();
let commons_dto = graph
.elements()
.iter()
.find(|e| e.module().map(|m| m.as_str()) == Some("Commons"))
.unwrap();
assert_eq!(commons_dto.qualified_name(), "Commons::DtoBaseModel");
let api_dto = graph
.elements()
.iter()
.find(|e| e.module().map(|m| m.as_str()) == Some("Api"))
.unwrap();
assert_eq!(api_dto.qualified_name(), "Api::DtoBaseModel");
let orphan = graph
.elements()
.iter()
.find(|e| e.name() == "Orphan")
.unwrap();
assert_eq!(orphan.qualified_name(), "Orphan");
}
#[test]
fn qualify_rewrites_unambiguous_relationship_target() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("App")));
graph.add_element(make_element("Order", Some("Domain")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let rel = &graph.relationships()[0];
assert_eq!(rel.source(), "App::OrderService");
assert_eq!(rel.target(), "Domain::Order");
}
#[test]
fn qualify_disambiguates_target_by_source_module() {
let mut graph = CodeGraph::new();
// DtoBaseModel exists in both Commons and Api
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
// GlobalAudienceDefinition inherits DtoBaseModel, and is in Commons
graph.add_element(make_element("GlobalAudienceDefinition", Some("Commons")));
let mut rel = Relationship::new(
"GlobalAudienceDefinition",
"DtoBaseModel",
RelationshipKind::Inheritance,
)
.unwrap();
// source_file is in the Commons module path
rel = rel.with_source_file(
archlens_domain::FilePath::new("src/commons/global_audience.rs").unwrap(),
);
// Make GlobalAudienceDefinition's element file match
let mut gad = make_element("GlobalAudienceDefinition", Some("Commons"));
// rebuild with matching file_path
gad = CodeElement::new(
"GlobalAudienceDefinition",
archlens_domain::CodeElementKind::Class,
archlens_domain::FilePath::new("src/commons/global_audience.rs").unwrap(),
1,
)
.unwrap()
.with_module(archlens_domain::ModuleName::new("Commons").unwrap());
let mut graph = CodeGraph::new();
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
graph.add_element(gad);
graph.add_relationship(rel);
let graph = graph.qualify();
let rel = &graph.relationships()[0];
assert_eq!(rel.source(), "Commons::GlobalAudienceDefinition");
assert_eq!(rel.target(), "Commons::DtoBaseModel");
}
#[test]
fn resolve_preserves_relationship_when_both_qualified_names_exist() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("GlobalAudienceDefinition", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
graph.add_relationship(
Relationship::new(
"GlobalAudienceDefinition",
"DtoBaseModel",
RelationshipKind::Inheritance,
)
.unwrap(),
);
let graph = graph.qualify().resolve_relationships();
// The relationship should survive — Commons::GlobalAudienceDefinition --> Commons::DtoBaseModel
assert_eq!(graph.relationships().len(), 1);
assert_eq!(
graph.relationships()[0].source(),
"Commons::GlobalAudienceDefinition"
);
assert_eq!(graph.relationships()[0].target(), "Commons::DtoBaseModel");
}
#[test]
fn cross_module_deps_for_returns_target_module_with_count() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("WidgetJobData", Some("aiss_worker")));
graph.add_element(make_element("WidgetType", Some("commons")));
graph.add_relationship(
Relationship::new("WidgetJobData", "WidgetType", RelationshipKind::Composition).unwrap(),
);
let module = ModuleName::new("aiss_worker").unwrap();
let deps = graph.cross_module_deps_for(&module);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].0.as_str(), "commons");
assert_eq!(deps[0].1, 1);
}
#[test]
fn cross_module_deps_for_returns_empty_for_intra_module_only() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let module = ModuleName::new("Orders").unwrap();
let deps = graph.cross_module_deps_for(&module);
assert!(deps.is_empty());
}
#[test]
fn cross_module_deps_for_aggregates_multiple_relationships_to_same_module() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("ServiceA", Some("app")));
graph.add_element(make_element("ServiceB", Some("app")));
graph.add_element(make_element("DomainType1", Some("domain")));
graph.add_element(make_element("DomainType2", Some("domain")));
graph.add_relationship(
Relationship::new("ServiceA", "DomainType1", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("ServiceB", "DomainType2", RelationshipKind::Composition).unwrap(),
);
let module = ModuleName::new("app").unwrap();
let deps = graph.cross_module_deps_for(&module);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].0.as_str(), "domain");
assert_eq!(deps[0].1, 2);
}
#[test]
fn graph_lists_unique_modules() {
let mut graph = CodeGraph::new();