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

P1 correctness:
- filter test files by default (--include-tests to opt in)
- per-module diagrams show cross-module dependency arrows
- qualified type names (Module::TypeName) fix false edges from duplicate names

P2 output richness:
- method parameter types and return types in class diagrams (Rust + Python)
- Python pyproject.toml project analyzer (--level project for monorepos)

P3 unique value:
- boundary rules in archlens.toml ([rules] allow/deny, --strict enforcement)

P4 nice to have:
- dependency weight labels on module arrows (--no-weights to disable)
- --watch mode with 500ms debounce
- D2 renderer adapter (--format d2)
- interactive self-contained HTML viewer (--format html)
- git-aware incremental analysis (--since <ref>)
This commit is contained in:
2026-06-17 09:50:50 +02:00
parent 27197062eb
commit fdd85011a4
42 changed files with 2767 additions and 92 deletions

View File

@@ -74,10 +74,15 @@ fn collect_classes(
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
let methods = child
.child_by_field_name("body")
.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);
elements.push(element.with_methods(methods));
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
@@ -267,6 +272,73 @@ fn collect_imports(
}
}
fn collect_methods(body: &Node, source: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let fn_name = &source[name_node.byte_range()];
if fn_name.starts_with('_') {
continue;
}
let params = child
.child_by_field_name("parameters")
.map(|p| extract_python_params(&p, source))
.unwrap_or_default();
let ret = child
.child_by_field_name("return_type")
.map(|n| source[n.byte_range()].trim().to_string())
.unwrap_or_default();
let sig = if ret.is_empty() {
format!("+{fn_name}({params})")
} else {
format!("+{fn_name}({params}) -> {ret}")
};
methods.push(sig);
}
methods
}
fn extract_python_params(params_node: &Node, source: &str) -> String {
let mut parts = Vec::new();
let mut cursor = params_node.walk();
for param in params_node.children(&mut cursor) {
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)
.find(|c| c.kind() == "identifier")
.map(|c| &source[c.byte_range()])
.unwrap_or_default();
if name != "self" && name != "cls" && !name.is_empty() {
let ty = source[type_node.byte_range()].trim();
parts.push(format!("{name}: {ty}"));
}
}
}
"identifier" => {
let name = &source[param.byte_range()];
if name != "self" && name != "cls" {
parts.push(name.to_string());
}
}
_ => {}
}
}
parts.join(", ")
}
fn collect_constructor_params(
body: &Node,
source: &str,

View File

@@ -270,7 +270,14 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
} else {
"-"
};
methods.push(format!("{vis}{fn_name}()"));
let params = extract_fn_params(&item, source);
let ret = extract_fn_return(&item, source);
let sig = if ret.is_empty() {
format!("{vis}{fn_name}({params})")
} else {
format!("{vis}{fn_name}({params}) -> {ret}")
};
methods.push(sig);
}
}
}
@@ -278,6 +285,38 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
methods
}
fn extract_fn_params(fn_item: &Node, source: &str) -> String {
let Some(params_node) = fn_item.child_by_field_name("parameters") else {
return String::new();
};
let mut parts = Vec::new();
let mut cursor = params_node.walk();
for param in params_node.children(&mut cursor) {
match param.kind() {
"parameter" => {
if let (Some(pat), Some(ty)) = (
param.child_by_field_name("pattern"),
param.child_by_field_name("type"),
) {
let name = &source[pat.byte_range()];
let ty_text = source[ty.byte_range()].trim();
parts.push(format!("{name}: {ty_text}"));
}
}
"self_parameter" | "&self" | "self" => {}
_ => {}
}
}
parts.join(", ")
}
fn extract_fn_return(fn_item: &Node, source: &str) -> String {
fn_item
.child_by_field_name("return_type")
.map(|n| source[n.byte_range()].trim().to_string())
.unwrap_or_default()
}
fn collect_mod_declarations(
node: &Node,
source: &str,

View File

@@ -136,3 +136,74 @@ fn extracts_composition_from_class_level_annotations() {
assert_eq!(composition[0].source(), "Definition");
assert_eq!(composition[0].target(), "Gad");
}
#[test]
fn extracts_python_class_methods() {
let source = "class OrderService:\n def process(self):\n pass\n def cancel(self):\n pass\n";
let result = analyze_python(source, "service.py");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert!(
element.methods().iter().any(|m| m.contains("process")),
"expected 'process' method, got: {:?}",
element.methods()
);
assert!(
element.methods().iter().any(|m| m.contains("cancel")),
"expected 'cancel' method, got: {:?}",
element.methods()
);
}
#[test]
fn extracts_python_method_typed_params() {
let source = "class OrderService:\n def process(self, order: Order, count: int) -> None:\n pass\n";
let result = analyze_python(source, "service.py");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("process"))
.unwrap();
assert!(
method.contains("order: Order"),
"missing typed param: {method}"
);
assert!(
method.contains("count: int"),
"missing typed param: {method}"
);
}
#[test]
fn extracts_python_method_return_annotation() {
let source = "class OrderService:\n def get(self) -> Order:\n pass\n";
let result = analyze_python(source, "service.py");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("get"))
.unwrap();
assert!(
method.contains("-> Order"),
"expected return type, got: {method}"
);
}

