Skip to content

Commit d325bcf

Browse files
zackeesclaude
andauthored
feat(bloat): graphviz .dot back-reference graphs (#463) (#468)
Closes #463. Part 2 of #459: takes the `referenced_by` data shipped in #460 and renders it as Graphviz `.dot` back-reference graphs. Three surfaces: 1. **`fbuild bloat graph <input> --symbol <S>`** — new CLI subcommand that walks the back-reference graph outward from one symbol and writes `.dot` to stdout or `-o <path>`. Reuses `fbuild symbols`' toolchain resolution (#428). 2. **Embedded `dot` blocks in `report.md`** — when `--output-dir` is given, the top-10 symbols each carry an inline fenced ```dot block under a `<details>` summary. AI-friendly: a fresh agent answers "what pulls in X?" from `report.md` alone, no extra fetches. `--no-graph` disables embedding. 3. **Sidecar `<output-dir>/graphs/*.dot`** — every symbol > 256 B gets a standalone file. Ranked + sanitized names (`0001_<symbol>.dot`) for ad-hoc `dot -Tsvg` rendering. ## Walker design Pure module in `fbuild_core::symbol_analysis::graph`. BFS outward from the root symbol's `referenced_by` list, then for each referencing TU find every symbol defined there and merge their back-references. Three termination predicates compose: - **Adaptive (default):** stop when a branch leaves the root symbol's archive — the boundary where the symbol "escapes" its own library, which is what bloat analysts want. - **Fan-out cap K=5:** per node, rank referencers by attributed flash bytes desc, keep top-K, collapse the rest into a `(… and N more)` super-node. - **Hard depth cap N=4:** safety belt for adaptive. Plus archive-level filters: - `--collapse-archive libc.a,libgcc.a` (default) hides the libc internal-wrapper layer so non-libc callers stand out. - `--exclude-archive ...` drops branches entirely. ## Rendering - Nodes colored by archive group (FastLED yellow, ESP-IDF blue, mbedTLS purple, libc gray, app near-white). - Node width sized by `log10(bytes)` so huge symbols stay big without dominating layout. - Edges: `referencer -> referenced` (the "pull" direction). ## Tests - `fbuild-core::symbol_analysis::graph::tests` (9): walker termination, fan-out overflow, exclude/collapse, .dot serialization, sanitization helpers. - `fbuild-build::symbol_analyzer::tests` (4 new): markdown embedding, legacy path stays clean, sidecar file emission, sidecar disabled. - `fbuild-cli::cli::graph_cmd::tests` (5): config parsing. All 50+ symbol-analysis tests pass. clippy + fmt clean. ## CLI shape Adds a new top-level `Bloat` subcommand parent for `graph`. The existing `fbuild symbols` keeps its surface (still emits the same report.json + report.md without graphs unless --output-dir is set; embedded graphs are default-on when output-dir is given, off via --no-graph). #434 can later add `Bloat::Report` as a sibling to land the long-discussed `symbols` -> `bloat` rename without disturbing this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bbfbba7 commit d325bcf

9 files changed

Lines changed: 1774 additions & 7 deletions

File tree

crates/fbuild-build/src/symbol_analyzer.rs

Lines changed: 380 additions & 1 deletion
Large diffs are not rendered by default.

crates/fbuild-cli/src/cli/args.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,46 @@ pub enum Commands {
118118
/// markdown report.
119119
#[arg(long, default_value = "25")]
120120
top: usize,
121+
/// Skip embedded Graphviz `.dot` blocks in `report.md` and
122+
/// don't write sidecar `graphs/*.dot` files. Use when a slim
123+
/// report is preferred (e.g. CI bloat-budget gates that diff
124+
/// only the JSON).
125+
#[arg(long = "no-graph")]
126+
no_graph: bool,
127+
/// How many top symbols get an embedded back-reference graph
128+
/// in `report.md` (default 10, capped by `--top`).
129+
#[arg(long = "graph-top", default_value = "10")]
130+
graph_top: usize,
131+
/// Minimum size in bytes for a sidecar `.dot` file to be
132+
/// written under `<output-dir>/graphs/`. Default 256 keeps
133+
/// the per-symbol output to non-trivial contributors.
134+
#[arg(long = "graph-min-bytes", default_value = "256")]
135+
graph_min_bytes: u64,
136+
/// Traversal depth: `adaptive` stops the first time a branch
137+
/// leaves the root archive; `<N>` forces an exact hop count.
138+
#[arg(long = "graph-depth", default_value = "adaptive")]
139+
graph_depth: String,
140+
/// Per-node fan-out cap (default 5). Excess referencers
141+
/// collapse into a single `(… and N more)` super-node.
142+
#[arg(long = "graph-fan-out", default_value = "5")]
143+
graph_fan_out: usize,
144+
/// Comma-separated archive list to collapse into per-archive
145+
/// super-nodes. Default `libc.a,libgcc.a` hides the libc
146+
/// internal-wrapper layer so non-libc callers stand out.
147+
#[arg(long = "graph-collapse-archive", default_value = "libc.a,libgcc.a")]
148+
graph_collapse_archive: String,
149+
/// Comma-separated archive list to drop from the graph
150+
/// entirely. Default empty.
151+
#[arg(long = "graph-exclude-archive", default_value = "")]
152+
graph_exclude_archive: String,
153+
},
154+
/// Bloat-related subcommands. Today this hosts `graph` (back-
155+
/// reference Graphviz export, fbuild #463); when #434 lands the
156+
/// report-rename, the existing `fbuild symbols` becomes
157+
/// `fbuild bloat` and lives here as a sibling subcommand.
158+
Bloat {
159+
#[command(subcommand)]
160+
cmd: BloatCmd,
121161
},
122162
/// Build firmware
123163
Build {
@@ -558,6 +598,61 @@ pub enum DaemonAction {
558598
},
559599
}
560600

601+
/// Subcommands under `fbuild bloat`. The parent enum lives so future
602+
/// `bloat`-themed actions (report, diff, budget gate) cohabit cleanly;
603+
/// today only `graph` ships — see fbuild #463.
604+
#[derive(Subcommand)]
605+
pub enum BloatCmd {
606+
/// Render a Graphviz `.dot` back-reference graph rooted at one
607+
/// symbol. Walks the `referenced_by` data emitted by
608+
/// `fbuild symbols` (#459) outward, applying cross-archive
609+
/// termination + per-node fan-out caps + collapse-archive rules
610+
/// so dense hubs like `printf` stay readable.
611+
Graph {
612+
/// ELF file OR project directory (same resolution as
613+
/// `fbuild symbols`).
614+
input: String,
615+
/// Target symbol (mangled OR demangled — fbuild matches on
616+
/// either).
617+
#[arg(long, short = 's')]
618+
symbol: String,
619+
/// Path to the linker map (auto-detected if omitted).
620+
#[arg(long)]
621+
map: Option<String>,
622+
/// Cross-toolchain `nm` (auto-resolved when omitted).
623+
#[arg(long)]
624+
nm: Option<String>,
625+
/// Cross-toolchain `c++filt` (derived from `nm` stem when
626+
/// omitted).
627+
#[arg(long = "cppfilt")]
628+
cppfilt: Option<String>,
629+
/// Path to a `build_info.json` that carries toolchain paths.
630+
#[arg(long = "build-info")]
631+
build_info: Option<String>,
632+
/// Output path for the `.dot` file. Defaults to stdout.
633+
#[arg(short = 'o', long = "output")]
634+
output: Option<String>,
635+
/// Traversal depth: `adaptive` (default) or `<N>` for a fixed
636+
/// hop count.
637+
#[arg(long, default_value = "adaptive")]
638+
depth: String,
639+
/// Per-node fan-out cap (excess collapses into a `(… and N
640+
/// more)` super-node).
641+
#[arg(long = "fan-out", default_value = "5")]
642+
fan_out: usize,
643+
/// Hard cap on traversal depth (safety belt for adaptive).
644+
#[arg(long = "max-depth", default_value = "4")]
645+
max_depth: u32,
646+
/// Comma-separated archive list to collapse into per-archive
647+
/// super-nodes.
648+
#[arg(long = "collapse-archive", default_value = "libc.a,libgcc.a")]
649+
collapse_archive: String,
650+
/// Comma-separated archive list to drop from the graph.
651+
#[arg(long = "exclude-archive", default_value = "")]
652+
exclude_archive: String,
653+
},
654+
}
655+
561656
/// Resolve project_dir: prefer the subcommand's value, fall back to the top-level positional arg,
562657
/// then default to ".". This lets callers write either `fbuild build <dir>` or `fbuild <dir> build`.
563658
pub fn resolve_project_dir(
@@ -587,6 +682,8 @@ pub const KNOWN_SUBCOMMANDS: &[&str] = &[
587682
"lib-select",
588683
"compile-many",
589684
"ci",
685+
"symbols",
686+
"bloat",
590687
];
591688

592689
/// Rewrite `fbuild <dir> <subcommand> ...` → `fbuild <subcommand> <dir> ...`

crates/fbuild-cli/src/cli/dispatch.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use clap::Parser;
55

66
use crate::{daemon_client, lib_select, mcp};
77

8-
use super::args::{resolve_project_dir, rewrite_args, Cli, Commands};
8+
use super::args::{resolve_project_dir, rewrite_args, BloatCmd, Cli, Commands};
99
use super::build::run_build;
1010
use super::clang_tools::{run_clang_tool, run_iwyu};
1111
use super::compile_many::{
@@ -14,6 +14,7 @@ use super::compile_many::{
1414
use super::daemon_cmd::run_daemon;
1515
use super::deploy::{run_deploy, run_monitor, run_test_emu};
1616
use super::device::run_device;
17+
use super::graph_cmd::run_bloat_graph;
1718
use super::lnk::run_lnk;
1819
use super::monitor_parse::parse_monitor_flags;
1920
use super::pio::{pio_build, pio_deploy, pio_monitor};
@@ -65,7 +66,59 @@ pub async fn async_main() {
6566
json,
6667
output_dir,
6768
top,
68-
}) => run_symbols(input, map, nm, cppfilt, build_info, json, output_dir, top),
69+
no_graph,
70+
graph_top,
71+
graph_min_bytes,
72+
graph_depth,
73+
graph_fan_out,
74+
graph_collapse_archive,
75+
graph_exclude_archive,
76+
}) => run_symbols(
77+
input,
78+
map,
79+
nm,
80+
cppfilt,
81+
build_info,
82+
json,
83+
output_dir,
84+
top,
85+
no_graph,
86+
graph_top,
87+
graph_min_bytes,
88+
graph_depth,
89+
graph_fan_out,
90+
graph_collapse_archive,
91+
graph_exclude_archive,
92+
),
93+
Some(Commands::Bloat { cmd }) => match cmd {
94+
BloatCmd::Graph {
95+
input,
96+
symbol,
97+
map,
98+
nm,
99+
cppfilt,
100+
build_info,
101+
output,
102+
depth,
103+
fan_out,
104+
max_depth,
105+
collapse_archive,
106+
exclude_archive,
107+
} => run_bloat_graph(
108+
input,
109+
symbol,
110+
map,
111+
nm,
112+
cppfilt,
113+
build_info,
114+
output,
115+
depth,
116+
fan_out,
117+
max_depth,
118+
collapse_archive,
119+
exclude_archive,
120+
),
121+
},
69122
Some(Commands::Build {
70123
project_dir,
71124
environment,
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//! `fbuild bloat graph` — render a back-reference graph for one
2+
//! symbol as Graphviz `.dot`.
3+
//!
4+
//! Consumes the same toolchain resolution as `fbuild symbols` (#428)
5+
//! so callers don't have to wire `--nm` / `--cppfilt` twice. Walks
6+
//! the symbol's transitive `referenced_by` data outward using the
7+
//! adaptive strategy documented in
8+
//! [`fbuild_core::symbol_analysis::graph`].
9+
//!
10+
//! Output goes either to `-o <path>` or stdout. The walker is pure
11+
//! (no I/O); this module is only the CLI glue.
12+
13+
use std::path::PathBuf;
14+
15+
use fbuild_build::symbol_analyzer::{
16+
analyze_elf, default_map_path, discover_elf_in_project, AnalyzeConfig,
17+
};
18+
use fbuild_core::symbol_analysis::{BackrefGraph, GraphConfig, GraphDepth};
19+
use fbuild_core::{FbuildError, Result};
20+
21+
use super::symbols_cmd::resolve_tool_paths_public;
22+
23+
#[allow(clippy::too_many_arguments)]
24+
pub fn run_bloat_graph(
25+
input: String,
26+
symbol: String,
27+
map: Option<String>,
28+
nm: Option<String>,
29+
cppfilt: Option<String>,
30+
build_info: Option<String>,
31+
output: Option<String>,
32+
depth: String,
33+
fan_out: usize,
34+
max_depth: u32,
35+
collapse_archive: String,
36+
exclude_archive: String,
37+
) -> Result<()> {
38+
let input_path = PathBuf::from(&input);
39+
if !input_path.exists() {
40+
return Err(FbuildError::BuildFailed(format!(
41+
"input not found: {}",
42+
input_path.display()
43+
)));
44+
}
45+
let elf_path = if input_path.is_dir() {
46+
discover_elf_in_project(&input_path).ok_or_else(|| {
47+
FbuildError::BuildFailed(format!("no ELF found under {}", input_path.display()))
48+
})?
49+
} else {
50+
input_path
51+
};
52+
53+
let (nm_path, cppfilt_path) = resolve_tool_paths_public(
54+
&elf_path,
55+
nm.as_deref(),
56+
cppfilt.as_deref(),
57+
build_info.as_deref(),
58+
)?;
59+
60+
let map_path_owned = map
61+
.map(PathBuf::from)
62+
.or_else(|| default_map_path(&elf_path));
63+
64+
let cfg = AnalyzeConfig {
65+
elf_path: &elf_path,
66+
map_path: map_path_owned.as_deref(),
67+
nm_path: &nm_path,
68+
cppfilt_path: cppfilt_path.as_deref(),
69+
};
70+
let report = analyze_elf(cfg)?;
71+
72+
let graph_config = parse_graph_config(
73+
&depth,
74+
fan_out,
75+
max_depth,
76+
&collapse_archive,
77+
&exclude_archive,
78+
)?;
79+
let graph = BackrefGraph::build(&report, &symbol, &graph_config);
80+
let dot = graph.to_dot();
81+
82+
match output {
83+
Some(path) => {
84+
std::fs::write(&path, &dot).map_err(|e| {
85+
FbuildError::Io(std::io::Error::new(e.kind(), format!("write {path}: {e}")))
86+
})?;
87+
println!(
88+
"Wrote back-reference graph for {symbol} to {path} ({} nodes, {} edges)",
89+
graph.nodes.len(),
90+
graph.edges.len()
91+
);
92+
}
93+
None => {
94+
print!("{dot}");
95+
}
96+
}
97+
Ok(())
98+
}
99+
100+
/// Parse the user-facing flag strings into a fully-populated
101+
/// [`GraphConfig`]. Public-ish helper because the report-embed path
102+
/// in `symbols_cmd.rs` parses the same flag shapes for the
103+
/// `--graph-*` set.
104+
pub fn parse_graph_config(
105+
depth: &str,
106+
fan_out: usize,
107+
max_depth: u32,
108+
collapse_archive: &str,
109+
exclude_archive: &str,
110+
) -> Result<GraphConfig> {
111+
let depth = match depth.trim().to_ascii_lowercase().as_str() {
112+
"adaptive" | "auto" | "" => GraphDepth::Adaptive,
113+
s => match s.parse::<u32>() {
114+
Ok(n) => GraphDepth::Fixed(n),
115+
Err(_) => {
116+
return Err(FbuildError::BuildFailed(format!(
117+
"graph --depth: expected 'adaptive' or a non-negative \
118+
integer, got `{s}`"
119+
)))
120+
}
121+
},
122+
};
123+
let collapse_archives: Vec<String> = split_archive_list(collapse_archive);
124+
let exclude_archives: Vec<String> = split_archive_list(exclude_archive);
125+
Ok(GraphConfig {
126+
depth,
127+
fan_out: fan_out.max(1),
128+
max_depth: max_depth.max(1),
129+
collapse_archives,
130+
exclude_archives,
131+
})
132+
}
133+
134+
fn split_archive_list(s: &str) -> Vec<String> {
135+
s.split(',')
136+
.map(|x| x.trim())
137+
.filter(|x| !x.is_empty())
138+
.map(|x| x.to_string())
139+
.collect()
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn parse_graph_config_adaptive_default() {
148+
let c = parse_graph_config("adaptive", 5, 4, "libc.a,libgcc.a", "").unwrap();
149+
assert!(matches!(c.depth, GraphDepth::Adaptive));
150+
assert_eq!(c.fan_out, 5);
151+
assert_eq!(c.max_depth, 4);
152+
assert_eq!(c.collapse_archives, vec!["libc.a", "libgcc.a"]);
153+
assert!(c.exclude_archives.is_empty());
154+
}
155+
156+
#[test]
157+
fn parse_graph_config_fixed_depth() {
158+
let c = parse_graph_config("3", 5, 4, "", "").unwrap();
159+
assert!(matches!(c.depth, GraphDepth::Fixed(3)));
160+
}
161+
162+
#[test]
163+
fn parse_graph_config_rejects_garbage_depth() {
164+
let err = parse_graph_config("woof", 5, 4, "", "").unwrap_err();
165+
let msg = format!("{err}");
166+
assert!(msg.contains("graph --depth"), "got: {msg}");
167+
}
168+
169+
#[test]
170+
fn split_archive_list_handles_spaces_and_empties() {
171+
let v = split_archive_list("libc.a, libgcc.a ,, libm.a");
172+
assert_eq!(v, vec!["libc.a", "libgcc.a", "libm.a"]);
173+
}
174+
175+
#[test]
176+
fn fan_out_zero_clamps_to_one() {
177+
let c = parse_graph_config("adaptive", 0, 4, "", "").unwrap();
178+
assert_eq!(c.fan_out, 1);
179+
}
180+
}

crates/fbuild-cli/src/cli/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod daemon_cmd;
1717
pub mod deploy;
1818
pub mod device;
1919
pub mod dispatch;
20+
pub mod graph_cmd;
2021
pub mod lnk;
2122
pub mod monitor_parse;
2223
pub mod pio;

0 commit comments

Comments
 (0)