Skip to main content

boundary_core/
pattern_detection.rs

1//! Pattern detection engine for FR-27.
2//!
3//! Computes independent confidence scores in [0.0, 1.0] for five architectural
4//! patterns. Confidence values do NOT sum to 1.0 — a codebase in transition
5//! may score above zero for multiple patterns simultaneously.
6//!
7//! Patterns:
8//!   ddd-hexagonal  — distinct layers, domain has ports, correct coupling direction
9//!   active-record  — domain types carry persistence (insufficient signals without method analysis)
10//!   flat-crud      — single package, all concrete, no layer convention
11//!   anemic-domain  — domain package has no interfaces, business logic lives elsewhere
12//!   service-layer  — some separation, cross-package deps, but no ports/adapters
13
14use std::collections::HashSet;
15
16use serde::{Deserialize, Serialize};
17
18use crate::types::{Component, ComponentKind, Dependency};
19
20/// A single pattern with its confidence score.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PatternScore {
23    pub name: String,
24    /// Independent confidence in [0.0, 1.0]. Values do not sum to 1.0.
25    pub confidence: f64,
26}
27
28/// Output of the pattern detection pass.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct PatternDetection {
31    /// All five patterns with their confidence scores.
32    pub patterns: Vec<PatternScore>,
33    /// Name of the pattern with the highest confidence.
34    pub top_pattern: String,
35    /// Confidence of the top pattern.
36    pub top_confidence: f64,
37}
38
39/// Internal signals derived from components and dependencies.
40struct Signals {
41    pkg_count: usize,
42    has_domain_layer: bool,
43    has_app_layer: bool,
44    has_infra_layer: bool,
45    layer_name_count: usize,
46    total_interfaces: usize,
47    domain_interfaces: usize,
48    domain_structs: usize,
49    domain_is_imported: bool,
50    domain_imports_nothing: bool,
51    has_any_internal_deps: bool,
52}
53
54/// Detect architectural patterns and return confidence scores for all five patterns.
55pub fn detect_patterns(components: &[Component], dependencies: &[Dependency]) -> PatternDetection {
56    let signals = extract_signals(components, dependencies);
57
58    let mut patterns = vec![
59        PatternScore {
60            name: "ddd-hexagonal".to_string(),
61            confidence: ddd_hexagonal(&signals),
62        },
63        PatternScore {
64            name: "active-record".to_string(),
65            confidence: active_record(&signals),
66        },
67        PatternScore {
68            name: "flat-crud".to_string(),
69            confidence: flat_crud(&signals),
70        },
71        PatternScore {
72            name: "anemic-domain".to_string(),
73            confidence: anemic_domain(&signals),
74        },
75        PatternScore {
76            name: "service-layer".to_string(),
77            confidence: service_layer(&signals),
78        },
79    ];
80
81    // Sort descending by confidence so the first entry is the top pattern.
82    // Stable sort preserves the fixed declaration order for ties.
83    patterns.sort_by(|a, b| {
84        b.confidence
85            .partial_cmp(&a.confidence)
86            .unwrap_or(std::cmp::Ordering::Equal)
87    });
88
89    let top_pattern = patterns
90        .first()
91        .map(|p| p.name.clone())
92        .unwrap_or_else(|| "unknown".to_string());
93    let top_confidence = patterns.first().map(|p| p.confidence).unwrap_or(0.0);
94
95    // Re-sort alphabetically so the JSON output is stable and predictable.
96    patterns.sort_by(|a, b| a.name.cmp(&b.name));
97
98    PatternDetection {
99        patterns,
100        top_pattern,
101        top_confidence,
102    }
103}
104
105// ─── Signal extraction ────────────────────────────────────────────────────────
106
107fn pkg_from_id(id: &str) -> &str {
108    id.split("::").next().unwrap_or("")
109}
110
111/// Check whether any path segment (split on `/`, `.`, or `:`) equals `layer`.
112/// Handles Go (`project/domain`), Java (`com.example.domain.user`),
113/// and Rust (`crate::domain::user`) package naming conventions.
114fn path_contains_layer(path: &str, layer: &str) -> bool {
115    path.split(['/', '.', ':']).any(|seg| seg == layer)
116}
117
118/// Return the canonical layer name found anywhere in `path`, or `None`.
119fn import_layer(path: &str) -> Option<&'static str> {
120    // Order matters: check more-specific names before aliases.
121    ["domain", "application", "infrastructure", "app", "infra"]
122        .iter()
123        .find(|&&layer| path_contains_layer(path, layer))
124        .copied()
125}
126
127/// Normalise layer aliases: "app" → "application", "infra" → "infrastructure".
128fn canonical_layer(layer: &str) -> &str {
129    match layer {
130        "app" => "application",
131        "infra" => "infrastructure",
132        other => other,
133    }
134}
135
136fn extract_signals(components: &[Component], dependencies: &[Dependency]) -> Signals {
137    // ── Package set ─────────────────────────────────────────────────────────
138    let mut pkg_paths: HashSet<String> = HashSet::new();
139    for comp in components {
140        let pkg = pkg_from_id(&comp.id.0);
141        if !pkg.is_empty() {
142            pkg_paths.insert(pkg.to_string());
143        }
144    }
145    let pkg_count = pkg_paths.len();
146
147    // ── Layer names ──────────────────────────────────────────────────────────
148    // Check whether any layer keyword appears as a segment anywhere in the pkg path.
149    // This handles sub-packages (Java `com.example.domain.user`) and
150    // sub-directories (Rust `src/domain/user`).
151    let has_domain_layer = pkg_paths.iter().any(|p| path_contains_layer(p, "domain"));
152    let has_app_layer = pkg_paths
153        .iter()
154        .any(|p| path_contains_layer(p, "application") || path_contains_layer(p, "app"));
155    let has_infra_layer = pkg_paths
156        .iter()
157        .any(|p| path_contains_layer(p, "infrastructure") || path_contains_layer(p, "infra"));
158    let layer_name_count = [has_domain_layer, has_app_layer, has_infra_layer]
159        .iter()
160        .filter(|&&x| x)
161        .count();
162
163    // ── Interface / struct counts ────────────────────────────────────────────
164    let mut total_interfaces = 0usize;
165    let mut domain_interfaces = 0usize;
166    let mut domain_structs = 0usize;
167
168    for comp in components {
169        let full_pkg = pkg_from_id(&comp.id.0);
170        let in_domain = path_contains_layer(full_pkg, "domain");
171        let is_interface = matches!(comp.kind, ComponentKind::Port(_));
172        if is_interface {
173            total_interfaces += 1;
174        }
175        if in_domain {
176            if is_interface {
177                domain_interfaces += 1;
178            } else {
179                domain_structs += 1;
180            }
181        }
182    }
183
184    // ── Internal coupling ─────────────────────────────────────────────────────
185    // Detect cross-layer deps by matching layer keywords in the from-package
186    // path and the import_path string.
187    let mut has_any_internal_deps = false;
188    let mut domain_has_afferent = false; // something outside domain imports domain
189    let mut domain_has_efferent = false; // domain imports something outside itself
190
191    for dep in dependencies {
192        let from_pkg = pkg_from_id(&dep.from.0);
193        let Some(from_layer) = import_layer(from_pkg) else {
194            continue;
195        };
196        let Some(to_layer) = dep.import_path.as_deref().and_then(import_layer) else {
197            continue;
198        };
199
200        let from_c = canonical_layer(from_layer);
201        let to_c = canonical_layer(to_layer);
202        if from_c == to_c {
203            continue; // intra-layer
204        }
205
206        has_any_internal_deps = true;
207        if to_c == "domain" {
208            domain_has_afferent = true;
209        }
210        if from_c == "domain" {
211            domain_has_efferent = true;
212        }
213    }
214
215    let domain_is_imported = domain_has_afferent;
216    let domain_imports_nothing = !domain_has_efferent;
217
218    Signals {
219        pkg_count,
220        has_domain_layer,
221        has_app_layer,
222        has_infra_layer,
223        layer_name_count,
224        total_interfaces,
225        domain_interfaces,
226        domain_structs,
227        domain_is_imported,
228        domain_imports_nothing,
229        has_any_internal_deps,
230    }
231}
232
233// ─── Pattern confidence functions ─────────────────────────────────────────────
234
235/// DDD + Hexagonal: distinct layers, domain has ports, stable core.
236fn ddd_hexagonal(s: &Signals) -> f64 {
237    let mut score = 0.0_f64;
238    if s.has_domain_layer {
239        score += 0.20;
240    }
241    if s.has_app_layer {
242        score += 0.15;
243    }
244    if s.has_infra_layer {
245        score += 0.15;
246    }
247    if s.domain_interfaces > 0 {
248        score += 0.20;
249    }
250    if s.domain_is_imported && s.domain_imports_nothing {
251        score += 0.20;
252    }
253    let total_domain = s.domain_interfaces + s.domain_structs;
254    if total_domain > 0 {
255        let ratio = s.domain_interfaces as f64 / total_domain as f64;
256        if ratio >= 0.25 {
257            score += 0.10;
258        }
259    }
260    score.clamp(0.0, 1.0)
261}
262
263/// Active Record: domain types carry persistence — requires method-level signals
264/// not available from structural analysis alone.
265fn active_record(_s: &Signals) -> f64 {
266    0.0
267}
268
269/// Flat CRUD: single package (or nearly so), all concrete, no layer convention.
270fn flat_crud(s: &Signals) -> f64 {
271    let mut score = 0.0_f64;
272    if s.pkg_count == 1 {
273        score += 0.55;
274    } else if s.pkg_count == 2 && !s.has_any_internal_deps {
275        // Two isolated packages with no coupling — weak flat signal
276        score += 0.05;
277    }
278    if s.total_interfaces == 0 {
279        score += 0.20;
280    }
281    if s.layer_name_count == 0 {
282        score += 0.15;
283    }
284    score.clamp(0.0, 1.0)
285}
286
287/// Anemic Domain: domain package is a data container (no interfaces), business
288/// logic lives in a separate service/application package.
289fn anemic_domain(s: &Signals) -> f64 {
290    if !s.has_domain_layer {
291        return 0.0;
292    }
293    let mut score = 0.0_f64;
294    if s.domain_interfaces == 0 && s.domain_structs > 0 {
295        score += 0.40; // key signal: domain has no abstract types
296    }
297    if s.domain_is_imported {
298        score += 0.30; // other packages depend on domain as a data layer
299    }
300    if s.total_interfaces == 0 {
301        score += 0.20; // no ports anywhere
302    }
303    if !s.has_app_layer {
304        score += 0.10; // logic layer is not named "application"
305    }
306    score.clamp(0.0, 1.0)
307}
308
309/// Service Layer: some separation and cross-package coupling, but no ports/adapters.
310fn service_layer(s: &Signals) -> f64 {
311    let mut score = 0.0_f64;
312    if s.pkg_count >= 2 {
313        score += 0.20;
314    }
315    if s.has_any_internal_deps {
316        score += 0.30;
317    }
318    if s.total_interfaces == 0 && s.has_any_internal_deps {
319        score += 0.20; // services call data types directly — no abstraction layer
320    }
321    if !s.has_domain_layer && !s.has_infra_layer && s.pkg_count >= 2 {
322        score += 0.10; // no DDD naming convention
323    }
324    score.clamp(0.0, 1.0)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::types::{
331        ArchLayer, ArchitectureMode, ComponentId, ComponentKind, DependencyKind, EntityInfo,
332        PortInfo, SourceLocation,
333    };
334    use std::path::PathBuf;
335
336    fn make_interface(id: &str) -> Component {
337        Component {
338            id: ComponentId(id.to_string()),
339            name: id.split("::").last().unwrap_or(id).to_string(),
340            kind: ComponentKind::Port(PortInfo {
341                name: id.to_string(),
342                methods: vec![],
343            }),
344            layer: None,
345            location: SourceLocation {
346                file: PathBuf::from("test.go"),
347                line: 1,
348                column: 1,
349            },
350            is_cross_cutting: false,
351            architecture_mode: ArchitectureMode::Ddd,
352        }
353    }
354
355    fn make_struct(id: &str) -> Component {
356        Component {
357            id: ComponentId(id.to_string()),
358            name: id.split("::").last().unwrap_or(id).to_string(),
359            kind: ComponentKind::Entity(EntityInfo {
360                name: id.to_string(),
361                fields: vec![],
362                methods: vec![],
363                is_active_record: false,
364                is_anemic_domain_model: false,
365            }),
366            layer: Some(ArchLayer::Domain),
367            location: SourceLocation {
368                file: PathBuf::from("test.go"),
369                line: 1,
370                column: 1,
371            },
372            is_cross_cutting: false,
373            architecture_mode: ArchitectureMode::Ddd,
374        }
375    }
376
377    fn make_dep(from: &str, to: &str, import_path: &str) -> Dependency {
378        Dependency {
379            from: ComponentId(from.to_string()),
380            to: ComponentId(to.to_string()),
381            kind: DependencyKind::Import,
382            location: SourceLocation {
383                file: PathBuf::from("test.go"),
384                line: 1,
385                column: 1,
386            },
387            import_path: Some(import_path.to_string()),
388        }
389    }
390
391    #[test]
392    fn ddd_hexagonal_confidence_high_for_layered_project_with_ports() {
393        // domain: 1 interface + 1 struct; application + infrastructure import domain
394        let components = vec![
395            make_interface("/project/domain::UserRepository"),
396            make_struct("/project/domain::User"),
397            make_struct("/project/application::UserService"),
398            make_struct("/project/infrastructure::UserRepo"),
399        ];
400        let deps = vec![
401            make_dep(
402                "/project/application::UserService",
403                "/project/domain::UserRepository",
404                "example/domain",
405            ),
406            make_dep(
407                "/project/infrastructure::UserRepo",
408                "/project/domain::UserRepository",
409                "example/domain",
410            ),
411        ];
412        let pd = detect_patterns(&components, &deps);
413        let conf = pd
414            .patterns
415            .iter()
416            .find(|p| p.name == "ddd-hexagonal")
417            .map(|p| p.confidence)
418            .unwrap();
419        assert!(
420            conf >= 0.5,
421            "ddd-hexagonal should be >= 0.5 for a layered project, got {conf}"
422        );
423        assert_eq!(pd.top_pattern, "ddd-hexagonal");
424    }
425
426    #[test]
427    fn flat_crud_confidence_high_for_single_package() {
428        let components = vec![
429            make_struct("/project/flat::Product"),
430            make_struct("/project/flat::Order"),
431            make_struct("/project/flat::Customer"),
432        ];
433        let pd = detect_patterns(&components, &[]);
434        let conf = pd
435            .patterns
436            .iter()
437            .find(|p| p.name == "flat-crud")
438            .map(|p| p.confidence)
439            .unwrap();
440        assert!(
441            conf >= 0.5,
442            "flat-crud should be >= 0.5 for a single-package all-concrete project, got {conf}"
443        );
444    }
445
446    #[test]
447    fn anemic_domain_confidence_high_when_domain_has_no_interfaces() {
448        // domain: 2 structs; services: 1 struct; services imports domain
449        let components = vec![
450            make_struct("/project/domain::Order"),
451            make_struct("/project/domain::Customer"),
452            make_struct("/project/services::OrderService"),
453        ];
454        let deps = vec![make_dep(
455            "/project/services::OrderService",
456            "/project/domain::Order",
457            "example/domain",
458        )];
459        let pd = detect_patterns(&components, &deps);
460        let conf = pd
461            .patterns
462            .iter()
463            .find(|p| p.name == "anemic-domain")
464            .map(|p| p.confidence)
465            .unwrap();
466        assert!(
467            conf >= 0.5,
468            "anemic-domain should be >= 0.5 for a domain with no interfaces, got {conf}"
469        );
470    }
471
472    #[test]
473    fn all_confidences_below_threshold_for_structurally_neutral_project() {
474        // alpha/beta: no layer names, no imports, no interfaces
475        let components = vec![
476            make_struct("/project/alpha::Foo"),
477            make_struct("/project/alpha::Bar"),
478            make_struct("/project/beta::Qux"),
479            make_struct("/project/beta::Baz"),
480        ];
481        let pd = detect_patterns(&components, &[]);
482        let max_conf = pd
483            .patterns
484            .iter()
485            .map(|p| p.confidence)
486            .fold(0.0_f64, f64::max);
487        assert!(
488            max_conf < 0.5,
489            "all confidences should be < 0.5 for a structurally neutral project, got max {max_conf}"
490        );
491    }
492
493    #[test]
494    fn transition_project_has_multiple_nonzero_patterns() {
495        // domain: 3 structs (no interfaces); infrastructure: 2 structs, imports domain
496        let components = vec![
497            make_struct("/project/domain::Order"),
498            make_struct("/project/domain::Customer"),
499            make_struct("/project/domain::Product"),
500            make_struct("/project/infrastructure::OrderRepo"),
501            make_struct("/project/infrastructure::CustomerRepo"),
502        ];
503        let deps = vec![
504            make_dep(
505                "/project/infrastructure::OrderRepo",
506                "/project/domain::Order",
507                "example/domain",
508            ),
509            make_dep(
510                "/project/infrastructure::CustomerRepo",
511                "/project/domain::Customer",
512                "example/domain",
513            ),
514        ];
515        let pd = detect_patterns(&components, &deps);
516        let nonzero = pd.patterns.iter().filter(|p| p.confidence > 0.0).count();
517        assert!(
518            nonzero > 1,
519            "a transition project should have more than one pattern above 0.0, got {nonzero}"
520        );
521    }
522
523    #[test]
524    fn output_always_contains_all_five_patterns() {
525        let pd = detect_patterns(&[], &[]);
526        let names: Vec<&str> = pd.patterns.iter().map(|p| p.name.as_str()).collect();
527        for expected in [
528            "ddd-hexagonal",
529            "active-record",
530            "flat-crud",
531            "anemic-domain",
532            "service-layer",
533        ] {
534            assert!(names.contains(&expected), "missing pattern '{expected}'");
535        }
536    }
537
538    #[test]
539    fn all_confidence_values_in_range() {
540        let pd = detect_patterns(&[], &[]);
541        for p in &pd.patterns {
542            assert!(
543                (0.0..=1.0).contains(&p.confidence),
544                "confidence for '{}' out of range: {}",
545                p.name,
546                p.confidence
547            );
548        }
549    }
550}