Skip to main content

normalize_architecture/
lib.rs

1//! Architectural metrics: coupling, cycles, layering, hubs.
2//!
3//! Extracted pure algorithms and supporting types for architecture analysis.
4//! Report structs and OutputFormatter impls live in the `normalize` crate.
5
6use normalize_facts::FileIndex;
7pub use normalize_graph::ImportChain;
8use normalize_languages::is_programming_language;
9use serde::Serialize;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::path::Path;
12
13// ── Supporting types ─────────────────────────────────────────────────────────
14
15/// A circular dependency cycle.
16#[derive(Debug, Serialize, schemars::JsonSchema)]
17pub struct Cycle {
18    /// Modules involved in the cycle.
19    pub modules: Vec<String>,
20}
21
22/// Coupling metrics for a module (file).
23#[derive(Debug, Serialize, schemars::JsonSchema)]
24pub struct ModuleCoupling {
25    pub path: String,
26    /// Number of modules that import this one.
27    pub fan_in: usize,
28    /// Number of modules this one imports.
29    pub fan_out: usize,
30    /// Instability metric: fan_out / (fan_in + fan_out).
31    /// 0 = stable (many depend on it), 1 = unstable (depends on many).
32    pub instability: f64,
33}
34
35/// Symbol-level metrics.
36#[derive(Debug, Serialize, schemars::JsonSchema)]
37pub struct SymbolMetrics {
38    pub file: String,
39    pub name: String,
40    pub kind: String,
41    /// Number of call sites.
42    pub callers: usize,
43}
44
45/// Bidirectional coupling between two modules.
46#[derive(Debug, Serialize, schemars::JsonSchema)]
47pub struct CrossImport {
48    pub module_a: String,
49    pub module_b: String,
50    /// Imports from A to B.
51    pub a_to_b: usize,
52    /// Imports from B to A.
53    pub b_to_a: usize,
54}
55
56/// Orphan module (never imported).
57#[derive(Debug, Serialize, schemars::JsonSchema)]
58pub struct OrphanModule {
59    pub path: String,
60    pub symbols: usize,
61}
62
63/// Hub module (high fan-in AND high fan-out).
64/// These are architectural bottlenecks — everything flows through them.
65#[derive(Debug, Serialize, schemars::JsonSchema)]
66pub struct HubModule {
67    pub path: String,
68    pub fan_in: usize,
69    pub fan_out: usize,
70    /// Product of fan_in * fan_out — higher = more central.
71    pub hub_score: usize,
72}
73
74/// Import flow between directory layers.
75/// Shows which directories import from which, helping identify layer violations.
76#[derive(Debug, Serialize, schemars::JsonSchema)]
77pub struct LayerFlow {
78    /// Source directory/layer.
79    pub from_layer: String,
80    /// Target directory/layer.
81    pub to_layer: String,
82    /// Number of imports in this direction.
83    pub count: usize,
84}
85
86// ── Import graph ─────────────────────────────────────────────────────────────
87
88/// Import graph: maps of who imports whom and who is imported by whom.
89pub struct ImportGraph {
90    pub imports_by_file: HashMap<String, HashSet<String>>,
91    pub importers_by_file: HashMap<String, HashSet<String>>,
92    pub raw_import_count: usize,
93}
94
95/// Build an import graph from the index.
96pub async fn build_import_graph(idx: &FileIndex) -> Result<ImportGraph, libsql::Error> {
97    let mut imports_by_file: HashMap<String, HashSet<String>> = HashMap::new();
98    let mut importers_by_file: HashMap<String, HashSet<String>> = HashMap::new();
99    let mut unresolved = 0usize;
100
101    let conn = idx.connection();
102    let stmt = conn
103        .prepare("SELECT file, module, name FROM imports")
104        .await?;
105    let mut rows = stmt.query(()).await?;
106
107    let mut raw_imports: Vec<(String, String)> = Vec::new();
108    while let Some(row) = rows.next().await? {
109        let file: String = row.get(0)?;
110        let module: Option<String> = row.get(1)?;
111        let name: String = row.get(2)?;
112
113        let full_module = match module {
114            Some(m) if !m.is_empty() => {
115                if m.contains("::") {
116                    m
117                } else if m == "crate" || m == "super" || m == "self" {
118                    format!("{}::{}", m, name)
119                } else {
120                    m
121                }
122            }
123            _ => {
124                if let Some(pos) = name.rfind("::") {
125                    name[..pos].to_string()
126                } else {
127                    continue;
128                }
129            }
130        };
131
132        raw_imports.push((file, full_module));
133    }
134
135    for (source_file, module) in &raw_imports {
136        let resolved_files = idx.module_to_files(module, source_file).await;
137
138        if resolved_files.is_empty() {
139            unresolved += 1;
140            continue;
141        }
142
143        for target_file in resolved_files {
144            imports_by_file
145                .entry(source_file.clone())
146                .or_default()
147                .insert(target_file.clone());
148            importers_by_file
149                .entry(target_file)
150                .or_default()
151                .insert(source_file.clone());
152        }
153    }
154
155    let _ = if raw_imports.is_empty() {
156        0.0
157    } else {
158        let resolved = raw_imports.len() - unresolved;
159        (resolved as f64 / raw_imports.len() as f64) * 100.0
160    };
161
162    Ok(ImportGraph {
163        imports_by_file,
164        importers_by_file,
165        raw_import_count: raw_imports.len(),
166    })
167}
168
169// ── Coupling and hub detection ────────────────────────────────────────────────
170
171/// Result of coupling and hub detection.
172pub struct CouplingAndHubs {
173    pub coupling: Vec<ModuleCoupling>,
174    pub hubs: Vec<HubModule>,
175}
176
177/// Compute coupling metrics and hub modules from the import graph.
178pub fn compute_coupling_and_hubs(
179    imports_by_file: &HashMap<String, HashSet<String>>,
180    importers_by_file: &HashMap<String, HashSet<String>>,
181    all_files: &HashSet<String>,
182) -> CouplingAndHubs {
183    let mut coupling: Vec<ModuleCoupling> = Vec::new();
184    for file in all_files {
185        let fan_out = imports_by_file.get(file).map(|s| s.len()).unwrap_or(0);
186        let fan_in = importers_by_file.get(file).map(|s| s.len()).unwrap_or(0);
187        let total = fan_in + fan_out;
188        let instability = if total > 0 {
189            fan_out as f64 / total as f64
190        } else {
191            0.5
192        };
193
194        if fan_in > 0 || fan_out > 0 {
195            coupling.push(ModuleCoupling {
196                path: file.clone(),
197                fan_in,
198                fan_out,
199                instability,
200            });
201        }
202    }
203
204    coupling.sort_by(|a, b| b.fan_in.cmp(&a.fan_in));
205
206    let mut hub_modules: Vec<HubModule> = coupling
207        .iter()
208        .filter(|m| m.fan_in >= 3 && m.fan_out >= 3)
209        .map(|m| HubModule {
210            path: m.path.clone(),
211            fan_in: m.fan_in,
212            fan_out: m.fan_out,
213            hub_score: m.fan_in * m.fan_out,
214        })
215        .collect();
216    hub_modules.sort_by(|a, b| b.hub_score.cmp(&a.hub_score));
217    hub_modules.truncate(10);
218
219    coupling.truncate(15);
220    CouplingAndHubs {
221        coupling,
222        hubs: hub_modules,
223    }
224}
225
226// ── Cross-import detection ────────────────────────────────────────────────────
227
228/// Detect bidirectional coupling between module pairs.
229pub fn detect_cross_imports(
230    imports_by_file: &HashMap<String, HashSet<String>>,
231) -> Vec<CrossImport> {
232    let mut cross_imports: Vec<CrossImport> = Vec::new();
233    let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
234
235    for (file_a, imports_a) in imports_by_file {
236        for file_b in imports_a {
237            if let Some(imports_b) = imports_by_file.get(file_b)
238                && imports_b.contains(file_a)
239            {
240                let pair = if file_a < file_b {
241                    (file_a.clone(), file_b.clone())
242                } else {
243                    (file_b.clone(), file_a.clone())
244                };
245                if !seen_pairs.contains(&pair) {
246                    seen_pairs.insert(pair);
247                    let a_to_b = imports_a.iter().filter(|f| *f == file_b).count();
248                    let b_to_a = imports_b.iter().filter(|f| *f == file_a).count();
249                    cross_imports.push(CrossImport {
250                        module_a: file_a.clone(),
251                        module_b: file_b.clone(),
252                        a_to_b,
253                        b_to_a,
254                    });
255                }
256            }
257        }
258    }
259    cross_imports
260}
261
262// ── Orphan module detection ───────────────────────────────────────────────────
263
264/// Find modules that have no importers (and are not entry-point files).
265pub async fn find_orphan_modules(
266    conn: &libsql::Connection,
267    importers_by_file: &HashMap<String, HashSet<String>>,
268) -> Result<Vec<OrphanModule>, libsql::Error> {
269    let mut orphans: Vec<OrphanModule> = Vec::new();
270    let stmt = conn
271        .prepare("SELECT file, COUNT(*) FROM symbols GROUP BY file")
272        .await?;
273    let mut rows = stmt.query(()).await?;
274    while let Some(row) = rows.next().await? {
275        let file: String = row.get(0)?;
276        let symbol_count: i64 = row.get(1)?;
277
278        if !is_programming_language(Path::new(&file)) {
279            continue;
280        }
281
282        let is_imported = importers_by_file.contains_key(&file);
283        let is_likely_entry = file.contains("main.")
284            || file.contains("mod.rs")
285            || file.contains("lib.rs")
286            || file.contains("index.")
287            || file.contains("__init__")
288            || file.contains("test")
289            || file.contains("spec");
290
291        if !is_imported && !is_likely_entry && symbol_count > 0 {
292            let symbols = if symbol_count < 0 {
293                0usize
294            } else {
295                symbol_count as usize
296            };
297            orphans.push(OrphanModule {
298                path: file,
299                symbols,
300            });
301        }
302    }
303    orphans.truncate(10);
304    Ok(orphans)
305}
306
307// ── Symbol hotspot detection ──────────────────────────────────────────────────
308
309const GENERIC_METHODS: &[&str] = &[
310    "new",
311    "default",
312    "from",
313    "into",
314    "clone",
315    "to_string",
316    "as_str",
317    "as_ref",
318    "get",
319    "set",
320    "len",
321    "is_empty",
322    "iter",
323    "next",
324    "unwrap",
325    "expect",
326    "ok",
327    "err",
328    "some",
329    "none",
330    "push",
331    "pop",
332    "insert",
333    "remove",
334    "contains",
335    "map",
336    "filter",
337    "collect",
338    "fmt",
339    "write",
340    "read",
341    "Ok",
342    "Err",
343    "Some",
344    "None",
345];
346
347/// Find symbols imported from many places (symbol hotspots).
348pub async fn find_symbol_hotspots(
349    conn: &libsql::Connection,
350) -> Result<Vec<SymbolMetrics>, libsql::Error> {
351    let generic: HashSet<&str> = GENERIC_METHODS.iter().copied().collect();
352
353    let mut symbol_callers: HashMap<String, usize> = HashMap::new();
354    let stmt = conn
355        .prepare("SELECT callee_name, COUNT(*) as cnt FROM calls GROUP BY callee_name ORDER BY cnt DESC LIMIT 100")
356        .await?;
357    let mut rows = stmt.query(()).await?;
358    while let Some(row) = rows.next().await? {
359        let name: String = row.get(0)?;
360        let count: i64 = row.get(1)?;
361        if !generic.contains(name.as_str()) {
362            let n = if count < 0 { 0usize } else { count as usize };
363            symbol_callers.insert(name, n);
364        }
365    }
366
367    // Filter to callers > 3 before the lookup query.
368    let candidates: Vec<(String, usize)> =
369        symbol_callers.into_iter().filter(|(_, c)| *c > 3).collect();
370
371    let mut hotspots: Vec<SymbolMetrics> = Vec::new();
372    if !candidates.is_empty() {
373        let placeholders = candidates
374            .iter()
375            .map(|_| "?")
376            .collect::<Vec<_>>()
377            .join(", ");
378        let sql = format!(
379            "SELECT name, file, kind FROM symbols WHERE name IN ({}) GROUP BY name",
380            placeholders
381        );
382        let stmt = conn.prepare(&sql).await?;
383        let params: Vec<libsql::Value> = candidates
384            .iter()
385            .map(|(n, _)| libsql::Value::Text(n.clone()))
386            .collect();
387        let mut rows = stmt.query(params).await?;
388        // Build a lookup from name → callers count.
389        let callers_map: HashMap<String, usize> = candidates.into_iter().collect();
390        while let Some(row) = rows.next().await? {
391            let name: String = row.get(0)?;
392            let file: String = row.get(1)?;
393            let kind: String = row.get(2)?;
394            if let Some(&callers) = callers_map.get(&name) {
395                hotspots.push(SymbolMetrics {
396                    file,
397                    name,
398                    kind,
399                    callers,
400                });
401            }
402        }
403    }
404    hotspots.sort_by(|a, b| b.callers.cmp(&a.callers));
405    hotspots.truncate(10);
406    Ok(hotspots)
407}
408
409// ── Cycle detection ───────────────────────────────────────────────────────────
410
411/// Find cycles in the import graph using iterative DFS.
412pub fn find_cycles(graph: &HashMap<String, HashSet<String>>) -> Vec<Cycle> {
413    let mut cycles = Vec::new();
414    let mut visited: HashSet<String> = HashSet::new();
415
416    for start in graph.keys() {
417        if visited.contains(start) {
418            continue;
419        }
420        // Each stack frame: (node, iterator-index-into-neighbors, already-pushed-to-path)
421        // We use an explicit stack of (node, neighbor_index) pairs.
422        // rec_stack tracks the current DFS path as a set; path tracks it as an ordered Vec.
423        let mut rec_stack: HashSet<String> = HashSet::new();
424        let mut path: Vec<String> = Vec::new();
425        // Stack entries: (node_name, index_of_next_neighbor_to_visit)
426        let mut stack: Vec<(String, usize)> = Vec::new();
427
428        // Push the start node
429        visited.insert(start.clone());
430        rec_stack.insert(start.clone());
431        path.push(start.clone());
432        stack.push((start.clone(), 0));
433
434        while let Some((node, idx)) = stack.last_mut() {
435            let node = node.clone();
436            let neighbors: Vec<String> = graph
437                .get(&node)
438                .map(|s| s.iter().cloned().collect())
439                .unwrap_or_default();
440
441            if *idx < neighbors.len() {
442                let neighbor = neighbors[*idx].clone();
443                *idx += 1;
444
445                if !visited.contains(&neighbor) {
446                    visited.insert(neighbor.clone());
447                    rec_stack.insert(neighbor.clone());
448                    path.push(neighbor.clone());
449                    stack.push((neighbor, 0));
450                } else if rec_stack.contains(&neighbor)
451                    && let Some(pos) = path.iter().position(|x| x == &neighbor)
452                    && path[pos..].len() > 1
453                {
454                    cycles.push(Cycle {
455                        modules: path[pos..].to_vec(),
456                    });
457                }
458            } else {
459                // Done with this node — pop it
460                stack.pop();
461                path.pop();
462                rec_stack.remove(&node);
463            }
464        }
465    }
466
467    // Deduplicate cycles (same cycle can be found starting from different nodes).
468    let mut unique_cycles: Vec<Cycle> = Vec::new();
469    let mut seen_cycle_sets: HashSet<Vec<String>> = HashSet::new();
470
471    for cycle in cycles {
472        let mut sorted = cycle.modules.clone();
473        sorted.sort();
474        if !seen_cycle_sets.contains(&sorted) {
475            seen_cycle_sets.insert(sorted);
476            unique_cycles.push(cycle);
477        }
478    }
479
480    unique_cycles.truncate(10);
481    unique_cycles
482}
483
484// ── Longest chain detection ───────────────────────────────────────────────────
485
486/// Find the longest import chains (dependency paths) in the graph.
487///
488/// Returns up to 5 chains (hard-coded limit), sorted by depth (longest first).
489/// Only chains with more than 3 nodes (depth > 3) are included.
490///
491/// Chains dominated by a suffix of a longer chain are removed: if chain B's
492/// modules are a suffix of chain A's modules, B is dropped. This keeps the
493/// result set non-redundant — each returned chain represents a unique root.
494///
495/// Uses DFS from each node with memoization to find the longest path, avoiding cycles.
496pub fn find_longest_chains(graph: &HashMap<String, HashSet<String>>) -> Vec<ImportChain> {
497    let mut longest_paths: Vec<ImportChain> = Vec::new();
498    let mut memo: HashMap<String, Vec<String>> = HashMap::new();
499
500    for start in graph.keys() {
501        let mut visited: HashSet<String> = HashSet::new();
502        let path = longest_path_from(start, graph, &mut visited, &mut memo);
503        if path.len() > 3 {
504            longest_paths.push(ImportChain {
505                depth: path.len() - 1,
506                modules: path,
507            });
508        }
509    }
510
511    longest_paths.sort_by(|a, b| b.depth.cmp(&a.depth));
512
513    let mut unique_chains: Vec<ImportChain> = Vec::new();
514    for chain in longest_paths {
515        let dominated = unique_chains.iter().any(|existing| {
516            existing.modules.len() > chain.modules.len()
517                && existing.modules.ends_with(&chain.modules)
518        });
519        if !dominated {
520            unique_chains.push(chain);
521        }
522        if unique_chains.len() >= 5 {
523            break;
524        }
525    }
526
527    unique_chains
528}
529
530/// Find the longest path from a node using DFS with memoization.
531///
532/// # Memoization limitation
533///
534/// Results are cached keyed only by `node`. When the same node is reached from
535/// two different roots the first cached result is reused, even though the
536/// `visited` set differs between the two calls. This means the cached result
537/// may be shorter than what would be computed from a different root (because
538/// some successors were marked visited in the first traversal). The trade-off
539/// is acceptable: the memo avoids O(n²) worst-case work and the goal is
540/// finding representative longest paths, not an exhaustive enumeration.
541fn longest_path_from(
542    node: &str,
543    graph: &HashMap<String, HashSet<String>>,
544    visited: &mut HashSet<String>,
545    memo: &mut HashMap<String, Vec<String>>,
546) -> Vec<String> {
547    if let Some(cached) = memo.get(node) {
548        return cached.clone();
549    }
550
551    visited.insert(node.to_string());
552
553    let mut longest: Vec<String> = vec![node.to_string()];
554
555    if let Some(neighbors) = graph.get(node) {
556        for neighbor in neighbors {
557            if !visited.contains(neighbor) {
558                let sub_path = longest_path_from(neighbor, graph, visited, memo);
559                if sub_path.len() + 1 > longest.len() {
560                    let mut new_path = vec![node.to_string()];
561                    new_path.extend(sub_path);
562                    longest = new_path;
563                }
564            }
565        }
566    }
567
568    visited.remove(node);
569    memo.insert(node.to_string(), longest.clone());
570    longest
571}
572
573// ── Layer extraction ──────────────────────────────────────────────────────────
574
575/// Extract the layer (top-level directory) from a file path.
576/// Returns the first significant directory component.
577pub fn extract_layer(path: &str) -> String {
578    let path = path
579        .strip_prefix("crates/normalize/src/")
580        .or_else(|| path.strip_prefix("crates/normalize-"))
581        .or_else(|| path.strip_prefix("src/"))
582        .unwrap_or(path);
583
584    if let Some(pos) = path.find('/') {
585        path[..pos].to_string()
586    } else {
587        path.split('.').next().unwrap_or("root").to_string()
588    }
589}
590
591/// Compute import flows between directory layers.
592pub fn compute_layer_flows(graph: &HashMap<String, HashSet<String>>) -> Vec<LayerFlow> {
593    let mut flow_counts: HashMap<(String, String), usize> = HashMap::new();
594
595    for (from_file, to_files) in graph {
596        let from_layer = extract_layer(from_file);
597        for to_file in to_files {
598            let to_layer = extract_layer(to_file);
599            if from_layer != to_layer {
600                *flow_counts
601                    .entry((from_layer.clone(), to_layer.clone()))
602                    .or_insert(0) += 1;
603            }
604        }
605    }
606
607    let mut flows: Vec<LayerFlow> = flow_counts
608        .into_iter()
609        .map(|((from, to), count)| LayerFlow {
610            from_layer: from,
611            to_layer: to,
612            count,
613        })
614        .collect();
615
616    flows.sort_by(|a, b| b.count.cmp(&a.count));
617    flows.truncate(15);
618    flows
619}
620
621// ── Depth computation ─────────────────────────────────────────────────────────
622
623/// Compute depth for a single node via DFS + memoization.
624/// depth(M) = max(1 + depth(importer) for importer in importers_by_file[M]), base 0.
625pub fn compute_depth(
626    node: &str,
627    importers_by_file: &HashMap<String, HashSet<String>>,
628    memo: &mut HashMap<String, usize>,
629    in_stack: &mut HashSet<String>,
630) -> usize {
631    if let Some(&d) = memo.get(node) {
632        return d;
633    }
634    if in_stack.contains(node) {
635        return 0;
636    }
637    in_stack.insert(node.to_string());
638
639    let depth = match importers_by_file.get(node) {
640        None => 0,
641        Some(importers) if importers.is_empty() => 0,
642        Some(importers) => importers
643            .iter()
644            .map(|imp| 1 + compute_depth(imp, importers_by_file, memo, in_stack))
645            .max()
646            .unwrap_or(0),
647    };
648
649    in_stack.remove(node);
650    memo.insert(node.to_string(), depth);
651    depth
652}
653
654/// Compute downstream count for a node: BFS through importers_by_file.
655pub fn compute_downstream(
656    node: &str,
657    importers_by_file: &HashMap<String, HashSet<String>>,
658) -> usize {
659    let mut visited = HashSet::new();
660    let mut queue = VecDeque::new();
661
662    if let Some(importers) = importers_by_file.get(node) {
663        for imp in importers {
664            if visited.insert(imp.clone()) {
665                queue.push_back(imp.clone());
666            }
667        }
668    }
669
670    while let Some(current) = queue.pop_front() {
671        if let Some(importers) = importers_by_file.get(&current) {
672            for imp in importers {
673                if visited.insert(imp.clone()) {
674                    queue.push_back(imp.clone());
675                }
676            }
677        }
678    }
679
680    visited.len()
681}
682
683// ── Layering compliance ───────────────────────────────────────────────────────
684
685/// Per-module layering metrics returned by `compute_layering_compliance`.
686pub struct LayeringModuleResult {
687    pub module: String,
688    pub layer: String,
689    /// Cross-layer imports (downward + upward only).
690    pub total_imports: usize,
691    pub downward_imports: usize,
692    pub upward_imports: usize,
693    /// Imports within the same layer.
694    pub self_imports: usize,
695    /// downward / (downward + upward); 1.0 if no cross-layer imports.
696    pub compliance: f64,
697}
698
699/// Classify imports for each module as downward, upward, or self-layer.
700///
701/// Takes the resolved import graph and per-layer average depths.
702/// Returns per-module compliance entries sorted worst-first.
703pub fn compute_layering_compliance(
704    imports_by_file: &HashMap<String, HashSet<String>>,
705    all_modules: &HashSet<String>,
706    layer_avg_depth: &HashMap<String, f64>,
707) -> Vec<LayeringModuleResult> {
708    let mut entries: Vec<LayeringModuleResult> = Vec::new();
709
710    for module in all_modules {
711        let imports = match imports_by_file.get(module) {
712            Some(targets) => targets,
713            None => continue,
714        };
715
716        let src_layer = extract_layer(module);
717        let src_avg = layer_avg_depth.get(&src_layer).copied().unwrap_or(0.0);
718
719        let mut downward = 0usize;
720        let mut upward = 0usize;
721        let mut self_count = 0usize;
722
723        for target in imports {
724            let tgt_layer = extract_layer(target);
725            if tgt_layer == src_layer {
726                self_count += 1;
727            } else {
728                let tgt_avg = layer_avg_depth.get(&tgt_layer).copied().unwrap_or(0.0);
729                if tgt_avg > src_avg {
730                    downward += 1;
731                } else if tgt_avg < src_avg {
732                    upward += 1;
733                } else {
734                    self_count += 1;
735                }
736            }
737        }
738
739        let cross = downward + upward;
740        let compliance = if cross == 0 {
741            1.0
742        } else {
743            downward as f64 / cross as f64
744        };
745
746        entries.push(LayeringModuleResult {
747            module: module.clone(),
748            layer: src_layer,
749            total_imports: cross,
750            downward_imports: downward,
751            upward_imports: upward,
752            self_imports: self_count,
753            compliance,
754        });
755    }
756
757    entries.sort_by(|a, b| {
758        a.compliance
759            .partial_cmp(&b.compliance)
760            .unwrap_or(std::cmp::Ordering::Equal)
761            .then(b.upward_imports.cmp(&a.upward_imports))
762            .then(a.module.cmp(&b.module))
763    });
764
765    entries
766}