refactor: five architectural deepening improvements
Some checks failed
CI / Check / Test (push) Failing after 43s
Architecture Docs / Generate diagrams (push) Successful in 3m20s

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:
2026-06-17 11:24:18 +02:00
parent b159cafc9d
commit fc8ad0ebc0
18 changed files with 614 additions and 511 deletions

View File

@@ -1,3 +1,5 @@
mod code_graph;
mod normalized_graph;
pub use code_graph::CodeGraph;
pub use normalized_graph::NormalizedGraph;

View 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
}
}

View File

@@ -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,
};

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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> {