From dc29eb3ed6cabd3c26e751531c298c4f6bdeaaed Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Thu, 23 Apr 2026 01:15:07 -0700 Subject: [PATCH 1/4] feat(report): add FormatVersion and to_json_versioned for all report types FormatVersion::V1 emits the pre-0.5.0 JSON shape: ChainReport.chains as flat string arrays, TraceReport.PackageEntry without edge_kinds or classification. V2 is the current shape. All reports include a format_version field in JSON output. Existing to_json() delegates to to_json_versioned(V2) for backward compat with REPL callers. --- src/report.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 5 deletions(-) diff --git a/src/report.rs b/src/report.rs index c41b56f..c4d037e 100644 --- a/src/report.rs +++ b/src/report.rs @@ -396,6 +396,22 @@ pub struct PackageListEntry { // Emit helper — centralizes --json dispatch // --------------------------------------------------------------------------- +/// JSON output schema version. Consumers pass `--format-version N` to pin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatVersion { + V1, + V2, +} + +impl FormatVersion { + pub fn number(self) -> u8 { + match self { + Self::V1 => 1, + Self::V2 => 2, + } + } +} + /// Emit a report to stdout, choosing JSON or terminal format. /// /// Centralizes the `--json` / terminal dispatch so every CLI output path @@ -416,7 +432,23 @@ pub fn emit(json: bool, json_fn: impl FnOnce() -> String, terminal_fn: impl FnOn impl TraceReport { pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap() + self.to_json_versioned(FormatVersion::V2) + } + + pub fn to_json_versioned(&self, version: FormatVersion) -> String { + let mut value = serde_json::to_value(self).unwrap(); + if version == FormatVersion::V1 { + if let Some(pkgs) = value["heavy_packages"].as_array_mut() { + for pkg in pkgs { + if let Some(obj) = pkg.as_object_mut() { + obj.remove("edge_kinds"); + obj.remove("classification"); + } + } + } + } + value["format_version"] = serde_json::json!(version.number()); + serde_json::to_string_pretty(&value).unwrap() } pub fn to_terminal(&self, color: bool) -> String { @@ -526,7 +558,29 @@ impl TraceReport { impl ChainReport { pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap() + self.to_json_versioned(FormatVersion::V2) + } + + pub fn to_json_versioned(&self, version: FormatVersion) -> String { + let mut value = match version { + FormatVersion::V1 => { + let legacy_chains: Vec> = self + .chains + .iter() + .map(|c| c.modules.iter().map(String::as_str).collect()) + .collect(); + serde_json::json!({ + "target": self.target, + "found_in_graph": self.found_in_graph, + "chain_count": self.chain_count, + "hop_count": self.hop_count, + "chains": legacy_chains, + }) + } + FormatVersion::V2 => serde_json::to_value(self).unwrap(), + }; + value["format_version"] = serde_json::json!(version.number()); + serde_json::to_string_pretty(&value).unwrap() } pub fn to_terminal(&self, color: bool) -> String { @@ -595,7 +649,13 @@ impl ChainReport { impl CutReport { pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap() + self.to_json_versioned(FormatVersion::V2) + } + + pub fn to_json_versioned(&self, version: FormatVersion) -> String { + let mut value = serde_json::to_value(self).unwrap(); + value["format_version"] = serde_json::json!(version.number()); + serde_json::to_string_pretty(&value).unwrap() } pub fn to_terminal(&self, color: bool) -> String { @@ -688,7 +748,13 @@ impl CutReport { impl DiffReport { pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap() + self.to_json_versioned(FormatVersion::V2) + } + + pub fn to_json_versioned(&self, version: FormatVersion) -> String { + let mut value = serde_json::to_value(self).unwrap(); + value["format_version"] = serde_json::json!(version.number()); + serde_json::to_string_pretty(&value).unwrap() } pub fn from_diff(diff: &DiffResult, entry_a: &str, entry_b: &str, limit: i32) -> Self { @@ -908,7 +974,13 @@ impl DiffReport { impl PackagesReport { pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap() + self.to_json_versioned(FormatVersion::V2) + } + + pub fn to_json_versioned(&self, version: FormatVersion) -> String { + let mut value = serde_json::to_value(self).unwrap(); + value["format_version"] = serde_json::json!(version.number()); + serde_json::to_string_pretty(&value).unwrap() } #[allow(clippy::cast_sign_loss)] @@ -1307,4 +1379,74 @@ mod tests { assert!(output.contains("[dynamic]")); assert!(output.contains("then dynamic")); } + + #[test] + fn chain_report_v1_json_is_flat_arrays() { + 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 json: serde_json::Value = + serde_json::from_str(&report.to_json_versioned(FormatVersion::V1)).unwrap(); + assert_eq!(json["format_version"], 1); + assert!(json["chains"][0].is_array()); + assert!(json["chains"][0][0].is_string()); + assert_eq!(json["chains"][0][0], "src/index.ts"); + } + + #[test] + fn chain_report_v2_json_is_annotated_objects() { + 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 json: serde_json::Value = + serde_json::from_str(&report.to_json_versioned(FormatVersion::V2)).unwrap(); + assert_eq!(json["format_version"], 2); + assert!(json["chains"][0].is_object()); + assert_eq!(json["chains"][0]["modules"][0], "src/index.ts"); + assert_eq!(json["chains"][0]["edge_kinds"][0], "static"); + } + + #[test] + fn trace_report_v1_json_omits_edge_fields() { + let report = TraceReport { + entry: "src/index.ts".into(), + static_weight_bytes: 1000, + static_module_count: 5, + dynamic_only_weight_bytes: 0, + dynamic_only_module_count: 0, + heavy_packages: vec![PackageEntry { + name: "zod".into(), + 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![], + total_modules_with_cost: 0, + include_dynamic: false, + top: 10, + }; + let json: serde_json::Value = + serde_json::from_str(&report.to_json_versioned(FormatVersion::V1)).unwrap(); + assert_eq!(json["format_version"], 1); + assert!(json["heavy_packages"][0].get("edge_kinds").is_none()); + assert!(json["heavy_packages"][0].get("classification").is_none()); + } } From ea507ce4ee3323eaf62e93adc0ece5abfe76ae1d Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Thu, 23 Apr 2026 01:19:14 -0700 Subject: [PATCH 2/4] feat(cli): add --format-version flag for JSON schema versioning Consumers pass --format-version 1 or 2 to pin the JSON schema. When --json is used without --format-version, stderr warns and defaults to 2. Threaded through all emit call sites in main.rs. --- src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++++-------- src/report.rs | 14 +++++------ 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index 45cfdc9..a25abe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,10 @@ enum Commands { #[arg(long)] json: bool, + /// JSON output schema version (pin this in CI scripts) + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=2))] + format_version: Option, + /// Max packages to show in diff output (-1 for all) #[arg(long, default_value_t = report::DEFAULT_TOP, allow_hyphen_values = true)] limit: i32, @@ -127,6 +131,10 @@ struct TraceArgs { #[arg(long)] json: bool, + /// JSON output schema version (pin this in CI scripts) + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=2))] + format_version: Option, + /// Force full re-parse, ignoring cache #[arg(long)] no_cache: bool, @@ -157,6 +165,10 @@ struct PackagesArgs { #[arg(long)] json: bool, + /// JSON output schema version (pin this in CI scripts) + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=2))] + format_version: Option, + /// Force full re-parse, ignoring cache #[arg(long)] no_cache: bool, @@ -194,6 +206,28 @@ fn resolve_color(no_color: bool) -> bool { ) } +fn resolve_format_version( + flag: Option, + json: bool, + sc: report::StderrColor, +) -> report::FormatVersion { + if !json { + return report::FormatVersion::V2; + } + match flag { + Some(1) => report::FormatVersion::V1, + Some(_) => report::FormatVersion::V2, + None => { + eprintln!( + "{} --format-version not specified, defaulting to 2. \ + Pin --format-version 2 in scripts to avoid future breaks.", + sc.warning("warning:"), + ); + report::FormatVersion::V2 + } + } +} + /// Cap the rayon thread pool to avoid VFS lock contention in the kernel. /// /// Filesystem-heavy workloads (stat, open, read) hit diminishing returns beyond @@ -238,9 +272,13 @@ fn run(command: Commands, no_color: bool, sc: report::StderrColor) -> Result run_diff(a, b, entry, json, limit, quiet, color, sc).map(|()| ExitCode::SUCCESS), + } => { + let fv = resolve_format_version(format_version, json, sc); + run_diff(a, b, entry, json, fv, limit, quiet, color, sc).map(|()| ExitCode::SUCCESS) + } Commands::Packages(ref args) => run_packages(args, color, sc).map(|()| ExitCode::SUCCESS), @@ -264,6 +302,7 @@ fn run(command: Commands, no_color: bool, sc: report::StderrColor) -> Result Result { let start = Instant::now(); + let fv = resolve_format_version(args.format_version, args.json, sc); // Validate mutually exclusive flags before loading graph let query_flags: Vec<&str> = [ @@ -314,7 +353,7 @@ fn run_trace(args: TraceArgs, color: bool, sc: report::StderrColor) -> Result Result Result Result Result t) { let kind = if args.include_dynamic { @@ -401,6 +441,7 @@ fn handle_trace_diff( no_cache: bool, limit: i32, json: bool, + fv: report::FormatVersion, color: bool, sc: report::StderrColor, ) -> Result<(), Error> { @@ -429,7 +470,7 @@ fn handle_trace_diff( let diff_output = query::diff_snapshots(&result.to_snapshot(entry_rel), &diff_snapshot); let report = report::DiffReport::from_diff(&diff_output, entry_rel, &diff_snapshot.entry, limit); - report::emit(json, || report.to_json(), || report.to_terminal(color)); + report::emit(json, || report.to_json_versioned(fv), || report.to_terminal(color)); Ok(()) } @@ -454,6 +495,7 @@ fn run_packages(args: &PackagesArgs, color: bool, sc: report::StderrColor) -> Re if args.top < -1 { return Err(Error::InvalidTopValue("--top", args.top)); } + let fv = resolve_format_version(args.format_version, args.json, sc); let start = Instant::now(); let session = Session::open(&args.entry, args.no_cache)?; if !args.quiet { @@ -461,7 +503,7 @@ fn run_packages(args: &PackagesArgs, color: bool, sc: report::StderrColor) -> Re } let report = session.packages_report(args.top); - report::emit(args.json, || report.to_json(), || report.to_terminal(color)); + report::emit(args.json, || report.to_json_versioned(fv), || report.to_terminal(color)); Ok(()) } @@ -514,6 +556,7 @@ fn run_diff( b: Option, entry: Option, json: bool, + fv: report::FormatVersion, limit: i32, quiet: bool, color: bool, @@ -556,13 +599,13 @@ fn run_diff( let wt_snap = build_snapshot_from_working_tree(entry_path, quiet, sc)?; let wt_label = wt_snap.entry.clone(); return finish_diff( - &snap_a, &label_a, &wt_snap, &wt_label, json, limit, color, start, quiet, sc, + &snap_a, &label_a, &wt_snap, &wt_label, json, fv, limit, color, start, quiet, sc, ); } }; finish_diff( - &snap_a, &label_a, &snap_b, &label_b, json, limit, color, start, quiet, sc, + &snap_a, &label_a, &snap_b, &label_b, json, fv, limit, color, start, quiet, sc, ) } @@ -573,6 +616,7 @@ fn finish_diff( snap_b: &query::TraceSnapshot, label_b: &str, json: bool, + fv: report::FormatVersion, limit: i32, color: bool, start: Instant, @@ -581,7 +625,7 @@ fn finish_diff( ) -> Result<(), Error> { let diff_output = query::diff_snapshots(snap_a, snap_b); let report = report::DiffReport::from_diff(&diff_output, label_a, label_b, limit); - report::emit(json, || report.to_json(), || report.to_terminal(color)); + report::emit(json, || report.to_json_versioned(fv), || report.to_terminal(color)); if !quiet { eprintln!( "\n{} in {:.1}ms", diff --git a/src/report.rs b/src/report.rs index c4d037e..9a2b5c3 100644 --- a/src/report.rs +++ b/src/report.rs @@ -437,13 +437,13 @@ impl TraceReport { pub fn to_json_versioned(&self, version: FormatVersion) -> String { let mut value = serde_json::to_value(self).unwrap(); - if version == FormatVersion::V1 { - if let Some(pkgs) = value["heavy_packages"].as_array_mut() { - for pkg in pkgs { - if let Some(obj) = pkg.as_object_mut() { - obj.remove("edge_kinds"); - obj.remove("classification"); - } + if version == FormatVersion::V1 + && let Some(pkgs) = value["heavy_packages"].as_array_mut() + { + for pkg in pkgs { + if let Some(obj) = pkg.as_object_mut() { + obj.remove("edge_kinds"); + obj.remove("classification"); } } } From e0556c8ef0facc3486f9c7c4b7d58d0d2578057c Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Thu, 23 Apr 2026 01:20:46 -0700 Subject: [PATCH 3/4] test: add format-version integration tests for V1 and V2 schemas --- tests/json_roundtrip.rs | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/json_roundtrip.rs b/tests/json_roundtrip.rs index e9e1b4e..de21f97 100644 --- a/tests/json_roundtrip.rs +++ b/tests/json_roundtrip.rs @@ -1,6 +1,7 @@ mod common; use chainsaw::query::TraceOptions; +use chainsaw::report::FormatVersion; use chainsaw::session::Session; /// Parse JSON, assert a field exists with the expected type. @@ -153,3 +154,50 @@ fn packages_report_json_schema() { assert_no_field(pkg, "size"); assert_no_field(pkg, "files"); } + +// --- FormatVersion round-trips --- + +#[test] +fn chain_report_format_version_1() { + let (_p, session) = open(); + let report = session.chain_report("lodash", false); + let json: serde_json::Value = + serde_json::from_str(&report.to_json_versioned(FormatVersion::V1)).unwrap(); + assert_eq!(json["format_version"], 1); + let chain = &json["chains"][0]; + assert!(chain.is_array(), "V1 chains should be string arrays"); + assert!(chain[0].is_string()); +} + +#[test] +fn chain_report_format_version_2() { + let (_p, session) = open(); + let report = session.chain_report("lodash", false); + let json: serde_json::Value = + serde_json::from_str(&report.to_json_versioned(FormatVersion::V2)).unwrap(); + assert_eq!(json["format_version"], 2); + let chain = &json["chains"][0]; + assert!(chain.is_object(), "V2 chains should be annotated objects"); + 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()); +} + +#[test] +fn trace_report_format_version_1_no_edge_fields() { + let (_p, mut session) = open(); + let report = session.trace_report( + &TraceOptions::default(), + chainsaw::report::DEFAULT_TOP_MODULES, + ); + let json: serde_json::Value = + serde_json::from_str(&report.to_json_versioned(FormatVersion::V1)).unwrap(); + assert_eq!(json["format_version"], 1); + if let Some(pkg) = json["heavy_packages"].as_array().and_then(|a| a.first()) { + assert!(pkg.get("edge_kinds").is_none(), "V1 should omit edge_kinds"); + assert!( + pkg.get("classification").is_none(), + "V1 should omit classification" + ); + } +} From 7c5be5fc0026d5455f5c23f9f6b4ecfcb870fea5 Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Thu, 23 Apr 2026 01:21:04 -0700 Subject: [PATCH 4/4] style: cargo fmt --- src/main.rs | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index a25abe1..09603ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -353,7 +353,11 @@ fn run_trace(args: TraceArgs, color: bool, sc: report::StderrColor) -> Result Result Result Result t) { let kind = if args.include_dynamic { @@ -470,7 +486,11 @@ fn handle_trace_diff( let diff_output = query::diff_snapshots(&result.to_snapshot(entry_rel), &diff_snapshot); let report = report::DiffReport::from_diff(&diff_output, entry_rel, &diff_snapshot.entry, limit); - report::emit(json, || report.to_json_versioned(fv), || report.to_terminal(color)); + report::emit( + json, + || report.to_json_versioned(fv), + || report.to_terminal(color), + ); Ok(()) } @@ -503,7 +523,11 @@ fn run_packages(args: &PackagesArgs, color: bool, sc: report::StderrColor) -> Re } let report = session.packages_report(args.top); - report::emit(args.json, || report.to_json_versioned(fv), || report.to_terminal(color)); + report::emit( + args.json, + || report.to_json_versioned(fv), + || report.to_terminal(color), + ); Ok(()) } @@ -625,7 +649,11 @@ fn finish_diff( ) -> Result<(), Error> { let diff_output = query::diff_snapshots(snap_a, snap_b); let report = report::DiffReport::from_diff(&diff_output, label_a, label_b, limit); - report::emit(json, || report.to_json_versioned(fv), || report.to_terminal(color)); + report::emit( + json, + || report.to_json_versioned(fv), + || report.to_terminal(color), + ); if !quiet { eprintln!( "\n{} in {:.1}ms",