1use std::collections::{HashMap, HashSet, VecDeque};
4
5use serde::{Deserialize, Serialize};
6
7use crate::graph::{EdgeKind, ModuleGraph, ModuleId};
8
9#[derive(Debug)]
11#[non_exhaustive]
12pub struct TraceResult {
13 pub static_weight: u64,
15 pub static_module_count: usize,
17 pub dynamic_only_weight: u64,
19 pub dynamic_only_module_count: usize,
21 pub heavy_packages: Vec<HeavyPackage>,
23 pub modules_by_cost: Vec<ModuleCost>,
25 pub all_packages: HashMap<String, u64>,
27 pub dynamic_packages: HashMap<String, u64>,
29}
30
31#[derive(Debug)]
33#[non_exhaustive]
34pub struct HeavyPackage {
35 pub name: String,
36 pub total_size: u64,
37 pub file_count: u32,
38 pub chain: Vec<ModuleId>,
40}
41
42#[derive(Debug, Clone, Copy)]
44#[non_exhaustive]
45pub struct ModuleCost {
46 pub module_id: ModuleId,
47 pub exclusive_size: u64,
48}
49
50#[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#[derive(Debug, Clone, PartialEq)]
70#[non_exhaustive]
71pub enum ChainTarget {
72 Package(String),
74 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
87const 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
96fn 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 preds[to_idx].push(mid.0);
126 } else {
127 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
139fn 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#[allow(clippy::cast_possible_truncation)]
158pub(crate) fn compute_exclusive_weights(
159 graph: &ModuleGraph,
160 entry: ModuleId,
161 include_dynamic: bool,
162) -> Vec<u64> {
163 let n = graph.modules.len();
164
165 let (rpo, preds) = reverse_postorder_with_preds(graph, entry, include_dynamic);
167 if rpo.is_empty() {
168 return vec![0; n];
169 }
170
171 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 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(
191 new_idom.map_or(p, |current| 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 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 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 static_parent: Vec<u32>,
241}
242
243fn bfs_reachable(graph: &ModuleGraph, entry: ModuleId) -> BfsResult {
246 let n = graph.modules.len();
247 let mut visited = vec![false; n];
248 let mut parent = vec![u32::MAX; n];
249 let mut static_set: Vec<ModuleId> = Vec::new();
250 let mut queue: VecDeque<ModuleId> = VecDeque::new();
251
252 visited[entry.0 as usize] = true;
253 static_set.push(entry);
254 queue.push_back(entry);
255
256 while let Some(mid) = queue.pop_front() {
258 for &edge_id in graph.outgoing_edges(mid) {
259 let edge = graph.edge(edge_id);
260 let idx = edge.to.0 as usize;
261 if edge.kind == EdgeKind::Static && !visited[idx] {
262 visited[idx] = true;
263 parent[idx] = mid.0;
264 static_set.push(edge.to);
265 queue.push_back(edge.to);
266 }
267 }
268 }
269
270 let mut dynamic_set: Vec<ModuleId> = Vec::new();
272 let mut dyn_queue: VecDeque<ModuleId> = VecDeque::new();
273
274 for &mid in &static_set {
275 for &edge_id in graph.outgoing_edges(mid) {
276 let edge = graph.edge(edge_id);
277 let idx = edge.to.0 as usize;
278 if edge.kind == EdgeKind::Dynamic && !visited[idx] {
279 visited[idx] = true;
280 dynamic_set.push(edge.to);
281 dyn_queue.push_back(edge.to);
282 }
283 }
284 }
285
286 while let Some(mid) = dyn_queue.pop_front() {
288 for &edge_id in graph.outgoing_edges(mid) {
289 let edge = graph.edge(edge_id);
290 let idx = edge.to.0 as usize;
291 if (edge.kind == EdgeKind::Static || edge.kind == EdgeKind::Dynamic) && !visited[idx] {
292 visited[idx] = true;
293 dynamic_set.push(edge.to);
294 dyn_queue.push_back(edge.to);
295 }
296 }
297 }
298
299 BfsResult {
300 static_set,
301 dynamic_set,
302 static_parent: parent,
303 }
304}
305
306fn reconstruct_chain(parent: &[u32], entry: ModuleId, target: ModuleId) -> Vec<ModuleId> {
309 let mut chain = vec![target];
310 let mut current = target.0;
311 while current != entry.0 {
312 let p = parent[current as usize];
313 if p == u32::MAX {
314 return Vec::new();
315 }
316 chain.push(ModuleId(p));
317 current = p;
318 }
319 chain.reverse();
320 chain
321}
322
323#[must_use]
324#[allow(clippy::cast_sign_loss)]
325pub fn trace(graph: &ModuleGraph, entry: ModuleId, opts: &TraceOptions) -> TraceResult {
326 let bfs = bfs_reachable(graph, entry);
327 let mut reachable = bfs.static_set;
328 let dynamic_only = bfs.dynamic_set;
329
330 let mut dynamic_pkg_sizes: HashMap<String, u64> = HashMap::new();
334 for &mid in &dynamic_only {
335 let module = graph.module(mid);
336 if let Some(ref pkg) = module.package {
337 *dynamic_pkg_sizes.entry(pkg.clone()).or_default() += module.size_bytes;
338 }
339 }
340
341 let (dynamic_only_weight, dynamic_only_module_count) = if opts.include_dynamic {
342 reachable.extend_from_slice(&dynamic_only);
343 (0, 0)
344 } else {
345 let w: u64 = dynamic_only
346 .iter()
347 .map(|&mid| graph.module(mid).size_bytes)
348 .sum();
349 (w, dynamic_only.len())
350 };
351
352 let static_weight: u64 = reachable
353 .iter()
354 .map(|&mid| graph.module(mid).size_bytes)
355 .sum();
356
357 let mut package_sizes: HashMap<String, (u64, u32)> = HashMap::new();
360 let mut package_nearest: HashMap<String, ModuleId> = HashMap::new();
361 for &mid in &reachable {
362 let module = graph.module(mid);
363 if let Some(ref pkg) = module.package {
364 let e = package_sizes.entry(pkg.clone()).or_default();
365 e.0 += module.size_bytes;
366 e.1 += 1;
367 package_nearest.entry(pkg.clone()).or_insert(mid);
368 }
369 }
370
371 let all_packages: HashMap<String, u64> = package_sizes
372 .iter()
373 .map(|(k, (size, _))| (k.clone(), *size))
374 .collect();
375
376 let mut sorted_packages: Vec<(String, u64, u32)> = package_sizes
378 .into_iter()
379 .map(|(name, (total_size, file_count))| (name, total_size, file_count))
380 .collect();
381 sorted_packages.sort_by(|a, b| b.1.cmp(&a.1));
382 if !opts.ignore.is_empty() {
383 sorted_packages.retain(|(name, _, _)| !opts.ignore.iter().any(|i| i == name));
384 }
385 if opts.top_n >= 0 {
386 sorted_packages.truncate(opts.top_n as usize);
387 }
388 let heavy_packages: Vec<HeavyPackage> = sorted_packages
389 .into_iter()
390 .map(|(name, total_size, file_count)| {
391 let chain = match package_nearest.get(&name) {
392 Some(&nearest) => reconstruct_chain(&bfs.static_parent, entry, nearest),
393 None => Vec::new(),
394 };
395 HeavyPackage {
396 name,
397 total_size,
398 file_count,
399 chain,
400 }
401 })
402 .collect();
403
404 let exclusive = compute_exclusive_weights(graph, entry, opts.include_dynamic);
406
407 let mut modules_by_cost: Vec<ModuleCost> = reachable
411 .iter()
412 .filter(|&&mid| mid != entry && graph.module(mid).package.is_none())
413 .map(|&mid| ModuleCost {
414 module_id: mid,
415 exclusive_size: exclusive[mid.0 as usize],
416 })
417 .collect();
418 if modules_by_cost.is_empty() {
419 modules_by_cost = reachable
420 .iter()
421 .filter(|&&mid| mid != entry)
422 .map(|&mid| ModuleCost {
423 module_id: mid,
424 exclusive_size: exclusive[mid.0 as usize],
425 })
426 .collect();
427 }
428
429 modules_by_cost.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
430
431 TraceResult {
432 static_weight,
433 static_module_count: reachable.len(),
434 dynamic_only_weight,
435 dynamic_only_module_count,
436 heavy_packages,
437 modules_by_cost,
438 all_packages,
439 dynamic_packages: dynamic_pkg_sizes,
440 }
441}
442
443#[must_use]
449pub fn find_all_chains(
450 graph: &ModuleGraph,
451 entry: ModuleId,
452 target: &ChainTarget,
453 include_dynamic: bool,
454) -> Vec<Vec<ModuleId>> {
455 let raw = all_shortest_chains(graph, entry, target, 10, include_dynamic);
456 dedup_chains_by_package(graph, raw)
457}
458
459fn dedup_chains_by_package(graph: &ModuleGraph, chains: Vec<Vec<ModuleId>>) -> Vec<Vec<ModuleId>> {
464 let mut seen: HashSet<Vec<String>> = HashSet::new();
465 let mut result = Vec::new();
466
467 for chain in chains {
468 let key: Vec<String> = chain
469 .iter()
470 .map(|&mid| {
471 let m = graph.module(mid);
472 m.package
473 .clone()
474 .unwrap_or_else(|| m.path.to_string_lossy().into_owned())
475 })
476 .collect();
477
478 if seen.insert(key) {
479 result.push(chain);
480 }
481 }
482
483 result
484}
485
486#[allow(clippy::too_many_lines)]
488fn all_shortest_chains(
489 graph: &ModuleGraph,
490 entry: ModuleId,
491 target: &ChainTarget,
492 max_chains: usize,
493 include_dynamic: bool,
494) -> Vec<Vec<ModuleId>> {
495 let n = graph.modules.len();
496 let mut parents: Vec<Vec<u32>> = vec![Vec::new(); n];
497 let mut depth: Vec<u32> = vec![u32::MAX; n];
498 let mut queue: VecDeque<ModuleId> = VecDeque::new();
499
500 depth[entry.0 as usize] = 0;
501 queue.push_back(entry);
502
503 let mut target_depth: Option<u32> = None;
504 let mut targets: Vec<ModuleId> = Vec::new();
505
506 while let Some(mid) = queue.pop_front() {
507 let d = depth[mid.0 as usize];
508
509 if let Some(td) = target_depth
511 && d > td
512 {
513 break;
514 }
515
516 if target.matches(graph, mid) {
518 if target_depth.is_none() {
519 target_depth = Some(d);
520 }
521 targets.push(mid);
522 continue; }
524
525 for &edge_id in graph.outgoing_edges(mid) {
526 let edge = graph.edge(edge_id);
527 match edge.kind {
528 EdgeKind::Static => {}
529 EdgeKind::Dynamic if include_dynamic => {}
530 _ => continue,
531 }
532
533 let next_depth = d + 1;
534 let idx = edge.to.0 as usize;
535 match depth[idx] {
536 d if d == next_depth => {
537 parents[idx].push(mid.0);
539 }
540 u32::MAX => {
541 depth[idx] = next_depth;
543 parents[idx].push(mid.0);
544 queue.push_back(edge.to);
545 }
546 _ => {} }
548 }
549 }
550
551 if targets.is_empty() {
552 return Vec::new();
553 }
554
555 let limit = max_chains * 2;
561 let mut all_chains: Vec<Vec<ModuleId>> = Vec::new();
562 for &target_mid in &targets {
563 let mut partial_paths: Vec<Vec<ModuleId>> = vec![vec![target_mid]];
564
565 loop {
566 let mut next_partial: Vec<Vec<ModuleId>> = Vec::new();
567 let mut any_extended = false;
568 let mut capped = false;
569
570 for path in &partial_paths {
571 let &head = path.last().expect("partial paths are non-empty");
572 if head == entry {
573 next_partial.push(path.clone());
574 continue;
575 }
576 if capped {
577 let pars = &parents[head.0 as usize];
580 if let Some(&p) = pars.first() {
581 any_extended = true;
582 let mut new_path = path.clone();
583 new_path.push(ModuleId(p));
584 next_partial.push(new_path);
585 }
586 continue;
587 }
588 let pars = &parents[head.0 as usize];
589 if !pars.is_empty() {
590 any_extended = true;
591 for &p in pars {
592 let mut new_path = path.clone();
593 new_path.push(ModuleId(p));
594 next_partial.push(new_path);
595 if next_partial.len() > limit {
596 capped = true;
597 break;
598 }
599 }
600 }
601 }
602
603 partial_paths = next_partial;
604 if !any_extended {
605 break;
606 }
607 }
608
609 for mut path in partial_paths {
610 path.reverse();
611 if path.first() == Some(&entry) {
612 all_chains.push(path);
613 if all_chains.len() >= max_chains {
614 return all_chains;
615 }
616 }
617 }
618 }
619
620 all_chains
621}
622
623#[derive(Debug, Clone, Copy)]
625#[non_exhaustive]
626pub struct CutModule {
627 pub module_id: ModuleId,
628 pub chains_broken: usize,
629 pub exclusive_size: u64,
630}
631
632#[must_use]
638#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
639pub fn find_cut_modules(
640 graph: &ModuleGraph,
641 chains: &[Vec<ModuleId>],
642 entry: ModuleId,
643 target: &ChainTarget,
644 top_n: i32,
645 exclusive_weights: &[u64],
646) -> Vec<CutModule> {
647 if chains.is_empty() {
648 return Vec::new();
649 }
650
651 let total = chains.len();
652 let mut frequency = vec![0usize; graph.modules.len()];
653 for chain in chains {
654 for &mid in chain {
655 frequency[mid.0 as usize] += 1;
656 }
657 }
658
659 let mut cuts: Vec<CutModule> = frequency
660 .iter()
661 .enumerate()
662 .filter(|&(idx, &count)| {
663 let mid = ModuleId(idx as u32);
664 count == total && mid != entry && !target.matches(graph, mid)
665 })
666 .map(|(idx, &count)| CutModule {
667 module_id: ModuleId(idx as u32),
668 chains_broken: count,
669 exclusive_size: exclusive_weights[idx],
670 })
671 .collect();
672
673 cuts.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
677 let mut seen_packages: HashSet<String> = HashSet::new();
678 cuts.retain(|c| {
679 let m = graph.module(c.module_id);
680 m.package
681 .as_ref()
682 .is_none_or(|pkg| seen_packages.insert(pkg.clone()))
683 });
684
685 if chains.len() == 1 {
688 cuts.sort_by(|a, b| a.exclusive_size.cmp(&b.exclusive_size));
689 }
690
691 if top_n >= 0 {
692 cuts.truncate(top_n as usize);
693 }
694 cuts
695}
696
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
699#[non_exhaustive]
700pub struct TraceSnapshot {
701 pub entry: String,
702 pub static_weight: u64,
703 pub packages: HashMap<String, u64>,
704 #[serde(default)]
705 pub dynamic_weight: u64,
706 #[serde(default)]
707 pub dynamic_packages: HashMap<String, u64>,
708}
709
710impl TraceResult {
711 pub fn to_snapshot(&self, entry: &str) -> TraceSnapshot {
712 TraceSnapshot {
713 entry: entry.to_string(),
714 static_weight: self.static_weight,
715 packages: self.all_packages.clone(),
716 dynamic_weight: self.dynamic_only_weight,
717 dynamic_packages: self.dynamic_packages.clone(),
718 }
719 }
720}
721
722#[derive(Debug)]
724#[non_exhaustive]
725pub struct DiffPackage {
726 pub name: String,
727 pub total_size_bytes: u64,
728}
729
730#[derive(Debug)]
732#[non_exhaustive]
733pub struct DiffResult {
734 pub entry_a_weight: u64,
735 pub entry_b_weight: u64,
736 pub weight_delta: i64,
737 pub dynamic_a_weight: u64,
738 pub dynamic_b_weight: u64,
739 pub dynamic_weight_delta: i64,
740 pub shared_count: usize,
741 pub only_in_a: Vec<DiffPackage>,
742 pub only_in_b: Vec<DiffPackage>,
743 pub dynamic_only_in_a: Vec<DiffPackage>,
744 pub dynamic_only_in_b: Vec<DiffPackage>,
745}
746
747#[must_use]
748#[allow(clippy::cast_possible_wrap)]
749pub fn diff_snapshots(a: &TraceSnapshot, b: &TraceSnapshot) -> DiffResult {
750 let keys_a: HashSet<&str> = a.packages.keys().map(String::as_str).collect();
751 let keys_b: HashSet<&str> = b.packages.keys().map(String::as_str).collect();
752
753 let mut only_in_a: Vec<DiffPackage> = keys_a
754 .difference(&keys_b)
755 .map(|&name| DiffPackage {
756 name: name.to_string(),
757 total_size_bytes: a.packages[name],
758 })
759 .collect();
760 only_in_a.sort_by(|x, y| y.total_size_bytes.cmp(&x.total_size_bytes));
761
762 let mut only_in_b: Vec<DiffPackage> = keys_b
763 .difference(&keys_a)
764 .map(|&name| DiffPackage {
765 name: name.to_string(),
766 total_size_bytes: b.packages[name],
767 })
768 .collect();
769 only_in_b.sort_by(|x, y| y.total_size_bytes.cmp(&x.total_size_bytes));
770
771 let dyn_keys_a: HashSet<&str> = a.dynamic_packages.keys().map(String::as_str).collect();
772 let dyn_keys_b: HashSet<&str> = b.dynamic_packages.keys().map(String::as_str).collect();
773
774 let mut dynamic_only_in_a: Vec<DiffPackage> = dyn_keys_a
775 .difference(&dyn_keys_b)
776 .map(|&name| DiffPackage {
777 name: name.to_string(),
778 total_size_bytes: a.dynamic_packages[name],
779 })
780 .collect();
781 dynamic_only_in_a.sort_by(|x, y| y.total_size_bytes.cmp(&x.total_size_bytes));
782
783 let mut dynamic_only_in_b: Vec<DiffPackage> = dyn_keys_b
784 .difference(&dyn_keys_a)
785 .map(|&name| DiffPackage {
786 name: name.to_string(),
787 total_size_bytes: b.dynamic_packages[name],
788 })
789 .collect();
790 dynamic_only_in_b.sort_by(|x, y| y.total_size_bytes.cmp(&x.total_size_bytes));
791
792 DiffResult {
793 entry_a_weight: a.static_weight,
794 entry_b_weight: b.static_weight,
795 weight_delta: b.static_weight as i64 - a.static_weight as i64,
796 dynamic_a_weight: a.dynamic_weight,
797 dynamic_b_weight: b.dynamic_weight,
798 dynamic_weight_delta: b.dynamic_weight as i64 - a.dynamic_weight as i64,
799 shared_count: keys_a.intersection(&keys_b).count(),
800 only_in_a,
801 only_in_b,
802 dynamic_only_in_a,
803 dynamic_only_in_b,
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use crate::graph::ModuleGraph;
811 use std::path::PathBuf;
812
813 fn make_graph(
817 nodes: &[(&str, u64, Option<&str>)],
818 edges: &[(usize, usize, EdgeKind)],
819 ) -> ModuleGraph {
820 let mut graph = ModuleGraph::new();
821 for &(path, size, pkg) in nodes {
822 graph.add_module(PathBuf::from(path), size, pkg.map(str::to_string));
823 }
824 for &(from, to, kind) in edges {
825 #[allow(clippy::cast_possible_truncation)]
826 graph.add_edge(ModuleId(from as u32), ModuleId(to as u32), kind, "");
827 }
828 graph
829 }
830
831 #[test]
834 fn trace_static_weight() {
835 let graph = make_graph(
837 &[
838 ("a.ts", 100, None),
839 ("b.ts", 200, None),
840 ("c.ts", 300, None),
841 ],
842 &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
843 );
844 let result = trace(&graph, ModuleId(0), &TraceOptions::default());
845 assert_eq!(result.static_weight, 600);
846 assert_eq!(result.static_module_count, 3);
847 }
848
849 #[test]
850 fn trace_dynamic_excluded_by_default() {
851 let graph = make_graph(
853 &[
854 ("a.ts", 100, None),
855 ("b.ts", 200, None),
856 ("c.ts", 300, None),
857 ],
858 &[(0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic)],
859 );
860 let result = trace(&graph, ModuleId(0), &TraceOptions::default());
861 assert_eq!(result.static_weight, 300); assert_eq!(result.dynamic_only_weight, 300); assert_eq!(result.dynamic_only_module_count, 1);
864 }
865
866 #[test]
867 fn trace_include_dynamic() {
868 let graph = make_graph(
870 &[("a.ts", 100, None), ("b.ts", 200, None)],
871 &[(0, 1, EdgeKind::Dynamic)],
872 );
873 let opts = TraceOptions {
874 include_dynamic: true,
875 top_n: 10,
876 ignore: Vec::new(),
877 };
878 let result = trace(&graph, ModuleId(0), &opts);
879 assert!(
881 result
882 .modules_by_cost
883 .iter()
884 .any(|m| m.module_id == ModuleId(1))
885 );
886 }
887
888 #[test]
891 fn chain_linear_path() {
892 let graph = make_graph(
894 &[
895 ("a.ts", 100, None),
896 ("b.ts", 100, None),
897 ("c.ts", 100, None),
898 ("node_modules/zod/index.js", 500, Some("zod")),
899 ],
900 &[
901 (0, 1, EdgeKind::Static),
902 (1, 2, EdgeKind::Static),
903 (2, 3, EdgeKind::Static),
904 ],
905 );
906 let chains = find_all_chains(
907 &graph,
908 ModuleId(0),
909 &ChainTarget::Package("zod".to_string()),
910 false,
911 );
912 assert_eq!(chains.len(), 1);
913 assert_eq!(
914 chains[0],
915 vec![ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)]
916 );
917 }
918
919 #[test]
920 fn chain_dedup_same_package_path() {
921 let graph = make_graph(
926 &[
927 ("a.ts", 100, None),
928 ("b.ts", 100, None),
929 ("node_modules/zod/index.js", 250, Some("zod")),
930 ("node_modules/zod/lib.js", 250, Some("zod")),
931 ],
932 &[
933 (0, 1, EdgeKind::Static),
934 (1, 2, EdgeKind::Static),
935 (1, 3, EdgeKind::Static),
936 ],
937 );
938 let chains = find_all_chains(
939 &graph,
940 ModuleId(0),
941 &ChainTarget::Package("zod".to_string()),
942 false,
943 );
944 assert_eq!(chains.len(), 1);
945 }
946
947 #[test]
948 fn chain_not_reachable() {
949 let graph = make_graph(
951 &[
952 ("a.ts", 100, None),
953 ("b.ts", 100, None),
954 ("node_modules/zod/index.js", 500, Some("zod")),
955 ],
956 &[(0, 1, EdgeKind::Static)],
957 );
958 let chains = find_all_chains(
959 &graph,
960 ModuleId(0),
961 &ChainTarget::Package("zod".to_string()),
962 false,
963 );
964 assert!(chains.is_empty());
965 }
966
967 #[test]
968 fn chain_through_dynamic_edge() {
969 let graph = make_graph(
972 &[
973 ("a.ts", 100, None),
974 ("b.ts", 100, None),
975 ("node_modules/zod/index.js", 500, Some("zod")),
976 ],
977 &[(0, 1, EdgeKind::Dynamic), (1, 2, EdgeKind::Static)],
978 );
979 let chains_static = find_all_chains(
980 &graph,
981 ModuleId(0),
982 &ChainTarget::Package("zod".to_string()),
983 false,
984 );
985 assert!(chains_static.is_empty());
986
987 let chains_dynamic = find_all_chains(
988 &graph,
989 ModuleId(0),
990 &ChainTarget::Package("zod".to_string()),
991 true,
992 );
993 assert_eq!(chains_dynamic.len(), 1);
994 assert_eq!(
995 chains_dynamic[0],
996 vec![ModuleId(0), ModuleId(1), ModuleId(2)]
997 );
998 }
999
1000 #[test]
1001 #[allow(clippy::cast_possible_truncation)]
1002 fn chain_high_fanout_not_dropped() {
1003 let fan = 25usize;
1014 let mut graph = ModuleGraph::new();
1015 graph.add_module(PathBuf::from("entry.ts"), 100, None); graph.add_module(PathBuf::from("hub.ts"), 100, None); for i in 0..fan {
1018 graph.add_module(PathBuf::from(format!("f{i}.ts")), 50, None); }
1020 let target_idx = (2 + fan) as u32;
1021 graph.add_module(
1022 PathBuf::from("node_modules/zod/index.js"),
1023 500,
1024 Some("zod".to_string()),
1025 );
1026
1027 graph.add_edge(ModuleId(0), ModuleId(1), EdgeKind::Static, "");
1029 for i in 0..fan {
1030 let fi = ModuleId((2 + i) as u32);
1031 graph.add_edge(ModuleId(1), fi, EdgeKind::Static, ""); graph.add_edge(fi, ModuleId(target_idx), EdgeKind::Static, ""); }
1034
1035 let chains = find_all_chains(
1036 &graph,
1037 ModuleId(0),
1038 &ChainTarget::Package("zod".to_string()),
1039 false,
1040 );
1041 assert!(
1043 !chains.is_empty(),
1044 "high-fanout target should still produce chains (got 0)"
1045 );
1046 for chain in &chains {
1048 assert_eq!(chain.first(), Some(&ModuleId(0)));
1049 assert_eq!(chain.last(), Some(&ModuleId(target_idx)));
1050 }
1051 }
1052
1053 #[test]
1056 fn cut_single_convergence_point() {
1057 let graph = make_graph(
1061 &[
1062 ("a.ts", 100, None),
1063 ("b.ts", 100, None),
1064 ("c.ts", 100, None),
1065 ("d.ts", 100, None),
1066 ("node_modules/zod/index.js", 500, Some("zod")),
1067 ],
1068 &[
1069 (0, 1, EdgeKind::Static),
1070 (0, 2, EdgeKind::Static),
1071 (1, 3, EdgeKind::Static),
1072 (2, 3, EdgeKind::Static),
1073 (3, 4, EdgeKind::Static),
1074 ],
1075 );
1076 let target = ChainTarget::Package("zod".to_string());
1077 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1078 assert_eq!(chains.len(), 2);
1079
1080 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1081 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1082 assert!(!cuts.is_empty());
1083 assert!(cuts.iter().any(|c| c.module_id == ModuleId(3)));
1084 }
1085
1086 #[test]
1087 fn cut_no_convergence_point() {
1088 let graph = make_graph(
1093 &[
1094 ("a.ts", 100, None),
1095 ("b.ts", 100, None),
1096 ("c.ts", 100, None),
1097 ("node_modules/zod/index.js", 250, Some("zod")),
1098 ("node_modules/zod/lib.js", 250, Some("zod")),
1099 ],
1100 &[
1101 (0, 1, EdgeKind::Static),
1102 (0, 2, EdgeKind::Static),
1103 (1, 3, EdgeKind::Static),
1104 (2, 4, EdgeKind::Static),
1105 ],
1106 );
1107 let target = ChainTarget::Package("zod".to_string());
1108 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1109 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1110 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1111 assert!(cuts.is_empty());
1112 }
1113
1114 #[test]
1115 fn cut_direct_import_no_intermediate() {
1116 let graph = make_graph(
1119 &[
1120 ("a.ts", 100, None),
1121 ("node_modules/zod/index.js", 500, Some("zod")),
1122 ],
1123 &[(0, 1, EdgeKind::Static)],
1124 );
1125 let target = ChainTarget::Package("zod".to_string());
1126 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1127 assert_eq!(chains.len(), 1);
1128 assert_eq!(chains[0].len(), 2); let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1131 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1132 assert!(cuts.is_empty());
1133 }
1134
1135 #[test]
1136 fn cut_single_chain_ascending_sort() {
1137 let graph = make_graph(
1140 &[
1141 ("a.ts", 100, None),
1142 ("b.ts", 5000, None),
1143 ("c.ts", 100, None),
1144 ("node_modules/zod/index.js", 500, Some("zod")),
1145 ],
1146 &[
1147 (0, 1, EdgeKind::Static),
1148 (1, 2, EdgeKind::Static),
1149 (2, 3, EdgeKind::Static),
1150 ],
1151 );
1152 let target = ChainTarget::Package("zod".to_string());
1153 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1154 assert_eq!(chains.len(), 1);
1155
1156 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1157 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1158 assert!(cuts.len() >= 2);
1159 assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size);
1161 }
1162
1163 fn snap(entry: &str, static_weight: u64, packages: &[(&str, u64)]) -> TraceSnapshot {
1166 TraceSnapshot {
1167 entry: entry.to_string(),
1168 static_weight,
1169 packages: packages
1170 .iter()
1171 .map(|(k, v)| ((*k).to_string(), *v))
1172 .collect(),
1173 dynamic_weight: 0,
1174 dynamic_packages: HashMap::new(),
1175 }
1176 }
1177
1178 fn snap_with_dynamic(
1179 entry: &str,
1180 static_weight: u64,
1181 packages: &[(&str, u64)],
1182 dynamic_weight: u64,
1183 dynamic_packages: &[(&str, u64)],
1184 ) -> TraceSnapshot {
1185 TraceSnapshot {
1186 entry: entry.to_string(),
1187 static_weight,
1188 packages: packages
1189 .iter()
1190 .map(|(k, v)| ((*k).to_string(), *v))
1191 .collect(),
1192 dynamic_weight,
1193 dynamic_packages: dynamic_packages
1194 .iter()
1195 .map(|(k, v)| ((*k).to_string(), *v))
1196 .collect(),
1197 }
1198 }
1199
1200 #[test]
1201 fn diff_snapshots_computes_sets() {
1202 let a = snap(
1203 "a.ts",
1204 1000,
1205 &[("zod", 500), ("chalk", 200), ("tslog", 300)],
1206 );
1207 let b = snap("b.ts", 800, &[("chalk", 200), ("tslog", 300), ("ajv", 100)]);
1208 let diff = diff_snapshots(&a, &b);
1209
1210 assert_eq!(diff.entry_a_weight, 1000);
1211 assert_eq!(diff.entry_b_weight, 800);
1212 assert_eq!(diff.weight_delta, -200);
1213 assert_eq!(diff.only_in_a.len(), 1);
1214 assert_eq!(diff.only_in_a[0].name, "zod");
1215 assert_eq!(diff.only_in_a[0].total_size_bytes, 500);
1216 assert_eq!(diff.only_in_b.len(), 1);
1217 assert_eq!(diff.only_in_b[0].name, "ajv");
1218 assert_eq!(diff.only_in_b[0].total_size_bytes, 100);
1219 assert_eq!(diff.shared_count, 2);
1220 }
1221
1222 #[test]
1223 fn diff_snapshots_sorted_by_size_descending() {
1224 let a = snap(
1225 "a.ts",
1226 1000,
1227 &[("small", 10), ("big", 500), ("medium", 100)],
1228 );
1229 let b = snap("b.ts", 0, &[]);
1230 let diff = diff_snapshots(&a, &b);
1231
1232 assert_eq!(diff.only_in_a.len(), 3);
1233 assert_eq!(diff.only_in_a[0].name, "big");
1234 assert_eq!(diff.only_in_a[1].name, "medium");
1235 assert_eq!(diff.only_in_a[2].name, "small");
1236 }
1237
1238 #[test]
1239 fn diff_snapshots_dynamic_packages() {
1240 let a = snap_with_dynamic("a.ts", 1000, &[("zod", 500)], 200, &[("lodash", 200)]);
1241 let b = snap_with_dynamic("b.ts", 800, &[("zod", 500)], 350, &[("moment", 350)]);
1242 let diff = diff_snapshots(&a, &b);
1243
1244 assert_eq!(diff.dynamic_a_weight, 200);
1245 assert_eq!(diff.dynamic_b_weight, 350);
1246 assert_eq!(diff.dynamic_weight_delta, 150);
1247 assert_eq!(diff.dynamic_only_in_a.len(), 1);
1248 assert_eq!(diff.dynamic_only_in_a[0].name, "lodash");
1249 assert_eq!(diff.dynamic_only_in_b.len(), 1);
1250 assert_eq!(diff.dynamic_only_in_b[0].name, "moment");
1251 }
1252
1253 #[test]
1256 fn exclusive_weight_linear_chain() {
1257 let graph = make_graph(
1260 &[
1261 ("entry.ts", 100, None),
1262 ("a.ts", 200, None),
1263 ("b.ts", 300, None),
1264 ],
1265 &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1266 );
1267 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1268 assert_eq!(weights[0], 600); assert_eq!(weights[1], 500); assert_eq!(weights[2], 300); }
1272
1273 #[test]
1274 fn exclusive_weight_diamond_shared() {
1275 let graph = make_graph(
1279 &[
1280 ("entry.ts", 100, None),
1281 ("a.ts", 200, None),
1282 ("b.ts", 300, None),
1283 ("d.ts", 500, None),
1284 ],
1285 &[
1286 (0, 1, EdgeKind::Static),
1287 (0, 2, EdgeKind::Static),
1288 (1, 3, EdgeKind::Static),
1289 (2, 3, EdgeKind::Static),
1290 ],
1291 );
1292 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1293 assert_eq!(weights[0], 1100); assert_eq!(weights[1], 200); assert_eq!(weights[2], 300); assert_eq!(weights[3], 500); }
1298
1299 #[test]
1300 fn exclusive_weight_mixed_exclusive_and_shared() {
1301 let graph = make_graph(
1305 &[
1306 ("entry.ts", 100, None),
1307 ("a.ts", 200, None),
1308 ("b.ts", 300, None),
1309 ("d.ts", 500, None),
1310 ("e.ts", 600, None),
1311 ],
1312 &[
1313 (0, 1, EdgeKind::Static),
1314 (0, 2, EdgeKind::Static),
1315 (1, 3, EdgeKind::Static),
1316 (2, 3, EdgeKind::Static),
1317 (1, 4, EdgeKind::Static),
1318 ],
1319 );
1320 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1321 assert_eq!(weights[0], 1700); assert_eq!(weights[1], 800); assert_eq!(weights[2], 300); assert_eq!(weights[3], 500); assert_eq!(weights[4], 600); }
1327
1328 #[test]
1329 fn exclusive_weight_with_dynamic_edges() {
1330 let graph = make_graph(
1335 &[
1336 ("entry.ts", 100, None),
1337 ("a.ts", 200, None),
1338 ("b.ts", 300, None),
1339 ("c.ts", 400, None),
1340 ],
1341 &[
1342 (0, 1, EdgeKind::Static),
1343 (0, 2, EdgeKind::Dynamic),
1344 (1, 3, EdgeKind::Static),
1345 (2, 3, EdgeKind::Static),
1346 ],
1347 );
1348 let static_weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1350 assert_eq!(static_weights[1], 600); let all_weights = compute_exclusive_weights(&graph, ModuleId(0), true);
1354 assert_eq!(all_weights[1], 200); assert_eq!(all_weights[2], 300); }
1357
1358 #[test]
1361 fn trace_ignore_filters_heavy_packages() {
1362 let graph = make_graph(
1363 &[
1364 ("entry.ts", 100, None),
1365 ("a.ts", 50, Some("pkg-a")),
1366 ("b.ts", 200, Some("pkg-b")),
1367 ("c.ts", 300, Some("pkg-c")),
1368 ],
1369 &[
1370 (0, 1, EdgeKind::Static),
1371 (0, 2, EdgeKind::Static),
1372 (0, 3, EdgeKind::Static),
1373 ],
1374 );
1375 let opts = TraceOptions {
1376 include_dynamic: false,
1377 top_n: 10,
1378 ignore: vec!["pkg-c".to_string()],
1379 };
1380 let result = trace(&graph, ModuleId(0), &opts);
1381 let names: Vec<&str> = result
1382 .heavy_packages
1383 .iter()
1384 .map(|p| p.name.as_str())
1385 .collect();
1386 assert!(names.contains(&"pkg-a"));
1387 assert!(names.contains(&"pkg-b"));
1388 assert!(!names.contains(&"pkg-c"));
1389 }
1390
1391 #[test]
1392 fn trace_ignore_does_not_affect_total_weight() {
1393 let graph = make_graph(
1394 &[("entry.ts", 100, None), ("a.ts", 500, Some("big-pkg"))],
1395 &[(0, 1, EdgeKind::Static)],
1396 );
1397 let opts = TraceOptions {
1398 include_dynamic: false,
1399 top_n: 10,
1400 ignore: vec!["big-pkg".to_string()],
1401 };
1402 let result = trace(&graph, ModuleId(0), &opts);
1403 assert!(result.heavy_packages.is_empty());
1404 assert_eq!(result.static_weight, 600);
1405 }
1406
1407 #[test]
1410 fn chain_to_module_by_id() {
1411 let graph = make_graph(
1412 &[
1413 ("entry.ts", 100, None),
1414 ("a.ts", 50, None),
1415 ("b.ts", 200, None),
1416 ],
1417 &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1418 );
1419 let target_id = graph.path_to_id[&PathBuf::from("b.ts")];
1420 let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Module(target_id), false);
1421 assert_eq!(chains.len(), 1);
1422 assert_eq!(chains[0].len(), 3);
1423 assert_eq!(*chains[0].last().unwrap(), target_id);
1424 }
1425
1426 #[test]
1427 fn cut_to_module_by_id() {
1428 let graph = make_graph(
1429 &[
1430 ("entry.ts", 100, None),
1431 ("bridge.ts", 50, None),
1432 ("target.ts", 200, None),
1433 ],
1434 &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1435 );
1436 let target_id = graph.path_to_id[&PathBuf::from("target.ts")];
1437 let target = ChainTarget::Module(target_id);
1438 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1439 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1440 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1441 assert_eq!(cuts.len(), 1);
1442 assert_eq!(
1443 cuts[0].module_id,
1444 graph.path_to_id[&PathBuf::from("bridge.ts")]
1445 );
1446 }
1447
1448 #[test]
1451 fn trace_top_n_negative_shows_all() {
1452 let graph = make_graph(
1453 &[
1454 ("entry.ts", 10, None),
1455 ("a.ts", 10, Some("pkg-a")),
1456 ("b.ts", 10, Some("pkg-b")),
1457 ("c.ts", 10, Some("pkg-c")),
1458 ],
1459 &[
1460 (0, 1, EdgeKind::Static),
1461 (0, 2, EdgeKind::Static),
1462 (0, 3, EdgeKind::Static),
1463 ],
1464 );
1465 let opts = TraceOptions {
1466 top_n: -1,
1467 ..Default::default()
1468 };
1469 let result = trace(&graph, ModuleId(0), &opts);
1470 assert_eq!(result.heavy_packages.len(), 3);
1471 }
1472
1473 #[test]
1474 fn trace_top_n_zero_shows_none() {
1475 let graph = make_graph(
1476 &[("entry.ts", 10, None), ("a.ts", 10, Some("pkg-a"))],
1477 &[(0, 1, EdgeKind::Static)],
1478 );
1479 let opts = TraceOptions {
1480 top_n: 0,
1481 ..Default::default()
1482 };
1483 let result = trace(&graph, ModuleId(0), &opts);
1484 assert_eq!(result.heavy_packages.len(), 0);
1485 }
1486}