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
@@ -1,8 +1,8 @@
1
- import { describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
2
  import { spawnSync } from "node:child_process";
4
3
  import { readFileSync, rmSync } from "node:fs";
5
4
  import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { buildIdentifiedFixesMap } from "pm-dedup-core";
8
8
  import {
@@ -14,15 +14,24 @@ import {
14
14
  } from "./index.ts";
15
15
 
16
16
  // These tests shell out to the real `pnpm` and require either network access or
17
- // a warm pnpm store; they are skipped when pnpm is not on PATH. `--lockfile-only`
18
- // keeps pnpm from writing node_modules, and `--frozen-lockfile` / `--check` never
19
- // rewrite the lockfile, so pnpm runs against the committed fixtures in place
20
- // without modifying them (asserted below). `pnpm dedupe --check` only flags the
21
- // safely-mergeable subset of duplicates, so the invariant is: everything pnpm
22
- // would dedupe is also reported by our listing.
23
-
24
- const pnpmAvailable =
25
- spawnSync("pnpm", ["--version"], { encoding: "utf8" }).status === 0;
17
+ // a warm pnpm store. `--lockfile-only` keeps pnpm from writing node_modules, and
18
+ // `--frozen-lockfile` / `--check` never rewrite the lockfile, so pnpm runs
19
+ // against the committed fixtures in place without modifying them (asserted
20
+ // below). The invariant is: everything `pnpm dedupe` would merge is also
21
+ // reported by our listing.
22
+ //
23
+ // What `dedupe` merges changes between pnpm minors, so the expectations hold for
24
+ // pnpm 12 only and the suite is skipped under any other major. The fixtures cannot
25
+ // pin it through `packageManager`: pnpm 12 records the pinned version in the
26
+ // lockfile, which breaks their byte-exactness.
27
+
28
+ const SUPPORTED_PNPM_MAJOR = 12;
29
+
30
+ const pnpmMajor = ((): number | null => {
31
+ const result = spawnSync("pnpm", ["--version"], { encoding: "utf8" });
32
+ if (result.status !== 0) return null;
33
+ return Number(result.stdout.trim().split(".")[0]);
34
+ })();
26
35
 
27
36
  const fixturePath = (scenario: string): string =>
28
37
  fileURLToPath(new URL(`../test/fixtures/${scenario}`, import.meta.url));
@@ -47,17 +56,26 @@ const runPnpm = (cwd: string, args: string[]): PnpmRun => {
47
56
  };
48
57
  };
49
58
 
50
- // `pnpm dedupe --check` prints a tree of changes; each merged dependency is on a
51
- // branch line `├── name fromVersion → toVersion`. We collect those names.
52
- const parseDedupedPackages = (output: string): string[] => {
59
+ const withoutPeerSuffix = (version: string): string =>
60
+ version.split("(")[0] ?? "";
61
+
62
+ // `pnpm dedupe --check` prints a tree of changes, one `├── name from → to` branch
63
+ // per changed dependency. A change that keeps the version only re-resolves peers,
64
+ // which is not a merge.
65
+ const parseMergedPackages = (output: string): string[] => {
53
66
  const names = new Set<string>();
54
67
  for (const line of output.split("\n")) {
55
68
  if (!line.includes("→")) continue;
56
- const name = line
69
+ const [name, from, , to] = line
57
70
  .replace(/^[\s│├└─]+/u, "")
58
71
  .trim()
59
- .split(/\s+/u)[0];
60
- if (name) {
72
+ .split(/\s+/u);
73
+ if (
74
+ name &&
75
+ from &&
76
+ to &&
77
+ withoutPeerSuffix(from) !== withoutPeerSuffix(to)
78
+ ) {
61
79
  names.add(name);
62
80
  }
63
81
  }
@@ -98,70 +116,35 @@ const assertPristine = (dir: string, lockBefore: string): void => {
98
116
  rmSync(join(dir, "node_modules"), { recursive: true, force: true });
99
117
  };
100
118
 
101
- const suite = pnpmAvailable ? describe : describe.skip;
119
+ const suite = pnpmMajor === SUPPORTED_PNPM_MAJOR ? describe : describe.skip;
102
120
 
103
121
  suite("pnpm dedupe --check vs listDuplicates", () => {
104
- // `duplicated-typescript-eslint-dedupe-peers` is the same dependency with
105
- // `dedupePeers: true`, which flattens the peer suffixes in the lockfile: pnpm
106
- // must still merge the same packages, and we must still list them.
107
- const mergeable: { scenario: string; expectedFlagged: string[] }[] = [
108
- {
109
- scenario: "duplicated-typescript-eslint",
110
- expectedFlagged: [
111
- "@typescript-eslint/tsconfig-utils",
112
- "@typescript-eslint/types",
113
- ],
114
- },
115
- {
116
- scenario: "duplicated-typescript-eslint-dedupe-peers",
117
- expectedFlagged: [
118
- "@typescript-eslint/tsconfig-utils",
119
- "@typescript-eslint/types",
120
- ],
121
- },
122
- ];
123
-
124
- for (const { scenario, expectedFlagged } of mergeable) {
125
- it(`flags the mergeable subset of ${scenario}, all of which listDuplicates reports`, () => {
126
- const dir = fixturePath(scenario);
127
- const lockBefore = lockContent(dir);
128
-
129
- const install = runPnpm(dir, ["install", "--frozen-lockfile"]);
130
- strictEqual(install.status, 0, install.output);
131
- assertPristine(dir, lockBefore);
132
-
133
- const check = runPnpm(dir, ["dedupe", "--check"]);
134
- ok(
135
- check.status !== 0,
136
- `dedupe --check should flag issues\n${check.output}`,
137
- );
138
- assertPristine(dir, lockBefore);
139
-
140
- deepStrictEqual(parseDedupedPackages(check.output), expectedFlagged);
141
-
142
- const duplicates = duplicateNames(scenario);
143
- for (const name of expectedFlagged) {
144
- ok(
145
- duplicates.includes(name),
146
- `listDuplicates should report ${name} that pnpm dedupe flags`,
147
- );
148
- }
149
- }, 180_000);
150
- }
151
-
152
- // Duplicates pnpm does not merge: `dedupe --check` exits 0 and flags nothing,
153
- // yet listDuplicates still reports the duplicate. The `mergeable-alias*`
154
- // fixtures go one step further: every declared range accepts the aliased
155
- // 5.0.7 pin, so we do identify a merge target — one pnpm will never apply,
156
- // because merging means downgrading the range from 5.3.1.
122
+ // pnpm merges none of these duplicates, yet listDuplicates reports each one.
123
+ // In the `duplicated-typescript-eslint*` fixtures (`-dedupe-peers` is the same
124
+ // dependency with `dedupePeers: true`, which flattens the peer suffixes) pnpm
125
+ // re-resolves `@pob/eslint-config`'s auto-installed `eslint` peer to 9.39.5,
126
+ // the newest version that also satisfies eslint-plugin-react, and leaves the
127
+ // @typescript-eslint packages duplicated; pnpm 11.26 still merged them.
128
+ // The `mergeable-alias*` fixtures go one step further: every declared range
129
+ // accepts the aliased 5.0.7 pin, so we do identify a merge target — one pnpm
130
+ // will never apply, because merging means downgrading the range from 5.3.1.
157
131
  // `wildcard-not-reused` is the widest case: a `*` range resolved to 0.87.0
158
132
  // instead of the installed 0.84.5, duplicating the metro family (see
159
- // wildcardNotReused.test.ts). pnpm will not undo it either.
160
- const unmergeableByPnpm: {
133
+ // wildcardNotReused.test.ts). pnpm will not undo it either; it only re-resolves
134
+ // the optional `supports-color` peer of metro-symbolicate.
135
+ const scenarios: {
161
136
  scenario: string;
162
137
  expectedDuplicate: string;
163
138
  expectedFixTargets?: string[];
164
139
  }[] = [
140
+ {
141
+ scenario: "duplicated-typescript-eslint",
142
+ expectedDuplicate: "@typescript-eslint/types",
143
+ },
144
+ {
145
+ scenario: "duplicated-typescript-eslint-dedupe-peers",
146
+ expectedDuplicate: "@typescript-eslint/types",
147
+ },
165
148
  {
166
149
  scenario: "duplicated-babel-frame",
167
150
  expectedDuplicate: "@babel/code-frame",
@@ -186,33 +169,34 @@ suite("pnpm dedupe --check vs listDuplicates", () => {
186
169
  },
187
170
  ];
188
171
 
189
- for (const {
190
- scenario,
191
- expectedDuplicate,
192
- expectedFixTargets,
193
- } of unmergeableByPnpm) {
194
- it(`flags nothing for ${scenario} but still lists ${expectedDuplicate}`, () => {
195
- const dir = fixturePath(scenario);
196
- const lockBefore = lockContent(dir);
197
-
198
- const install = runPnpm(dir, ["install", "--frozen-lockfile"]);
199
- strictEqual(install.status, 0, install.output);
200
- assertPristine(dir, lockBefore);
201
-
202
- const check = runPnpm(dir, ["dedupe", "--check"]);
203
- strictEqual(
204
- check.status,
205
- 0,
206
- `dedupe --check should find nothing to merge\n${check.output}`,
207
- );
208
- assertPristine(dir, lockBefore);
209
-
210
- deepStrictEqual(parseDedupedPackages(check.output), []);
211
- ok(duplicateNames(scenario).includes(expectedDuplicate));
212
- deepStrictEqual(
213
- fixTargets(scenario, expectedDuplicate),
214
- expectedFixTargets ?? [],
215
- );
216
- }, 180_000);
172
+ for (const { scenario, expectedDuplicate, expectedFixTargets } of scenarios) {
173
+ it(
174
+ `merges nothing in ${scenario} but still lists ${expectedDuplicate}`,
175
+ { timeout: 180_000 },
176
+ () => {
177
+ const dir = fixturePath(scenario);
178
+ const lockBefore = lockContent(dir);
179
+
180
+ const install = runPnpm(dir, ["install", "--frozen-lockfile"]);
181
+ strictEqual(install.status, 0, install.output);
182
+ assertPristine(dir, lockBefore);
183
+
184
+ // exits 1 on a peer-only re-resolution too
185
+ const check = runPnpm(dir, ["dedupe", "--check"]);
186
+ ok(
187
+ check.status === 0 ||
188
+ check.output.includes("ERR_PNPM_DEDUPE_CHECK_ISSUES"),
189
+ check.output,
190
+ );
191
+ assertPristine(dir, lockBefore);
192
+
193
+ deepStrictEqual(parseMergedPackages(check.output), []);
194
+ ok(duplicateNames(scenario).includes(expectedDuplicate));
195
+ deepStrictEqual(
196
+ fixTargets(scenario, expectedDuplicate),
197
+ expectedFixTargets ?? [],
198
+ );
199
+ },
200
+ );
217
201
  }
218
202
  });
@@ -1,8 +1,8 @@
1
- import { afterEach, describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
2
  import { spawnSync } from "node:child_process";
4
3
  import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
5
4
  import { join } from "node:path";
5
+ import { afterEach, describe, it } from "node:test";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { buildPnpmPackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
8
8
  import { readDuplicateSnapshot } from "./helpers/duplicateSnapshot.ts";
@@ -89,80 +89,87 @@ const resolutionsOf = (dir: string, packageName: string): string[] => {
89
89
  const suite = pnpmAvailable ? describe : describe.skip;
90
90
 
91
91
  suite("pnpm-dedupe end to end", () => {
92
- it("converges the cluster and leaves pnpm holding it without an override", () => {
93
- const dir = projects.create();
94
- // only the manifest: the lockfile has to be the real resolution pnpm writes
95
- // here, not the trimmed one the unit-test fixture commits
96
- cpSync(join(fixtureDir, "package.json"), join(dir, "package.json"));
97
- writeFileSync(join(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
98
-
99
- const installed = install(dir);
100
- strictEqual(installed.status, 0, installed.output);
101
-
102
- // `@yudiel/react-qr-scanner` pins barcode-detector at exactly 3.0.3 while
103
- // expo-camera's `^3.0.0` resolved to 3.2.2, and the pair drags zxing-wasm
104
- // along: four resolutions for two packages.
105
- const before = readDuplicateSnapshot(lockPath(dir));
106
- strictEqual(before.size, 4, [...before].join(", "));
107
- ok(before.has("barcode-detector@3.0.3"));
108
- ok(before.has("barcode-detector@3.2.2"));
109
-
110
- const applied = dedupe(dir);
111
- strictEqual(applied.status, 0, applied.output);
112
-
113
- // the tool's own verdict: the convergence override went in, came back out,
114
- // and the result survived the re-resolution without it
115
- ok(applied.output.includes('"barcode-detector@": "3.0.3"'), applied.output);
116
- ok(
117
- applied.output.includes(
118
- "Removing the overrides and re-resolving to check the result holds",
119
- ),
120
- applied.output,
121
- );
122
- // what it deduped, named: two packages, one copy of each merged away
123
- ok(
124
- applied.output.includes("Deduped 2 packages, 2 copies merged:"),
125
- applied.output,
126
- );
127
- ok(
128
- applied.output.includes(
129
- "barcode-detector: 2 versions (3.0.3, 3.2.2) -> 1 version (3.0.3)",
130
- ),
131
- applied.output,
132
- );
133
- ok(applied.output.includes("No duplicate left."), applied.output);
134
-
135
- // what matters is the lockfile pnpm was left with
136
- strictEqual(readDuplicateSnapshot(lockPath(dir)).size, 0);
137
- deepStrictEqual(resolutionsOf(dir, "barcode-detector"), [
138
- "barcode-detector@3.0.3",
139
- ]);
140
- // barcode-detector 3.2.2 pinned zxing-wasm at exactly 3.1.3; 3.0.3 asks for
141
- // `^2.1.2`, so converging onto it has to bring the 2.x line back — a 3.1.3
142
- // left standing would be a range the installed tree contradicts.
143
- const zxing = resolutionsOf(dir, "zxing-wasm");
144
- strictEqual(zxing.length, 1, zxing.join(", "));
145
- ok(zxing[0]?.startsWith("zxing-wasm@2."), zxing.join(", "));
146
-
147
- // no standing override is left behind, in a file otherwise untouched
148
- strictEqual(
149
- readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8"),
150
- workspaceYamlContent,
151
- );
152
-
153
- // and pnpm itself agrees, from the manifests alone
154
- const frozen = install(dir, "--frozen-lockfile");
155
- strictEqual(frozen.status, 0, frozen.output);
156
-
157
- // pnpm's own account of the installed tree, not ours: one copy, reached
158
- // from both dependents
159
- const list = run(dir, "pnpm", ["list", "--depth", "Infinity"]);
160
- strictEqual(list.status, 0, list.output);
161
- ok(list.output.includes("barcode-detector@3.0.3 [deduped]"), list.output);
162
- strictEqual(list.output.includes("barcode-detector@3.2.2"), false);
163
-
164
- const check = dedupe(dir, "--check");
165
- strictEqual(check.status, 0, check.output);
166
- ok(check.output.includes("Nothing to dedupe."), check.output);
167
- }, 180_000);
92
+ it(
93
+ "converges the cluster and leaves pnpm holding it without an override",
94
+ { timeout: 180_000 },
95
+ () => {
96
+ const dir = projects.create();
97
+ // only the manifest: the lockfile has to be the real resolution pnpm writes
98
+ // here, not the trimmed one the unit-test fixture commits
99
+ cpSync(join(fixtureDir, "package.json"), join(dir, "package.json"));
100
+ writeFileSync(join(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
101
+
102
+ const installed = install(dir);
103
+ strictEqual(installed.status, 0, installed.output);
104
+
105
+ // `@yudiel/react-qr-scanner` pins barcode-detector at exactly 3.0.3 while
106
+ // expo-camera's `^3.0.0` resolved to 3.2.2, and the pair drags zxing-wasm
107
+ // along: four resolutions for two packages.
108
+ const before = readDuplicateSnapshot(lockPath(dir));
109
+ strictEqual(before.size, 4, [...before].join(", "));
110
+ ok(before.has("barcode-detector@3.0.3"));
111
+ ok(before.has("barcode-detector@3.2.2"));
112
+
113
+ const applied = dedupe(dir);
114
+ strictEqual(applied.status, 0, applied.output);
115
+
116
+ // the tool's own verdict: the convergence override went in, came back out,
117
+ // and the result survived the re-resolution without it
118
+ ok(
119
+ applied.output.includes('"barcode-detector@": "3.0.3"'),
120
+ applied.output,
121
+ );
122
+ ok(
123
+ applied.output.includes(
124
+ "Removing the overrides and re-resolving to check the result holds",
125
+ ),
126
+ applied.output,
127
+ );
128
+ // what it deduped, named: two packages, one copy of each merged away
129
+ ok(
130
+ applied.output.includes("Deduped 2 packages, 2 copies merged:"),
131
+ applied.output,
132
+ );
133
+ ok(
134
+ applied.output.includes(
135
+ "barcode-detector: 2 versions (3.0.3, 3.2.2) -> 1 version (3.0.3)",
136
+ ),
137
+ applied.output,
138
+ );
139
+ ok(applied.output.includes("No duplicate left."), applied.output);
140
+
141
+ // what matters is the lockfile pnpm was left with
142
+ strictEqual(readDuplicateSnapshot(lockPath(dir)).size, 0);
143
+ deepStrictEqual(resolutionsOf(dir, "barcode-detector"), [
144
+ "barcode-detector@3.0.3",
145
+ ]);
146
+ // barcode-detector 3.2.2 pinned zxing-wasm at exactly 3.1.3; 3.0.3 asks for
147
+ // `^2.1.2`, so converging onto it has to bring the 2.x line back — a 3.1.3
148
+ // left standing would be a range the installed tree contradicts.
149
+ const zxing = resolutionsOf(dir, "zxing-wasm");
150
+ strictEqual(zxing.length, 1, zxing.join(", "));
151
+ ok(zxing[0]?.startsWith("zxing-wasm@2."), zxing.join(", "));
152
+
153
+ // no standing override is left behind, in a file otherwise untouched
154
+ strictEqual(
155
+ readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8"),
156
+ workspaceYamlContent,
157
+ );
158
+
159
+ // and pnpm itself agrees, from the manifests alone
160
+ const frozen = install(dir, "--frozen-lockfile");
161
+ strictEqual(frozen.status, 0, frozen.output);
162
+
163
+ // pnpm's own account of the installed tree, not ours: one copy, reached
164
+ // from both dependents
165
+ const list = run(dir, "pnpm", ["list", "--depth", "Infinity"]);
166
+ strictEqual(list.status, 0, list.output);
167
+ ok(list.output.includes("barcode-detector@3.0.3 [deduped]"), list.output);
168
+ strictEqual(list.output.includes("barcode-detector@3.2.2"), false);
169
+
170
+ const check = dedupe(dir, "--check");
171
+ strictEqual(check.status, 0, check.output);
172
+ ok(check.output.includes("Nothing to dedupe."), check.output);
173
+ },
174
+ );
168
175
  });
@@ -1,5 +1,5 @@
1
- import { describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { buildIdentifiedFixesMap } from "pm-dedup-core";
5
5
  import type { PackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { ok } from "node:assert/strict";
1
+ import { ok, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { ClusterFix } from "pm-dedup-core";
5
5
  import { displayMany } from "./displayMany.ts";
@@ -86,7 +86,6 @@ const clusterFix = (overrides: Partial<ClusterFix> = {}): ClusterFix => ({
86
86
  excludedMembers: [],
87
87
  anchor: null,
88
88
  reuseFixes: [],
89
- floatingMembers: [],
90
89
  workspaceChanges: [],
91
90
  reResolutionSet: ["metro"],
92
91
  externalConstraints: [],
@@ -128,7 +127,8 @@ describe("displayMany", () => {
128
127
  });
129
128
 
130
129
  it("reports no duplicates for a clean lockfile", () => {
131
- expect(renderDuplicates("simple")).toBe(
130
+ strictEqual(
131
+ renderDuplicates("simple"),
132
132
  "No duplicates found\n\nFound 1 dependency, 0 duplicates, 0 dedupable.\n",
133
133
  );
134
134
  });
@@ -1,5 +1,5 @@
1
- import { describe, it } from "bun:test";
2
1
  import { ok, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { buildIdentifiedFixesMap } from "pm-dedup-core";
4
4
  import type { PackageResolution, PackagesMap } from "./buildPnpmPackagesMap.ts";
5
5
  import type { DependentsMap } from "./collectPnpmDependents.ts";
@@ -1,5 +1,5 @@
1
- import { describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { readPnpmLock } from "../readPnpmLock.ts";
5
5
  import {
@@ -1,5 +1,5 @@
1
- import { describe, it } from "bun:test";
2
1
  import { deepStrictEqual, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { readPnpmLock } from "../readPnpmLock.ts";
5
5
  import { collectDependentRanges } from "./collectDependentRanges.ts";
@@ -1,5 +1,5 @@
1
- import { describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { readPnpmLock } from "../readPnpmLock.ts";
5
5
  import { collectPnpmDependents } from "./collectPnpmDependents.ts";
@@ -0,0 +1,100 @@
1
+ import { deepStrictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { describeUnheldOverride } from "./describeUnheldOverride.ts";
4
+
5
+ describe("describeUnheldOverride", () => {
6
+ it("names a requester once, whatever its peer contexts", () => {
7
+ deepStrictEqual(
8
+ describeUnheldOverride(
9
+ { packageName: "lightningcss", version: "1.30.1", reason: "converge" },
10
+ [
11
+ // two installations of one requester, one already repointed
12
+ {
13
+ key: "react-native-css@3.1.0-rc.0",
14
+ range: ">=1.27.0",
15
+ resolvedVersion: "1.30.1",
16
+ requesterName: "react-native-css",
17
+ peer: true,
18
+ },
19
+ {
20
+ key: "react-native-css@3.1.0-rc.0",
21
+ range: ">=1.27.0",
22
+ resolvedVersion: "1.33.0",
23
+ requesterName: "react-native-css",
24
+ peer: true,
25
+ },
26
+ {
27
+ key: "react-native-css@3.1.0-rc.0",
28
+ range: ">=1.27.0",
29
+ resolvedVersion: "1.33.0",
30
+ requesterName: "react-native-css",
31
+ peer: true,
32
+ },
33
+ {
34
+ key: "vite@8.3.0",
35
+ range: "^1.33.0",
36
+ resolvedVersion: "1.33.0",
37
+ requesterName: "vite",
38
+ },
39
+ ],
40
+ ),
41
+ [
42
+ 'vite@8.3.0 requires "^1.33.0"',
43
+ 'react-native-css@3.1.0-rc.0 requires ">=1.27.0" (peer), is provided 1.33.0',
44
+ ],
45
+ );
46
+ });
47
+
48
+ it("lists the ranges that reject the version first", () => {
49
+ deepStrictEqual(
50
+ describeUnheldOverride(
51
+ {
52
+ packageName: "@commitlint/types",
53
+ version: "21.0.1",
54
+ reason: "converge",
55
+ },
56
+ [
57
+ {
58
+ key: "@commitlint/format@21.0.1",
59
+ range: "^21.0.1",
60
+ resolvedVersion: "21.1.0",
61
+ requesterName: "@commitlint/format",
62
+ },
63
+ {
64
+ key: "@commitlint/rules@21.1.0",
65
+ range: "^21.1.0",
66
+ resolvedVersion: "21.1.0",
67
+ requesterName: "@commitlint/rules",
68
+ },
69
+ {
70
+ key: "@pob/root@27.11.0",
71
+ range: "21.0.1",
72
+ resolvedVersion: "21.0.1",
73
+ requesterName: "@pob/root",
74
+ },
75
+ ],
76
+ ),
77
+ [
78
+ '@commitlint/rules@21.1.0 requires "^21.1.0"',
79
+ '@commitlint/format@21.0.1 requires "^21.0.1", resolves 21.1.0 without an override',
80
+ ],
81
+ );
82
+ });
83
+
84
+ it("names an importer by its manifest and dependency type", () => {
85
+ deepStrictEqual(
86
+ describeUnheldOverride(
87
+ { packageName: "leaf", version: "2.0.0", reason: "converge" },
88
+ [
89
+ {
90
+ key: "package.json in devDependencies",
91
+ range: "1.0.0",
92
+ resolvedVersion: "1.0.0",
93
+ workspace: { path: ".", depType: "devDependencies" },
94
+ },
95
+ ],
96
+ ),
97
+ ['package.json in devDependencies requires "1.0.0"'],
98
+ );
99
+ });
100
+ });
@@ -0,0 +1,53 @@
1
+ import type { PlannedOverride } from "pm-dedup-core";
2
+ import semver from "semver";
3
+ import type { DependentRange } from "./collectDependentRanges.ts";
4
+
5
+ const rejects = (version: string, range: string): boolean =>
6
+ semver.validRange(range) !== null &&
7
+ !semver.satisfies(version, range, { includePrerelease: true });
8
+
9
+ const describeDependent = (
10
+ dependent: DependentRange & { resolvedVersion: string },
11
+ version: string,
12
+ ): string => {
13
+ const declared = `${dependent.key} requires "${dependent.range}"`;
14
+ if (rejects(version, dependent.range)) {
15
+ return dependent.peer ? `${declared} (peer)` : declared;
16
+ }
17
+ // pnpm provides a peer from the parent's context rather than resolving it
18
+ return dependent.peer
19
+ ? `${declared} (peer), is provided ${dependent.resolvedVersion}`
20
+ : `${declared}, resolves ${dependent.resolvedVersion} without an override`;
21
+ };
22
+
23
+ /**
24
+ * Why an override did not hold, as one line per edge still resolving away from
25
+ * its version, the ranges that reject it first: those are what blocks the merge.
26
+ *
27
+ * Several peer contexts of one requester share a key, so lines are counted once.
28
+ */
29
+ export const describeUnheldOverride = (
30
+ override: PlannedOverride,
31
+ dependents: DependentRange[],
32
+ ): string[] => {
33
+ const offVersion = dependents.filter(
34
+ (dependent): dependent is DependentRange & { resolvedVersion: string } =>
35
+ dependent.resolvedVersion !== undefined &&
36
+ dependent.resolvedVersion !== override.version,
37
+ );
38
+
39
+ const blockersFirst = offVersion.toSorted(
40
+ (a, b) =>
41
+ Number(rejects(override.version, b.range)) -
42
+ Number(rejects(override.version, a.range)) ||
43
+ a.key.localeCompare(b.key),
44
+ );
45
+
46
+ return [
47
+ ...new Set(
48
+ blockersFirst.map((dependent) =>
49
+ describeDependent(dependent, override.version),
50
+ ),
51
+ ),
52
+ ];
53
+ };
@@ -1,5 +1,5 @@
1
- import { describe, it } from "bun:test";
2
1
  import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { readPnpmLock } from "../readPnpmLock.ts";
5
5
  import {