pnpm-dedup 2.2.1 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +15 -5
  2. package/dist/applyClusterFixes.js +103 -80
  3. package/dist/applyClusterFixes.js.map +1 -1
  4. package/dist/applyClusterFixes.test.js +257 -29
  5. package/dist/applyClusterFixes.test.js.map +1 -1
  6. package/dist/bin/pnpm-why-duplicate.test.js +17 -8
  7. package/dist/bin/pnpm-why-duplicate.test.js.map +1 -1
  8. package/dist/dedupe.js +15 -4
  9. package/dist/dedupe.js.map +1 -1
  10. package/dist/dedupeCheckConsistency.test.js +53 -59
  11. package/dist/dedupeCheckConsistency.test.js.map +1 -1
  12. package/dist/dedupeEndToEnd.test.js +3 -3
  13. package/dist/dedupeEndToEnd.test.js.map +1 -1
  14. package/dist/dedupePeers.test.js +1 -1
  15. package/dist/dedupePeers.test.js.map +1 -1
  16. package/dist/definitions/applyClusterFixes.d.ts +6 -3
  17. package/dist/definitions/applyClusterFixes.d.ts.map +1 -1
  18. package/dist/definitions/dedupe.d.ts.map +1 -1
  19. package/dist/definitions/helpers/describeUnheldOverride.d.ts +10 -0
  20. package/dist/definitions/helpers/describeUnheldOverride.d.ts.map +1 -0
  21. package/dist/definitions/helpers/describeUnheldOverride.test.d.ts +2 -0
  22. package/dist/definitions/helpers/describeUnheldOverride.test.d.ts.map +1 -0
  23. package/dist/definitions/helpers/pnpmWorkspaceYaml.d.ts +1 -5
  24. package/dist/definitions/helpers/pnpmWorkspaceYaml.d.ts.map +1 -1
  25. package/dist/definitions/pnpmLockTypes.d.ts +1 -0
  26. package/dist/definitions/pnpmLockTypes.d.ts.map +1 -1
  27. package/dist/definitions/readPnpmLock.d.ts +7 -0
  28. package/dist/definitions/readPnpmLock.d.ts.map +1 -1
  29. package/dist/definitions/readPnpmLock.test.d.ts +2 -0
  30. package/dist/definitions/readPnpmLock.test.d.ts.map +1 -0
  31. package/dist/displayMany.test.js +3 -4
  32. package/dist/displayMany.test.js.map +1 -1
  33. package/dist/helpers/buildIdentifiedFixesMap.test.js +1 -1
  34. package/dist/helpers/buildIdentifiedFixesMap.test.js.map +1 -1
  35. package/dist/helpers/buildPnpmPackagesMap.test.js +1 -1
  36. package/dist/helpers/buildPnpmPackagesMap.test.js.map +1 -1
  37. package/dist/helpers/collectDependentRanges.test.js +1 -1
  38. package/dist/helpers/collectDependentRanges.test.js.map +1 -1
  39. package/dist/helpers/collectPnpmDependents.test.js +1 -1
  40. package/dist/helpers/collectPnpmDependents.test.js.map +1 -1
  41. package/dist/helpers/describeUnheldOverride.js +30 -0
  42. package/dist/helpers/describeUnheldOverride.js.map +1 -0
  43. package/dist/helpers/describeUnheldOverride.test.js +80 -0
  44. package/dist/helpers/describeUnheldOverride.test.js.map +1 -0
  45. package/dist/helpers/parsePnpmLockPackages.test.js +1 -1
  46. package/dist/helpers/parsePnpmLockPackages.test.js.map +1 -1
  47. package/dist/helpers/pnpmWorkspaceYaml.js +2 -17
  48. package/dist/helpers/pnpmWorkspaceYaml.js.map +1 -1
  49. package/dist/helpers/pnpmWorkspaceYaml.test.js +3 -5
  50. package/dist/helpers/pnpmWorkspaceYaml.test.js.map +1 -1
  51. package/dist/helpers/readInstalledManifest.test.js +1 -1
  52. package/dist/helpers/readInstalledManifest.test.js.map +1 -1
  53. package/dist/helpers/toLockstepGraph.test.js +1 -1
  54. package/dist/helpers/toLockstepGraph.test.js.map +1 -1
  55. package/dist/identifyClusterFixes.test.js +22 -41
  56. package/dist/identifyClusterFixes.test.js.map +1 -1
  57. package/dist/identifyResolutionFixes.test.js +8 -7
  58. package/dist/identifyResolutionFixes.test.js.map +1 -1
  59. package/dist/readPnpmLock.js +21 -2
  60. package/dist/readPnpmLock.js.map +1 -1
  61. package/dist/readPnpmLock.test.js +43 -0
  62. package/dist/readPnpmLock.test.js.map +1 -0
  63. package/package.json +4 -3
  64. package/src/applyClusterFixes.test.ts +398 -36
  65. package/src/applyClusterFixes.ts +178 -125
  66. package/src/bin/pnpm-why-duplicate.test.ts +31 -8
  67. package/src/dedupe.ts +22 -7
  68. package/src/dedupeCheckConsistency.test.ts +84 -100
  69. package/src/dedupeEndToEnd.test.ts +84 -77
  70. package/src/dedupePeers.test.ts +1 -1
  71. package/src/displayMany.test.ts +4 -4
  72. package/src/helpers/buildIdentifiedFixesMap.test.ts +1 -1
  73. package/src/helpers/buildPnpmPackagesMap.test.ts +1 -1
  74. package/src/helpers/collectDependentRanges.test.ts +1 -1
  75. package/src/helpers/collectPnpmDependents.test.ts +1 -1
  76. package/src/helpers/describeUnheldOverride.test.ts +100 -0
  77. package/src/helpers/describeUnheldOverride.ts +53 -0
  78. package/src/helpers/parsePnpmLockPackages.test.ts +1 -1
  79. package/src/helpers/pnpmWorkspaceYaml.test.ts +2 -5
  80. package/src/helpers/pnpmWorkspaceYaml.ts +5 -20
  81. package/src/helpers/readInstalledManifest.test.ts +1 -1
  82. package/src/helpers/toLockstepGraph.test.ts +1 -1
  83. package/src/identifyClusterFixes.test.ts +27 -58
  84. package/src/identifyResolutionFixes.test.ts +8 -7
  85. package/src/pnpmLockTypes.ts +1 -0
  86. package/src/readPnpmLock.test.ts +65 -0
  87. package/src/readPnpmLock.ts +26 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pnpm-dedup",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "List duplicates and dedupe pnpm lock file",
