Files
archlens/docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md
Gabriel Kaszewski 009c821f48
All checks were successful
CI / Check / Test (push) Successful in 3m1s
Architecture Docs / Generate diagrams (push) Successful in 2m43s
style: cargo fmt
2026-06-17 13:44:22 +02:00

8.7 KiB

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>:

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

// 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: &Pathexisting_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

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:

/// 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:

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 RenderedFiles 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 AnalysisWarnings 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