feat: implement all P1/P2/P3/P4 improvements from issue backlog
Some checks failed
CI / Check / Test (push) Failing after 1m33s
Architecture Docs / Generate diagrams (push) Successful in 3m21s

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:
2026-06-17 09:50:50 +02:00
parent 27197062eb
commit fdd85011a4
42 changed files with 2767 additions and 92 deletions

View File

@@ -19,7 +19,11 @@ archlens-file-writer.workspace = true
archlens-stdout-writer.workspace = true
archlens-toml-config.workspace = true
archlens-cargo-workspace.workspace = true
archlens-python-project.workspace = true
archlens-d2.workspace = true
archlens-html.workspace = true
anyhow.workspace = true
notify.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View File

@@ -32,6 +32,18 @@ pub struct Cli {
#[arg(long)]
pub exclude: Vec<String>,
#[arg(long)]
pub include_tests: bool,
#[arg(long)]
pub no_weights: bool,
#[arg(long)]
pub watch: bool,
#[arg(long, value_name = "REF")]
pub since: Option<String>,
#[arg(long)]
pub split_by_module: bool,

View File

@@ -7,12 +7,15 @@ use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer;
use archlens_domain::{
CodeGraph, DiagramLevel, ModuleName,
BoundaryRule, CodeGraph, DiagramLevel, ModuleName, check_boundary_rules,
ports::{ConfigLoader, OutputWriter, 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;
@@ -30,15 +33,43 @@ pub fn run(args: Cli) -> Result<()> {
}
init_tracing(args.verbose);
if args.watch {
return run_watch(args);
}
let level = parse_level(&args.level);
let config_loader = load_config(&args)?;
let graph = build_graph(&args, level)?;
let renderer = create_renderer(&args.format, 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);
}
// 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()
);
}
}
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
} else {
@@ -75,9 +106,20 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
excludes.extend(args.exclude.iter().cloned());
analysis_config = analysis_config.with_excludes(excludes);
}
if args.include_tests {
analysis_config = analysis_config.with_include_tests(true);
}
if let Some(ref git_ref) = args.since {
let changed = get_changed_files(&args.path, git_ref)?;
analysis_config = analysis_config.with_changed_files(changed);
}
if level == DiagramLevel::Project {
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
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 discovery = WalkdirDiscovery::new();
@@ -106,10 +148,13 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
if workspace_toml.exists()
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
{
merge_project_deps_as_module_edges(&mut graph, &project_graph);
let project_graph = if workspace_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
} else {
PythonProjectAnalyzer::new().analyze(&args.path).ok()
};
if let Some(pg) = project_graph {
merge_project_deps_as_module_edges(&mut graph, &pg);
}
}
@@ -119,10 +164,15 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
fn create_renderer(
format: &str,
level: DiagramLevel,
show_weights: bool,
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
match format {
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level))),
"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())),
fmt => bail!("unknown format: {fmt}"),
}
}
@@ -130,6 +180,8 @@ fn create_renderer(
fn format_extension(format: &str) -> &str {
match format {
"mermaid" => "mmd",
"d2" => "d2",
"html" => "html",
_ => "txt",
}
}
@@ -174,14 +226,17 @@ fn write_split(
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()),
module_output
.files()
.first()
.map(|f| f.content())
.unwrap_or(""),
&content,
)?;
writer.write(&archlens_domain::RenderOutput::single(module_file))?;
}
@@ -258,7 +313,7 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
let level = parse_level(&args.level);
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, 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("");
@@ -331,6 +386,13 @@ format = "mermaid"
# Generate separate files per module
split_by_module = false
[rules]
# Allowed dependency directions between modules (if set, unlisted directions are violations)
# allow = ["Application --> Domain", "Adapters --> Domain"]
# Explicitly forbidden dependency directions (always checked)
# deny = ["Domain --> Adapters", "Domain --> Application"]
"#;
std::fs::write(&config_path, content)?;
@@ -362,3 +424,104 @@ fn init_tracing(verbosity: u8) {
.try_init()
.ok();
}
fn get_changed_files(
root: &std::path::Path,
git_ref: &str,
) -> Result<std::collections::HashSet<String>> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", git_ref])
.current_dir(root)
.output()
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
if !output.status.success() {
bail!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let files = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(files)
}
fn run_watch(args: Cli) -> Result<()> {
use notify::{RecursiveMode, Watcher, recommended_watcher};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let level = parse_level(&args.level);
let ext = format_extension(&args.format);
let debounce = Duration::from_millis(500);
let run_once = |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)?;
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
}
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());
}
}
Ok(())
};
eprintln!(
"Watching {} for changes (Ctrl+C to stop)...",
args.path.display()
);
if let Err(e) = run_once(&args) {
eprintln!("Error: {e}");
} else {
eprintln!("Diagram generated.");
}
let (tx, rx) = mpsc::channel();
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;
}
last_run = Instant::now();
eprintln!("Change detected, regenerating...");
if let Err(e) = run_once(&args) {
eprintln!("Error: {e}");
} else {
eprintln!("Diagram updated.");
}
}
Err(e) => {
eprintln!("Watch error: {e}");
break;
}
}
}
Ok(())
}

View File

@@ -53,6 +53,10 @@ fn analyzes_rust_project_and_writes_mermaid_to_file() {
config: None,
scope: None,
exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: false,
strict: false,
check: false,
@@ -83,6 +87,10 @@ fn works_without_config_file() {
config: None,
scope: None,
exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: false,
strict: false,
check: false,
@@ -108,6 +116,10 @@ fn split_by_module_writes_overview_and_per_module_files() {
config: None,
scope: None,
exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: true,
strict: false,
check: false,
@@ -131,3 +143,59 @@ fn split_by_module_writes_overview_and_per_module_files() {
"should have overview + at least one module file"
);
}
fn create_cross_module_project(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/app")).unwrap();
fs::create_dir_all(dir.join("src/domain")).unwrap();
fs::write(
dir.join("src/domain/order.rs"),
"pub struct Order { pub id: u64 }\n",
)
.unwrap();
fs::write(
dir.join("src/app/service.rs"),
"use crate::domain::Order;\npub struct OrderService { order: Order }\n",
)
.unwrap();
}
#[test]
fn per_module_file_shows_cross_module_dependency_arrows() {
let project = tempfile::tempdir().unwrap();
create_cross_module_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "type".to_string(),
format: "mermaid".to_string(),
output: Some(output_dir.path().to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: true,
strict: false,
check: false,
verbose: 0,
})
.unwrap();
let app_file = output_dir.path().join("app.mmd");
assert!(app_file.exists(), "app.mmd should exist");
let content = fs::read_to_string(&app_file).unwrap();
assert!(
content.contains("<<module>>"),
"per-module file should contain external module placeholder: {content}"
);
assert!(
content.contains("domain"),
"per-module file should reference the domain module: {content}"
);
}