diff --git a/src/lib.rs b/src/lib.rs index 827c4aa..3c89b8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,6 +60,8 @@ mod auto_trait_tests { is_normal::(); is_normal::(); is_normal::(); + is_normal::(); + is_normal::(); is_normal::(); is_normal::(); is_normal::(); diff --git a/src/query.rs b/src/query.rs index 69dad98..8a67cf2 100644 --- a/src/query.rs +++ b/src/query.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde::{Deserialize, Serialize}; -use crate::graph::{EdgeKind, ModuleGraph, ModuleId}; +use crate::graph::{EdgeId, EdgeKind, ModuleGraph, ModuleId}; /// Results of tracing transitive import weight from an entry module. #[derive(Debug)] @@ -39,6 +39,62 @@ pub struct HeavyPackage { pub chain: Vec, } +/// A shortest import chain with edge-kind annotations per hop. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AnnotatedChain { + nodes: Vec, + edge_kinds: Vec, +} + +impl AnnotatedChain { + pub fn new(nodes: Vec, edge_kinds: Vec) -> Self { + debug_assert_eq!( + edge_kinds.len(), + nodes.len().saturating_sub(1), + "edge_kinds.len() must be nodes.len() - 1" + ); + Self { nodes, edge_kinds } + } + + pub fn modules(&self) -> &[ModuleId] { + &self.nodes + } + + pub fn edge_kinds(&self) -> &[EdgeKind] { + &self.edge_kinds + } + + pub fn hop_count(&self) -> usize { + self.edge_kinds.len() + } + + pub fn classify(&self) -> ChainClassification { + if self.edge_kinds.is_empty() || self.edge_kinds.iter().all(|&k| k == EdgeKind::Static) { + return ChainClassification::AllStatic; + } + if self.edge_kinds.iter().all(|&k| k == EdgeKind::Dynamic) { + return ChainClassification::AllDynamic; + } + let first_dynamic_hop = self + .edge_kinds + .iter() + .position(|&k| k == EdgeKind::Dynamic) + .expect("mixed chain has at least one dynamic edge"); + ChainClassification::Mixed { first_dynamic_hop } + } +} + +/// Classification of a chain based on its edge kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[non_exhaustive] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChainClassification { + AllStatic, + Mixed { first_dynamic_hop: usize }, + AllDynamic, +} + /// A module with its exclusive weight (bytes only it contributes to the graph). #[derive(Debug, Clone, Copy)] #[non_exhaustive] @@ -451,7 +507,7 @@ pub fn find_all_chains( entry: ModuleId, target: &ChainTarget, include_dynamic: bool, -) -> Vec> { +) -> Vec { let raw = all_shortest_chains(graph, entry, target, 10, include_dynamic); dedup_chains_by_package(graph, raw) } @@ -460,12 +516,16 @@ pub fn find_all_chains( /// Two chains that differ only by which internal file within a package /// they pass through will have the same package-level key and only the /// first is kept. -fn dedup_chains_by_package(graph: &ModuleGraph, chains: Vec>) -> Vec> { +fn dedup_chains_by_package( + graph: &ModuleGraph, + chains: Vec, +) -> Vec { let mut seen: HashSet> = HashSet::new(); let mut result = Vec::new(); for chain in chains { let key: Vec = chain + .modules() .iter() .map(|&mid| { let m = graph.module(mid); @@ -491,9 +551,9 @@ fn all_shortest_chains( target: &ChainTarget, max_chains: usize, include_dynamic: bool, -) -> Vec> { +) -> Vec { let n = graph.modules.len(); - let mut parents: Vec> = vec![Vec::new(); n]; + let mut parents: Vec> = vec![Vec::new(); n]; let mut depth: Vec = vec![u32::MAX; n]; let mut queue: VecDeque = VecDeque::new(); @@ -534,16 +594,14 @@ fn all_shortest_chains( let idx = edge.to.0 as usize; match depth[idx] { d if d == next_depth => { - // Same depth -- add as alternate parent - parents[idx].push(mid.0); + parents[idx].push((mid.0, edge_id)); } u32::MAX => { - // First visit depth[idx] = next_depth; - parents[idx].push(mid.0); + parents[idx].push((mid.0, edge_id)); queue.push_back(edge.to); } - _ => {} // Already visited at shorter depth, skip + _ => {} } } } @@ -552,35 +610,29 @@ fn all_shortest_chains( return Vec::new(); } - // Backtrack from each target to reconstruct all paths. - // `limit` caps *expansion* per iteration (not total paths) — - // capped paths extend with a single parent to avoid discarding - // reachable paths. Total paths grow linearly with depth, not - // exponentially. let limit = max_chains * 2; - let mut all_chains: Vec> = Vec::new(); + let mut all_chains: Vec = Vec::new(); for &target_mid in &targets { - let mut partial_paths: Vec> = vec![vec![target_mid]]; + let mut partial_paths: Vec)>> = + vec![vec![(target_mid, None)]]; loop { - let mut next_partial: Vec> = Vec::new(); + let mut next_partial: Vec)>> = Vec::new(); let mut any_extended = false; let mut capped = false; for path in &partial_paths { - let &head = path.last().expect("partial paths are non-empty"); + let &(head, _) = path.last().expect("partial paths are non-empty"); if head == entry { next_partial.push(path.clone()); continue; } if capped { - // Over budget — only keep the first partial for this branch - // so it can still reach entry in future iterations. let pars = &parents[head.0 as usize]; - if let Some(&p) = pars.first() { + if let Some(&(p, eid)) = pars.first() { any_extended = true; let mut new_path = path.clone(); - new_path.push(ModuleId(p)); + new_path.push((ModuleId(p), Some(eid))); next_partial.push(new_path); } continue; @@ -588,9 +640,9 @@ fn all_shortest_chains( let pars = &parents[head.0 as usize]; if !pars.is_empty() { any_extended = true; - for &p in pars { + for &(p, eid) in pars { let mut new_path = path.clone(); - new_path.push(ModuleId(p)); + new_path.push((ModuleId(p), Some(eid))); next_partial.push(new_path); if next_partial.len() > limit { capped = true; @@ -606,10 +658,20 @@ fn all_shortest_chains( } } - for mut path in partial_paths { - path.reverse(); - if path.first() == Some(&entry) { - all_chains.push(path); + for path in partial_paths { + let mut reversed = path; + reversed.reverse(); + if reversed.first().map(|&(m, _)| m) == Some(entry) { + let nodes: Vec = reversed.iter().map(|&(m, _)| m).collect(); + let edge_kinds: Vec = reversed[..reversed.len() - 1] + .iter() + .map(|&(_, eid)| { + graph + .edge(eid.expect("non-target nodes have edge ids")) + .kind + }) + .collect(); + all_chains.push(AnnotatedChain::new(nodes, edge_kinds)); if all_chains.len() >= max_chains { return all_chains; } @@ -911,8 +973,8 @@ mod tests { ); assert_eq!(chains.len(), 1); assert_eq!( - chains[0], - vec![ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)] + chains[0].modules(), + &[ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)] ); } @@ -992,8 +1054,102 @@ mod tests { ); assert_eq!(chains_dynamic.len(), 1); assert_eq!( - chains_dynamic[0], - vec![ModuleId(0), ModuleId(1), ModuleId(2)] + chains_dynamic[0].modules(), + &[ModuleId(0), ModuleId(1), ModuleId(2)] + ); + } + + #[test] + fn chain_returns_edge_kinds() { + let graph = make_graph( + &[ + ("a.ts", 100, None), + ("b.ts", 100, None), + ("node_modules/zod/index.js", 500, Some("zod")), + ], + &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)], + ); + let chains = find_all_chains( + &graph, + ModuleId(0), + &ChainTarget::Package("zod".to_string()), + false, + ); + assert_eq!(chains.len(), 1); + assert_eq!( + chains[0].modules(), + &[ModuleId(0), ModuleId(1), ModuleId(2)] + ); + assert_eq!( + chains[0].edge_kinds(), + &[EdgeKind::Static, EdgeKind::Static] + ); + } + + #[test] + fn chain_mixed_edge_kinds() { + let graph = make_graph( + &[ + ("a.ts", 100, None), + ("b.ts", 100, None), + ("node_modules/zod/index.js", 500, Some("zod")), + ], + &[(0, 1, EdgeKind::Dynamic), (1, 2, EdgeKind::Static)], + ); + let chains = find_all_chains( + &graph, + ModuleId(0), + &ChainTarget::Package("zod".to_string()), + true, + ); + assert_eq!(chains.len(), 1); + assert_eq!( + chains[0].edge_kinds(), + &[EdgeKind::Dynamic, EdgeKind::Static] + ); + } + + #[test] + fn chain_classification() { + assert_eq!( + AnnotatedChain::new(vec![ModuleId(0)], vec![]).classify(), + ChainClassification::AllStatic, + ); + assert_eq!( + AnnotatedChain::new( + vec![ModuleId(0), ModuleId(1), ModuleId(2)], + vec![EdgeKind::Static, EdgeKind::Static], + ) + .classify(), + ChainClassification::AllStatic, + ); + assert_eq!( + AnnotatedChain::new( + vec![ModuleId(0), ModuleId(1), ModuleId(2)], + vec![EdgeKind::Dynamic, EdgeKind::Dynamic], + ) + .classify(), + ChainClassification::AllDynamic, + ); + assert_eq!( + AnnotatedChain::new( + vec![ModuleId(0), ModuleId(1), ModuleId(2)], + vec![EdgeKind::Static, EdgeKind::Dynamic], + ) + .classify(), + ChainClassification::Mixed { + first_dynamic_hop: 1 + }, + ); + assert_eq!( + AnnotatedChain::new( + vec![ModuleId(0), ModuleId(1), ModuleId(2)], + vec![EdgeKind::Dynamic, EdgeKind::Static], + ) + .classify(), + ChainClassification::Mixed { + first_dynamic_hop: 0 + }, ); } @@ -1045,8 +1201,8 @@ mod tests { ); // Every chain must start at entry and end at target for chain in &chains { - assert_eq!(chain.first(), Some(&ModuleId(0))); - assert_eq!(chain.last(), Some(&ModuleId(target_idx))); + assert_eq!(chain.modules().first(), Some(&ModuleId(0))); + assert_eq!(chain.modules().last(), Some(&ModuleId(target_idx))); } } @@ -1078,7 +1234,9 @@ mod tests { assert_eq!(chains.len(), 2); let weights = compute_exclusive_weights(&graph, ModuleId(0), false); - let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights); + let module_chains: Vec> = + chains.iter().map(|c| c.modules().to_vec()).collect(); + let cuts = find_cut_modules(&graph, &module_chains, ModuleId(0), &target, 10, &weights); assert!(!cuts.is_empty()); assert!(cuts.iter().any(|c| c.module_id == ModuleId(3))); } @@ -1107,7 +1265,9 @@ mod tests { let target = ChainTarget::Package("zod".to_string()); let chains = find_all_chains(&graph, ModuleId(0), &target, false); let weights = compute_exclusive_weights(&graph, ModuleId(0), false); - let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights); + let module_chains: Vec> = + chains.iter().map(|c| c.modules().to_vec()).collect(); + let cuts = find_cut_modules(&graph, &module_chains, ModuleId(0), &target, 10, &weights); assert!(cuts.is_empty()); } @@ -1125,10 +1285,12 @@ mod tests { let target = ChainTarget::Package("zod".to_string()); let chains = find_all_chains(&graph, ModuleId(0), &target, false); assert_eq!(chains.len(), 1); - assert_eq!(chains[0].len(), 2); // 1 hop = 2 nodes + assert_eq!(chains[0].modules().len(), 2); // 1 hop = 2 nodes let weights = compute_exclusive_weights(&graph, ModuleId(0), false); - let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights); + let module_chains: Vec> = + chains.iter().map(|c| c.modules().to_vec()).collect(); + let cuts = find_cut_modules(&graph, &module_chains, ModuleId(0), &target, 10, &weights); assert!(cuts.is_empty()); } @@ -1154,7 +1316,9 @@ mod tests { assert_eq!(chains.len(), 1); let weights = compute_exclusive_weights(&graph, ModuleId(0), false); - let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights); + let module_chains: Vec> = + chains.iter().map(|c| c.modules().to_vec()).collect(); + let cuts = find_cut_modules(&graph, &module_chains, ModuleId(0), &target, 10, &weights); assert!(cuts.len() >= 2); // First cut should have smaller exclusive_size (more surgical) assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size); @@ -1419,8 +1583,8 @@ mod tests { let target_id = graph.path_to_id[&PathBuf::from("b.ts")]; let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Module(target_id), false); assert_eq!(chains.len(), 1); - assert_eq!(chains[0].len(), 3); - assert_eq!(*chains[0].last().unwrap(), target_id); + assert_eq!(chains[0].modules().len(), 3); + assert_eq!(*chains[0].modules().last().unwrap(), target_id); } #[test] @@ -1437,7 +1601,9 @@ mod tests { let target = ChainTarget::Module(target_id); let chains = find_all_chains(&graph, ModuleId(0), &target, false); let weights = compute_exclusive_weights(&graph, ModuleId(0), false); - let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights); + let module_chains: Vec> = + chains.iter().map(|c| c.modules().to_vec()).collect(); + let cuts = find_cut_modules(&graph, &module_chains, ModuleId(0), &target, 10, &weights); assert_eq!(cuts.len(), 1); assert_eq!( cuts[0].module_id, diff --git a/src/report.rs b/src/report.rs index 9b3d84c..c41b56f 100644 --- a/src/report.rs +++ b/src/report.rs @@ -7,8 +7,8 @@ use std::path::{Component, Path, PathBuf}; use serde::Serialize; -use crate::graph::{ModuleGraph, ModuleId}; -use crate::query::DiffResult; +use crate::graph::{EdgeKind, ModuleGraph, ModuleId}; +use crate::query::{AnnotatedChain, ChainClassification, DiffResult}; /// Default number of heavy dependencies to display. pub const DEFAULT_TOP: i32 = 10; @@ -253,6 +253,28 @@ pub(crate) fn chain_display_names( .collect() } +pub(crate) fn annotated_chain_display( + graph: &ModuleGraph, + chain: &AnnotatedChain, + root: &Path, +) -> AnnotatedChainReport { + let names = chain_display_names(graph, chain.modules(), root); + let edge_kinds: Vec = chain + .edge_kinds() + .iter() + .map(|k| match k { + EdgeKind::Static => "static".into(), + EdgeKind::Dynamic => "dynamic".into(), + _ => unreachable!("only Static and Dynamic edges appear in chains"), + }) + .collect(); + AnnotatedChainReport { + modules: names, + edge_kinds, + classification: chain.classify(), + } +} + // --------------------------------------------------------------------------- // Structured report types // --------------------------------------------------------------------------- @@ -283,6 +305,8 @@ pub struct PackageEntry { pub total_size_bytes: u64, pub file_count: u32, pub chain: Vec, + pub edge_kinds: Vec, + pub classification: ChainClassification, } #[derive(Debug, Clone, Serialize)] @@ -291,6 +315,14 @@ pub struct ModuleEntry { pub exclusive_size_bytes: u64, } +/// A single annotated chain for display. +#[derive(Debug, Clone, Serialize)] +pub struct AnnotatedChainReport { + pub modules: Vec, + pub edge_kinds: Vec, + pub classification: ChainClassification, +} + /// Display-ready chain result. Produced by `Session::chain_report()`. #[derive(Debug, Clone, Serialize)] pub struct ChainReport { @@ -298,7 +330,7 @@ pub struct ChainReport { pub found_in_graph: bool, pub chain_count: usize, pub hop_count: usize, - pub chains: Vec>, + pub chains: Vec, } /// Display-ready cut result. Produced by `Session::cut_report()`. @@ -449,7 +481,12 @@ impl TraceReport { ) .unwrap(); if pkg.chain.len() > 1 { - writeln!(out, " -> {}", pkg.chain.join(" -> ")).unwrap(); + let mut chain_str = pkg.chain[0].clone(); + for (j, module) in pkg.chain.iter().skip(1).enumerate() { + let kind = pkg.edge_kinds.get(j).map_or("static", String::as_str); + write!(chain_str, " -[{kind}]-> {module}").unwrap(); + } + writeln!(out, " -> {chain_str}").unwrap(); } } } @@ -529,7 +566,27 @@ impl ChainReport { ) .unwrap(); for (i, chain) in self.chains.iter().enumerate() { - writeln!(out, " {}. {}", i + 1, chain.join(" -> ")).unwrap(); + let mut line = format!(" {}. {}", i + 1, chain.modules[0]); + for (j, module) in chain.modules.iter().skip(1).enumerate() { + let kind = &chain.edge_kinds[j]; + write!(line, " -[{kind}]-> {module}").unwrap(); + } + writeln!(out, "{line}").unwrap(); + + let summary = match chain.classification { + ChainClassification::AllStatic => "all static".to_string(), + ChainClassification::AllDynamic => "all dynamic".to_string(), + ChainClassification::Mixed { + first_dynamic_hop: 0, + } => "dynamic from entry".to_string(), + ChainClassification::Mixed { first_dynamic_hop } => { + format!( + "static until {}, then dynamic", + chain.modules[first_dynamic_hop] + ) + } + }; + writeln!(out, " {}", c.dim(&summary)).unwrap(); } out @@ -975,6 +1032,8 @@ mod tests { total_size_bytes: 500, file_count: 3, chain: vec!["src/index.ts".into(), "zod".into()], + edge_kinds: vec!["static".into()], + classification: ChainClassification::AllStatic, }], modules_by_cost: vec![ModuleEntry { path: "src/utils.ts".into(), @@ -988,6 +1047,11 @@ mod tests { assert!(json["entry"].is_string()); assert!(json["static_weight_bytes"].is_number()); assert!(json["heavy_packages"][0]["total_size_bytes"].is_number()); + assert!(json["heavy_packages"][0]["edge_kinds"].is_array()); + assert_eq!( + json["heavy_packages"][0]["classification"]["type"], + "all_static" + ); assert!(json["modules_by_cost"][0]["exclusive_size_bytes"].is_number()); assert_eq!(json["total_modules_with_cost"], 10); // include_dynamic should not appear in JSON (serde skip) @@ -1001,16 +1065,39 @@ mod tests { found_in_graph: true, chain_count: 1, hop_count: 2, - chains: vec![vec![ - "src/index.ts".into(), - "src/lib.ts".into(), - "zod".into(), - ]], + chains: vec![AnnotatedChainReport { + modules: vec!["src/index.ts".into(), "src/lib.ts".into(), "zod".into()], + edge_kinds: vec!["static".into(), "static".into()], + classification: ChainClassification::AllStatic, + }], }; let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap(); assert!(json["target"].is_string()); assert!(json["found_in_graph"].is_boolean()); - assert!(json["chains"][0].is_array()); + assert_eq!(json["chains"][0]["modules"][0], "src/index.ts"); + assert_eq!(json["chains"][0]["edge_kinds"][0], "static"); + assert_eq!(json["chains"][0]["classification"]["type"], "all_static"); + } + + #[test] + fn chain_report_mixed_classification_json() { + let report = ChainReport { + target: "zod".into(), + found_in_graph: true, + chain_count: 1, + hop_count: 2, + chains: vec![AnnotatedChainReport { + modules: vec!["src/index.ts".into(), "src/lazy.ts".into(), "zod".into()], + edge_kinds: vec!["static".into(), "dynamic".into()], + classification: ChainClassification::Mixed { + first_dynamic_hop: 1, + }, + }], + }; + let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap(); + let cls = &json["chains"][0]["classification"]; + assert_eq!(cls["type"], "mixed"); + assert_eq!(cls["first_dynamic_hop"], 1); } #[test] @@ -1181,4 +1268,43 @@ mod tests { assert_eq!(report.only_in_a.len(), 1); assert_eq!(report.only_in_a[0].name, "zod"); } + + #[test] + fn chain_terminal_shows_edge_kinds() { + let report = ChainReport { + target: "zod".into(), + found_in_graph: true, + chain_count: 1, + hop_count: 2, + chains: vec![AnnotatedChainReport { + modules: vec!["src/index.ts".into(), "src/lib.ts".into(), "zod".into()], + edge_kinds: vec!["static".into(), "static".into()], + classification: ChainClassification::AllStatic, + }], + }; + let output = report.to_terminal(false); + assert!(output.contains("[static]")); + assert!(output.contains("all static")); + } + + #[test] + fn chain_terminal_mixed_classification() { + let report = ChainReport { + target: "zod".into(), + found_in_graph: true, + chain_count: 1, + hop_count: 2, + chains: vec![AnnotatedChainReport { + modules: vec!["src/index.ts".into(), "src/lazy.ts".into(), "zod".into()], + edge_kinds: vec!["static".into(), "dynamic".into()], + classification: ChainClassification::Mixed { + first_dynamic_hop: 1, + }, + }], + }; + let output = report.to_terminal(false); + assert!(output.contains("[static]")); + assert!(output.contains("[dynamic]")); + assert!(output.contains("then dynamic")); + } } diff --git a/src/session.rs b/src/session.rs index 9c02d44..8cb7c3a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -15,7 +15,10 @@ use crate::cache::{CacheWriteHandle, LOCKFILES}; use crate::error::Error; use crate::graph::{EdgeId, EdgeKind, ModuleGraph, ModuleId, PackageInfo}; use crate::loader; -use crate::query::{self, ChainTarget, CutModule, DiffResult, TraceOptions, TraceResult}; +use crate::query::{ + self, AnnotatedChain, ChainClassification, ChainTarget, CutModule, DiffResult, TraceOptions, + TraceResult, +}; use crate::report::{ self, ChainReport, CutEntry, CutReport, DiffReport, ModuleEntry, PackageEntry, PackageListEntry, PackagesReport, TraceReport, @@ -175,7 +178,7 @@ impl Session { &self, target_arg: &str, include_dynamic: bool, - ) -> (ResolvedTarget, Vec>) { + ) -> (ResolvedTarget, Vec) { let resolved = self.resolve_target(target_arg); let chains = query::find_all_chains( &self.graph, @@ -192,7 +195,7 @@ impl Session { target_arg: &str, top: i32, include_dynamic: bool, - ) -> (ResolvedTarget, Vec>, Vec) { + ) -> (ResolvedTarget, Vec, Vec) { let resolved = self.resolve_target(target_arg); let chains = query::find_all_chains( &self.graph, @@ -206,9 +209,11 @@ impl Session { .as_ref() .expect("ensure_weights populates cache") .weights; + let module_chains: Vec> = + chains.iter().map(|c| c.modules().to_vec()).collect(); let cuts = query::find_cut_modules( &self.graph, - &chains, + &module_chains, self.entry_id, &resolved.target, top, @@ -488,10 +493,10 @@ impl Session { target: resolved.label, found_in_graph: resolved.exists, chain_count: chains.len(), - hop_count: chains.first().map_or(0, |c| c.len().saturating_sub(1)), + hop_count: chains.first().map_or(0, AnnotatedChain::hop_count), chains: chains .iter() - .map(|chain| report::chain_display_names(&self.graph, chain, &self.root)) + .map(|chain| report::annotated_chain_display(&self.graph, chain, &self.root)) .collect(), } } @@ -505,7 +510,7 @@ impl Session { chain_count: chains.len(), direct_import: !chains.is_empty() && cuts.is_empty() - && chains.iter().all(|c| c.len() == 2), + && chains.iter().all(|c| c.modules().len() == 2), cut_points: cuts .iter() .map(|c| CutEntry { @@ -622,6 +627,8 @@ fn build_trace_report( total_size_bytes: pkg.total_size, file_count: pkg.file_count, chain: report::chain_display_names(graph, &pkg.chain, root), + edge_kinds: vec!["static".into(); pkg.chain.len().saturating_sub(1)], + classification: ChainClassification::AllStatic, }) .collect(); @@ -998,7 +1005,8 @@ mod tests { let report = session.chain_report("a.ts", false); assert!(report.found_in_graph); assert_eq!(report.chain_count, 1); - assert!(report.chains[0].iter().any(|s| s.contains("a.ts"))); + assert!(report.chains[0].modules.iter().any(|s| s.contains("a.ts"))); + assert!(report.chains[0].edge_kinds.iter().all(|k| k == "static")); } #[test] diff --git a/tests/json_roundtrip.rs b/tests/json_roundtrip.rs index a496edb..e9e1b4e 100644 --- a/tests/json_roundtrip.rs +++ b/tests/json_roundtrip.rs @@ -52,6 +52,8 @@ fn trace_report_json_schema() { assert_field(pkg, "total_size_bytes", |v| v.is_number()); assert_field(pkg, "file_count", |v| v.is_number()); assert_field(pkg, "chain", |v| v.is_array()); + assert_field(pkg, "edge_kinds", |v| v.is_array()); + assert_field(pkg, "classification", |v| v.is_object()); // Nested: modules_by_cost entries let mod_entry = &v["modules_by_cost"][0]; @@ -73,10 +75,13 @@ fn chain_report_json_schema() { assert_field(&v, "hop_count", |v| v.is_number()); assert_field(&v, "chains", |v| v.is_array()); - // Each chain is an array of strings + // Each chain is an object with modules, edge_kinds, classification let chain = &v["chains"][0]; - assert!(chain.is_array()); - assert!(chain[0].is_string()); + assert!(chain.is_object()); + assert_field(chain, "modules", |v| v.is_array()); + assert_field(chain, "edge_kinds", |v| v.is_array()); + assert_field(chain, "classification", |v| v.is_object()); + assert!(chain["modules"][0].is_string()); } // --- CutReport ---