also looking at this
fix(install): keep .env-inferred services out of pantry.lock
#254
4 files
+286
-70
| @@ -136,8 +136,11 @@ package, so a fresh checkout does not install cleanly and then fail to start: | ||
| 136 | 136 | - pantry prints each package it adds and the key that caused it, e.g. |
| 137 | 137 | `+ postgresql.org (from DB_CONNECTION=pgsql in .env; PANTRY_ENV_DEPS=0 to skip)`. |
| 138 | 138 | Only those keys are read, and no other `.env` value is printed. |
| 139 | - Under `--frozen-lockfile` a package is inferred only if `pantry.lock` | |
| 140 | already records it, so CI never drifts from the committed lock. | |
| 139 | - **An inference never reaches `pantry.lock`** — not the service, not what it | |
| 140 | pulls in, and not as a constraint on the workspace either. The lock records | |
| 141 | what the workspace declares, so it reads the same on every machine, whatever | |
| 142 | each developer's `.env` says. `--frozen-lockfile` therefore needs no special | |
| 143 | case: an inference cannot move the lock, so it cannot drift from it. | |
| 141 | 144 | - Set `PANTRY_ENV_DEPS=0` to turn inference off. |
| 142 | 145 | |
| 143 | 146 | ### Service Groups |
| @@ -167,11 +167,16 @@ fn tryFastUpToDate(allocator: std.mem.Allocator, cwd: []const u8, start_time: i6 | ||
| 167 | 167 | |
| 168 | 168 | // 4a. Services the project's .env names (DB_CONNECTION=pgsql, ...) count |
| 169 | 169 | // as dependencies too, or a changed driver would never be installed. |
| 170 | // | |
| 171 | // Asked of the disk rather than of the lockfile, because an inference is | |
| 172 | // deliberately absent from the lock (#253). Against the lock this read | |
| 173 | // "not locked, so not up to date" for every inferred service, sending a | |
| 174 | // project with a `.env` down the slow path on every install. | |
| 170 | 175 | { |
| 171 | 176 | const inferred = env_deps.infer(allocator, cwd, deps, .{ .report = false }) catch &.{}; |
| 172 | 177 | defer env_deps.freeDeps(allocator, inferred); |
| 173 | 178 | for (inferred) |dep| { |
| 174 | if (!helpers.canSkipFromLockfile(&lockfile.packages, dep.name, dep.version, cwd, allocator, modules_dir)) return null; | |
| 179 | if (!helpers.isPackageInstalled(cwd, modules_dir, dep.name)) return null; | |
| 175 | 180 | } |
| 176 | 181 | } |
| 177 | 182 | |
| @@ -563,7 +568,6 @@ pub fn installCommandWithOptions(allocator: std.mem.Allocator, args: []const []c | ||
| 563 | 568 | ws_file.root_dir, |
| 564 | 569 | companion_options, |
| 565 | 570 | staged_lockfile_path, |
| 566 | options.frozen_lockfile, | |
| 567 | 571 | ), df.path); |
| 568 | 572 | } |
| 569 | 573 | } |
| @@ -701,11 +705,20 @@ pub fn installCommandWithOptions(allocator: std.mem.Allocator, args: []const []c | ||
| 701 | 705 | allocator, |
| 702 | 706 | if (deps_file_path) |path| std.fs.path.dirname(path) orelse cwd else cwd, |
| 703 | 707 | deps, |
| 704 | .{ .frozen_lockfile = options.frozen_lockfile }, | |
| 708 | .{}, | |
| 705 | 709 | ) catch &.{}; |
| 706 | 710 | defer env_deps.freeDeps(allocator, env_inferred); |
| 707 | 711 | try filtered_deps.appendSlice(allocator, env_inferred); |
| 708 | 712 | |
| 713 | // They are installed, and that is all they are. `pantry.lock` records | |
| 714 | // what the workspace declares, so neither these nor what they pull in | |
| 715 | // may reach it — a lock refreshed on a machine whose `.env` says | |
| 716 | // postgres otherwise carries three entries a checkout without that | |
| 717 | // `.env` removes again (#253). | |
| 718 | var env_only_domains = env_deps.inferredOnlyDomains(allocator, env_inferred, deps) catch | |
| 719 | env_deps.DomainSet{ .allocator = allocator, .map = std.StringHashMap(void).init(allocator) }; | |
| 720 | defer env_only_domains.deinit(); | |
| 721 | ||
| 709 | 722 | // Load overrides/resolutions from package.json if it exists |
| 710 | 723 | var override_map = lib.deps.overrides.OverrideMap.init(allocator); |
| 711 | 724 | defer override_map.deinit(); |
| @@ -1219,15 +1232,21 @@ pub fn installCommandWithOptions(allocator: std.mem.Allocator, args: []const []c | ||
| 1219 | 1232 | const resolved_url = try std.fmt.allocPrint(allocator, "registry:{s}@{s}", .{ clean_name, result.version }); |
| 1220 | 1233 | defer allocator.free(resolved_url); |
| 1221 | 1234 | |
| 1222 | if (options.frozen_lockfile) { | |
| 1223 | if (!resolutionLockMatches(&lock_file, clean_name, result.version, resolved_url)) | |
| 1224 | frozen_lockfile_changed = true; | |
| 1225 | } else if (!options.no_save) { | |
| 1226 | if (lockfile_hooks.getLockedVersionForPackage(&lock_file, clean_name)) |previous| { | |
| 1227 | if (!std.mem.eql(u8, previous.version, result.version)) | |
| 1228 | style.printLockPinMoved(clean_name, previous.version, result.version, false); | |
| 1235 | // An inferred service is outside the lock's contract, so it | |
| 1236 | // is neither written to the lock nor able to report the lock | |
| 1237 | // as out of date. It is still installed, and the checkpoint | |
| 1238 | // below still records it. | |
| 1239 | if (!env_only_domains.contains(helpers.resolvePackageAlias(clean_name))) { | |
| 1240 | if (options.frozen_lockfile) { | |
| 1241 | if (!resolutionLockMatches(&lock_file, clean_name, result.version, resolved_url)) | |
| 1242 | frozen_lockfile_changed = true; | |
| 1243 | } else if (!options.no_save) { | |
| 1244 | if (lockfile_hooks.getLockedVersionForPackage(&lock_file, clean_name)) |previous| { | |
| 1245 | if (!std.mem.eql(u8, previous.version, result.version)) | |
| 1246 | style.printLockPinMoved(clean_name, previous.version, result.version, false); | |
| 1247 | } | |
| 1248 | try lockfile_hooks.addPackageToLockfile(&lock_file, clean_name, result.version, resolved_url, null); | |
| 1229 | 1249 | } |
| 1230 | try lockfile_hooks.addPackageToLockfile(&lock_file, clean_name, result.version, resolved_url, null); | |
| 1231 | 1250 | } |
| 1232 | 1251 | // Nothing of it is on disk to record below. |
| 1233 | 1252 | if (result.lock_only) continue; |
| @@ -1975,7 +1994,6 @@ fn installCompanionDepsFile( | ||
| 1975 | 1994 | project_root: []const u8, |
| 1976 | 1995 | options: types.InstallOptions, |
| 1977 | 1996 | lockfile_path: ?[]const u8, |
| 1978 | frozen_environment: bool, | |
| 1979 | 1997 | ) !types.CommandResult { |
| 1980 | 1998 | const parser = ; |
| 1981 | 1999 | const pipeline = ; |
| @@ -1989,13 +2007,10 @@ fn installCompanionDepsFile( | ||
| 1989 | 2007 | allocator.free(declared_deps); |
| 1990 | 2008 | } |
| 1991 | 2009 | |
| 1992 | // Services the project's .env names, unless declared (#36). A non-null | |
| 1993 | // Frozen installs infer only what the committed lock already records. | |
| 1994 | // A staged path is also used by ordinary installs; it does not make their | |
| 1995 | // environment inference frozen. | |
| 1996 | const env_inferred = env_deps.infer(allocator, project_root, declared_deps, .{ | |
| 1997 | .frozen_lockfile = frozen_environment, | |
| 1998 | }) catch &.{}; | |
| 2010 | // Services the project's .env names, unless declared (#36). What they | |
| 2011 | // resolve to is installed but never locked (#253), so a frozen run infers | |
| 2012 | // exactly what any other run does. | |
| 2013 | const env_inferred = env_deps.infer(allocator, project_root, declared_deps, .{}) catch &.{}; | |
| 1999 | 2014 | defer env_deps.freeDeps(allocator, env_inferred); |
| 2000 | 2015 | const deps = if (env_inferred.len == 0) declared_deps else try std.mem.concat(allocator, parser.PackageDependency, &.{ declared_deps, env_inferred }); |
| 2001 | 2016 | defer if (env_inferred.len > 0) allocator.free(deps); |
| @@ -2092,11 +2107,19 @@ fn installCompanionDepsFile( | ||
| 2092 | 2107 | // lockfile so `pantry install` actually refreshes stale runtime pins while |
| 2093 | 2108 | // preserving every workspace and npm entry. |
| 2094 | 2109 | if (!options.no_save) { |
| 2110 | // `declared_deps`, not `deps`: the constraints recorded on the root | |
| 2111 | // workspace are the ones the workspace declares. `env_only_domains` | |
| 2112 | // keeps the same inference out of the resolved package entries, where | |
| 2113 | // it arrives with everything it pulled in. | |
| 2114 | var env_only_domains = try env_deps.inferredOnlyDomains(allocator, env_inferred, declared_deps); | |
| 2115 | defer env_only_domains.deinit(); | |
| 2116 | ||
| 2095 | 2117 | const lockfile_changed = try syncCompanionLockfile( |
| 2096 | 2118 | allocator, |
| 2097 | 2119 | project_root, |
| 2098 | deps, | |
| 2120 | declared_deps, | |
| 2099 | 2121 | pipeline_result.results, |
| 2122 | &env_only_domains, | |
| 2100 | 2123 | options.frozen_lockfile, |
| 2101 | 2124 | lockfile_path, |
| 2102 | 2125 | ); |
| @@ -2118,6 +2141,7 @@ fn syncCompanionLockfile( | ||
| 2118 | 2141 | project_root: []const u8, |
| 2119 | 2142 | deps: []const .PackageDependency, |
| 2120 | 2143 | results: []const .PackageResult, |
| 2144 | env_only_domains: *const env_deps.DomainSet, | |
| 2121 | 2145 | frozen: bool, |
| 2122 | 2146 | lockfile_path_override: ?[]const u8, |
| 2123 | 2147 | ) !bool { |
| @@ -2138,7 +2162,7 @@ fn syncCompanionLockfile( | ||
| 2138 | 2162 | try lib.packages.Lockfile.init(allocator, "1.0.0"); |
| 2139 | 2163 | defer lockfile.deinit(allocator); |
| 2140 | 2164 | |
| 2141 | try mergeCompanionLockfileEntries(allocator, &lockfile, deps, results); | |
| 2165 | try mergeCompanionLockfileEntries(allocator, &lockfile, deps, results, env_only_domains); | |
| 2142 | 2166 | |
| 2143 | 2167 | const changed = if (original) |*existing| |
| 2144 | 2168 | !lockfile_writer.lockfilesEqual(existing, &lockfile) |
| @@ -2161,6 +2185,7 @@ fn mergeCompanionLockfileEntries( | ||
| 2161 | 2185 | lockfile: *lib.packages.Lockfile, |
| 2162 | 2186 | deps: []const .PackageDependency, |
| 2163 | 2187 | results: []const .PackageResult, |
| 2188 | env_only_domains: *const env_deps.DomainSet, | |
| 2164 | 2189 | ) !void { |
| 2165 | 2190 | // Packages whose resolved version depends on which platform installs them, |
| 2166 | 2191 | // so the sweep below can tell a stale pin from another platform's. |
| @@ -2218,6 +2243,7 @@ fn mergeCompanionLockfileEntries( | ||
| 2218 | 2243 | for (results) |result| { |
| 2219 | 2244 | if (!result.success or result.name.len == 0) continue; |
| 2220 | 2245 | const clean_name = helpers.resolvePackageAlias(helpers.normalizePackageName(result.name)); |
| 2246 | if (env_only_domains.contains(clean_name)) continue; | |
| 2221 | 2247 | |
| 2222 | 2248 | // Replace every older pin for this package, regardless of its key's |
| 2223 | 2249 | // version spelling (constraint or resolved version). |
| @@ -2366,7 +2392,9 @@ test "companion deps replace stale system pins without dropping workspace packag | ||
| 2366 | 2392 | .success = true, |
| 2367 | 2393 | }}; |
| 2368 | 2394 | |
| 2369 | try mergeCompanionLockfileEntries(allocator, &lockfile, &deps, &results); | |
| 2395 | var env_only = env_deps.DomainSet{ .allocator = allocator, .map = std.StringHashMap(void).init(allocator) }; | |
| 2396 | defer env_only.deinit(); | |
| 2397 | try mergeCompanionLockfileEntries(allocator, &lockfile, &deps, &results, &env_only); | |
| 2370 | 2398 | |
| 2371 | 2399 | try t.expectEqualStrings("^1.3.14", lockfile.workspaces.get("").?.system.?.get("bun.sh").?); |
| 2372 | 2400 | try t.expect(lockfile.packages.get("bun.sh@^1.3.4") == null); |
| @@ -2375,6 +2403,47 @@ test "companion deps replace stale system pins without dropping workspace packag | ||
| 2375 | 2403 | try t.expect(lockfile.workspaces.get("packages/ui") != null); |
| 2376 | 2404 | } |
| 2377 | 2405 | |
| 2406 | test "a service inferred from .env stays out of the lock, with what it pulled in" { | |
| 2407 | const t = std.testing; | |
| 2408 | const allocator = t.allocator; | |
| 2409 | const parser = ; | |
| 2410 | const pipeline = ; | |
| 2411 | ||
| 2412 | var lockfile = try lib.packages.Lockfile.init(allocator, "1.0.0"); | |
| 2413 | defer lockfile.deinit(allocator); | |
| 2414 | try lockfile.addWorkspace(allocator, "", .{ .name = try allocator.dupe(u8, "fixture") }); | |
| 2415 | ||
| 2416 | // What the workspace declares. postgresql.org is not in it: `.env` said | |
| 2417 | // DB_CONNECTION=postgres on the machine that ran this install. | |
| 2418 | const declared = [_]parser.PackageDependency{.{ .name = "sqlite.org", .version = "^3" }}; | |
| 2419 | const inferred = [_]parser.PackageDependency{.{ .name = "postgresql.org", .version = "latest" }}; | |
| 2420 | ||
| 2421 | // The pipeline installed all of it, postgres's own dependencies included. | |
| 2422 | const results = [_]pipeline.PackageResult{ | |
| 2423 | .{ .name = "sqlite.org", .version = "3.51.0", .success = true }, | |
| 2424 | .{ .name = "postgresql.org", .version = "18.4.0", .success = true }, | |
| 2425 | .{ .name = "gnome.org/libxslt", .version = "1.1.45", .success = true }, | |
| 2426 | .{ .name = "unicode.org", .version = "73.2.0", .success = true }, | |
| 2427 | }; | |
| 2428 | ||
| 2429 | var env_only = try env_deps.inferredOnlyDomains(allocator, &inferred, &declared); | |
| 2430 | defer env_only.deinit(); | |
| 2431 | try mergeCompanionLockfileEntries(allocator, &lockfile, &declared, &results, &env_only); | |
| 2432 | ||
| 2433 | // The declared package is locked, as always. | |
| 2434 | try t.expectEqualStrings("3.51.0", lockfile.packages.get("sqlite.org@3.51.0").?.version); | |
| 2435 | try t.expectEqualStrings("^3", lockfile.workspaces.get("").?.system.?.get("sqlite.org").?); | |
| 2436 | ||
| 2437 | // The three entries the reported lock gained are absent, as is the | |
| 2438 | // constraint that put "postgresql.org": "latest" on the root workspace — | |
| 2439 | // so this lock is byte-identical to one cut on a machine whose .env says | |
| 2440 | // sqlite (#253). | |
| 2441 | try t.expect(lockfile.packages.get("postgresql.org@18.4.0") == null); | |
| 2442 | try t.expect(lockfile.packages.get("gnome.org/libxslt@1.1.45") == null); | |
| 2443 | try t.expect(lockfile.packages.get("unicode.org@73.2.0") == null); | |
| 2444 | try t.expect(lockfile.workspaces.get("").?.system.?.get("postgresql.org") == null); | |
| 2445 | } | |
| 2446 | ||
| 2378 | 2447 | test "a manifest install fails when a package it asked for did not arrive" { |
| 2379 | 2448 | // The bug this pins: this path returned 0 unconditionally while every other |
| 2380 | 2449 | // install path returned 1, so `pantry install` printed "1 package(s) failed |
| @@ -19,9 +19,15 @@ | ||
| 19 | 19 | //! is added and its pin is untouched. |
| 20 | 20 | //! - Only those keys are read, and only a recognised driver name is ever |
| 21 | 21 | //! printed; no other `.env` value leaves this file. |
| 22 | //! - Under `--frozen-lockfile` the lockfile is the contract: a package is | |
| 23 | //! inferred only if `pantry.lock` already records it, so CI never drifts | |
| 24 | //! from the committed lock. | |
| 22 | //! - An inference never reaches `pantry.lock`, neither as a constraint nor | |
| 23 | //! as a package entry, and neither does anything it drags in. The lock | |
| 24 | //! records what the workspace declares, so it has to read the same on | |
| 25 | //! every machine; a service read out of someone's local `.env` does not. | |
| 26 | //! Writing them in made a lock cut on a Postgres machine carry | |
| 27 | //! postgresql.org, gnome.org/libxslt and unicode.org, which a checkout | |
| 28 | //! without that `.env` then removed again (#253). | |
| 29 | //! - Because of that rule, `--frozen-lockfile` needs no special case here: | |
| 30 | //! an inference cannot move the lock, so it cannot drift from it either. | |
| 25 | 31 | //! - `PANTRY_ENV_DEPS=0` (or `false`/`off`) turns the whole thing off. |
| 26 | 32 | |
| 27 | 33 | const std = ; |
| @@ -119,15 +125,21 @@ pub fn inferFromEnv(content: []const u8, out: *[max_inferences]Inference) []Infe | ||
| 119 | 125 | return out[0..n]; |
| 120 | 126 | } |
| 121 | 127 | |
| 128 | /// The registry domain a dependency name refers to, in any spelling | |
| 129 | /// (`postgres`, `auto:redis`, `postgresql.org@17`). | |
| 130 | pub fn canonicalDomain(dep_name: []const u8) []const u8 { | |
| 131 | var name = helpers.normalizePackageName(dep_name); | |
| 132 | // `postgresql.org@17` written as a single key. | |
| 133 | if (std.mem.lastIndexOfScalar(u8, name, '@')) |at| { | |
| 134 | if (at > 0) name = name[0..at]; | |
| 135 | } | |
| 136 | return helpers.resolvePackageAlias(name); | |
| 137 | } | |
| 138 | ||
| 122 | 139 | /// Does the dependency list already name `domain`, in any spelling? |
| 123 | 140 | pub fn isDeclared(declared: []const parser.PackageDependency, domain: []const u8) bool { |
| 124 | 141 | for (declared) |dep| { |
| 125 | var name = helpers.normalizePackageName(dep.name); | |
| 126 | // `postgresql.org@17` written as a single key. | |
| 127 | if (std.mem.lastIndexOfScalar(u8, name, '@')) |at| { | |
| 128 | if (at > 0) name = name[0..at]; | |
| 129 | } | |
| 130 | if (std.mem.eql(u8, helpers.resolvePackageAlias(name), domain)) return true; | |
| 142 | if (std.mem.eql(u8, canonicalDomain(dep.name), domain)) return true; | |
| 131 | 143 | } |
| 132 | 144 | return false; |
| 133 | 145 | } |
| @@ -137,22 +149,7 @@ pub fn isEnabled() bool { | ||
| 137 | 149 | return !(std.mem.eql(u8, val, "0") or std.ascii.eqlIgnoreCase(val, "false") or std.ascii.eqlIgnoreCase(val, "off") or std.ascii.eqlIgnoreCase(val, "no")); |
| 138 | 150 | } |
| 139 | 151 | |
| 140 | fn lockfileHas(allocator: std.mem.Allocator, project_dir: []const u8, domain: []const u8) bool { | |
| 141 | const lockfile_reader = ; | |
| 142 | const path = std.fs.path.join(allocator, &.{ project_dir, "pantry.lock" }) catch return false; | |
| 143 | defer allocator.free(path); | |
| 144 | var lock = lockfile_reader.readLockfile(allocator, path) catch return false; | |
| 145 | defer lock.deinit(allocator); | |
| 146 | if (lock.packages.get(domain) != null) return true; | |
| 147 | var it = lock.packages.iterator(); | |
| 148 | while (it.next()) |entry| { | |
| 149 | if (std.mem.eql(u8, entry.value_ptr.name, domain)) return true; | |
| 150 | } | |
| 151 | return false; | |
| 152 | } | |
| 153 | ||
| 154 | 152 | pub const Options = struct { |
| 155 | frozen_lockfile: bool = false, | |
| 156 | 153 | /// Print what was added and why. |
| 157 | 154 | report: bool = true, |
| 158 | 155 | }; |
| @@ -184,7 +181,6 @@ pub fn infer( | ||
| 184 | 181 | for (found) |inf| { |
| 185 | 182 | if (isDeclared(declared, inf.domain)) continue; |
| 186 | 183 | if (isDeclared(added.items, inf.domain)) continue; |
| 187 | if (options.frozen_lockfile and !lockfileHas(allocator, project_dir, inf.domain)) continue; | |
| 188 | 184 | |
| 189 | 185 | const name = try allocator.dupe(u8, inf.domain); |
| 190 | 186 | errdefer allocator.free(name); |
| @@ -199,6 +195,106 @@ pub fn infer( | ||
| 199 | 195 | return added.toOwnedSlice(allocator); |
| 200 | 196 | } |
| 201 | 197 | |
| 198 | /// A set of registry domains, owning its keys. | |
| 199 | pub const DomainSet = struct { | |
| 200 | allocator: std.mem.Allocator, | |
| 201 | map: std.StringHashMap(void), | |
| 202 | ||
| 203 | pub fn contains(self: DomainSet, domain: []const u8) bool { | |
| 204 | return self.map.contains(domain); | |
| 205 | } | |
| 206 | ||
| 207 | pub fn deinit(self: *DomainSet) void { | |
| 208 | var it = self.map.keyIterator(); | |
| 209 | while (it.next()) |key| self.allocator.free(key.*); | |
| 210 | self.map.deinit(); | |
| 211 | } | |
| 212 | }; | |
| 213 | ||
| 214 | /// Walk `seeds` and everything the catalog says they need. `visit` is called | |
| 215 | /// with each domain reached, seeds included, once each. | |
| 216 | fn walkCatalog( | |
| 217 | allocator: std.mem.Allocator, | |
| 218 | seeds: []const []const u8, | |
| 219 | context: anytype, | |
| 220 | comptime visit: fn (@TypeOf(context), []const u8) anyerror!void, | |
| 221 | ) !void { | |
| 222 | const generated = ; | |
| 223 | const pipeline = ; | |
| 224 | ||
| 225 | var seen = std.StringHashMap(void).init(allocator); | |
| 226 | defer seen.deinit(); | |
| 227 | ||
| 228 | var frontier: std.ArrayList([]const u8) = .empty; | |
| 229 | defer frontier.deinit(allocator); | |
| 230 | for (seeds) |seed| try frontier.append(allocator, seed); | |
| 231 | ||
| 232 | while (frontier.pop()) |domain| { | |
| 233 | if ((try seen.getOrPut(domain)).found_existing) continue; | |
| 234 | try visit(context, domain); | |
| 235 | const info = generated.getPackageByDomain(domain) orelse continue; | |
| 236 | for (info.dependencies) |spec| { | |
| 237 | // Any OS's guard: a lock is written on one platform and read on | |
| 238 | // another, so what the other one would pull in counts too. | |
| 239 | const parsed = pipeline.parseAnyOsDepSpec(spec) orelse continue; | |
| 240 | try frontier.append(allocator, parsed.domain); | |
| 241 | } | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | /// Domains that are in this install ONLY because `.env` named a service. | |
| 246 | /// | |
| 247 | /// The inferred roots, plus everything the package catalog says they pull in, | |
| 248 | /// minus everything a declared dependency pulls in. The subtraction is the | |
| 249 | /// point: `unicode.org` arrives with postgresql.org here, but a workspace that | |
| 250 | /// declares something else needing it has it on its own merits, and leaving | |
| 251 | /// that out of the lock would make the lock depend on the local `.env` in the | |
| 252 | /// opposite direction. | |
| 253 | /// | |
| 254 | /// The transitive half is what made this a set rather than a name check: the | |
| 255 | /// lock cut on a Postgres machine gained gnome.org/libxslt and unicode.org as | |
| 256 | /// well as postgresql.org itself (#253). | |
| 257 | /// | |
| 258 | /// The caller owns the result. | |
| 259 | pub fn inferredOnlyDomains( | |
| 260 | allocator: std.mem.Allocator, | |
| 261 | inferred: []const parser.PackageDependency, | |
| 262 | declared: []const parser.PackageDependency, | |
| 263 | ) !DomainSet { | |
| 264 | var set = DomainSet{ .allocator = allocator, .map = std.StringHashMap(void).init(allocator) }; | |
| 265 | errdefer set.deinit(); | |
| 266 | if (inferred.len == 0) return set; | |
| 267 | ||
| 268 | var inferred_roots: std.ArrayList([]const u8) = .empty; | |
| 269 | defer inferred_roots.deinit(allocator); | |
| 270 | for (inferred) |dep| try inferred_roots.append(allocator, canonicalDomain(dep.name)); | |
| 271 | ||
| 272 | const Add = struct { | |
| 273 | fn visit(target: *DomainSet, domain: []const u8) anyerror!void { | |
| 274 | const gop = try target.map.getOrPut(domain); | |
| 275 | if (gop.found_existing) return; | |
| 276 | gop.key_ptr.* = target.allocator.dupe(u8, domain) catch |err| { | |
| 277 | _ = target.map.remove(domain); | |
| 278 | return err; | |
| 279 | }; | |
| 280 | } | |
| 281 | }; | |
| 282 | try walkCatalog(allocator, inferred_roots.items, &set, Add.visit); | |
| 283 | ||
| 284 | var declared_roots: std.ArrayList([]const u8) = .empty; | |
| 285 | defer declared_roots.deinit(allocator); | |
| 286 | for (declared) |dep| try declared_roots.append(allocator, canonicalDomain(dep.name)); | |
| 287 | ||
| 288 | const Remove = struct { | |
| 289 | fn visit(target: *DomainSet, domain: []const u8) anyerror!void { | |
| 290 | if (target.map.fetchRemove(domain)) |removed| target.allocator.free(removed.key); | |
| 291 | } | |
| 292 | }; | |
| 293 | try walkCatalog(allocator, declared_roots.items, &set, Remove.visit); | |
| 294 | ||
| 295 | return set; | |
| 296 | } | |
| 297 | ||
| 202 | 298 | pub fn freeDeps(allocator: std.mem.Allocator, deps: []const parser.PackageDependency) void { |
| 203 | 299 | for (deps) |dep| { |
| 204 | 300 | var owned = dep; |
| @@ -317,7 +413,7 @@ test "infer reads the project's .env and never overrides an explicit pin" { | ||
| 317 | 413 | try testing.expectEqualStrings("redis.io", with_pin[0].name); |
| 318 | 414 | } |
| 319 | 415 | |
| 320 | test "under --frozen-lockfile only packages the lockfile records are inferred" { | |
| 416 | test "inference is independent of the lockfile, in frozen runs too" { | |
| 321 | 417 | const allocator = testing.allocator; |
| 322 | 418 | var tmp = testing.tmpDir(.{}); |
| 323 | 419 | defer tmp.cleanup(); |
| @@ -326,23 +422,59 @@ test "under --frozen-lockfile only packages the lockfile records are inferred" { | ||
| 326 | 422 | |
| 327 | 423 | try writeFixture(tmp, ".env", "DB_CONNECTION=pgsql\nQUEUE_CONNECTION=redis\n"); |
| 328 | 424 | |
| 329 | // No lockfile at all: frozen infers nothing. | |
| 330 | const without_lock = try infer(allocator, dir, &.{}, .{ .frozen_lockfile = true, .report = false }); | |
| 331 | defer freeDeps(allocator, without_lock); | |
| 332 | try testing.expectEqual(@as(usize, 0), without_lock.len); | |
| 333 | ||
| 334 | try writeFixture(tmp, "pantry.lock", | |
| 335 | \\{ | |
| 336 | \\ "version": "1", | |
| 337 | \\ "lockfileVersion": 1, | |
| 338 | \\ "generatedAt": "2026-10-03T00:00:00Z", | |
| 339 | \\ "packages": { | |
| 340 | \\ "postgresql.org@17.6.0": { "name": "postgresql.org", "version": "17.6.0", "source": "pantry" } | |
| 341 | \\ } | |
| 342 | \\} | |
| 343 | ); | |
| 344 | const with_lock = try infer(allocator, dir, &.{}, .{ .frozen_lockfile = true, .report = false }); | |
| 345 | defer freeDeps(allocator, with_lock); | |
| 346 | try testing.expectEqual(@as(usize, 1), with_lock.len); | |
| 347 | try testing.expectEqualStrings("postgresql.org", with_lock[0].name); | |
| 425 | // No lockfile at all. Inference used to be gated on the lock recording the | |
| 426 | // package, which only held because inference itself had put it there; now | |
| 427 | // nothing it produces reaches the lock, so there is nothing to gate on and | |
| 428 | // a --frozen-lockfile run infers exactly what any other run does (#253). | |
| 429 | const inferred = try infer(allocator, dir, &.{}, .{ .report = false }); | |
| 430 | defer freeDeps(allocator, inferred); | |
| 431 | try testing.expectEqual(@as(usize, 2), inferred.len); | |
| 432 | try testing.expectEqualStrings("postgresql.org", inferred[0].name); | |
| 433 | try testing.expectEqualStrings("redis.io", inferred[1].name); | |
| 434 | } | |
| 435 | ||
| 436 | test "inferred-only domains cover what an inference drags in" { | |
| 437 | const allocator = testing.allocator; | |
| 438 | const mk = struct { | |
| 439 | fn dep(name: []const u8) parser.PackageDependency { | |
| 440 | return .{ .name = name, .version = "latest" }; | |
| 441 | } | |
| 442 | }.dep; | |
| 443 | ||
| 444 | var set = try inferredOnlyDomains(allocator, &.{mk("postgresql.org")}, &.{mk("bun.sh")}); | |
| 445 | defer set.deinit(); | |
| 446 | ||
| 447 | // The root itself, and the transitive entries that appeared in the lock | |
| 448 | // alongside it in the reported failure. | |
| 449 | try testing.expect(set.contains("postgresql.org")); | |
| 450 | try testing.expect(set.contains("gnome.org/libxslt")); | |
| 451 | try testing.expect(set.contains("unicode.org")); | |
| 452 | try testing.expect(!set.contains("bun.sh")); | |
| 453 | } | |
| 454 | ||
| 455 | test "a domain a declared package also needs is not inferred-only" { | |
| 456 | const allocator = testing.allocator; | |
| 457 | const mk = struct { | |
| 458 | fn dep(name: []const u8) parser.PackageDependency { | |
| 459 | return .{ .name = name, .version = "latest" }; | |
| 460 | } | |
| 461 | }.dep; | |
| 462 | ||
| 463 | // Declaring postgres outright leaves nothing attributable to the .env... | |
| 464 | var declared = try inferredOnlyDomains(allocator, &.{mk("postgresql.org")}, &.{mk("postgres")}); | |
| 465 | defer declared.deinit(); | |
| 466 | try testing.expect(!declared.contains("postgresql.org")); | |
| 467 | try testing.expect(!declared.contains("unicode.org")); | |
| 468 | ||
| 469 | // ...and so does declaring something that needs the same library: the lock | |
| 470 | // must not lose an entry that package needs on its own merits. | |
| 471 | var shared = try inferredOnlyDomains(allocator, &.{mk("postgresql.org")}, &.{mk("gnome.org/libxslt")}); | |
| 472 | defer shared.deinit(); | |
| 473 | try testing.expect(shared.contains("postgresql.org")); | |
| 474 | try testing.expect(!shared.contains("gnome.org/libxslt")); | |
| 475 | ||
| 476 | // Nothing inferred, nothing to exclude. | |
| 477 | var empty = try inferredOnlyDomains(allocator, &.{}, &.{mk("postgresql.org")}); | |
| 478 | defer empty.deinit(); | |
| 479 | try testing.expectEqual(@as(u32, 0), empty.map.count()); | |
| 348 | 480 | } |
| @@ -652,7 +652,19 @@ pub fn canSkipFromLockfile( | ||
| 652 | 652 | if (!found) return false; |
| 653 | 653 | } |
| 654 | 654 | |
| 655 | // Check if destination directory actually exists (use stack buffer + access instead of openDir) | |
| 655 | return isPackageInstalled(proj_dir, modules_dir, clean_name); | |
| 656 | } | |
| 657 | ||
| 658 | /// Is this package present and usable under `proj_dir/modules_dir`? | |
| 659 | /// | |
| 660 | /// The half of `canSkipFromLockfile` that does not consult the lock, for the | |
| 661 | /// packages that are deliberately not in it: a service inferred from `.env` | |
| 662 | /// is installed but never locked (#253), so the lock cannot answer whether | |
| 663 | /// this install still has work to do. | |
| 664 | pub fn isPackageInstalled(proj_dir: []const u8, modules_dir: []const u8, dep_name: []const u8) bool { | |
| 665 | const clean_name = resolvePackageAlias(normalizePackageName(dep_name)); | |
| 666 | // Stack buffer + access instead of openDir: this runs per dependency on | |
| 667 | // the fast path. | |
| 656 | 668 | var dest_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 657 | 669 | const dest_dir = std.fmt.bufPrint(&dest_buf, "{s}/{s}/{s}", .{ proj_dir, modules_dir, clean_name }) catch return false; |
| 658 | 670 | io_helper.accessAbsolute(dest_dir, .{}) catch return false; |