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,28 +1,28 @@
mod cli;
use std::path::PathBuf;
use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::{
check_freshness::CheckFreshness,
diff_diagram::DiffDiagram,
generate_diagram::{GenerateDiagram, write_split},
};
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer;
use archlens_domain::{
BoundaryRule, CodeGraph, DiagramLevel, check_boundary_rules,
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
BoundaryRule, DiagramLevel, NormalizedGraph,
ports::{ConfigLoader, ProjectAnalyzer},
};
use archlens_file_writer::FileOutputWriter;
use archlens_html::HtmlRenderer;
use archlens_mermaid::MermaidRenderer;
use archlens_python_project::PythonProjectAnalyzer;
use archlens_stdout_writer::StdoutOutputWriter;
use archlens_toml_config::TomlConfigLoader;
use archlens_tree_sitter::TreeSitterAnalyzer;
use archlens_walkdir::WalkdirDiscovery;
pub use cli::{Cli, Command};
pub type CliArgs = Cli;
pub fn run(args: Cli) -> Result<()> {
@@ -41,40 +41,44 @@ pub fn run(args: Cli) -> Result<()> {
let config_loader = load_config(&args)?;
let graph = build_graph(&args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let ext = format_extension(&args.format);
if args.check {
return check_freshness(&args.output, &graph, &*renderer);
let existing_path = args.output.as_ref()
.ok_or_else(|| anyhow::anyhow!("--check requires --output to specify the file to check against"))?;
let up_to_date = CheckFreshness {
graph: &graph,
renderer: &*renderer,
existing_path: std::path::Path::new(existing_path),
}.execute()?;
if up_to_date {
println!("Architecture diagram is up to date.");
} else {
eprintln!("Architecture diagram is outdated: {existing_path}");
std::process::exit(1);
}
return Ok(());
}
// Boundary rule checking
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
if !allow.is_empty() || !deny.is_empty() {
let violations = check_boundary_rules(&graph, &allow, &deny);
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
if args.strict && !violations.is_empty() {
bail!(
"{} boundary rule violation(s) in strict mode",
violations.len()
);
}
}
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: args.split_by_module,
format_ext: format_extension(&args.format).to_string(),
output_dir,
};
let violations = use_case.check_violations_only();
if args.strict && !violations.is_empty() {
bail!("{} boundary rule violation(s) in strict mode", violations.len());
}
use_case.execute()?;
Ok(())
}
@@ -93,7 +97,7 @@ fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
}
}
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
let config_loader = load_config(args)?;
let mut analysis_config = config_loader.load_analysis_config()?;
analysis_config = analysis_config.with_level(level);
@@ -116,10 +120,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
if level == DiagramLevel::Project {
let cargo_toml = args.path.join("Cargo.toml");
if cargo_toml.exists() {
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
}
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
let project_graph = if cargo_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
} else {
PythonProjectAnalyzer::new().analyze(&args.path)?
};
return Ok(NormalizedGraph::from_project(project_graph));
}
let discovery = WalkdirDiscovery::new();
@@ -129,18 +135,10 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
if !result.warnings().is_empty() {
for warning in result.warnings() {
eprintln!(
"WARNING: {}:{} {}",
warning.file_path().as_str(),
warning.line(),
warning.message()
);
eprintln!("WARNING: {}:{} {}", warning.file_path().as_str(), warning.line(), warning.message());
}
if args.strict {
bail!(
"analysis produced {} warning(s) in strict mode",
result.warnings().len()
);
bail!("analysis produced {} warning(s) in strict mode", result.warnings().len());
}
}
@@ -167,9 +165,7 @@ fn create_renderer(
show_weights: bool,
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
match format {
"mermaid" => Ok(Box::new(
MermaidRenderer::with_level(level).with_weights(show_weights),
)),
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level).with_weights(show_weights))),
"ascii" => Ok(Box::new(AsciiRenderer::new())),
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
"html" => Ok(Box::new(HtmlRenderer::new())),
@@ -186,85 +182,6 @@ fn format_extension(format: &str) -> &str {
}
}
fn check_freshness(
output: &Option<String>,
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
) -> Result<()> {
let Some(path) = output else {
bail!("--check requires --output to specify the file to check against");
};
let rendered = renderer.render(graph)?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(path).unwrap_or_default();
if current != existing {
eprintln!("Architecture diagram is outdated: {path}");
std::process::exit(1);
}
println!("Architecture diagram is up to date.");
Ok(())
}
fn write_split(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
ext: &str,
) -> Result<()> {
let output_dir = output
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let writer = FileOutputWriter::new(output_dir);
let overview = renderer.render(graph)?;
let overview_file = archlens_domain::RenderedFile::new(
&format!("overview.{ext}"),
overview.files().first().map(|f| f.content()).unwrap_or(""),
)?;
writer.write(&archlens_domain::RenderOutput::single(overview_file))?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let cross_deps = graph.cross_module_deps_for(&module);
let module_output = renderer.render(&subgraph)?;
let raw = module_output
.files()
.first()
.map(|f| f.content())
.unwrap_or("");
let content = renderer.append_cross_module_deps(raw, &module, &cross_deps);
let module_file = archlens_domain::RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
&content,
)?;
writer.write(&archlens_domain::RenderOutput::single(module_file))?;
}
Ok(())
}
fn write_single(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
) -> Result<()> {
let rendered = renderer.render(graph)?;
match output {
Some(path) => {
let writer = FileOutputWriter::single_file(PathBuf::from(path));
writer.write(&rendered)?;
}
None => {
let writer = StdoutOutputWriter::new();
writer.write(&rendered)?;
}
}
Ok(())
}
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
init_tracing(args.verbose);
@@ -272,47 +189,24 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or("");
let diff = DiffDiagram {
graph: &graph,
renderer: &*renderer,
existing_path,
}.execute()?;
let existing = std::fs::read_to_string(existing_path).unwrap_or_default();
if current == existing {
if diff.is_empty() {
println!("No changes detected.");
return Ok(());
}
let current_lines: Vec<&str> = current.lines().collect();
let existing_lines: Vec<&str> = existing.lines().collect();
let mut added = Vec::new();
let mut removed = Vec::new();
for line in &current_lines {
if !existing_lines.contains(line) {
added.push(*line);
}
for line in &diff.removed {
println!("{line}");
}
for line in &existing_lines {
if !current_lines.contains(line) {
removed.push(*line);
}
for line in &diff.added {
println!("{line}");
}
if !removed.is_empty() {
println!("Removed:");
for line in &removed {
println!(" - {line}");
}
}
if !added.is_empty() {
println!("Added:");
for line in &added {
println!(" + {line}");
}
}
println!("\n{} added, {} removed", added.len(), removed.len());
println!("\n{} added, {} removed", diff.added.len(), diff.removed.len());
std::process::exit(1);
}
@@ -372,7 +266,6 @@ fn init_tracing(verbosity: u8) {
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -393,10 +286,7 @@ fn get_changed_files(
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
if !output.status.success() {
bail!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr)
);
bail!("git diff failed: {}", String::from_utf8_lossy(&output.stderr));
}
let files = String::from_utf8_lossy(&output.stdout)
@@ -420,33 +310,40 @@ fn run_watch(args: Cli) -> Result<()> {
let config_loader = load_config(args)?;
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
write_split(&graph, &*renderer, &output_dir, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
let rendered = renderer.render(graph.as_graph())?;
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
match &output_dir {
Some(path) => std::fs::write(path, content)?,
None => print!("{content}"),
}
}
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
if !allow.is_empty() || !deny.is_empty() {
let violations = check_boundary_rules(&graph, &allow, &deny);
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: false,
format_ext: ext.to_string(),
output_dir: None,
};
for v in use_case.check_violations_only() {
eprintln!("RULE VIOLATION: {v}");
}
}
Ok(())
};
eprintln!(
"Watching {} for changes (Ctrl+C to stop)...",
args.path.display()
);
eprintln!("Watching {} for changes (Ctrl+C to stop)...", args.path.display());
if let Err(e) = run_once(&args) {
eprintln!("Error: {e}");
} else {
@@ -454,18 +351,14 @@ fn run_watch(args: Cli) -> Result<()> {
}
let (tx, rx) = mpsc::channel();
let mut watcher = recommended_watcher(move |res| {
let _ = tx.send(res);
})?;
let mut watcher = recommended_watcher(move |res| { let _ = tx.send(res); })?;
watcher.watch(&args.path, RecursiveMode::Recursive)?;
let mut last_run = Instant::now();
loop {
match rx.recv() {
Ok(_) => {
if last_run.elapsed() < debounce {
continue;
}
if last_run.elapsed() < debounce { continue; }
last_run = Instant::now();
eprintln!("Change detected, regenerating...");
if let Err(e) = run_once(&args) {
@@ -474,10 +367,7 @@ fn run_watch(args: Cli) -> Result<()> {
eprintln!("Diagram updated.");
}
}
Err(e) => {
eprintln!("Watch error: {e}");
break;
}
Err(e) => { eprintln!("Watch error: {e}"); break; }
}
}
Ok(())