5
5
  "keywords": [
6
6
  "dedupe",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "src",
28
- "dist"
28
+ "dist",
29
+ "bin"
29
30
  ],
30
31
  "type": "module",
31
32
  "sideEffects": false,
@@ -49,7 +50,7 @@
49
50
  "tsc": "tsc"
50
51
  },
51
52
  "dependencies": {
52
- "pm-dedup-core": "2.1.0",
53
+ "pm-dedup-core": "2.2.1",
53
54
  "semver": "7.8.5",
54
55
  "tslib": "^2.8.1",
55
56
  "yaml": "2.9.1"
@@ -1,10 +1,15 @@
1
- import { afterEach, describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
3
  import { join } from "node:path";
4
+ import { afterEach, describe, it } from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
- import type { ClusterFix, DuplicateSnapshot } from "pm-dedup-core";
6
+ import type {
7
+ ClusterFix,
8
+ DuplicateSnapshot,
9
+ VersionsSnapshot,
10
+ } from "pm-dedup-core";
7
11
  import { applyClusterFixes } from "./applyClusterFixes.ts";
12
+ import type { DependentRangesMap } from "./helpers/collectDependentRanges.ts";
8
13
  import { createTempProjects } from "./helpers/tempProjects.ts";
9
14
 
10
15
  const fix = (overrides: Partial<ClusterFix>): ClusterFix => ({
@@ -18,7 +23,6 @@ const fix = (overrides: Partial<ClusterFix>): ClusterFix => ({
18
23
  excludedMembers: [],
19
24
  anchor: null,
20
25
  reuseFixes: [],
21
- floatingMembers: [],
22
26
  workspaceChanges: [],
23
27
  reResolutionSet: [],
24
28
  externalConstraints: [],
@@ -145,16 +149,309 @@ describe("applyClusterFixes", () => {
145
149
  ok(logs.some((line) => line.includes("Removing the overrides")));
146
150
  });
147
151
 
148
- it("keeps the overrides, explaining why in the file and in the result", () => {
152
+ describe("when some overrides do not hold", () => {
153
+ const familyFix = (...convergentMembers: string[]): ClusterFix =>
154
+ fix({ applicable: true, target: "2.0.0", convergentMembers });
155
+
156
+ /**
157
+ * pnpm as far as the override step can tell, its state kept in the lockfile
158
+ * so that restoring files restores it too. A package with an override in
159
+ * `pnpm-workspace.yaml` merges onto 2.0.0; without one, a merged package
160
+ * stays merged only if `stays` says so, given what was merged before.
161
+ */
162
+ const simulatePnpm = (
163
+ dir: string,
164
+ packages: string[],
165
+ stays: Record<string, (merged: Set<string>) => boolean>,
166
+ ) => {
167
+ const mergedNow = (): Set<string> =>
168
+ new Set(
169
+ /# merged: (.*)/
170
+ .exec(read(dir, "pnpm-lock.yaml"))?.[1]
171
+ ?.split(",")
172
+ .filter(Boolean),
173
+ );
174
+ return {
175
+ resolve: (): number => {
176
+ const workspaceYaml = read(dir, "pnpm-workspace.yaml");
177
+ const previous = mergedNow();
178
+ const merged = packages.filter(
179
+ (name) =>
180
+ new RegExp(`"${name}@?":`).test(workspaceYaml) ||
181
+ (previous.has(name) && (stays[name]?.(previous) ?? false)),
182
+ );
183
+ writeFileSync(
184
+ join(dir, "pnpm-lock.yaml"),
185
+ `lockfileVersion: '9.0'\n# merged: ${merged.join(",")}\n`,
186
+ );
187
+ return 0;
188
+ },
189
+ readDuplicates: (): DuplicateSnapshot => {
190
+ const merged = mergedNow();
191
+ return new Set(
192
+ packages.flatMap((name) =>
193
+ merged.has(name) ? [] : [`${name}@1.0.0`, `${name}@2.0.0`],
194
+ ),
195
+ );
196
+ },
197
+ readVersions: (): VersionsSnapshot => {
198
+ const merged = mergedNow();
199
+ return new Map(
200
+ packages.map((name) => [
201
+ name,
202
+ merged.has(name) ? ["2.0.0"] : ["1.0.0", "2.0.0"],
203
+ ]),
204
+ );
205
+ },
206
+ };
207
+ };
208
+
209
+ const branchDependent = (name: string): DependentRangesMap =>
210
+ new Map([
211
+ [
212
+ name,
213
+ [
214
+ {
215
+ key: "branch@1.0.0",
216
+ range: "^1.0.0",
217
+ resolvedVersion: "1.0.0",
218
+ requesterName: "branch",
219
+ },
220
+ ],
221
+ ],
222
+ ]);
223
+
224
+ const sticky = (): boolean => true;
225
+ const stickyWith =
226
+ (companion: string) =>
227
+ (merged: Set<string>): boolean =>
228
+ merged.has(companion);
229
+
230
+ const run = (
231
+ dir: string,
232
+ fixes: ClusterFix[],
233
+ pnpm: ReturnType<typeof simulatePnpm>,
234
+ logs: string[],
235
+ dependentRanges: DependentRangesMap = new Map(),
236
+ ) =>
237
+ applyClusterFixes({
238
+ projectDir: dir,
239
+ color: false,
240
+ log: (message = "") => logs.push(message),
241
+ pnpmVersion: () => "11.17.0",
242
+ readFixes: () => fixes,
243
+ readDependentRanges: () => dependentRanges,
244
+ ...pnpm,
245
+ });
246
+
247
+ const projectFiles = {
248
+ "package.json": manifestContent,
249
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
250
+ "pnpm-workspace.yaml": workspaceYamlContent,
251
+ };
252
+
253
+ const addedIn = (logs: string[], from: number): string[] =>
254
+ logs.slice(from).filter((line) => /^ {2}"\w+@": "2\.0\.0"/.test(line));
255
+
256
+ it("keeps only the overrides that hold, and leaves none behind", () => {
257
+ const dir = makeProject(projectFiles);
258
+ const logs: string[] = [];
259
+ const pnpm = simulatePnpm(dir, ["leaf", "twig"], { leaf: sticky });
260
+
261
+ const outcome = run(
262
+ dir,
263
+ [familyFix("leaf", "twig")],
264
+ pnpm,
265
+ logs,
266
+ branchDependent("twig"),
267
+ );
268
+
269
+ strictEqual(outcome.status, "applied");
270
+ deepStrictEqual([...outcome.after], ["twig@1.0.0", "twig@2.0.0"]);
271
+ deepStrictEqual(
272
+ outcome.stickyOverrides.map((override) => override.packageName),
273
+ ["twig"],
274
+ );
275
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
276
+
277
+ const retry = logs.indexOf("Retrying with only the overrides that held:");
278
+ ok(retry > 0);
279
+ deepStrictEqual(addedIn(logs, retry), [' "leaf@": "2.0.0" (converge)']);
280
+ ok(
281
+ logs.includes(
282
+ "pnpm resolves these back without an override, so they are not merged:",
283
+ ),
284
+ );
285
+ ok(logs.includes(" twig onto 2.0.0:"));
286
+ ok(logs.includes(' branch@1.0.0 requires "^1.0.0"'));
287
+ });
288
+
289
+ it("reverts and says so when an override does not merge even while present", () => {
290
+ const dir = makeProject(projectFiles);
291
+ const logs: string[] = [];
292
+ const pnpm = simulatePnpm(dir, ["leaf"], {});
293
+ // pnpm ignores the override: the declared range rejects the version
294
+ const inert = { ...pnpm, resolve: () => 0 };
295
+
296
+ const outcome = run(
297
+ dir,
298
+ [familyFix("leaf")],
299
+ inert,
300
+ logs,
301
+ branchDependent("leaf"),
302
+ );
303
+
304
+ strictEqual(outcome.status, "reverted");
305
+ deepStrictEqual(
306
+ outcome.stickyOverrides.map((override) => override.packageName),
307
+ ["leaf"],
308
+ );
309
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
310
+ ok(logs.includes("Not merged even with an override:"));
311
+ ok(logs.includes(" No override holds on its own — reverting them"));
312
+ });
313
+
314
+ it("keeps the range edits when no override holds", () => {
315
+ const dir = makeProject(projectFiles);
316
+ const logs: string[] = [];
317
+ const pnpm = simulatePnpm(dir, ["metro-config"], {});
318
+
319
+ const outcome = run(dir, [metroFix], pnpm, logs);
320
+
321
+ strictEqual(outcome.status, "applied");
322
+ ok(read(dir, "package.json").includes('"metro": "0.87.0"'));
323
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
324
+ });
325
+
326
+ it("drops an override that only held alongside one a round lost", () => {
327
+ const dir = makeProject(projectFiles);
328
+ const logs: string[] = [];
329
+ const pnpm = simulatePnpm(dir, ["leaf", "twig", "bud"], {
330
+ leaf: sticky,
331
+ twig: stickyWith("bud"),
332
+ });
333
+
334
+ const outcome = run(dir, [familyFix("leaf", "twig", "bud")], pnpm, logs);
335
+
336
+ strictEqual(outcome.status, "applied");
337
+ deepStrictEqual(
338
+ outcome.stickyOverrides.map((override) => override.packageName),
339
+ ["bud", "twig"],
340
+ );
341
+ deepStrictEqual(
342
+ addedIn(
343
+ logs,
344
+ logs.lastIndexOf("Retrying with only the overrides that held:"),
345
+ ),
346
+ [' "leaf@": "2.0.0" (converge)'],
347
+ );
348
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
349
+ });
350
+
351
+ it("gives up after three rounds", () => {
352
+ const dir = makeProject(projectFiles);
353
+ const logs: string[] = [];
354
+ const pnpm = simulatePnpm(dir, ["leaf", "twig", "bud", "seed"], {
355
+ leaf: sticky,
356
+ twig: stickyWith("bud"),
357
+ bud: stickyWith("seed"),
358
+ });
359
+
360
+ const outcome = run(
361
+ dir,
362
+ [familyFix("leaf", "twig", "bud", "seed")],
363
+ pnpm,
364
+ logs,
365
+ );
366
+
367
+ strictEqual(outcome.status, "reverted");
368
+ strictEqual(outcome.stickyOverrides.length, 4);
369
+ strictEqual(logs.filter((line) => line.startsWith("Retrying")).length, 2);
370
+ ok(
371
+ logs.includes(
372
+ " Overrides still came apart after 3 rounds — reverting them",
373
+ ),
374
+ );
375
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
376
+ deepStrictEqual(outcome.after, outcome.before);
377
+ });
378
+
379
+ it("keeps the same rule with plain overrides", () => {
380
+ const dir = makeProject(projectFiles);
381
+ const logs: string[] = [];
382
+ const pnpm = simulatePnpm(dir, ["leaf", "twig"], { leaf: sticky });
383
+
384
+ const outcome = applyClusterFixes({
385
+ projectDir: dir,
386
+ color: false,
387
+ log: (message = "") => logs.push(message),
388
+ convergenceOverrides: false,
389
+ readFixes: () => [familyFix("leaf", "twig")],
390
+ readDependentRanges: () => new Map(),
391
+ ...pnpm,
392
+ });
393
+
394
+ strictEqual(outcome.status, "applied");
395
+ deepStrictEqual(
396
+ outcome.stickyOverrides.map((override) => override.packageName),
397
+ ["twig"],
398
+ );
399
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
400
+ });
401
+ });
402
+
403
+ it("writes the override for a member nothing duplicates that holds its family apart", () => {
149
404
  const dir = makeProject({
150
405
  "package.json": manifestContent,
151
406
  "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
152
407
  "pnpm-workspace.yaml": workspaceYamlContent,
153
408
  });
409
+ const logs: string[] = [];
410
+ let parentMoved = false;
154
411
 
155
- const duplicated = snapshot("leaf@1.0.0", "leaf@2.0.0");
156
- let duplicates = duplicated;
412
+ const outcome = applyClusterFixes({
413
+ projectDir: dir,
414
+ color: false,
415
+ log: (message = "") => logs.push(message),
416
+ pnpmVersion: () => "11.17.0",
417
+ readFixes: () => [
418
+ fix({
419
+ applicable: true,
420
+ target: "2.0.0",
421
+ convergentMembers: ["leaf"],
422
+ reResolutionSet: ["parent"],
423
+ }),
424
+ ],
425
+ readDuplicates: () =>
426
+ parentMoved ? snapshot() : snapshot("leaf@1.0.0", "leaf@2.0.0"),
427
+ readVersions: () =>
428
+ new Map([
429
+ ["leaf", parentMoved ? ["2.0.0"] : ["1.0.0", "2.0.0"]],
430
+ ["parent", [parentMoved ? "2.0.0" : "1.0.0"]],
431
+ ]),
432
+ resolve: () => {
433
+ // parent pins leaf exactly: the family only merges once parent moves
434
+ if (read(dir, "pnpm-workspace.yaml").includes('"parent@"')) {
435
+ parentMoved = true;
436
+ }
437
+ return 0;
438
+ },
439
+ });
440
+
441
+ strictEqual(outcome.status, "applied");
442
+ deepStrictEqual(outcome.stickyOverrides, []);
443
+ ok(logs.includes(' "parent@": "2.0.0" (converge)'));
444
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
445
+ });
446
+
447
+ it("reverts when the result without overrides adds a duplicate", () => {
448
+ const dir = makeProject({
449
+ "package.json": manifestContent,
450
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
451
+ "pnpm-workspace.yaml": workspaceYamlContent,
452
+ });
157
453
  const logs: string[] = [];
454
+ let duplicates = snapshot("leaf@1.0.0", "leaf@2.0.0");
158
455
 
159
456
  const outcome = applyClusterFixes({
160
457
  projectDir: dir,
@@ -164,34 +461,20 @@ describe("applyClusterFixes", () => {
164
461
  readFixes: () => [leafFix],
165
462
  readDuplicates: () => duplicates,
166
463
  resolve: () => {
167
- // the duplicate only stays away while the override is there
168
464
  duplicates = read(dir, "pnpm-workspace.yaml").includes('"leaf@"')
169
465
  ? snapshot()
170
- : duplicated;
466
+ : snapshot("leaf@1.0.0", "leaf@2.0.0", "other@1.0.0", "other@2.0.0");
171
467
  return 0;
172
468
  },
173
469
  });
174
470
 
175
- strictEqual(outcome.status, "kept-overrides");
176
- deepStrictEqual(
177
- outcome.stickyOverrides.map((override) => override.packageName),
178
- ["leaf"],
179
- );
180
-
181
- const workspaceYaml = read(dir, "pnpm-workspace.yaml");
182
- ok(workspaceYaml.includes('"leaf@": "2.0.0"'));
183
- ok(workspaceYaml.includes("# Added by pnpm-dedup."));
471
+ strictEqual(outcome.status, "reverted");
184
472
  ok(
185
- workspaceYaml.includes(
186
- "# handled without a standing override: https://github.com",
473
+ logs.includes(
474
+ " removing the overrides introduced 2 new duplicate(s) — reverting",
187
475
  ),
188
476
  );
189
- // the user's own content survives the write
190
- ok(workspaceYaml.includes("# keep me"));
191
- ok(workspaceYaml.includes("resolutionMode: time-based"));
192
-
193
- ok(logs.some((line) => line.includes("github.com/christophehurpeau")));
194
- ok(logs.some((line) => line.includes("Kept in")));
477
+ strictEqual(read(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
195
478
  });
196
479
 
197
480
  it("writes plain overrides and skips the version gate when convergence is disabled", () => {
@@ -244,17 +527,9 @@ describe("applyClusterFixes", () => {
244
527
  pnpmVersion: () => "11.17.0",
245
528
  readFixes: () => [
246
529
  fix({
247
- anchor: "0.84.5",
248
- reuseFixes: [
249
- {
250
- requester: "@tamagui/metro-plugin@1.0.0",
251
- requesterName: "@tamagui/metro-plugin",
252
- packageName: "metro-config",
253
- range: "*",
254
- from: "0.87.0",
255
- to: "0.84.5",
256
- },
257
- ],
530
+ applicable: true,
531
+ target: "0.84.5",
532
+ convergentMembers: ["metro-config"],
258
533
  externalConstraints: [
259
534
  {
260
535
  requester: "@react-native/community-cli-plugin@0.87.0",
@@ -379,6 +654,93 @@ describe("applyClusterFixes", () => {
379
654
  ok(outcome.plannedChangeCount > 0);
380
655
  });
381
656
 
657
+ describe("an open range that ignored the pinned version", () => {
658
+ const reuseFix = (
659
+ requesterName: string,
660
+ packageName: string,
661
+ range: string,
662
+ from: string,
663
+ to: string,
664
+ ): ClusterFix =>
665
+ fix({
666
+ anchor: to,
667
+ reuseFixes: [
668
+ {
669
+ requester: `${requesterName}@1.0.0`,
670
+ requesterName,
671
+ packageName,
672
+ range,
673
+ from,
674
+ to,
675
+ },
676
+ ],
677
+ });
678
+ // the metro fixture's wildcard, and alouette's peer
679
+ const reuseFixes = [
680
+ reuseFix(
681
+ "@tamagui/metro-plugin",
682
+ "metro-config",
683
+ "*",
684
+ "0.87.0",
685
+ "0.84.5",
686
+ ),
687
+ reuseFix(
688
+ "react-native-css",
689
+ "lightningcss",
690
+ ">=1.27.0",
691
+ "1.33.0",
692
+ "1.30.1",
693
+ ),
694
+ ];
695
+
696
+ const planFor = (fixes: ClusterFix[]) => {
697
+ const dir = makeProject({
698
+ "package.json": manifestContent,
699
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
700
+ });
701
+ const logs: string[] = [];
702
+ const outcome = applyClusterFixes({
703
+ projectDir: dir,
704
+ color: false,
705
+ dryRun: true,
706
+ log: (message = "") => logs.push(message),
707
+ pnpmVersion: () => "11.17.0",
708
+ readFixes: () => fixes,
709
+ readDuplicates: () => snapshot(),
710
+ resolve: () => {
711
+ throw new Error("a dry run must not resolve");
712
+ },
713
+ });
714
+ return { outcome, logs };
715
+ };
716
+
717
+ it("plans nothing for them: pnpm resolves them back to the highest copy", () => {
718
+ const { outcome, logs } = planFor(reuseFixes);
719
+
720
+ // `--check` has nothing to fail on, run after run
721
+ strictEqual(outcome.plannedChangeCount, 0);
722
+ ok(!logs.some((line) => line.includes("(reuse)")));
723
+ });
724
+
725
+ it("lets the family's own target through instead", () => {
726
+ const { logs } = planFor([
727
+ fix({
728
+ ...reuseFixes[0]!,
729
+ applicable: true,
730
+ target: "0.87.0",
731
+ convergentMembers: ["metro-config"],
732
+ }),
733
+ ]);
734
+
735
+ ok(
736
+ logs.some((line) =>
737
+ line.includes('"metro-config@": "0.87.0" (converge)'),
738
+ ),
739
+ );
740
+ ok(!logs.some((line) => line.includes("ignoring")));
741
+ });
742
+ });
743
+
382
744
  it("renders repo-relative paths and the transient override file", () => {
383
745
  const dir = makeProject({
384
746
  "package.json": manifestContent,