From a9b89f5a13e714d6b0c791fbce5f2dfdcd0bf461 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 29 Jan 2026 08:56:45 +0100 Subject: [PATCH] feat(install): add nested/scoped dependency overrides Support npm nested overrides, yarn resolution paths (`parent/child`), and pnpm `>` syntax (`parent>child`) to scope overrides to specific dependency subtrees. This extends OverrideMap with a tree structure that tracks override context through the dependency graph during resolution, enabling overrides like `{ express: { bytes: "1.0.0" } }` to only affect `bytes` when it appears under `express`. Includes serialization for both bun.lock and bun.lockb formats, version- constrained parent keys, and multi-level nesting. Co-Authored-By: Claude --- src/install/PackageManager.zig | 14 + .../PackageManager/PackageManagerEnqueue.zig | 157 +++ .../PackageManagerResolution.zig | 80 ++ .../PackageManager/install_with_manager.zig | 31 +- src/install/lockfile/OverrideMap.zig | 935 +++++++++++++-- src/install/lockfile/Package.zig | 16 + src/install/lockfile/bun.lock.zig | 271 ++++- src/install/lockfile/bun.lockb.zig | 58 + test/cli/install/overrides.test.ts | 1002 +++++++++++++++-- 9 files changed, 2346 insertions(+), 218 deletions(-) diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index 935c3730d7..59b6461784 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -103,6 +103,18 @@ peer_dependencies: bun.LinearFifo(DependencyID, .Dynamic) = .init(default_alloca // name hash from alias package name -> aliased package dependency version info known_npm_aliases: NpmAliasMap = .{}, +/// Maps PackageID → OverrideMap.NodeID +/// Tracks which override tree node is the context for each resolved package's children. +pkg_override_ctx: std.AutoHashMapUnmanaged(PackageID, OverrideMap.NodeID) = .{}, + +/// Maps DependencyID → OverrideMap.NodeID +/// Temporary: holds the override context for a dependency between enqueue and resolution. +dep_pending_override: std.AutoHashMapUnmanaged(DependencyID, OverrideMap.NodeID) = .{}, + +/// Precomputed reverse mapping: DependencyID → owning PackageID. +/// Built lazily to avoid O(N) scans per dependency in the enqueue path. +dep_parent_map: std.ArrayListUnmanaged(PackageID) = .{}, + event_loop: jsc.AnyEventLoop, // During `installPackages` we learn exactly what dependencies from --trust @@ -1217,6 +1229,7 @@ pub const assignResolution = resolution.assignResolution; pub const assignRootResolution = resolution.assignRootResolution; pub const formatLaterVersionInCache = resolution.formatLaterVersionInCache; pub const getInstalledVersionsFromDiskCache = resolution.getInstalledVersionsFromDiskCache; +pub const populateOverrideContexts = resolution.populateOverrideContexts; pub const resolveFromDiskCache = resolution.resolveFromDiskCache; pub const scopeForPackageName = resolution.scopeForPackageName; pub const verifyResolutions = resolution.verifyResolutions; @@ -1322,4 +1335,5 @@ const TaskCallbackContext = bun.install.TaskCallbackContext; const initializeStore = bun.install.initializeStore; const Lockfile = bun.install.Lockfile; +const OverrideMap = Lockfile.OverrideMap; const Package = Lockfile.Package; diff --git a/src/install/PackageManager/PackageManagerEnqueue.zig b/src/install/PackageManager/PackageManagerEnqueue.zig index 9df5508e74..98205d7d31 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.zig +++ b/src/install/PackageManager/PackageManagerEnqueue.zig @@ -478,6 +478,64 @@ pub fn enqueueDependencyWithMainAndSuccessFn( // allow overriding all dependencies unless the dependency is coming directly from an alias, "npm:" or // if it's a workspaceOnly dependency if (!dependency.behavior.isWorkspace() and (dependency.version.tag != .npm or !dependency.version.value.npm.is_alias)) { + // Phase 1: Tree-based nested override check + if (this.lockfile.overrides.hasTree()) tree_check: { + const parent_pkg_id = getParentPackageIdFromMap(this, id); + const parent_ctx = if (parent_pkg_id != invalid_package_id) + this.pkg_override_ctx.get(parent_pkg_id) orelse 0 + else + 0; + + // Walk up from context through ancestors, checking each level for matching children. + // If a child matches name_hash but fails key_spec, try the next sibling with the same name. + var ctx = parent_ctx; + while (true) { + var candidate = this.lockfile.overrides.findChild(ctx, name_hash); + while (candidate) |child_id| { + const child = this.lockfile.overrides.nodes.items[child_id]; + + // Check version constraint on the matched node (e.g., "express@^4.0.0") + if (!child.key_spec.isEmpty()) { + if (!isKeySpecCompatible(child.key_spec, dependency, this.lockfile.buffers.string_bytes.items)) { + // Try next sibling with the same name_hash + candidate = this.lockfile.overrides.findChildAfter(ctx, name_hash, child_id); + continue; + } + } + + // Store context for propagation when this dep resolves + this.dep_pending_override.put(this.allocator, id, child_id) catch {}; + + if (child.value) |val| { + // Apply the override + debug("nested override: {s} -> {s}", .{ this.lockfile.str(&dependency.version.literal), this.lockfile.str(&val.version.literal) }); + name, name_hash = updateNameAndNameHashFromVersionReplacement(this.lockfile, name, name_hash, val.version); + + if (val.version.tag == .catalog) { + if (this.lockfile.catalogs.get(this.lockfile, val.version.value.catalog, name)) |catalog_dep| { + name, name_hash = updateNameAndNameHashFromVersionReplacement(this.lockfile, name, name_hash, catalog_dep.version); + break :version catalog_dep.version; + } + } + + break :version val.version; + } + + break :tree_check; + } + + // Move up to parent context + if (ctx == 0) break; + const parent = this.lockfile.overrides.nodes.items[ctx].parent; + if (parent == OverrideMap.invalid_node_id) break; + ctx = parent; + } + + // Inherit parent's context even if no override value applied + this.dep_pending_override.put(this.allocator, id, parent_ctx) catch {}; + } + + // Phase 2: Fall back to flat global override (existing behavior) if (this.lockfile.overrides.get(name_hash)) |new| { debug("override: {s} -> {s}", .{ this.lockfile.str(&dependency.version.literal), this.lockfile.str(&new.literal) }); @@ -1327,6 +1385,104 @@ fn enqueueLocalTarball( return &task.threadpool_task; } +/// Look up the parent PackageID for a given DependencyID using a precomputed +/// reverse mapping, building/extending it lazily as needed. +fn getParentPackageIdFromMap(this: *PackageManager, dep_id: DependencyID) PackageID { + const total_deps = this.lockfile.buffers.dependencies.items.len; + if (total_deps == 0) return invalid_package_id; + + // Rebuild/extend the map when new dependencies have been added since last build. + if (dep_id >= this.dep_parent_map.items.len) { + const old_len = this.dep_parent_map.items.len; + this.dep_parent_map.ensureTotalCapacityPrecise(this.allocator, total_deps) catch return invalid_package_id; + this.dep_parent_map.appendNTimesAssumeCapacity(@as(PackageID, invalid_package_id), total_deps - old_len); + + const dep_lists = this.lockfile.packages.items(.dependencies); + for (dep_lists, 0..) |dep_slice, pkg_id| { + const end = dep_slice.off +| dep_slice.len; + // Only fill entries that are new (>= old_len) or were never built. + if (end <= old_len) continue; + const start = @max(dep_slice.off, @as(u32, @intCast(old_len))); + var i: u32 = start; + while (i < end) : (i += 1) { + if (i < this.dep_parent_map.items.len) { + this.dep_parent_map.items[i] = @intCast(pkg_id); + } + } + } + } + + if (dep_id >= this.dep_parent_map.items.len) return invalid_package_id; + return this.dep_parent_map.items[dep_id]; +} + +/// Check if a dependency's version range is compatible with a key_spec constraint. +/// For example, if key_spec is "^4.0.0" and the dependency version is "4.18.2" or "^4.0.0", +/// checks if they can intersect (i.e., some version could satisfy both). +fn isKeySpecCompatible(key_spec: String, dependency: *const Dependency, buf: string) bool { + if (key_spec.isEmpty()) return true; + + // Only check npm dependencies with semver ranges + if (dependency.version.tag != .npm) return true; + + const key_spec_str = key_spec.slice(buf); + if (key_spec_str.len == 0) return true; + + // Parse key_spec as a semver query. The parsed query's internal strings + // reference key_spec_str, so we must use key_spec_str as the list_buf + // when calling satisfies on key_spec_group. + const sliced = Semver.SlicedString.init(key_spec_str, key_spec_str); + var key_spec_group = Semver.Query.parse( + bun.default_allocator, + key_spec_str, + sliced, + ) catch return true; // on parse error, allow optimistically + defer key_spec_group.deinit(); + + // Check if any boundary version of the dependency's range satisfies the key_spec. + // Walk the dependency's query list checking left/right boundary versions. + // Note: dep versions reference `buf` (lockfile strings), key_spec_group references `key_spec_str`. + const dep_group = dependency.version.value.npm.version; + var dep_list: ?*const Semver.Query.List = &dep_group.head; + while (dep_list) |queries| { + var curr: ?*const Semver.Query = &queries.head; + while (curr) |query| { + // key_spec_group's strings are in key_spec_str, version's strings are in buf + if (query.range.hasLeft()) { + if (key_spec_group.head.satisfies(query.range.left.version, key_spec_str, buf)) + return true; + } + if (query.range.hasRight()) { + if (key_spec_group.head.satisfies(query.range.right.version, key_spec_str, buf)) + return true; + } + curr = query.next; + } + dep_list = queries.next; + } + + // Also check if any key_spec boundary satisfies the dependency range + // dep_group's strings are in buf, key_spec version's strings are in key_spec_str + var ks_list: ?*const Semver.Query.List = &key_spec_group.head; + while (ks_list) |queries| { + var curr: ?*const Semver.Query = &queries.head; + while (curr) |query| { + if (query.range.hasLeft()) { + if (dep_group.head.satisfies(query.range.left.version, buf, key_spec_str)) + return true; + } + if (query.range.hasRight()) { + if (dep_group.head.satisfies(query.range.right.version, buf, key_spec_str)) + return true; + } + curr = query.next; + } + ks_list = queries.next; + } + + return false; +} + fn updateNameAndNameHashFromVersionReplacement( lockfile: *const Lockfile, original_name: String, @@ -1897,6 +2053,7 @@ const TaskCallbackContext = bun.install.TaskCallbackContext; const invalid_package_id = bun.install.invalid_package_id; const Lockfile = bun.install.Lockfile; +const OverrideMap = Lockfile.OverrideMap; const Package = Lockfile.Package; const NetworkTask = bun.install.NetworkTask; diff --git a/src/install/PackageManager/PackageManagerResolution.zig b/src/install/PackageManager/PackageManagerResolution.zig index 16f4ba3021..d09dfea527 100644 --- a/src/install/PackageManager/PackageManagerResolution.zig +++ b/src/install/PackageManager/PackageManagerResolution.zig @@ -152,6 +152,14 @@ pub fn assignResolution(this: *PackageManager, dependency_id: DependencyID, pack dep.name = this.lockfile.packages.items(.name)[package_id]; dep.name_hash = this.lockfile.packages.items(.name_hash)[package_id]; } + + // Propagate override context (first-write-wins for shared packages) + if (this.dep_pending_override.get(dependency_id)) |ctx_id| { + const gop = this.pkg_override_ctx.getOrPut(this.allocator, package_id) catch return; + if (!gop.found_existing) { + gop.value_ptr.* = ctx_id; + } + } } pub fn assignRootResolution(this: *PackageManager, dependency_id: DependencyID, package_id: PackageID) void { @@ -168,6 +176,14 @@ pub fn assignRootResolution(this: *PackageManager, dependency_id: DependencyID, dep.name = this.lockfile.packages.items(.name)[package_id]; dep.name_hash = this.lockfile.packages.items(.name_hash)[package_id]; } + + // Propagate override context for root resolution + if (this.dep_pending_override.get(dependency_id)) |ctx_id| { + const gop = this.pkg_override_ctx.getOrPut(this.allocator, package_id) catch return; + if (!gop.found_existing) { + gop.value_ptr.* = ctx_id; + } + } } pub fn verifyResolutions(this: *PackageManager, log_level: PackageManager.Options.LogLevel) void { @@ -217,6 +233,70 @@ pub fn verifyResolutions(this: *PackageManager, log_level: PackageManager.Option if (any_failed) this.crash(); } +/// Pre-populate override contexts for all resolved packages. +/// This is needed during re-resolution when overrides change, +/// because existing packages were resolved without context tracking. +/// Does a BFS from root, propagating override tree node IDs along the dependency graph. +pub fn populateOverrideContexts(this: *PackageManager) void { + if (!this.lockfile.overrides.hasTree()) return; + + const OverrideMap = Lockfile.OverrideMap; + const packages = this.lockfile.packages.slice(); + const dep_lists = packages.items(.dependencies); + const res_lists = packages.items(.resolutions); + const name_hashes = packages.items(.name_hash); + + // Use a simple worklist (BFS queue) + const QueueItem = struct { pkg_id: PackageID, ctx: OverrideMap.NodeID }; + var queue = std.ArrayListUnmanaged(QueueItem){}; + defer queue.deinit(this.allocator); + + // Start from root package + this.pkg_override_ctx.put(this.allocator, 0, 0) catch return; + queue.append(this.allocator, .{ .pkg_id = 0, .ctx = 0 }) catch return; + + // BFS using index-based iteration to avoid O(N) shifts from orderedRemove(0) + var queue_idx: usize = 0; + while (queue_idx < queue.items.len) { + const item = queue.items[queue_idx]; + queue_idx += 1; + const deps = dep_lists[item.pkg_id].get(this.lockfile.buffers.dependencies.items); + const ress = res_lists[item.pkg_id].get(this.lockfile.buffers.resolutions.items); + + for (deps, ress) |dep, resolved_pkg_id| { + if (resolved_pkg_id >= packages.len) continue; + + // Determine child context: if the dep matches a child in the override tree, use that child node + var child_ctx = item.ctx; + if (this.lockfile.overrides.findChild(item.ctx, dep.name_hash)) |child_id| { + child_ctx = child_id; + } else if (item.ctx != 0) { + // Also check if the dep matches a child of root (for packages that match + // a root-level entry in the tree but are discovered via a non-matching path) + if (this.lockfile.overrides.findChild(0, dep.name_hash)) |child_id| { + child_ctx = child_id; + } + } + + // Also check by resolved package's name_hash (in case dep name differs from pkg name) + if (child_ctx == item.ctx and resolved_pkg_id < name_hashes.len) { + const pkg_name_hash = name_hashes[resolved_pkg_id]; + if (pkg_name_hash != dep.name_hash) { + if (this.lockfile.overrides.findChild(item.ctx, pkg_name_hash)) |child_id| { + child_ctx = child_id; + } + } + } + + const gop = this.pkg_override_ctx.getOrPut(this.allocator, resolved_pkg_id) catch continue; + if (!gop.found_existing) { + gop.value_ptr.* = child_ctx; + queue.append(this.allocator, .{ .pkg_id = resolved_pkg_id, .ctx = child_ctx }) catch continue; + } + } + } +} + const string = []const u8; const std = @import("std"); diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 2dd6767a27..4e226dda4f 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -237,12 +237,29 @@ pub fn installWithManager( const all_name_hashes: []PackageNameHash = brk: { if (!manager.summary.overrides_changed) break :brk &.{}; - const hashes_len = manager.lockfile.overrides.map.entries.len + lockfile.overrides.map.entries.len; - if (hashes_len == 0) break :brk &.{}; - var all_name_hashes = try bun.default_allocator.alloc(PackageNameHash, hashes_len); + + // Collect hashes from flat maps + const flat_hashes_len = manager.lockfile.overrides.map.entries.len + lockfile.overrides.map.entries.len; + + // Collect hashes from tree leaf nodes + const old_tree_hashes = try manager.lockfile.overrides.collectTreeLeafHashes(bun.default_allocator); + defer if (old_tree_hashes.len > 0) bun.default_allocator.free(old_tree_hashes); + const new_tree_hashes = try lockfile.overrides.collectTreeLeafHashes(bun.default_allocator); + defer if (new_tree_hashes.len > 0) bun.default_allocator.free(new_tree_hashes); + + const total_len = flat_hashes_len + old_tree_hashes.len + new_tree_hashes.len; + if (total_len == 0) break :brk &.{}; + + var all_name_hashes = try bun.default_allocator.alloc(PackageNameHash, total_len); @memcpy(all_name_hashes[0..manager.lockfile.overrides.map.entries.len], manager.lockfile.overrides.map.keys()); - @memcpy(all_name_hashes[manager.lockfile.overrides.map.entries.len..], lockfile.overrides.map.keys()); - var i = manager.lockfile.overrides.map.entries.len; + @memcpy(all_name_hashes[manager.lockfile.overrides.map.entries.len .. manager.lockfile.overrides.map.entries.len + lockfile.overrides.map.entries.len], lockfile.overrides.map.keys()); + var dest = manager.lockfile.overrides.map.entries.len + lockfile.overrides.map.entries.len; + @memcpy(all_name_hashes[dest .. dest + old_tree_hashes.len], old_tree_hashes); + dest += old_tree_hashes.len; + @memcpy(all_name_hashes[dest .. dest + new_tree_hashes.len], new_tree_hashes); + + // Deduplicate + var i: usize = manager.lockfile.overrides.map.entries.len; while (i < all_name_hashes.len) { if (std.mem.indexOfScalar(PackageNameHash, all_name_hashes[0..i], all_name_hashes[i]) != null) { all_name_hashes[i] = all_name_hashes[all_name_hashes.len - 1]; @@ -361,6 +378,10 @@ pub fn installWithManager( builder.clamp(); if (manager.summary.overrides_changed and all_name_hashes.len > 0) { + // Pre-populate override contexts for existing resolved packages + // so that re-enqueued deps can find their override tree context. + manager.populateOverrideContexts(); + for (manager.lockfile.buffers.dependencies.items, 0..) |*dependency, dependency_i| { if (std.mem.indexOfScalar(PackageNameHash, all_name_hashes, dependency.name_hash)) |_| { manager.lockfile.buffers.resolutions.items[dependency_i] = invalid_package_id; diff --git a/src/install/lockfile/OverrideMap.zig b/src/install/lockfile/OverrideMap.zig index b6e509bbc6..efcca78a8c 100644 --- a/src/install/lockfile/OverrideMap.zig +++ b/src/install/lockfile/OverrideMap.zig @@ -4,12 +4,103 @@ const debug = Output.scoped(.OverrideMap, .visible); map: std.ArrayHashMapUnmanaged(PackageNameHash, Dependency, ArrayIdentityContext.U64, false) = .{}, -/// In the future, this `get` function should handle multi-level resolutions. This is difficult right -/// now because given a Dependency ID, there is no fast way to trace it to its package. -/// -/// A potential approach is to add another buffer to the lockfile that maps Dependency ID to Package ID, -/// and from there `OverrideMap.map` can have a union as the value, where the union is between "override all" -/// and "here is a list of overrides depending on the package that imported" similar to PackageIndex above. +/// Tree of override nodes for nested/scoped overrides. +/// Node 0 is the virtual root (has no name/value, only children). +nodes: std.ArrayListUnmanaged(OverrideNode) = .{}, + +pub const NodeID = u16; +pub const invalid_node_id = std.math.maxInt(NodeID); + +pub const OverrideNode = struct { + name: String, // package name (empty for root) + name_hash: PackageNameHash, + key_spec: String, // version constraint from "@..." in key (empty = any version) + value: ?Dependency, // override value (null = no override, just a context node) + first_child: NodeID, // first child node, or invalid_node_id + next_sibling: NodeID, // next sibling node, or invalid_node_id + parent: NodeID, // parent node, or invalid_node_id for root + + /// Serializable external representation for binary lockfile. + pub const External = extern struct { + name_hash: PackageNameHash, + name: String, + key_spec: String, + has_value: u8, + value: Dependency.External, + _padding: [1]u8 = .{0} ** 1, + first_child: NodeID, + next_sibling: NodeID, + }; + + pub fn toExternal(this: OverrideNode) External { + return .{ + .name_hash = this.name_hash, + .name = this.name, + .key_spec = this.key_spec, + .has_value = if (this.value != null) 1 else 0, + .value = if (this.value) |v| v.toExternal() else std.mem.zeroes(Dependency.External), + .first_child = this.first_child, + .next_sibling = this.next_sibling, + }; + } + + pub fn fromExternal(ext: External, context: Dependency.Context) OverrideNode { + return .{ + .name = ext.name, + .name_hash = ext.name_hash, + .key_spec = ext.key_spec, + .value = if (ext.has_value != 0) Dependency.toDependency(ext.value, context) else null, + .first_child = ext.first_child, + .next_sibling = ext.next_sibling, + .parent = invalid_node_id, // rebuilt by rebuildParentPointers + }; + } +}; + +/// Check whether the tree has any non-root nodes. +pub fn hasTree(this: *const OverrideMap) bool { + if (this.nodes.items.len == 0) return false; + return this.nodes.items[0].first_child != invalid_node_id; +} + +/// Find a child of `parent_node_id` matching `name_hash`. +/// If `after` is not invalid_node_id, skip children up to and including `after` (for iterating multiple matches). +pub fn findChild(this: *const OverrideMap, parent_node_id: NodeID, name_hash: PackageNameHash) ?NodeID { + return this.findChildAfter(parent_node_id, name_hash, invalid_node_id); +} + +/// Find a child of `parent_node_id` matching `name_hash`, starting after `after_id`. +/// Pass `invalid_node_id` to start from the first child. +pub fn findChildAfter(this: *const OverrideMap, parent_node_id: NodeID, name_hash: PackageNameHash, after_id: NodeID) ?NodeID { + if (parent_node_id >= this.nodes.items.len) return null; + var child_id = if (after_id != invalid_node_id) + this.nodes.items[after_id].next_sibling + else + this.nodes.items[parent_node_id].first_child; + while (child_id != invalid_node_id) { + if (child_id >= this.nodes.items.len) return null; + const child = this.nodes.items[child_id]; + if (child.name_hash == name_hash) return child_id; + child_id = child.next_sibling; + } + return null; +} + +/// Walk up from `context_node_id` through ancestors, checking each level's children +/// for a match. Returns the most specific (deepest) matching child. +/// This implements npm's "ruleset" semantics where closer overrides shadow ancestor overrides. +pub fn findOverrideInContext(this: *const OverrideMap, context_node_id: NodeID, name_hash: PackageNameHash) ?NodeID { + var ctx = context_node_id; + while (true) { + if (this.findChild(ctx, name_hash)) |child_id| return child_id; + if (ctx == 0) return null; + const parent = this.nodes.items[ctx].parent; + if (parent == invalid_node_id) return null; + ctx = parent; + } +} + +/// Get the flat global override for a name_hash (existing behavior). pub fn get(this: *const OverrideMap, name_hash: PackageNameHash) ?Dependency.Version { debug("looking up override for {x}", .{name_hash}); if (this.map.count() == 0) { @@ -42,16 +133,93 @@ pub fn sort(this: *OverrideMap, lockfile: *const Lockfile) void { }; this.map.sort(&ctx); + + // Sort tree children at each level by name_hash for deterministic comparison + for (this.nodes.items) |*node| { + this.sortChildren(node); + } +} + +fn sortChildren(this: *OverrideMap, node: *OverrideNode) void { + var stack_fallback = std.heap.stackFallback(257 * @sizeOf(NodeID), bun.default_allocator); + const allocator = stack_fallback.get(); + + var child_count: usize = 0; + var child_id = node.first_child; + while (child_id != invalid_node_id) { + child_count += 1; + if (child_id >= this.nodes.items.len) break; + child_id = this.nodes.items[child_id].next_sibling; + } + if (child_count < 2) return; + + const children_slice = allocator.alloc(NodeID, child_count) catch return; + defer allocator.free(children_slice); + + var idx: usize = 0; + child_id = node.first_child; + while (child_id != invalid_node_id and idx < child_count) { + children_slice[idx] = child_id; + idx += 1; + if (child_id >= this.nodes.items.len) break; + child_id = this.nodes.items[child_id].next_sibling; + } + + const nodes_ptr = this.nodes.items.ptr; + const SortCtx = struct { + nodes: [*]const OverrideNode, + pub fn lessThan(ctx: @This(), a: NodeID, b: NodeID) bool { + return ctx.nodes[a].name_hash < ctx.nodes[b].name_hash; + } + }; + std.sort.pdq(NodeID, children_slice, SortCtx{ .nodes = nodes_ptr }, SortCtx.lessThan); + + // Relink + node.first_child = children_slice[0]; + for (children_slice[0 .. child_count - 1], children_slice[1..child_count]) |curr, next| { + this.nodes.items[curr].next_sibling = next; + } + this.nodes.items[children_slice[child_count - 1]].next_sibling = invalid_node_id; } pub fn deinit(this: *OverrideMap, allocator: Allocator) void { this.map.deinit(allocator); + this.nodes.deinit(allocator); +} + +/// Rebuild parent pointers from the first_child/next_sibling links. +/// Called after deserializing from binary lockfile where parent is not stored. +pub fn rebuildParentPointers(this: *OverrideMap) void { + for (this.nodes.items) |*node| { + node.parent = invalid_node_id; + } + for (this.nodes.items, 0..) |node, i| { + var child_id = node.first_child; + while (child_id != invalid_node_id) { + if (child_id >= this.nodes.items.len) break; + this.nodes.items[child_id].parent = @intCast(i); + child_id = this.nodes.items[child_id].next_sibling; + } + } } pub fn count(this: *OverrideMap, lockfile: *Lockfile, builder: *Lockfile.StringBuilder) void { for (this.map.values()) |dep| { dep.count(lockfile.buffers.string_bytes.items, @TypeOf(builder), builder); } + // Count strings in tree nodes + const buf = lockfile.buffers.string_bytes.items; + for (this.nodes.items) |node| { + if (!node.name.isEmpty()) { + builder.count(node.name.slice(buf)); + } + if (!node.key_spec.isEmpty()) { + builder.count(node.key_spec.slice(buf)); + } + if (node.value) |dep| { + dep.count(buf, @TypeOf(builder), builder); + } + } } pub fn clone(this: *OverrideMap, pm: *PackageManager, old_lockfile: *Lockfile, new_lockfile: *Lockfile, new_builder: *Lockfile.StringBuilder) !OverrideMap { @@ -65,9 +233,162 @@ pub fn clone(this: *OverrideMap, pm: *PackageManager, old_lockfile: *Lockfile, n ); } + // Clone tree nodes + if (this.nodes.items.len > 0) { + try new.nodes.ensureTotalCapacity(new_lockfile.allocator, this.nodes.items.len); + const old_buf = old_lockfile.buffers.string_bytes.items; + for (this.nodes.items) |node| { + const new_name = if (!node.name.isEmpty()) + new_builder.append(String, node.name.slice(old_buf)) + else + String{}; + const new_key_spec = if (!node.key_spec.isEmpty()) + new_builder.append(String, node.key_spec.slice(old_buf)) + else + String{}; + const new_value = if (node.value) |dep| + try dep.clone(pm, old_buf, @TypeOf(new_builder), new_builder) + else + null; + new.nodes.appendAssumeCapacity(.{ + .name = new_name, + .name_hash = node.name_hash, + .key_spec = new_key_spec, + .value = new_value, + .first_child = node.first_child, + .next_sibling = node.next_sibling, + .parent = node.parent, + }); + } + } + return new; } +/// Compare two override trees for semantic equality. +/// Trees are compared by walking children in sorted order (by name_hash), +/// so structural differences in node layout don't cause false mismatches. +pub fn treeEquals(this: *const OverrideMap, other: *const OverrideMap, this_buf: string, other_buf: string) bool { + if (this.nodes.items.len != other.nodes.items.len) return false; + if (this.nodes.items.len == 0) return true; + // Both trees have a root at node 0; walk recursively + return subtreeEquals(this, other, 0, 0, this_buf, other_buf); +} + +fn subtreeEquals(this: *const OverrideMap, other: *const OverrideMap, this_id: NodeID, other_id: NodeID, this_buf: string, other_buf: string) bool { + const a = this.nodes.items[this_id]; + const b = other.nodes.items[other_id]; + + if (a.name_hash != b.name_hash) return false; + + // Compare key_spec + const a_spec = if (!a.key_spec.isEmpty()) a.key_spec.slice(this_buf) else ""; + const b_spec = if (!b.key_spec.isEmpty()) b.key_spec.slice(other_buf) else ""; + if (!strings.eql(a_spec, b_spec)) return false; + + // Compare values + if (a.value != null and b.value != null) { + if (!a.value.?.eql(&b.value.?, this_buf, other_buf)) return false; + } else if (a.value != null or b.value != null) { + return false; + } + + // Compare children by walking both linked lists (already sorted by name_hash after sort()) + var a_child = a.first_child; + var b_child = b.first_child; + while (a_child != invalid_node_id and b_child != invalid_node_id) { + if (!subtreeEquals(this, other, a_child, b_child, this_buf, other_buf)) return false; + a_child = this.nodes.items[a_child].next_sibling; + b_child = other.nodes.items[b_child].next_sibling; + } + // Both should be exhausted + return a_child == invalid_node_id and b_child == invalid_node_id; +} + +/// Ensure root node exists. +pub fn ensureRootNode(this: *OverrideMap, allocator: Allocator) !void { + if (this.nodes.items.len == 0) { + try this.nodes.append(allocator, .{ + .name = String{}, + .name_hash = 0, + .key_spec = String{}, + .value = null, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }); + } +} + +/// Add a child node under `parent_id`. If a child with the same `name_hash` and `key_spec` already exists, return it. +/// Different key_specs for the same name create separate nodes (e.g., "express@^3" and "express@^4"). +/// `buf` is the string buffer used to compare key_spec values. +pub fn getOrAddChild(this: *OverrideMap, allocator: Allocator, parent_id: NodeID, node: OverrideNode, buf: string) !NodeID { + // Check if child already exists with matching name_hash AND key_spec + var child_id = this.nodes.items[parent_id].first_child; + while (child_id != invalid_node_id) { + if (child_id >= this.nodes.items.len) break; + if (this.nodes.items[child_id].name_hash == node.name_hash) { + const existing_spec = this.nodes.items[child_id].key_spec.slice(buf); + const new_spec = node.key_spec.slice(buf); + if (strings.eql(existing_spec, new_spec)) { + // Existing node found - update value if the new one has a value and the existing doesn't + if (node.value != null and this.nodes.items[child_id].value == null) { + this.nodes.items[child_id].value = node.value; + } + return child_id; + } + } + child_id = this.nodes.items[child_id].next_sibling; + } + + // Create new node + const new_id: NodeID = @intCast(this.nodes.items.len); + var new_node = node; + new_node.parent = parent_id; + try this.nodes.append(allocator, new_node); + + // Prepend as first child of parent + this.nodes.items[new_id].next_sibling = this.nodes.items[parent_id].first_child; + this.nodes.items[parent_id].first_child = new_id; + + return new_id; +} + +/// Parse a key like "foo", "foo@^2.0.0", "@scope/foo@^2" into (name, key_spec). +/// Split at the last `@` that isn't at position 0. +pub fn parseKeyWithVersion(k: []const u8) struct { name: []const u8, spec: []const u8 } { + if (k.len == 0) return .{ .name = k, .spec = "" }; + + // Find the last '@' that isn't at position 0 + var i: usize = k.len; + while (i > 1) { + i -= 1; + if (k[i] == '@') { + return .{ .name = k[0..i], .spec = k[i + 1 ..] }; + } + } + return .{ .name = k, .spec = "" }; +} + +/// Split a pnpm-style key at `>`. For example: +/// "bar@1>foo" → ["bar@1", "foo"] +/// "@scope/bar>foo@2" → ["@scope/bar", "foo@2"] +fn splitPnpmDelimiter(k: []const u8) ?struct { parent: []const u8, child: []const u8 } { + // pnpm splits at `>` preceded by a non-space, non-`|`, non-`@` char + var i: usize = 1; // skip first char + while (i < k.len) : (i += 1) { + if (k[i] == '>') { + if (k[i - 1] != ' ' and k[i - 1] != '|' and k[i - 1] != '@') { + if (i + 1 < k.len) { + return .{ .parent = k[0..i], .child = k[i + 1 ..] }; + } + } + } + } + return null; +} + // the rest of this struct is expression parsing code: pub fn parseCount( @@ -80,31 +401,111 @@ pub fn parseCount( if (overrides.expr.data != .e_object) return; - for (overrides.expr.data.e_object.properties.slice()) |entry| { - builder.count(entry.key.?.asString(lockfile.allocator).?); - switch (entry.value.?.data) { - .e_string => |s| { - builder.count(s.slice(lockfile.allocator)); - }, - .e_object => { - if (entry.value.?.asProperty(".")) |dot| { - if (dot.expr.asString(lockfile.allocator)) |s| { - builder.count(s); - } - } - }, - else => {}, - } - } + countOverrideObject(lockfile, overrides.expr, builder); } else if (expr.asProperty("resolutions")) |resolutions| { if (resolutions.expr.data != .e_object) return; for (resolutions.expr.data.e_object.properties.slice()) |entry| { - builder.count(entry.key.?.asString(lockfile.allocator).?); + // Count all segments from the key path + var k = entry.key.?.asString(lockfile.allocator).?; + // Strip **/ prefixes + while (strings.hasPrefixComptime(k, "**/")) k = k[3..]; + builder.count(k); + // For path-based resolutions, also count individual segments + countPathSegments(k, builder); builder.count(entry.value.?.asString(lockfile.allocator) orelse continue); } } + + // Also count pnpm.overrides + if (expr.asProperty("pnpm")) |pnpm| { + if (pnpm.expr.asProperty("overrides")) |pnpm_overrides| { + if (pnpm_overrides.expr.data == .e_object) { + countOverrideObject(lockfile, pnpm_overrides.expr, builder); + } + } + } +} + +fn countOverrideObject(lockfile: *Lockfile, expr: Expr, builder: *Lockfile.StringBuilder) void { + if (expr.data != .e_object) return; + for (expr.data.e_object.properties.slice()) |entry| { + const k = entry.key.?.asString(lockfile.allocator).?; + builder.count(k); + // Also count the name part without version constraint + const parsed = parseKeyWithVersion(k); + if (parsed.spec.len > 0) { + builder.count(parsed.name); + builder.count(parsed.spec); + } + // Check for > delimiter + if (splitPnpmDelimiter(k)) |parts| { + builder.count(parts.parent); + builder.count(parts.child); + const parent_parsed = parseKeyWithVersion(parts.parent); + if (parent_parsed.spec.len > 0) { + builder.count(parent_parsed.name); + builder.count(parent_parsed.spec); + } + const child_parsed = parseKeyWithVersion(parts.child); + if (child_parsed.spec.len > 0) { + builder.count(child_parsed.name); + builder.count(child_parsed.spec); + } + } + switch (entry.value.?.data) { + .e_string => |s| { + builder.count(s.slice(lockfile.allocator)); + }, + .e_object => { + if (entry.value.?.asProperty(".")) |dot| { + if (dot.expr.asString(lockfile.allocator)) |s| { + builder.count(s); + } + } + // Recursively count nested objects + countOverrideObject(lockfile, entry.value.?, builder); + }, + else => {}, + } + } +} + +fn countPathSegments(k: []const u8, builder: *Lockfile.StringBuilder) void { + var remaining = k; + while (true) { + // Handle scoped packages + if (remaining.len > 0 and remaining[0] == '@') { + const first_slash = strings.indexOfChar(remaining, '/') orelse break; + if (first_slash + 1 < remaining.len) { + const after_scope = remaining[first_slash + 1 ..]; + const next_slash = strings.indexOfChar(after_scope, '/'); + if (next_slash) |ns| { + const segment = remaining[0 .. first_slash + 1 + ns]; + builder.count(segment); + remaining = after_scope[ns + 1 ..]; + // Strip **/ + while (strings.hasPrefixComptime(remaining, "**/")) remaining = remaining[3..]; + continue; + } else { + // Last segment + builder.count(remaining); + break; + } + } else break; + } else { + const slash = strings.indexOfChar(remaining, '/'); + if (slash) |s| { + builder.count(remaining[0..s]); + remaining = remaining[s + 1 ..]; + while (strings.hasPrefixComptime(remaining, "**/")) remaining = remaining[3..]; + } else { + builder.count(remaining); + break; + } + } + } } /// Given a package json expression, detect and parse override configuration into the given override map. @@ -127,7 +528,15 @@ pub fn parseAppend( } else if (expr.asProperty("resolutions")) |resolutions| { try this.parseFromResolutions(pm, lockfile, root_package, json_source, log, resolutions.expr, builder); } - debug("parsed {d} overrides", .{this.map.entries.len}); + + // Also parse pnpm.overrides (additive) + if (expr.asProperty("pnpm")) |pnpm| { + if (pnpm.expr.asProperty("overrides")) |pnpm_overrides| { + try this.parseFromPnpmOverrides(pm, lockfile, root_package, json_source, log, pnpm_overrides.expr, builder); + } + } + + debug("parsed {d} overrides ({d} tree nodes)", .{ this.map.entries.len, this.nodes.items.len }); } /// https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides @@ -148,6 +557,24 @@ pub fn parseFromOverrides( try this.map.ensureUnusedCapacity(lockfile.allocator, expr.data.e_object.properties.len); + try this.parseOverrideObject(pm, lockfile, root_package, source, log, expr, builder, 0, true); +} + +/// Recursively parse an override object, building the tree structure. +fn parseOverrideObject( + this: *OverrideMap, + pm: *PackageManager, + lockfile: *Lockfile, + root_package: *Lockfile.Package, + source: *const logger.Source, + log: *logger.Log, + expr: Expr, + builder: *Lockfile.StringBuilder, + parent_node_id: NodeID, + is_root_level: bool, +) !void { + if (expr.data != .e_object) return; + for (expr.data.e_object.properties.slice()) |prop| { const key = prop.key.?; const k = key.asString(lockfile.allocator).?; @@ -156,54 +583,192 @@ pub fn parseFromOverrides( continue; } - const name_hash = String.Builder.stringHash(k); + // Skip "." key (handled by parent) + if (strings.eql(k, ".")) continue; - const value = value: { - // for one level deep, we will only support a string and { ".": value } - const value_expr = prop.value.?; - if (value_expr.data == .e_string) { - break :value value_expr; - } else if (value_expr.data == .e_object) { - if (value_expr.asProperty(".")) |dot| { - if (dot.expr.data == .e_string) { - if (value_expr.data.e_object.properties.len > 1) { - try log.addWarningFmt(source, value_expr.loc, lockfile.allocator, "Bun currently does not support nested \"overrides\"", .{}); - } - break :value dot.expr; - } else { - try log.addWarningFmt(source, value_expr.loc, lockfile.allocator, "Invalid override value for \"{s}\"", .{k}); - continue; - } + // Check for pnpm-style > delimiter in key + if (splitPnpmDelimiter(k)) |parts| { + try this.parsePnpmChain(pm, lockfile, root_package, source, log, parts.parent, parts.child, prop.value.?, builder, parent_node_id); + continue; + } + + const parsed_key = parseKeyWithVersion(k); + const pkg_name = parsed_key.name; + const key_spec_str = parsed_key.spec; + const name_hash = String.Builder.stringHash(pkg_name); + + const value_expr = prop.value.?; + + if (value_expr.data == .e_string) { + // Leaf: string value + const version_str = value_expr.data.e_string.slice(lockfile.allocator); + if (strings.hasPrefixComptime(version_str, "patch:")) { + try log.addWarningFmt(source, key.loc, lockfile.allocator, "Bun currently does not support patched package \"overrides\"", .{}); + continue; + } + + if (try parseOverrideValue( + "override", + lockfile, + pm, + root_package, + source, + value_expr.loc, + log, + pkg_name, + version_str, + builder, + )) |version| { + if (is_root_level and parent_node_id == 0) { + // Global override: add to flat map + this.map.putAssumeCapacity(name_hash, version); } else { - try log.addWarningFmt(source, value_expr.loc, lockfile.allocator, "Bun currently does not support nested \"overrides\"", .{}); - continue; + // Nested override: add to tree only + try this.ensureRootNode(lockfile.allocator); + const key_spec = if (key_spec_str.len > 0) builder.append(String, key_spec_str) else String{}; + _ = try this.getOrAddChild(lockfile.allocator, parent_node_id, .{ + .name = builder.appendWithHash(String, pkg_name, name_hash), + .name_hash = name_hash, + .key_spec = key_spec, + .value = version, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }, lockfile.buffers.string_bytes.items); } } + } else if (value_expr.data == .e_object) { + // Object value: can have "." for self-override plus nested children + var self_value: ?Dependency = null; + + if (value_expr.asProperty(".")) |dot| { + if (dot.expr.data == .e_string) { + const version_str = dot.expr.data.e_string.slice(lockfile.allocator); + if (!strings.hasPrefixComptime(version_str, "patch:")) { + self_value = try parseOverrideValue( + "override", + lockfile, + pm, + root_package, + source, + dot.expr.loc, + log, + pkg_name, + version_str, + builder, + ); + } + } + } + + // Check if there are non-"." properties (nested children) + var has_children = false; + for (value_expr.data.e_object.properties.slice()) |child_prop| { + const child_key = child_prop.key.?.asString(lockfile.allocator).?; + if (!strings.eql(child_key, ".")) { + has_children = true; + break; + } + } + + if (is_root_level and parent_node_id == 0 and self_value != null and !has_children) { + // Simple case: only "." key at root level, treat as flat override + this.map.putAssumeCapacity(name_hash, self_value.?); + } else { + // Add to tree + try this.ensureRootNode(lockfile.allocator); + const key_spec = if (key_spec_str.len > 0) builder.append(String, key_spec_str) else String{}; + + if (is_root_level and self_value != null) { + // Also add to flat map for backward compat + this.map.putAssumeCapacity(name_hash, self_value.?); + } + + const node_id = try this.getOrAddChild(lockfile.allocator, parent_node_id, .{ + .name = builder.appendWithHash(String, pkg_name, name_hash), + .name_hash = name_hash, + .key_spec = key_spec, + .value = self_value, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }, lockfile.buffers.string_bytes.items); + + // Recurse into children + try this.parseOverrideObject(pm, lockfile, root_package, source, log, value_expr, builder, node_id, false); + } + } else { try log.addWarningFmt(source, value_expr.loc, lockfile.allocator, "Invalid override value for \"{s}\"", .{k}); - continue; - }; - - const version_str = value.data.e_string.slice(lockfile.allocator); - if (strings.hasPrefixComptime(version_str, "patch:")) { - // TODO(dylan-conway): apply .patch files to packages - try log.addWarningFmt(source, key.loc, lockfile.allocator, "Bun currently does not support patched package \"overrides\"", .{}); - continue; } + } +} - if (try parseOverrideValue( - "override", - lockfile, - pm, - root_package, - source, - value.loc, - log, - k, - version_str, - builder, - )) |version| { - this.map.putAssumeCapacity(name_hash, version); - } +/// Parse a pnpm-style "parent>child" chain for overrides. +fn parsePnpmChain( + this: *OverrideMap, + pm: *PackageManager, + lockfile: *Lockfile, + root_package: *Lockfile.Package, + source: *const logger.Source, + log: *logger.Log, + parent_str: []const u8, + child_str: []const u8, + value_expr: Expr, + builder: *Lockfile.StringBuilder, + base_parent_id: NodeID, +) !void { + if (value_expr.data != .e_string) return; + const version_str = value_expr.data.e_string.slice(lockfile.allocator); + if (strings.hasPrefixComptime(version_str, "patch:")) return; + + try this.ensureRootNode(lockfile.allocator); + + // Parse parent + const parent_parsed = parseKeyWithVersion(parent_str); + const parent_name_hash = String.Builder.stringHash(parent_parsed.name); + const parent_key_spec = if (parent_parsed.spec.len > 0) builder.append(String, parent_parsed.spec) else String{}; + const parent_node_id = try this.getOrAddChild(lockfile.allocator, base_parent_id, .{ + .name = builder.appendWithHash(String, parent_parsed.name, parent_name_hash), + .name_hash = parent_name_hash, + .key_spec = parent_key_spec, + .value = null, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }, lockfile.buffers.string_bytes.items); + + // Parse child - check for further > splits + if (splitPnpmDelimiter(child_str)) |parts| { + try this.parsePnpmChain(pm, lockfile, root_package, source, log, parts.parent, parts.child, value_expr, builder, parent_node_id); + return; + } + + const child_parsed = parseKeyWithVersion(child_str); + const child_name = child_parsed.name; + const child_name_hash = String.Builder.stringHash(child_name); + const child_key_spec = if (child_parsed.spec.len > 0) builder.append(String, child_parsed.spec) else String{}; + + if (try parseOverrideValue( + "override", + lockfile, + pm, + root_package, + source, + value_expr.loc, + log, + child_name, + version_str, + builder, + )) |version| { + _ = try this.getOrAddChild(lockfile.allocator, parent_node_id, .{ + .name = builder.appendWithHash(String, child_name, child_name_hash), + .name_hash = child_name_hash, + .key_spec = child_key_spec, + .value = version, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }, lockfile.buffers.string_bytes.items); } } @@ -227,7 +792,8 @@ pub fn parseFromResolutions( for (expr.data.e_object.properties.slice()) |prop| { const key = prop.key.?; var k = key.asString(lockfile.allocator).?; - if (strings.hasPrefixComptime(k, "**/")) + // Strip all **/ prefixes + while (strings.hasPrefixComptime(k, "**/")) k = k[3..]; if (k.len == 0) { try log.addWarningFmt(source, key.loc, lockfile.allocator, "Missing resolution package name", .{}); @@ -238,22 +804,6 @@ pub fn parseFromResolutions( try log.addWarningFmt(source, key.loc, lockfile.allocator, "Expected string value for resolution \"{s}\"", .{k}); continue; } - // currently we only support one level deep, so we should error if there are more than one - // - "foo/bar": - // - "@namespace/hello/world" - if (k[0] == '@') { - const first_slash = strings.indexOfChar(k, '/') orelse { - try log.addWarningFmt(source, key.loc, lockfile.allocator, "Invalid package name \"{s}\"", .{k}); - continue; - }; - if (strings.indexOfChar(k[first_slash + 1 ..], '/') != null) { - try log.addWarningFmt(source, key.loc, lockfile.allocator, "Bun currently does not support nested \"resolutions\"", .{}); - continue; - } - } else if (strings.indexOfChar(k, '/') != null) { - try log.addWarningFmt(source, key.loc, lockfile.allocator, "Bun currently does not support nested \"resolutions\"", .{}); - continue; - } const version_str = value.data.e_string.data; if (strings.hasPrefixComptime(version_str, "patch:")) { @@ -262,20 +812,198 @@ pub fn parseFromResolutions( continue; } - if (try parseOverrideValue( - "resolution", - lockfile, - pm, - root_package, - source, - value.loc, - log, - k, - version_str, - builder, - )) |version| { - const name_hash = String.Builder.stringHash(k); - this.map.putAssumeCapacity(name_hash, version); + // Check for > delimiter (pnpm style in resolutions) + if (splitPnpmDelimiter(k)) |parts| { + try this.parsePnpmChain(pm, lockfile, root_package, source, log, parts.parent, parts.child, value, builder, 0); + continue; + } + + // Parse path segments (e.g., "parent/child" or "@scope/parent/child") + const segments = splitResolutionPath(k); + if (segments.count == 1) { + // Simple resolution (no nesting) + if (try parseOverrideValue( + "resolution", + lockfile, + pm, + root_package, + source, + value.loc, + log, + segments.last, + version_str, + builder, + )) |version| { + const name_hash = String.Builder.stringHash(segments.last); + this.map.putAssumeCapacity(name_hash, version); + } + } else { + // Nested resolution path: build tree chain + try this.ensureRootNode(lockfile.allocator); + var current_parent: NodeID = 0; + + // Add intermediate nodes + for (0..segments.count - 1) |seg_i| { + const seg = segments.get(seg_i); + const seg_hash = String.Builder.stringHash(seg); + current_parent = try this.getOrAddChild(lockfile.allocator, current_parent, .{ + .name = builder.appendWithHash(String, seg, seg_hash), + .name_hash = seg_hash, + .key_spec = String{}, + .value = null, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }, lockfile.buffers.string_bytes.items); + } + + // Add leaf node with the override value + if (try parseOverrideValue( + "resolution", + lockfile, + pm, + root_package, + source, + value.loc, + log, + segments.last, + version_str, + builder, + )) |version| { + const leaf_hash = String.Builder.stringHash(segments.last); + _ = try this.getOrAddChild(lockfile.allocator, current_parent, .{ + .name = builder.appendWithHash(String, segments.last, leaf_hash), + .name_hash = leaf_hash, + .key_spec = String{}, + .value = version, + .first_child = invalid_node_id, + .next_sibling = invalid_node_id, + .parent = invalid_node_id, + }, lockfile.buffers.string_bytes.items); + } + } + } +} + +const ResolutionSegments = struct { + segments: [8][]const u8 = undefined, + count: usize = 0, + last: []const u8 = "", + + fn get(this: *const ResolutionSegments, idx: usize) []const u8 { + return this.segments[idx]; + } +}; + +/// Split a resolution path like "parent/child" or "@scope/parent/child" into segments. +/// Handles scoped packages correctly. +fn splitResolutionPath(k: []const u8) ResolutionSegments { + var result = ResolutionSegments{}; + var remaining = k; + + while (remaining.len > 0 and result.count < 8) { + // Strip **/ prefixes + while (strings.hasPrefixComptime(remaining, "**/")) remaining = remaining[3..]; + if (remaining.len == 0) break; + + if (remaining[0] == '@') { + // Scoped package: @scope/name + const first_slash = strings.indexOfChar(remaining, '/') orelse { + // Malformed, treat rest as one segment + result.segments[result.count] = remaining; + result.count += 1; + result.last = remaining; + break; + }; + if (first_slash + 1 >= remaining.len) { + result.segments[result.count] = remaining; + result.count += 1; + result.last = remaining; + break; + } + const after_scope = remaining[first_slash + 1 ..]; + const next_slash = strings.indexOfChar(after_scope, '/'); + if (next_slash) |ns| { + const segment = remaining[0 .. first_slash + 1 + ns]; + result.segments[result.count] = segment; + result.count += 1; + remaining = after_scope[ns + 1 ..]; + } else { + // Last segment + result.segments[result.count] = remaining; + result.count += 1; + result.last = remaining; + break; + } + } else { + const slash = strings.indexOfChar(remaining, '/'); + if (slash) |s| { + result.segments[result.count] = remaining[0..s]; + result.count += 1; + remaining = remaining[s + 1 ..]; + } else { + result.segments[result.count] = remaining; + result.count += 1; + result.last = remaining; + break; + } + } + } + + if (result.count > 0 and result.last.len == 0) { + result.last = result.segments[result.count - 1]; + } + + return result; +} + +/// Parse pnpm.overrides field +fn parseFromPnpmOverrides( + this: *OverrideMap, + pm: *PackageManager, + lockfile: *Lockfile, + root_package: *Lockfile.Package, + source: *const logger.Source, + log: *logger.Log, + expr: Expr, + builder: *Lockfile.StringBuilder, +) !void { + if (expr.data != .e_object) { + try log.addWarningFmt(source, expr.loc, lockfile.allocator, "\"pnpm.overrides\" must be an object", .{}); + return; + } + + for (expr.data.e_object.properties.slice()) |prop| { + const key = prop.key.?; + const k = key.asString(lockfile.allocator).?; + if (k.len == 0) continue; + + const value = prop.value.?; + if (value.data != .e_string) continue; + + // Check for > delimiter + if (splitPnpmDelimiter(k)) |parts| { + try this.parsePnpmChain(pm, lockfile, root_package, source, log, parts.parent, parts.child, value, builder, 0); + } else { + // Simple flat override + const version_str = value.data.e_string.slice(lockfile.allocator); + if (strings.hasPrefixComptime(version_str, "patch:")) continue; + + if (try parseOverrideValue( + "override", + lockfile, + pm, + root_package, + source, + value.loc, + log, + k, + version_str, + builder, + )) |version| { + const name_hash = String.Builder.stringHash(k); + try this.map.put(lockfile.allocator, name_hash, version); + } } } } @@ -339,6 +1067,21 @@ pub fn parseOverrideValue( }; } +/// Collect all name_hashes from tree leaf nodes (nodes with values). +pub fn collectTreeLeafHashes(this: *const OverrideMap, allocator: Allocator) ![]PackageNameHash { + if (this.nodes.items.len == 0) return &.{}; + var result = std.ArrayListUnmanaged(PackageNameHash){}; + for (this.nodes.items) |node| { + if (node.value != null and node.name_hash != 0) { + // Deduplicate + if (std.mem.indexOfScalar(PackageNameHash, result.items, node.name_hash) == null) { + try result.append(allocator, node.name_hash); + } + } + } + return result.toOwnedSlice(allocator); +} + const string = []const u8; const std = @import("std"); diff --git a/src/install/lockfile/Package.zig b/src/install/lockfile/Package.zig index 48c8970699..635ba9de92 100644 --- a/src/install/lockfile/Package.zig +++ b/src/install/lockfile/Package.zig @@ -596,6 +596,22 @@ pub fn Package(comptime SemverIntType: type) type { } } + // Also compare override trees + if (!summary.overrides_changed) { + from_lockfile.overrides.sort(from_lockfile); + to_lockfile.overrides.sort(to_lockfile); + if (!from_lockfile.overrides.treeEquals( + &to_lockfile.overrides, + from_lockfile.buffers.string_bytes.items, + to_lockfile.buffers.string_bytes.items, + )) { + summary.overrides_changed = true; + if (PackageManager.verbose_install) { + Output.prettyErrorln("Override tree changed since last install", .{}); + } + } + } + if (is_root) catalogs: { // don't sort if lengths are different diff --git a/src/install/lockfile/bun.lock.zig b/src/install/lockfile/bun.lock.zig index de72aaf9a7..f68e8f8d09 100644 --- a/src/install/lockfile/bun.lock.zig +++ b/src/install/lockfile/bun.lock.zig @@ -300,7 +300,7 @@ pub const Stringifier = struct { ); } - if (lockfile.overrides.map.count() > 0) { + if (lockfile.overrides.map.count() > 0 or lockfile.overrides.hasTree()) { lockfile.overrides.sort(lockfile); try writeIndent(writer, indent); @@ -309,12 +309,33 @@ pub const Stringifier = struct { \\ ); indent.* += 1; - for (lockfile.overrides.map.values()) |override_dep| { - try writeIndent(writer, indent); - try writer.print( - \\{f}: {f}, - \\ - , .{ override_dep.name.fmtJson(buf, .{}), override_dep.version.literal.fmtJson(buf, .{}) }); + + if (lockfile.overrides.hasTree()) { + // Write tree nodes recursively, starting from root's children + try writeOverrideTree(writer, &lockfile.overrides, buf, indent); + } else { + // Write flat overrides + for (lockfile.overrides.map.values()) |override_dep| { + try writeIndent(writer, indent); + try writer.print( + \\{f}: {f}, + \\ + , .{ override_dep.name.fmtJson(buf, .{}), override_dep.version.literal.fmtJson(buf, .{}) }); + } + } + + // Also write flat-only overrides that are not in the tree + if (lockfile.overrides.hasTree()) { + for (lockfile.overrides.map.values()) |override_dep| { + const name_hash = override_dep.name_hash; + // Skip if this override is already represented in the tree + if (lockfile.overrides.findChild(0, name_hash) != null) continue; + try writeIndent(writer, indent); + try writer.print( + \\{f}: {f}, + \\ + , .{ override_dep.name.fmtJson(buf, .{}), override_dep.version.literal.fmtJson(buf, .{}) }); + } } try decIndent(writer, indent); @@ -961,6 +982,64 @@ pub const Stringifier = struct { try writer.writeAll("},"); } + fn writeOverrideTree(writer: *std.Io.Writer, overrides: *const OverrideMap, buf: string, indent: *u32) std.Io.Writer.Error!void { + if (overrides.nodes.items.len == 0) return; + try writeOverrideNodeChildren(writer, overrides, 0, buf, indent); + } + + fn writeOverrideNodeChildren(writer: *std.Io.Writer, overrides: *const OverrideMap, node_id: OverrideMap.NodeID, buf: string, indent: *u32) std.Io.Writer.Error!void { + if (node_id >= overrides.nodes.items.len) return; + var child_id = overrides.nodes.items[node_id].first_child; + while (child_id != OverrideMap.invalid_node_id) { + if (child_id >= overrides.nodes.items.len) break; + const child = overrides.nodes.items[child_id]; + + try writeIndent(writer, indent); + + if (child.first_child != OverrideMap.invalid_node_id) { + // Has children: write as object with key = name or name@key_spec + try writeOverrideNodeKey(writer, child, buf); + try writer.writeAll(": {\n"); + indent.* += 1; + if (child.value) |val| { + try writeIndent(writer, indent); + try writer.print( + \\".": {f}, + \\ + , .{val.version.literal.fmtJson(buf, .{})}); + } + try writeOverrideNodeChildren(writer, overrides, child_id, buf, indent); + try decIndent(writer, indent); + try writer.writeAll("},\n"); + } else if (child.value) |val| { + // Leaf with value: write key = name or name@key_spec + try writeOverrideNodeKey(writer, child, buf); + try writer.print( + \\: {f}, + \\ + , .{val.version.literal.fmtJson(buf, .{})}); + } + + child_id = child.next_sibling; + } + } + + /// Write the JSON key for an override node: "name" or "name@key_spec" + fn writeOverrideNodeKey(writer: *std.Io.Writer, node: OverrideMap.OverrideNode, buf: string) std.Io.Writer.Error!void { + const key_spec_str = node.key_spec.slice(buf); + if (key_spec_str.len > 0) { + // Write "name@key_spec" as a single JSON string + const name_str = node.name.slice(buf); + try writer.writeAll("\""); + try writer.writeAll(name_str); + try writer.writeAll("@"); + try writer.writeAll(key_spec_str); + try writer.writeAll("\""); + } else { + try writer.print("{f}", .{node.name.fmtJson(buf, .{})}); + } + } + fn writeIndent(writer: *std.Io.Writer, indent: *const u32) std.Io.Writer.Error!void { for (0..indent.*) |_| { try writer.writeAll(" " ** indent_scalar); @@ -1223,49 +1302,7 @@ pub fn parseIntoBinaryLockfile( return error.InvalidOverridesObject; } - for (overrides_expr.data.e_object.properties.slice()) |prop| { - const key = prop.key.?; - const value = prop.value.?; - - if (!key.isString() or key.data.e_string.len() == 0) { - try log.addError(source, key.loc, "Expected a non-empty string"); - return error.InvalidOverridesObject; - } - - const name_str = key.asString(allocator).?; - const name_hash = String.Builder.stringHash(name_str); - const name = try string_buf.appendWithHash(name_str, name_hash); - - // TODO(dylan-conway) also accept object when supported - if (!value.isString()) { - try log.addError(source, value.loc, "Expected a string"); - return error.InvalidOverridesObject; - } - - const version_str = value.asString(allocator).?; - const version_hash = String.Builder.stringHash(version_str); - const version = try string_buf.appendWithHash(version_str, version_hash); - const version_sliced = version.sliced(string_buf.bytes.items); - - const dep: Dependency = .{ - .name = name, - .name_hash = name_hash, - .version = Dependency.parse( - allocator, - name, - name_hash, - version_sliced.slice, - &version_sliced, - log, - manager, - ) orelse { - try log.addError(source, value.loc, "Invalid override version"); - return error.InvalidOverridesObject; - }, - }; - - try lockfile.overrides.map.put(allocator, name_hash, dep); - } + try parseOverridesFromLockfileObj(lockfile, overrides_expr, allocator, &string_buf, log, source, manager, 0); } if (root.get("catalog")) |catalog_expr| { @@ -2038,6 +2075,139 @@ pub fn parseIntoBinaryLockfile( } } +fn parseOverridesFromLockfileObj( + lockfile: *BinaryLockfile, + expr: Expr, + allocator: std.mem.Allocator, + string_buf: *String.Buf, + log: *logger.Log, + source: *const logger.Source, + manager: ?*PackageManager, + parent_node_id: OverrideMap.NodeID, +) !void { + if (!expr.isObject()) return; + + for (expr.data.e_object.properties.slice()) |prop| { + const key = prop.key.?; + const value = prop.value.?; + + if (!key.isString() or key.data.e_string.len() == 0) { + try log.addError(source, key.loc, "Expected a non-empty string"); + return error.InvalidOverridesObject; + } + + const raw_key_str = key.asString(allocator).?; + // Skip "." key (handled by parent) + if (strings.eql(raw_key_str, ".")) continue; + + // Parse key: "name" or "name@key_spec" + const parsed_key = OverrideMap.parseKeyWithVersion(raw_key_str); + const name_str = parsed_key.name; + const key_spec_str = parsed_key.spec; + + const name_hash = String.Builder.stringHash(name_str); + const name = try string_buf.appendWithHash(name_str, name_hash); + const key_spec_s = if (key_spec_str.len > 0) try string_buf.append(key_spec_str) else String{}; + + if (value.isString()) { + const version_str = value.asString(allocator).?; + const version_hash = String.Builder.stringHash(version_str); + const version_s = try string_buf.appendWithHash(version_str, version_hash); + const version_sliced = version_s.sliced(string_buf.bytes.items); + + const dep: Dependency = .{ + .name = name, + .name_hash = name_hash, + .version = Dependency.parse( + allocator, + name, + name_hash, + version_sliced.slice, + &version_sliced, + log, + manager, + ) orelse { + try log.addError(source, value.loc, "Invalid override version"); + return error.InvalidOverridesObject; + }, + }; + + if (parent_node_id == 0 and lockfile.overrides.nodes.items.len == 0) { + try lockfile.overrides.map.put(allocator, name_hash, dep); + } else { + try lockfile.overrides.ensureRootNode(allocator); + _ = try lockfile.overrides.getOrAddChild(allocator, parent_node_id, .{ + .name = name, + .name_hash = name_hash, + .key_spec = key_spec_s, + .value = dep, + .first_child = OverrideMap.invalid_node_id, + .next_sibling = OverrideMap.invalid_node_id, + .parent = OverrideMap.invalid_node_id, + }, string_buf.bytes.items); + } + } else if (value.isObject()) { + var self_dep: ?Dependency = null; + + if (value.asProperty(".")) |dot_prop| { + if (dot_prop.expr.isString()) { + const dot_str = dot_prop.expr.asString(allocator).?; + const dot_hash = String.Builder.stringHash(dot_str); + const dot_s = try string_buf.appendWithHash(dot_str, dot_hash); + const dot_sliced = dot_s.sliced(string_buf.bytes.items); + self_dep = .{ + .name = name, + .name_hash = name_hash, + .version = Dependency.parse( + allocator, + name, + name_hash, + dot_sliced.slice, + &dot_sliced, + log, + manager, + ) orelse { + try log.addError(source, dot_prop.expr.loc, "Invalid override version"); + return error.InvalidOverridesObject; + }, + }; + } + } + + var has_children = false; + for (value.data.e_object.properties.slice()) |child_prop| { + const ck = child_prop.key.?.asString(allocator).?; + if (!strings.eql(ck, ".")) { + has_children = true; + break; + } + } + + if (!has_children and self_dep != null and parent_node_id == 0 and lockfile.overrides.nodes.items.len == 0) { + try lockfile.overrides.map.put(allocator, name_hash, self_dep.?); + } else { + try lockfile.overrides.ensureRootNode(allocator); + if (self_dep != null and parent_node_id == 0) { + try lockfile.overrides.map.put(allocator, name_hash, self_dep.?); + } + const node_id = try lockfile.overrides.getOrAddChild(allocator, parent_node_id, .{ + .name = name, + .name_hash = name_hash, + .key_spec = key_spec_s, + .value = self_dep, + .first_child = OverrideMap.invalid_node_id, + .next_sibling = OverrideMap.invalid_node_id, + .parent = OverrideMap.invalid_node_id, + }, string_buf.bytes.items); + try parseOverridesFromLockfileObj(lockfile, value, allocator, string_buf, log, source, manager, node_id); + } + } else { + try log.addError(source, value.loc, "Expected a string or object"); + return error.InvalidOverridesObject; + } + } +} + fn mapDepToPkg(dep: *Dependency, dep_id: DependencyID, pkg_id: PackageID, lockfile: *BinaryLockfile, pkg_resolutions: []const Resolution) void { lockfile.buffers.resolutions.items[dep_id] = pkg_id; @@ -2253,6 +2423,7 @@ const invalid_package_id = Install.invalid_package_id; const BinaryLockfile = bun.install.Lockfile; const DependencySlice = BinaryLockfile.DependencySlice; +const OverrideMap = BinaryLockfile.OverrideMap; const LoadResult = BinaryLockfile.LoadResult; const Meta = BinaryLockfile.Package.Meta; diff --git a/src/install/lockfile/bun.lockb.zig b/src/install/lockfile/bun.lockb.zig index 14f0ea2ae7..68b85b05a1 100644 --- a/src/install/lockfile/bun.lockb.zig +++ b/src/install/lockfile/bun.lockb.zig @@ -8,6 +8,7 @@ const has_workspace_package_ids_tag: u64 = @bitCast(@as([8]u8, "wOrKsPaC".*)); const has_trusted_dependencies_tag: u64 = @bitCast(@as([8]u8, "tRuStEDd".*)); const has_empty_trusted_dependencies_tag: u64 = @bitCast(@as([8]u8, "eMpTrUsT".*)); const has_overrides_tag: u64 = @bitCast(@as([8]u8, "oVeRriDs".*)); +const has_nested_overrides_tag: u64 = @bitCast(@as([8]u8, "nStOvRd\x00".*)); const has_catalogs_tag: u64 = @bitCast(@as([8]u8, "cAtAlOgS".*)); const has_config_version_tag: u64 = @bitCast(@as([8]u8, "cNfGvRsN".*)); @@ -156,6 +157,29 @@ pub fn save(this: *Lockfile, options: *const PackageManager.Options, bytes: *std ); } + // Write nested override tree (if any) + if (this.overrides.hasTree()) { + try writer.writeAll(std.mem.asBytes(&has_nested_overrides_tag)); + + const node_count: u32 = @intCast(this.overrides.nodes.items.len); + try writer.writeAll(std.mem.asBytes(&node_count)); + + var external_nodes = try std.ArrayListUnmanaged(OverrideMap.OverrideNode.External).initCapacity(z_allocator, node_count); + defer external_nodes.deinit(z_allocator); + external_nodes.items.len = node_count; + for (external_nodes.items, this.overrides.nodes.items) |*dest, src| { + dest.* = src.toExternal(); + } + try Lockfile.Buffers.writeArray( + StreamType, + stream, + @TypeOf(writer), + writer, + []OverrideMap.OverrideNode.External, + external_nodes.items, + ); + } + if (this.patched_dependencies.entries.len > 0) { for (this.patched_dependencies.values()) |patched_dep| bun.assert(!patched_dep.patchfile_hash_is_null); @@ -475,6 +499,39 @@ pub fn load( } } + // Read nested override tree + { + const remaining_in_buffer = total_buffer_size -| stream.pos; + + if (remaining_in_buffer > 8 and total_buffer_size <= stream.buffer.len) { + const next_num = try reader.readInt(u64, .little); + if (next_num == has_nested_overrides_tag) { + const node_count = try reader.readInt(u32, .little); + if (node_count > 0) { + const external_nodes = try Lockfile.Buffers.readArray( + stream, + allocator, + std.ArrayListUnmanaged(OverrideMap.OverrideNode.External), + ); + const context: Dependency.Context = .{ + .allocator = allocator, + .log = log, + .buffer = lockfile.buffers.string_bytes.items, + .package_manager = manager, + }; + var nodes = &lockfile.overrides.nodes; + try nodes.ensureTotalCapacity(allocator, external_nodes.items.len); + for (external_nodes.items) |ext_node| { + nodes.appendAssumeCapacity(OverrideMap.OverrideNode.fromExternal(ext_node, context)); + } + lockfile.overrides.rebuildParentPointers(); + } + } else { + stream.pos -= 8; + } + } + } + { const remaining_in_buffer = total_buffer_size -| stream.pos; @@ -634,6 +691,7 @@ const PatchedDep = install.PatchedDep; const alignment_bytes_to_repeat_buffer = install.alignment_bytes_to_repeat_buffer; const Lockfile = install.Lockfile; +const OverrideMap = Lockfile.OverrideMap; const PackageIndex = Lockfile.PackageIndex; const Stream = Lockfile.Stream; const StringPool = Lockfile.StringPool; diff --git a/test/cli/install/overrides.test.ts b/test/cli/install/overrides.test.ts index 9ea001d97e..297d66a39b 100644 --- a/test/cli/install/overrides.test.ts +++ b/test/cli/install/overrides.test.ts @@ -1,7 +1,7 @@ import { write } from "bun"; -import { beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import { readFileSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, tmpdirSync } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "path"; beforeAll(() => { @@ -55,155 +55,150 @@ function ensureLockfileDoesntChangeOnBunI(cwd: string) { } test("overrides affect your own packages", async () => { - const tmp = tmpdirSync(); - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ + using dir = tempDir("override-own-pkg", { + "package.json": JSON.stringify({ dependencies: {}, overrides: { lodash: "4.0.0", }, }), - ); - install(tmp, ["install", "lodash"]); - expect(versionOf(tmp, "node_modules/lodash/package.json")).toBe("4.0.0"); - ensureLockfileDoesntChangeOnBunI(tmp); + }); + const cwd = String(dir); + install(cwd, ["install", "lodash"]); + expect(versionOf(cwd, "node_modules/lodash/package.json")).toBe("4.0.0"); + ensureLockfileDoesntChangeOnBunI(cwd); }); test("overrides affects all dependencies", async () => { - const tmp = tmpdirSync(); - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ + using dir = tempDir("override-all-deps", { + "package.json": JSON.stringify({ dependencies: {}, overrides: { bytes: "1.0.0", }, }), - ); - install(tmp, ["install", "express@4.18.2"]); - expect(versionOf(tmp, "node_modules/bytes/package.json")).toBe("1.0.0"); + }); + const cwd = String(dir); + install(cwd, ["install", "express@4.18.2"]); + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); - ensureLockfileDoesntChangeOnBunI(tmp); + ensureLockfileDoesntChangeOnBunI(cwd); }); test("overrides being set later affects all dependencies", async () => { - const tmp = tmpdirSync(); - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ + using dir = tempDir("override-set-later", { + "package.json": JSON.stringify({ dependencies: {}, }), - ); - install(tmp, ["install", "express@4.18.2"]); - expect(versionOf(tmp, "node_modules/bytes/package.json")).not.toBe("1.0.0"); + }); + const cwd = String(dir); + install(cwd, ["install", "express@4.18.2"]); + expect(versionOf(cwd, "node_modules/bytes/package.json")).not.toBe("1.0.0"); - ensureLockfileDoesntChangeOnBunI(tmp); + ensureLockfileDoesntChangeOnBunI(cwd); writeFileSync( - join(tmp, "package.json"), + join(cwd, "package.json"), JSON.stringify({ - ...JSON.parse(readFileSync(join(tmp, "package.json")).toString()), + ...JSON.parse(readFileSync(join(cwd, "package.json")).toString()), overrides: { bytes: "1.0.0", }, }), ); - install(tmp, ["install"]); - expect(versionOf(tmp, "node_modules/bytes/package.json")).toBe("1.0.0"); + install(cwd, ["install"]); + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); - ensureLockfileDoesntChangeOnBunI(tmp); + ensureLockfileDoesntChangeOnBunI(cwd); }); test("overrides to npm specifier", async () => { - const tmp = tmpdirSync(); - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ + using dir = tempDir("override-npm-spec", { + "package.json": JSON.stringify({ dependencies: {}, overrides: { bytes: "npm:lodash@4.0.0", }, }), - ); - install(tmp, ["install", "express@4.18.2"]); + }); + const cwd = String(dir); + install(cwd, ["install", "express@4.18.2"]); - const bytes = JSON.parse(readFileSync(join(tmp, "node_modules/bytes/package.json"), "utf-8")); + const bytes = JSON.parse(readFileSync(join(cwd, "node_modules/bytes/package.json"), "utf-8")); expect(bytes.name).toBe("lodash"); expect(bytes.version).toBe("4.0.0"); - ensureLockfileDoesntChangeOnBunI(tmp); + ensureLockfileDoesntChangeOnBunI(cwd); }); test("changing overrides makes the lockfile changed, prevent frozen install", async () => { - const tmp = tmpdirSync(); - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ + using dir = tempDir("override-frozen", { + "package.json": JSON.stringify({ dependencies: {}, overrides: { bytes: "1.0.0", }, }), - ); - install(tmp, ["install", "express@4.18.2"]); + }); + const cwd = String(dir); + install(cwd, ["install", "express@4.18.2"]); writeFileSync( - join(tmp, "package.json"), + join(cwd, "package.json"), JSON.stringify({ - ...JSON.parse(readFileSync(join(tmp, "package.json")).toString()), + ...JSON.parse(readFileSync(join(cwd, "package.json")).toString()), overrides: { bytes: "1.0.1", }, }), ); - installExpectFail(tmp, ["install", "--frozen-lockfile"]); + installExpectFail(cwd, ["install", "--frozen-lockfile"]); }); test("overrides reset when removed", async () => { - const tmp = tmpdirSync(); - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ + using dir = tempDir("override-reset", { + "package.json": JSON.stringify({ overrides: { bytes: "1.0.0", }, }), - ); - install(tmp, ["install", "express@4.18.2"]); - expect(versionOf(tmp, "node_modules/bytes/package.json")).toBe("1.0.0"); + }); + const cwd = String(dir); + install(cwd, ["install", "express@4.18.2"]); + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); writeFileSync( - join(tmp, "package.json"), + join(cwd, "package.json"), JSON.stringify({ - ...JSON.parse(readFileSync(join(tmp, "package.json")).toString()), + ...JSON.parse(readFileSync(join(cwd, "package.json")).toString()), overrides: undefined, }), ); - install(tmp, ["install"]); - expect(versionOf(tmp, "node_modules/bytes/package.json")).not.toBe("1.0.0"); + install(cwd, ["install"]); + expect(versionOf(cwd, "node_modules/bytes/package.json")).not.toBe("1.0.0"); - ensureLockfileDoesntChangeOnBunI(tmp); + ensureLockfileDoesntChangeOnBunI(cwd); }); test("overrides do not apply to workspaces", async () => { - const tmp = tmpdirSync(); + using dir = tempDir("override-no-workspace", {}); + const cwd = String(dir); await Promise.all([ write( - join(tmp, "package.json"), + join(cwd, "package.json"), JSON.stringify({ name: "monorepo-root", workspaces: ["packages/*"], overrides: { "pkg1": "file:pkg2" } }), ), write( - join(tmp, "packages", "pkg1", "package.json"), + join(cwd, "packages", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.1.1", }), ), write( - join(tmp, "pkg2", "package.json"), + join(cwd, "pkg2", "package.json"), JSON.stringify({ name: "pkg2", version: "2.2.2", @@ -213,7 +208,7 @@ test("overrides do not apply to workspaces", async () => { let { exited, stderr } = Bun.spawn({ cmd: [bunExe(), "install"], - cwd: tmp, + cwd, env: bunEnv, stderr: "pipe", stdout: "inherit", @@ -225,7 +220,7 @@ test("overrides do not apply to workspaces", async () => { // --frozen-lockfile works ({ exited, stderr } = Bun.spawn({ cmd: [bunExe(), "install", "--frozen-lockfile"], - cwd: tmp, + cwd, env: bunEnv, stderr: "pipe", stdout: "inherit", @@ -238,7 +233,7 @@ test("overrides do not apply to workspaces", async () => { ({ exited, stderr } = Bun.spawn({ cmd: [bunExe(), "install"], - cwd: tmp, + cwd, env: bunEnv, stderr: "pipe", stdout: "inherit", @@ -247,3 +242,876 @@ test("overrides do not apply to workspaces", async () => { expect(await exited).toBe(0); expect(await stderr.text()).not.toContain("Saved lockfile"); }); + +// ---- Nested overrides tests ---- + +describe.concurrent("nested overrides", () => { + // --- Basic nested override functionality --- + + test("npm format: nested override applies to transitive dep under parent", () => { + // First, verify the baseline version without overrides + using baselineDir = tempDir("nested-npm-baseline", { + "package.json": JSON.stringify({ + dependencies: { express: "4.18.2" }, + }), + }); + const baselineCwd = String(baselineDir); + install(baselineCwd, ["install"]); + expect(versionOf(baselineCwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + expect(versionOf(baselineCwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + // Now test with the nested override + using dir = tempDir("nested-npm-basic", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes should be overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd should NOT be affected, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("npm format: self-override with nested children using '.' key", () => { + // Now test with the self-override + nested child + using dir = tempDir("nested-npm-dot-key", { + "package.json": JSON.stringify({ + dependencies: {}, + overrides: { + express: { + ".": "4.18.1", + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install", "express"]); + // POSITIVE: express should be overridden to 4.18.1 (from latest) + expect(versionOf(cwd, "node_modules/express/package.json")).toBe("4.18.1"); + // POSITIVE: bytes should be overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd should NOT be affected, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- Conflicting override versions --- + + test("nested override takes priority over global override for same package", () => { + // First verify global-only override applies correctly + using globalDir = tempDir("nested-priority-global-only", { + "package.json": JSON.stringify({ + dependencies: { express: "4.18.2" }, + overrides: { bytes: "2.0.0" }, + }), + }); + const globalCwd = String(globalDir); + install(globalCwd, ["install"]); + // POSITIVE: global override applies + expect(versionOf(globalCwd, "node_modules/bytes/package.json")).toBe("2.0.0"); + // NEGATIVE: depd is NOT overridden + expect(versionOf(globalCwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + // Now with nested + global: nested should win + using dir = tempDir("nested-priority-over-global", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + bytes: "2.0.0", + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: nested override (1.0.0) takes priority over global (2.0.0) for bytes under express + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd is NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("global override applies when no nested override matches", () => { + using dir = tempDir("nested-global-fallback", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + depd: "1.1.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: global override should change depd from 2.0.0 to 1.1.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("1.1.0"); + // NEGATIVE: bytes is NOT overridden, stays at natural 3.1.2 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("nested override version changes are detected on re-install", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-version-change", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + // Change the nested override to a different version + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "2.0.0", + }, + }, + }), + ); + install(cwd, ["install"]); + // POSITIVE: bytes now overridden from 3.1.2 to 2.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("2.0.0"); + // NEGATIVE: depd still NOT overridden + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- Syntax format tests --- + + test("yarn resolution path: parent/child", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-yarn-path", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + resolutions: { + "express/bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("yarn resolution path with glob prefixes", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-yarn-glob", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + resolutions: { + "**/express/**/bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("pnpm > syntax in overrides field", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-pnpm-gt-overrides", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express>bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("pnpm.overrides field (flat)", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-pnpm-flat", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + pnpm: { + overrides: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("pnpm.overrides with > syntax", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-pnpm-gt-field", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + pnpm: { + overrides: { + "express>bytes": "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- Lockfile lifecycle tests --- + + test("nested override in lockfile round-trips correctly", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-lockfile-roundtrip", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + // Read lockfile + const lockfile1 = readFileSync(join(cwd, "bun.lock"), "utf-8"); + expect(lockfile1).toContain("overrides"); + + // Re-install with --force - should produce identical lockfile + install(cwd, ["install", "--force"]); + const lockfile2 = readFileSync(join(cwd, "bun.lock"), "utf-8"); + expect(lockfile1).toBe(lockfile2); + // POSITIVE: bytes should still be 1.0.0 after round-trip + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd still NOT overridden after round-trip + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + }); + + test("adding nested override after initial install applies correctly", () => { + using dir = tempDir("nested-add-after-install", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // NEGATIVE: bytes is at natural version 3.1.2 (no override yet) + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + // NEGATIVE: depd is at natural version 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + // Add nested override + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "1.0.0", + }, + }, + }), + ); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd still NOT overridden + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("removing nested override restores original version", () => { + using dir = tempDir("nested-remove-override", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + // Remove override + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + }), + ); + install(cwd, ["install"]); + // NEGATIVE: bytes should restore to natural version 3.1.2 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + // NEGATIVE: depd still at natural version 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("nested override change breaks frozen-lockfile", () => { + using dir = tempDir("nested-frozen-break", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + + // Change the nested override + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "2.0.0", + }, + }, + }), + ); + installExpectFail(cwd, ["install", "--frozen-lockfile"]); + }); + + // --- Override with npm: alias specifier in nested context --- + + test("nested override with npm: alias specifier", () => { + // bytes is naturally bytes@3.1.2 with express@4.18.2 + using dir = tempDir("nested-npm-alias", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + bytes: "npm:lodash@4.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + + // POSITIVE: bytes should be aliased from bytes@3.1.2 to lodash@4.0.0 + const bytes = JSON.parse(readFileSync(join(cwd, "node_modules/bytes/package.json"), "utf-8")); + expect(bytes.name).toBe("lodash"); + expect(bytes.version).toBe("4.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- pnpm.overrides coexists with npm overrides --- + + test("pnpm.overrides and npm overrides can coexist", () => { + // bytes is naturally 3.1.2, depd is naturally 2.0.0 with express@4.18.2 + using dir = tempDir("nested-pnpm-npm-coexist", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + bytes: "1.0.0", + }, + pnpm: { + overrides: { + depd: "1.1.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: npm overrides: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // POSITIVE: pnpm.overrides: depd overridden from 2.0.0 to 1.1.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("1.1.0"); + // NEGATIVE: express itself NOT overridden, stays at 4.18.2 + expect(versionOf(cwd, "node_modules/express/package.json")).toBe("4.18.2"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- Multi-level nesting --- + + test("multi-level nesting: express > body-parser > bytes", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-multi-level-npm", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + express: { + "body-parser": { + bytes: "1.0.0", + }, + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 (under body-parser under express) + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("yarn resolution path: multi-level parent/intermediate/child", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-multi-level-yarn", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + resolutions: { + "express/body-parser/bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("pnpm > syntax: multi-level parent>intermediate>child", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-multi-level-pnpm", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express>body-parser>bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- Version-constrained parent keys --- + + test("version-constrained parent key: override applies when version matches", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-version-constraint-match", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express@^4.0.0": { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: express@4.18.2 satisfies ^4.0.0, so bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + // --- Negative test cases --- + + test("version-constrained parent key: override does NOT apply when version does NOT match", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-version-constraint-nomatch", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express@^3.0.0": { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // NEGATIVE: express@4.18.2 does NOT satisfy ^3.0.0, so bytes stays at 3.1.2 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + // NEGATIVE: depd also NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("nested override does not affect packages outside the parent scope", () => { + // bytes is naturally 3.1.2, depd is naturally 2.0.0 with express@4.18.2 + // Override bytes only under body-parser (bytes is only a dep of body-parser) + using dir = tempDir("nested-no-leak-outside-scope", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "body-parser": { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes under body-parser overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("nested override for non-existent parent does not crash", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-nonexistent-parent", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "nonexistent-pkg": { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // NEGATIVE: bytes stays at natural 3.1.2 since nonexistent-pkg is not in the tree + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + // NEGATIVE: depd also stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("pnpm > syntax: version-constrained parent that does not match", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-pnpm-gt-version-nomatch", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express@^3.0.0>bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // NEGATIVE: express@4.18.2 does NOT satisfy ^3.0.0, so bytes stays at 3.1.2 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + // NEGATIVE: depd also stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("multi-level nesting under wrong parent does not apply", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-wrong-parent-chain", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + // "accepts" does not depend on body-parser, so this override chain never matches + accepts: { + "body-parser": { + bytes: "1.0.0", + }, + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // NEGATIVE: bytes stays at natural 3.1.2 because accepts doesn't depend on body-parser + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + // NEGATIVE: depd also stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("pnpm > syntax with version-constrained parent applies when version matches", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-pnpm-gt-version-match", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express@^4.0.0>bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: express@4.18.2 satisfies ^4.0.0, so bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("$reference syntax: override value references root dependency version", () => { + // depd is naturally 2.0.0 with express@4.18.2 + using dir = tempDir("nested-dollar-ref", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + depd: "1.1.0", + }, + overrides: { + depd: "$depd", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: depd overridden from 2.0.0 to 1.1.0 (the root dependency version) + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("1.1.0"); + // NEGATIVE: bytes NOT overridden, stays at natural 3.1.2 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("3.1.2"); + + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("version-constrained key_spec survives lockfile round-trip", () => { + using dir = tempDir("nested-keyspec-roundtrip", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express@^4.0.0": { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + + // Lockfile should contain the version constraint in the key + const lockfile = readFileSync(join(cwd, "bun.lock"), "utf-8"); + expect(lockfile).toContain("express@^4.0.0"); + + // Re-install should produce identical lockfile (key_spec survived round-trip) + install(cwd, ["install", "--force"]); + const lockfile2 = readFileSync(join(cwd, "bun.lock"), "utf-8"); + expect(lockfile).toBe(lockfile2); + + // And bytes should still be overridden + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + }); + + // --- Test gaps from pnpm test suite --- + + test("empty overrides object is handled gracefully", () => { + using dir = tempDir("nested-empty-overrides", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: {}, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("scoped parent with unscoped child in pnpm > syntax", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-scoped-parent", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + "express>bytes": "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("nested override applies to optionalDependencies", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-override-optdeps", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + bytes: "1.0.0", + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: bytes overridden from 3.1.2 to 1.0.0 + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + ensureLockfileDoesntChangeOnBunI(cwd); + }); + + test("override specificity: nested override wins over global for same package", () => { + // bytes is naturally 3.1.2 with express@4.18.2 + using dir = tempDir("nested-specificity", { + "package.json": JSON.stringify({ + dependencies: { + express: "4.18.2", + }, + overrides: { + bytes: "2.0.0", + express: { + bytes: "1.0.0", + }, + }, + }), + }); + const cwd = String(dir); + install(cwd, ["install"]); + // POSITIVE: nested override (1.0.0) wins over global (2.0.0) for bytes under express + // (natural version would be 3.1.2 without any overrides) + expect(versionOf(cwd, "node_modules/bytes/package.json")).toBe("1.0.0"); + // NEGATIVE: depd NOT overridden, stays at natural 2.0.0 + expect(versionOf(cwd, "node_modules/depd/package.json")).toBe("2.0.0"); + ensureLockfileDoesntChangeOnBunI(cwd); + }); +});