View File

@@ -128,3 +128,145 @@ fn extracts_mod_declarations() {
assert!(imports.iter().any(|r| r.target() == "crate::models"));
assert!(imports.iter().any(|r| r.target() == "crate::services"));
}
#[test]
fn extracts_rust_method_with_typed_params() {
let source = r#"
pub struct OrderService;
impl OrderService {
pub fn process(&self, order: Order, count: u64) {}
}
"#;
let result = analyze_rust(source, "service.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert!(
element
.methods()
.iter()
.any(|m| m.contains("order: Order") && m.contains("count: u64")),
"expected typed params in method, got: {:?}",
element.methods()
);
}
#[test]
fn extracts_rust_method_return_type() {
let source = r#"
pub struct OrderService;
impl OrderService {
pub fn get(&self) -> Order {}
}
"#;
let result = analyze_rust(source, "service.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert!(
element.methods().iter().any(|m| m.contains("-> Order")),
"expected return type in method, got: {:?}",
element.methods()
);
}
#[test]
fn extracts_rust_method_params_and_return() {
let source = r#"
pub struct OrderService;
impl OrderService {
pub fn process(&self, order: Order) -> Result<(), Error> {}
}
"#;
let result = analyze_rust(source, "service.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("process"))
.unwrap();
assert!(method.contains("order: Order"), "missing param: {method}");
assert!(method.contains("->"), "missing return arrow: {method}");
}
#[test]
fn extracts_rust_static_method_params() {
let source = r#"
pub struct Finder;
impl Finder {
pub fn detect(path: &str, count: usize) -> bool { false }
}
"#;
let result = analyze_rust(source, "finder.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "Finder")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("detect"))
.unwrap();
assert!(method.contains("path"), "missing path param: {method}");
assert!(method.contains("count"), "missing count param: {method}");
}
#[test]
fn extracts_rust_private_method_params() {
let source = r#"
pub struct WalkdirDiscovery;
impl WalkdirDiscovery {
fn detect_language(path: &std::path::Path) -> Option<String> { None }
}
"#;
let result = analyze_rust(source, "discovery.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "WalkdirDiscovery")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("detect_language"))
.unwrap();
assert!(method.contains("path"), "missing path param: {method}");
}
#[test]
fn extracts_rust_method_reference_param() {
let source = r#"
use std::path::Path;
pub struct WalkdirDiscovery;
impl WalkdirDiscovery {
fn detect_language(path: &Path) -> Option<String> { None }
}
"#;
let result = analyze_rust(source, "discovery.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "WalkdirDiscovery")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("detect_language"))
.unwrap();
assert!(method.contains("path"), "missing path param: {method}");
}