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
@@ -6,10 +6,13 @@ import {
6
6
  createPackageFilter,
7
7
  describeSkippedClusterFix,
8
8
  diffDuplicates,
9
+ heldOverrides,
10
+ outstandingOverrides,
9
11
  partitionUnconditionalOverrides,
10
12
  planClusterApply,
11
13
  renderApplyPlan,
12
14
  restoreFiles,
15
+ reuseKeys,
13
16
  selectClusterFixes,
14
17
  shouldColorize,
15
18
  } from "pm-dedup-core";
@@ -18,12 +21,17 @@ import type {
18
21
  ClusterFix,
19
22
  DuplicateSnapshot,
20
23
  FileSnapshot,
24
+ OverrideTargetState,
21
25
  PackageFilterOptions,
22
26
  PlannedManifestEdit,
23
27
  PlannedOverride,
24
28
  SelectedClusterFixes,
29
+ VersionsSnapshot,
25
30
  } from "pm-dedup-core";
26
31
  import { buildPnpmPackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
32
+ import { collectDependentRanges } from "./helpers/collectDependentRanges.ts";
33
+ import type { DependentRangesMap } from "./helpers/collectDependentRanges.ts";
34
+ import { describeUnheldOverride } from "./helpers/describeUnheldOverride.ts";
27
35
  import { readDuplicateSnapshot } from "./helpers/duplicateSnapshot.ts";
28
36
  import { parsePnpmLockPackages } from "./helpers/parsePnpmLockPackages.ts";
29
37
  import {
@@ -35,6 +43,7 @@ import { addOverrides, overrideKey } from "./helpers/pnpmWorkspaceYaml.ts";
35
43
  import { lockPathOf } from "./helpers/projectDir.ts";
36
44
  import { createManifestReader } from "./helpers/readInstalledManifest.ts";
37
45
  import { runPnpm } from "./helpers/runPnpm.ts";
46
+ import { readVersionsSnapshot } from "./helpers/versionsSnapshot.ts";
38
47
  import { identifyClusterFixes } from "./identifyClusterFixes.ts";
39
48
  import { readPnpmLock } from "./readPnpmLock.ts";
40
49
 
@@ -43,7 +52,6 @@ const issuesUrl = "https://github.com/christophehurpeau/pm-tools/issues";
43
52
  export type ClusterApplyStatus =
44
53
  | "applied"
45
54
  | "dry-run"
46
- | "kept-overrides"
47
55
  | "not-supported"
48
56
  | "nothing-to-do"
49
57
  | "reverted";
@@ -52,7 +60,7 @@ export interface ClusterApplyOutcome {
52
60
  status: ClusterApplyStatus;
53
61
  before: DuplicateSnapshot;
54
62
  after: DuplicateSnapshot;
55
- // overrides the duplicates came back without: reported, never left behind
63
+ // overrides the result did not hold without: reported, never left behind
56
64
  stickyOverrides: PlannedOverride[];
57
65
  // how many edits the plan holds, so `--check` gates without re-planning
58
66
  plannedChangeCount: number;
@@ -66,6 +74,11 @@ export interface ApplyClusterFixesOptions {
66
74
  resolve?: () => number | null;
67
75
  readFixes?: (projectDir: string) => ClusterFix[];
68
76
  readDuplicates?: (lockPath: string) => DuplicateSnapshot;
77
+ readVersions?: (lockPath: string) => VersionsSnapshot;
78
+ readDependentRanges?: (
79
+ projectDir: string,
80
+ packageNames: Set<string>,
81
+ ) => DependentRangesMap;
69
82
  pnpmVersion?: () => string | null;
70
83
  // false writes plain overrides instead, which pnpm applies to every requester
71
84
  // whatever range it declares
@@ -99,36 +112,29 @@ const describeOverride = (
99
112
  ): string =>
100
113
  `"${overrideKey(override.packageName, convergence)}": "${override.version}"`;
101
114
 
102
- /**
103
- * Why the overrides below them had to stay. Used verbatim as the comment left in
104
- * `pnpm-workspace.yaml` and as the console explanation, so the file and the run
105
- * say the same thing. The packages are the entries that follow, so they are not
106
- * repeated here.
107
- */
108
- const stickyOverrideReason: string[] = [
109
- "Added by pnpm-dedup.",
110
- "Removing these makes `pnpm dedupe` resolve them back to a duplicate, so they",
111
- "are what holds the deduplicated result. Keeping them is a workaround: no fix",
112
- "was found that survives on its own. Please report this cluster so it can be",
113
- `handled without a standing override: ${issuesUrl}`,
114
- ];
115
-
116
- interface ApplyState {
117
- duplicates: DuplicateSnapshot;
118
- // one key per open range still resolving away from the version the workspace
119
- // anchors its family at
120
- reuses: Set<string>;
121
- }
122
-
123
- const reuseKeys = (fixes: ClusterFix[]): Set<string> =>
124
- new Set(
125
- fixes.flatMap((fix) =>
126
- fix.reuseFixes.map(
127
- (reuse) => `${reuse.requesterName}>${reuse.packageName}@${reuse.to}`,
128
- ),
129
- ),
115
+ const defaultReadDependentRanges = (
116
+ projectDir: string,
117
+ packageNames: Set<string>,
118
+ ): DependentRangesMap =>
119
+ collectDependentRanges(
120
+ readPnpmLock(lockPathOf(projectDir)),
121
+ packageNames,
122
+ createManifestReader(projectDir),
130
123
  );
131
124
 
125
+ // Each round only drops overrides, so this bounds a run that keeps losing one to
126
+ // a companion it held alongside.
127
+ const maxOverrideRounds = 3;
128
+
129
+ type ApplyState = OverrideTargetState;
130
+
131
+ interface DroppedOverride {
132
+ override: PlannedOverride;
133
+ // whether it held while the overrides were there, and only came apart once
134
+ // pnpm resolved without them
135
+ heldWithOverrides: boolean;
136
+ }
137
+
132
138
  export const applyClusterFixes = ({
133
139
  projectDir,
134
140
  dryRun = false,
@@ -141,6 +147,8 @@ export const applyClusterFixes = ({
141
147
  resolve = () => runPnpm(["dedupe"], { cwd: projectDir }).status,
142
148
  readFixes = defaultReadFixes,
143
149
  readDuplicates = readDuplicateSnapshot,
150
+ readVersions = readVersionsSnapshot,
151
+ readDependentRanges = defaultReadDependentRanges,
144
152
  pnpmVersion = () => readPnpmVersion(),
145
153
  convergenceOverrides = true,
146
154
  filter,
@@ -167,7 +175,10 @@ export const applyClusterFixes = ({
167
175
 
168
176
  const { selected: fixes, skipped: filteredOut } =
169
177
  readSelectedFixes(projectDir);
170
- const plan = planClusterApply(fixes);
178
+ // pnpm resolves an open range to the highest copy still in the tree, so a
179
+ // reuse of the pinned version comes apart as soon as its override is removed.
180
+ // The report still names those ranges; the plan does not chase them.
181
+ const plan = planClusterApply(fixes, { reuse: false });
171
182
 
172
183
  const skipped = [
173
184
  ...filteredOut.map(describeSkippedClusterFix),
@@ -309,6 +320,7 @@ export const applyClusterFixes = ({
309
320
  // gone from its output once the edge points at the anchored version.
310
321
  const readState = (): ApplyState => ({
311
322
  duplicates: readDuplicates(lockPath),
323
+ versions: readVersions(lockPath),
312
324
  reuses: reuseKeys(readSelectedFixes(projectDir).selected),
313
325
  });
314
326
 
@@ -346,121 +358,162 @@ export const applyClusterFixes = ({
346
358
  )!;
347
359
 
348
360
  const remaining = readState();
349
- // An override is still worth writing while its package is duplicated, or
350
- // while the edge it repoints is still resolving elsewhere.
351
- const outstanding = plannedOverrides.filter(
352
- (override) =>
353
- [...remaining.duplicates].some((resolution) =>
354
- resolution.startsWith(`${override.packageName}@`),
355
- ) ||
356
- [...remaining.reuses].some((key) =>
357
- key.endsWith(`>${override.packageName}@${override.version}`),
358
- ),
359
- );
360
-
361
- if (outstanding.length === 0) {
362
- return {
363
- status: "applied",
364
- before,
365
- after: remaining.duplicates,
366
- stickyOverrides: [],
367
- plannedChangeCount: changeCount,
368
- };
369
- }
361
+ const outstanding = outstandingOverrides(plannedOverrides, remaining);
370
362
 
371
- log(
372
- `Adding ${convergenceOverrides ? "convergence" : "plain"} overrides to pnpm-workspace.yaml:`,
373
- );
374
- for (const override of outstanding) {
375
- log(
376
- ` ${describeOverride(override, convergenceOverrides)} (${override.reason})`,
377
- );
378
- }
379
- writeFileSync(
380
- workspaceYamlPath,
381
- addOverrides(
382
- workspaceYamlBefore.content,
383
- new Map(
384
- outstanding.map((override) => [override.packageName, override.version]),
385
- ),
386
- { convergence: convergenceOverrides },
387
- ),
388
- );
363
+ const keptOutcome = (
364
+ after: DuplicateSnapshot,
365
+ stickyOverrides: PlannedOverride[],
366
+ ): ClusterApplyOutcome => ({
367
+ status: "applied",
368
+ before,
369
+ after,
370
+ stickyOverrides,
371
+ plannedChangeCount: changeCount,
372
+ });
389
373
 
390
- const withOverrides = resolveAndCheck("the overrides");
391
- if (withOverrides === null) {
392
- revertTo(withoutOverridesSnapshot);
393
- return unchanged("reverted", changeCount);
374
+ if (outstanding.length === 0) {
375
+ return keptOutcome(remaining.duplicates, []);
394
376
  }
395
377
 
396
- // Overrides are scaffolding first: if pnpm holds the result without them they
397
- // are removed, and if it does not they go back with the reason recorded.
398
- log("Removing the overrides and re-resolving to check the result holds:");
399
- restoreFiles([workspaceYamlBefore]);
400
-
401
- if (resolve() !== 0) {
402
- log(" `pnpm dedupe` failed without the overrides — reverting");
378
+ // The range edits already held on their own, so dropping the overrides keeps
379
+ // them: only a run that made none of those has nothing left to show.
380
+ const withoutOverrides = (
381
+ stickyOverrides: PlannedOverride[],
382
+ ): ClusterApplyOutcome => {
403
383
  revertTo(withoutOverridesSnapshot);
404
- return unchanged("reverted", changeCount);
405
- }
406
-
407
- const after = readState();
408
- const returnedDuplicates = diffDuplicates(
409
- withOverrides.duplicates,
410
- after.duplicates,
411
- ).added;
412
- const returnedReuses = [...after.reuses].filter(
413
- (key) => !withOverrides.reuses.has(key),
414
- );
384
+ return plan.manifestEdits.length > 0
385
+ ? keptOutcome(readDuplicates(lockPath), stickyOverrides)
386
+ : { ...unchanged("reverted", changeCount), stickyOverrides };
387
+ };
415
388
 
416
- if (returnedDuplicates.length > 0 || returnedReuses.length > 0) {
389
+ const heldIn = (
390
+ overrides: PlannedOverride[],
391
+ end: ApplyState,
392
+ ): PlannedOverride[] => heldOverrides(overrides, { start: remaining, end });
393
+
394
+ // Overrides are scaffolding: they are written, pnpm resolves with them, and
395
+ // what counts is what is still there once they are removed again.
396
+ const overrideRound = (
397
+ overrides: PlannedOverride[],
398
+ ): {
399
+ heldWith: PlannedOverride[];
400
+ heldAfter: PlannedOverride[];
401
+ after: ApplyState;
402
+ } | null => {
403
+ log(
404
+ `Adding ${convergenceOverrides ? "convergence" : "plain"} overrides to pnpm-workspace.yaml:`,
405
+ );
406
+ for (const override of overrides) {
407
+ log(
408
+ ` ${describeOverride(override, convergenceOverrides)} (${override.reason})`,
409
+ );
410
+ }
417
411
  writeFileSync(
418
412
  workspaceYamlPath,
419
413
  addOverrides(
420
414
  workspaceYamlBefore.content,
421
415
  new Map(
422
- outstanding.map((override) => [
423
- override.packageName,
424
- override.version,
425
- ]),
416
+ overrides.map((override) => [override.packageName, override.version]),
426
417
  ),
427
- {
428
- convergence: convergenceOverrides,
429
- comment: stickyOverrideReason.join("\n"),
430
- },
418
+ { convergence: convergenceOverrides },
431
419
  ),
432
420
  );
433
421
 
434
- if (resolve() !== 0) {
435
- log(" `pnpm dedupe` failed with the overrides back — reverting");
436
- revertTo(withoutOverridesSnapshot);
437
- return unchanged("reverted", changeCount);
438
- }
422
+ const withOverrides = resolveAndCheck("the overrides");
423
+ if (withOverrides === null) return null;
424
+ const heldWith = heldIn(overrides, withOverrides);
425
+
426
+ log("Removing the overrides and re-resolving to check the result holds:");
427
+ restoreFiles([workspaceYamlBefore]);
428
+ const after = resolveAndCheck("removing the overrides");
429
+ if (after === null) return null;
430
+
431
+ return { heldWith, heldAfter: heldIn(overrides, after), after };
432
+ };
433
+
434
+ // A member nothing duplicates only moves for its family's sake, so the
435
+ // family's duplicated members already say what did not merge.
436
+ const reportDropped = (dropped: DroppedOverride[]): void => {
437
+ const reported = dropped.filter(({ override }) =>
438
+ [...remaining.duplicates].some((resolution) =>
439
+ resolution.startsWith(`${override.packageName}@`),
440
+ ),
441
+ );
442
+ if (reported.length === 0) return;
443
+
444
+ const ranges = readDependentRanges(
445
+ projectDir,
446
+ new Set(reported.map(({ override }) => override.packageName)),
447
+ );
448
+ const logGroup = (title: string, entries: DroppedOverride[]): void => {
449
+ if (entries.length === 0) return;
450
+ log(title);
451
+ for (const { override } of entries) {
452
+ log(` ${override.packageName} onto ${override.version}:`);
453
+ for (const line of describeUnheldOverride(
454
+ override,
455
+ ranges.get(override.packageName) ?? [],
456
+ )) {
457
+ log(` ${line}`);
458
+ }
459
+ }
460
+ };
439
461
 
462
+ logGroup(
463
+ "Not merged even with an override:",
464
+ reported.filter((entry) => !entry.heldWithOverrides),
465
+ );
466
+ logGroup(
467
+ "pnpm resolves these back without an override, so they are not merged:",
468
+ reported.filter((entry) => entry.heldWithOverrides),
469
+ );
440
470
  log(
441
- ` Kept in ${workspaceYamlPath}, with the same explanation as a comment:`,
471
+ `No override is left behind. If one of these should merge, please report it at ${issuesUrl}`,
442
472
  );
443
- for (const override of outstanding) {
444
- log(` ${describeOverride(override, convergenceOverrides)}`);
473
+ };
474
+
475
+ const dropped: DroppedOverride[] = [];
476
+ let candidates = outstanding;
477
+ for (let round = 1; round <= maxOverrideRounds; round++) {
478
+ if (round > 1) {
479
+ log("Retrying with only the overrides that held:");
480
+ restoreFiles(withoutOverridesSnapshot);
445
481
  }
446
- for (const line of stickyOverrideReason) {
447
- log(` ${line}`);
482
+
483
+ const result = overrideRound(candidates);
484
+ if (result === null) return withoutOverrides([]);
485
+
486
+ const { heldWith, heldAfter, after } = result;
487
+ for (const override of candidates) {
488
+ if (!heldAfter.includes(override)) {
489
+ dropped.push({
490
+ override,
491
+ heldWithOverrides: heldWith.includes(override),
492
+ });
493
+ }
448
494
  }
449
495
 
450
- return {
451
- status: "kept-overrides",
452
- before,
453
- after: readDuplicates(lockPath),
454
- stickyOverrides: outstanding,
455
- plannedChangeCount: changeCount,
456
- };
496
+ if (heldAfter.length === candidates.length) {
497
+ reportDropped(dropped);
498
+ return keptOutcome(
499
+ after.duplicates,
500
+ dropped.map(({ override }) => override),
501
+ );
502
+ }
503
+ candidates = heldAfter;
504
+ if (candidates.length === 0) break;
457
505
  }
458
506
 
459
- return {
460
- status: "applied",
461
- before,
462
- after: after.duplicates,
463
- stickyOverrides: [],
464
- plannedChangeCount: changeCount,
465
- };
507
+ log(
508
+ candidates.length === 0
509
+ ? " No override holds on its own — reverting them"
510
+ : ` Overrides still came apart after ${maxOverrideRounds} rounds — reverting them`,
511
+ );
512
+ // what is left only held alongside overrides the last round dropped
513
+ dropped.push(
514
+ ...candidates.map((override) => ({ override, heldWithOverrides: true })),
515
+ );
516
+ const outcome = withoutOverrides(dropped.map(({ override }) => override));
517
+ reportDropped(dropped);
518
+ return outcome;
466
519
  };
@@ -1,7 +1,8 @@
1
- import { afterEach, describe, expect, it } from "bun:test";
1
+ import { ok, strictEqual } from "node:assert/strict";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { cpSync, mkdirSync, realpathSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { afterEach, describe, it } from "node:test";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { createTempProjects } from "../helpers/tempProjects.ts";
7
8
 
@@ -58,11 +59,11 @@ describe("pnpm-why-duplicate", () => {
58
59
  const fromRoot = runBin("pnpm-why-duplicate", dir, ["--details"]);
59
60
  const fromNested = runBin("pnpm-why-duplicate", nested, ["--details"]);
60
61
 
61
- expect(fromNested.status).toBe(0);
62
- expect(fromNested.stdout).toBe(fromRoot.stdout);
63
- expect(fromNested.stdout).toContain("printable-shell-command");
64
- expect(fromNested.stderr.trim()).toBe(lockNotice(dir));
65
- expect(fromRoot.stderr).toBe("");
62
+ strictEqual(fromNested.status, 0);
63
+ strictEqual(fromNested.stdout, fromRoot.stdout);
64
+ ok(fromNested.stdout.includes("printable-shell-command"));
65
+ strictEqual(fromNested.stderr.trim(), lockNotice(dir));
66
+ strictEqual(fromRoot.stderr, "");
66
67
  });
67
68
 
68
69
  it("names the lockfile it could not find and exits 1", () => {
@@ -70,10 +71,32 @@ describe("pnpm-why-duplicate", () => {
70
71
 
71
72
  const result = runBin("pnpm-why-duplicate", dir, []);
72
73
 
73
- expect(result.status).toBe(1);
74
- expect(result.stderr.trim()).toBe(
74
+ strictEqual(result.status, 1);
75
+ strictEqual(
76
+ result.stderr.trim(),
75
77
  `No pnpm-lock.yaml found in ${realpathSync(dir)} or any parent directory`,
76
78
  );
77
79
  });
78
80
  });
81
+
82
+ it("reports on a lockfile that also resolves the package manager", () => {
83
+ const result = runBin(
84
+ "pnpm-why-duplicate",
85
+ project("package-manager-document"),
86
+ ["--details"],
87
+ );
88
+
89
+ strictEqual(result.stderr, "");
90
+ strictEqual(result.status, 0);
91
+ strictEqual(
92
+ result.stdout,
93
+ runBin(
94
+ "pnpm-why-duplicate",
95
+ project("duplicated-printable-shell-command"),
96
+ ["--details"],
97
+ ).stdout,
98
+ );
99
+ ok(result.stdout.includes("printable-shell-command"));
100
+ ok(!result.stdout.includes("@pnpm/exe"));
101
+ });
79
102
  });
package/src/dedupe.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import {
2
4
  countDuplicatedPackages,
3
5
  createPackageFilter,
@@ -32,6 +34,23 @@ const residualsMessage = "`pnpm dedupe` would also change the lockfile.";
32
34
  const filteredResidualsMessage =
33
35
  "`pnpm dedupe` was not run: it ignores the package filter. Run `pnpm dedupe` yourself, or this command without a filter, to finish the rest.";
34
36
 
37
+ // The comment pnpm-dedup 2.2 wrote above the overrides it kept. Those still
38
+ // apply on every resolution, so a run cannot tell what holds without them.
39
+ const leftoverOverridesMarker = "# Added by pnpm-dedup.";
40
+
41
+ const warnOnLeftoverOverrides = (projectDir: string): void => {
42
+ const workspaceYamlPath = join(projectDir, "pnpm-workspace.yaml");
43
+ if (
44
+ !existsSync(workspaceYamlPath) ||
45
+ !readFileSync(workspaceYamlPath, "utf8").includes(leftoverOverridesMarker)
46
+ ) {
47
+ return;
48
+ }
49
+ console.log(
50
+ `pnpm-workspace.yaml still has overrides an earlier pnpm-dedup kept (under "${leftoverOverridesMarker.slice(2)}"). This version never keeps any: remove them and run again to see what holds on its own.`,
51
+ );
52
+ };
53
+
35
54
  /**
36
55
  * `pnpm dedupe` only merges what it can resolve to a single version on its own:
37
56
  * it never widens a workspace range, and it never repoints an edge that resolved
@@ -79,6 +98,8 @@ export function dedupe({
79
98
  return undefined;
80
99
  })();
81
100
 
101
+ warnOnLeftoverOverrides(projectDir);
102
+
82
103
  const outcome = applyClusterFixes({
83
104
  projectDir,
84
105
  dryRun,
@@ -87,12 +108,6 @@ export function dedupe({
87
108
  packageManagerResiduals: residuals,
88
109
  });
89
110
 
90
- if (outcome.status === "kept-overrides") {
91
- console.log(
92
- `${outcome.stickyOverrides.length} override(s) left in pnpm-workspace.yaml — see the comment above them`,
93
- );
94
- }
95
-
96
111
  if (mode === "check") {
97
112
  process.exitCode =
98
113
  outcome.plannedChangeCount > 0 || (probe !== null && probe.status !== 0)
@@ -106,7 +121,7 @@ export function dedupe({
106
121
  // Applying an override needs a real resolution, and `pnpm dedupe` is the
107
122
  // only one that performs it — so a filtered run that changed something has
108
123
  // already merged whatever else pnpm could reach on its own.
109
- if (outcome.status === "applied" || outcome.status === "kept-overrides") {
124
+ if (outcome.status === "applied") {
110
125
  console.log(
111
126
  "`pnpm dedupe` ran to apply the fixes above and merged what it could on its own: the filter bounds the edits made here, not pnpm's resolution.",
112
127
  );