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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analysis;
|
||||
pub mod graph;
|
||||
pub mod output;
|
||||
pub mod rules;
|
||||
pub mod source;
|
||||
|
||||
28
crates/domain/src/value_objects/rules/boundary_rule.rs
Normal file
28
crates/domain/src/value_objects/rules/boundary_rule.rs
Normal 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
|
||||
}
|
||||
}
|
||||
52
crates/domain/src/value_objects/rules/mod.rs
Normal file
52
crates/domain/src/value_objects/rules/mod.rs
Normal 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
|
||||
}
|
||||
45
crates/domain/src/value_objects/rules/rule_violation.rs
Normal file
45
crates/domain/src/value_objects/rules/rule_violation.rs
Normal 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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
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