style: cargo fmt
All checks were successful
CI / Check / Test (push) Successful in 3m1s
Architecture Docs / Generate diagrams (push) Successful in 2m43s

This commit is contained in:
2026-06-17 13:44:19 +02:00
parent a24dc572bd
commit 009c821f48
14 changed files with 1834 additions and 51 deletions

File diff suppressed because it is too large Load Diff

View 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`