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

Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single
named operation returning a distinct type; raw CodeGraph cannot call
module_edges/subgraph_by_module — pipeline order enforced at compile time.

Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram
extracted to application/src/use_cases/; presentation is now a thin CLI
dispatcher (~100 lines less, three fewer local functions).

Candidate 3 (ExtractionContext): shared accumulator for both Rust and
Python extractors replaces parallel Vec<> + 4-arg passing chains.

Candidate 4 (ModuleAssignment): ModuleName::assign() returns
ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option,
callers can distinguish resolution strategies.

Candidate 5 (SplitRenderer): append_cross_module_deps removed from
DiagramRenderer port; replaced by render_for_module() default impl —
port interface now reflects what all renderers actually share.
This commit is contained in:
2026-06-17 11:24:18 +02:00
parent b159cafc9d
commit fc8ad0ebc0
18 changed files with 614 additions and 511 deletions

View File

@@ -226,42 +226,36 @@ impl DiagramRenderer for MermaidRenderer {
Ok(RenderOutput::single(file))
}
fn append_cross_module_deps(
fn render_for_module(
&self,
content: &str,
subgraph: &CodeGraph,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
if deps.is_empty() {
return content.to_string();
}
let src_id = format!(
"{}_module",
module.as_str().to_lowercase().replace('-', "_")
);
let mut extra = format!(
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
module.as_str()
);
for (dep_mod, count) in deps {
let dep_id = format!(
"{}_module",
dep_mod.as_str().to_lowercase().replace('-', "_")
cross_deps: &[(ModuleName, usize)],
) -> Result<RenderOutput, DomainError> {
let base = self.render_class_diagram(subgraph);
let content = if cross_deps.is_empty() {
base
} else {
let src_id = format!("{}_module", module.as_str().to_lowercase().replace('-', "_"));
let mut extra = format!(
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
module.as_str()
);
extra.push_str(&format!(
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
dep_mod.as_str()
));
let label = if *count == 1 {
"1 dep".to_string()
} else {
format!("{count} deps")
};
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
}
format!("{content}\n{extra}")
for (dep_mod, count) in cross_deps {
let dep_id = format!(
"{}_module",
dep_mod.as_str().to_lowercase().replace('-', "_")
);
extra.push_str(&format!(
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
dep_mod.as_str()
));
let label = if *count == 1 { "1 dep".to_string() } else { format!("{count} deps") };
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
}
format!("{base}\n{extra}")
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,56 @@
use std::collections::HashSet;
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, DomainError, FilePath, Relationship,
};
/// Accumulator passed through tree-walking helpers.
///
/// Bundles the four parallel mutable collections that every language extractor
/// maintains, giving helpers a single receiver to push into and eliminating
/// 4-parameter passing chains.
pub struct ExtractionContext {
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
warnings: Vec<AnalysisWarning>,
/// Names of types defined in the current file — used to filter primitive
/// and external types from relationship targets.
pub local_types: HashSet<String>,
file_path: FilePath,
}
impl ExtractionContext {
pub fn new(file_path: FilePath) -> Self {
Self {
elements: Vec::new(),
relationships: Vec::new(),
warnings: Vec::new(),
local_types: HashSet::new(),
file_path,
}
}
pub fn add_element(&mut self, element: CodeElement) {
self.local_types.insert(element.name().to_string());
self.elements.push(element);
}
pub fn add_relationship(&mut self, rel: Relationship) {
self.relationships.push(rel.with_source_file(self.file_path.clone()));
}
pub fn add_warning(&mut self, file_path: FilePath, line: usize, message: &str) {
if let Ok(w) = AnalysisWarning::new(file_path, line, message) {
self.warnings.push(w);
}
}
pub fn file_path(&self) -> &FilePath {
&self.file_path
}
/// Consumes the context and returns the completed `AnalysisResult`.
pub fn into_result(self) -> Result<AnalysisResult, DomainError> {
Ok(AnalysisResult::new(self.elements, self.relationships, self.warnings))
}
}

View File

@@ -1,3 +1,4 @@
mod extraction_context;
mod language_extractor;
mod python;
mod rust;

View File

@@ -1,12 +1,11 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind,
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind,
};
use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor;
pub struct PythonExtractor;
@@ -27,40 +26,16 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
collect_classes(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut relationships,
&mut warnings,
);
collect_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
collect_classes(&root, source, &mut ctx);
collect_imports(&root, source, &mut ctx);
Ok(AnalysisResult::new(elements, relationships, warnings))
ctx.into_result()
}
fn collect_classes(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "class_definition" {
@@ -79,26 +54,21 @@ fn collect_classes(
.map(|body| collect_methods(&body, source))
.unwrap_or_default();
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => {
type_names.insert(name.to_string());
elements.push(element.with_methods(methods));
}
match CodeElement::new(name, CodeElementKind::Class, ctx.file_path().clone(), line) {
Ok(element) => ctx.add_element(element.with_methods(methods)),
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
ctx.add_warning(ctx.file_path().clone(), line, &e.to_string());
continue;
}
}
if let Some(superclasses) = child.child_by_field_name("superclasses") {
collect_inheritance(&superclasses, source, name, type_names, relationships);
collect_inheritance(&superclasses, source, name, ctx);
}
if let Some(body) = child.child_by_field_name("body") {
collect_typed_fields(&body, source, name, type_names, relationships);
collect_constructor_params(&body, source, name, type_names, relationships);
collect_typed_fields(&body, source, name, ctx);
collect_constructor_params(&body, source, name, ctx);
}
}
}
@@ -107,8 +77,7 @@ fn collect_inheritance(
superclasses: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
ctx: &mut ExtractionContext,
) {
let mut cursor = superclasses.walk();
for child in superclasses.children(&mut cursor) {
@@ -118,7 +87,7 @@ fn collect_inheritance(
&& let Ok(rel) =
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -228,16 +197,12 @@ fn is_external_import(module: &str) -> bool {
false
}
fn collect_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
fn collect_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let file_name = std::path::Path::new(ctx.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
.unwrap_or("unknown")
.to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
@@ -249,9 +214,9 @@ fn collect_imports(
let module = &source[name_child.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
Relationship::new(&file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -261,9 +226,9 @@ fn collect_imports(
let module = &source[module_node.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
Relationship::new(&file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -314,7 +279,6 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
match param.kind() {
"typed_parameter" => {
if let Some(type_node) = param.child_by_field_name("type") {
// name is the first identifier child (not a named field)
let mut inner = param.walk();
let name = param
.children(&mut inner)
@@ -339,13 +303,7 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
parts.join(", ")
}
fn collect_constructor_params(
body: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
fn collect_constructor_params(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
@@ -373,30 +331,18 @@ fn collect_constructor_params(
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
}
}
fn collect_typed_fields(
body: &Node,
source: &str,
class_name: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
collect_typed_fields_recursive(body, source, class_name, type_names, relationships);
fn collect_typed_fields(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
collect_typed_fields_recursive(body, source, class_name, ctx);
}
fn collect_typed_fields_recursive(
node: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
fn collect_typed_fields_recursive(node: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
@@ -410,10 +356,10 @@ fn collect_typed_fields_recursive(
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
collect_typed_fields_recursive(&child, source, class_name, ctx);
}
}

View File

@@ -1,47 +1,17 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
const RUST_PRIMITIVES: &[&str] = &[
"bool",
"char",
"str",
"String",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"PhantomData",
"Pin",
"Cow",
"Self",
"bool", "char", "str", "String", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16",
"i32", "i64", "i128", "isize", "f32", "f64", "Vec", "Option", "Result", "Box", "Rc", "Arc",
"HashMap", "HashSet", "BTreeMap", "BTreeSet", "PhantomData", "Pin", "Cow", "Self",
];
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind, Visibility,
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind, Visibility,
};
use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor;
pub struct RustExtractor;
@@ -62,46 +32,18 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
collect_types(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut warnings,
);
collect_relationships(
&root,
source,
&type_names,
&mut relationships,
&mut warnings,
);
collect_mod_declarations(&root, source, file_path, &mut relationships);
collect_use_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
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);
Ok(AnalysisResult::new(elements, relationships, warnings))
ctx.into_result()
}
fn collect_types(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let (kind, name_field) = match child.kind() {
@@ -116,52 +58,34 @@ fn collect_types(
let line = child.start_position().row + 1;
let visibility = detect_visibility(&child, source);
match CodeElement::new(name, kind, file_path.clone(), line) {
match CodeElement::new(name, kind, ctx.file_path().clone(), line) {
Ok(element) => {
let fields = extract_fields(&child, source);
let methods = extract_methods(node, source, name);
let element = element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods);
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
ctx.add_element(
element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods),
);
}
Err(e) => ctx.add_warning(ctx.file_path().clone(), line, &e.to_string()),
}
}
}
}
fn collect_relationships(
node: &Node,
source: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_relationships(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"struct_item" => {
if let Some(name_node) = child.child_by_field_name("name") {
let struct_name = source[name_node.byte_range()].to_string();
collect_field_compositions(
&child,
source,
&struct_name,
type_names,
relationships,
);
collect_field_compositions(&child, source, &struct_name, ctx);
}
}
"impl_item" => {
collect_trait_impl(&child, source, type_names, relationships, warnings);
}
"impl_item" => collect_trait_impl(&child, source, ctx),
_ => {}
}
}
@@ -171,8 +95,7 @@ fn collect_field_compositions(
struct_node: &Node,
source: &str,
struct_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
ctx: &mut ExtractionContext,
) {
if let Some(body) = struct_node.child_by_field_name("body") {
let mut cursor = body.walk();
@@ -187,20 +110,14 @@ fn collect_field_compositions(
&& let Ok(rel) =
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
}
}
fn collect_trait_impl(
impl_node: &Node,
source: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
_warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_trait_impl(impl_node: &Node, source: &str, ctx: &mut ExtractionContext) {
let trait_node = impl_node.child_by_field_name("trait");
let type_node = impl_node.child_by_field_name("type");
@@ -215,7 +132,7 @@ fn collect_trait_impl(
&& let Ok(rel) =
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -226,12 +143,8 @@ fn extract_fields(node: &Node, source: &str) -> Vec<String> {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() == "field_declaration" {
let name = child
.child_by_field_name("name")
.map(|n| &source[n.byte_range()]);
let ty = child
.child_by_field_name("type")
.map(|n| extract_base_type(&n, source));
let name = child.child_by_field_name("name").map(|n| &source[n.byte_range()]);
let ty = child.child_by_field_name("type").map(|n| extract_base_type(&n, source));
if let (Some(name), Some(ty)) = (name, ty) {
fields.push(format!("{name}: {ty}"));
}
@@ -245,19 +158,15 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "impl_item" {
if child.kind() != "impl_item" || child.child_by_field_name("trait").is_some() {
continue;
}
if child.child_by_field_name("trait").is_some() {
if child
.child_by_field_name("type")
.is_some_and(|tn| extract_base_type(&tn, source) != type_name)
{
continue;
}
let type_node = child.child_by_field_name("type");
if let Some(tn) = type_node {
let impl_name = extract_base_type(&tn, source);
if impl_name != type_name {
continue;
}
}
if let Some(body) = child.child_by_field_name("body") {
let mut body_cursor = body.walk();
for item in body.children(&mut body_cursor) {
@@ -265,11 +174,7 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
&& let Some(name_node) = item.child_by_field_name("name")
{
let fn_name = &source[name_node.byte_range()];
let vis = if detect_visibility(&item, source) == Visibility::Public {
"+"
} else {
"-"
};
let vis = if detect_visibility(&item, source) == Visibility::Public { "+" } else { "-" };
let params = extract_fn_params(&item, source);
let ret = extract_fn_return(&item, source);
let sig = if ret.is_empty() {
@@ -317,45 +222,34 @@ fn extract_fn_return(fn_item: &Node, source: &str) -> String {
.unwrap_or_default()
}
fn collect_mod_declarations(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
fn collect_mod_declarations(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let file_name = std::path::Path::new(ctx.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
.unwrap_or("unknown")
.to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "mod_item" {
continue;
}
if child.child_by_field_name("body").is_some() {
if child.kind() != "mod_item" || child.child_by_field_name("body").is_some() {
continue;
}
if let Some(name_node) = child.child_by_field_name("name") {
let mod_name = &source[name_node.byte_range()];
let target = format!("crate::{mod_name}");
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
relationships.push(rel);
if let Ok(rel) = Relationship::new(&file_name, &target, RelationshipKind::Import) {
ctx.add_relationship(rel);
}
}
}
}
fn collect_use_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
fn collect_use_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let file_name = std::path::Path::new(ctx.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
.unwrap_or("unknown")
.to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
@@ -364,16 +258,12 @@ fn collect_use_imports(
}
if let Some(arg) = child.child_by_field_name("argument") {
let text = &source[arg.byte_range()];
let path = text
.split('{')
.next()
.unwrap_or(text)
.trim_end_matches("::");
let path = text.split('{').next().unwrap_or(text).trim_end_matches("::");
if (path.starts_with("crate::") || path.starts_with("super::"))
&& let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import)
&& let Ok(rel) = Relationship::new(&file_name, path, RelationshipKind::Import)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}