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:
80
crates/domain/tests/boundary_rule_tests.rs
Normal file
80
crates/domain/tests/boundary_rule_tests.rs
Normal 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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user