From aded701d1d2f89663a114c22ef6550153c9b23e4 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 28 Jan 2026 18:01:39 -0800 Subject: [PATCH] feat(build): add --metafile-md CLI option for LLM-friendly bundle analysis (#26441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `--metafile-md` CLI option to `bun build` that generates a markdown visualization of the module graph - Designed to help Claude and other LLMs analyze bundle composition, identify bloat, and understand dependency chains - Reuses existing metafile JSON generation code as a post-processing step ## Features The generated markdown includes: 1. **Quick Summary** - Module counts, sizes, ESM/CJS breakdown, output/input ratio 2. **Largest Input Files** - Sorted by size to identify potential bloat 3. **Entry Point Analysis** - Shows bundle size, exports, CSS bundles, and bundled modules 4. **Dependency Chains** - Most commonly imported modules and reverse dependencies 5. **Full Module Graph** - Complete import/export info for each module 6. **Raw Data for Searching** - Grep-friendly markers in code blocks: - `[MODULE:]`, `[SIZE:]`, `[IMPORT:]`, `[IMPORTED_BY:]` - `[ENTRY:]`, `[EXTERNAL:]`, `[NODE_MODULES:]` ## Usage ```bash # Default filename (meta.md) bun build entry.js --metafile-md --outdir=dist # Custom filename bun build entry.js --metafile-md=analysis.md --outdir=dist # Both JSON and markdown bun build entry.js --metafile=meta.json --metafile-md=meta.md --outdir=dist ``` ## Example Output See sample output: https://gist.github.com/example (will add) ## Test plan - [x] Test default filename (`meta.md`) - [x] Test custom filename - [x] Test both `--metafile` and `--metafile-md` together - [x] Test summary metrics - [x] Test module format info (ESM/CJS) - [x] Test external imports - [x] Test exports list - [x] Test bundled modules table - [x] Test CSS bundle reference - [x] Test import kinds (static, dynamic, require) - [x] Test commonly imported modules - [x] Test largest files sorting (bloat analysis) - [x] Test output/input ratio - [x] Test grep-friendly raw data section - [x] Test entry point markers - [x] Test external import markers - [x] Test node_modules markers All 17 new tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot Co-authored-by: Claude Opus 4.5 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Dylan Conway --- meta.json | 24 + src/bake/production.zig | 1 + src/bun.js/api/JSBundler.zig | 37 +- src/bun.js/bindings/BundlerMetafile.cpp | 32 +- src/bundler/LinkerContext.zig | 4 + src/bundler/bundle_v2.zig | 117 ++- .../linker_context/MetafileBuilder.zig | 731 ++++++++++++++++++ src/cli.zig | 1 + src/cli/Arguments.zig | 9 + src/cli/build_command.zig | 35 +- src/js/builtins/BunBuiltinNames.h | 1 + src/options.zig | 4 + test/bundler/metafile.test.ts | 722 ++++++++++++++++- 13 files changed, 1676 insertions(+), 42 deletions(-) create mode 100644 meta.json diff --git a/meta.json b/meta.json new file mode 100644 index 0000000000..682daccfce --- /dev/null +++ b/meta.json @@ -0,0 +1,24 @@ +{ + "inputs": { + "../../tmp/test-entry.js": { + "bytes": 21, + "imports": [ + ], + "format": "esm" + } + }, + "outputs": { + "./test-entry.js": { + "bytes": 49, + "inputs": { + "../../tmp/test-entry.js": { + "bytesInOutput": 22 + } + }, + "imports": [ + ], + "exports": [], + "entryPoint": "../../tmp/test-entry.js" + } + } +} diff --git a/src/bake/production.zig b/src/bake/production.zig index 3c18ac7d95..bacdb61dd1 100644 --- a/src/bake/production.zig +++ b/src/bake/production.zig @@ -433,6 +433,7 @@ pub fn buildWithVm(ctx: bun.cli.Command.Context, cwd: []const u8, vm: *VirtualMa .asset => {}, .bytecode => {}, .sourcemap => {}, + .@"metafile-json", .@"metafile-markdown" => {}, } }, } diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index 633139a074..af8ba45e79 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -242,6 +242,10 @@ pub const JSBundler = struct { bytecode: bool = false, banner: OwnedString = OwnedString.initEmpty(bun.default_allocator), footer: OwnedString = OwnedString.initEmpty(bun.default_allocator), + /// Path to write JSON metafile (if specified via metafile object) - TEST: moved here + metafile_json_path: OwnedString = OwnedString.initEmpty(bun.default_allocator), + /// Path to write markdown metafile (if specified via metafile object) - TEST: moved here + metafile_markdown_path: OwnedString = OwnedString.initEmpty(bun.default_allocator), css_chunking: bool = false, drop: bun.StringSet = bun.StringSet.init(bun.default_allocator), features: bun.StringSet = bun.StringSet.init(bun.default_allocator), @@ -254,6 +258,7 @@ pub const JSBundler = struct { /// In-memory files that can be used as entrypoints or imported. /// These files do not need to exist on disk. files: FileMap = .{}, + /// Generate metafile (JSON module graph) metafile: bool = false, pub const CompileOptions = struct { @@ -936,8 +941,30 @@ pub const JSBundler = struct { this.throw_on_error = flag; } - if (try config.getBooleanLoose(globalThis, "metafile")) |flag| { - this.metafile = flag; + // Parse metafile option: boolean | string | { json?: string, markdown?: string } + if (try config.getOwn(globalThis, "metafile")) |metafile_value| { + if (metafile_value.isBoolean()) { + this.metafile = metafile_value == .true; + } else if (metafile_value.isString()) { + // metafile: "path/to/meta.json" - shorthand for { json: "..." } + this.metafile = true; + const slice = try metafile_value.toSlice(globalThis, bun.default_allocator); + defer slice.deinit(); + try this.metafile_json_path.appendSliceExact(slice.slice()); + } else if (metafile_value.isObject()) { + // metafile: { json?: string, markdown?: string } + this.metafile = true; + if (try metafile_value.getOptional(globalThis, "json", ZigString.Slice)) |slice| { + defer slice.deinit(); + try this.metafile_json_path.appendSliceExact(slice.slice()); + } + if (try metafile_value.getOptional(globalThis, "markdown", ZigString.Slice)) |slice| { + defer slice.deinit(); + try this.metafile_markdown_path.appendSliceExact(slice.slice()); + } + } else if (!metafile_value.isUndefinedOrNull()) { + return globalThis.throwInvalidArguments("Expected metafile to be a boolean, string, or object with json/markdown paths", .{}); + } } if (try CompileOptions.fromJS( @@ -1070,6 +1097,8 @@ pub const JSBundler = struct { self.footer.deinit(); self.tsconfig_override.deinit(); self.files.deinitAndUnprotect(); + self.metafile_json_path.deinit(); + self.metafile_markdown_path.deinit(); } }; @@ -1688,9 +1717,11 @@ pub const BuildArtifact = struct { @"entry-point", sourcemap, bytecode, + @"metafile-json", + @"metafile-markdown", pub fn isFileInStandaloneMode(this: OutputKind) bool { - return this != .sourcemap and this != .bytecode; + return this != .sourcemap and this != .bytecode and this != .@"metafile-json" and this != .@"metafile-markdown"; } }; diff --git a/src/bun.js/bindings/BundlerMetafile.cpp b/src/bun.js/bindings/BundlerMetafile.cpp index 68fbfdb037..c21e641a36 100644 --- a/src/bun.js/bindings/BundlerMetafile.cpp +++ b/src/bun.js/bindings/BundlerMetafile.cpp @@ -1,10 +1,13 @@ /** - * Lazy getter for BuildOutput.metafile that parses JSON on first access. + * Lazy getter for BuildOutput.metafile that returns the parsed JSON directly. * Uses CustomValue so the parsed result replaces the getter. + * + * For backward compatibility, result.metafile returns the parsed JSON object directly + * (with inputs/outputs properties), not wrapped in { json: ... }. */ #include "root.h" -#include "BunBuiltinNames.h" +#include "BunClientData.h" #include "ZigGlobalObject.h" #include @@ -15,6 +18,7 @@ namespace Bun { using namespace JSC; +// Lazy getter for metafile property - returns parsed JSON directly for backward compatibility JSC_DEFINE_CUSTOM_GETTER(bundlerMetafileLazyGetter, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName property)) { auto& vm = JSC::getVM(globalObject); @@ -26,9 +30,11 @@ JSC_DEFINE_CUSTOM_GETTER(bundlerMetafileLazyGetter, (JSGlobalObject * globalObje } // Get the raw JSON string from private property - const auto& privateName = Bun::builtinNames(vm).dataPrivateName(); + const auto& privateName = WebCore::builtinNames(vm).metafileJsonPrivateName(); JSValue metafileStringValue = thisObject->getDirect(vm, privateName); - ASSERT(metafileStringValue.isString()); + if (!metafileStringValue || !metafileStringValue.isString()) { + return JSValue::encode(jsUndefined()); + } auto str = metafileStringValue.toString(globalObject); RETURN_IF_EXCEPTION(scope, {}); @@ -49,17 +55,25 @@ JSC_DEFINE_CUSTOM_GETTER(bundlerMetafileLazyGetter, (JSGlobalObject * globalObje } // Helper to set up the lazy metafile on a BuildOutput object -extern "C" SYSV_ABI void Bun__setupLazyMetafile(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue buildOutputEncoded, JSC::EncodedJSValue metafileStringEncoded) +// For backward compatibility, metafile is the parsed JSON directly (not wrapped in { json: ... }) +extern "C" SYSV_ABI void Bun__setupLazyMetafile( + JSC::JSGlobalObject* globalObject, + JSC::EncodedJSValue buildOutputEncoded, + JSC::EncodedJSValue metafileJsonStringEncoded, + JSC::EncodedJSValue metafileMarkdownStringEncoded) { auto& vm = JSC::getVM(globalObject); JSObject* buildOutput = JSValue::decode(buildOutputEncoded).getObject(); ASSERT(buildOutput); - // Store the raw JSON string in a private property - const auto& privateName = Bun::builtinNames(vm).dataPrivateName(); - buildOutput->putDirect(vm, privateName, JSValue::decode(metafileStringEncoded), 0); + JSValue metafileJsonString = JSValue::decode(metafileJsonStringEncoded); + // metafileMarkdownString is currently unused for backward compatibility + // (we only set the JSON on result.metafile directly) + (void)metafileMarkdownStringEncoded; - // Set up the lazy getter + // Store raw JSON string in private property on buildOutput and set up lazy getter for "metafile" + // This returns the parsed JSON directly for backward compatibility with esbuild API + buildOutput->putDirect(vm, WebCore::builtinNames(vm).metafileJsonPrivateName(), metafileJsonString, 0); buildOutput->putDirectCustomAccessor( vm, Identifier::fromString(vm, "metafile"_s), diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 5020967092..3fba3de7e6 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -71,6 +71,10 @@ pub const LinkerContext = struct { source_maps: options.SourceMapOption = .none, target: options.Target = .browser, metafile: bool = false, + /// Path to write JSON metafile (for Bun.build API) + metafile_json_path: []const u8 = "", + /// Path to write markdown metafile (for Bun.build API) + metafile_markdown_path: []const u8 = "", mode: Mode = .bundle, diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index e8b93bb4d4..78e3fd1189 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -972,6 +972,8 @@ pub const BundleV2 = struct { this.linker.options.output_format = transpiler.options.output_format; this.linker.options.generate_bytecode_cache = transpiler.options.bytecode; this.linker.options.metafile = transpiler.options.metafile; + this.linker.options.metafile_json_path = transpiler.options.metafile_json_path; + this.linker.options.metafile_markdown_path = transpiler.options.metafile_markdown_path; this.linker.dev_server = transpiler.options.dev_server; @@ -1593,12 +1595,13 @@ pub const BundleV2 = struct { return .{ .output_files = std.array_list.Managed(options.OutputFile).init(alloc), .metafile = null, + .metafile_markdown = null, }; } const output_files = try this.linker.generateChunksInParallel(chunks, false); - // Generate metafile if requested + // Generate metafile if requested (CLI writes files in build_command.zig) const metafile: ?[]const u8 = if (this.linker.options.metafile) LinkerContext.MetafileBuilder.generate(bun.default_allocator, &this.linker, chunks) catch |err| blk: { bun.Output.warn("Failed to generate metafile: {s}", .{@errorName(err)}); @@ -1607,9 +1610,11 @@ pub const BundleV2 = struct { else null; + // Markdown is generated later in build_command.zig for CLI return .{ .output_files = output_files, .metafile = metafile, + .metafile_markdown = null, }; } @@ -1823,6 +1828,7 @@ pub const BundleV2 = struct { pub const BuildResult = struct { output_files: std.array_list.Managed(options.OutputFile), metafile: ?[]const u8 = null, + metafile_markdown: ?[]const u8 = null, pub fn deinit(this: *BuildResult) void { for (this.output_files.items) |*output_file| { @@ -1835,6 +1841,11 @@ pub const BundleV2 = struct { bun.default_allocator.free(mf); this.metafile = null; } + + if (this.metafile_markdown) |md| { + bun.default_allocator.free(md); + this.metafile_markdown = null; + } } }; @@ -1984,6 +1995,8 @@ pub const BundleV2 = struct { transpiler.options.footer = config.footer.slice(); transpiler.options.react_fast_refresh = config.react_fast_refresh; transpiler.options.metafile = config.metafile; + transpiler.options.metafile_json_path = config.metafile_json_path.slice(); + transpiler.options.metafile_markdown_path = config.metafile_markdown_path.slice(); if (transpiler.options.compile) { // Emitting DCE annotations is nonsensical in --compile. @@ -2295,13 +2308,20 @@ pub const BundleV2 = struct { }, ); - // Add metafile if it was generated (lazy parsing via getter) + // Add metafile if it was generated + // metafile: { json: , markdown?: string } if (build.metafile) |metafile| { const metafile_js_str = bun.String.createUTF8ForJS(globalThis, metafile) catch |err| { return promise.reject(globalThis, err); }; - // Set up lazy getter that parses JSON on first access and memoizes - Bun__setupLazyMetafile(globalThis, build_output, metafile_js_str); + const metafile_md_str: jsc.JSValue = if (build.metafile_markdown) |md| + (bun.String.createUTF8ForJS(globalThis, md) catch |err| { + return promise.reject(globalThis, err); + }) + else + .js_undefined; + // Set up metafile object with json (lazy) and markdown (if present) + Bun__setupLazyMetafile(globalThis, build_output, metafile_js_str, metafile_md_str); } const didHandleCallbacks = if (this.plugins) |plugin| runOnEndCallbacks(globalThis, plugin, promise, build_output, .js_undefined) catch |err| { @@ -2738,7 +2758,7 @@ pub const BundleV2 = struct { return error.BuildFailed; } - const output_files = try this.linker.generateChunksInParallel(chunks, false); + var output_files = try this.linker.generateChunksInParallel(chunks, false); // Generate metafile if requested const metafile: ?[]const u8 = if (this.linker.options.metafile) @@ -2749,12 +2769,96 @@ pub const BundleV2 = struct { else null; + // Generate markdown if metafile was generated and path specified + const metafile_markdown: ?[]const u8 = if (this.linker.options.metafile_markdown_path.len > 0 and metafile != null) + LinkerContext.MetafileBuilder.generateMarkdown(bun.default_allocator, metafile.?) catch |err| blk: { + bun.Output.warn("Failed to generate metafile markdown: {s}", .{@errorName(err)}); + break :blk null; + } + else + null; + + // Write metafile outputs to disk and add them as OutputFiles. + // Metafile paths are relative to outdir, like all other output files. + const outdir = this.linker.resolver.opts.output_dir; + if (this.linker.options.metafile_json_path.len > 0) { + if (metafile) |mf| { + try writeMetafileOutput(&output_files, outdir, this.linker.options.metafile_json_path, mf, .@"metafile-json"); + } + } + if (this.linker.options.metafile_markdown_path.len > 0) { + if (metafile_markdown) |md| { + try writeMetafileOutput(&output_files, outdir, this.linker.options.metafile_markdown_path, md, .@"metafile-markdown"); + } + } + return .{ .output_files = output_files, .metafile = metafile, + .metafile_markdown = metafile_markdown, }; } + /// Writes a metafile (JSON or markdown) to disk and appends it to the output_files list. + /// Metafile paths are relative to outdir, like all other output files. + fn writeMetafileOutput( + output_files: *std.array_list.Managed(options.OutputFile), + outdir: []const u8, + file_path: []const u8, + content: []const u8, + output_kind: jsc.API.BuildArtifact.OutputKind, + ) !void { + if (outdir.len > 0) { + // Open the output directory + var root_dir = bun.FD.cwd().stdDir().makeOpenPath(outdir, .{}) catch |err| { + bun.Output.warn("Failed to open output directory '{s}': {s}", .{ outdir, @errorName(err) }); + return; + }; + defer root_dir.close(); + + // Create parent directories if needed (relative to outdir) + if (std.fs.path.dirname(file_path)) |parent| { + if (parent.len > 0) { + root_dir.makePath(parent) catch {}; + } + } + + // Write to disk relative to outdir + var path_buf: bun.PathBuffer = undefined; + _ = jsc.Node.fs.NodeFS.writeFileWithPathBuffer(&path_buf, .{ + .data = .{ .buffer = .{ + .buffer = .{ + .ptr = @constCast(content.ptr), + .len = @as(u32, @truncate(content.len)), + .byte_len = @as(u32, @truncate(content.len)), + }, + } }, + .encoding = .buffer, + .mode = 0o644, + .dirfd = bun.FD.fromStdDir(root_dir), + .file = .{ .path = .{ + .string = bun.PathString.init(file_path), + } }, + }).unwrap() catch |err| { + bun.Output.warn("Failed to write metafile to '{s}': {s}", .{ file_path, @errorName(err) }); + }; + } + + // Add as OutputFile so it appears in result.outputs + const is_json = output_kind == .@"metafile-json"; + try output_files.append(options.OutputFile.init(.{ + .loader = if (is_json) .json else .file, + .input_loader = if (is_json) .json else .file, + .input_path = bun.handleOom(bun.default_allocator.dupe(u8, if (is_json) "metafile.json" else "metafile.md")), + .output_path = bun.handleOom(bun.default_allocator.dupe(u8, file_path)), + .data = .{ .saved = content.len }, + .output_kind = output_kind, + .is_executable = false, + .side = null, + .entry_point_index = null, + })); + } + fn shouldAddWatcherPlugin(bv2: *BundleV2, namespace: []const u8, path: []const u8) bool { return bun.strings.eqlComptime(namespace, "file") and std.fs.path.isAbsolute(path) and @@ -4721,7 +4825,8 @@ const string = []const u8; // C++ binding for lazy metafile getter (defined in BundlerMetafile.cpp) // Uses jsc.conv (SYSV_ABI on Windows x64) for proper calling convention -extern "C" fn Bun__setupLazyMetafile(globalThis: *jsc.JSGlobalObject, buildOutput: jsc.JSValue, metafileString: jsc.JSValue) callconv(jsc.conv) void; +// Sets up metafile object with { json: , markdown?: string } +extern "C" fn Bun__setupLazyMetafile(globalThis: *jsc.JSGlobalObject, buildOutput: jsc.JSValue, metafileJsonString: jsc.JSValue, metafileMarkdownString: jsc.JSValue) callconv(jsc.conv) void; const options = @import("../options.zig"); diff --git a/src/bundler/linker_context/MetafileBuilder.zig b/src/bundler/linker_context/MetafileBuilder.zig index f502e5f827..36ad57dc45 100644 --- a/src/bundler/linker_context/MetafileBuilder.zig +++ b/src/bundler/linker_context/MetafileBuilder.zig @@ -344,6 +344,737 @@ fn writeJSONString(writer: anytype, str: []const u8) !void { try writer.print("{f}", .{bun.fmt.formatJSONStringUTF8(str, .{})}); } +/// Generates a markdown visualization of the module graph from metafile JSON. +/// This is a post-processing step that parses the JSON and produces LLM-friendly output. +/// Designed to help diagnose bundle bloat, dependency chains, and entry point analysis. +/// The caller is responsible for freeing the returned slice. +pub fn generateMarkdown(allocator: std.mem.Allocator, metafile_json: []const u8) ![]const u8 { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, metafile_json, .{}) catch { + return error.InvalidJSON; + }; + defer parsed.deinit(); + + const root = parsed.value; + if (root != .object) return error.InvalidJSON; + + var md = std.array_list.Managed(u8).init(allocator); + errdefer md.deinit(); + const writer = md.writer(); + + // Get inputs and outputs + const inputs = root.object.get("inputs") orelse return error.InvalidJSON; + const outputs = root.object.get("outputs") orelse return error.InvalidJSON; + + if (inputs != .object or outputs != .object) return error.InvalidJSON; + + // Header + try writer.writeAll("# Bundle Analysis Report\n\n"); + try writer.writeAll("This report helps identify bundle size issues, dependency bloat, and optimization opportunities.\n\n"); + + // Table of Contents for easy navigation + try writer.writeAll("## Table of Contents\n\n"); + try writer.writeAll("- [Quick Summary](#quick-summary)\n"); + try writer.writeAll("- [Largest Modules by Output Contribution](#largest-modules-by-output-contribution)\n"); + try writer.writeAll("- [Entry Point Analysis](#entry-point-analysis)\n"); + try writer.writeAll("- [Dependency Chains](#dependency-chains)\n"); + try writer.writeAll("- [Full Module Graph](#full-module-graph)\n"); + try writer.writeAll("- [Raw Data for Searching](#raw-data-for-searching)\n\n"); + try writer.writeAll("---\n\n"); + + // ==================== SUMMARY ==================== + try writer.writeAll("## Quick Summary\n\n"); + + var total_output_bytes: u64 = 0; + var esm_count: u32 = 0; + var cjs_count: u32 = 0; + var json_count: u32 = 0; + var external_count: u32 = 0; + var node_modules_count: u32 = 0; + var node_modules_bytes: u64 = 0; + + // Build a map of module path -> bytesInOutput (bytes contributed to output) + // This aggregates from all outputs since a module may appear in multiple chunks + var bytes_in_output = bun.StringHashMap(u64).init(allocator); + defer bytes_in_output.deinit(); + + // First pass through outputs to collect bytesInOutput for each module + var output_iter_first = outputs.object.iterator(); + while (output_iter_first.next()) |out_entry| { + const output = out_entry.value_ptr.*; + if (output != .object) continue; + + if (output.object.get("inputs")) |output_inputs| { + if (output_inputs == .object) { + var oi_iter = output_inputs.object.iterator(); + while (oi_iter.next()) |oi_entry| { + const module_path = oi_entry.key_ptr.*; + const module_info = oi_entry.value_ptr.*; + if (module_info == .object) { + if (module_info.object.get("bytesInOutput")) |bio| { + if (bio == .integer) { + const bytes_val: u64 = @intCast(bio.integer); + const gop = try bytes_in_output.getOrPut(module_path); + if (gop.found_existing) { + gop.value_ptr.* += bytes_val; + } else { + gop.value_ptr.* = bytes_val; + } + } + } + } + } + } + } + } + + // Build reverse dependency map: who imports each file? + // Also collect input file data for sorting + const InputFileInfo = struct { + path: []const u8, + bytes_in_output: u64, + import_count: u32, + is_node_modules: bool, + format: []const u8, + }; + + var input_files: std.ArrayListUnmanaged(InputFileInfo) = .{}; + defer input_files.deinit(allocator); + + var imported_by = bun.StringHashMap(std.ArrayListUnmanaged([]const u8)).init(allocator); + defer { + var it = imported_by.valueIterator(); + while (it.next()) |list| { + list.deinit(allocator); + } + imported_by.deinit(); + } + + // Second pass: collect all input file info and build reverse dependency map + var input_iter = inputs.object.iterator(); + while (input_iter.next()) |entry| { + const path = entry.key_ptr.*; + const input = entry.value_ptr.*; + if (input != .object) continue; + + const is_node_modules = std.mem.indexOf(u8, path, "node_modules") != null; + const module_bytes = bytes_in_output.get(path) orelse 0; + + var info = InputFileInfo{ + .path = path, + .bytes_in_output = module_bytes, + .import_count = 0, + .is_node_modules = is_node_modules, + .format = "", + }; + + if (is_node_modules) { + node_modules_bytes += module_bytes; + node_modules_count += 1; + } + + if (input.object.get("format")) |format| { + if (format == .string) { + info.format = format.string; + if (std.mem.eql(u8, format.string, "esm")) { + esm_count += 1; + } else if (std.mem.eql(u8, format.string, "cjs")) { + cjs_count += 1; + } else if (std.mem.eql(u8, format.string, "json")) { + json_count += 1; + } + } + } + + // Build reverse dependency map + if (input.object.get("imports")) |imps| { + if (imps == .array) { + info.import_count = @intCast(imps.array.items.len); + for (imps.array.items) |imp| { + if (imp == .object) { + if (imp.object.get("external")) |ext| { + if (ext == .bool and ext.bool) { + external_count += 1; + continue; + } + } + if (imp.object.get("path")) |imp_path| { + if (imp_path == .string) { + // Try to find the matching input key for this import + // The import path may be absolute while input keys are relative + // Or it may be a relative path like "../utils/logger.js" + const target = imp_path.string; + + // First, try exact match + var matched_key: ?[]const u8 = null; + if (inputs.object.contains(target)) { + matched_key = target; + } else { + // Try matching by basename or suffix + var key_iter = inputs.object.iterator(); + while (key_iter.next()) |key_entry| { + const input_key = key_entry.key_ptr.*; + // Check if target ends with the input key + if (std.mem.endsWith(u8, target, input_key)) { + // Make sure it's a path boundary (preceded by / or \ or start) + if (target.len == input_key.len or + (target.len > input_key.len and (target[target.len - input_key.len - 1] == '/' or target[target.len - input_key.len - 1] == '\\'))) + { + matched_key = input_key; + break; + } + } + // Also check if input_key ends with target (for relative paths) + // e.g., target="../utils/logger.js" might match "src/utils/logger.js" + if (std.mem.indexOf(u8, target, "..") != null) { + // This is a relative path, try matching just the filename parts + const target_base = std.fs.path.basename(target); + const key_base = std.fs.path.basename(input_key); + if (std.mem.eql(u8, target_base, key_base)) { + // Check if paths share common suffix + const target_without_dots = stripParentRefs(target); + if (std.mem.endsWith(u8, input_key, target_without_dots)) { + matched_key = input_key; + break; + } + } + } + } + } + + if (matched_key) |key| { + const gop = try imported_by.getOrPut(key); + if (!gop.found_existing) { + gop.value_ptr.* = .{}; + } + try gop.value_ptr.append(allocator, path); + } + } + } + } + } + } + } + + try input_files.append(allocator, info); + } + + // Count outputs and entry points + var entry_point_count: u32 = 0; + var chunk_count: u32 = 0; + var output_iter = outputs.object.iterator(); + while (output_iter.next()) |entry| { + if (entry.value_ptr.* == .object) { + if (entry.value_ptr.object.get("bytes")) |bytes| { + if (bytes == .integer) { + total_output_bytes += @intCast(bytes.integer); + } + } + if (entry.value_ptr.object.get("entryPoint")) |_| { + entry_point_count += 1; + } else { + chunk_count += 1; + } + } + } + + // Summary table + try writer.writeAll("| Metric | Value |\n"); + try writer.writeAll("|--------|-------|\n"); + try writer.print("| Total output size | {f} |\n", .{bun.fmt.size(total_output_bytes, .{})}); + try writer.print("| Input modules | {d} |\n", .{inputs.object.count()}); + if (entry_point_count > 0) { + try writer.print("| Entry points | {d} |\n", .{entry_point_count}); + } + if (chunk_count > 0) { + try writer.print("| Code-split chunks | {d} |\n", .{chunk_count}); + } + if (node_modules_count > 0) { + try writer.print("| node_modules contribution | {d} files ({f}) |\n", .{ node_modules_count, bun.fmt.size(node_modules_bytes, .{}) }); + } + if (esm_count > 0) try writer.print("| ESM modules | {d} |\n", .{esm_count}); + if (cjs_count > 0) try writer.print("| CommonJS modules | {d} |\n", .{cjs_count}); + if (json_count > 0) try writer.print("| JSON files | {d} |\n", .{json_count}); + if (external_count > 0) try writer.print("| External imports | {d} |\n", .{external_count}); + + // ==================== LARGEST MODULES (BLOAT ANALYSIS) ==================== + try writer.writeAll("\n## Largest Modules by Output Contribution\n\n"); + try writer.writeAll("Modules sorted by bytes contributed to the output bundle. Large modules may indicate bloat.\n\n"); + + // Sort by bytes_in_output descending + std.mem.sort(InputFileInfo, input_files.items, {}, struct { + fn lessThan(_: void, a: InputFileInfo, b: InputFileInfo) bool { + return a.bytes_in_output > b.bytes_in_output; + } + }.lessThan); + + try writer.writeAll("| Output Bytes | % of Total | Module | Format |\n"); + try writer.writeAll("|--------------|------------|--------|--------|\n"); + + const max_to_show: usize = 20; + for (input_files.items, 0..) |info, i| { + if (i >= max_to_show) break; + if (info.bytes_in_output == 0) break; // Skip modules with no output contribution + const pct = if (total_output_bytes > 0) + @as(f64, @floatFromInt(info.bytes_in_output)) / @as(f64, @floatFromInt(total_output_bytes)) * 100.0 + else + 0.0; + try writer.print("| {f} | {d:.1}% | `{s}` | {s} |\n", .{ + bun.fmt.size(info.bytes_in_output, .{}), + pct, + info.path, + if (info.format.len > 0) info.format else "-", + }); + } + + // Count remaining modules with non-zero contribution + var remaining_count: usize = 0; + if (input_files.items.len > max_to_show) { + for (input_files.items[max_to_show..]) |info| { + if (info.bytes_in_output > 0) remaining_count += 1; + } + } + if (remaining_count > 0) { + try writer.print("\n*...and {d} more modules with output contribution*\n", .{remaining_count}); + } + + // ==================== ENTRY POINT ANALYSIS ==================== + try writer.writeAll("\n## Entry Point Analysis\n\n"); + try writer.writeAll("Each entry point and the total code it loads (including shared chunks).\n\n"); + + var out_iter2 = outputs.object.iterator(); + while (out_iter2.next()) |entry| { + const output_path = entry.key_ptr.*; + const output = entry.value_ptr.*; + if (output != .object) continue; + + const entry_point = output.object.get("entryPoint") orelse continue; + if (entry_point != .string) continue; + + try writer.print("### Entry: `{s}`\n\n", .{entry_point.string}); + + // Output file info + try writer.print("**Output file**: `{s}`\n", .{output_path}); + + if (output.object.get("bytes")) |bytes| { + if (bytes == .integer) { + try writer.print("**Bundle size**: {f}\n", .{bun.fmt.size(@as(u64, @intCast(bytes.integer)), .{})}); + } + } + + // CSS bundle + if (output.object.get("cssBundle")) |css_bundle| { + if (css_bundle == .string) { + try writer.print("**CSS bundle**: `{s}`\n", .{css_bundle.string}); + } + } + + // Exports + if (output.object.get("exports")) |exports| { + if (exports == .array and exports.array.items.len > 0) { + try writer.writeAll("**Exports**: "); + var first = true; + const max_exports: usize = 10; + for (exports.array.items, 0..) |exp, i| { + if (i >= max_exports) { + try writer.print(" ...+{d} more", .{exports.array.items.len - max_exports}); + break; + } + if (exp == .string) { + if (!first) try writer.writeAll(", "); + first = false; + try writer.print("`{s}`", .{exp.string}); + } + } + try writer.writeAll("\n"); + } + } + + // Chunk dependencies + if (output.object.get("imports")) |chunk_imports| { + if (chunk_imports == .array and chunk_imports.array.items.len > 0) { + try writer.writeAll("\n**Loads these chunks** (code-splitting):\n"); + for (chunk_imports.array.items) |imp| { + if (imp == .object) { + const path = imp.object.get("path") orelse continue; + const kind = imp.object.get("kind") orelse continue; + if (path == .string and kind == .string) { + // Try to get chunk size + if (outputs.object.get(path.string)) |chunk| { + if (chunk == .object) { + if (chunk.object.get("bytes")) |bytes| { + if (bytes == .integer) { + try writer.print("- `{s}` ({f}, {s})\n", .{ + path.string, + bun.fmt.size(@as(u64, @intCast(bytes.integer)), .{}), + kind.string, + }); + continue; + } + } + } + } + try writer.print("- `{s}` ({s})\n", .{ path.string, kind.string }); + } + } + } + } + } + + // Modules bundled into this entry + if (output.object.get("inputs")) |output_inputs| { + if (output_inputs == .object and output_inputs.object.count() > 0) { + try writer.writeAll("\n**Bundled modules** (sorted by contribution):\n\n"); + try writer.writeAll("| Bytes | Module |\n"); + try writer.writeAll("|-------|--------|\n"); + + // Collect and sort by size + const ModuleSize = struct { path: []const u8, bytes: u64 }; + var module_sizes: std.ArrayListUnmanaged(ModuleSize) = .{}; + defer module_sizes.deinit(allocator); + + var oi_iter = output_inputs.object.iterator(); + while (oi_iter.next()) |oi_entry| { + const module_path = oi_entry.key_ptr.*; + const module_info = oi_entry.value_ptr.*; + if (module_info == .object) { + if (module_info.object.get("bytesInOutput")) |bio| { + if (bio == .integer) { + try module_sizes.append(allocator, .{ .path = module_path, .bytes = @intCast(bio.integer) }); + } + } + } + } + + std.mem.sort(ModuleSize, module_sizes.items, {}, struct { + fn lessThan(_: void, a: ModuleSize, b: ModuleSize) bool { + return a.bytes > b.bytes; + } + }.lessThan); + + const max_modules: usize = 15; + for (module_sizes.items, 0..) |ms, i| { + if (i >= max_modules) break; + try writer.print("| {f} | `{s}` |\n", .{ bun.fmt.size(ms.bytes, .{}), ms.path }); + } + if (module_sizes.items.len > max_modules) { + try writer.print("\n*...and {d} more modules*\n", .{module_sizes.items.len - max_modules}); + } + } + } + + try writer.writeAll("\n"); + } + + // ==================== DEPENDENCY CHAINS (WHY IS THIS INCLUDED?) ==================== + try writer.writeAll("## Dependency Chains\n\n"); + try writer.writeAll("For each module, shows what files import it. Use this to understand why a module is included.\n\n"); + + // Show modules that are imported by many files (potential optimization targets) + const ImportedByInfo = struct { path: []const u8, count: usize }; + var highly_imported: std.ArrayListUnmanaged(ImportedByInfo) = .{}; + defer highly_imported.deinit(allocator); + + var ib_iter = imported_by.iterator(); + while (ib_iter.next()) |entry| { + try highly_imported.append(allocator, .{ .path = entry.key_ptr.*, .count = entry.value_ptr.items.len }); + } + + std.mem.sort(ImportedByInfo, highly_imported.items, {}, struct { + fn lessThan(_: void, a: ImportedByInfo, b: ImportedByInfo) bool { + return a.count > b.count; + } + }.lessThan); + + // Show most commonly imported modules + if (highly_imported.items.len > 0) { + try writer.writeAll("### Most Commonly Imported Modules\n\n"); + try writer.writeAll("Modules imported by many files. Extracting these to shared chunks may help.\n\n"); + try writer.writeAll("| Import Count | Module | Imported By |\n"); + try writer.writeAll("|--------------|--------|-------------|\n"); + + const max_common: usize = 15; + for (highly_imported.items, 0..) |hi, i| { + if (i >= max_common) break; + if (hi.count < 2) break; // Only show if imported by 2+ files + + try writer.print("| {d} | `{s}` | ", .{ hi.count, hi.path }); + + // Show first few importers + if (imported_by.get(hi.path)) |importers| { + const max_importers: usize = 3; + for (importers.items, 0..) |importer, j| { + if (j >= max_importers) { + try writer.print("+{d} more", .{importers.items.len - max_importers}); + break; + } + if (j > 0) try writer.writeAll(", "); + try writer.print("`{s}`", .{importer}); + } + } + try writer.writeAll(" |\n"); + } + } + + // ==================== FULL MODULE GRAPH ==================== + try writer.writeAll("\n## Full Module Graph\n\n"); + try writer.writeAll("Complete dependency information for each module.\n\n"); + + // Sort inputs alphabetically for easier navigation + const PathOnly = struct { path: []const u8 }; + var sorted_paths: std.ArrayListUnmanaged(PathOnly) = .{}; + defer sorted_paths.deinit(allocator); + + var path_iter = inputs.object.iterator(); + while (path_iter.next()) |entry| { + try sorted_paths.append(allocator, .{ .path = entry.key_ptr.* }); + } + + std.mem.sort(PathOnly, sorted_paths.items, {}, struct { + fn lessThan(_: void, a: PathOnly, b: PathOnly) bool { + return std.mem.lessThan(u8, a.path, b.path); + } + }.lessThan); + + for (sorted_paths.items) |sp| { + const input_path = sp.path; + const input = inputs.object.get(input_path) orelse continue; + if (input != .object) continue; + + try writer.print("### `{s}`\n\n", .{input_path}); + + // Show bytes contributed to output + if (bytes_in_output.get(input_path)) |contrib| { + if (contrib > 0) { + try writer.print("- **Output contribution**: {f}\n", .{bun.fmt.size(contrib, .{})}); + } + } + + if (input.object.get("format")) |format| { + if (format == .string) { + try writer.print("- **Format**: {s}\n", .{format.string}); + } + } + + // Who imports this file? + if (imported_by.get(input_path)) |importers| { + try writer.print("- **Imported by** ({d} files):", .{importers.items.len}); + if (importers.items.len <= 5) { + for (importers.items) |importer| { + try writer.print(" `{s}`", .{importer}); + } + } else { + for (importers.items[0..5]) |importer| { + try writer.print(" `{s}`", .{importer}); + } + try writer.print(" +{d} more", .{importers.items.len - 5}); + } + try writer.writeAll("\n"); + } else { + // This is likely an entry point + try writer.writeAll("- **Imported by**: (entry point or orphan)\n"); + } + + // What does this file import? + if (input.object.get("imports")) |imps| { + if (imps == .array and imps.array.items.len > 0) { + try writer.writeAll("- **Imports**:\n"); + for (imps.array.items) |imp| { + if (imp == .object) { + const path = imp.object.get("path") orelse continue; + const kind = imp.object.get("kind") orelse continue; + if (path != .string or kind != .string) continue; + + const is_external = blk: { + if (imp.object.get("external")) |ext| { + if (ext == .bool) break :blk ext.bool; + } + break :blk false; + }; + + const original = blk: { + if (imp.object.get("original")) |orig| { + if (orig == .string) break :blk orig.string; + } + break :blk null; + }; + + // Get output contribution of imported file if available + const imported_contrib: ?u64 = if (!is_external) bytes_in_output.get(path.string) else null; + + if (is_external) { + if (original) |orig| { + try writer.print(" - `{s}` ({s}, **external**, specifier: `{s}`)\n", .{ path.string, kind.string, orig }); + } else { + try writer.print(" - `{s}` ({s}, **external**)\n", .{ path.string, kind.string }); + } + } else if (imported_contrib) |contrib| { + if (contrib > 0) { + if (original) |orig| { + try writer.print(" - `{s}` ({s}, contributes {f}, specifier: `{s}`)\n", .{ path.string, kind.string, bun.fmt.size(contrib, .{}), orig }); + } else { + try writer.print(" - `{s}` ({s}, contributes {f})\n", .{ path.string, kind.string, bun.fmt.size(contrib, .{}) }); + } + } else { + if (original) |orig| { + try writer.print(" - `{s}` ({s}, specifier: `{s}`)\n", .{ path.string, kind.string, orig }); + } else { + try writer.print(" - `{s}` ({s})\n", .{ path.string, kind.string }); + } + } + } else { + if (original) |orig| { + try writer.print(" - `{s}` ({s}, specifier: `{s}`)\n", .{ path.string, kind.string, orig }); + } else { + try writer.print(" - `{s}` ({s})\n", .{ path.string, kind.string }); + } + } + + // Show import attributes if present + if (imp.object.get("with")) |with| { + if (with == .object) { + if (with.object.get("type")) |type_val| { + if (type_val == .string) { + try writer.print(" - with type: `{s}`\n", .{type_val.string}); + } + } + } + } + } + } + } + } + + try writer.writeAll("\n"); + } + + // ==================== RAW DATA FOR SEARCHING ==================== + try writer.writeAll("## Raw Data for Searching\n\n"); + try writer.writeAll("This section contains raw, grep-friendly data. Use these patterns:\n"); + try writer.writeAll("- `[MODULE:` - Find all modules\n"); + try writer.writeAll("- `[OUTPUT_BYTES:` - Find output contribution for each module\n"); + try writer.writeAll("- `[IMPORT:` - Find all import relationships\n"); + try writer.writeAll("- `[IMPORTED_BY:` - Find reverse dependencies\n"); + try writer.writeAll("- `[ENTRY:` - Find entry points\n"); + try writer.writeAll("- `[EXTERNAL:` - Find external imports\n"); + try writer.writeAll("- `[NODE_MODULES:` - Find node_modules files\n\n"); + + // All modules with output contribution + try writer.writeAll("### All Modules\n\n"); + try writer.writeAll("```\n"); + for (input_files.items) |info| { + try writer.print("[MODULE: {s}]\n", .{info.path}); + if (info.bytes_in_output > 0) { + try writer.print("[OUTPUT_BYTES: {s} = {d} bytes]\n", .{ info.path, info.bytes_in_output }); + } + if (info.format.len > 0) { + try writer.print("[FORMAT: {s} = {s}]\n", .{ info.path, info.format }); + } + if (info.is_node_modules) { + try writer.print("[NODE_MODULES: {s}]\n", .{info.path}); + } + } + try writer.writeAll("```\n\n"); + + // All import relationships + try writer.writeAll("### All Imports\n\n"); + try writer.writeAll("```\n"); + var import_iter2 = inputs.object.iterator(); + while (import_iter2.next()) |entry| { + const source_path = entry.key_ptr.*; + const input2 = entry.value_ptr.*; + if (input2 != .object) continue; + + if (input2.object.get("imports")) |imps| { + if (imps == .array) { + for (imps.array.items) |imp| { + if (imp == .object) { + const is_ext = blk: { + if (imp.object.get("external")) |ext| { + if (ext == .bool) break :blk ext.bool; + } + break :blk false; + }; + + if (imp.object.get("path")) |imp_path| { + if (imp_path == .string) { + if (is_ext) { + try writer.print("[EXTERNAL: {s} imports {s}]\n", .{ source_path, imp_path.string }); + } else { + try writer.print("[IMPORT: {s} -> {s}]\n", .{ source_path, imp_path.string }); + } + } + } + } + } + } + } + } + try writer.writeAll("```\n\n"); + + // All reverse dependencies (imported by) + try writer.writeAll("### Reverse Dependencies (Imported By)\n\n"); + try writer.writeAll("```\n"); + var ib_iter2 = imported_by.iterator(); + while (ib_iter2.next()) |entry| { + const target = entry.key_ptr.*; + for (entry.value_ptr.items) |importer| { + try writer.print("[IMPORTED_BY: {s} <- {s}]\n", .{ target, importer }); + } + } + try writer.writeAll("```\n\n"); + + // Entry points + try writer.writeAll("### Entry Points\n\n"); + try writer.writeAll("```\n"); + var out_iter3 = outputs.object.iterator(); + while (out_iter3.next()) |entry| { + const output_path2 = entry.key_ptr.*; + const output2 = entry.value_ptr.*; + if (output2 != .object) continue; + + if (output2.object.get("entryPoint")) |ep| { + if (ep == .string) { + var size: u64 = 0; + if (output2.object.get("bytes")) |bytes| { + if (bytes == .integer) { + size = @intCast(bytes.integer); + } + } + try writer.print("[ENTRY: {s} -> {s} ({d} bytes)]\n", .{ ep.string, output_path2, size }); + } + } + } + try writer.writeAll("```\n\n"); + + // node_modules summary + if (node_modules_count > 0) { + try writer.writeAll("### node_modules Summary\n\n"); + try writer.writeAll("```\n"); + for (input_files.items) |info| { + if (info.is_node_modules and info.bytes_in_output > 0) { + try writer.print("[NODE_MODULES: {s} (contributes {d} bytes)]\n", .{ info.path, info.bytes_in_output }); + } + } + try writer.writeAll("```\n"); + } + + return md.toOwnedSlice(); +} + +/// Strips leading "../" sequences from a relative path. +/// e.g., "../utils/logger.js" -> "utils/logger.js" +fn stripParentRefs(path: []const u8) []const u8 { + var result = path; + while (result.len >= 3 and std.mem.startsWith(u8, result, "../")) { + result = result[3..]; + } + // Also handle ./ prefix + while (result.len >= 2 and std.mem.startsWith(u8, result, "./")) { + result = result[2..]; + } + return result; +} + const std = @import("std"); const bun = @import("bun"); diff --git a/src/cli.zig b/src/cli.zig index cef6c6ee19..dd53d9f596 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -433,6 +433,7 @@ pub const Command = struct { outdir: []const u8 = "", outfile: []const u8 = "", metafile: [:0]const u8 = "", + metafile_md: [:0]const u8 = "", root_dir: []const u8 = "", public_path: []const u8 = "", entry_naming: []const u8 = "[dir]/[name].[ext]", diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 1c00f0ccb1..57f27d0223 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -171,6 +171,7 @@ pub const build_only_params = [_]ParamType{ clap.parseParam("--outdir Default to \"dist\" if multiple files") catch unreachable, clap.parseParam("--outfile Write to a file") catch unreachable, clap.parseParam("--metafile ? Write a JSON file with metadata about the build") catch unreachable, + clap.parseParam("--metafile-md ? Write a markdown file with a visualization of the module graph (LLM-friendly)") catch unreachable, clap.parseParam("--sourcemap ? Build with sourcemaps - 'linked', 'inline', 'external', or 'none'") catch unreachable, clap.parseParam("--banner Add a banner to the bundled output such as \"use client\"; for a bundle being used with RSCs") catch unreachable, clap.parseParam("--footer Add a footer to the bundled output such as // built with bun!") catch unreachable, @@ -1271,6 +1272,14 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C "meta.json"; } + if (args.option("--metafile-md")) |metafile_md| { + // If --metafile-md is passed without a value, default to "meta.md" + ctx.bundler_options.metafile_md = if (metafile_md.len > 0) + bun.handleOom(allocator.dupeZ(u8, metafile_md)) + else + "meta.md"; + } + if (args.option("--root")) |root_dir| { if (root_dir.len > 0) { ctx.bundler_options.root_dir = root_dir; diff --git a/src/cli/build_command.zig b/src/cli/build_command.zig index 73e31338d3..a3ad272784 100644 --- a/src/cli/build_command.zig +++ b/src/cli/build_command.zig @@ -85,7 +85,7 @@ pub const BuildCommand = struct { this_transpiler.options.bundler_feature_flags = Runtime.Features.initBundlerFeatureFlags(allocator, ctx.args.feature_flags); this_transpiler.options.css_chunking = ctx.bundler_options.css_chunking; - this_transpiler.options.metafile = ctx.bundler_options.metafile.len > 0; + this_transpiler.options.metafile = ctx.bundler_options.metafile.len > 0 or ctx.bundler_options.metafile_md.len > 0; this_transpiler.options.output_dir = ctx.bundler_options.outdir; this_transpiler.options.output_format = ctx.bundler_options.output_format; @@ -352,6 +352,35 @@ pub const BuildCommand = struct { }, } } + + // Write markdown metafile if requested + if (ctx.bundler_options.metafile_md.len > 0) { + const metafile_md = MetafileBuilder.generateMarkdown(allocator, metafile_json) catch |err| blk: { + Output.warn("Failed to generate markdown metafile: {s}", .{@errorName(err)}); + break :blk null; + }; + if (metafile_md) |md_content| { + defer allocator.free(md_content); + const file = switch (bun.sys.File.makeOpen(ctx.bundler_options.metafile_md, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o664)) { + .result => |f| f, + .err => |err| { + Output.err(err, "could not open metafile-md {f}", .{bun.fmt.quote(ctx.bundler_options.metafile_md)}); + exitOrWatch(1, ctx.debug.hot_reload == .watch); + unreachable; + }, + }; + defer file.close(); + + switch (file.writeAll(md_content)) { + .result => {}, + .err => |err| { + Output.err(err, "could not write metafile-md {f}", .{bun.fmt.quote(ctx.bundler_options.metafile_md)}); + exitOrWatch(1, ctx.debug.hot_reload == .watch); + unreachable; + }, + } + } + } } break :brk build_result.output_files.items; @@ -554,6 +583,7 @@ pub const BuildCommand = struct { .asset => Output.prettyFmt("", true), .sourcemap => Output.prettyFmt("", true), .bytecode => Output.prettyFmt("", true), + .@"metafile-json", .@"metafile-markdown" => Output.prettyFmt("", true), }); try writer.writeAll(rel_path); @@ -584,6 +614,8 @@ pub const BuildCommand = struct { .asset => "asset", .sourcemap => "source map", .bytecode => "bytecode", + .@"metafile-json" => "metafile json", + .@"metafile-markdown" => "metafile markdown", }}); if (Output.enable_ansi_colors_stdout) try writer.writeAll("\x1b[0m"); @@ -678,6 +710,7 @@ fn printSummary(bundled_end: i128, minify_duration: u64, minified: bool, input_c const string = []const u8; +const MetafileBuilder = @import("../bundler/linker_context/MetafileBuilder.zig"); const fs = @import("../fs.zig"); const options = @import("../options.zig"); const resolve_path = @import("../resolver/resolve_path.zig"); diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index b80bf6897f..d2e4f3e2eb 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -166,6 +166,7 @@ using namespace JSC; macro(makeGetterTypeError) \ macro(maxAge) \ macro(method) \ + macro(metafileJson) \ macro(mockedFunction) \ macro(mode) \ macro(mtimeMs) \ diff --git a/src/options.zig b/src/options.zig index c770a92dfa..d1e76d5acf 100644 --- a/src/options.zig +++ b/src/options.zig @@ -1825,6 +1825,10 @@ pub const BundleOptions = struct { compile: bool = false, metafile: bool = false, + /// Path to write JSON metafile (for Bun.build API) + metafile_json_path: []const u8 = "", + /// Path to write markdown metafile (for Bun.build API) + metafile_markdown_path: []const u8 = "", /// Set when bake.DevServer is bundling. dev_server: ?*bun.bake.DevServer = null, diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 2b24009d7e..3add57d4dd 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -46,13 +46,12 @@ describe("bundler metafile", () => { expect(result.metafile).toBeDefined(); expect(typeof result.metafile).toBe("object"); - // Check inputs structure - expect(result.metafile.inputs).toBeDefined(); - expect(typeof result.metafile.inputs).toBe("object"); - - // Check outputs structure - expect(result.metafile.outputs).toBeDefined(); - expect(typeof result.metafile.outputs).toBe("object"); + // Check metafile structure - metafile returns the JSON directly (backward compatible with esbuild) + const metafile = result.metafile as Metafile; + expect(metafile.inputs).toBeDefined(); + expect(typeof metafile.inputs).toBe("object"); + expect(metafile.outputs).toBeDefined(); + expect(typeof metafile.outputs).toBe("object"); }); test("metafile inputs contain file metadata", async () => { @@ -69,7 +68,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; const inputKeys = Object.keys(inputs); // Should have at least 2 input files @@ -97,7 +96,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const outputs = result.metafile.outputs as Record; + const outputs = (result.metafile as Metafile).outputs as Record; const outputKeys = Object.keys(outputs); // Should have at least 1 output @@ -129,7 +128,7 @@ describe("bundler metafile", () => { expect(result.metafile).toBeDefined(); // Find the entry file in inputs - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; let entryInput: MetafileInput | null = null; for (const [path, input] of Object.entries(inputs)) { if (path.includes("index.js")) { @@ -158,7 +157,7 @@ describe("bundler metafile", () => { expect(result.metafile).toBeDefined(); // Find the entry file in inputs - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; let entryImports: MetafileImport[] | null = null; for (const [path, input] of Object.entries(inputs)) { if (path.includes("entry.js")) { @@ -205,7 +204,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const outputs = result.metafile.outputs as Record; + const outputs = (result.metafile as Metafile).outputs as Record; const outputKeys = Object.keys(outputs); expect(outputKeys.length).toBeGreaterThanOrEqual(1); @@ -228,7 +227,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const outputs = result.metafile.outputs as Record; + const outputs = (result.metafile as Metafile).outputs as Record; const outputKeys = Object.keys(outputs); // At least one output should have entryPoint @@ -256,7 +255,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; // At least one input should have format let hasFormat = false; for (const key of Object.keys(inputs)) { @@ -283,7 +282,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; // Find the foo.js file which uses CommonJS exports let fooInput: MetafileInput | null = null; for (const [path, input] of Object.entries(inputs)) { @@ -311,7 +310,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; let foundExternal = false; for (const key of Object.keys(inputs)) { @@ -343,7 +342,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const outputs = result.metafile.outputs as Record; + const outputs = (result.metafile as Metafile).outputs as Record; const outputKeys = Object.keys(outputs); // With splitting, we should have more outputs (shared chunk) @@ -365,7 +364,7 @@ describe("bundler metafile", () => { expect(result.metafile).toBeDefined(); // Find the entry file in inputs - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; let jsonImport: MetafileImport | null = null; for (const [path, input] of Object.entries(inputs)) { if (path.includes("entry.js")) { @@ -399,7 +398,7 @@ describe("bundler metafile", () => { expect(result.metafile).toBeDefined(); // Find the entry file in inputs - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; let requireImport: MetafileImport | null = null; for (const [path, input] of Object.entries(inputs)) { if (path.includes("entry.js")) { @@ -433,7 +432,7 @@ describe("bundler metafile", () => { expect(result.metafile).toBeDefined(); // Find the entry file in inputs - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; let dynamicImport: MetafileImport | null = null; for (const [path, input] of Object.entries(inputs)) { if (path.includes("entry.js")) { @@ -454,7 +453,7 @@ describe("bundler metafile", () => { expect(dynamicImport!.path).toMatch(/^\.\/chunk-[a-z0-9]+\.js$/); // Verify the path corresponds to an actual output chunk - const outputs = result.metafile.outputs as Record; + const outputs = (result.metafile as Metafile).outputs as Record; const outputPaths = Object.keys(outputs); expect(outputPaths).toContain(dynamicImport!.path); }); @@ -473,7 +472,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const outputs = result.metafile.outputs as Record; + const outputs = (result.metafile as Metafile).outputs as Record; // Find the JS output that should reference the CSS bundle let foundCssBundle = false; @@ -503,7 +502,7 @@ describe("bundler metafile", () => { expect(result.success).toBe(true); expect(result.metafile).toBeDefined(); - const inputs = result.metafile.inputs as Record; + const inputs = (result.metafile as Metafile).inputs as Record; const inputKeys = Object.keys(inputs); // Should have both files @@ -525,3 +524,680 @@ describe("bundler metafile", () => { expect(bImportsA).toBe(true); }); }); + +describe("Bun.build metafile option variants", () => { + test("metafile: string writes JSON to file path", async () => { + using dir = tempDir("metafile-string-path", { + "entry.js": `import { foo } from "./foo.js"; console.log(foo);`, + "foo.js": `export const foo = "hello";`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/entry.js`], + outdir: `${dir}/dist`, + metafile: "output-meta.json", + }); + + expect(result.success).toBe(true); + + // Check JSON file was written (relative to outdir) + const jsonFile = Bun.file(`${dir}/dist/output-meta.json`); + expect(await jsonFile.exists()).toBe(true); + + // Verify JSON content + const content = await jsonFile.text(); + const parsed = JSON.parse(content); + expect(parsed.inputs).toBeDefined(); + expect(parsed.outputs).toBeDefined(); + + // Also check result.metafile is available (backward compatible - returns JSON directly) + expect(result.metafile).toBeDefined(); + const metafile = result.metafile as Metafile; + expect(typeof metafile).toBe("object"); + expect(metafile.inputs).toBeDefined(); + expect(metafile.outputs).toBeDefined(); + }); + + test("metafile: { json: path } writes JSON to specified path", async () => { + using dir = tempDir("metafile-object-json", { + "main.js": `export const value = 42;`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/main.js`], + outdir: `${dir}/dist`, + metafile: { json: "custom-meta.json" }, + }); + + expect(result.success).toBe(true); + + // Check JSON file was written (relative to outdir) + const jsonFile = Bun.file(`${dir}/dist/custom-meta.json`); + expect(await jsonFile.exists()).toBe(true); + + // Verify content + const parsed = JSON.parse(await jsonFile.text()); + expect(parsed.inputs).toBeDefined(); + expect(parsed.outputs).toBeDefined(); + }); + + test("metafile: { markdown: path } writes markdown to specified path", async () => { + using dir = tempDir("metafile-object-md", { + "app.js": `import "./util.js"; console.log("app");`, + "util.js": `console.log("util");`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/app.js`], + outdir: `${dir}/dist`, + metafile: { markdown: "analysis.md" }, + }); + + expect(result.success).toBe(true); + + // Check markdown file was written (relative to outdir) + const mdFile = Bun.file(`${dir}/dist/analysis.md`); + expect(await mdFile.exists()).toBe(true); + + // Verify markdown content + const content = await mdFile.text(); + expect(content).toContain("# Bundle Analysis Report"); + expect(content).toContain("app.js"); + + // Also check result.metafile is available (backward compatible - returns JSON directly) + expect(result.metafile).toBeDefined(); + const metafile = result.metafile as Metafile; + expect(metafile.inputs).toBeDefined(); + }); + + test("metafile: { json: path, markdown: path } writes both files", async () => { + using dir = tempDir("metafile-object-both", { + "index.js": `import { helper } from "./helper.js"; helper();`, + "helper.js": `export function helper() { return "help"; }`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/index.js`], + outdir: `${dir}/dist`, + metafile: { + json: "meta.json", + markdown: "meta.md", + }, + }); + + expect(result.success).toBe(true); + + // Check both files exist (relative to outdir) + const jsonFile = Bun.file(`${dir}/dist/meta.json`); + const mdFile = Bun.file(`${dir}/dist/meta.md`); + expect(await jsonFile.exists()).toBe(true); + expect(await mdFile.exists()).toBe(true); + + // Verify JSON + const parsedJson = JSON.parse(await jsonFile.text()); + expect(parsedJson.inputs).toBeDefined(); + + // Verify markdown + const mdContent = await mdFile.text(); + expect(mdContent).toContain("# Bundle Analysis Report"); + + // result.metafile is available (backward compatible - returns JSON directly) + expect(result.metafile).toBeDefined(); + const metafile = result.metafile as Metafile; + expect(metafile.inputs).toBeDefined(); + expect(metafile.outputs).toBeDefined(); + }); + + test("metafile is lazily parsed", async () => { + using dir = tempDir("metafile-lazy-json", { + "entry.js": `export const x = 1;`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/entry.js`], + metafile: true, + }); + + expect(result.success).toBe(true); + expect(result.metafile).toBeDefined(); + + // First access should parse the JSON (backward compatible - returns JSON directly) + const metafile1 = result.metafile as Metafile; + expect(metafile1).toBeDefined(); + expect(typeof metafile1).toBe("object"); + expect(metafile1.inputs).toBeDefined(); + + // Second access should return the same cached object + const metafile2 = result.metafile as Metafile; + expect(metafile1).toBe(metafile2); // Same reference (memoized) + }); + + test("metafile: true provides metafile object", async () => { + using dir = tempDir("metafile-true", { + "test.js": `console.log("test");`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/test.js`], + metafile: true, + }); + + expect(result.success).toBe(true); + expect(result.metafile).toBeDefined(); + // Backward compatible - returns JSON directly + const metafile = result.metafile as Metafile; + expect(metafile.inputs).toBeDefined(); + expect(metafile.outputs).toBeDefined(); + }); + + test("metafile: { markdown: path } provides metafile object", async () => { + using dir = tempDir("metafile-md-has-json", { + "test.js": `export const a = 1;`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/test.js`], + outdir: `${dir}/dist`, + metafile: { markdown: "meta.md" }, + }); + + expect(result.success).toBe(true); + expect(result.metafile).toBeDefined(); + // Backward compatible - returns JSON directly + const metafile = result.metafile as Metafile; + expect(metafile.inputs).toBeDefined(); + expect(metafile.outputs).toBeDefined(); + }); +}); + +// CLI tests for --metafile-md +import { bunEnv, bunExe } from "harness"; + +describe("bun build --metafile-md", () => { + test("generates markdown metafile with default name", async () => { + using dir = tempDir("metafile-md-test", { + "index.js": `import { foo } from "./foo.js"; console.log(foo);`, + "foo.js": `export const foo = "hello";`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "index.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + // Check meta.md was created + const metaFile = Bun.file(`${dir}/meta.md`); + expect(await metaFile.exists()).toBe(true); + + const content = await metaFile.text(); + + // Verify markdown structure + expect(content).toContain("# Bundle Analysis Report"); + expect(content).toContain("## Quick Summary"); + expect(content).toContain("## Entry Point Analysis"); + expect(content).toContain("## Full Module Graph"); + + // Verify content includes our files + expect(content).toContain("index.js"); + expect(content).toContain("foo.js"); + }); + + test("generates markdown metafile with custom name", async () => { + using dir = tempDir("metafile-md-custom-name", { + "main.js": `export const value = 42;`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "main.js", "--metafile-md=build-graph.md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + // Check custom-named file was created + const metaFile = Bun.file(`${dir}/build-graph.md`); + expect(await metaFile.exists()).toBe(true); + + const content = await metaFile.text(); + expect(content).toContain("# Bundle Analysis Report"); + expect(content).toContain("main.js"); + }); + + test("generates both metafile and metafile-md when both specified", async () => { + using dir = tempDir("metafile-both", { + "app.js": `import "./util.js"; console.log("app");`, + "util.js": `console.log("util");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "app.js", "--metafile=meta.json", "--metafile-md=meta.md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + // Check both files exist + const jsonFile = Bun.file(`${dir}/meta.json`); + const mdFile = Bun.file(`${dir}/meta.md`); + + expect(await jsonFile.exists()).toBe(true); + expect(await mdFile.exists()).toBe(true); + + // Verify JSON is valid + const jsonContent = await jsonFile.text(); + const parsed = JSON.parse(jsonContent); + expect(parsed.inputs).toBeDefined(); + expect(parsed.outputs).toBeDefined(); + + // Verify markdown structure + const mdContent = await mdFile.text(); + expect(mdContent).toContain("# Bundle Analysis Report"); + }); + + test("markdown includes summary metrics", async () => { + using dir = tempDir("metafile-md-metrics", { + "entry.js": `import { a } from "./a.js"; import { b } from "./b.js"; console.log(a, b);`, + "a.js": `export const a = 1;`, + "b.js": `export const b = 2;`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Verify summary table + expect(content).toContain("| Input modules |"); + expect(content).toContain("| Entry points |"); + expect(content).toContain("| Total output size |"); + expect(content).toContain("| ESM modules |"); + }); + + test("markdown includes module format information", async () => { + using dir = tempDir("metafile-md-format", { + "esm.js": `export const x = 1;`, + "cjs.js": `module.exports = { y: 2 };`, + "entry.js": `import { x } from "./esm.js"; const cjs = require("./cjs.js"); console.log(x, cjs);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Should indicate both esm and cjs formats + expect(content).toContain("**Format**: esm"); + expect(content).toContain("**Format**: cjs"); + }); + + test("markdown includes external imports", async () => { + using dir = tempDir("metafile-md-external", { + "app.js": `import fs from "fs"; console.log(fs);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "app.js", "--metafile-md", "--external=fs", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Check external is noted in summary + expect(content).toContain("External imports"); + + // Check external marker in imports list + expect(content).toContain("**external**"); + }); + + test("markdown includes exports list", async () => { + using dir = tempDir("metafile-md-exports", { + "lib.js": `export const foo = 1; export const bar = 2; export default function main() {}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "lib.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Check exports are listed + expect(content).toContain("**Exports**:"); + expect(content).toContain("`foo`"); + expect(content).toContain("`bar`"); + expect(content).toContain("`default`"); + }); + + test("markdown includes bundled modules table", async () => { + using dir = tempDir("metafile-md-bundled", { + "index.js": `import { utils } from "./utils.js"; utils();`, + "utils.js": `export function utils() { return "utility"; }`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "index.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Check bundled modules table + expect(content).toContain("**Bundled modules**"); + expect(content).toContain("| Bytes | Module |"); + }); + + test("markdown includes CSS bundle reference", async () => { + using dir = tempDir("metafile-md-css", { + "app.js": `import "./styles.css"; console.log("styled");`, + "styles.css": `.foo { color: red; }`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "app.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Check CSS bundle reference + expect(content).toContain("**CSS bundle**:"); + expect(content).toContain(".css"); + }); + + test("markdown includes import kinds", async () => { + using dir = tempDir("metafile-md-import-kinds", { + "entry.js": ` + import { static_import } from "./static.js"; + const dynamic = import("./dynamic.js"); + const required = require("./required.js"); + `, + "static.js": `export const static_import = 1;`, + "dynamic.js": `export const dynamic_value = 2;`, + "required.js": `module.exports = { required_value: 3 };`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist", "--splitting"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Check import kinds are shown + expect(content).toContain("import-statement"); + expect(content).toContain("dynamic-import"); + expect(content).toContain("require-call"); + }); + + test("markdown shows commonly imported modules", async () => { + using dir = tempDir("metafile-md-common-imports", { + "a.js": `import { shared } from "./shared.js"; console.log("a", shared);`, + "b.js": `import { shared } from "./shared.js"; console.log("b", shared);`, + "c.js": `import { shared } from "./shared.js"; console.log("c", shared);`, + "shared.js": `export const shared = "common code";`, + "entry.js": `import "./a.js"; import "./b.js"; import "./c.js";`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Verify the Dependency Chains section exists + expect(content).toContain("## Dependency Chains"); + expect(content).toContain("Most Commonly Imported Modules"); + + // shared.js should be listed as commonly imported (by 3 files) + expect(content).toContain("shared.js"); + + // Should show imported by a.js, b.js, c.js + expect(content).toContain("a.js"); + expect(content).toContain("b.js"); + expect(content).toContain("c.js"); + }); + + test("markdown shows largest files for bloat analysis", async () => { + using dir = tempDir("metafile-md-bloat", { + "entry.js": `import "./small.js"; import "./large.js";`, + "small.js": `export const s = 1;`, + "large.js": `export const large = "${"x".repeat(500)}";`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Verify bloat analysis section + expect(content).toContain("## Largest Modules by Output Contribution"); + expect(content).toContain("bytes contributed to the output bundle"); + expect(content).toContain("% of Total"); + }); + + test("markdown shows output contribution", async () => { + using dir = tempDir("metafile-md-contrib", { + "entry.js": `export const x = 1;`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Should show output contribution in Full Module Graph + expect(content).toContain("**Output contribution**:"); + expect(content).toMatch(/\d+\.\d+%/); // Should have percentage in Largest Modules section + }); + + test("markdown includes grep-friendly raw data section", async () => { + using dir = tempDir("metafile-md-grep", { + "main.js": `import { helper } from "./helper.js"; console.log(helper);`, + "helper.js": `export const helper = "utility";`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "main.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Should have table of contents + expect(content).toContain("## Table of Contents"); + expect(content).toContain("[Quick Summary]"); + expect(content).toContain("[Raw Data for Searching]"); + + // Should have raw data section + expect(content).toContain("## Raw Data for Searching"); + + // Should have grep-friendly markers + expect(content).toContain("[MODULE:"); + expect(content).toContain("[OUTPUT_BYTES:"); + expect(content).toContain("[IMPORT:"); + expect(content).toContain("[IMPORTED_BY:"); + + // main.js imports helper.js should be searchable + expect(content).toMatch(/\[IMPORT: main\.js -> .*helper\.js\]/); + + // helper.js is imported by main.js + expect(content).toMatch(/\[IMPORTED_BY: .*helper\.js <- main\.js\]/); + }); + + test("markdown includes entry point markers", async () => { + using dir = tempDir("metafile-md-entry-markers", { + "app.js": `console.log("app");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "app.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Should have entry point marker in raw data + expect(content).toContain("[ENTRY:"); + // Entry format is: [ENTRY: source -> output (bytes)] + expect(content).toMatch(/\[ENTRY: app\.js -> .*app\.js/); + }); + + test("markdown includes external import markers", async () => { + using dir = tempDir("metafile-md-external-markers", { + "index.js": `import fs from "fs"; console.log(fs);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "index.js", "--metafile-md", "--external=fs", "--outdir=dist", "--target=node"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Should have external marker in raw data + expect(content).toContain("[EXTERNAL:"); + expect(content).toMatch(/\[EXTERNAL: index\.js imports fs\]/); + }); + + test("markdown includes node_modules markers", async () => { + using dir = tempDir("metafile-md-node-modules", { + "app.js": `import lodash from "./node_modules/lodash/index.js"; console.log(lodash);`, + "node_modules/lodash/index.js": `export default { version: "4.0.0" };`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "app.js", "--metafile-md", "--outdir=dist"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + + // Should have node_modules marker in raw data + expect(content).toContain("[NODE_MODULES:"); + expect(content).toContain("node_modules/lodash"); + }); +});