refactor: five architectural deepening improvements
Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single
named operation returning a distinct type; raw CodeGraph cannot call
module_edges/subgraph_by_module — pipeline order enforced at compile time.
Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram
extracted to application/src/use_cases/; presentation is now a thin CLI
dispatcher (~100 lines less, three fewer local functions).
Candidate 3 (ExtractionContext): shared accumulator for both Rust and
Python extractors replaces parallel Vec<> + 4-arg passing chains.
Candidate 4 (ModuleAssignment): ModuleName::assign() returns
ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option,
callers can distinguish resolution strategies.
Candidate 5 (SplitRenderer): append_cross_module_deps removed from
DiagramRenderer port; replaced by render_for_module() default impl —
port interface now reflects what all renderers actually share.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
mod code_graph;
|
||||
mod normalized_graph;
|
||||
|
||||
pub use code_graph::CodeGraph;
|
||||
pub use normalized_graph::NormalizedGraph;
|
||||
|
||||
77
crates/domain/src/aggregates/normalized_graph.rs
Normal file
77
crates/domain/src/aggregates/normalized_graph.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{CodeElement, CodeGraph, DomainError, ModuleName, Relationship};
|
||||
|
||||
/// A `CodeGraph` that has been fully normalized: qualified, resolved, and
|
||||
/// filtered. Only this type exposes module-level and split-by-module queries —
|
||||
/// callers cannot call those on a raw `CodeGraph`, making incorrect pipeline
|
||||
/// order a compile-time error rather than a silent bug.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NormalizedGraph(CodeGraph);
|
||||
|
||||
impl NormalizedGraph {
|
||||
/// Normalize a raw `CodeGraph` — qualifies type names, resolves
|
||||
/// relationships, and filters external imports in one named operation.
|
||||
pub fn from_analyzed(
|
||||
graph: CodeGraph,
|
||||
known_dirs: &HashSet<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let normalized = graph
|
||||
.qualify()
|
||||
.resolve_relationships()
|
||||
.filter_external_imports(known_dirs);
|
||||
Ok(Self(normalized))
|
||||
}
|
||||
|
||||
/// Wrap a project-level graph (from `CargoWorkspaceAnalyzer` or
|
||||
/// `PythonProjectAnalyzer`) that is already ready to render — no
|
||||
/// analysis pipeline needed.
|
||||
pub fn from_project(graph: CodeGraph) -> Self {
|
||||
Self(graph)
|
||||
}
|
||||
|
||||
// ── Element access ───────────────────────────────────────────────────────
|
||||
|
||||
pub fn elements(&self) -> &[CodeElement] {
|
||||
self.0.elements()
|
||||
}
|
||||
|
||||
pub fn relationships(&self) -> &[Relationship] {
|
||||
self.0.relationships()
|
||||
}
|
||||
|
||||
pub fn modules(&self) -> Vec<ModuleName> {
|
||||
self.0.modules()
|
||||
}
|
||||
|
||||
pub fn elements_by_module(
|
||||
&self,
|
||||
) -> (HashMap<String, Vec<&CodeElement>>, Vec<&CodeElement>) {
|
||||
self.0.elements_by_module()
|
||||
}
|
||||
|
||||
// ── Module-level queries (only available on NormalizedGraph) ─────────────
|
||||
|
||||
pub fn module_edges(&self) -> HashMap<(String, String), usize> {
|
||||
self.0.module_edges()
|
||||
}
|
||||
|
||||
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
|
||||
self.0.subgraph_by_module(module)
|
||||
}
|
||||
|
||||
pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> {
|
||||
self.0.cross_module_deps_for(module)
|
||||
}
|
||||
|
||||
// ── Mutation (merge project edges after normalization) ───────────────────
|
||||
|
||||
pub fn merge_project_edges(&mut self, project_graph: &CodeGraph) {
|
||||
self.0.merge_project_edges(project_graph);
|
||||
}
|
||||
|
||||
/// Expose the inner graph for renderers that accept `&CodeGraph`.
|
||||
pub fn as_graph(&self) -> &CodeGraph {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ pub mod entities;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use aggregates::CodeGraph;
|
||||
pub use aggregates::{CodeGraph, NormalizedGraph};
|
||||
pub use entities::{CodeElement, Relationship};
|
||||
pub use error::DomainError;
|
||||
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
|
||||
@@ -13,5 +13,6 @@ 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, normalize_cargo_package, normalize_python_package,
|
||||
FilePath, Language, ModuleAssignment, ModuleName, SourceFile,
|
||||
normalize_cargo_package, normalize_python_package,
|
||||
};
|
||||
|
||||
@@ -3,13 +3,19 @@ use crate::{CodeGraph, DomainError, ModuleName, RenderOutput};
|
||||
pub trait DiagramRenderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
|
||||
|
||||
fn append_cross_module_deps(
|
||||
/// Render a single module's subgraph for split-by-module output.
|
||||
///
|
||||
/// `cross_deps` is the list of (external module, relationship count) pairs
|
||||
/// for dependencies this module has on other modules. The default
|
||||
/// implementation ignores cross_deps and falls back to `render(subgraph)`.
|
||||
/// Adapters that support per-module annotations (e.g. Mermaid) override
|
||||
/// this to include cross-module dependency information in the output.
|
||||
fn render_for_module(
|
||||
&self,
|
||||
content: &str,
|
||||
module: &ModuleName,
|
||||
deps: &[(ModuleName, usize)],
|
||||
) -> String {
|
||||
let _ = (module, deps);
|
||||
content.to_string()
|
||||
subgraph: &CodeGraph,
|
||||
_module: &ModuleName,
|
||||
_cross_deps: &[(ModuleName, usize)],
|
||||
) -> Result<RenderOutput, DomainError> {
|
||||
self.render(subgraph)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ mod source_file;
|
||||
|
||||
pub use file_path::FilePath;
|
||||
pub use language::Language;
|
||||
pub use module_name::ModuleName;
|
||||
pub use module_name::{ModuleAssignment, ModuleName};
|
||||
pub use source_file::SourceFile;
|
||||
|
||||
pub fn normalize_cargo_package(name: &str) -> String {
|
||||
|
||||
@@ -6,6 +6,40 @@ use crate::DomainError;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModuleName(String);
|
||||
|
||||
/// How a module name was assigned to a source file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModuleAssignment {
|
||||
/// Matched an explicit user-configured mapping in `archlens.toml`.
|
||||
Explicit(ModuleName),
|
||||
/// Derived from file path structure by heuristic (e.g. `crates/<name>/...`).
|
||||
Inferred(ModuleName),
|
||||
/// No mapping matched and the heuristic could not determine a module.
|
||||
Unresolved(&'static str),
|
||||
}
|
||||
|
||||
impl ModuleAssignment {
|
||||
pub fn module_name(&self) -> Option<&ModuleName> {
|
||||
match self {
|
||||
Self::Explicit(m) | Self::Inferred(m) => Some(m),
|
||||
Self::Unresolved(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_module_name(self) -> Option<ModuleName> {
|
||||
match self {
|
||||
Self::Explicit(m) | Self::Inferred(m) => Some(m),
|
||||
Self::Unresolved(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reason(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Unresolved(r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleName {
|
||||
pub fn new(value: &str) -> Result<Self, DomainError> {
|
||||
let trimmed = value.trim();
|
||||
@@ -15,11 +49,13 @@ impl ModuleName {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn from_path(
|
||||
/// Assign a module to a file path, returning a typed assignment that
|
||||
/// distinguishes explicit mappings, heuristic inference, and failure.
|
||||
pub fn assign(
|
||||
file_path: &str,
|
||||
root: &Path,
|
||||
module_mappings: &HashMap<String, String>,
|
||||
) -> Option<Self> {
|
||||
) -> ModuleAssignment {
|
||||
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||
let root_str = canonical_root.to_str().unwrap_or("");
|
||||
let relative = file_path
|
||||
@@ -27,15 +63,19 @@ impl ModuleName {
|
||||
.unwrap_or(file_path)
|
||||
.trim_start_matches('/');
|
||||
|
||||
// 1. Explicit mapping
|
||||
for (pattern, module_name) in module_mappings {
|
||||
if relative.starts_with(pattern.as_str()) {
|
||||
return Self::new(module_name).ok();
|
||||
if relative.starts_with(pattern.as_str())
|
||||
&& let Ok(m) = Self::new(module_name)
|
||||
{
|
||||
return ModuleAssignment::Explicit(m);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Heuristic inference from path structure
|
||||
let parts: Vec<&str> = relative.split('/').collect();
|
||||
if parts.len() <= 1 {
|
||||
return None;
|
||||
return ModuleAssignment::Unresolved("path has no directory component");
|
||||
}
|
||||
|
||||
let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 {
|
||||
@@ -43,10 +83,23 @@ impl ModuleName {
|
||||
} else if parts[0] != "src" && parts.len() > 1 {
|
||||
parts[0]
|
||||
} else {
|
||||
return None;
|
||||
return ModuleAssignment::Unresolved("path under src/ with no further structure");
|
||||
};
|
||||
|
||||
Self::new(&Self::capitalize(module_dir)).ok()
|
||||
match Self::new(&Self::capitalize(module_dir)) {
|
||||
Ok(m) => ModuleAssignment::Inferred(m),
|
||||
Err(_) => ModuleAssignment::Unresolved("inferred directory name was empty"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper — returns None for Unresolved.
|
||||
/// Prefer `assign()` when you want to distinguish strategies.
|
||||
pub fn from_path(
|
||||
file_path: &str,
|
||||
root: &Path,
|
||||
module_mappings: &HashMap<String, String>,
|
||||
) -> Option<Self> {
|
||||
Self::assign(file_path, root, module_mappings).into_module_name()
|
||||
}
|
||||
|
||||
pub fn from_directory_group(member_path: &str) -> Option<Self> {
|
||||
|
||||
Reference in New Issue
Block a user