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:
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user