ReviewOS

also looking at this

pantry-pm/pantry

fix(install): keep .env-inferred services out of pantry.lock

#254
Merged glennmichael123 wants to merge fix/253-env-deps-lockfile into main
4 files +286 -70
docs/features/service-management.mdmodified+5-2
Changes to docs/features/service-management.md
@@ -136,8 +136,11 @@ package, so a fresh checkout does not install cleanly and then fail to start:
136136- pantry prints each package it adds and the key that caused it, e.g.
137137 `+ postgresql.org (from DB_CONNECTION=pgsql in .env; PANTRY_ENV_DEPS=0 to skip)`.
138138 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.
141144- Set `PANTRY_ENV_DEPS=0` to turn inference off.
142145
143146### Service Groups
packages/zig/src/cli/commands/install/core.zigmodified+91-22
Changes to packages/zig/src/cli/commands/install/core.zig
@@ -167,11 +167,16 @@ fn tryFastUpToDate(allocator: std.mem.Allocator, cwd: []const u8, start_time: i6
167167
168168 // 4a. Services the project's .env names (DB_CONNECTION=pgsql, ...) count
169169 // 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.
170175 {
171176 const inferred = env_deps.infer(allocator, cwd, deps, .{ .report = false }) catch &.{};
172177 defer env_deps.freeDeps(allocator, inferred);
173178 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;
175180 }
176181 }
177182
@@ -563,7 +568,6 @@ pub fn installCommandWithOptions(allocator: std.mem.Allocator, args: []const []c
563568 ws_file.root_dir,
564569 companion_options,
565570 staged_lockfile_path,
566 options.frozen_lockfile,
567571 ), df.path);
568572 }
569573 }
@@ -701,11 +705,20 @@ pub fn installCommandWithOptions(allocator: std.mem.Allocator, args: []const []c
701705 allocator,
702706 if (deps_file_path) |path| std.fs.path.dirname(path) orelse cwd else cwd,
703707 deps,
704 .{ .frozen_lockfile = options.frozen_lockfile },
708 .{},
705709 ) catch &.{};
706710 defer env_deps.freeDeps(allocator, env_inferred);
707711 try filtered_deps.appendSlice(allocator, env_inferred);
708712
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
709722 // Load overrides/resolutions from package.json if it exists
710723 var override_map = lib.deps.overrides.OverrideMap.init(allocator);
711724 defer override_map.deinit();
@@ -1219,15 +1232,21 @@ pub fn installCommandWithOptions(allocator: std.mem.Allocator, args: []const []c
12191232 const resolved_url = try std.fmt.allocPrint(allocator, "registry:{s}@{s}", .{ clean_name, result.version });
12201233 defer allocator.free(resolved_url);
12211234
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);
12291249 }
1230 try lockfile_hooks.addPackageToLockfile(&lock_file, clean_name, result.version, resolved_url, null);
12311250 }
12321251 // Nothing of it is on disk to record below.
12331252 if (result.lock_only) continue;
@@ -1975,7 +1994,6 @@ fn installCompanionDepsFile(
19751994 project_root: []const u8,
19761995 options: types.InstallOptions,
19771996 lockfile_path: ?[]const u8,
1978 frozen_environment: bool,
19791997) !types.CommandResult {
19801998 const parser = ;
19811999 const pipeline = ;
@@ -1989,13 +2007,10 @@ fn installCompanionDepsFile(
19892007 allocator.free(declared_deps);
19902008 }
19912009
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 &.{};
19992014 defer env_deps.freeDeps(allocator, env_inferred);
20002015 const deps = if (env_inferred.len == 0) declared_deps else try std.mem.concat(allocator, parser.PackageDependency, &.{ declared_deps, env_inferred });
20012016 defer if (env_inferred.len > 0) allocator.free(deps);
@@ -2092,11 +2107,19 @@ fn installCompanionDepsFile(
20922107 // lockfile so `pantry install` actually refreshes stale runtime pins while
20932108 // preserving every workspace and npm entry.
20942109 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
20952117 const lockfile_changed = try syncCompanionLockfile(
20962118 allocator,
20972119 project_root,
2098 deps,
2120 declared_deps,
20992121 pipeline_result.results,
2122 &env_only_domains,
21002123 options.frozen_lockfile,
21012124 lockfile_path,
21022125 );
@@ -2118,6 +2141,7 @@ fn syncCompanionLockfile(
21182141 project_root: []const u8,
21192142 deps: []const .PackageDependency,
21202143 results: []const .PackageResult,
2144 env_only_domains: *const env_deps.DomainSet,
21212145 frozen: bool,
21222146 lockfile_path_override: ?[]const u8,
21232147) !bool {
@@ -2138,7 +2162,7 @@ fn syncCompanionLockfile(
21382162 try lib.packages.Lockfile.init(allocator, "1.0.0");
21392163 defer lockfile.deinit(allocator);
21402164
2141 try mergeCompanionLockfileEntries(allocator, &lockfile, deps, results);
2165 try mergeCompanionLockfileEntries(allocator, &lockfile, deps, results, env_only_domains);
21422166
21432167 const changed = if (original) |*existing|
21442168 !lockfile_writer.lockfilesEqual(existing, &lockfile)
@@ -2161,6 +2185,7 @@ fn mergeCompanionLockfileEntries(
21612185 lockfile: *lib.packages.Lockfile,
21622186 deps: []const .PackageDependency,
21632187 results: []const .PackageResult,
2188 env_only_domains: *const env_deps.DomainSet,
21642189) !void {
21652190 // Packages whose resolved version depends on which platform installs them,
21662191 // so the sweep below can tell a stale pin from another platform's.
@@ -2218,6 +2243,7 @@ fn mergeCompanionLockfileEntries(
22182243 for (results) |result| {
22192244 if (!result.success or result.name.len == 0) continue;
22202245 const clean_name = helpers.resolvePackageAlias(helpers.normalizePackageName(result.name));
2246 if (env_only_domains.contains(clean_name)) continue;
22212247
22222248 // Replace every older pin for this package, regardless of its key's
22232249 // version spelling (constraint or resolved version).
@@ -2366,7 +2392,9 @@ test "companion deps replace stale system pins without dropping workspace packag
23662392 .success = true,
23672393 }};
23682394
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);
23702398
23712399 try t.expectEqualStrings("^1.3.14", lockfile.workspaces.get("").?.system.?.get("bun.sh").?);
23722400 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
23752403 try t.expect(lockfile.workspaces.get("packages/ui") != null);
23762404}
23772405
2406test "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
23782447test "a manifest install fails when a package it asked for did not arrive" {
23792448 // The bug this pins: this path returned 0 unconditionally while every other
23802449 // install path returned 1, so `pantry install` printed "1 package(s) failed
packages/zig/src/cli/commands/install/env_deps.zigmodified+177-45
Changes to packages/zig/src/cli/commands/install/env_deps.zig
@@ -19,9 +19,15 @@
1919//! is added and its pin is untouched.
2020//! - Only those keys are read, and only a recognised driver name is ever
2121//! 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.
2531//! - `PANTRY_ENV_DEPS=0` (or `false`/`off`) turns the whole thing off.
2632
2733const std = ;
@@ -119,15 +125,21 @@ pub fn inferFromEnv(content: []const u8, out: *[max_inferences]Inference) []Infe
119125 return out[0..n];
120126}
121127
128/// The registry domain a dependency name refers to, in any spelling
129/// (`postgres`, `auto:redis`, `postgresql.org@17`).
130pub 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
122139/// Does the dependency list already name `domain`, in any spelling?
123140pub fn isDeclared(declared: []const parser.PackageDependency, domain: []const u8) bool {
124141 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;
131143 }
132144 return false;
133145}
@@ -137,22 +149,7 @@ pub fn isEnabled() bool {
137149 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"));
138150}
139151
140fn 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
154152pub const Options = struct {
155 frozen_lockfile: bool = false,
156153 /// Print what was added and why.
157154 report: bool = true,
158155};
@@ -184,7 +181,6 @@ pub fn infer(
184181 for (found) |inf| {
185182 if (isDeclared(declared, inf.domain)) continue;
186183 if (isDeclared(added.items, inf.domain)) continue;
187 if (options.frozen_lockfile and !lockfileHas(allocator, project_dir, inf.domain)) continue;
188184
189185 const name = try allocator.dupe(u8, inf.domain);
190186 errdefer allocator.free(name);
@@ -199,6 +195,106 @@ pub fn infer(
199195 return added.toOwnedSlice(allocator);
200196}
201197
198/// A set of registry domains, owning its keys.
199pub 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.
216fn 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.
259pub 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
202298pub fn freeDeps(allocator: std.mem.Allocator, deps: []const parser.PackageDependency) void {
203299 for (deps) |dep| {
204300 var owned = dep;
@@ -317,7 +413,7 @@ test "infer reads the project's .env and never overrides an explicit pin" {
317413 try testing.expectEqualStrings("redis.io", with_pin[0].name);
318414}
319415
320test "under --frozen-lockfile only packages the lockfile records are inferred" {
416test "inference is independent of the lockfile, in frozen runs too" {
321417 const allocator = testing.allocator;
322418 var tmp = testing.tmpDir(.{});
323419 defer tmp.cleanup();
@@ -326,23 +422,59 @@ test "under --frozen-lockfile only packages the lockfile records are inferred" {
326422
327423 try writeFixture(tmp, ".env", "DB_CONNECTION=pgsql\nQUEUE_CONNECTION=redis\n");
328424
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
436test "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
455test "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());
348480}
packages/zig/src/cli/commands/install/helpers.zigmodified+13-1
Changes to packages/zig/src/cli/commands/install/helpers.zig
@@ -652,7 +652,19 @@ pub fn canSkipFromLockfile(
652652 if (!found) return false;
653653 }
654654
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.
664pub 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.
656668 var dest_buf: [std.fs.max_path_bytes]u8 = undefined;
657669 const dest_dir = std.fmt.bufPrint(&dest_buf, "{s}/{s}/{s}", .{ proj_dir, modules_dir, clean_name }) catch return false;
658670 io_helper.accessAbsolute(dest_dir, .{}) catch return false;