Skip to main content

chainsaw/
query.rs

1//! Graph queries: trace weight, import chains, cut points, and diffs.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use serde::{Deserialize, Serialize};
6
7use crate::graph::{EdgeKind, ModuleGraph, ModuleId};
8
9/// Results of tracing transitive import weight from an entry module.
10#[derive(Debug)]
11#[non_exhaustive]
12pub struct TraceResult {
13    /// Total file size reachable via static imports
14    pub static_weight: u64,
15    /// Number of modules reachable via static imports
16    pub static_module_count: usize,
17    /// Total file size reachable only via dynamic imports (not already counted in static)
18    pub dynamic_only_weight: u64,
19    /// Number of modules reachable only via dynamic imports
20    pub dynamic_only_module_count: usize,
21    /// Heavy packages found via static imports, sorted by total reachable size descending
22    pub heavy_packages: Vec<HeavyPackage>,
23    /// All reachable modules with their exclusive weight, sorted descending
24    pub modules_by_cost: Vec<ModuleCost>,
25    /// All statically reachable packages with their total size
26    pub all_packages: HashMap<String, u64>,
27    /// Packages reachable only via dynamic imports (not in static set)
28    pub dynamic_packages: HashMap<String, u64>,
29}
30
31/// A third-party package with its reachable size and shortest import chain.
32#[derive(Debug)]
33#[non_exhaustive]
34pub struct HeavyPackage {
35    pub name: String,
36    pub total_size: u64,
37    pub file_count: u32,
38    /// Shortest chain from entry point to the first module in this package
39    pub chain: Vec<ModuleId>,
40}
41
42/// A module with its exclusive weight (bytes only it contributes to the graph).
43#[derive(Debug, Clone, Copy)]
44#[non_exhaustive]
45pub struct ModuleCost {
46    pub module_id: ModuleId,
47    pub exclusive_size: u64,
48}
49
50/// Options controlling which edges to follow and how many results to return.
51#[derive(Debug)]
52pub struct TraceOptions {
53    pub include_dynamic: bool,
54    pub top_n: i32,
55    pub ignore: Vec<String>,
56}
57
58impl Default for TraceOptions {
59    fn default() -> Self {
60        Self {
61            include_dynamic: false,
62            top_n: 10,
63            ignore: Vec::new(),
64        }
65    }
66}
67
68/// Target for `--chain`/`--cut` queries: a package name or a specific module.
69#[derive(Debug, Clone, PartialEq)]
70#[non_exhaustive]
71pub enum ChainTarget {
72    /// Match any module belonging to this third-party package.
73    Package(String),
74    /// Match a specific module by its graph id.
75    Module(ModuleId),
76}
77
78impl ChainTarget {
79    fn matches(&self, graph: &ModuleGraph, mid: ModuleId) -> bool {
80        match self {
81            Self::Package(name) => graph.module(mid).package.as_deref() == Some(name),
82            Self::Module(target) => mid == *target,
83        }
84    }
85}
86
87/// Whether to follow an edge based on its kind and the `include_dynamic` flag.
88const fn should_follow(kind: EdgeKind, include_dynamic: bool) -> bool {
89    match kind {
90        EdgeKind::Static => true,
91        EdgeKind::Dynamic if include_dynamic => true,
92        _ => false,
93    }
94}
95
96/// Iterative DFS to compute reverse postorder and predecessor lists in one pass.
97fn reverse_postorder_with_preds(
98    graph: &ModuleGraph,
99    entry: ModuleId,
100    include_dynamic: bool,
101) -> (Vec<ModuleId>, Vec<Vec<u32>>) {
102    let n = graph.modules.len();
103    let mut visited = vec![false; n];
104    let mut postorder = Vec::new();
105    let mut preds: Vec<Vec<u32>> = vec![Vec::new(); n];
106    let mut stack: Vec<(ModuleId, bool)> = vec![(entry, false)];
107
108    while let Some((mid, post_visit)) = stack.pop() {
109        let idx = mid.0 as usize;
110        if post_visit {
111            postorder.push(mid);
112            continue;
113        }
114        if visited[idx] {
115            continue;
116        }
117        visited[idx] = true;
118        stack.push((mid, true));
119        for &edge_id in graph.outgoing_edges(mid) {
120            let edge = graph.edge(edge_id);
121            if should_follow(edge.kind, include_dynamic) {
122                let to_idx = edge.to.0 as usize;
123                if visited[to_idx] {
124                    // Back/cross edge to already-visited (reachable) node
125                    preds[to_idx].push(mid.0);
126                } else {
127                    // Tree edge — record predecessor and visit
128                    preds[to_idx].push(mid.0);
129                    stack.push((edge.to, false));
130                }
131            }
132        }
133    }
134
135    postorder.reverse();
136    (postorder, preds)
137}
138
139/// Walk up dominator tree to find common dominator of a and b.
140fn intersect_idom(idom: &[u32], rpo_num: &[u32], mut a: u32, mut b: u32) -> u32 {
141    while a != b {
142        while rpo_num[a as usize] > rpo_num[b as usize] {
143            a = idom[a as usize];
144        }
145        while rpo_num[b as usize] > rpo_num[a as usize] {
146            b = idom[b as usize];
147        }
148    }
149    a
150}
151
152/// Compute exclusive weight for every reachable module using a dominator tree.
153///
154/// Exclusive weight of module M = total size of all modules in M's dominator
155/// subtree (modules that become unreachable if M is removed from the graph).
156/// Uses the Cooper-Harvey-Kennedy iterative dominator algorithm: O(N).
157#[allow(clippy::cast_possible_truncation)]
158fn compute_exclusive_weights(
159    graph: &ModuleGraph,
160    entry: ModuleId,
161    include_dynamic: bool,
162) -> Vec<u64> {
163    let n = graph.modules.len();
164
165    // Step 1+3: DFS for reverse postorder and predecessor lists in one pass
166    let (rpo, preds) = reverse_postorder_with_preds(graph, entry, include_dynamic);
167    if rpo.is_empty() {
168        return vec![0; n];
169    }
170
171    // Step 2: RPO numbering (lower = earlier)
172    let mut rpo_num = vec![u32::MAX; n];
173    for (i, &mid) in rpo.iter().enumerate() {
174        rpo_num[mid.0 as usize] = i as u32;
175    }
176
177    // Step 4: Cooper-Harvey-Kennedy iterative idom computation
178    let entry_idx = entry.0;
179    let mut idom = vec![u32::MAX; n];
180    idom[entry_idx as usize] = entry_idx;
181
182    let mut changed = true;
183    while changed {
184        changed = false;
185        for &mid in rpo.iter().skip(1) {
186            let idx = mid.0 as usize;
187            let mut new_idom: Option<u32> = None;
188            for &p in &preds[idx] {
189                if idom[p as usize] != u32::MAX {
190                    new_idom = Some(new_idom.map_or(p, |current| {
191                        intersect_idom(&idom, &rpo_num, current, p)
192                    }));
193                }
194            }
195            if let Some(ni) = new_idom
196                && idom[idx] != ni
197            {
198                idom[idx] = ni;
199                changed = true;
200            }
201        }
202    }
203
204    // Step 5: Build dominator tree children
205    let mut children: Vec<Vec<u32>> = vec![Vec::new(); n];
206    for &mid in &rpo {
207        let idx = mid.0 as usize;
208        let dom = idom[idx];
209        if dom != u32::MAX && dom != idx as u32 {
210            children[dom as usize].push(idx as u32);
211        }
212    }
213
214    // Step 6: Post-order DFS of dominator tree to accumulate subtree sums
215    let mut weights = vec![0u64; n];
216    let mut stack: Vec<(u32, bool)> = vec![(entry_idx, false)];
217    while let Some((node, post_visit)) = stack.pop() {
218        if post_visit {
219            weights[node as usize] = graph.modules[node as usize].size_bytes;
220            for &child in &children[node as usize] {
221                weights[node as usize] += weights[child as usize];
222            }
223            continue;
224        }
225        stack.push((node, true));
226        for &child in &children[node as usize] {
227            stack.push((child, false));
228        }
229    }
230
231    weights
232}
233
234struct BfsResult {
235    static_set: Vec<ModuleId>,
236    dynamic_set: Vec<ModuleId>,
237    /// BFS parent pointers from the static traversal. parent[i] is the
238    /// predecessor of module i on the shortest static path from entry.
239    /// Entry and unreachable modules have `u32::MAX`.
240    static_parent: Vec<u32>,
241}
242
243/// BFS from entry point, collecting all reachable modules.
244/// Also records parent pointers during the static phase for chain reconstruction.
245fn bfs_reachable(
246    graph: &ModuleGraph,
247    entry: ModuleId,
248) -> BfsResult {
249    let n = graph.modules.len();
250    let mut visited = vec![false; n];
251    let mut parent = vec![u32::MAX; n];
252    let mut static_set: Vec<ModuleId> = Vec::new();
253    let mut queue: VecDeque<ModuleId> = VecDeque::new();
254
255    visited[entry.0 as usize] = true;
256    static_set.push(entry);
257    queue.push_back(entry);
258
259    // BFS following static edges
260    while let Some(mid) = queue.pop_front() {
261        for &edge_id in graph.outgoing_edges(mid) {
262            let edge = graph.edge(edge_id);
263            let idx = edge.to.0 as usize;
264            if edge.kind == EdgeKind::Static && !visited[idx] {
265                visited[idx] = true;
266                parent[idx] = mid.0;
267                static_set.push(edge.to);
268                queue.push_back(edge.to);
269            }
270        }
271    }
272
273    // BFS following dynamic edges from all statically reachable modules
274    let mut dynamic_set: Vec<ModuleId> = Vec::new();
275    let mut dyn_queue: VecDeque<ModuleId> = VecDeque::new();
276
277    for &mid in &static_set {
278        for &edge_id in graph.outgoing_edges(mid) {
279            let edge = graph.edge(edge_id);
280            let idx = edge.to.0 as usize;
281            if edge.kind == EdgeKind::Dynamic && !visited[idx] {
282                visited[idx] = true;
283                dynamic_set.push(edge.to);
284                dyn_queue.push_back(edge.to);
285            }
286        }
287    }
288
289    // Continue BFS from dynamic-only modules (they may have static imports of their own)
290    while let Some(mid) = dyn_queue.pop_front() {
291        for &edge_id in graph.outgoing_edges(mid) {
292            let edge = graph.edge(edge_id);
293            let idx = edge.to.0 as usize;
294            if (edge.kind == EdgeKind::Static || edge.kind == EdgeKind::Dynamic)
295                && !visited[idx]
296            {
297                visited[idx] = true;
298                dynamic_set.push(edge.to);
299                dyn_queue.push_back(edge.to);
300            }
301        }
302    }
303
304    BfsResult { static_set, dynamic_set, static_parent: parent }
305}
306
307/// Reconstruct the shortest chain from entry to target using pre-computed
308/// BFS parent pointers. Returns empty vec if target is unreachable.
309fn reconstruct_chain(parent: &[u32], entry: ModuleId, target: ModuleId) -> Vec<ModuleId> {
310    let mut chain = vec![target];
311    let mut current = target.0;
312    while current != entry.0 {
313        let p = parent[current as usize];
314        if p == u32::MAX {
315            return Vec::new();
316        }
317        chain.push(ModuleId(p));
318        current = p;
319    }
320    chain.reverse();
321    chain
322}
323
324#[must_use]
325#[allow(clippy::cast_sign_loss)]
326pub fn trace(graph: &ModuleGraph, entry: ModuleId, opts: &TraceOptions) -> TraceResult {
327    let bfs = bfs_reachable(graph, entry);
328    let mut reachable = bfs.static_set;
329    let dynamic_only = bfs.dynamic_set;
330
331    // When --include-dynamic is set, fold dynamic modules into the reachable
332    // set. There's nothing "only dynamic" when the user asked to include them.
333    // Compute dynamic-only packages before potentially merging sets
334    let mut dynamic_pkg_sizes: HashMap<String, u64> = HashMap::new();
335    for &mid in &dynamic_only {
336        let module = graph.module(mid);
337        if let Some(ref pkg) = module.package {
338            *dynamic_pkg_sizes.entry(pkg.clone()).or_default() += module.size_bytes;
339        }
340    }
341
342    let (dynamic_only_weight, dynamic_only_module_count) = if opts.include_dynamic {
343        reachable.extend_from_slice(&dynamic_only);
344        (0, 0)
345    } else {
346        let w: u64 = dynamic_only.iter().map(|&mid| graph.module(mid).size_bytes).sum();
347        (w, dynamic_only.len())
348    };
349
350    let static_weight: u64 = reachable
351        .iter()
352        .map(|&mid| graph.module(mid).size_bytes)
353        .sum();
354
355    // Find heavy packages in the reachable set, tracking the first module
356    // encountered per package (BFS order = shortest distance from entry).
357    let mut package_sizes: HashMap<String, (u64, u32)> = HashMap::new();
358    let mut package_nearest: HashMap<String, ModuleId> = HashMap::new();
359    for &mid in &reachable {
360        let module = graph.module(mid);
361        if let Some(ref pkg) = module.package {
362            let e = package_sizes.entry(pkg.clone()).or_default();
363            e.0 += module.size_bytes;
364            e.1 += 1;
365            package_nearest.entry(pkg.clone()).or_insert(mid);
366        }
367    }
368
369    let all_packages: HashMap<String, u64> =
370        package_sizes.iter().map(|(k, (size, _))| (k.clone(), *size)).collect();
371
372    // Sort and truncate BEFORE computing chains (each chain is a full BFS)
373    let mut sorted_packages: Vec<(String, u64, u32)> = package_sizes
374        .into_iter()
375        .map(|(name, (total_size, file_count))| (name, total_size, file_count))
376        .collect();
377    sorted_packages.sort_by(|a, b| b.1.cmp(&a.1));
378    if !opts.ignore.is_empty() {
379        sorted_packages.retain(|(name, _, _)| !opts.ignore.iter().any(|i| i == name));
380    }
381    if opts.top_n >= 0 {
382        sorted_packages.truncate(opts.top_n as usize);
383    }
384    let heavy_packages: Vec<HeavyPackage> = sorted_packages
385        .into_iter()
386        .map(|(name, total_size, file_count)| {
387            let chain = match package_nearest.get(&name) {
388                Some(&nearest) => reconstruct_chain(&bfs.static_parent, entry, nearest),
389                None => Vec::new(),
390            };
391            HeavyPackage {
392                name,
393                total_size,
394                file_count,
395                chain,
396            }
397        })
398        .collect();
399
400    // Compute exclusive weight for all reachable modules via dominator tree
401    let exclusive = compute_exclusive_weights(graph, entry, opts.include_dynamic);
402
403    // Prefer first-party (no package) modules for the per-file breakdown.
404    // Fall back to all modules when no first-party modules exist (e.g. Python
405    // projects where every reachable module is a third-party package).
406    let mut modules_by_cost: Vec<ModuleCost> = reachable
407        .iter()
408        .filter(|&&mid| mid != entry && graph.module(mid).package.is_none())
409        .map(|&mid| ModuleCost {
410            module_id: mid,
411            exclusive_size: exclusive[mid.0 as usize],
412        })
413        .collect();
414    if modules_by_cost.is_empty() {
415        modules_by_cost = reachable
416            .iter()
417            .filter(|&&mid| mid != entry)
418            .map(|&mid| ModuleCost {
419                module_id: mid,
420                exclusive_size: exclusive[mid.0 as usize],
421            })
422            .collect();
423    }
424
425    modules_by_cost.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
426
427    TraceResult {
428        static_weight,
429        static_module_count: reachable.len(),
430        dynamic_only_weight,
431        dynamic_only_module_count,
432        heavy_packages,
433        modules_by_cost,
434        all_packages,
435        dynamic_packages: dynamic_pkg_sizes,
436    }
437}
438
439/// Find ALL shortest chains from entry to a specific target (package or module).
440///
441/// Returns up to `max_chains` distinct shortest paths (all same hop count),
442/// deduplicated at the package-name level so chains that differ only by
443/// internal file paths are collapsed into one.
444#[must_use]
445pub fn find_all_chains(
446    graph: &ModuleGraph,
447    entry: ModuleId,
448    target: &ChainTarget,
449    include_dynamic: bool,
450) -> Vec<Vec<ModuleId>> {
451    let raw = all_shortest_chains(graph, entry, target, 10, include_dynamic);
452    dedup_chains_by_package(graph, raw)
453}
454
455/// Deduplicate chains that look identical at the package-name level.
456/// Two chains that differ only by which internal file within a package
457/// they pass through will have the same package-level key and only the
458/// first is kept.
459fn dedup_chains_by_package(
460    graph: &ModuleGraph,
461    chains: Vec<Vec<ModuleId>>,
462) -> Vec<Vec<ModuleId>> {
463    let mut seen: HashSet<Vec<String>> = HashSet::new();
464    let mut result = Vec::new();
465
466    for chain in chains {
467        let key: Vec<String> = chain
468            .iter()
469            .map(|&mid| {
470                let m = graph.module(mid);
471                m.package.clone().unwrap_or_else(|| m.path.to_string_lossy().into_owned())
472            })
473            .collect();
474
475        if seen.insert(key) {
476            result.push(chain);
477        }
478    }
479
480    result
481}
482
483/// BFS with multi-parent tracking to find all shortest paths to a target.
484fn all_shortest_chains(
485    graph: &ModuleGraph,
486    entry: ModuleId,
487    target: &ChainTarget,
488    max_chains: usize,
489    include_dynamic: bool,
490) -> Vec<Vec<ModuleId>> {
491    let n = graph.modules.len();
492    let mut parents: Vec<Vec<u32>> = vec![Vec::new(); n];
493    let mut depth: Vec<u32> = vec![u32::MAX; n];
494    let mut queue: VecDeque<ModuleId> = VecDeque::new();
495
496    depth[entry.0 as usize] = 0;
497    queue.push_back(entry);
498
499    let mut target_depth: Option<u32> = None;
500    let mut targets: Vec<ModuleId> = Vec::new();
501
502    while let Some(mid) = queue.pop_front() {
503        let d = depth[mid.0 as usize];
504
505        // If we've found targets and moved past their depth, stop
506        if let Some(td) = target_depth
507            && d > td
508        {
509            break;
510        }
511
512        // Check if this module matches the target
513        if target.matches(graph, mid) {
514            if target_depth.is_none() {
515                target_depth = Some(d);
516            }
517            targets.push(mid);
518            continue; // Don't expand past target package modules
519        }
520
521        for &edge_id in graph.outgoing_edges(mid) {
522            let edge = graph.edge(edge_id);
523            match edge.kind {
524                EdgeKind::Static => {}
525                EdgeKind::Dynamic if include_dynamic => {}
526                _ => continue,
527            }
528
529            let next_depth = d + 1;
530            let idx = edge.to.0 as usize;
531            match depth[idx] {
532                d if d == next_depth => {
533                    // Same depth -- add as alternate parent
534                    parents[idx].push(mid.0);
535                }
536                u32::MAX => {
537                    // First visit
538                    depth[idx] = next_depth;
539                    parents[idx].push(mid.0);
540                    queue.push_back(edge.to);
541                }
542                _ => {} // Already visited at shorter depth, skip
543            }
544        }
545    }
546
547    if targets.is_empty() {
548        return Vec::new();
549    }
550
551    // Backtrack from each target to reconstruct all paths
552    let mut all_chains: Vec<Vec<ModuleId>> = Vec::new();
553    for &target_mid in &targets {
554        let mut partial_paths: Vec<Vec<ModuleId>> = vec![vec![target_mid]];
555
556        loop {
557            let mut next_partial: Vec<Vec<ModuleId>> = Vec::new();
558            let mut any_extended = false;
559
560            for path in &partial_paths {
561                let &head = path.last().unwrap();
562                if head == entry {
563                    next_partial.push(path.clone());
564                    continue;
565                }
566                let pars = &parents[head.0 as usize];
567                if !pars.is_empty() {
568                    any_extended = true;
569                    for &p in pars {
570                        let mut new_path = path.clone();
571                        new_path.push(ModuleId(p));
572                        next_partial.push(new_path);
573                        if next_partial.len() > max_chains * 2 {
574                            break; // Prevent combinatorial explosion
575                        }
576                    }
577                }
578            }
579
580            partial_paths = next_partial;
581            if !any_extended || partial_paths.len() > max_chains * 2 {
582                break;
583            }
584        }
585
586        for mut path in partial_paths {
587            path.reverse();
588            if path.first() == Some(&entry) {
589                all_chains.push(path);
590                if all_chains.len() >= max_chains {
591                    return all_chains;
592                }
593            }
594        }
595    }
596
597    all_chains
598}
599
600/// A module whose dynamic conversion would sever one or more import chains.
601#[derive(Debug, Clone, Copy)]
602#[non_exhaustive]
603pub struct CutModule {
604    pub module_id: ModuleId,
605    pub chains_broken: usize,
606    pub exclusive_size: u64,
607}
608
609/// Find modules that appear in all chains from entry to a package.
610///
611/// Removing any one of these severs every import path to the target.
612/// Sorted by exclusive weight descending (highest-impact first),
613/// truncated to `top_n`.
614#[must_use]
615#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
616pub fn find_cut_modules(
617    graph: &ModuleGraph,
618    chains: &[Vec<ModuleId>],
619    entry: ModuleId,
620    target: &ChainTarget,
621    top_n: i32,
622    include_dynamic: bool,
623) -> Vec<CutModule> {
624    if chains.is_empty() {
625        return Vec::new();
626    }
627
628    let exclusive = compute_exclusive_weights(graph, entry, include_dynamic);
629
630    let total = chains.len();
631    let mut frequency = vec![0usize; graph.modules.len()];
632    for chain in chains {
633        for &mid in chain {
634            frequency[mid.0 as usize] += 1;
635        }
636    }
637
638    let mut cuts: Vec<CutModule> = frequency
639        .iter()
640        .enumerate()
641        .filter(|&(idx, &count)| {
642            let mid = ModuleId(idx as u32);
643            count == total
644                && mid != entry
645                && !target.matches(graph, mid)
646        })
647        .map(|(idx, &count)| CutModule {
648            module_id: ModuleId(idx as u32),
649            chains_broken: count,
650            exclusive_size: exclusive[idx],
651        })
652        .collect();
653
654    // Deduplicate at package level -- multiple files within the same
655    // node_modules package are the same cut point from the user's perspective.
656    // Sort descending first so we keep the highest-weight entry per package.
657    cuts.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
658    let mut seen_packages: HashSet<String> = HashSet::new();
659    cuts.retain(|c| {
660        let m = graph.module(c.module_id);
661        m.package.as_ref().is_none_or(|pkg| seen_packages.insert(pkg.clone()))
662    });
663
664    // Single chain: sort ascending (most surgical cut first).
665    // Multiple chains: sort descending (highest-impact convergence point first).
666    if chains.len() == 1 {
667        cuts.sort_by(|a, b| a.exclusive_size.cmp(&b.exclusive_size));
668    }
669
670    if top_n >= 0 {
671        cuts.truncate(top_n as usize);
672    }
673    cuts
674}
675
676/// Minimal snapshot of a trace result for before/after comparison.
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[non_exhaustive]
679pub struct TraceSnapshot {
680    pub entry: String,
681    pub static_weight: u64,
682    pub packages: HashMap<String, u64>,
683    #[serde(default)]
684    pub dynamic_weight: u64,
685    #[serde(default)]
686    pub dynamic_packages: HashMap<String, u64>,
687}
688
689impl TraceResult {
690    pub fn to_snapshot(&self, entry: &str) -> TraceSnapshot {
691        TraceSnapshot {
692            entry: entry.to_string(),
693            static_weight: self.static_weight,
694            packages: self.all_packages.clone(),
695            dynamic_weight: self.dynamic_only_weight,
696            dynamic_packages: self.dynamic_packages.clone(),
697        }
698    }
699}
700
701/// A package that appears in only one side of a diff, with its size.
702#[derive(Debug)]
703#[non_exhaustive]
704pub struct DiffPackage {
705    pub name: String,
706    pub size: u64,
707}
708
709/// Compute a diff between two trace snapshots.
710#[derive(Debug)]
711#[non_exhaustive]
712pub struct DiffResult {
713    pub entry_a_weight: u64,
714    pub entry_b_weight: u64,
715    pub weight_delta: i64,
716    pub dynamic_a_weight: u64,
717    pub dynamic_b_weight: u64,
718    pub dynamic_weight_delta: i64,
719    pub shared_count: usize,
720    pub only_in_a: Vec<DiffPackage>,
721    pub only_in_b: Vec<DiffPackage>,
722    pub dynamic_only_in_a: Vec<DiffPackage>,
723    pub dynamic_only_in_b: Vec<DiffPackage>,
724}
725
726#[must_use]
727#[allow(clippy::cast_possible_wrap)]
728pub fn diff_snapshots(a: &TraceSnapshot, b: &TraceSnapshot) -> DiffResult {
729    let keys_a: HashSet<&str> = a.packages.keys().map(String::as_str).collect();
730    let keys_b: HashSet<&str> = b.packages.keys().map(String::as_str).collect();
731
732    let mut only_in_a: Vec<DiffPackage> = keys_a
733        .difference(&keys_b)
734        .map(|&name| DiffPackage {
735            name: name.to_string(),
736            size: a.packages[name],
737        })
738        .collect();
739    only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
740
741    let mut only_in_b: Vec<DiffPackage> = keys_b
742        .difference(&keys_a)
743        .map(|&name| DiffPackage {
744            name: name.to_string(),
745            size: b.packages[name],
746        })
747        .collect();
748    only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
749
750    let dyn_keys_a: HashSet<&str> = a.dynamic_packages.keys().map(String::as_str).collect();
751    let dyn_keys_b: HashSet<&str> = b.dynamic_packages.keys().map(String::as_str).collect();
752
753    let mut dynamic_only_in_a: Vec<DiffPackage> = dyn_keys_a
754        .difference(&dyn_keys_b)
755        .map(|&name| DiffPackage {
756            name: name.to_string(),
757            size: a.dynamic_packages[name],
758        })
759        .collect();
760    dynamic_only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
761
762    let mut dynamic_only_in_b: Vec<DiffPackage> = dyn_keys_b
763        .difference(&dyn_keys_a)
764        .map(|&name| DiffPackage {
765            name: name.to_string(),
766            size: b.dynamic_packages[name],
767        })
768        .collect();
769    dynamic_only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
770
771    DiffResult {
772        entry_a_weight: a.static_weight,
773        entry_b_weight: b.static_weight,
774        weight_delta: b.static_weight as i64 - a.static_weight as i64,
775        dynamic_a_weight: a.dynamic_weight,
776        dynamic_b_weight: b.dynamic_weight,
777        dynamic_weight_delta: b.dynamic_weight as i64 - a.dynamic_weight as i64,
778        shared_count: keys_a.intersection(&keys_b).count(),
779        only_in_a,
780        only_in_b,
781        dynamic_only_in_a,
782        dynamic_only_in_b,
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::graph::ModuleGraph;
790    use std::path::PathBuf;
791
792    /// Build a small graph from declarative specs.
793    /// `nodes`: `(path, size_bytes, package_name)`
794    /// `edges`: `(from_index, to_index, kind)`
795    fn make_graph(
796        nodes: &[(&str, u64, Option<&str>)],
797        edges: &[(usize, usize, EdgeKind)],
798    ) -> ModuleGraph {
799        let mut graph = ModuleGraph::new();
800        for &(path, size, pkg) in nodes {
801            graph.add_module(
802                PathBuf::from(path),
803                size,
804                pkg.map(str::to_string),
805            );
806        }
807        for &(from, to, kind) in edges {
808            #[allow(clippy::cast_possible_truncation)]
809            graph.add_edge(
810                ModuleId(from as u32),
811                ModuleId(to as u32),
812                kind,
813                "",
814            );
815        }
816        graph
817    }
818
819    // --- BFS / trace ---
820
821    #[test]
822    fn trace_static_weight() {
823        // A(100) -> B(200) -> C(300)
824        let graph = make_graph(
825            &[("a.ts", 100, None), ("b.ts", 200, None), ("c.ts", 300, None)],
826            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
827        );
828        let result = trace(&graph, ModuleId(0), &TraceOptions::default());
829        assert_eq!(result.static_weight, 600);
830        assert_eq!(result.static_module_count, 3);
831    }
832
833    #[test]
834    fn trace_dynamic_excluded_by_default() {
835        // A(100) -static-> B(200), A -dynamic-> C(300)
836        let graph = make_graph(
837            &[("a.ts", 100, None), ("b.ts", 200, None), ("c.ts", 300, None)],
838            &[(0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic)],
839        );
840        let result = trace(&graph, ModuleId(0), &TraceOptions::default());
841        assert_eq!(result.static_weight, 300); // A + B only
842        assert_eq!(result.dynamic_only_weight, 300); // C
843        assert_eq!(result.dynamic_only_module_count, 1);
844    }
845
846    #[test]
847    fn trace_include_dynamic() {
848        // A(100) -dynamic-> B(200)
849        let graph = make_graph(
850            &[("a.ts", 100, None), ("b.ts", 200, None)],
851            &[(0, 1, EdgeKind::Dynamic)],
852        );
853        let opts = TraceOptions {
854            include_dynamic: true,
855            top_n: 10,
856            ignore: Vec::new(),
857        };
858        let result = trace(&graph, ModuleId(0), &opts);
859        // B should appear in modules_by_cost when include_dynamic is set
860        assert!(result.modules_by_cost.iter().any(|m| m.module_id == ModuleId(1)));
861    }
862
863    // --- Chain finding ---
864
865    #[test]
866    fn chain_linear_path() {
867        // A -> B -> C -> zod
868        let graph = make_graph(
869            &[
870                ("a.ts", 100, None),
871                ("b.ts", 100, None),
872                ("c.ts", 100, None),
873                ("node_modules/zod/index.js", 500, Some("zod")),
874            ],
875            &[
876                (0, 1, EdgeKind::Static),
877                (1, 2, EdgeKind::Static),
878                (2, 3, EdgeKind::Static),
879            ],
880        );
881        let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
882        assert_eq!(chains.len(), 1);
883        assert_eq!(chains[0], vec![ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)]);
884    }
885
886    #[test]
887    fn chain_dedup_same_package_path() {
888        // Two paths to zod that differ only by internal zod file:
889        // A -> B -> zod/index.js
890        // A -> B -> zod/lib.js
891        // These should dedup to one chain at the package level.
892        let graph = make_graph(
893            &[
894                ("a.ts", 100, None),
895                ("b.ts", 100, None),
896                ("node_modules/zod/index.js", 250, Some("zod")),
897                ("node_modules/zod/lib.js", 250, Some("zod")),
898            ],
899            &[
900                (0, 1, EdgeKind::Static),
901                (1, 2, EdgeKind::Static),
902                (1, 3, EdgeKind::Static),
903            ],
904        );
905        let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
906        assert_eq!(chains.len(), 1);
907    }
908
909    #[test]
910    fn chain_not_reachable() {
911        // A -> B, no path to zod
912        let graph = make_graph(
913            &[
914                ("a.ts", 100, None),
915                ("b.ts", 100, None),
916                ("node_modules/zod/index.js", 500, Some("zod")),
917            ],
918            &[(0, 1, EdgeKind::Static)],
919        );
920        let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
921        assert!(chains.is_empty());
922    }
923
924    #[test]
925    fn chain_through_dynamic_edge() {
926        // A -dynamic-> B -static-> zod
927        // Without include_dynamic: no chain. With: chain found.
928        let graph = make_graph(
929            &[
930                ("a.ts", 100, None),
931                ("b.ts", 100, None),
932                ("node_modules/zod/index.js", 500, Some("zod")),
933            ],
934            &[
935                (0, 1, EdgeKind::Dynamic),
936                (1, 2, EdgeKind::Static),
937            ],
938        );
939        let chains_static = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
940        assert!(chains_static.is_empty());
941
942        let chains_dynamic = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), true);
943        assert_eq!(chains_dynamic.len(), 1);
944        assert_eq!(chains_dynamic[0], vec![ModuleId(0), ModuleId(1), ModuleId(2)]);
945    }
946
947    // --- Cut points ---
948
949    #[test]
950    fn cut_single_convergence_point() {
951        // Diamond: A -> B -> D -> zod
952        //          A -> C -> D -> zod
953        // D is the cut point (appears in all chains)
954        let graph = make_graph(
955            &[
956                ("a.ts", 100, None),
957                ("b.ts", 100, None),
958                ("c.ts", 100, None),
959                ("d.ts", 100, None),
960                ("node_modules/zod/index.js", 500, Some("zod")),
961            ],
962            &[
963                (0, 1, EdgeKind::Static),
964                (0, 2, EdgeKind::Static),
965                (1, 3, EdgeKind::Static),
966                (2, 3, EdgeKind::Static),
967                (3, 4, EdgeKind::Static),
968            ],
969        );
970        let target = ChainTarget::Package("zod".to_string());
971        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
972        assert_eq!(chains.len(), 2);
973
974        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
975        assert!(!cuts.is_empty());
976        assert!(cuts.iter().any(|c| c.module_id == ModuleId(3)));
977    }
978
979    #[test]
980    fn cut_no_convergence_point() {
981        // Two independent paths:
982        // A -> B -> zod1
983        // A -> C -> zod2
984        // No module (other than A and zod) appears in all chains.
985        let graph = make_graph(
986            &[
987                ("a.ts", 100, None),
988                ("b.ts", 100, None),
989                ("c.ts", 100, None),
990                ("node_modules/zod/index.js", 250, Some("zod")),
991                ("node_modules/zod/lib.js", 250, Some("zod")),
992            ],
993            &[
994                (0, 1, EdgeKind::Static),
995                (0, 2, EdgeKind::Static),
996                (1, 3, EdgeKind::Static),
997                (2, 4, EdgeKind::Static),
998            ],
999        );
1000        let target = ChainTarget::Package("zod".to_string());
1001        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1002        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1003        assert!(cuts.is_empty());
1004    }
1005
1006    #[test]
1007    fn cut_direct_import_no_intermediate() {
1008        // Entry directly imports target: A -> zod (1 hop)
1009        // No intermediate module exists to cut.
1010        let graph = make_graph(
1011            &[
1012                ("a.ts", 100, None),
1013                ("node_modules/zod/index.js", 500, Some("zod")),
1014            ],
1015            &[(0, 1, EdgeKind::Static)],
1016        );
1017        let target = ChainTarget::Package("zod".to_string());
1018        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1019        assert_eq!(chains.len(), 1);
1020        assert_eq!(chains[0].len(), 2); // 1 hop = 2 nodes
1021
1022        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1023        assert!(cuts.is_empty());
1024    }
1025
1026    #[test]
1027    fn cut_single_chain_ascending_sort() {
1028        // Single chain: A -> B(big) -> C(small) -> zod
1029        // With single chain, cuts should sort ascending (surgical first).
1030        let graph = make_graph(
1031            &[
1032                ("a.ts", 100, None),
1033                ("b.ts", 5000, None),
1034                ("c.ts", 100, None),
1035                ("node_modules/zod/index.js", 500, Some("zod")),
1036            ],
1037            &[
1038                (0, 1, EdgeKind::Static),
1039                (1, 2, EdgeKind::Static),
1040                (2, 3, EdgeKind::Static),
1041            ],
1042        );
1043        let target = ChainTarget::Package("zod".to_string());
1044        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1045        assert_eq!(chains.len(), 1);
1046
1047        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1048        assert!(cuts.len() >= 2);
1049        // First cut should have smaller exclusive_size (more surgical)
1050        assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size);
1051    }
1052
1053    // --- Diff ---
1054
1055    fn snap(entry: &str, static_weight: u64, packages: &[(&str, u64)]) -> TraceSnapshot {
1056        TraceSnapshot {
1057            entry: entry.to_string(),
1058            static_weight,
1059            packages: packages.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1060            dynamic_weight: 0,
1061            dynamic_packages: HashMap::new(),
1062        }
1063    }
1064
1065    fn snap_with_dynamic(
1066        entry: &str,
1067        static_weight: u64,
1068        packages: &[(&str, u64)],
1069        dynamic_weight: u64,
1070        dynamic_packages: &[(&str, u64)],
1071    ) -> TraceSnapshot {
1072        TraceSnapshot {
1073            entry: entry.to_string(),
1074            static_weight,
1075            packages: packages.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1076            dynamic_weight,
1077            dynamic_packages: dynamic_packages.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1078        }
1079    }
1080
1081    #[test]
1082    fn diff_snapshots_computes_sets() {
1083        let a = snap("a.ts", 1000, &[("zod", 500), ("chalk", 200), ("tslog", 300)]);
1084        let b = snap("b.ts", 800, &[("chalk", 200), ("tslog", 300), ("ajv", 100)]);
1085        let diff = diff_snapshots(&a, &b);
1086
1087        assert_eq!(diff.entry_a_weight, 1000);
1088        assert_eq!(diff.entry_b_weight, 800);
1089        assert_eq!(diff.weight_delta, -200);
1090        assert_eq!(diff.only_in_a.len(), 1);
1091        assert_eq!(diff.only_in_a[0].name, "zod");
1092        assert_eq!(diff.only_in_a[0].size, 500);
1093        assert_eq!(diff.only_in_b.len(), 1);
1094        assert_eq!(diff.only_in_b[0].name, "ajv");
1095        assert_eq!(diff.only_in_b[0].size, 100);
1096        assert_eq!(diff.shared_count, 2);
1097    }
1098
1099    #[test]
1100    fn diff_snapshots_sorted_by_size_descending() {
1101        let a = snap("a.ts", 1000, &[("small", 10), ("big", 500), ("medium", 100)]);
1102        let b = snap("b.ts", 0, &[]);
1103        let diff = diff_snapshots(&a, &b);
1104
1105        assert_eq!(diff.only_in_a.len(), 3);
1106        assert_eq!(diff.only_in_a[0].name, "big");
1107        assert_eq!(diff.only_in_a[1].name, "medium");
1108        assert_eq!(diff.only_in_a[2].name, "small");
1109    }
1110
1111    #[test]
1112    fn diff_snapshots_dynamic_packages() {
1113        let a = snap_with_dynamic("a.ts", 1000, &[("zod", 500)], 200, &[("lodash", 200)]);
1114        let b = snap_with_dynamic("b.ts", 800, &[("zod", 500)], 350, &[("moment", 350)]);
1115        let diff = diff_snapshots(&a, &b);
1116
1117        assert_eq!(diff.dynamic_a_weight, 200);
1118        assert_eq!(diff.dynamic_b_weight, 350);
1119        assert_eq!(diff.dynamic_weight_delta, 150);
1120        assert_eq!(diff.dynamic_only_in_a.len(), 1);
1121        assert_eq!(diff.dynamic_only_in_a[0].name, "lodash");
1122        assert_eq!(diff.dynamic_only_in_b.len(), 1);
1123        assert_eq!(diff.dynamic_only_in_b[0].name, "moment");
1124    }
1125
1126    // --- Exclusive weight ---
1127
1128    #[test]
1129    fn exclusive_weight_linear_chain() {
1130        // Entry(100) -> A(200) -> B(300)
1131        // No sharing: exclusive == full subtree
1132        let graph = make_graph(
1133            &[("entry.ts", 100, None), ("a.ts", 200, None), ("b.ts", 300, None)],
1134            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1135        );
1136        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1137        assert_eq!(weights[0], 600); // entry: entire graph
1138        assert_eq!(weights[1], 500); // a: a + b
1139        assert_eq!(weights[2], 300); // b: just b
1140    }
1141
1142    #[test]
1143    fn exclusive_weight_diamond_shared() {
1144        // Entry(100) -> A(200) -> D(500)
1145        // Entry(100) -> B(300) -> D(500)
1146        // D is shared: not in A's or B's exclusive subtree
1147        let graph = make_graph(
1148            &[
1149                ("entry.ts", 100, None), ("a.ts", 200, None),
1150                ("b.ts", 300, None), ("d.ts", 500, None),
1151            ],
1152            &[
1153                (0, 1, EdgeKind::Static), (0, 2, EdgeKind::Static),
1154                (1, 3, EdgeKind::Static), (2, 3, EdgeKind::Static),
1155            ],
1156        );
1157        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1158        assert_eq!(weights[0], 1100); // entry: everything
1159        assert_eq!(weights[1], 200);  // a: only itself (D shared)
1160        assert_eq!(weights[2], 300);  // b: only itself (D shared)
1161        assert_eq!(weights[3], 500);  // d: only itself
1162    }
1163
1164    #[test]
1165    fn exclusive_weight_mixed_exclusive_and_shared() {
1166        // Entry(100) -> A(200) -> D(500)
1167        // Entry(100) -> B(300) -> D(500)
1168        // A(200) -> E(600)  -- E only reachable through A
1169        let graph = make_graph(
1170            &[
1171                ("entry.ts", 100, None), ("a.ts", 200, None),
1172                ("b.ts", 300, None), ("d.ts", 500, None),
1173                ("e.ts", 600, None),
1174            ],
1175            &[
1176                (0, 1, EdgeKind::Static), (0, 2, EdgeKind::Static),
1177                (1, 3, EdgeKind::Static), (2, 3, EdgeKind::Static),
1178                (1, 4, EdgeKind::Static),
1179            ],
1180        );
1181        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1182        assert_eq!(weights[0], 1700); // entry: everything
1183        assert_eq!(weights[1], 800);  // a: a(200) + e(600), not d
1184        assert_eq!(weights[2], 300);  // b: only itself
1185        assert_eq!(weights[3], 500);  // d: only itself (shared)
1186        assert_eq!(weights[4], 600);  // e: only itself
1187    }
1188
1189    #[test]
1190    fn exclusive_weight_with_dynamic_edges() {
1191        // Entry(100) -static-> A(200) -static-> C(400)
1192        // Entry(100) -dynamic-> B(300) -static-> C(400)
1193        // Static only: C exclusively through A
1194        // With dynamic: C shared between A and B
1195        let graph = make_graph(
1196            &[
1197                ("entry.ts", 100, None), ("a.ts", 200, None),
1198                ("b.ts", 300, None), ("c.ts", 400, None),
1199            ],
1200            &[
1201                (0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic),
1202                (1, 3, EdgeKind::Static), (2, 3, EdgeKind::Static),
1203            ],
1204        );
1205        // Static only: B unreachable, C exclusively through A
1206        let static_weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1207        assert_eq!(static_weights[1], 600); // a: a(200) + c(400)
1208
1209        // With dynamic: C shared between A and B
1210        let all_weights = compute_exclusive_weights(&graph, ModuleId(0), true);
1211        assert_eq!(all_weights[1], 200); // a: only itself (c shared with b)
1212        assert_eq!(all_weights[2], 300); // b: only itself (c shared with a)
1213    }
1214
1215    // --- Ignore filter ---
1216
1217    #[test]
1218    fn trace_ignore_filters_heavy_packages() {
1219        let graph = make_graph(
1220            &[
1221                ("entry.ts", 100, None),
1222                ("a.ts", 50, Some("pkg-a")),
1223                ("b.ts", 200, Some("pkg-b")),
1224                ("c.ts", 300, Some("pkg-c")),
1225            ],
1226            &[
1227                (0, 1, EdgeKind::Static),
1228                (0, 2, EdgeKind::Static),
1229                (0, 3, EdgeKind::Static),
1230            ],
1231        );
1232        let opts = TraceOptions {
1233            include_dynamic: false,
1234            top_n: 10,
1235            ignore: vec!["pkg-c".to_string()],
1236        };
1237        let result = trace(&graph, ModuleId(0), &opts);
1238        let names: Vec<&str> = result.heavy_packages.iter().map(|p| p.name.as_str()).collect();
1239        assert!(names.contains(&"pkg-a"));
1240        assert!(names.contains(&"pkg-b"));
1241        assert!(!names.contains(&"pkg-c"));
1242    }
1243
1244    #[test]
1245    fn trace_ignore_does_not_affect_total_weight() {
1246        let graph = make_graph(
1247            &[
1248                ("entry.ts", 100, None),
1249                ("a.ts", 500, Some("big-pkg")),
1250            ],
1251            &[
1252                (0, 1, EdgeKind::Static),
1253            ],
1254        );
1255        let opts = TraceOptions {
1256            include_dynamic: false,
1257            top_n: 10,
1258            ignore: vec!["big-pkg".to_string()],
1259        };
1260        let result = trace(&graph, ModuleId(0), &opts);
1261        assert!(result.heavy_packages.is_empty());
1262        assert_eq!(result.static_weight, 600);
1263    }
1264
1265    // --- ChainTarget::Module ---
1266
1267    #[test]
1268    fn chain_to_module_by_id() {
1269        let graph = make_graph(
1270            &[
1271                ("entry.ts", 100, None),
1272                ("a.ts", 50, None),
1273                ("b.ts", 200, None),
1274            ],
1275            &[
1276                (0, 1, EdgeKind::Static),
1277                (1, 2, EdgeKind::Static),
1278            ],
1279        );
1280        let target_id = graph.path_to_id[&PathBuf::from("b.ts")];
1281        let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Module(target_id), false);
1282        assert_eq!(chains.len(), 1);
1283        assert_eq!(chains[0].len(), 3);
1284        assert_eq!(*chains[0].last().unwrap(), target_id);
1285    }
1286
1287    #[test]
1288    fn cut_to_module_by_id() {
1289        let graph = make_graph(
1290            &[
1291                ("entry.ts", 100, None),
1292                ("bridge.ts", 50, None),
1293                ("target.ts", 200, None),
1294            ],
1295            &[
1296                (0, 1, EdgeKind::Static),
1297                (1, 2, EdgeKind::Static),
1298            ],
1299        );
1300        let target_id = graph.path_to_id[&PathBuf::from("target.ts")];
1301        let target = ChainTarget::Module(target_id);
1302        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1303        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1304        assert_eq!(cuts.len(), 1);
1305        assert_eq!(cuts[0].module_id, graph.path_to_id[&PathBuf::from("bridge.ts")]);
1306    }
1307
1308    // --- top_n negative/zero ---
1309
1310    #[test]
1311    fn trace_top_n_negative_shows_all() {
1312        let graph = make_graph(
1313            &[
1314                ("entry.ts", 10, None),
1315                ("a.ts", 10, Some("pkg-a")),
1316                ("b.ts", 10, Some("pkg-b")),
1317                ("c.ts", 10, Some("pkg-c")),
1318            ],
1319            &[
1320                (0, 1, EdgeKind::Static),
1321                (0, 2, EdgeKind::Static),
1322                (0, 3, EdgeKind::Static),
1323            ],
1324        );
1325        let opts = TraceOptions { top_n: -1, ..Default::default() };
1326        let result = trace(&graph, ModuleId(0), &opts);
1327        assert_eq!(result.heavy_packages.len(), 3);
1328    }
1329
1330    #[test]
1331    fn trace_top_n_zero_shows_none() {
1332        let graph = make_graph(
1333            &[
1334                ("entry.ts", 10, None),
1335                ("a.ts", 10, Some("pkg-a")),
1336            ],
1337            &[
1338                (0, 1, EdgeKind::Static),
1339            ],
1340        );
1341        let opts = TraceOptions { top_n: 0, ..Default::default() };
1342        let result = trace(&graph, ModuleId(0), &opts);
1343        assert_eq!(result.heavy_packages.len(), 0);
1344    }
1345}