isolate-package 1.33.0 → 1.34.0

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 (67) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/{isolate-DyRD5Zd_.mjs → isolate-DI3eUTci.mjs} +862 -263
  4. package/dist/isolate-DI3eUTci.mjs.map +1 -0
  5. package/dist/isolate-bin.mjs +5 -6
  6. package/dist/isolate-bin.mjs.map +1 -1
  7. package/package.json +23 -19
  8. package/src/get-internal-package-names.test.ts +1 -1
  9. package/src/get-internal-package-names.ts +2 -2
  10. package/src/isolate-bin.ts +5 -5
  11. package/src/isolate.ts +22 -17
  12. package/src/lib/config.test.ts +1 -1
  13. package/src/lib/config.ts +3 -3
  14. package/src/lib/lockfile/helpers/bun-lockfile.ts +153 -0
  15. package/src/lib/lockfile/helpers/generate-bun-lockfile.test.ts +3 -3
  16. package/src/lib/lockfile/helpers/generate-bun-lockfile.ts +14 -146
  17. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +1 -5
  18. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +311 -16
  19. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +193 -22
  20. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +2 -2
  21. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +6 -6
  22. package/src/lib/lockfile/helpers/generate-yarn-lockfile.ts +5 -5
  23. package/src/lib/lockfile/process-lockfile.test.ts +2 -2
  24. package/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts +3 -3
  25. package/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +2 -2
  26. package/src/lib/manifest/helpers/adapt-manifest-internal-deps.ts +1 -1
  27. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.test.ts +4 -4
  28. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts +7 -7
  29. package/src/lib/manifest/helpers/resolve-catalog-dependencies.test.ts +410 -0
  30. package/src/lib/manifest/helpers/resolve-catalog-dependencies.ts +115 -27
  31. package/src/lib/manifest/io.ts +6 -2
  32. package/src/lib/manifest/validate-manifest.ts +2 -2
  33. package/src/lib/output/get-build-output-dir.ts +1 -1
  34. package/src/lib/output/pack-dependencies.ts +1 -1
  35. package/src/lib/output/process-build-output-files.ts +6 -17
  36. package/src/lib/package-manager/helpers/infer-from-files.ts +5 -5
  37. package/src/lib/package-manager/helpers/infer-from-manifest.ts +7 -8
  38. package/src/lib/package-manager/index.ts +1 -1
  39. package/src/lib/package-manager/names.ts +8 -10
  40. package/src/lib/patches/collect-installed-names-bun.test.ts +154 -0
  41. package/src/lib/patches/collect-installed-names-bun.ts +87 -0
  42. package/src/lib/patches/collect-installed-names-pnpm.test.ts +316 -0
  43. package/src/lib/patches/collect-installed-names-pnpm.ts +365 -0
  44. package/src/lib/patches/copy-patches.test.ts +130 -13
  45. package/src/lib/patches/copy-patches.ts +47 -10
  46. package/src/lib/patches/write-isolate-pnpm-workspace.test.ts +83 -3
  47. package/src/lib/patches/write-isolate-pnpm-workspace.ts +4 -4
  48. package/src/lib/registry/collect-reachable-package-names.test.ts +1 -1
  49. package/src/lib/registry/create-packages-registry.ts +34 -31
  50. package/src/lib/registry/helpers/find-packages-globs.ts +23 -19
  51. package/src/lib/registry/list-internal-packages.test.ts +2 -2
  52. package/src/lib/types.ts +2 -2
  53. package/src/lib/utils/filter-patched-dependencies.test.ts +1 -1
  54. package/src/lib/utils/filter-patched-dependencies.ts +2 -2
  55. package/src/lib/utils/get-dirname.ts +1 -1
  56. package/src/lib/utils/index.ts +1 -1
  57. package/src/lib/utils/json.ts +12 -14
  58. package/src/lib/utils/pack.ts +32 -22
  59. package/src/lib/utils/reset-isolate-dir.test.ts +165 -0
  60. package/src/lib/utils/reset-isolate-dir.ts +147 -0
  61. package/src/lib/utils/unpack.test.ts +76 -0
  62. package/src/lib/utils/unpack.ts +16 -10
  63. package/src/lib/utils/wait-for-complete-file.test.ts +105 -0
  64. package/src/lib/utils/wait-for-complete-file.ts +44 -0
  65. package/src/lib/utils/yaml.ts +8 -9
  66. package/src/testing/setup.ts +1 -1
  67. package/dist/isolate-DyRD5Zd_.mjs.map +0 -1
