Compare commits
10 Commits
a700fc6160
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cab8d9d784 | ||
| 009c821f48 | |||
| a24dc572bd | |||
| 682a64354f | |||
| 04da26beba | |||
| 8b20bf3874 | |||
| 97c7268661 | |||
| 7487cea0e2 | |||
| 8f68714977 | |||
| 692a64a622 |
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -107,6 +107,7 @@ name = "archlens-ascii"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"archlens-domain",
|
"archlens-domain",
|
||||||
|
"archlens-rendering-primitives",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -128,6 +129,7 @@ name = "archlens-d2"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"archlens-domain",
|
"archlens-domain",
|
||||||
|
"archlens-rendering-primitives",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ name = "archlens-html"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"archlens-domain",
|
"archlens-domain",
|
||||||
|
"archlens-rendering-primitives",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -163,6 +166,7 @@ name = "archlens-mermaid"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"archlens-domain",
|
"archlens-domain",
|
||||||
|
"archlens-rendering-primitives",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -177,6 +181,13 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "archlens-rendering-primitives"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"archlens-domain",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archlens-stdout-writer"
|
name = "archlens-stdout-writer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ members = [
|
|||||||
"crates/adapters/python-project",
|
"crates/adapters/python-project",
|
||||||
"crates/adapters/d2",
|
"crates/adapters/d2",
|
||||||
"crates/adapters/html-viewer",
|
"crates/adapters/html-viewer",
|
||||||
|
"crates/adapters/rendering-primitives",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -32,6 +33,7 @@ archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" }
|
|||||||
archlens-python-project = { path = "crates/adapters/python-project" }
|
archlens-python-project = { path = "crates/adapters/python-project" }
|
||||||
archlens-d2 = { path = "crates/adapters/d2" }
|
archlens-d2 = { path = "crates/adapters/d2" }
|
||||||
archlens-html = { path = "crates/adapters/html-viewer" }
|
archlens-html = { path = "crates/adapters/html-viewer" }
|
||||||
|
archlens-rendering-primitives = { path = "crates/adapters/rendering-primitives" }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
archlens-domain.workspace = true
|
archlens-domain.workspace = true
|
||||||
|
archlens-rendering-primitives.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use archlens_domain::{
|
|||||||
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
|
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
|
||||||
ports::DiagramRenderer,
|
ports::DiagramRenderer,
|
||||||
};
|
};
|
||||||
|
use archlens_rendering_primitives::non_import_rels;
|
||||||
|
|
||||||
pub struct AsciiRenderer;
|
pub struct AsciiRenderer;
|
||||||
|
|
||||||
@@ -94,20 +95,16 @@ impl DiagramRenderer for AsciiRenderer {
|
|||||||
lines.push("└───".to_string());
|
lines.push("└───".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let non_import_rels: Vec<_> = graph
|
let filtered_rels: Vec<_> = non_import_rels(graph.relationships()).collect();
|
||||||
.relationships()
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.kind() != RelationshipKind::Import)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !non_import_rels.is_empty() {
|
if !filtered_rels.is_empty() {
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
lines.push("── Relationships ──".to_string());
|
lines.push("── Relationships ──".to_string());
|
||||||
for rel in &non_import_rels {
|
for rel in &filtered_rels {
|
||||||
let arrow = match rel.kind() {
|
let arrow = match rel.kind() {
|
||||||
RelationshipKind::Inheritance => "extends",
|
RelationshipKind::Inheritance => "extends",
|
||||||
RelationshipKind::Composition => "has",
|
RelationshipKind::Composition => "has",
|
||||||
RelationshipKind::Import => "imports",
|
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
|
||||||
};
|
};
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
" {} ─[{}]─> {}",
|
" {} ─[{}]─> {}",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
archlens-domain.workspace = true
|
archlens-domain.workspace = true
|
||||||
|
archlens-rendering-primitives.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
|
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
|
||||||
};
|
};
|
||||||
|
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
|
||||||
|
|
||||||
pub struct D2Renderer {
|
pub struct D2Renderer {
|
||||||
level: DiagramLevel,
|
level: DiagramLevel,
|
||||||
@@ -36,20 +37,16 @@ impl DiagramRenderer for D2Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize(name: &str) -> String {
|
|
||||||
name.replace("::", "_").replace(['-', ' '], "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_type(graph: &CodeGraph) -> String {
|
fn render_type(graph: &CodeGraph) -> String {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
let (by_module, ungrouped) = graph.elements_by_module();
|
let (by_module, ungrouped) = graph.elements_by_module();
|
||||||
|
|
||||||
// Grouped by module
|
// Grouped by module
|
||||||
for (module, elements) in &by_module {
|
for (module, elements) in &by_module {
|
||||||
let mod_id = sanitize(module);
|
let mod_id = sanitize_identifier(module);
|
||||||
lines.push(format!("{mod_id}: {{"));
|
lines.push(format!("{mod_id}: {{"));
|
||||||
for el in elements {
|
for el in elements {
|
||||||
let el_id = sanitize(el.name());
|
let el_id = sanitize_identifier(el.name());
|
||||||
lines.push(format!(" {el_id}: {{"));
|
lines.push(format!(" {el_id}: {{"));
|
||||||
lines.push(" shape: class".to_string());
|
lines.push(" shape: class".to_string());
|
||||||
for field in el.fields() {
|
for field in el.fields() {
|
||||||
@@ -69,21 +66,21 @@ fn render_type(graph: &CodeGraph) -> String {
|
|||||||
|
|
||||||
// Ungrouped elements
|
// Ungrouped elements
|
||||||
for el in &ungrouped {
|
for el in &ungrouped {
|
||||||
let el_id = sanitize(el.name());
|
let el_id = sanitize_identifier(el.name());
|
||||||
lines.push(format!("{el_id}: {{"));
|
lines.push(format!("{el_id}: {{"));
|
||||||
lines.push(" shape: class".to_string());
|
lines.push(" shape: class".to_string());
|
||||||
lines.push("}".to_string());
|
lines.push("}".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
for rel in graph.relationships() {
|
for rel in non_import_rels(graph.relationships()) {
|
||||||
use archlens_domain::RelationshipKind;
|
use archlens_domain::RelationshipKind;
|
||||||
let src = sanitize(rel.source());
|
let src = sanitize_identifier(rel.source());
|
||||||
let tgt = sanitize(rel.target());
|
let tgt = sanitize_identifier(rel.target());
|
||||||
let arrow = match rel.kind() {
|
let arrow = match rel.kind() {
|
||||||
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
|
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
|
||||||
RelationshipKind::Composition => format!("{src} -> {tgt}"),
|
RelationshipKind::Composition => format!("{src} -> {tgt}"),
|
||||||
RelationshipKind::Import => continue,
|
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
|
||||||
};
|
};
|
||||||
lines.push(arrow);
|
lines.push(arrow);
|
||||||
}
|
}
|
||||||
@@ -95,47 +92,51 @@ fn render_module(graph: &CodeGraph) -> String {
|
|||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
for module in graph.modules() {
|
for module in graph.modules() {
|
||||||
let id = sanitize(module.as_str());
|
let id = sanitize_identifier(module.as_str());
|
||||||
lines.push(format!("{id}: {}", module.as_str()));
|
lines.push(format!("{id}: {}", module.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (src, tgt) in graph.module_edges().keys() {
|
for (src, tgt) in graph.module_edges().keys() {
|
||||||
lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt)));
|
lines.push(format!(
|
||||||
|
"{} -> {}",
|
||||||
|
sanitize_identifier(src),
|
||||||
|
sanitize_identifier(tgt)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_project(graph: &CodeGraph) -> String {
|
fn render_project(graph: &CodeGraph) -> String {
|
||||||
use archlens_domain::RelationshipKind;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
let (by_module, ungrouped) = graph.elements_by_module();
|
let (by_module, ungrouped) = graph.elements_by_module();
|
||||||
|
|
||||||
for (module, elements) in &by_module {
|
for (module, elements) in &by_module {
|
||||||
let mod_id = sanitize(module);
|
let mod_id = sanitize_identifier(module);
|
||||||
lines.push(format!("{mod_id}: {{"));
|
lines.push(format!("{mod_id}: {{"));
|
||||||
for el in elements {
|
for el in elements {
|
||||||
lines.push(format!(" {}: {}", sanitize(el.name()), el.name()));
|
lines.push(format!(
|
||||||
|
" {}: {}",
|
||||||
|
sanitize_identifier(el.name()),
|
||||||
|
el.name()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
lines.push("}".to_string());
|
lines.push("}".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
for el in &ungrouped {
|
for el in &ungrouped {
|
||||||
lines.push(format!("{}: {}", sanitize(el.name()), el.name()));
|
lines.push(format!("{}: {}", sanitize_identifier(el.name()), el.name()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let name_to_id: HashMap<&str, String> = graph
|
let name_to_id: HashMap<&str, String> = graph
|
||||||
.elements()
|
.elements()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| (e.name(), sanitize(e.name())))
|
.map(|e| (e.name(), sanitize_identifier(e.name())))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for rel in graph.relationships() {
|
for rel in non_import_rels(graph.relationships()) {
|
||||||
if rel.kind() == RelationshipKind::Import {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
|
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
|
||||||
{
|
{
|
||||||
lines.push(format!("{src} -> {tgt}"));
|
lines.push(format!("{src} -> {tgt}"));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
archlens-domain.workspace = true
|
archlens-domain.workspace = true
|
||||||
|
archlens-rendering-primitives.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer};
|
||||||
CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer,
|
use archlens_rendering_primitives::non_import_rels;
|
||||||
};
|
|
||||||
|
|
||||||
pub struct HtmlRenderer;
|
pub struct HtmlRenderer;
|
||||||
|
|
||||||
@@ -66,10 +65,7 @@ impl DiagramRenderer for HtmlRenderer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let edges = graph
|
let edges = non_import_rels(graph.relationships())
|
||||||
.relationships()
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.kind() != RelationshipKind::Import)
|
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let src = id_map.get(r.source())?;
|
let src = id_map.get(r.source())?;
|
||||||
let tgt = id_map.get(r.target())?;
|
let tgt = id_map.get(r.target())?;
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
archlens-domain.workspace = true
|
archlens-domain.workspace = true
|
||||||
|
archlens-rendering-primitives.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use archlens_domain::{
|
|||||||
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
|
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
|
||||||
RenderedFile, Visibility, ports::DiagramRenderer,
|
RenderedFile, Visibility, ports::DiagramRenderer,
|
||||||
};
|
};
|
||||||
|
use archlens_rendering_primitives::non_import_rels;
|
||||||
|
|
||||||
pub struct MermaidRenderer {
|
pub struct MermaidRenderer {
|
||||||
level: DiagramLevel,
|
level: DiagramLevel,
|
||||||
@@ -94,14 +95,11 @@ impl MermaidRenderer {
|
|||||||
lines.extend(deferred_members);
|
lines.extend(deferred_members);
|
||||||
|
|
||||||
let mut rel_seen: HashSet<String> = HashSet::new();
|
let mut rel_seen: HashSet<String> = HashSet::new();
|
||||||
for rel in graph.relationships() {
|
for rel in non_import_rels(graph.relationships()) {
|
||||||
if rel.kind() == RelationshipKind::Import {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let arrow = match rel.kind() {
|
let arrow = match rel.kind() {
|
||||||
RelationshipKind::Inheritance => "<|--",
|
RelationshipKind::Inheritance => "<|--",
|
||||||
RelationshipKind::Composition => "-->",
|
RelationshipKind::Composition => "-->",
|
||||||
RelationshipKind::Import => "..>",
|
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
|
||||||
};
|
};
|
||||||
let src = Self::display_name(rel.source());
|
let src = Self::display_name(rel.source());
|
||||||
let tgt = Self::display_name(rel.target());
|
let tgt = Self::display_name(rel.target());
|
||||||
|
|||||||
8
crates/adapters/rendering-primitives/Cargo.toml
Normal file
8
crates/adapters/rendering-primitives/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "archlens-rendering-primitives"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
archlens-domain.workspace = true
|
||||||
11
crates/adapters/rendering-primitives/src/lib.rs
Normal file
11
crates/adapters/rendering-primitives/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use archlens_domain::{Relationship, RelationshipKind};
|
||||||
|
|
||||||
|
/// Returns an iterator over all relationships except those with kind `Import`.
|
||||||
|
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
|
||||||
|
rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces `::`, `-`, `.`, and space with `_`.
|
||||||
|
pub fn sanitize_identifier(name: &str) -> String {
|
||||||
|
name.replace("::", "_").replace(['-', '.', ' '], "_")
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use archlens_domain::{Relationship, RelationshipKind};
|
||||||
|
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
|
||||||
|
|
||||||
|
fn rel(src: &str, tgt: &str, kind: RelationshipKind) -> Relationship {
|
||||||
|
Relationship::new(src, tgt, kind).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_import_rels_excludes_import_relationships() {
|
||||||
|
let rels = vec![
|
||||||
|
rel("A", "B", RelationshipKind::Composition),
|
||||||
|
rel("C", "D", RelationshipKind::Import),
|
||||||
|
rel("E", "F", RelationshipKind::Inheritance),
|
||||||
|
];
|
||||||
|
let filtered: Vec<_> = non_import_rels(&rels).collect();
|
||||||
|
assert_eq!(filtered.len(), 2);
|
||||||
|
assert!(
|
||||||
|
filtered
|
||||||
|
.iter()
|
||||||
|
.all(|r| r.kind() != RelationshipKind::Import)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_import_rels_passes_all_non_import_kinds() {
|
||||||
|
let rels = vec![
|
||||||
|
rel("A", "B", RelationshipKind::Composition),
|
||||||
|
rel("C", "D", RelationshipKind::Inheritance),
|
||||||
|
];
|
||||||
|
let filtered: Vec<_> = non_import_rels(&rels).collect();
|
||||||
|
assert_eq!(filtered.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_identifier_replaces_double_colon_with_underscore() {
|
||||||
|
assert_eq!(sanitize_identifier("foo::bar"), "foo_bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_identifier_replaces_hyphen_with_underscore() {
|
||||||
|
assert_eq!(sanitize_identifier("my-crate"), "my_crate");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_identifier_replaces_dot_with_underscore() {
|
||||||
|
assert_eq!(sanitize_identifier("v1.2"), "v1_2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_identifier_replaces_space_with_underscore() {
|
||||||
|
assert_eq!(sanitize_identifier("my crate"), "my_crate");
|
||||||
|
}
|
||||||
@@ -1,5 +1,39 @@
|
|||||||
|
use tree_sitter::{Node, Parser};
|
||||||
|
|
||||||
use archlens_domain::{AnalysisResult, DomainError, FilePath};
|
use archlens_domain::{AnalysisResult, DomainError, FilePath};
|
||||||
|
|
||||||
|
use crate::extraction_context::ExtractionContext;
|
||||||
|
|
||||||
pub trait LanguageExtractor {
|
pub trait LanguageExtractor {
|
||||||
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError>;
|
fn tree_sitter_language(&self) -> tree_sitter::Language;
|
||||||
|
|
||||||
|
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
|
||||||
|
|
||||||
|
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
|
||||||
|
|
||||||
|
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_extraction(
|
||||||
|
extractor: &dyn LanguageExtractor,
|
||||||
|
source: &str,
|
||||||
|
file_path: &FilePath,
|
||||||
|
) -> Result<AnalysisResult, DomainError> {
|
||||||
|
let mut parser = Parser::new();
|
||||||
|
parser
|
||||||
|
.set_language(&extractor.tree_sitter_language())
|
||||||
|
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
|
||||||
|
|
||||||
|
let tree = parser
|
||||||
|
.parse(source, None)
|
||||||
|
.ok_or_else(|| DomainError::AnalysisError("failed to parse source".into()))?;
|
||||||
|
|
||||||
|
let mut ctx = ExtractionContext::new(file_path.clone());
|
||||||
|
let root = tree.root_node();
|
||||||
|
|
||||||
|
extractor.extract_types(&root, source, &mut ctx);
|
||||||
|
extractor.extract_relationships(&root, source, &mut ctx);
|
||||||
|
extractor.extract_imports(&root, source, &mut ctx);
|
||||||
|
|
||||||
|
ctx.into_result()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use tree_sitter::{Node, Parser};
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind};
|
||||||
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
|
|
||||||
RelationshipKind,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::extraction_context::ExtractionContext;
|
use crate::extraction_context::ExtractionContext;
|
||||||
use crate::language_extractor::LanguageExtractor;
|
use crate::language_extractor::LanguageExtractor;
|
||||||
@@ -11,28 +8,23 @@ use crate::language_extractor::LanguageExtractor;
|
|||||||
pub struct PythonExtractor;
|
pub struct PythonExtractor;
|
||||||
|
|
||||||
impl LanguageExtractor for PythonExtractor {
|
impl LanguageExtractor for PythonExtractor {
|
||||||
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
fn tree_sitter_language(&self) -> tree_sitter::Language {
|
||||||
analyze(source, file_path)
|
tree_sitter_python::LANGUAGE.into()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
let mut parser = Parser::new();
|
// collect_classes handles class elements, inheritance, and field compositions
|
||||||
parser
|
// in a single pass — Python's relationship extraction is interleaved with type extraction
|
||||||
.set_language(&tree_sitter_python::LANGUAGE.into())
|
collect_classes(root, source, ctx);
|
||||||
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
|
}
|
||||||
|
|
||||||
let tree = parser
|
fn extract_relationships(&self, _root: &Node, _source: &str, _ctx: &mut ExtractionContext) {
|
||||||
.parse(source, None)
|
// Relationships are collected inside collect_classes for Python
|
||||||
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
|
}
|
||||||
|
|
||||||
let mut ctx = ExtractionContext::new(file_path.clone());
|
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
let root = tree.root_node();
|
collect_imports(root, source, ctx);
|
||||||
|
}
|
||||||
collect_classes(&root, source, &mut ctx);
|
|
||||||
collect_imports(&root, source, &mut ctx);
|
|
||||||
|
|
||||||
ctx.into_result()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use tree_sitter::{Node, Parser};
|
use tree_sitter::Node;
|
||||||
|
|
||||||
const RUST_PRIMITIVES: &[&str] = &[
|
const RUST_PRIMITIVES: &[&str] = &[
|
||||||
"bool",
|
"bool",
|
||||||
@@ -35,10 +35,7 @@ const RUST_PRIMITIVES: &[&str] = &[
|
|||||||
"Self",
|
"Self",
|
||||||
];
|
];
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind, Visibility};
|
||||||
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
|
|
||||||
RelationshipKind, Visibility,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::extraction_context::ExtractionContext;
|
use crate::extraction_context::ExtractionContext;
|
||||||
use crate::language_extractor::LanguageExtractor;
|
use crate::language_extractor::LanguageExtractor;
|
||||||
@@ -46,30 +43,22 @@ use crate::language_extractor::LanguageExtractor;
|
|||||||
pub struct RustExtractor;
|
pub struct RustExtractor;
|
||||||
|
|
||||||
impl LanguageExtractor for RustExtractor {
|
impl LanguageExtractor for RustExtractor {
|
||||||
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
fn tree_sitter_language(&self) -> tree_sitter::Language {
|
||||||
analyze(source, file_path)
|
tree_sitter_rust::LANGUAGE.into()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
let mut parser = Parser::new();
|
collect_types(root, source, ctx);
|
||||||
parser
|
}
|
||||||
.set_language(&tree_sitter_rust::LANGUAGE.into())
|
|
||||||
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
|
|
||||||
|
|
||||||
let tree = parser
|
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
.parse(source, None)
|
collect_relationships(root, source, ctx);
|
||||||
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
|
}
|
||||||
|
|
||||||
let mut ctx = ExtractionContext::new(file_path.clone());
|
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
let root = tree.root_node();
|
collect_mod_declarations(root, source, ctx);
|
||||||
|
collect_use_imports(root, source, ctx);
|
||||||
collect_types(&root, source, &mut ctx);
|
}
|
||||||
collect_relationships(&root, source, &mut ctx);
|
|
||||||
collect_mod_declarations(&root, source, &mut ctx);
|
|
||||||
collect_use_imports(&root, source, &mut ctx);
|
|
||||||
|
|
||||||
ctx.into_result()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
|
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
|
||||||
|
|
||||||
use crate::language_extractor::LanguageExtractor;
|
use crate::language_extractor::{LanguageExtractor, run_extraction};
|
||||||
use crate::python::PythonExtractor;
|
use crate::python::PythonExtractor;
|
||||||
use crate::rust::RustExtractor;
|
use crate::rust::RustExtractor;
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ impl SourceAnalyzer for TreeSitterAnalyzer {
|
|||||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||||
|
|
||||||
match self.extractor_for(file.language()) {
|
match self.extractor_for(file.language()) {
|
||||||
Some(extractor) => extractor.analyze(&source, file.path()),
|
Some(extractor) => run_extraction(extractor, &source, file.path()),
|
||||||
None => Ok(AnalysisResult::empty()),
|
None => Ok(AnalysisResult::empty()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
crates/application/src/use_cases/build_code_graph.rs
Normal file
69
crates/application/src/use_cases/build_code_graph.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use archlens_domain::{
|
||||||
|
AnalysisConfig, AnalysisWarning, DiagramLevel, DomainError, NormalizedGraph,
|
||||||
|
ports::{FileDiscovery, ProjectAnalyzer, SourceAnalyzer},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::queries::AnalyzeCodebase;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BuildCodeGraphResult {
|
||||||
|
pub graph: NormalizedGraph,
|
||||||
|
pub warnings: Vec<AnalysisWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BuildCodeGraph<F, S>
|
||||||
|
where
|
||||||
|
F: FileDiscovery + Send + Sync,
|
||||||
|
S: SourceAnalyzer,
|
||||||
|
{
|
||||||
|
pub discovery: F,
|
||||||
|
pub source_analyzer: S,
|
||||||
|
pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F, S> BuildCodeGraph<F, S>
|
||||||
|
where
|
||||||
|
F: FileDiscovery + Send + Sync,
|
||||||
|
S: SourceAnalyzer,
|
||||||
|
{
|
||||||
|
pub fn execute(
|
||||||
|
self,
|
||||||
|
root: &Path,
|
||||||
|
config: &AnalysisConfig,
|
||||||
|
level: DiagramLevel,
|
||||||
|
) -> Result<BuildCodeGraphResult, DomainError> {
|
||||||
|
match level {
|
||||||
|
DiagramLevel::Project => {
|
||||||
|
let pa = self.project_analyzer.ok_or_else(|| {
|
||||||
|
DomainError::AnalysisError(
|
||||||
|
"no project analyzer available for Project level".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let cg = pa.analyze(root)?;
|
||||||
|
Ok(BuildCodeGraphResult {
|
||||||
|
graph: NormalizedGraph::from_project(cg),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DiagramLevel::Module | DiagramLevel::Type => {
|
||||||
|
let analyze = AnalyzeCodebase::new(self.discovery, self.source_analyzer);
|
||||||
|
let result = analyze.execute(root, config)?;
|
||||||
|
let mut graph = result.graph().clone();
|
||||||
|
if level == DiagramLevel::Module
|
||||||
|
&& let Some(pa) = self.project_analyzer
|
||||||
|
{
|
||||||
|
// Propagate error: a present but malformed manifest is a user error,
|
||||||
|
// not a silent skip.
|
||||||
|
let project_cg = pa.analyze(root)?;
|
||||||
|
graph.merge_project_edges(&project_cg);
|
||||||
|
}
|
||||||
|
Ok(BuildCodeGraphResult {
|
||||||
|
graph,
|
||||||
|
warnings: result.warnings().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
|
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
|
||||||
|
|
||||||
/// Compares the current rendered output against an on-disk file.
|
/// Compares the current rendered output against provided file content.
|
||||||
/// Returns `Ok(true)` if up to date, `Ok(false)` if stale.
|
/// Returns `Ok(true)` if up to date, `Ok(false)` if stale.
|
||||||
pub struct CheckFreshness<'a> {
|
pub struct CheckFreshness<'a> {
|
||||||
pub graph: &'a NormalizedGraph,
|
pub graph: &'a NormalizedGraph,
|
||||||
pub renderer: &'a dyn DiagramRenderer,
|
pub renderer: &'a dyn DiagramRenderer,
|
||||||
pub existing_path: &'a std::path::Path,
|
pub existing_content: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> CheckFreshness<'a> {
|
impl<'a> CheckFreshness<'a> {
|
||||||
pub fn execute(&self) -> Result<bool, DomainError> {
|
pub fn execute(&self) -> Result<bool, DomainError> {
|
||||||
let rendered = self.renderer.render(self.graph.as_graph())?;
|
let rendered = self.renderer.render(self.graph.as_graph())?;
|
||||||
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||||
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
|
Ok(current == self.existing_content)
|
||||||
Ok(current == existing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ impl DiffResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compares the rendered current graph against an existing diagram file and
|
/// Compares the rendered current graph against provided diagram content and
|
||||||
/// returns which lines were added or removed.
|
/// returns which lines were added or removed.
|
||||||
pub struct DiffDiagram<'a> {
|
pub struct DiffDiagram<'a> {
|
||||||
pub graph: &'a NormalizedGraph,
|
pub graph: &'a NormalizedGraph,
|
||||||
pub renderer: &'a dyn DiagramRenderer,
|
pub renderer: &'a dyn DiagramRenderer,
|
||||||
pub existing_path: &'a std::path::Path,
|
pub existing_content: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> DiffDiagram<'a> {
|
impl<'a> DiffDiagram<'a> {
|
||||||
pub fn execute(&self) -> Result<DiffResult, DomainError> {
|
pub fn execute(&self) -> Result<DiffResult, DomainError> {
|
||||||
let rendered = self.renderer.render(self.graph.as_graph())?;
|
let rendered = self.renderer.render(self.graph.as_graph())?;
|
||||||
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||||
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
|
|
||||||
|
|
||||||
let current_lines: std::collections::HashSet<&str> = current.lines().collect();
|
let current_lines: std::collections::HashSet<&str> = current.lines().collect();
|
||||||
let existing_lines: std::collections::HashSet<&str> = existing.lines().collect();
|
let existing_lines: std::collections::HashSet<&str> =
|
||||||
|
self.existing_content.lines().collect();
|
||||||
|
|
||||||
let added: Vec<String> = current_lines
|
let added: Vec<String> = current_lines
|
||||||
.difference(&existing_lines)
|
.difference(&existing_lines)
|
||||||
|
|||||||
@@ -1,122 +1,67 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, check_boundary_rules,
|
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, RuleViolation,
|
||||||
ports::DiagramRenderer,
|
check_boundary_rules, ports::DiagramRenderer,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Result of running the generate use case — exposed violations and any output
|
|
||||||
/// that should be written to disk.
|
|
||||||
pub struct GenerateDiagramResult {
|
pub struct GenerateDiagramResult {
|
||||||
pub violations: Vec<String>,
|
pub violations: Vec<RuleViolation>,
|
||||||
pub output: RenderOutput,
|
pub output: RenderOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates diagram generation: renders the graph (split or single),
|
|
||||||
/// checks boundary rules, and returns the output for the caller to write.
|
|
||||||
pub struct GenerateDiagram {
|
pub struct GenerateDiagram {
|
||||||
pub graph: NormalizedGraph,
|
pub graph: NormalizedGraph,
|
||||||
pub renderer: Box<dyn DiagramRenderer>,
|
pub renderer: Box<dyn DiagramRenderer>,
|
||||||
pub allow_rules: Vec<BoundaryRule>,
|
pub allow_rules: Vec<BoundaryRule>,
|
||||||
pub deny_rules: Vec<BoundaryRule>,
|
pub deny_rules: Vec<BoundaryRule>,
|
||||||
pub split_by_module: bool,
|
pub split_by_module: bool,
|
||||||
pub format_ext: String,
|
|
||||||
pub output_dir: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GenerateDiagram {
|
impl GenerateDiagram {
|
||||||
pub fn execute(self) -> Result<(), DomainError> {
|
pub fn execute(self) -> Result<GenerateDiagramResult, DomainError> {
|
||||||
// Boundary rule checking
|
|
||||||
let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() {
|
let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() {
|
||||||
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
|
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render and write
|
let output = if self.split_by_module {
|
||||||
if self.split_by_module {
|
render_split(&self.graph, &*self.renderer)?
|
||||||
write_split(
|
|
||||||
&self.graph,
|
|
||||||
&*self.renderer,
|
|
||||||
&self.output_dir,
|
|
||||||
&self.format_ext,
|
|
||||||
)?;
|
|
||||||
} else {
|
} else {
|
||||||
let rendered = self.renderer.render(self.graph.as_graph())?;
|
self.renderer.render(self.graph.as_graph())?
|
||||||
write_to_output(rendered, &self.output_dir)?;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Report violations (after writing so the diagram is still produced)
|
Ok(GenerateDiagramResult { violations, output })
|
||||||
for v in &violations {
|
|
||||||
eprintln!("RULE VIOLATION: {}", v.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_violations_only(&self) -> Vec<String> {
|
|
||||||
if self.allow_rules.is_empty() && self.deny_rules.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
|
|
||||||
.into_iter()
|
|
||||||
.map(|v| v.message())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_split(
|
fn render_split(
|
||||||
graph: &NormalizedGraph,
|
graph: &NormalizedGraph,
|
||||||
renderer: &dyn DiagramRenderer,
|
renderer: &dyn DiagramRenderer,
|
||||||
output_dir: &Option<PathBuf>,
|
) -> Result<RenderOutput, DomainError> {
|
||||||
ext: &str,
|
let mut files = Vec::new();
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let dir = output_dir.clone().unwrap_or_else(|| PathBuf::from("."));
|
|
||||||
|
|
||||||
let overview = renderer.render(graph.as_graph())?;
|
let overview = renderer.render(graph.as_graph())?;
|
||||||
let overview_file = RenderedFile::new(
|
let ext = overview
|
||||||
&format!("overview.{ext}"),
|
.files()
|
||||||
overview.files().first().map(|f| f.content()).unwrap_or(""),
|
.first()
|
||||||
)?;
|
.and_then(|f| f.name().rsplit_once('.').map(|(_, e)| e))
|
||||||
write_file_to_dir(&dir, overview_file)?;
|
.unwrap_or("txt");
|
||||||
|
|
||||||
|
if let Some(f) = overview.files().first() {
|
||||||
|
files.push(RenderedFile::new(&format!("overview.{ext}"), f.content())?);
|
||||||
|
}
|
||||||
|
|
||||||
for module in graph.modules() {
|
for module in graph.modules() {
|
||||||
let subgraph = graph.subgraph_by_module(&module);
|
let subgraph = graph.subgraph_by_module(&module);
|
||||||
let cross_deps = graph.cross_module_deps_for(&module);
|
let cross_deps = graph.cross_module_deps_for(&module);
|
||||||
let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
|
let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
|
||||||
let module_file = RenderedFile::new(
|
if let Some(f) = module_output.files().first() {
|
||||||
|
files.push(RenderedFile::new(
|
||||||
&format!("{}.{ext}", module.as_str().to_lowercase()),
|
&format!("{}.{ext}", module.as_str().to_lowercase()),
|
||||||
module_output
|
f.content(),
|
||||||
.files()
|
)?);
|
||||||
.first()
|
}
|
||||||
.map(|f| f.content())
|
|
||||||
.unwrap_or(""),
|
|
||||||
)?;
|
|
||||||
write_file_to_dir(&dir, module_file)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(RenderOutput::new(files))
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file_to_dir(dir: &PathBuf, file: RenderedFile) -> Result<(), DomainError> {
|
|
||||||
let path = dir.join(file.name());
|
|
||||||
std::fs::create_dir_all(dir).map_err(|e| DomainError::IoError(e.to_string()))?;
|
|
||||||
std::fs::write(&path, file.content()).map_err(|e| DomainError::IoError(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_to_output(rendered: RenderOutput, output: &Option<PathBuf>) -> Result<(), DomainError> {
|
|
||||||
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
|
||||||
match output {
|
|
||||||
Some(path) => {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| DomainError::IoError(e.to_string()))?;
|
|
||||||
}
|
|
||||||
std::fs::write(path, content).map_err(|e| DomainError::IoError(e.to_string()))
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
print!("{content}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod build_code_graph;
|
||||||
pub mod check_freshness;
|
pub mod check_freshness;
|
||||||
pub mod diff_diagram;
|
pub mod diff_diagram;
|
||||||
pub mod generate_diagram;
|
pub mod generate_diagram;
|
||||||
|
|||||||
176
crates/application/tests/build_code_graph_tests.rs
Normal file
176
crates/application/tests/build_code_graph_tests.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
mod fakes;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use archlens_application::use_cases::build_code_graph::BuildCodeGraph;
|
||||||
|
use archlens_domain::{
|
||||||
|
AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, CodeGraph,
|
||||||
|
DiagramLevel, FilePath, Language, SourceFile,
|
||||||
|
};
|
||||||
|
|
||||||
|
use fakes::{FakeFileDiscovery, FakeProjectAnalyzer, FakeSourceAnalyzer};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_level_returns_project_analyzer_graph() {
|
||||||
|
let mut cg = CodeGraph::new();
|
||||||
|
cg.add_element(
|
||||||
|
CodeElement::new(
|
||||||
|
"MyApp",
|
||||||
|
CodeElementKind::Project,
|
||||||
|
FilePath::new("Cargo.toml").unwrap(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let use_case = BuildCodeGraph {
|
||||||
|
discovery: FakeFileDiscovery::empty(),
|
||||||
|
source_analyzer: FakeSourceAnalyzer::new(),
|
||||||
|
project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(cg))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = use_case
|
||||||
|
.execute(
|
||||||
|
Path::new("."),
|
||||||
|
&AnalysisConfig::default(),
|
||||||
|
DiagramLevel::Project,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.graph.elements().len(), 1);
|
||||||
|
assert_eq!(result.graph.elements()[0].name(), "MyApp");
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_level_without_analyzer_returns_error() {
|
||||||
|
let use_case = BuildCodeGraph {
|
||||||
|
discovery: FakeFileDiscovery::empty(),
|
||||||
|
source_analyzer: FakeSourceAnalyzer::new(),
|
||||||
|
project_analyzer: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = use_case
|
||||||
|
.execute(
|
||||||
|
Path::new("."),
|
||||||
|
&AnalysisConfig::default(),
|
||||||
|
DiagramLevel::Project,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("no project analyzer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_level_uses_source_analyzer_not_project() {
|
||||||
|
let files = vec![SourceFile::new(
|
||||||
|
FilePath::new("src/order.rs").unwrap(),
|
||||||
|
Language::Rust,
|
||||||
|
)];
|
||||||
|
let discovery = FakeFileDiscovery::new(files);
|
||||||
|
let analyzer = FakeSourceAnalyzer::new().with_result(
|
||||||
|
"src/order.rs",
|
||||||
|
AnalysisResult::new(
|
||||||
|
vec![
|
||||||
|
CodeElement::new(
|
||||||
|
"Order",
|
||||||
|
CodeElementKind::Struct,
|
||||||
|
FilePath::new("src/order.rs").unwrap(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut project_cg = CodeGraph::new();
|
||||||
|
project_cg.add_element(
|
||||||
|
CodeElement::new(
|
||||||
|
"ProjectElement",
|
||||||
|
CodeElementKind::Project,
|
||||||
|
FilePath::new("Cargo.toml").unwrap(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let use_case = BuildCodeGraph {
|
||||||
|
discovery,
|
||||||
|
source_analyzer: analyzer,
|
||||||
|
project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(project_cg))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = use_case
|
||||||
|
.execute(
|
||||||
|
Path::new("."),
|
||||||
|
&AnalysisConfig::default(),
|
||||||
|
DiagramLevel::Type,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Source element present, project element NOT merged (Type level skips merge)
|
||||||
|
assert_eq!(result.graph.elements().len(), 1);
|
||||||
|
assert_eq!(result.graph.elements()[0].name(), "Order");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn module_level_without_project_analyzer_succeeds() {
|
||||||
|
let use_case = BuildCodeGraph {
|
||||||
|
discovery: FakeFileDiscovery::empty(),
|
||||||
|
source_analyzer: FakeSourceAnalyzer::new(),
|
||||||
|
project_analyzer: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = use_case
|
||||||
|
.execute(
|
||||||
|
Path::new("."),
|
||||||
|
&AnalysisConfig::default(),
|
||||||
|
DiagramLevel::Module,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.graph.elements().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warnings_from_source_analysis_are_propagated() {
|
||||||
|
let files = vec![SourceFile::new(
|
||||||
|
FilePath::new("src/broken.rs").unwrap(),
|
||||||
|
Language::Rust,
|
||||||
|
)];
|
||||||
|
let discovery = FakeFileDiscovery::new(files);
|
||||||
|
let analyzer = FakeSourceAnalyzer::new().with_result(
|
||||||
|
"src/broken.rs",
|
||||||
|
AnalysisResult::new(
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
AnalysisWarning::new(
|
||||||
|
FilePath::new("src/broken.rs").unwrap(),
|
||||||
|
5,
|
||||||
|
"unparseable block",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let use_case = BuildCodeGraph {
|
||||||
|
discovery,
|
||||||
|
source_analyzer: analyzer,
|
||||||
|
project_analyzer: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = use_case
|
||||||
|
.execute(
|
||||||
|
Path::new("."),
|
||||||
|
&AnalysisConfig::default(),
|
||||||
|
DiagramLevel::Module,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].message(), "unparseable block");
|
||||||
|
}
|
||||||
43
crates/application/tests/check_freshness_tests.rs
Normal file
43
crates/application/tests/check_freshness_tests.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
mod fakes;
|
||||||
|
|
||||||
|
use archlens_application::use_cases::check_freshness::CheckFreshness;
|
||||||
|
use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer};
|
||||||
|
use fakes::FakeDiagramRenderer;
|
||||||
|
|
||||||
|
fn empty_graph() -> NormalizedGraph {
|
||||||
|
NormalizedGraph::from_project(CodeGraph::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_true_when_content_matches() {
|
||||||
|
let graph = empty_graph();
|
||||||
|
let renderer = FakeDiagramRenderer::new();
|
||||||
|
let rendered = renderer.render(graph.as_graph()).unwrap();
|
||||||
|
let existing = rendered.files().first().unwrap().content().to_string();
|
||||||
|
|
||||||
|
let result = CheckFreshness {
|
||||||
|
graph: &graph,
|
||||||
|
renderer: &renderer,
|
||||||
|
existing_content: &existing,
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_false_when_content_differs() {
|
||||||
|
let graph = empty_graph();
|
||||||
|
let renderer = FakeDiagramRenderer::new();
|
||||||
|
|
||||||
|
let result = CheckFreshness {
|
||||||
|
graph: &graph,
|
||||||
|
renderer: &renderer,
|
||||||
|
existing_content: "stale content that does not match",
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
44
crates/application/tests/diff_diagram_tests.rs
Normal file
44
crates/application/tests/diff_diagram_tests.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
mod fakes;
|
||||||
|
|
||||||
|
use archlens_application::use_cases::diff_diagram::DiffDiagram;
|
||||||
|
use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer};
|
||||||
|
use fakes::FakeDiagramRenderer;
|
||||||
|
|
||||||
|
fn empty_graph() -> NormalizedGraph {
|
||||||
|
NormalizedGraph::from_project(CodeGraph::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_diff_when_content_matches() {
|
||||||
|
let graph = empty_graph();
|
||||||
|
let renderer = FakeDiagramRenderer::new();
|
||||||
|
let rendered = renderer.render(graph.as_graph()).unwrap();
|
||||||
|
let existing = rendered.files().first().unwrap().content().to_string();
|
||||||
|
|
||||||
|
let result = DiffDiagram {
|
||||||
|
graph: &graph,
|
||||||
|
renderer: &renderer,
|
||||||
|
existing_content: &existing,
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_added_lines() {
|
||||||
|
let graph = empty_graph();
|
||||||
|
let renderer = FakeDiagramRenderer::new();
|
||||||
|
|
||||||
|
let result = DiffDiagram {
|
||||||
|
graph: &graph,
|
||||||
|
renderer: &renderer,
|
||||||
|
existing_content: "old line that will be removed",
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.added.is_empty());
|
||||||
|
assert!(!result.removed.is_empty());
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
mod diagram_renderer;
|
mod diagram_renderer;
|
||||||
mod file_discovery;
|
mod file_discovery;
|
||||||
mod output_writer;
|
mod output_writer;
|
||||||
|
mod project_analyzer;
|
||||||
mod source_analyzer;
|
mod source_analyzer;
|
||||||
|
|
||||||
pub use diagram_renderer::FakeDiagramRenderer;
|
pub use diagram_renderer::FakeDiagramRenderer;
|
||||||
pub use file_discovery::FakeFileDiscovery;
|
pub use file_discovery::FakeFileDiscovery;
|
||||||
pub use output_writer::FakeOutputWriter;
|
pub use output_writer::FakeOutputWriter;
|
||||||
|
pub use project_analyzer::FakeProjectAnalyzer;
|
||||||
pub use source_analyzer::FakeSourceAnalyzer;
|
pub use source_analyzer::FakeSourceAnalyzer;
|
||||||
|
|||||||
25
crates/application/tests/fakes/project_analyzer.rs
Normal file
25
crates/application/tests/fakes/project_analyzer.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use archlens_domain::{CodeGraph, DomainError, ports::ProjectAnalyzer};
|
||||||
|
|
||||||
|
pub struct FakeProjectAnalyzer {
|
||||||
|
graph: CodeGraph,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProjectAnalyzer {
|
||||||
|
pub fn new(graph: CodeGraph) -> Self {
|
||||||
|
Self { graph }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
graph: CodeGraph::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectAnalyzer for FakeProjectAnalyzer {
|
||||||
|
fn analyze(&self, _root: &Path) -> Result<CodeGraph, DomainError> {
|
||||||
|
Ok(self.graph.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
125
crates/application/tests/generate_diagram_tests.rs
Normal file
125
crates/application/tests/generate_diagram_tests.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
mod fakes;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use archlens_application::queries::AnalyzeCodebase;
|
||||||
|
use archlens_application::use_cases::generate_diagram::GenerateDiagram;
|
||||||
|
use archlens_domain::{
|
||||||
|
AnalysisConfig, AnalysisResult, BoundaryRule, CodeElement, CodeElementKind, CodeGraph,
|
||||||
|
FilePath, Language, ModuleName, NormalizedGraph, Relationship, RelationshipKind, SourceFile,
|
||||||
|
};
|
||||||
|
|
||||||
|
use fakes::{FakeDiagramRenderer, FakeFileDiscovery, FakeSourceAnalyzer};
|
||||||
|
|
||||||
|
fn empty_graph() -> NormalizedGraph {
|
||||||
|
NormalizedGraph::from_project(CodeGraph::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_with_one_module() -> NormalizedGraph {
|
||||||
|
let files = vec![SourceFile::new(
|
||||||
|
FilePath::new("/p/src/orders/order.rs").unwrap(),
|
||||||
|
Language::Rust,
|
||||||
|
)];
|
||||||
|
let discovery = FakeFileDiscovery::new(files);
|
||||||
|
let analyzer = FakeSourceAnalyzer::new().with_result(
|
||||||
|
"/p/src/orders/order.rs",
|
||||||
|
AnalysisResult::new(
|
||||||
|
vec![
|
||||||
|
CodeElement::new(
|
||||||
|
"Order",
|
||||||
|
CodeElementKind::Struct,
|
||||||
|
FilePath::new("/p/src/orders/order.rs").unwrap(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AnalyzeCodebase::new(discovery, analyzer)
|
||||||
|
.execute(Path::new("/p"), &AnalysisConfig::default())
|
||||||
|
.unwrap()
|
||||||
|
.graph()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_with_violation() -> NormalizedGraph {
|
||||||
|
let mut cg = CodeGraph::new();
|
||||||
|
let a = CodeElement::new(
|
||||||
|
"A",
|
||||||
|
CodeElementKind::Struct,
|
||||||
|
FilePath::new("a.rs").unwrap(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_module(ModuleName::new("Alpha").unwrap());
|
||||||
|
let b = CodeElement::new(
|
||||||
|
"B",
|
||||||
|
CodeElementKind::Struct,
|
||||||
|
FilePath::new("b.rs").unwrap(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_module(ModuleName::new("Beta").unwrap());
|
||||||
|
cg.add_element(a);
|
||||||
|
cg.add_element(b);
|
||||||
|
cg.add_relationship(Relationship::new("A", "B", RelationshipKind::Composition).unwrap());
|
||||||
|
NormalizedGraph::from_project(cg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_returns_render_output_without_writing_files() {
|
||||||
|
let use_case = GenerateDiagram {
|
||||||
|
graph: empty_graph(),
|
||||||
|
renderer: Box::new(FakeDiagramRenderer::new()),
|
||||||
|
allow_rules: vec![],
|
||||||
|
deny_rules: vec![],
|
||||||
|
split_by_module: false,
|
||||||
|
};
|
||||||
|
let result = use_case.execute().unwrap();
|
||||||
|
assert!(!result.output.files().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_returns_empty_violations_when_no_rules_set() {
|
||||||
|
let use_case = GenerateDiagram {
|
||||||
|
graph: empty_graph(),
|
||||||
|
renderer: Box::new(FakeDiagramRenderer::new()),
|
||||||
|
allow_rules: vec![],
|
||||||
|
deny_rules: vec![],
|
||||||
|
split_by_module: false,
|
||||||
|
};
|
||||||
|
let result = use_case.execute().unwrap();
|
||||||
|
assert!(result.violations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_returns_violations_as_rule_violation_type() {
|
||||||
|
let deny = vec![BoundaryRule::parse("Alpha --> Beta").unwrap()];
|
||||||
|
let use_case = GenerateDiagram {
|
||||||
|
graph: graph_with_violation(),
|
||||||
|
renderer: Box::new(FakeDiagramRenderer::new()),
|
||||||
|
allow_rules: vec![],
|
||||||
|
deny_rules: deny,
|
||||||
|
split_by_module: false,
|
||||||
|
};
|
||||||
|
let result = use_case.execute().unwrap();
|
||||||
|
assert_eq!(result.violations.len(), 1);
|
||||||
|
assert!(result.violations[0].message().contains("Alpha"));
|
||||||
|
assert!(result.violations[0].message().contains("Beta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_by_module_returns_multiple_rendered_files() {
|
||||||
|
let use_case = GenerateDiagram {
|
||||||
|
graph: graph_with_one_module(),
|
||||||
|
renderer: Box::new(FakeDiagramRenderer::new()),
|
||||||
|
allow_rules: vec![],
|
||||||
|
deny_rules: vec![],
|
||||||
|
split_by_module: true,
|
||||||
|
};
|
||||||
|
let result = use_case.execute().unwrap();
|
||||||
|
// overview + at least one module file
|
||||||
|
assert!(result.output.files().len() >= 2);
|
||||||
|
}
|
||||||
@@ -2,17 +2,15 @@ mod cli;
|
|||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
use archlens_application::queries::AnalyzeCodebase;
|
|
||||||
use archlens_application::use_cases::{
|
use archlens_application::use_cases::{
|
||||||
check_freshness::CheckFreshness,
|
build_code_graph::BuildCodeGraph, check_freshness::CheckFreshness, diff_diagram::DiffDiagram,
|
||||||
diff_diagram::DiffDiagram,
|
generate_diagram::GenerateDiagram,
|
||||||
generate_diagram::{GenerateDiagram, write_split},
|
|
||||||
};
|
};
|
||||||
use archlens_ascii::AsciiRenderer;
|
use archlens_ascii::AsciiRenderer;
|
||||||
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
||||||
use archlens_d2::D2Renderer;
|
use archlens_d2::D2Renderer;
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
BoundaryRule, DiagramLevel, NormalizedGraph,
|
BoundaryRule, DiagramLevel, NormalizedGraph, RenderOutput,
|
||||||
ports::{ConfigLoader, ProjectAnalyzer},
|
ports::{ConfigLoader, ProjectAnalyzer},
|
||||||
};
|
};
|
||||||
use archlens_html::HtmlRenderer;
|
use archlens_html::HtmlRenderer;
|
||||||
@@ -46,10 +44,12 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
let existing_path = args.output.as_ref().ok_or_else(|| {
|
let existing_path = args.output.as_ref().ok_or_else(|| {
|
||||||
anyhow::anyhow!("--check requires --output to specify the file to check against")
|
anyhow::anyhow!("--check requires --output to specify the file to check against")
|
||||||
})?;
|
})?;
|
||||||
|
let existing_content = std::fs::read_to_string(existing_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot read {existing_path}: {e}"))?;
|
||||||
let up_to_date = CheckFreshness {
|
let up_to_date = CheckFreshness {
|
||||||
graph: &graph,
|
graph: &graph,
|
||||||
renderer: &*renderer,
|
renderer: &*renderer,
|
||||||
existing_path: std::path::Path::new(existing_path),
|
existing_content: &existing_content,
|
||||||
}
|
}
|
||||||
.execute()?;
|
.execute()?;
|
||||||
if up_to_date {
|
if up_to_date {
|
||||||
@@ -70,7 +70,6 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|s| BoundaryRule::parse(s))
|
.filter_map(|s| BoundaryRule::parse(s))
|
||||||
.collect();
|
.collect();
|
||||||
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
|
|
||||||
|
|
||||||
let use_case = GenerateDiagram {
|
let use_case = GenerateDiagram {
|
||||||
graph,
|
graph,
|
||||||
@@ -78,22 +77,48 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
allow_rules: allow,
|
allow_rules: allow,
|
||||||
deny_rules: deny,
|
deny_rules: deny,
|
||||||
split_by_module: args.split_by_module,
|
split_by_module: args.split_by_module,
|
||||||
format_ext: format_extension(&args.format).to_string(),
|
|
||||||
output_dir,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let violations = use_case.check_violations_only();
|
let result = use_case.execute()?;
|
||||||
if args.strict && !violations.is_empty() {
|
if args.strict && !result.violations.is_empty() {
|
||||||
bail!(
|
bail!(
|
||||||
"{} boundary rule violation(s) in strict mode",
|
"{} boundary rule violation(s) in strict mode",
|
||||||
violations.len()
|
result.violations.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
use_case.execute()?;
|
for v in &result.violations {
|
||||||
|
eprintln!("RULE VIOLATION: {}", v.message());
|
||||||
|
}
|
||||||
|
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_diagram_output(
|
||||||
|
output: &RenderOutput,
|
||||||
|
output_path: Option<&str>,
|
||||||
|
split: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
if split {
|
||||||
|
let dir = std::path::PathBuf::from(output_path.unwrap_or("."));
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
for file in output.files() {
|
||||||
|
std::fs::write(dir.join(file.name()), file.content())?;
|
||||||
|
}
|
||||||
|
} else if let Some(path) = output_path {
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
if let Some(parent) = p.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = output.files().first().map(|f| f.content()).unwrap_or("");
|
||||||
|
std::fs::write(p, content)?;
|
||||||
|
} else {
|
||||||
|
let content = output.files().first().map(|f| f.content()).unwrap_or("");
|
||||||
|
print!("{content}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
|
fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
|
||||||
match &args.config {
|
match &args.config {
|
||||||
Some(path) => Ok(TomlConfigLoader::from_path(std::path::Path::new(path))?),
|
Some(path) => Ok(TomlConfigLoader::from_path(std::path::Path::new(path))?),
|
||||||
@@ -129,23 +154,28 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
|
|||||||
analysis_config = analysis_config.with_changed_files(changed);
|
analysis_config = analysis_config.with_changed_files(changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
if level == DiagramLevel::Project {
|
let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
|
||||||
let cargo_toml = args.path.join("Cargo.toml");
|
let cargo_toml = args.path.join("Cargo.toml");
|
||||||
let project_graph = if cargo_toml.exists() {
|
let pyproject = args.path.join("pyproject.toml");
|
||||||
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
|
if cargo_toml.exists() {
|
||||||
|
Some(Box::new(CargoWorkspaceAnalyzer::new()))
|
||||||
|
} else if pyproject.exists() {
|
||||||
|
Some(Box::new(PythonProjectAnalyzer::new()))
|
||||||
} else {
|
} else {
|
||||||
PythonProjectAnalyzer::new().analyze(&args.path)?
|
None
|
||||||
};
|
|
||||||
return Ok(NormalizedGraph::from_project(project_graph));
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let discovery = WalkdirDiscovery::new();
|
let use_case = BuildCodeGraph {
|
||||||
let analyzer = TreeSitterAnalyzer::new();
|
discovery: WalkdirDiscovery::new(),
|
||||||
let analyze = AnalyzeCodebase::new(discovery, analyzer);
|
source_analyzer: TreeSitterAnalyzer::new(),
|
||||||
let result = analyze.execute(&args.path, &analysis_config)?;
|
project_analyzer,
|
||||||
|
};
|
||||||
|
|
||||||
if !result.warnings().is_empty() {
|
let result = use_case.execute(&args.path, &analysis_config, level)?;
|
||||||
for warning in result.warnings() {
|
|
||||||
|
if !result.warnings.is_empty() {
|
||||||
|
for warning in &result.warnings {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"WARNING: {}:{} {}",
|
"WARNING: {}:{} {}",
|
||||||
warning.file_path().as_str(),
|
warning.file_path().as_str(),
|
||||||
@@ -156,26 +186,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
|
|||||||
if args.strict {
|
if args.strict {
|
||||||
bail!(
|
bail!(
|
||||||
"analysis produced {} warning(s) in strict mode",
|
"analysis produced {} warning(s) in strict mode",
|
||||||
result.warnings().len()
|
result.warnings.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut graph = result.graph().clone();
|
Ok(result.graph)
|
||||||
|
|
||||||
if level == DiagramLevel::Module {
|
|
||||||
let workspace_toml = args.path.join("Cargo.toml");
|
|
||||||
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 {
|
|
||||||
graph.merge_project_edges(&pg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(graph)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_renderer(
|
fn create_renderer(
|
||||||
@@ -194,15 +210,6 @@ fn create_renderer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_extension(format: &str) -> &str {
|
|
||||||
match format {
|
|
||||||
"mermaid" => "mmd",
|
|
||||||
"d2" => "d2",
|
|
||||||
"html" => "html",
|
|
||||||
_ => "txt",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
||||||
init_tracing(args.verbose);
|
init_tracing(args.verbose);
|
||||||
|
|
||||||
@@ -210,10 +217,12 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
|||||||
let graph = build_graph(args, level)?;
|
let graph = build_graph(args, level)?;
|
||||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
||||||
|
|
||||||
|
let existing_content = std::fs::read_to_string(existing_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", existing_path.display()))?;
|
||||||
let diff = DiffDiagram {
|
let diff = DiffDiagram {
|
||||||
graph: &graph,
|
graph: &graph,
|
||||||
renderer: &*renderer,
|
renderer: &*renderer,
|
||||||
existing_path,
|
existing_content: &existing_content,
|
||||||
}
|
}
|
||||||
.execute()?;
|
.execute()?;
|
||||||
|
|
||||||
@@ -332,25 +341,12 @@ fn run_watch(args: Cli) -> Result<()> {
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
let level = parse_level(&args.level);
|
let level = parse_level(&args.level);
|
||||||
let ext = format_extension(&args.format);
|
|
||||||
let debounce = Duration::from_millis(500);
|
let debounce = Duration::from_millis(500);
|
||||||
|
|
||||||
let run_once = |args: &Cli| -> Result<()> {
|
let run_once = |args: &Cli| -> Result<()> {
|
||||||
let config_loader = load_config(args)?;
|
let config_loader = load_config(args)?;
|
||||||
let graph = build_graph(args, level)?;
|
let graph = build_graph(args, level)?;
|
||||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
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, &output_dir, ext)?;
|
|
||||||
} else {
|
|
||||||
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 (raw_allow, raw_deny) = config_loader.load_rules();
|
||||||
let allow: Vec<BoundaryRule> = raw_allow
|
let allow: Vec<BoundaryRule> = raw_allow
|
||||||
@@ -361,19 +357,18 @@ fn run_watch(args: Cli) -> Result<()> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|s| BoundaryRule::parse(s))
|
.filter_map(|s| BoundaryRule::parse(s))
|
||||||
.collect();
|
.collect();
|
||||||
if !allow.is_empty() || !deny.is_empty() {
|
|
||||||
let use_case = GenerateDiagram {
|
let use_case = GenerateDiagram {
|
||||||
graph,
|
graph,
|
||||||
renderer,
|
renderer,
|
||||||
allow_rules: allow,
|
allow_rules: allow,
|
||||||
deny_rules: deny,
|
deny_rules: deny,
|
||||||
split_by_module: false,
|
split_by_module: args.split_by_module,
|
||||||
format_ext: ext.to_string(),
|
|
||||||
output_dir: None,
|
|
||||||
};
|
};
|
||||||
for v in use_case.check_violations_only() {
|
let result = use_case.execute()?;
|
||||||
eprintln!("RULE VIOLATION: {v}");
|
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
|
||||||
}
|
for v in &result.violations {
|
||||||
|
eprintln!("RULE VIOLATION: {}", v.message());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ graph TD
|
|||||||
Presentation[Presentation]
|
Presentation[Presentation]
|
||||||
Adapters[Adapters]
|
Adapters[Adapters]
|
||||||
Application[Application]
|
Application[Application]
|
||||||
Presentation -->|1 dep| Application
|
|
||||||
Adapters -->|25 deps| Domain
|
|
||||||
Presentation -->|1 dep| Domain
|
Presentation -->|1 dep| Domain
|
||||||
|
Adapters -->|26 deps| Domain
|
||||||
Presentation -->|11 deps| Adapters
|
Presentation -->|11 deps| Adapters
|
||||||
Application -->|4 deps| Domain
|
Application -->|5 deps| Domain
|
||||||
|
Presentation -->|1 dep| Application
|
||||||
@@ -14,29 +14,35 @@ graph TD
|
|||||||
archlens_python_project[archlens-python-project]
|
archlens_python_project[archlens-python-project]
|
||||||
archlens_d2[archlens-d2]
|
archlens_d2[archlens-d2]
|
||||||
archlens_html[archlens-html]
|
archlens_html[archlens-html]
|
||||||
|
archlens_rendering_primitives[archlens-rendering-primitives]
|
||||||
end
|
end
|
||||||
archlens_application --> archlens_domain
|
archlens_application --> archlens_domain
|
||||||
archlens --> archlens_application
|
|
||||||
archlens --> archlens_mermaid
|
|
||||||
archlens --> archlens_stdout_writer
|
|
||||||
archlens --> archlens_ascii
|
|
||||||
archlens --> archlens_domain
|
|
||||||
archlens --> archlens_file_writer
|
|
||||||
archlens --> archlens_python_project
|
|
||||||
archlens --> archlens_walkdir
|
|
||||||
archlens --> archlens_toml_config
|
|
||||||
archlens --> archlens_html
|
|
||||||
archlens --> archlens_cargo_workspace
|
|
||||||
archlens --> archlens_d2
|
|
||||||
archlens --> archlens_tree_sitter
|
archlens --> archlens_tree_sitter
|
||||||
|
archlens --> archlens_walkdir
|
||||||
|
archlens --> archlens_application
|
||||||
|
archlens --> archlens_stdout_writer
|
||||||
|
archlens --> archlens_html
|
||||||
|
archlens --> archlens_file_writer
|
||||||
|
archlens --> archlens_mermaid
|
||||||
|
archlens --> archlens_toml_config
|
||||||
|
archlens --> archlens_cargo_workspace
|
||||||
|
archlens --> archlens_python_project
|
||||||
|
archlens --> archlens_ascii
|
||||||
|
archlens --> archlens_d2
|
||||||
|
archlens --> archlens_domain
|
||||||
archlens_tree_sitter --> archlens_domain
|
archlens_tree_sitter --> archlens_domain
|
||||||
archlens_walkdir --> archlens_domain
|
archlens_walkdir --> archlens_domain
|
||||||
archlens_mermaid --> archlens_domain
|
archlens_mermaid --> archlens_domain
|
||||||
|
archlens_mermaid --> archlens_rendering_primitives
|
||||||
|
archlens_ascii --> archlens_rendering_primitives
|
||||||
archlens_ascii --> archlens_domain
|
archlens_ascii --> archlens_domain
|
||||||
archlens_file_writer --> archlens_domain
|
archlens_file_writer --> archlens_domain
|
||||||
archlens_stdout_writer --> archlens_domain
|
archlens_stdout_writer --> archlens_domain
|
||||||
archlens_toml_config --> archlens_domain
|
archlens_toml_config --> archlens_domain
|
||||||
archlens_cargo_workspace --> archlens_domain
|
archlens_cargo_workspace --> archlens_domain
|
||||||
archlens_python_project --> archlens_domain
|
archlens_python_project --> archlens_domain
|
||||||
|
archlens_d2 --> archlens_rendering_primitives
|
||||||
archlens_d2 --> archlens_domain
|
archlens_d2 --> archlens_domain
|
||||||
|
archlens_html --> archlens_rendering_primitives
|
||||||
archlens_html --> archlens_domain
|
archlens_html --> archlens_domain
|
||||||
|
archlens_rendering_primitives --> archlens_domain
|
||||||
@@ -2,6 +2,8 @@ classDiagram
|
|||||||
namespace Application {
|
namespace Application {
|
||||||
class AnalyzeCodebase
|
class AnalyzeCodebase
|
||||||
class AnalyzeCodebaseResult
|
class AnalyzeCodebaseResult
|
||||||
|
class BuildCodeGraphResult
|
||||||
|
class BuildCodeGraph
|
||||||
class DiffResult
|
class DiffResult
|
||||||
class DiffDiagram
|
class DiffDiagram
|
||||||
class CheckFreshness
|
class CheckFreshness
|
||||||
@@ -16,16 +18,22 @@ classDiagram
|
|||||||
AnalyzeCodebaseResult : warnings Vec
|
AnalyzeCodebaseResult : warnings Vec
|
||||||
AnalyzeCodebaseResult : +graph() -] NormalizedGraph
|
AnalyzeCodebaseResult : +graph() -] NormalizedGraph
|
||||||
AnalyzeCodebaseResult : +warnings() -] [AnalysisWarning]
|
AnalyzeCodebaseResult : +warnings() -] [AnalysisWarning]
|
||||||
|
BuildCodeGraphResult : graph NormalizedGraph
|
||||||
|
BuildCodeGraphResult : warnings Vec
|
||||||
|
BuildCodeGraph : discovery F
|
||||||
|
BuildCodeGraph : source_analyzer S
|
||||||
|
BuildCodeGraph : project_analyzer Option
|
||||||
|
BuildCodeGraph : +execute(root Path, config AnalysisConfig, level DiagramLevel) -] Result[BuildCodeGraphResult, DomainError]
|
||||||
DiffResult : added Vec
|
DiffResult : added Vec
|
||||||
DiffResult : removed Vec
|
DiffResult : removed Vec
|
||||||
DiffResult : +is_empty() -] bool
|
DiffResult : +is_empty() -] bool
|
||||||
DiffDiagram : graph a NormalizedGraph
|
DiffDiagram : graph a NormalizedGraph
|
||||||
DiffDiagram : renderer a dyn DiagramRenderer
|
DiffDiagram : renderer a dyn DiagramRenderer
|
||||||
DiffDiagram : existing_path a std path Path
|
DiffDiagram : existing_content a str
|
||||||
DiffDiagram : +execute() -] Result[DiffResult, DomainError]
|
DiffDiagram : +execute() -] Result[DiffResult, DomainError]
|
||||||
CheckFreshness : graph a NormalizedGraph
|
CheckFreshness : graph a NormalizedGraph
|
||||||
CheckFreshness : renderer a dyn DiagramRenderer
|
CheckFreshness : renderer a dyn DiagramRenderer
|
||||||
CheckFreshness : existing_path a std path Path
|
CheckFreshness : existing_content a str
|
||||||
CheckFreshness : +execute() -] Result[bool, DomainError]
|
CheckFreshness : +execute() -] Result[bool, DomainError]
|
||||||
GenerateDiagramResult : violations Vec
|
GenerateDiagramResult : violations Vec
|
||||||
GenerateDiagramResult : output RenderOutput
|
GenerateDiagramResult : output RenderOutput
|
||||||
@@ -34,14 +42,11 @@ classDiagram
|
|||||||
GenerateDiagram : allow_rules Vec
|
GenerateDiagram : allow_rules Vec
|
||||||
GenerateDiagram : deny_rules Vec
|
GenerateDiagram : deny_rules Vec
|
||||||
GenerateDiagram : split_by_module bool
|
GenerateDiagram : split_by_module bool
|
||||||
GenerateDiagram : format_ext String
|
GenerateDiagram : +execute() -] Result[GenerateDiagramResult, DomainError]
|
||||||
GenerateDiagram : output_dir Option
|
|
||||||
GenerateDiagram : +execute() -] Result[(), DomainError]
|
|
||||||
GenerateDiagram : +check_violations_only() -] Vec[String]
|
|
||||||
class application_module["Application"] {
|
class application_module["Application"] {
|
||||||
<<module>>
|
<<module>>
|
||||||
}
|
}
|
||||||
class domain_module["Domain"] {
|
class domain_module["Domain"] {
|
||||||
<<module>>
|
<<module>>
|
||||||
}
|
}
|
||||||
application_module --> domain_module : 3 deps
|
application_module --> domain_module : 4 deps
|
||||||
@@ -1,7 +1,34 @@
|
|||||||
classDiagram
|
classDiagram
|
||||||
namespace Presentation {
|
namespace Domain {
|
||||||
class Cli
|
class OutputConfig
|
||||||
class Command
|
class DiagramLevel
|
||||||
|
class RenderOutput
|
||||||
|
class RenderedFile
|
||||||
|
class SourceFile
|
||||||
|
class ModuleName
|
||||||
|
class ModuleAssignment
|
||||||
|
class Language
|
||||||
|
class FilePath
|
||||||
|
class RelationshipKind
|
||||||
|
class Visibility
|
||||||
|
class CodeElementKind
|
||||||
|
class RuleKind
|
||||||
|
class RuleViolation
|
||||||
|
class BoundaryRule
|
||||||
|
class AnalysisConfig
|
||||||
|
class AnalysisResult
|
||||||
|
class AnalysisWarning
|
||||||
|
class Relationship
|
||||||
|
class CodeElement
|
||||||
|
class FileDiscovery
|
||||||
|
class ConfigLoader
|
||||||
|
class ProjectAnalyzer
|
||||||
|
class OutputWriter
|
||||||
|
class DiagramRenderer
|
||||||
|
class SourceAnalyzer
|
||||||
|
class NormalizedGraph
|
||||||
|
class CodeGraph
|
||||||
|
class DomainError
|
||||||
}
|
}
|
||||||
namespace Adapters {
|
namespace Adapters {
|
||||||
class MermaidRenderer
|
class MermaidRenderer
|
||||||
@@ -36,158 +63,21 @@ classDiagram
|
|||||||
class MemberToml
|
class MemberToml
|
||||||
class PackageSection
|
class PackageSection
|
||||||
}
|
}
|
||||||
namespace Domain {
|
namespace Presentation {
|
||||||
class OutputConfig
|
class Cli
|
||||||
class DiagramLevel
|
class Command
|
||||||
class RenderOutput
|
|
||||||
class RenderedFile
|
|
||||||
class SourceFile
|
|
||||||
class ModuleName
|
|
||||||
class ModuleAssignment
|
|
||||||
class Language
|
|
||||||
class FilePath
|
|
||||||
class RelationshipKind
|
|
||||||
class Visibility
|
|
||||||
class CodeElementKind
|
|
||||||
class RuleKind
|
|
||||||
class RuleViolation
|
|
||||||
class BoundaryRule
|
|
||||||
class AnalysisConfig
|
|
||||||
class AnalysisResult
|
|
||||||
class AnalysisWarning
|
|
||||||
class Relationship
|
|
||||||
class CodeElement
|
|
||||||
class FileDiscovery
|
|
||||||
class ConfigLoader
|
|
||||||
class ProjectAnalyzer
|
|
||||||
class OutputWriter
|
|
||||||
class DiagramRenderer
|
|
||||||
class SourceAnalyzer
|
|
||||||
class NormalizedGraph
|
|
||||||
class CodeGraph
|
|
||||||
class DomainError
|
|
||||||
}
|
}
|
||||||
namespace Application {
|
namespace Application {
|
||||||
class AnalyzeCodebase
|
class AnalyzeCodebase
|
||||||
class AnalyzeCodebaseResult
|
class AnalyzeCodebaseResult
|
||||||
|
class BuildCodeGraphResult
|
||||||
|
class BuildCodeGraph
|
||||||
class DiffResult
|
class DiffResult
|
||||||
class DiffDiagram
|
class DiffDiagram
|
||||||
class CheckFreshness
|
class CheckFreshness
|
||||||
class GenerateDiagramResult
|
class GenerateDiagramResult
|
||||||
class GenerateDiagram
|
class GenerateDiagram
|
||||||
}
|
}
|
||||||
Cli : command Option
|
|
||||||
Cli : path PathBuf
|
|
||||||
Cli : level String
|
|
||||||
Cli : format String
|
|
||||||
Cli : output Option
|
|
||||||
Cli : config Option
|
|
||||||
Cli : scope Option
|
|
||||||
Cli : exclude Vec
|
|
||||||
Cli : include_tests bool
|
|
||||||
Cli : no_weights bool
|
|
||||||
Cli : watch bool
|
|
||||||
Cli : since Option
|
|
||||||
Cli : split_by_module bool
|
|
||||||
Cli : strict bool
|
|
||||||
Cli : check bool
|
|
||||||
Cli : verbose u8
|
|
||||||
MermaidRenderer : level DiagramLevel
|
|
||||||
MermaidRenderer : show_weights bool
|
|
||||||
MermaidRenderer : +new() -] Self
|
|
||||||
MermaidRenderer : +with_level(level DiagramLevel) -] Self
|
|
||||||
MermaidRenderer : +with_weights(show bool) -] Self
|
|
||||||
MermaidRenderer : -display_name(qualified str) -] str
|
|
||||||
MermaidRenderer : -format_element_name(element CodeElement) -] String
|
|
||||||
MermaidRenderer : -format_visibility(visibility Visibility) -] static str
|
|
||||||
MermaidRenderer : -render_class_diagram(graph CodeGraph) -] String
|
|
||||||
MermaidRenderer : -push_class_lines(lines mut Vec[String], deferred mut Vec[String], element CodeElement, indent str, in_namespace bool)
|
|
||||||
MermaidRenderer : -render_module_flowchart(graph CodeGraph) -] String
|
|
||||||
MermaidRenderer : -render_project_flowchart(graph CodeGraph) -] String
|
|
||||||
MermaidRenderer : -sanitize_id(name str) -] String
|
|
||||||
AsciiRenderer : +new() -] Self
|
|
||||||
AsciiRenderer : -format_kind(element CodeElement) -] static str
|
|
||||||
PythonProjectAnalyzer : +new() -] Self
|
|
||||||
<<private>> ProjectSection
|
|
||||||
ProjectSection : name Option
|
|
||||||
ProjectSection : dependencies Vec
|
|
||||||
<<private>> PoetrySection
|
|
||||||
PoetrySection : name Option
|
|
||||||
PoetrySection : dependencies HashMap
|
|
||||||
<<private>> ToolSection
|
|
||||||
ToolSection : poetry PoetrySection
|
|
||||||
<<private>> PyprojectToml
|
|
||||||
PyprojectToml : project Option
|
|
||||||
PyprojectToml : tool ToolSection
|
|
||||||
WalkdirDiscovery : +new() -] Self
|
|
||||||
WalkdirDiscovery : -detect_language(path Path) -] Option[Language]
|
|
||||||
WalkdirDiscovery : -is_excluded(path Path, root Path, excludes [String]) -] bool
|
|
||||||
ExtractionContext : elements Vec
|
|
||||||
ExtractionContext : relationships Vec
|
|
||||||
ExtractionContext : warnings Vec
|
|
||||||
ExtractionContext : local_types HashSet
|
|
||||||
ExtractionContext : file_path FilePath
|
|
||||||
ExtractionContext : +new(file_path FilePath) -] Self
|
|
||||||
ExtractionContext : +add_element(element CodeElement)
|
|
||||||
ExtractionContext : +add_relationship(rel Relationship)
|
|
||||||
ExtractionContext : +add_warning(file_path FilePath, line usize, message str)
|
|
||||||
ExtractionContext : +file_path() -] FilePath
|
|
||||||
ExtractionContext : +into_result() -] Result[AnalysisResult, DomainError]
|
|
||||||
TreeSitterAnalyzer : rust RustExtractor
|
|
||||||
TreeSitterAnalyzer : python PythonExtractor
|
|
||||||
TreeSitterAnalyzer : +new() -] Self
|
|
||||||
TreeSitterAnalyzer : -extractor_for(language Language) -] Option[dyn LanguageExtractor]
|
|
||||||
FileOutputWriter : output_path OutputPath
|
|
||||||
FileOutputWriter : +new(output_dir PathBuf) -] Self
|
|
||||||
FileOutputWriter : +single_file(path PathBuf) -] Self
|
|
||||||
<<private>> OutputPath
|
|
||||||
StdoutOutputWriter : +new() -] Self
|
|
||||||
<<private>> RawRules
|
|
||||||
RawRules : allow Vec
|
|
||||||
RawRules : deny Vec
|
|
||||||
<<private>> RawConfig
|
|
||||||
RawConfig : analysis RawAnalysis
|
|
||||||
RawConfig : output RawOutput
|
|
||||||
RawConfig : modules HashMap
|
|
||||||
RawConfig : rules RawRules
|
|
||||||
<<private>> RawAnalysis
|
|
||||||
RawAnalysis : exclude Vec
|
|
||||||
RawAnalysis : level Option
|
|
||||||
<<private>> RawOutput
|
|
||||||
RawOutput : format Option
|
|
||||||
RawOutput : path Option
|
|
||||||
RawOutput : split_by_module bool
|
|
||||||
TomlConfigLoader : raw RawConfig
|
|
||||||
TomlConfigLoader : +from_path(path Path) -] Result[Self, DomainError]
|
|
||||||
TomlConfigLoader : -parse_level(level Option[String]) -] DiagramLevel
|
|
||||||
D2Renderer : level DiagramLevel
|
|
||||||
D2Renderer : +new() -] Self
|
|
||||||
D2Renderer : +with_level(level DiagramLevel) -] Self
|
|
||||||
HtmlRenderer : +new() -] Self
|
|
||||||
<<private>> GraphData
|
|
||||||
GraphData : nodes Vec
|
|
||||||
GraphData : edges Vec
|
|
||||||
<<private>> NodeData
|
|
||||||
NodeData : id String
|
|
||||||
NodeData : label String
|
|
||||||
NodeData : module String
|
|
||||||
NodeData : kind String
|
|
||||||
NodeData : fields Vec
|
|
||||||
NodeData : methods Vec
|
|
||||||
<<private>> EdgeData
|
|
||||||
EdgeData : source String
|
|
||||||
EdgeData : target String
|
|
||||||
EdgeData : kind String
|
|
||||||
CargoWorkspaceAnalyzer : +new() -] Self
|
|
||||||
<<private>> WorkspaceToml
|
|
||||||
WorkspaceToml : workspace Option
|
|
||||||
<<private>> WorkspaceSection
|
|
||||||
WorkspaceSection : members Vec
|
|
||||||
<<private>> MemberToml
|
|
||||||
MemberToml : package Option
|
|
||||||
MemberToml : dependencies HashMap
|
|
||||||
<<private>> PackageSection
|
|
||||||
PackageSection : name String
|
|
||||||
OutputConfig : split_by_module bool
|
OutputConfig : split_by_module bool
|
||||||
OutputConfig : output_path Option
|
OutputConfig : output_path Option
|
||||||
OutputConfig : +with_split_by_module(split bool) -] Self
|
OutputConfig : +with_split_by_module(split bool) -] Self
|
||||||
@@ -336,6 +226,118 @@ classDiagram
|
|||||||
CodeGraph : +subgraph_by_module(module ModuleName) -] CodeGraph
|
CodeGraph : +subgraph_by_module(module ModuleName) -] CodeGraph
|
||||||
CodeGraph : +merge_project_edges(project_graph CodeGraph)
|
CodeGraph : +merge_project_edges(project_graph CodeGraph)
|
||||||
CodeGraph : +module_edges() -] HashMap[(String, String), usize]
|
CodeGraph : +module_edges() -] HashMap[(String, String), usize]
|
||||||
|
MermaidRenderer : level DiagramLevel
|
||||||
|
MermaidRenderer : show_weights bool
|
||||||
|
MermaidRenderer : +new() -] Self
|
||||||
|
MermaidRenderer : +with_level(level DiagramLevel) -] Self
|
||||||
|
MermaidRenderer : +with_weights(show bool) -] Self
|
||||||
|
MermaidRenderer : -display_name(qualified str) -] str
|
||||||
|
MermaidRenderer : -format_element_name(element CodeElement) -] String
|
||||||
|
MermaidRenderer : -format_visibility(visibility Visibility) -] static str
|
||||||
|
MermaidRenderer : -render_class_diagram(graph CodeGraph) -] String
|
||||||
|
MermaidRenderer : -push_class_lines(lines mut Vec[String], deferred mut Vec[String], element CodeElement, indent str, in_namespace bool)
|
||||||
|
MermaidRenderer : -render_module_flowchart(graph CodeGraph) -] String
|
||||||
|
MermaidRenderer : -render_project_flowchart(graph CodeGraph) -] String
|
||||||
|
MermaidRenderer : -sanitize_id(name str) -] String
|
||||||
|
AsciiRenderer : +new() -] Self
|
||||||
|
AsciiRenderer : -format_kind(element CodeElement) -] static str
|
||||||
|
PythonProjectAnalyzer : +new() -] Self
|
||||||
|
<<private>> ProjectSection
|
||||||
|
ProjectSection : name Option
|
||||||
|
ProjectSection : dependencies Vec
|
||||||
|
<<private>> PoetrySection
|
||||||
|
PoetrySection : name Option
|
||||||
|
PoetrySection : dependencies HashMap
|
||||||
|
<<private>> ToolSection
|
||||||
|
ToolSection : poetry PoetrySection
|
||||||
|
<<private>> PyprojectToml
|
||||||
|
PyprojectToml : project Option
|
||||||
|
PyprojectToml : tool ToolSection
|
||||||
|
WalkdirDiscovery : +new() -] Self
|
||||||
|
WalkdirDiscovery : -detect_language(path Path) -] Option[Language]
|
||||||
|
WalkdirDiscovery : -is_excluded(path Path, root Path, excludes [String]) -] bool
|
||||||
|
ExtractionContext : elements Vec
|
||||||
|
ExtractionContext : relationships Vec
|
||||||
|
ExtractionContext : warnings Vec
|
||||||
|
ExtractionContext : local_types HashSet
|
||||||
|
ExtractionContext : file_path FilePath
|
||||||
|
ExtractionContext : +new(file_path FilePath) -] Self
|
||||||
|
ExtractionContext : +add_element(element CodeElement)
|
||||||
|
ExtractionContext : +add_relationship(rel Relationship)
|
||||||
|
ExtractionContext : +add_warning(file_path FilePath, line usize, message str)
|
||||||
|
ExtractionContext : +file_path() -] FilePath
|
||||||
|
ExtractionContext : +into_result() -] Result[AnalysisResult, DomainError]
|
||||||
|
TreeSitterAnalyzer : rust RustExtractor
|
||||||
|
TreeSitterAnalyzer : python PythonExtractor
|
||||||
|
TreeSitterAnalyzer : +new() -] Self
|
||||||
|
TreeSitterAnalyzer : -extractor_for(language Language) -] Option[dyn LanguageExtractor]
|
||||||
|
FileOutputWriter : output_path OutputPath
|
||||||
|
FileOutputWriter : +new(output_dir PathBuf) -] Self
|
||||||
|
FileOutputWriter : +single_file(path PathBuf) -] Self
|
||||||
|
<<private>> OutputPath
|
||||||
|
StdoutOutputWriter : +new() -] Self
|
||||||
|
<<private>> RawRules
|
||||||
|
RawRules : allow Vec
|
||||||
|
RawRules : deny Vec
|
||||||
|
<<private>> RawConfig
|
||||||
|
RawConfig : analysis RawAnalysis
|
||||||
|
RawConfig : output RawOutput
|
||||||
|
RawConfig : modules HashMap
|
||||||
|
RawConfig : rules RawRules
|
||||||
|
<<private>> RawAnalysis
|
||||||
|
RawAnalysis : exclude Vec
|
||||||
|
RawAnalysis : level Option
|
||||||
|
<<private>> RawOutput
|
||||||
|
RawOutput : format Option
|
||||||
|
RawOutput : path Option
|
||||||
|
RawOutput : split_by_module bool
|
||||||
|
TomlConfigLoader : raw RawConfig
|
||||||
|
TomlConfigLoader : +from_path(path Path) -] Result[Self, DomainError]
|
||||||
|
TomlConfigLoader : -parse_level(level Option[String]) -] DiagramLevel
|
||||||
|
D2Renderer : level DiagramLevel
|
||||||
|
D2Renderer : +new() -] Self
|
||||||
|
D2Renderer : +with_level(level DiagramLevel) -] Self
|
||||||
|
HtmlRenderer : +new() -] Self
|
||||||
|
<<private>> GraphData
|
||||||
|
GraphData : nodes Vec
|
||||||
|
GraphData : edges Vec
|
||||||
|
<<private>> NodeData
|
||||||
|
NodeData : id String
|
||||||
|
NodeData : label String
|
||||||
|
NodeData : module String
|
||||||
|
NodeData : kind String
|
||||||
|
NodeData : fields Vec
|
||||||
|
NodeData : methods Vec
|
||||||
|
<<private>> EdgeData
|
||||||
|
EdgeData : source String
|
||||||
|
EdgeData : target String
|
||||||
|
EdgeData : kind String
|
||||||
|
CargoWorkspaceAnalyzer : +new() -] Self
|
||||||
|
<<private>> WorkspaceToml
|
||||||
|
WorkspaceToml : workspace Option
|
||||||
|
<<private>> WorkspaceSection
|
||||||
|
WorkspaceSection : members Vec
|
||||||
|
<<private>> MemberToml
|
||||||
|
MemberToml : package Option
|
||||||
|
MemberToml : dependencies HashMap
|
||||||
|
<<private>> PackageSection
|
||||||
|
PackageSection : name String
|
||||||
|
Cli : command Option
|
||||||
|
Cli : path PathBuf
|
||||||
|
Cli : level String
|
||||||
|
Cli : format String
|
||||||
|
Cli : output Option
|
||||||
|
Cli : config Option
|
||||||
|
Cli : scope Option
|
||||||
|
Cli : exclude Vec
|
||||||
|
Cli : include_tests bool
|
||||||
|
Cli : no_weights bool
|
||||||
|
Cli : watch bool
|
||||||
|
Cli : since Option
|
||||||
|
Cli : split_by_module bool
|
||||||
|
Cli : strict bool
|
||||||
|
Cli : check bool
|
||||||
|
Cli : verbose u8
|
||||||
AnalyzeCodebase : file_discovery F
|
AnalyzeCodebase : file_discovery F
|
||||||
AnalyzeCodebase : source_analyzer S
|
AnalyzeCodebase : source_analyzer S
|
||||||
AnalyzeCodebase : +new(file_discovery F, source_analyzer S) -] Self
|
AnalyzeCodebase : +new(file_discovery F, source_analyzer S) -] Self
|
||||||
@@ -344,16 +346,22 @@ classDiagram
|
|||||||
AnalyzeCodebaseResult : warnings Vec
|
AnalyzeCodebaseResult : warnings Vec
|
||||||
AnalyzeCodebaseResult : +graph() -] NormalizedGraph
|
AnalyzeCodebaseResult : +graph() -] NormalizedGraph
|
||||||
AnalyzeCodebaseResult : +warnings() -] [AnalysisWarning]
|
AnalyzeCodebaseResult : +warnings() -] [AnalysisWarning]
|
||||||
|
BuildCodeGraphResult : graph NormalizedGraph
|
||||||
|
BuildCodeGraphResult : warnings Vec
|
||||||
|
BuildCodeGraph : discovery F
|
||||||
|
BuildCodeGraph : source_analyzer S
|
||||||
|
BuildCodeGraph : project_analyzer Option
|
||||||
|
BuildCodeGraph : +execute(root Path, config AnalysisConfig, level DiagramLevel) -] Result[BuildCodeGraphResult, DomainError]
|
||||||
DiffResult : added Vec
|
DiffResult : added Vec
|
||||||
DiffResult : removed Vec
|
DiffResult : removed Vec
|
||||||
DiffResult : +is_empty() -] bool
|
DiffResult : +is_empty() -] bool
|
||||||
DiffDiagram : graph a NormalizedGraph
|
DiffDiagram : graph a NormalizedGraph
|
||||||
DiffDiagram : renderer a dyn DiagramRenderer
|
DiffDiagram : renderer a dyn DiagramRenderer
|
||||||
DiffDiagram : existing_path a std path Path
|
DiffDiagram : existing_content a str
|
||||||
DiffDiagram : +execute() -] Result[DiffResult, DomainError]
|
DiffDiagram : +execute() -] Result[DiffResult, DomainError]
|
||||||
CheckFreshness : graph a NormalizedGraph
|
CheckFreshness : graph a NormalizedGraph
|
||||||
CheckFreshness : renderer a dyn DiagramRenderer
|
CheckFreshness : renderer a dyn DiagramRenderer
|
||||||
CheckFreshness : existing_path a std path Path
|
CheckFreshness : existing_content a str
|
||||||
CheckFreshness : +execute() -] Result[bool, DomainError]
|
CheckFreshness : +execute() -] Result[bool, DomainError]
|
||||||
GenerateDiagramResult : violations Vec
|
GenerateDiagramResult : violations Vec
|
||||||
GenerateDiagramResult : output RenderOutput
|
GenerateDiagramResult : output RenderOutput
|
||||||
@@ -362,10 +370,7 @@ classDiagram
|
|||||||
GenerateDiagram : allow_rules Vec
|
GenerateDiagram : allow_rules Vec
|
||||||
GenerateDiagram : deny_rules Vec
|
GenerateDiagram : deny_rules Vec
|
||||||
GenerateDiagram : split_by_module bool
|
GenerateDiagram : split_by_module bool
|
||||||
GenerateDiagram : format_ext String
|
GenerateDiagram : +execute() -] Result[GenerateDiagramResult, DomainError]
|
||||||
GenerateDiagram : output_dir Option
|
|
||||||
GenerateDiagram : +execute() -] Result[(), DomainError]
|
|
||||||
GenerateDiagram : +check_violations_only() -] Vec[String]
|
|
||||||
SourceFile --> FilePath
|
SourceFile --> FilePath
|
||||||
SourceFile --> Language
|
SourceFile --> Language
|
||||||
RuleViolation --> RuleKind
|
RuleViolation --> RuleKind
|
||||||
@@ -401,5 +406,6 @@ classDiagram
|
|||||||
HtmlRenderer <|-- DiagramRenderer
|
HtmlRenderer <|-- DiagramRenderer
|
||||||
CargoWorkspaceAnalyzer <|-- ProjectAnalyzer
|
CargoWorkspaceAnalyzer <|-- ProjectAnalyzer
|
||||||
AnalyzeCodebaseResult --> NormalizedGraph
|
AnalyzeCodebaseResult --> NormalizedGraph
|
||||||
|
BuildCodeGraphResult --> NormalizedGraph
|
||||||
GenerateDiagramResult --> RenderOutput
|
GenerateDiagramResult --> RenderOutput
|
||||||
GenerateDiagram --> NormalizedGraph
|
GenerateDiagram --> NormalizedGraph
|
||||||
1518
docs/superpowers/plans/2026-06-17-deepening-interfaces.md
Normal file
1518
docs/superpowers/plans/2026-06-17-deepening-interfaces.md
Normal file
File diff suppressed because it is too large
Load Diff
207
docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md
Normal file
207
docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Deepening Application and Adapter Interfaces
|
||||||
|
|
||||||
|
**Date:** 2026-06-17
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** Four architectural deepening refactors across the application and adapter layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Four architectural friction points identified via architecture review:
|
||||||
|
|
||||||
|
1. I/O leaks across the seam into the application layer (`GenerateDiagram`, `CheckFreshness`, `DiffDiagram`)
|
||||||
|
2. Orchestration logic (`build_graph`) lives in the presentation layer instead of application
|
||||||
|
3. Renderer adapters duplicate import filtering and identifier sanitization
|
||||||
|
4. `LanguageExtractor` trait's single `analyze()` method gives no structure to implementors
|
||||||
|
|
||||||
|
All four refactors deepen existing modules — shrinking their interfaces and concentrating behavior — without adding new capabilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Candidate 1: Pull I/O out of the application layer
|
||||||
|
|
||||||
|
**Files:** `crates/application/src/use_cases/generate_diagram.rs`, `check_freshness.rs`, `diff_diagram.rs`
|
||||||
|
|
||||||
|
### GenerateDiagram
|
||||||
|
|
||||||
|
Remove `output_dir: Option<PathBuf>` and `format_ext: String` fields. Remove `write_split`, `write_file_to_dir` free functions, and `check_violations_only()` method.
|
||||||
|
|
||||||
|
`execute()` returns `Result<GenerateDiagramResult, DomainError>`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct GenerateDiagramResult {
|
||||||
|
pub violations: Vec<RuleViolation>, // domain type, not pre-formatted strings
|
||||||
|
pub output: RenderOutput, // caller writes; split mode produces N files
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Split-by-module rendering stays inside `execute()`: it builds a `RenderOutput` with an overview file plus one file per module. Presentation receives the `RenderOutput` and hands it to `OutputWriter`.
|
||||||
|
|
||||||
|
`check_violations_only()` is removed. Callers needing early-exit on violations (strict mode, watch mode) call `execute()` and inspect `result.violations`.
|
||||||
|
|
||||||
|
### CheckFreshness
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
pub existing_path: &'a std::path::Path,
|
||||||
|
|
||||||
|
// after
|
||||||
|
pub existing_content: &'a str,
|
||||||
|
```
|
||||||
|
|
||||||
|
`execute()` becomes a pure string comparison. Presentation reads the file with `std::fs::read_to_string()` before constructing the struct.
|
||||||
|
|
||||||
|
### DiffDiagram
|
||||||
|
|
||||||
|
Same pattern: `existing_path: &Path` → `existing_content: &str`. Presentation reads the file.
|
||||||
|
|
||||||
|
### Presentation impact
|
||||||
|
|
||||||
|
- Reads files before calling `CheckFreshness` / `DiffDiagram`
|
||||||
|
- After `GenerateDiagram::execute()`: inspects `result.violations` for strict-mode bail, prints violations, calls `output_writer.write(&result.output)`
|
||||||
|
- `write_split` and `write_file_to_dir` free functions move into presentation (or are inlined)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Candidate 2: Sink graph-building orchestration into the application layer
|
||||||
|
|
||||||
|
**Files:** `crates/application/src/use_cases/build_code_graph.rs` (new), `crates/presentation/src/lib.rs`
|
||||||
|
|
||||||
|
### New use case: BuildCodeGraph
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct BuildCodeGraph<F, S> {
|
||||||
|
pub discovery: F,
|
||||||
|
pub source_analyzer: S,
|
||||||
|
pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BuildCodeGraphResult {
|
||||||
|
pub graph: NormalizedGraph,
|
||||||
|
pub warnings: Vec<AnalysisWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: FileDiscovery, S: SourceAnalyzer> BuildCodeGraph<F, S> {
|
||||||
|
pub fn execute(
|
||||||
|
self,
|
||||||
|
root: &Path,
|
||||||
|
config: &AnalysisConfig,
|
||||||
|
level: DiagramLevel,
|
||||||
|
) -> Result<BuildCodeGraphResult, DomainError>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Logic inside `execute()`:
|
||||||
|
- `DiagramLevel::Project` → call `project_analyzer.expect("project analyzer required for Project level").analyze(root)`, return via `NormalizedGraph::from_project()`, empty warnings
|
||||||
|
- `DiagramLevel::Module | Type` → call `AnalyzeCodebase::execute()`, collect warnings; at `Module` level, if `project_analyzer` is `Some`, merge its edges into the graph
|
||||||
|
|
||||||
|
### Presentation impact
|
||||||
|
|
||||||
|
`build_graph()` in `presentation/src/lib.rs` drops from ~70 lines to ~25:
|
||||||
|
|
||||||
|
1. Apply CLI overrides to `AnalysisConfig` (scope, excludes, include_tests, changed_files) — stays in presentation, CLI-derived
|
||||||
|
2. Detect project analyzer: `if cargo_toml.exists() { Some(CargoWorkspaceAnalyzer) } else if pyproject.exists() { Some(PythonProjectAnalyzer) } else { None }`
|
||||||
|
3. Construct `BuildCodeGraph` and call `execute(root, config, level)`
|
||||||
|
4. Handle warnings (`eprintln`, strict bail) — stays in presentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Candidate 3: Shared renderer primitives
|
||||||
|
|
||||||
|
**Files:** `crates/adapters/rendering-primitives/src/lib.rs` (new crate), all four renderer adapter crates
|
||||||
|
|
||||||
|
### New crate: rendering-primitives
|
||||||
|
|
||||||
|
Depends only on `archlens_domain`. Exports two functions:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Returns all relationships excluding Import kind.
|
||||||
|
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
|
||||||
|
rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize an identifier by replacing common separator characters with underscore.
|
||||||
|
/// Handles `::`, `-`, `.`, and space.
|
||||||
|
pub fn sanitize_identifier(name: &str) -> String {
|
||||||
|
name.replace("::", "_").replace(['-', '.', ' '], "_")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each of the four renderer adapter crates (`mermaid`, `d2`, `ascii`, `html-viewer`) adds `rendering-primitives` as a dependency.
|
||||||
|
|
||||||
|
Mermaid's `sanitize_id` strips only `[-.]` (not `::`) — intentionally different because Mermaid handles qualified names via `display_name()`. It keeps its local variant; the shared `sanitize_identifier` is used by D2 and HTML. The universal win is `non_import_rels`, which replaces the identical filter expression in all four renderers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Candidate 4: Typed LanguageExtractor pipeline
|
||||||
|
|
||||||
|
**Files:** `crates/adapters/tree-sitter/src/language_extractor.rs`, `rust/mod.rs`, `python/mod.rs`, `tree_sitter_analyzer.rs`
|
||||||
|
|
||||||
|
### Revised LanguageExtractor trait
|
||||||
|
|
||||||
|
Replace the current single-method trait with a 3-method trait plus a `run_extraction` free function:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait LanguageExtractor {
|
||||||
|
fn tree_sitter_language(&self) -> tree_sitter::Language;
|
||||||
|
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
|
||||||
|
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
|
||||||
|
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_extraction(
|
||||||
|
extractor: &dyn LanguageExtractor,
|
||||||
|
source: &str,
|
||||||
|
file_path: &FilePath,
|
||||||
|
) -> Result<AnalysisResult, DomainError>
|
||||||
|
```
|
||||||
|
|
||||||
|
`run_extraction` owns: create parser, set language, parse, call all 3 methods in order on the root node, return `ctx.into_result()`.
|
||||||
|
|
||||||
|
### Extractor changes
|
||||||
|
|
||||||
|
`RustExtractor` and `PythonExtractor` implement the new trait. Existing free functions are reorganised into the 3 methods — no logic changes:
|
||||||
|
- `extract_types` → wraps `collect_types`
|
||||||
|
- `extract_relationships` → wraps `collect_relationships`
|
||||||
|
- `extract_imports` → wraps `collect_mod_declarations` + `collect_use_imports`
|
||||||
|
|
||||||
|
`TreeSitterAnalyzer::analyze_file()` replaces `extractor.analyze(source, path)` with `run_extraction(extractor, source, path)`.
|
||||||
|
|
||||||
|
Adding CSharp = implement 4 methods. No guesswork about what's required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution order
|
||||||
|
|
||||||
|
1. Candidate 1 — cleanest standalone win; establishes the pattern (pure use cases return data)
|
||||||
|
2. Candidate 2 — independent of Candidate 1 but benefits from the pattern being in place; `BuildCodeGraph` and `GenerateDiagram` are separate use cases with no shared code
|
||||||
|
3. Candidates 3 and 4 — independent of 1+2 and of each other; can run in parallel
|
||||||
|
|
||||||
|
`FileOutputWriter` (Directory variant) already iterates over all `RenderedFile`s in a `RenderOutput`, so split-by-module output producing N files in one `RenderOutput` requires no changes to the writer.
|
||||||
|
|
||||||
|
## TDD behaviors to verify (by candidate)
|
||||||
|
|
||||||
|
### Candidate 1
|
||||||
|
- `GenerateDiagram::execute()` returns `RenderOutput` with rendered content (no filesystem side effects)
|
||||||
|
- `GenerateDiagram::execute()` returns `Vec<RuleViolation>` when rules are violated
|
||||||
|
- `GenerateDiagram::execute()` with `split_by_module=true` returns `RenderOutput` with multiple files (overview + per-module)
|
||||||
|
- `CheckFreshness::execute()` returns `true` when rendered content equals `existing_content`
|
||||||
|
- `CheckFreshness::execute()` returns `false` when they differ
|
||||||
|
- `DiffDiagram::execute()` returns correct added/removed lines
|
||||||
|
|
||||||
|
### Candidate 2
|
||||||
|
- `BuildCodeGraph::execute()` at `Project` level delegates to `ProjectAnalyzer`, returns its graph
|
||||||
|
- `BuildCodeGraph::execute()` at `Module` level merges project edges when `project_analyzer` is `Some`
|
||||||
|
- `BuildCodeGraph::execute()` at `Type` level does not merge edges even when `project_analyzer` is `Some`
|
||||||
|
- `BuildCodeGraph::execute()` with `None` project analyzer at `Module` level skips merge cleanly
|
||||||
|
- `BuildCodeGraph::execute()` propagates `AnalysisWarning`s from `AnalyzeCodebase`
|
||||||
|
|
||||||
|
### Candidate 3
|
||||||
|
- `non_import_rels` excludes `Import` relationships, passes through `Inheritance` and `Composition`
|
||||||
|
- `sanitize_identifier` replaces `::`, `-`, `.`, space with `_`
|
||||||
|
|
||||||
|
### Candidate 4
|
||||||
|
- `run_extraction` calls `extract_types`, `extract_relationships`, `extract_imports` in order
|
||||||
|
- `RustExtractor` extracts struct/enum/trait types via `extract_types`
|
||||||
|
- `PythonExtractor` extracts classes via `extract_types`
|
||||||
Reference in New Issue
Block a user