also looking at this
fix(install): keep .env-inferred services out of pantry.lock
#254
4 files
+286
-70
| @@ -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 | } |