From bc32ddfbd3d4a159859abc477cdfc0068e4ba4f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 Aug 2025 03:39:16 +0200 Subject: [PATCH] Add comprehensive bundler graph visualizer for debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements GraphVisualizer that dumps complete LinkerContext state to JSON - Captures files, symbols, imports/exports, chunks, parts, and dependency graph - Controlled via BUN_BUNDLER_GRAPH_DUMP environment variable (all/scan/chunks/compute/link) - Uses proper bun.json.toAST and js_printer.printJSON for correct JSON serialization - Enhanced json.toAST to support custom toExprForJSON methods and BabyList-like types - Includes interactive D3.js HTML visualizer with multiple views - Helps debug duplicate exports, circular dependencies, and bundling issues - Outputs to /tmp/bun-bundler-debug/ with timestamped files Usage: BUN_BUNDLER_GRAPH_DUMP=all bun build file.js 🤖 Generated with Claude Code Co-Authored-By: Claude --- cmake/sources/ZigSources.txt | 1 + src/bundler/LinkerContext.zig | 29 + src/bundler/graph_visualizer.html | 990 ++++++++++++++++++++++++++++++ src/bundler/graph_visualizer.zig | 551 +++++++++++++++++ src/interchange/json.zig | 25 +- 5 files changed, 1594 insertions(+), 2 deletions(-) create mode 100644 src/bundler/graph_visualizer.html create mode 100644 src/bundler/graph_visualizer.zig diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index 8e903797ee..cb0b2a0c93 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -414,6 +414,7 @@ src/bundler/BundleThread.zig src/bundler/Chunk.zig src/bundler/DeferredBatchTask.zig src/bundler/entry_points.zig +src/bundler/graph_visualizer.zig src/bundler/Graph.zig src/bundler/HTMLImportManifest.zig src/bundler/linker_context/computeChunks.zig diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 8ccbc6bc58..093bce1732 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -4,6 +4,7 @@ pub const LinkerContext = struct { pub const OutputFileListBuilder = @import("./linker_context/OutputFileListBuilder.zig"); pub const StaticRouteVisitor = @import("./linker_context/StaticRouteVisitor.zig"); + pub const GraphVisualizer = @import("./graph_visualizer.zig").GraphVisualizer; parse_graph: *Graph = undefined, graph: LinkerGraph = undefined, @@ -392,6 +393,13 @@ pub const LinkerContext = struct { } try this.scanImportsAndExports(); + + // Dump graph state after scan + if (comptime Environment.isDebug) { + GraphVisualizer.dumpGraphState(this, "after_scan", null) catch |err| { + debug("Failed to dump graph after scan: {}", .{err}); + }; + } // Stop now if there were errors if (this.log.hasErrors()) { @@ -409,18 +417,39 @@ pub const LinkerContext = struct { } const chunks = try this.computeChunks(bundle.unique_key); + + // Dump graph state after computing chunks + if (comptime Environment.isDebug) { + GraphVisualizer.dumpGraphState(this, "after_chunks", chunks) catch |err| { + debug("Failed to dump graph after chunks: {}", .{err}); + }; + } if (comptime FeatureFlags.help_catch_memory_issues) { this.checkForMemoryCorruption(); } try this.computeCrossChunkDependencies(chunks); + + // Dump graph state after computing dependencies + if (comptime Environment.isDebug) { + GraphVisualizer.dumpGraphState(this, "after_compute", chunks) catch |err| { + debug("Failed to dump graph after compute: {}", .{err}); + }; + } if (comptime FeatureFlags.help_catch_memory_issues) { this.checkForMemoryCorruption(); } this.graph.symbols.followAll(); + + // Final dump after linking + if (comptime Environment.isDebug) { + GraphVisualizer.dumpGraphState(this, "after_link", chunks) catch |err| { + debug("Failed to dump graph after link: {}", .{err}); + }; + } return chunks; } diff --git a/src/bundler/graph_visualizer.html b/src/bundler/graph_visualizer.html new file mode 100644 index 0000000000..bfad0c2f3a --- /dev/null +++ b/src/bundler/graph_visualizer.html @@ -0,0 +1,990 @@ + + + + + + Bun Bundler Graph Visualizer + + + + + + + +
+ + +
+
+ + + + + + + +
+
+
+ Entry Point +
+
+
+ Has Exports +
+
+
+ CSS File +
+
+
+ Dynamic Import +
+
+
+
+
Select a node to view details...
+
+
+
+ + + + \ No newline at end of file diff --git a/src/bundler/graph_visualizer.zig b/src/bundler/graph_visualizer.zig new file mode 100644 index 0000000000..8ee07ae141 --- /dev/null +++ b/src/bundler/graph_visualizer.zig @@ -0,0 +1,551 @@ +const std = @import("std"); +const bun = @import("bun"); +const string = bun.string; +const Output = bun.Output; +const Global = bun.Global; +const Environment = bun.Environment; +const strings = bun.strings; +const MutableString = bun.MutableString; +const stringZ = bun.stringZ; +const default_allocator = bun.default_allocator; +const C = bun.C; +const JSC = bun.JSC; +const js_ast = bun.ast; +const bundler = bun.bundle_v2; +const Index = js_ast.Index; +const Ref = js_ast.Ref; +const Symbol = js_ast.Symbol; +const ImportRecord = bun.ImportRecord; +const DeclaredSymbol = js_ast.DeclaredSymbol; +const logger = bun.logger; +const Part = js_ast.Part; +const Chunk = bundler.Chunk; +const js_printer = bun.js_printer; +const JSON = bun.json; +const JSAst = bun.ast; + +pub const GraphVisualizer = struct { + const debug = Output.scoped(.GraphViz, .visible); + + pub fn shouldDump() bool { + if (comptime !Environment.isDebug) return false; + return bun.getenvZ("BUN_BUNDLER_GRAPH_DUMP") != null; + } + + pub fn getDumpStage() DumpStage { + const env_val = bun.getenvZ("BUN_BUNDLER_GRAPH_DUMP") orelse return .none; + + if (strings.eqlComptime(env_val, "all")) return .all; + if (strings.eqlComptime(env_val, "scan")) return .after_scan; + if (strings.eqlComptime(env_val, "compute")) return .after_compute; + if (strings.eqlComptime(env_val, "chunks")) return .after_chunks; + if (strings.eqlComptime(env_val, "link")) return .after_link; + + return .all; // Default to all if set but not recognized + } + + pub const DumpStage = enum { + none, + after_scan, + after_compute, + after_chunks, + after_link, + all, + }; + + pub fn dumpGraphState( + ctx: *bundler.LinkerContext, + stage: []const u8, + chunks: ?[]const Chunk, + ) !void { + if (!shouldDump()) return; + + const dump_stage = getDumpStage(); + const should_dump_now = switch (dump_stage) { + .none => false, + .all => true, + .after_scan => strings.eqlComptime(stage, "after_scan"), + .after_compute => strings.eqlComptime(stage, "after_compute"), + .after_chunks => strings.eqlComptime(stage, "after_chunks"), + .after_link => strings.eqlComptime(stage, "after_link"), + }; + + if (!should_dump_now) return; + + debug("Dumping graph state: {s}", .{stage}); + + var arena = std.heap.ArenaAllocator.init(default_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // Create output directory + const output_dir = "/tmp/bun-bundler-debug"; + std.fs.cwd().makePath(output_dir) catch |err| { + debug("Failed to create output directory: {}", .{err}); + return; + }; + + // Generate filename with timestamp + const timestamp = std.time.milliTimestamp(); + const filename = try std.fmt.allocPrint(allocator, "{s}/bundler_graph_{s}_{d}.json", .{ + output_dir, + stage, + timestamp, + }); + + // Build the graph data structure + const graph_data = try buildGraphData(ctx, allocator, stage, timestamp, chunks); + + // Convert to JSON AST + const json_ast = try JSON.toAST(allocator, GraphData, graph_data); + + // Print JSON to buffer + var stack_fallback = std.heap.stackFallback(1024 * 1024, allocator); // 1MB stack fallback + const print_allocator = stack_fallback.get(); + + const buffer_writer = js_printer.BufferWriter.init(print_allocator); + var writer = js_printer.BufferPrinter.init(buffer_writer); + defer writer.ctx.buffer.deinit(); + + const source = &logger.Source.initEmptyFile(filename); + _ = js_printer.printJSON( + *js_printer.BufferPrinter, + &writer, + json_ast, + source, + .{ .mangled_props = null }, + ) catch |err| { + debug("Failed to print JSON: {}", .{err}); + return; + }; + + // Write to file + const file = try std.fs.cwd().createFile(filename, .{}); + defer file.close(); + try file.writeAll(writer.ctx.buffer.list.items); + + debug("Graph dump written to: {s}", .{filename}); + + // Also generate the visualizer HTML + try generateVisualizerHTML(allocator, output_dir, timestamp); + } + + const GraphData = struct { + stage: []const u8, + timestamp: i64, + metadata: Metadata, + files: []FileData, + symbols: SymbolData, + entry_points: []EntryPointData, + imports_and_exports: ImportsExports, + chunks: ?[]ChunkData, + dependency_graph: DependencyGraph, + }; + + const Metadata = struct { + total_files: usize, + reachable_files: usize, + entry_points: usize, + code_splitting: bool, + output_format: []const u8, + target: []const u8, + tree_shaking: bool, + minify: bool, + }; + + const FileData = struct { + index: usize, + path: []const u8, + loader: []const u8, + source_length: usize, + entry_point_kind: []const u8, + part_count: usize, + parts: ?[]PartData, + named_exports_count: usize, + named_imports_count: usize, + flags: FileFlags, + }; + + const FileFlags = struct { + is_async: bool, + needs_exports_variable: bool, + needs_synthetic_default_export: bool, + wrap: []const u8, + }; + + const PartData = struct { + index: usize, + stmt_count: usize, + import_record_count: usize, + declared_symbol_count: usize, + can_be_removed_if_unused: bool, + force_tree_shaking: bool, + symbol_uses: []SymbolUse, + dependencies: []PartDependency, + }; + + const SymbolUse = struct { + ref: []const u8, + count: u32, + }; + + const PartDependency = struct { + source: u32, + part: u32, + }; + + const SymbolData = struct { + total_symbols: usize, + by_source: []SourceSymbols, + }; + + const SourceSymbols = struct { + source_index: usize, + symbol_count: usize, + symbols: []SymbolInfo, + }; + + const SymbolInfo = struct { + inner_index: usize, + kind: []const u8, + original_name: []const u8, + link: ?[]const u8, + }; + + const EntryPointData = struct { + source_index: u32, + output_path: []const u8, + }; + + const ImportsExports = struct { + total_exports: usize, + total_imports: usize, + total_import_records: usize, + exports: []ExportInfo, + imports: []ImportInfo, + }; + + const ExportInfo = struct { + source: u32, + name: []const u8, + ref: []const u8, + }; + + const ImportInfo = struct { + source: u32, + kind: []const u8, + path: []const u8, + target_source: ?u32, + }; + + const ChunkData = struct { + index: usize, + is_entry_point: bool, + source_index: u32, + files_in_chunk: []u32, + cross_chunk_import_count: usize, + }; + + const DependencyGraph = struct { + edges: []GraphEdge, + }; + + const GraphEdge = struct { + from: NodeRef, + to: NodeRef, + }; + + const NodeRef = struct { + source: u32, + part: u32, + }; + + fn buildGraphData( + ctx: *bundler.LinkerContext, + allocator: std.mem.Allocator, + stage: []const u8, + timestamp: i64, + chunks: ?[]const Chunk, + ) !GraphData { + const sources = ctx.parse_graph.input_files.items(.source); + const loaders = ctx.parse_graph.input_files.items(.loader); + const ast_list = ctx.graph.ast.slice(); + const meta_list = ctx.graph.meta.slice(); + const files_list = ctx.graph.files.slice(); + + // Build metadata + const metadata = Metadata{ + .total_files = ctx.graph.files.len, + .reachable_files = ctx.graph.reachable_files.len, + .entry_points = ctx.graph.entry_points.len, + .code_splitting = ctx.graph.code_splitting, + .output_format = @tagName(ctx.options.output_format), + .target = @tagName(ctx.options.target), + .tree_shaking = ctx.options.tree_shaking, + .minify = ctx.options.minify_syntax, + }; + + // Build file data + var file_data_list = try allocator.alloc(FileData, ctx.graph.files.len); + for (0..ctx.graph.files.len) |i| { + var parts_data: ?[]PartData = null; + + if (i < ast_list.items(.parts).len) { + const parts = ast_list.items(.parts)[i].slice(); + if (parts.len > 0) { + parts_data = try allocator.alloc(PartData, parts.len); + for (parts, 0..) |part, j| { + // Build symbol uses + var symbol_uses = try allocator.alloc(SymbolUse, part.symbol_uses.count()); + var use_idx: usize = 0; + var use_iter = part.symbol_uses.iterator(); + while (use_iter.next()) |entry| : (use_idx += 1) { + symbol_uses[use_idx] = .{ + .ref = try std.fmt.allocPrint(allocator, "{}", .{entry.key_ptr.*}), + .count = entry.value_ptr.count_estimate, + }; + } + + // Build dependencies + var deps = try allocator.alloc(PartDependency, part.dependencies.len); + for (part.dependencies.slice(), 0..) |dep, k| { + deps[k] = .{ + .source = dep.source_index.get(), + .part = dep.part_index, + }; + } + + parts_data.?[j] = .{ + .index = j, + .stmt_count = part.stmts.len, + .import_record_count = part.import_record_indices.len, + .declared_symbol_count = part.declared_symbols.entries.len, + .can_be_removed_if_unused = part.can_be_removed_if_unused, + .force_tree_shaking = part.force_tree_shaking, + .symbol_uses = symbol_uses, + .dependencies = deps, + }; + } + } + } + + const path = if (i < sources.len) sources[i].path.text else "unknown"; + const loader = if (i < loaders.len) @tagName(loaders[i]) else "unknown"; + const entry_point_kind = @tagName(files_list.items(.entry_point_kind)[i]); + + var flags = FileFlags{ + .is_async = false, + .needs_exports_variable = false, + .needs_synthetic_default_export = false, + .wrap = "none", + }; + + if (i < meta_list.items(.flags).len) { + const meta_flags = meta_list.items(.flags)[i]; + flags = .{ + .is_async = meta_flags.is_async_or_has_async_dependency, + .needs_exports_variable = meta_flags.needs_exports_variable, + .needs_synthetic_default_export = meta_flags.needs_synthetic_default_export, + .wrap = @tagName(meta_flags.wrap), + }; + } + + const named_exports_count = if (i < ast_list.items(.named_exports).len) + ast_list.items(.named_exports)[i].count() else 0; + const named_imports_count = if (i < ast_list.items(.named_imports).len) + ast_list.items(.named_imports)[i].count() else 0; + const part_count = if (i < ast_list.items(.parts).len) + ast_list.items(.parts)[i].len else 0; + + file_data_list[i] = .{ + .index = i, + .path = path, + .loader = loader, + .source_length = if (i < sources.len) sources[i].contents.len else 0, + .entry_point_kind = entry_point_kind, + .part_count = part_count, + .parts = parts_data, + .named_exports_count = named_exports_count, + .named_imports_count = named_imports_count, + .flags = flags, + }; + } + + // Build symbol data + var by_source = try allocator.alloc(SourceSymbols, ctx.graph.symbols.symbols_for_source.len); + var total_symbols: usize = 0; + for (ctx.graph.symbols.symbols_for_source.slice(), 0..) |symbols, source_idx| { + total_symbols += symbols.len; + + var symbol_infos = try allocator.alloc(SymbolInfo, symbols.len); + for (symbols.slice(), 0..) |symbol, j| { + symbol_infos[j] = .{ + .inner_index = j, + .kind = @tagName(symbol.kind), + .original_name = symbol.original_name, + .link = if (symbol.link.isValid()) + try std.fmt.allocPrint(allocator, "{}", .{symbol.link}) + else null, + }; + } + + by_source[source_idx] = .{ + .source_index = source_idx, + .symbol_count = symbols.len, + .symbols = symbol_infos, + }; + } + + const symbol_data = SymbolData{ + .total_symbols = total_symbols, + .by_source = by_source, + }; + + // Build entry points + const entry_points = ctx.graph.entry_points.slice(); + var entry_point_data = try allocator.alloc(EntryPointData, entry_points.len); + for (entry_points.items(.source_index), entry_points.items(.output_path), 0..) |source_idx, output_path, i| { + entry_point_data[i] = .{ + .source_index = source_idx, + .output_path = output_path.slice(), + }; + } + + // Build imports and exports + const ast_named_exports = ast_list.items(.named_exports); + const ast_named_imports = ast_list.items(.named_imports); + const import_records_list = ast_list.items(.import_records); + + var total_exports: usize = 0; + var total_imports: usize = 0; + var total_import_records: usize = 0; + + // Count totals + for (ast_named_exports) |exports| { + total_exports += exports.count(); + } + for (ast_named_imports) |imports| { + total_imports += imports.count(); + } + for (import_records_list) |records| { + total_import_records += records.len; + } + + // Collect all exports + var exports_list = try std.ArrayList(ExportInfo).initCapacity(allocator, @min(total_exports, 1000)); + for (ast_named_exports, 0..) |exports, source_idx| { + if (exports.count() == 0) continue; + + var iter = exports.iterator(); + while (iter.next()) |entry| { + if (exports_list.items.len >= 1000) break; // Limit for performance + + try exports_list.append(.{ + .source = @intCast(source_idx), + .name = entry.key_ptr.*, + .ref = try std.fmt.allocPrint(allocator, "{}", .{entry.value_ptr.ref}), + }); + } + if (exports_list.items.len >= 1000) break; + } + + // Collect all imports + var imports_list = try std.ArrayList(ImportInfo).initCapacity(allocator, @min(total_import_records, 1000)); + for (import_records_list, 0..) |records, source_idx| { + if (records.len == 0) continue; + + for (records.slice()[0..@min(records.len, 100)]) |record| { + if (imports_list.items.len >= 1000) break; // Limit for performance + + try imports_list.append(.{ + .source = @intCast(source_idx), + .kind = @tagName(record.kind), + .path = record.path.text, + .target_source = if (record.source_index.isValid()) record.source_index.get() else null, + }); + } + if (imports_list.items.len >= 1000) break; + } + + const imports_exports = ImportsExports{ + .total_exports = total_exports, + .total_imports = total_imports, + .total_import_records = total_import_records, + .exports = exports_list.items, + .imports = imports_list.items, + }; + + // Build chunks data + var chunks_data: ?[]ChunkData = null; + if (chunks) |chunk_list| { + chunks_data = try allocator.alloc(ChunkData, chunk_list.len); + for (chunk_list, 0..) |chunk, i| { + // Collect files in chunk + var files_in_chunk = try allocator.alloc(u32, chunk.files_with_parts_in_chunk.count()); + var file_iter = chunk.files_with_parts_in_chunk.iterator(); + var j: usize = 0; + while (file_iter.next()) |entry| : (j += 1) { + files_in_chunk[j] = entry.key_ptr.*; + } + + chunks_data.?[i] = .{ + .index = i, + .is_entry_point = chunk.entry_point.is_entry_point, + .source_index = chunk.entry_point.source_index, + .files_in_chunk = files_in_chunk, + .cross_chunk_import_count = chunk.cross_chunk_imports.len, + }; + } + } + + // Build dependency graph + const parts_lists = ast_list.items(.parts); + var edges = try std.ArrayList(GraphEdge).initCapacity(allocator, 1000); + + for (parts_lists, 0..) |parts, source_idx| { + for (parts.slice(), 0..) |part, part_idx| { + for (part.dependencies.slice()) |dep| { + if (edges.items.len >= 1000) break; // Limit for performance + + try edges.append(.{ + .from = .{ .source = @intCast(source_idx), .part = @intCast(part_idx) }, + .to = .{ .source = dep.source_index.get(), .part = dep.part_index }, + }); + } + if (edges.items.len >= 1000) break; + } + if (edges.items.len >= 1000) break; + } + + const dependency_graph = DependencyGraph{ + .edges = edges.items, + }; + + return GraphData{ + .stage = stage, + .timestamp = timestamp, + .metadata = metadata, + .files = file_data_list, + .symbols = symbol_data, + .entry_points = entry_point_data, + .imports_and_exports = imports_exports, + .chunks = chunks_data, + .dependency_graph = dependency_graph, + }; + } + + fn generateVisualizerHTML(allocator: std.mem.Allocator, output_dir: []const u8, timestamp: i64) !void { + const html_content = @embedFile("./graph_visualizer.html"); + + const filename = try std.fmt.allocPrint(allocator, "{s}/visualizer_{d}.html", .{ + output_dir, + timestamp, + }); + + const file = try std.fs.cwd().createFile(filename, .{}); + defer file.close(); + try file.writeAll(html_content); + + debug("Visualizer HTML written to: {s}", .{filename}); + } +}; \ No newline at end of file diff --git a/src/interchange/json.zig b/src/interchange/json.zig index 3109d8c600..1f6b8007fe 100644 --- a/src/interchange/json.zig +++ b/src/interchange/json.zig @@ -488,6 +488,16 @@ pub fn toAST( value: Type, ) anyerror!js_ast.Expr { const type_info: std.builtin.Type = @typeInfo(Type); + + // Check if type has custom toExprForJSON method (only for structs, unions, and enums) + switch (type_info) { + .@"struct", .@"union", .@"enum" => { + if (comptime @hasDecl(Type, "toExprForJSON")) { + return try Type.toExprForJSON(&value, allocator); + } + }, + else => {}, + } switch (type_info) { .bool => { @@ -536,7 +546,7 @@ pub fn toAST( const exprs = try allocator.alloc(Expr, value.len); for (exprs, 0..) |*ex, i| ex.* = try toAST(allocator, @TypeOf(value[i]), value[i]); - return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = exprs }, logger.Loc.Empty); + return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = .init(exprs) }, logger.Loc.Empty); }, else => @compileError("Unable to stringify type '" ++ @typeName(T) ++ "'"), }, @@ -548,9 +558,20 @@ pub fn toAST( const exprs = try allocator.alloc(Expr, value.len); for (exprs, 0..) |*ex, i| ex.* = try toAST(allocator, @TypeOf(value[i]), value[i]); - return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = exprs }, logger.Loc.Empty); + return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = .init(exprs) }, logger.Loc.Empty); }, .@"struct" => |Struct| { + // Check if struct has a slice() method - treat it as an array + if (comptime @hasField(Type, "ptr") and @hasField(Type, "len")) { + // This looks like it might be array-like, check for slice method + if (comptime @hasDecl(Type, "slice")) { + const slice = value.slice(); + const exprs = try allocator.alloc(Expr, slice.len); + for (exprs, 0..) |*ex, i| ex.* = try toAST(allocator, @TypeOf(slice[i]), slice[i]); + return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = .init(exprs) }, logger.Loc.Empty); + } + } + const fields: []const std.builtin.Type.StructField = Struct.fields; var properties = try allocator.alloc(js_ast.G.Property, fields.len); var property_i: usize = 0;