@@ -405,12 +405,14 @@ describe("buildIsolatedLockfileJson", () => {
405
405
  });
406
406
 
407
407
  /**
408
- * When the target's nested entry remaps onto the same path as a hoisted
409
- * entry still needed by another reachable dependency, and the two
410
- * entries differ in content, we must refuse to produce the lockfile
411
- * rather than silently drop one version.
408
+ * Reproduces https://github.com/0x80/isolate-package/issues/187. When the
409
+ * target's nested entry remaps onto the same path as a hoisted entry still
410
+ * needed by another reachable dependency, the target's nested version must
411
+ * win at the new root (the target becomes the isolate root) and the
412
+ * displaced hoisted entry must be re-nested under each consumer that
413
+ * originally resolved to it.
412
414
  */
413
- it("throws on a remap collision with conflicting entries", () => {
415
+ it("re-nests the displaced hoisted entry under each consumer that resolved to it", () => {
414
416
  const srcData: Parameters<typeof buildIsolatedLockfileJson>[0]["srcData"] =
415
417
  {
416
418
  name: "root",
@@ -430,13 +432,13 @@ describe("buildIsolatedLockfileJson", () => {
430
432
  dependencies: { semver: "^7" },
431
433
  },
432
434
  "node_modules/shared": { resolved: "packages/shared", link: true },
433
- /** Hoisted v7 used by the internal dep. */
435
+ /** Hoisted v7 used by the internal dep "shared". */
434
436
  "node_modules/semver": {
435
437
  version: "7.7.4",
436
438
  resolved: "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
437
439
  integrity: "sha512-hoisted-v7",
438
440
  },
439
- /** Nested v6 used by the target — will collide with the hoisted one. */
441
+ /** Nested v6 used by the target — collides with the hoisted one. */
440
442
  "packages/api/node_modules/semver": {
441
443
  version: "6.3.1",
442
444
  resolved: "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -457,15 +459,308 @@ describe("buildIsolatedLockfileJson", () => {
457
459
  { location: "packages/api/node_modules/semver", isLink: false },
458
460
  ];
459
461
 
460
- expect(() =>
461
- buildIsolatedLockfileJson({
462
- srcData,
463
- reachable,
464
- targetImporterLoc: "packages/api",
465
- targetLinkLoc: "node_modules/api",
466
- targetPackageManifest: { name: "api", version: "1.0.0" },
467
- }),
468
- ).toThrow(/Path collision at "node_modules\/semver"/);
462
+ const out = buildIsolatedLockfileJson({
463
+ srcData,
464
+ reachable,
465
+ targetImporterLoc: "packages/api",
466
+ targetLinkLoc: "node_modules/api",
467
+ targetPackageManifest: {
468
+ name: "api",
469
+ version: "1.0.0",
470
+ dependencies: { semver: "^6", shared: "file:./packages/shared" },
471
+ },
472
+ });
473
+
474
+ /** Target's nested v6 wins at the new root. */
475
+ expect(out.packages["node_modules/semver"]).toEqual({
476
+ version: "6.3.1",
477
+ resolved: "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
478
+ integrity: "sha512-nested-v6",
479
+ });
480
+
481
+ /** Original nested path must not leak through. */
482
+ expect(out.packages["packages/api/node_modules/semver"]).toBeUndefined();
483
+
484
+ /** Displaced v7 is re-nested under the consumer that resolved to it. */
485
+ expect(out.packages["packages/shared/node_modules/semver"]).toEqual({
486
+ version: "7.7.4",
487
+ resolved: "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
488
+ integrity: "sha512-hoisted-v7",
489
+ });
490
+ });
491
+
492
+ /**
493
+ * When multiple reachable consumers each resolve to the displaced hoisted
494
+ * entry, every consumer should get its own nested copy.
495
+ */
496
+ it("re-nests the displaced entry under every consumer that needs it", () => {
497
+ const srcData: Parameters<typeof buildIsolatedLockfileJson>[0]["srcData"] =
498
+ {
499
+ name: "root",
500
+ version: "0.0.0",
501
+ lockfileVersion: 3,
502
+ requires: true,
503
+ packages: {
504
+ "": { name: "root", version: "0.0.0" },
505
+ "packages/api": {
506
+ name: "api",
507
+ version: "1.0.0",
508
+ dependencies: { "resolve-from": "^5" },
509
+ },
510
+ /** Target's nested override — wins at the new root. */
511
+ "packages/api/node_modules/resolve-from": {
512
+ version: "5.0.0",
513
+ resolved:
514
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
515
+ integrity: "sha512-nested-v5",
516
+ },
517
+ /** Hoisted older version used by two transitive deps. */
518
+ "node_modules/resolve-from": {
519
+ version: "4.0.0",
520
+ resolved:
521
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
522
+ integrity: "sha512-hoisted-v4",
523
+ },
524
+ "node_modules/cosmiconfig": {
525
+ version: "7.0.0",
526
+ resolved:
527
+ "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
528
+ integrity: "sha512-cosmi",
529
+ dependencies: { "resolve-from": "^4" },
530
+ },
531
+ "node_modules/import-fresh": {
532
+ version: "3.3.0",
533
+ resolved:
534
+ "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
535
+ integrity: "sha512-import-fresh",
536
+ dependencies: { "resolve-from": "^4" },
537
+ },
538
+ },
539
+ };
540
+
541
+ const reachable: ReachableNode[] = [
542
+ { location: "packages/api", isLink: false },
543
+ { location: "packages/api/node_modules/resolve-from", isLink: false },
544
+ { location: "node_modules/resolve-from", isLink: false },
545
+ { location: "node_modules/cosmiconfig", isLink: false },
546
+ { location: "node_modules/import-fresh", isLink: false },
547
+ ];
548
+
549
+ const out = buildIsolatedLockfileJson({
550
+ srcData,
551
+ reachable,
552
+ targetImporterLoc: "packages/api",
553
+ targetLinkLoc: "node_modules/api",
554
+ targetPackageManifest: {
555
+ name: "api",
556
+ version: "1.0.0",
557
+ dependencies: { "resolve-from": "^5" },
558
+ },
559
+ });
560
+
561
+ expect(out.packages["node_modules/resolve-from"]!.version).toBe("5.0.0");
562
+ expect(
563
+ out.packages["node_modules/cosmiconfig/node_modules/resolve-from"],
564
+ ).toEqual({
565
+ version: "4.0.0",
566
+ resolved:
567
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
568
+ integrity: "sha512-hoisted-v4",
569
+ });
570
+ expect(
571
+ out.packages["node_modules/import-fresh/node_modules/resolve-from"],
572
+ ).toEqual({
573
+ version: "4.0.0",
574
+ resolved:
575
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
576
+ integrity: "sha512-hoisted-v4",
577
+ });
578
+ });
579
+
580
+ /**
581
+ * If a reachable consumer has its own nested copy that already satisfies
582
+ * the dep, it should not get an additional copy of the displaced entry.
583
+ */
584
+ it("skips consumers that have their own nested resolution", () => {
585
+ const srcData: Parameters<typeof buildIsolatedLockfileJson>[0]["srcData"] =
586
+ {
587
+ name: "root",
588
+ version: "0.0.0",
589
+ lockfileVersion: 3,
590
+ requires: true,
591
+ packages: {
592
+ "": { name: "root", version: "0.0.0" },
593
+ "packages/api": {
594
+ name: "api",
595
+ version: "1.0.0",
596
+ dependencies: { "resolve-from": "^5" },
597
+ },
598
+ "packages/api/node_modules/resolve-from": {
599
+ version: "5.0.0",
600
+ resolved:
601
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
602
+ integrity: "sha512-nested-v5",
603
+ },
604
+ /** Displaced hoisted version. */
605
+ "node_modules/resolve-from": {
606
+ version: "4.0.0",
607
+ resolved:
608
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
609
+ integrity: "sha512-hoisted-v4",
610
+ },
611
+ /** Consumer with its own nested override (v3) — should not be re-nested. */
612
+ "node_modules/legacy-dep": {
613
+ version: "1.0.0",
614
+ resolved:
615
+ "https://registry.npmjs.org/legacy-dep/-/legacy-dep-1.0.0.tgz",
616
+ integrity: "sha512-legacy-dep",
617
+ dependencies: { "resolve-from": "^3" },
618
+ },
619
+ "node_modules/legacy-dep/node_modules/resolve-from": {
620
+ version: "3.0.0",
621
+ resolved:
622
+ "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
623
+ integrity: "sha512-legacy-v3",
624
+ },
625
+ /** Consumer without an own override — resolves to the displaced v4. */
626
+ "node_modules/cosmiconfig": {
627
+ version: "7.0.0",
628
+ resolved:
629
+ "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
630
+ integrity: "sha512-cosmi",
631
+ dependencies: { "resolve-from": "^4" },
632
+ },
633
+ },
634
+ };
635
+
636
+ const reachable: ReachableNode[] = [
637
+ { location: "packages/api", isLink: false },
638
+ { location: "packages/api/node_modules/resolve-from", isLink: false },
639
+ { location: "node_modules/resolve-from", isLink: false },
640
+ { location: "node_modules/legacy-dep", isLink: false },
641
+ {
642
+ location: "node_modules/legacy-dep/node_modules/resolve-from",
643
+ isLink: false,
644
+ },
645
+ { location: "node_modules/cosmiconfig", isLink: false },
646
+ ];
647
+
648
+ const out = buildIsolatedLockfileJson({
649
+ srcData,
650
+ reachable,
651
+ targetImporterLoc: "packages/api",
652
+ targetLinkLoc: "node_modules/api",
653
+ targetPackageManifest: {
654
+ name: "api",
655
+ version: "1.0.0",
656
+ dependencies: { "resolve-from": "^5" },
657
+ },
658
+ });
659
+
660
+ /** legacy-dep keeps its own nested v3 — no extra copy at this path. */
661
+ expect(
662
+ out.packages["node_modules/legacy-dep/node_modules/resolve-from"]!
663
+ .version,
664
+ ).toBe("3.0.0");
665
+
666
+ /** cosmiconfig had no own override and gets the displaced v4 nested. */
667
+ expect(
668
+ out.packages["node_modules/cosmiconfig/node_modules/resolve-from"]!
669
+ .version,
670
+ ).toBe("4.0.0");
671
+ });
672
+
673
+ /**
674
+ * Cascade scenario: a reachable node is simultaneously (a) the loser of one
675
+ * collision (its slot is taken by a target-nested winner) and (b) a
676
+ * potential consumer of another displaced entry. Because the displaced
677
+ * consumer is not actually present in the isolate, the re-nesting loop must
678
+ * skip it — otherwise it would try to nest under a slot already held by an
679
+ * unrelated target-nested entry and throw a spurious secondary-collision
680
+ * error.
681
+ */
682
+ it("skips re-nesting under a consumer whose own entry has been displaced", () => {
683
+ const srcData: Parameters<typeof buildIsolatedLockfileJson>[0]["srcData"] =
684
+ {
685
+ name: "root",
686
+ version: "0.0.0",
687
+ lockfileVersion: 3,
688
+ requires: true,
689
+ packages: {
690
+ "": { name: "root", version: "0.0.0" },
691
+ "packages/api": {
692
+ name: "api",
693
+ version: "1.0.0",
694
+ dependencies: { "dep-a": "*", "dep-x": "^5" },
695
+ },
696
+ /** Target's nested A@v2 displaces the hoisted A@v1. */
697
+ "packages/api/node_modules/dep-a": {
698
+ version: "2.0.0",
699
+ resolved: "https://registry.npmjs.org/dep-a/-/dep-a-2.0.0.tgz",
700
+ integrity: "sha512-a-v2",
701
+ dependencies: { "dep-x": "^3" },
702
+ },
703
+ /** A@v2 brings its own nested X@v3. */
704
+ "packages/api/node_modules/dep-a/node_modules/dep-x": {
705
+ version: "3.0.0",
706
+ resolved: "https://registry.npmjs.org/dep-x/-/dep-x-3.0.0.tgz",
707
+ integrity: "sha512-x-v3",
708
+ },
709
+ /** Target's nested X@v5 displaces the hoisted X@v1. */
710
+ "packages/api/node_modules/dep-x": {
711
+ version: "5.0.0",
712
+ resolved: "https://registry.npmjs.org/dep-x/-/dep-x-5.0.0.tgz",
713
+ integrity: "sha512-x-v5",
714
+ },
715
+ /** Hoisted A@v1, still reachable through another transitive path. */
716
+ "node_modules/dep-a": {
717
+ version: "1.0.0",
718
+ resolved: "https://registry.npmjs.org/dep-a/-/dep-a-1.0.0.tgz",
719
+ integrity: "sha512-a-v1",
720
+ dependencies: { "dep-x": "^1" },
721
+ },
722
+ /** Hoisted X@v1, which A@v1 originally resolved to. */
723
+ "node_modules/dep-x": {
724
+ version: "1.0.0",
725
+ resolved: "https://registry.npmjs.org/dep-x/-/dep-x-1.0.0.tgz",
726
+ integrity: "sha512-x-v1",
727
+ },
728
+ },
729
+ };
730
+
731
+ const reachable: ReachableNode[] = [
732
+ { location: "packages/api", isLink: false },
733
+ { location: "packages/api/node_modules/dep-a", isLink: false },
734
+ {
735
+ location: "packages/api/node_modules/dep-a/node_modules/dep-x",
736
+ isLink: false,
737
+ },
738
+ { location: "packages/api/node_modules/dep-x", isLink: false },
739
+ { location: "node_modules/dep-a", isLink: false },
740
+ { location: "node_modules/dep-x", isLink: false },
741
+ ];
742
+
743
+ const out = buildIsolatedLockfileJson({
744
+ srcData,
745
+ reachable,
746
+ targetImporterLoc: "packages/api",
747
+ targetLinkLoc: "node_modules/api",
748
+ targetPackageManifest: {
749
+ name: "api",
750
+ version: "1.0.0",
751
+ dependencies: { "dep-a": "*", "dep-x": "^5" },
752
+ },
753
+ });
754
+
755
+ /** The displaced hoisted A@v1 leaves no consumer present in the isolate
756
+ * (its only reachable consumer was itself), so no re-nesting happens for
757
+ * dep-x under node_modules/dep-a — the existing X@v3 placed via remap
758
+ * stays untouched. */
759
+ expect(out.packages["node_modules/dep-a"]!.version).toBe("2.0.0");
760
+ expect(out.packages["node_modules/dep-x"]!.version).toBe("5.0.0");
761
+ expect(out.packages["node_modules/dep-a/node_modules/dep-x"]!.version).toBe(
762
+ "3.0.0",
763
+ );
469
764
  });
470
765
 
471
766
  /**
@@ -1,9 +1,9 @@
1
1
  import Arborist from "@npmcli/arborist";
2
2
  import fs from "fs-extra";
3
3
  import path from "node:path";
4
- import { useLogger } from "~/lib/logger";
5
- import type { PackageManifest, PackagesRegistry } from "~/lib/types";
6
- import { getErrorMessage } from "~/lib/utils";
4
+ import { useLogger } from "#/lib/logger";
5
+ import type { PackageManifest, PackagesRegistry } from "#/lib/types";
6
+ import { getErrorMessage } from "#/lib/utils";
7
7
  import { loadNpmConfig } from "./load-npm-config";
8
8
 
9
9
  /**
@@ -114,9 +114,9 @@ export async function generateNpmLockfile({
114
114
  "Created lockfile at",
115
115
  path.join(isolateDir, "package-lock.json"),
116
116
  );
117
- } catch (err) {
118
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
119
- throw err;
117
+ } catch (error) {
118
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
119
+ throw error;
120
120
  }
121
121
  }
122
122
 
@@ -160,7 +160,7 @@ async function generateFromRootLockfile({
160
160
  }
161
161
 
162
162
  if (typeof targetImporterNode.location !== "string") {
163
- throw new Error(
163
+ throw new TypeError(
164
164
  `Target workspace "${targetPackageName}" resolved to a node without a location`,
165
165
  );
166
166
  }
@@ -277,11 +277,31 @@ export function buildIsolatedLockfileJson({
277
277
 
278
278
  const targetNestedNodeModulesPrefix = `${targetImporterLoc}/node_modules/`;
279
279
 
280
- /** Track the source location each output entry came from, so we can
281
- * produce a clear error if two source paths remap to the same target.
282
- */
280
+ const remapToOutputLoc = (origLoc: string): string => {
281
+ if (origLoc === targetImporterLoc) return "";
282
+ if (origLoc.startsWith(targetNestedNodeModulesPrefix)) {
283
+ return origLoc.slice(targetImporterLoc.length + 1);
284
+ }
285
+ return origLoc;
286
+ };
287
+
288
+ const isTargetNested = (origLoc: string): boolean =>
289
+ origLoc === targetImporterLoc ||
290
+ origLoc.startsWith(targetNestedNodeModulesPrefix);
291
+
292
+ /** Track the source location each output entry came from, used to identify
293
+ * the displaced entry when a collision occurs. */
283
294
  const origLocByNewLoc = new Map<string, string>();
284
295
 
296
+ /** Collisions where the target's nested entry displaces a hoisted entry that
297
+ * is still reachable from other deps. The displaced entry needs to be
298
+ * re-nested under each consumer that originally resolved to it. */
299
+ type Collision = {
300
+ loserOrigLoc: string;
301
+ loserEntry: LockfilePackageEntry;
302
+ };
303
+ const collisions: Collision[] = [];
304
+
285
305
  for (const node of reachable) {
286
306
  const origLoc = node.location;
287
307
 
@@ -299,14 +319,7 @@ export function buildIsolatedLockfileJson({
299
319
  * `packages/app/lib/core`) are preserved verbatim because their disk
300
320
  * location in the isolate is unchanged.
301
321
  */
302
- let newLoc: string;
303
- if (origLoc === targetImporterLoc) {
304
- newLoc = "";
305
- } else if (origLoc.startsWith(targetNestedNodeModulesPrefix)) {
306
- newLoc = origLoc.slice(targetImporterLoc.length + 1);
307
- } else {
308
- newLoc = origLoc;
309
- }
322
+ const newLoc = remapToOutputLoc(origLoc);
310
323
 
311
324
  const srcEntry = srcPackages[origLoc];
312
325
  if (!srcEntry) {
@@ -318,9 +331,36 @@ export function buildIsolatedLockfileJson({
318
331
  const existing = outPackages[newLoc];
319
332
  if (existing && !entriesAreEquivalent(existing, srcEntry)) {
320
333
  const previousOrigLoc = origLocByNewLoc.get(newLoc) ?? "<unknown>";
334
+ const incomingIsTargetNested = isTargetNested(origLoc);
335
+ const previousIsTargetNested = isTargetNested(previousOrigLoc);
336
+
337
+ if (incomingIsTargetNested && !previousIsTargetNested) {
338
+ /** The target-nested entry wins. The previously-stored hoisted entry
339
+ * is displaced and must be re-nested under its consumers. */
340
+ collisions.push({
341
+ loserOrigLoc: previousOrigLoc,
342
+ loserEntry: existing,
343
+ });
344
+ outPackages[newLoc] = { ...srcEntry };
345
+ origLocByNewLoc.set(newLoc, origLoc);
346
+ continue;
347
+ }
348
+
349
+ if (!incomingIsTargetNested && previousIsTargetNested) {
350
+ /** The previously-stored target-nested entry wins; the incoming
351
+ * hoisted entry is the loser. */
352
+ collisions.push({
353
+ loserOrigLoc: origLoc,
354
+ loserEntry: { ...srcEntry },
355
+ });
356
+ continue;
357
+ }
358
+
359
+ /** Neither side is the target's nested version, or both are — we have
360
+ * no rule to pick a winner. Bail loudly. */
321
361
  throw new Error(
322
- `Path collision at "${newLoc}": source locations "${previousOrigLoc}" and "${origLoc}" both map there with conflicting entries. ` +
323
- `This happens when the target pins a nested version override that collides with a hoisted version still needed by another reachable dependency. ` +
362
+ `Path collision at "${newLoc}": source locations "${previousOrigLoc}" and "${origLoc}" both map there with conflicting entries and no rule applies to pick a winner ` +
363
+ `(neither is a target-nested override, or both are). ` +
324
364
  `Please report a reproduction at https://github.com/0x80/isolate-package/issues.`,
325
365
  );
326
366
  }
@@ -329,6 +369,70 @@ export function buildIsolatedLockfileJson({
329
369
  origLocByNewLoc.set(newLoc, origLoc);
330
370
  }
331
371
 
372
+ /** Re-nest each displaced entry under the reachable consumers that
373
+ * originally resolved to it via node_modules walk-up. */
374
+ for (const collision of collisions) {
375
+ const loserName = extractPackageNameFromLockfileLoc(collision.loserOrigLoc);
376
+ if (!loserName) continue;
377
+
378
+ for (const consumer of reachable) {
379
+ const consumerSrcLoc = consumer.location;
380
+ if (consumerSrcLoc === collision.loserOrigLoc) continue;
381
+ if (consumerSrcLoc === targetLinkLoc) continue;
382
+
383
+ const consumerEntry = srcPackages[consumerSrcLoc];
384
+ if (!consumerEntry) continue;
385
+ /** Workspace links carry dependency metadata on the importer entry,
386
+ * not the link entry itself. Skip the link side. */
387
+ if (consumerEntry.link) continue;
388
+
389
+ if (!entryDependsOn(consumerEntry, loserName)) continue;
390
+
391
+ const resolvedSrcLoc = resolveDepInSrcLockfile(
392
+ consumerSrcLoc,
393
+ loserName,
394
+ srcPackages,
395
+ );
396
+ if (resolvedSrcLoc !== collision.loserOrigLoc) continue;
397
+
398
+ const consumerNewLoc = remapToOutputLoc(consumerSrcLoc);
399
+ /** Consumer maps to the isolate root (the target itself). The root
400
+ * slot is already taken by the winning version. The target's own
401
+ * dependencies use that version — we cannot serve a different one
402
+ * here without nesting under the target, which would be its own
403
+ * collision. Accept the resolution shift. */
404
+ if (consumerNewLoc === "") continue;
405
+
406
+ /** If the consumer was itself displaced by another collision (its
407
+ * src-side entry doesn't match the entry we actually placed at its
408
+ * new location), the consumer isn't really present in the isolate.
409
+ * Its original dep needs are irrelevant here. */
410
+ const consumerOutEntry = outPackages[consumerNewLoc];
411
+ if (
412
+ !consumerOutEntry ||
413
+ !entriesAreEquivalent(consumerOutEntry, consumerEntry)
414
+ ) {
415
+ continue;
416
+ }
417
+
418
+ const nestedLoc = `${consumerNewLoc}/node_modules/${loserName}`;
419
+
420
+ const existingNested = outPackages[nestedLoc];
421
+ if (existingNested) {
422
+ if (entriesAreEquivalent(existingNested, collision.loserEntry)) {
423
+ continue;
424
+ }
425
+ throw new Error(
426
+ `Cannot re-nest displaced "${loserName}" under "${consumerNewLoc}": ` +
427
+ `the slot "${nestedLoc}" already contains a different entry. ` +
428
+ `Please report a reproduction at https://github.com/0x80/isolate-package/issues.`,
429
+ );
430
+ }
431
+
432
+ outPackages[nestedLoc] = { ...collision.loserEntry };
433
+ }
434
+ }
435
+
332
436
  /**
333
437
  * If the target importer didn't make it into the reachable set for any
334
438
  * reason (upstream Arborist bug, programmer error), bail loudly rather
@@ -341,8 +445,10 @@ export function buildIsolatedLockfileJson({
341
445
  }
342
446
 
343
447
  /** Overlay the isolate root with the adapted target manifest. */
344
- const rootEntry: LockfilePackageEntry = { ...outPackages[""] };
345
- rootEntry.name = targetPackageManifest.name;
448
+ const rootEntry: LockfilePackageEntry = {
449
+ ...outPackages[""],
450
+ name: targetPackageManifest.name,
451
+ };
346
452
  if (targetPackageManifest.version) {
347
453
  rootEntry.version = targetPackageManifest.version;
348
454
  }
@@ -396,6 +502,71 @@ function entriesAreEquivalent(
396
502
  );
397
503
  }
398
504
 
505
+ /**
506
+ * Extracts the package name from a lockfile install location. Handles scoped
507
+ * packages, where the name is two segments after the last `node_modules/`.
508
+ *
509
+ * "node_modules/foo" -> "foo"
510
+ * "node_modules/@scope/foo" -> "@scope/foo"
511
+ * "node_modules/a/node_modules/b" -> "b"
512
+ * "node_modules/a/node_modules/@scope/b" -> "@scope/b"
513
+ *
514
+ * Returns null for locations that don't contain `node_modules/`.
515
+ */
516
+ function extractPackageNameFromLockfileLoc(loc: string): string | null {
517
+ const marker = "node_modules/";
518
+ const lastIdx = loc.lastIndexOf(marker);
519
+ if (lastIdx < 0) return null;
520
+ const tail = loc.slice(lastIdx + marker.length);
521
+ if (tail.startsWith("@")) {
522
+ const slashIdx = tail.indexOf("/");
523
+ if (slashIdx < 0) return tail;
524
+ /** Stop at the second `/` so we don't include any further nesting. */
525
+ const secondSlash = tail.indexOf("/", slashIdx + 1);
526
+ return secondSlash < 0 ? tail : tail.slice(0, secondSlash);
527
+ }
528
+ const slashIdx = tail.indexOf("/");
529
+ return slashIdx < 0 ? tail : tail.slice(0, slashIdx);
530
+ }
531
+
532
+ /**
533
+ * Returns true if the lockfile entry lists `depName` in any of its dependency
534
+ * fields. Includes peer/optional/dev because any of them may have been the
535
+ * reason for the dep being installed at runtime.
536
+ */
537
+ function entryDependsOn(entry: LockfilePackageEntry, depName: string): boolean {
538
+ return (
539
+ entry.dependencies?.[depName] !== undefined ||
540
+ entry.devDependencies?.[depName] !== undefined ||
541
+ entry.peerDependencies?.[depName] !== undefined ||
542
+ entry.optionalDependencies?.[depName] !== undefined
543
+ );
544
+ }
545
+
546
+ /**
547
+ * Resolves `depName` against `srcPackages` from the perspective of a consumer
548
+ * at `consumerLoc`, mirroring Node.js `node_modules` walk-up. Returns the
549
+ * lockfile key of the entry the consumer would load at runtime, or null when
550
+ * no candidate exists.
551
+ */
552
+ function resolveDepInSrcLockfile(
553
+ consumerLoc: string,
554
+ depName: string,
555
+ srcPackages: Record<string, LockfilePackageEntry>,
556
+ ): string | null {
557
+ let scope = consumerLoc;
558
+ while (true) {
559
+ const candidate =
560
+ scope === ""
561
+ ? `node_modules/${depName}`
562
+ : `${scope}/node_modules/${depName}`;
563
+ if (srcPackages[candidate]) return candidate;
564
+ if (scope === "") return null;
565
+ const idx = scope.lastIndexOf("/node_modules/");
566
+ scope = idx < 0 ? "" : scope.slice(0, idx);
567
+ }
568
+ }
569
+
399
570
  function overlayManifestDeps(
400
571
  entry: LockfilePackageEntry,
401
572
  manifest: PackageManifest,
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { generatePnpmLockfile } from "./generate-pnpm-lockfile";
3
3
 
4
4
  /** Mock utils */
5
- vi.mock("~/lib/utils", () => ({
5
+ vi.mock("#/lib/utils", () => ({
6
6
  getErrorMessage: vi.fn((err: Error) => err.message),
7
7
  isRushWorkspace: vi.fn(() => false),
8
8
  }));
@@ -57,7 +57,7 @@ const { pruneLockfile: pruneLockfile_v9 } = vi.mocked(
57
57
  await import("pnpm_prune_lockfile_v9"),
58
58
  );
59
59
 
60
- const { isRushWorkspace } = vi.mocked(await import("~/lib/utils"));
60
+ const { isRushWorkspace } = vi.mocked(await import("#/lib/utils"));
61
61
 
62
62
  /** Reusable lockfile fixture */
63
63
  function createMockLockfile() {
@@ -13,9 +13,9 @@ import {
13
13
  import { pruneLockfile as pruneLockfile_v8 } from "pnpm_prune_lockfile_v8";
14
14
  import { pruneLockfile as pruneLockfile_v9 } from "pnpm_prune_lockfile_v9";
15
15
  import { pick } from "remeda";
16
- import { useLogger } from "~/lib/logger";
17
- import type { PackageManifest, PackagesRegistry, PatchFile } from "~/lib/types";
18
- import { getErrorMessage, isRushWorkspace } from "~/lib/utils";
16
+ import { useLogger } from "#/lib/logger";
17
+ import type { PackageManifest, PackagesRegistry, PatchFile } from "#/lib/types";
18
+ import { getErrorMessage, isRushWorkspace } from "#/lib/utils";
19
19
  import { pnpmMapImporter } from "./pnpm-map-importer";
20
20
 
21
21
  export async function generatePnpmLockfile({
@@ -185,8 +185,8 @@ export async function generatePnpmLockfile({
185
185
  }
186
186
 
187
187
  log.debug("Created lockfile at", path.join(isolateDir, "pnpm-lock.yaml"));
188
- } catch (err) {
189
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
190
- throw err;
188
+ } catch (error) {
189
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
190
+ throw error;
191
191
  }
192
192
  }