isolate-package 1.31.0 → 1.32.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 (28) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/{isolate-DtNAHzfa.mjs → isolate-BRD2AgVJ.mjs} +385 -123
  3. package/dist/isolate-BRD2AgVJ.mjs.map +1 -0
  4. package/dist/isolate-bin.mjs +1 -1
  5. package/package.json +1 -1
  6. package/src/isolate.ts +1 -0
  7. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/package-lock.json +82 -0
  8. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/package.json +8 -0
  9. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/api/package.json +12 -0
  10. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/shared/package.json +12 -0
  11. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/utils/package.json +11 -0
  12. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/package-lock.json +56 -0
  13. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/package.json +8 -0
  14. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/packages/api/package.json +11 -0
  15. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/packages/other/package.json +11 -0
  16. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +243 -0
  17. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +604 -0
  18. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +417 -21
  19. package/src/lib/lockfile/process-lockfile.test.ts +4 -0
  20. package/src/lib/lockfile/process-lockfile.ts +14 -16
  21. package/src/lib/patches/copy-patches.test.ts +78 -0
  22. package/src/lib/patches/copy-patches.ts +22 -1
  23. package/src/lib/registry/collect-reachable-package-names.test.ts +239 -0
  24. package/src/lib/registry/collect-reachable-package-names.ts +60 -0
  25. package/src/lib/registry/index.ts +1 -0
  26. package/src/lib/utils/filter-patched-dependencies.test.ts +77 -0
  27. package/src/lib/utils/filter-patched-dependencies.ts +41 -17
  28. package/dist/isolate-DtNAHzfa.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { c as detectPackageManager, i as defineConfig, l as readTypedJson, n as listInternalPackages, o as resolveConfig, r as createPackagesRegistry, s as resolveWorkspacePaths, t as isolate } from "./isolate-DtNAHzfa.mjs";
1
+ import { c as detectPackageManager, i as defineConfig, l as readTypedJson, n as listInternalPackages, o as resolveConfig, r as createPackagesRegistry, s as resolveWorkspacePaths, t as isolate } from "./isolate-BRD2AgVJ.mjs";
2
2
  import path from "node:path";
3
3
  //#region src/get-internal-package-names.ts
4
4
  /**
@@ -75,10 +75,12 @@ function getPackageName(packageSpec) {
75
75
  //#endregion
76
76
  //#region src/lib/utils/filter-patched-dependencies.ts
77
77
  /**
78
- * Filters patched dependencies to only include patches for packages that are
79
- * present in the target package's dependencies based on dependency type.
78
+ * Filters patched dependencies to only include patches for packages that will
79
+ * be present in the isolated output, either as a direct dependency of the
80
+ * target or as a transitive dependency reachable through internal workspace
81
+ * packages.
80
82
  */
81
- function filterPatchedDependencies({ patchedDependencies, targetPackageManifest, includeDevDependencies }) {
83
+ function filterPatchedDependencies({ patchedDependencies, targetPackageManifest, includeDevDependencies, reachableDependencyNames }) {
82
84
  const log = useLogger();
83
85
  if (!patchedDependencies || typeof patchedDependencies !== "object") return;
84
86
  const filteredPatches = {};
@@ -86,27 +88,35 @@ function filterPatchedDependencies({ patchedDependencies, targetPackageManifest,
86
88
  let excludedCount = 0;
87
89
  for (const [packageSpec, patchInfo] of Object.entries(patchedDependencies)) {
88
90
  const packageName = getPackageName(packageSpec);
89
- /** Check if it's a production dependency */
91
+ /** Direct production dependency */
90
92
  if (targetPackageManifest.dependencies?.[packageName]) {
91
93
  filteredPatches[packageSpec] = patchInfo;
92
94
  includedCount++;
93
95
  log.debug(`Including production dependency patch: ${packageSpec}`);
94
96
  continue;
95
97
  }
96
- /** Check if it's a dev dependency and we should include dev dependencies */
97
- if (targetPackageManifest.devDependencies?.[packageName]) {
98
- if (includeDevDependencies) {
99
- filteredPatches[packageSpec] = patchInfo;
100
- includedCount++;
101
- log.debug(`Including dev dependency patch: ${packageSpec}`);
102
- } else {
103
- excludedCount++;
104
- log.debug(`Excluding dev dependency patch: ${packageSpec}`);
105
- }
98
+ /** Direct dev dependency (respects the dev-deps flag) */
99
+ if (includeDevDependencies && targetPackageManifest.devDependencies?.[packageName]) {
100
+ filteredPatches[packageSpec] = patchInfo;
101
+ includedCount++;
102
+ log.debug(`Including dev dependency patch: ${packageSpec}`);
106
103
  continue;
107
104
  }
108
- /** Package not found in dependencies or devDependencies */
109
- log.debug(`Excluding patch: ${packageSpec} (package "${packageName}" not in target dependencies)`);
105
+ /**
106
+ * Reachable via an internal workspace package. This fires even when the
107
+ * package is also listed in the target's devDependencies with
108
+ * `includeDevDependencies=false`, because the package is still installed
109
+ * in the isolate as a prod transitive.
110
+ */
111
+ if (reachableDependencyNames?.has(packageName)) {
112
+ filteredPatches[packageSpec] = patchInfo;
113
+ includedCount++;
114
+ log.debug(`Including transitive dependency patch: ${packageSpec}`);
115
+ continue;
116
+ }
117
+ /** Package won't be installed in the isolate */
118
+ if (targetPackageManifest.devDependencies?.[packageName]) log.debug(`Excluding dev dependency patch: ${packageSpec}`);
119
+ else log.debug(`Excluding patch: ${packageSpec} (package "${packageName}" not reachable from target)`);
110
120
  excludedCount++;
111
121
  }
112
122
  log.debug(`Filtered patches: ${includedCount} included, ${excludedCount} excluded`);
@@ -670,29 +680,227 @@ async function loadNpmConfig({ npmPath }) {
670
680
  //#endregion
671
681
  //#region src/lib/lockfile/helpers/generate-npm-lockfile.ts
672
682
  /**
673
- * Generate an isolated / pruned lockfile, based on the contents of installed
674
- * node_modules from the monorepo root plus the adapted package manifest in the
675
- * isolate directory.
683
+ * Generate an isolated NPM lockfile for the target package.
684
+ *
685
+ * When a root `package-lock.json` exists we preserve original resolved
686
+ * versions and integrity by copying entries verbatim from the source
687
+ * lockfile. When it doesn't (forceNpm from pnpm/bun/yarn or modern-Yarn
688
+ * fallback), we fall back to Arborist's `buildIdealTree` against the
689
+ * isolate directory, which matches the prior behaviour.
676
690
  */
677
- async function generateNpmLockfile({ workspaceRootDir, isolateDir }) {
691
+ async function generateNpmLockfile({ workspaceRootDir, isolateDir, targetPackageName, targetPackageManifest, packagesRegistry, internalDepPackageNames }) {
678
692
  const log = useLogger();
679
- log.debug("Generating NPM lockfile...");
680
- const nodeModulesPath = path.join(workspaceRootDir, "node_modules");
681
693
  try {
682
- if (!fs.existsSync(nodeModulesPath)) throw new Error(`Failed to find node_modules at ${nodeModulesPath}`);
683
- const { meta } = await new Arborist({
684
- path: isolateDir,
685
- ...(await loadNpmConfig({ npmPath: workspaceRootDir })).flat
686
- }).buildIdealTree();
687
- meta?.commit();
688
- const lockfilePath = path.join(isolateDir, "package-lock.json");
689
- await fs.writeFile(lockfilePath, String(meta));
690
- log.debug("Created lockfile at", lockfilePath);
694
+ const rootLockfilePath = path.join(workspaceRootDir, "package-lock.json");
695
+ if (fs.existsSync(rootLockfilePath)) {
696
+ log.debug("Generating NPM lockfile from root package-lock.json...");
697
+ await generateFromRootLockfile({
698
+ workspaceRootDir,
699
+ isolateDir,
700
+ targetPackageName,
701
+ targetPackageManifest,
702
+ packagesRegistry,
703
+ internalDepPackageNames
704
+ });
705
+ } else {
706
+ log.debug("No root package-lock.json found; falling back to buildIdealTree generation");
707
+ await generateViaBuildIdealTree({
708
+ workspaceRootDir,
709
+ isolateDir
710
+ });
711
+ }
712
+ log.debug("Created lockfile at", path.join(isolateDir, "package-lock.json"));
691
713
  } catch (err) {
692
714
  log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
693
715
  throw err;
694
716
  }
695
717
  }
718
+ async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPackageName, targetPackageManifest, packagesRegistry, internalDepPackageNames }) {
719
+ const log = useLogger();
720
+ const arborist = new Arborist({
721
+ path: workspaceRootDir,
722
+ ...(await loadNpmConfig({ npmPath: workspaceRootDir })).flat
723
+ });
724
+ /**
725
+ * `loadVirtual` hydrates every Node with `resolved` and `integrity` taken
726
+ * directly from the lockfile entries. It performs no registry calls.
727
+ */
728
+ const rootTree = await arborist.loadVirtual();
729
+ const targetImporterNode = arborist.workspaceNodes(rootTree, [targetPackageName])[0];
730
+ if (!targetImporterNode) throw new Error(`Target workspace "${targetPackageName}" not found in root package-lock.json`);
731
+ if (typeof targetImporterNode.location !== "string") throw new Error(`Target workspace "${targetPackageName}" resolved to a node without a location`);
732
+ /**
733
+ * `workspaceDependencySet` walks `edgesOut` from each seed node. It does
734
+ * not add the seed node itself to the result, so ensure the target
735
+ * importer is included.
736
+ */
737
+ const reachableNodes = arborist.workspaceDependencySet(rootTree, [targetPackageName], false);
738
+ reachableNodes.add(targetImporterNode);
739
+ const srcData = rootTree.meta?.data;
740
+ if (!srcData || !srcData.packages || Object.keys(srcData.packages).length === 0) {
741
+ /**
742
+ * Arborist normalises v1 lockfiles to v3 in `loadVirtual`, but fall
743
+ * back defensively if the virtual tree still has no `packages` map
744
+ * (e.g. an unusual lockfile shape). The fallback generator reads
745
+ * node_modules and won't preserve original versions, but it will
746
+ * produce a valid lockfile rather than failing.
747
+ */
748
+ useLogger().debug("Source lockfile has no `packages` map; falling back to buildIdealTree");
749
+ await generateViaBuildIdealTree({
750
+ workspaceRootDir,
751
+ isolateDir
752
+ });
753
+ return;
754
+ }
755
+ const reachable = [...reachableNodes].map((node) => ({
756
+ location: node.location,
757
+ isLink: node.isLink,
758
+ target: node.target ? { location: node.target.location } : void 0
759
+ }));
760
+ const internalDepLocs = /* @__PURE__ */ new Map();
761
+ for (const depName of internalDepPackageNames) {
762
+ const pkg = packagesRegistry[depName];
763
+ if (!pkg) throw new Error(`Package ${depName} not found in packages registry`);
764
+ internalDepLocs.set(depName, toPosix(pkg.rootRelativeDir));
765
+ }
766
+ const out = buildIsolatedLockfileJson({
767
+ srcData,
768
+ reachable,
769
+ targetImporterLoc: targetImporterNode.location,
770
+ targetLinkLoc: `node_modules/${targetPackageName}`,
771
+ targetPackageManifest
772
+ });
773
+ /**
774
+ * Overlay each internal dep's adapted manifest onto its lockfile entry
775
+ * so cross-internal-dep references use `file:` instead of `workspace:*`.
776
+ */
777
+ for (const [, depLoc] of internalDepLocs) {
778
+ if (!out.packages[depLoc]) continue;
779
+ const adaptedManifestPath = path.join(isolateDir, depLoc, "package.json");
780
+ if (!fs.existsSync(adaptedManifestPath)) {
781
+ log.debug(`Adapted internal dep manifest missing at ${adaptedManifestPath}; leaving lockfile entry unchanged`);
782
+ continue;
783
+ }
784
+ const adapted = await fs.readJson(adaptedManifestPath);
785
+ overlayManifestDeps(out.packages[depLoc], adapted);
786
+ }
787
+ const outPath = path.join(isolateDir, "package-lock.json");
788
+ await fs.writeFile(outPath, JSON.stringify(out, null, 2) + "\n");
789
+ }
790
+ /**
791
+ * Pure JSON rewrite of the source lockfile into an isolated lockfile.
792
+ * Extracted so it can be unit tested without mocking Arborist.
793
+ */
794
+ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targetLinkLoc, targetPackageManifest }) {
795
+ const outPackages = {};
796
+ const srcPackages = srcData.packages;
797
+ if (!srcPackages[targetImporterLoc]) throw new Error(`Source lockfile has no entry for target importer "${targetImporterLoc}"`);
798
+ const targetNestedNodeModulesPrefix = `${targetImporterLoc}/node_modules/`;
799
+ /** Track the source location each output entry came from, so we can
800
+ * produce a clear error if two source paths remap to the same target.
801
+ */
802
+ const origLocByNewLoc = /* @__PURE__ */ new Map();
803
+ for (const node of reachable) {
804
+ const origLoc = node.location;
805
+ /** The target's self-link has no place in the isolate (root IS the target). */
806
+ if (origLoc === targetLinkLoc) continue;
807
+ /**
808
+ * The target workspace becomes the isolate root, so:
809
+ * "packages/app" -> ""
810
+ * "packages/app/node_modules/<name>" -> "node_modules/<name>"
811
+ * "packages/app/node_modules/a/node_modules/b" -> "node_modules/a/node_modules/b"
812
+ *
813
+ * Only `node_modules` subpaths under the target are remapped — other
814
+ * paths (e.g. a nested workspace importer like
815
+ * `packages/app/lib/core`) are preserved verbatim because their disk
816
+ * location in the isolate is unchanged.
817
+ */
818
+ let newLoc;
819
+ if (origLoc === targetImporterLoc) newLoc = "";
820
+ else if (origLoc.startsWith(targetNestedNodeModulesPrefix)) newLoc = origLoc.slice(targetImporterLoc.length + 1);
821
+ else newLoc = origLoc;
822
+ const srcEntry = srcPackages[origLoc];
823
+ if (!srcEntry) throw new Error(`Reachable node "${origLoc}" has no entry in source lockfile packages`);
824
+ const existing = outPackages[newLoc];
825
+ if (existing && !entriesAreEquivalent(existing, srcEntry)) {
826
+ const previousOrigLoc = origLocByNewLoc.get(newLoc) ?? "<unknown>";
827
+ throw new Error(`Path collision at "${newLoc}": source locations "${previousOrigLoc}" and "${origLoc}" both map there with conflicting entries. This happens when the target pins a nested version override that collides with a hoisted version still needed by another reachable dependency. Please report a reproduction at https://github.com/0x80/isolate-package/issues.`);
828
+ }
829
+ outPackages[newLoc] = { ...srcEntry };
830
+ origLocByNewLoc.set(newLoc, origLoc);
831
+ }
832
+ /**
833
+ * If the target importer didn't make it into the reachable set for any
834
+ * reason (upstream Arborist bug, programmer error), bail loudly rather
835
+ * than emit a synthesised root entry with no source metadata.
836
+ */
837
+ if (!outPackages[""]) throw new Error(`Target importer "${targetImporterLoc}" was not present in the reachable node set; cannot construct isolate root entry`);
838
+ /** Overlay the isolate root with the adapted target manifest. */
839
+ const rootEntry = { ...outPackages[""] };
840
+ rootEntry.name = targetPackageManifest.name;
841
+ if (targetPackageManifest.version) rootEntry.version = targetPackageManifest.version;
842
+ overlayManifestDeps(rootEntry, targetPackageManifest);
843
+ /** The isolate is no longer a workspace root. */
844
+ delete rootEntry.workspaces;
845
+ outPackages[""] = rootEntry;
846
+ /**
847
+ * Spread unknown top-level fields from the source lockfile so future
848
+ * npm-introduced metadata survives isolation. Then override identity
849
+ * fields and the recomputed `packages`, and drop the legacy
850
+ * `dependencies` tree which would be stale now that `packages` has
851
+ * been subsetted.
852
+ */
853
+ const out = {
854
+ ...srcData,
855
+ name: targetPackageManifest.name,
856
+ version: targetPackageManifest.version,
857
+ lockfileVersion: srcData.lockfileVersion ?? 3,
858
+ packages: outPackages
859
+ };
860
+ /**
861
+ * `requires` is propagated via the `...srcData` spread when the source
862
+ * has it. Don't invent one when the source omitted it — that would be
863
+ * an unnecessary diff from the original lockfile shape.
864
+ */
865
+ if (srcData.requires === void 0) delete out.requires;
866
+ delete out.dependencies;
867
+ return out;
868
+ }
869
+ /**
870
+ * Two source entries that map to the same output location are only
871
+ * "equivalent" if they install identical content. We compare the fields
872
+ * that actually determine what npm fetches and stores — version, resolved
873
+ * URL, integrity, and the link flag for workspace links.
874
+ */
875
+ function entriesAreEquivalent(a, b) {
876
+ return a.version === b.version && a.resolved === b.resolved && a.integrity === b.integrity && !!a.link === !!b.link;
877
+ }
878
+ function overlayManifestDeps(entry, manifest) {
879
+ for (const field of [
880
+ "dependencies",
881
+ "devDependencies",
882
+ "optionalDependencies",
883
+ "peerDependencies"
884
+ ]) {
885
+ const value = manifest[field];
886
+ if (value) entry[field] = value;
887
+ else delete entry[field];
888
+ }
889
+ }
890
+ function toPosix(p) {
891
+ return p.split(path.sep).join(path.posix.sep);
892
+ }
893
+ async function generateViaBuildIdealTree({ workspaceRootDir, isolateDir }) {
894
+ const nodeModulesPath = path.join(workspaceRootDir, "node_modules");
895
+ if (!fs.existsSync(nodeModulesPath)) throw new Error(`Failed to find node_modules at ${nodeModulesPath}`);
896
+ const { meta } = await new Arborist({
897
+ path: isolateDir,
898
+ ...(await loadNpmConfig({ npmPath: workspaceRootDir })).flat
899
+ }).buildIdealTree();
900
+ meta?.commit();
901
+ const lockfilePath = path.join(isolateDir, "package-lock.json");
902
+ await fs.writeFile(lockfilePath, String(meta));
903
+ }
696
904
  //#endregion
697
905
  //#region src/lib/lockfile/helpers/pnpm-map-importer.ts
698
906
  /** Convert dependency links */
@@ -830,24 +1038,26 @@ async function generateYarnLockfile({ workspaceRootDir, isolateDir }) {
830
1038
  * be done is to remove the root dependencies and devDependencies, and rename
831
1039
  * the path to the target package to act as the new root.
832
1040
  */
833
- async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir, internalDepPackageNames, targetPackageDir, targetPackageManifest, patchedDependencies, config }) {
1041
+ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir, internalDepPackageNames, targetPackageDir, targetPackageName, targetPackageManifest, patchedDependencies, config }) {
834
1042
  const log = useLogger();
1043
+ const npmGeneratorParams = {
1044
+ workspaceRootDir,
1045
+ isolateDir,
1046
+ targetPackageName,
1047
+ targetPackageManifest,
1048
+ packagesRegistry,
1049
+ internalDepPackageNames
1050
+ };
835
1051
  if (config.forceNpm) {
836
1052
  log.debug("Forcing to use NPM for isolate output");
837
- await generateNpmLockfile({
838
- workspaceRootDir,
839
- isolateDir
840
- });
1053
+ await generateNpmLockfile(npmGeneratorParams);
841
1054
  return true;
842
1055
  }
843
1056
  const { name, majorVersion } = usePackageManager();
844
1057
  let usedFallbackToNpm = false;
845
1058
  switch (name) {
846
1059
  case "npm":
847
- await generateNpmLockfile({
848
- workspaceRootDir,
849
- isolateDir
850
- });
1060
+ await generateNpmLockfile(npmGeneratorParams);
851
1061
  break;
852
1062
  case "yarn":
853
1063
  if (majorVersion === 1) await generateYarnLockfile({
@@ -856,10 +1066,7 @@ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir,
856
1066
  });
857
1067
  else {
858
1068
  log.warn("Detected modern version of Yarn. Using NPM lockfile fallback.");
859
- await generateNpmLockfile({
860
- workspaceRootDir,
861
- isolateDir
862
- });
1069
+ await generateNpmLockfile(npmGeneratorParams);
863
1070
  usedFallbackToNpm = true;
864
1071
  }
865
1072
  break;
@@ -888,10 +1095,7 @@ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir,
888
1095
  break;
889
1096
  default:
890
1097
  log.warn(`Unexpected package manager ${name}. Using NPM for output`);
891
- await generateNpmLockfile({
892
- workspaceRootDir,
893
- isolateDir
894
- });
1098
+ await generateNpmLockfile(npmGeneratorParams);
895
1099
  usedFallbackToNpm = true;
896
1100
  }
897
1101
  return usedFallbackToNpm;
@@ -1188,82 +1392,48 @@ async function unpackDependencies(packedFilesByName, packagesRegistry, tmpDir, i
1188
1392
  }));
1189
1393
  }
1190
1394
  //#endregion
1191
- //#region src/lib/patches/copy-patches.ts
1192
- async function copyPatches({ workspaceRootDir, targetPackageManifest, isolateDir, includeDevDependencies }) {
1193
- const log = useLogger();
1194
- const { name: packageManagerName } = usePackageManager();
1195
- let patchedDependencies;
1196
- /**
1197
- * Only try reading pnpm-workspace.yaml for pnpm workspaces. Bun workspaces
1198
- * don't have this file and the warning would be noisy.
1199
- */
1200
- if (packageManagerName === "pnpm") try {
1201
- patchedDependencies = readTypedYamlSync(path.join(workspaceRootDir, "pnpm-workspace.yaml"))?.patchedDependencies;
1202
- } catch (error) {
1203
- log.warn(`Could not read pnpm-workspace.yaml: ${error instanceof Error ? error.message : String(error)}`);
1204
- }
1205
- if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1206
- if (packageManagerName === "pnpm") log.debug("No patched dependencies found in pnpm-workspace.yaml; Falling back to workspace root package.json");
1207
- else log.debug("Reading patched dependencies from workspace root package.json");
1208
- try {
1209
- const workspaceRootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1210
- /** PNPM stores patches under pnpm.patchedDependencies, Bun at the top level */
1211
- patchedDependencies = workspaceRootManifest?.pnpm?.patchedDependencies ?? workspaceRootManifest?.patchedDependencies;
1212
- } catch (error) {
1213
- log.warn(`Could not read workspace root package.json: ${error instanceof Error ? error.message : String(error)}`);
1214
- }
1215
- }
1216
- if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1217
- log.debug("No patched dependencies found in workspace root package.json");
1218
- return {};
1219
- }
1220
- log.debug(`Found ${Object.keys(patchedDependencies).length} patched dependencies in workspace`);
1221
- const filteredPatches = filterPatchedDependencies({
1222
- patchedDependencies,
1223
- targetPackageManifest,
1224
- includeDevDependencies
1225
- });
1226
- if (!filteredPatches) return {};
1227
- /**
1228
- * Read the pnpm lockfile to get patch hashes. Bun doesn't store hashes in
1229
- * its lockfile so we skip this for Bun.
1230
- */
1231
- const lockfilePatchedDependencies = packageManagerName === "pnpm" ? await readLockfilePatchedDependencies(workspaceRootDir) : void 0;
1232
- const copiedPatches = {};
1233
- for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) {
1234
- const sourcePatchPath = path.resolve(workspaceRootDir, patchPath);
1235
- if (!fs.existsSync(sourcePatchPath)) {
1236
- log.warn(`Patch file not found: ${getRootRelativeLogPath(sourcePatchPath, workspaceRootDir)}`);
1237
- continue;
1238
- }
1239
- /** Preserve original folder structure */
1240
- const targetPatchPath = path.join(isolateDir, patchPath);
1241
- await fs.ensureDir(path.dirname(targetPatchPath));
1242
- await fs.copy(sourcePatchPath, targetPatchPath);
1243
- log.debug(`Copied patch for ${packageSpec}: ${patchPath}`);
1244
- const hash = (lockfilePatchedDependencies?.[packageSpec])?.hash ?? "";
1245
- if (packageManagerName === "pnpm" && !hash) log.warn(`No hash found for patch ${packageSpec} in lockfile`);
1246
- copiedPatches[packageSpec] = {
1247
- path: patchPath,
1248
- hash
1249
- };
1250
- }
1251
- if (Object.keys(copiedPatches).length > 0) log.debug(`Copied ${Object.keys(copiedPatches).length} patch files`);
1252
- return copiedPatches;
1253
- }
1395
+ //#region src/lib/registry/collect-reachable-package-names.ts
1254
1396
  /**
1255
- * Read the patchedDependencies from the original lockfile to get the hashes.
1256
- * Since the file content is the same after copying, the hash remains valid.
1397
+ * Walk the target manifest and the manifests of any internal (workspace)
1398
+ * packages reachable from it, collecting every dependency name encountered
1399
+ * (both internal and external).
1400
+ *
1401
+ * The resulting set is a superset of the target's direct dependencies: it also
1402
+ * includes dependencies of internal workspace packages that will end up in the
1403
+ * isolated output. This is used to filter workspace-level
1404
+ * `patchedDependencies` so that patches for deps introduced via internal
1405
+ * packages aren't dropped.
1406
+ *
1407
+ * `dependencies`, `optionalDependencies`, and `peerDependencies` are all
1408
+ * walked — any of them can lead to a package being installed in the isolate
1409
+ * (pnpm installs peers by default via `autoInstallPeers`). devDependencies of
1410
+ * internal packages are never followed, and devDependencies of the *target*
1411
+ * are followed only when `includeDevDependencies` is true.
1412
+ *
1413
+ * Note: only recurses through internal packages — manifests of external deps
1414
+ * aren't available here. Deep external→external transitives therefore won't
1415
+ * appear in the set.
1257
1416
  */
1258
- async function readLockfilePatchedDependencies(workspaceRootDir) {
1259
- try {
1260
- const { majorVersion } = usePackageManager();
1261
- const useVersion9 = majorVersion >= 9;
1262
- const lockfileDir = isRushWorkspace(workspaceRootDir) ? path.join(workspaceRootDir, "common/config/rush") : workspaceRootDir;
1263
- return (useVersion9 ? await readWantedLockfile$1(lockfileDir, { ignoreIncompatible: false }) : await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }))?.patchedDependencies;
1264
- } catch {
1265
- /** Package manager not detected or lockfile not readable */
1266
- return;
1417
+ function collectReachablePackageNames({ targetPackageManifest, packagesRegistry, includeDevDependencies }) {
1418
+ const names = /* @__PURE__ */ new Set();
1419
+ const visitedInternal = /* @__PURE__ */ new Set();
1420
+ walk(targetPackageManifest, true);
1421
+ return names;
1422
+ function walk(manifest, isTarget) {
1423
+ const depNames = [
1424
+ ...Object.keys(manifest.dependencies ?? {}),
1425
+ ...Object.keys(manifest.optionalDependencies ?? {}),
1426
+ ...Object.keys(manifest.peerDependencies ?? {}),
1427
+ ...isTarget && includeDevDependencies ? Object.keys(manifest.devDependencies ?? {}) : []
1428
+ ];
1429
+ for (const name of depNames) {
1430
+ names.add(name);
1431
+ const internalPkg = packagesRegistry[name];
1432
+ if (internalPkg && !visitedInternal.has(name)) {
1433
+ visitedInternal.add(name);
1434
+ walk(internalPkg.manifest, false);
1435
+ }
1436
+ }
1267
1437
  }
1268
1438
  }
1269
1439
  //#endregion
@@ -1383,6 +1553,97 @@ function listInternalPackages(manifest, packagesRegistry, { includeDevDependenci
1383
1553
  return [...new Set(result)];
1384
1554
  }
1385
1555
  //#endregion
1556
+ //#region src/lib/patches/copy-patches.ts
1557
+ async function copyPatches({ workspaceRootDir, targetPackageManifest, packagesRegistry, isolateDir, includeDevDependencies }) {
1558
+ const log = useLogger();
1559
+ const { name: packageManagerName } = usePackageManager();
1560
+ let patchedDependencies;
1561
+ /**
1562
+ * Only try reading pnpm-workspace.yaml for pnpm workspaces. Bun workspaces
1563
+ * don't have this file and the warning would be noisy.
1564
+ */
1565
+ if (packageManagerName === "pnpm") try {
1566
+ patchedDependencies = readTypedYamlSync(path.join(workspaceRootDir, "pnpm-workspace.yaml"))?.patchedDependencies;
1567
+ } catch (error) {
1568
+ log.warn(`Could not read pnpm-workspace.yaml: ${error instanceof Error ? error.message : String(error)}`);
1569
+ }
1570
+ if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1571
+ if (packageManagerName === "pnpm") log.debug("No patched dependencies found in pnpm-workspace.yaml; Falling back to workspace root package.json");
1572
+ else log.debug("Reading patched dependencies from workspace root package.json");
1573
+ try {
1574
+ const workspaceRootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1575
+ /** PNPM stores patches under pnpm.patchedDependencies, Bun at the top level */
1576
+ patchedDependencies = workspaceRootManifest?.pnpm?.patchedDependencies ?? workspaceRootManifest?.patchedDependencies;
1577
+ } catch (error) {
1578
+ log.warn(`Could not read workspace root package.json: ${error instanceof Error ? error.message : String(error)}`);
1579
+ }
1580
+ }
1581
+ if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1582
+ log.debug("No patched dependencies found in workspace root package.json");
1583
+ return {};
1584
+ }
1585
+ log.debug(`Found ${Object.keys(patchedDependencies).length} patched dependencies in workspace`);
1586
+ /**
1587
+ * Collect the set of dependency names reachable from the target (direct deps
1588
+ * plus deps introduced by internal workspace packages). Patches for names in
1589
+ * this set are preserved even when the target doesn't list them directly —
1590
+ * see issue #167.
1591
+ */
1592
+ const reachableDependencyNames = collectReachablePackageNames({
1593
+ targetPackageManifest,
1594
+ packagesRegistry,
1595
+ includeDevDependencies
1596
+ });
1597
+ const filteredPatches = filterPatchedDependencies({
1598
+ patchedDependencies,
1599
+ targetPackageManifest,
1600
+ includeDevDependencies,
1601
+ reachableDependencyNames
1602
+ });
1603
+ if (!filteredPatches) return {};
1604
+ /**
1605
+ * Read the pnpm lockfile to get patch hashes. Bun doesn't store hashes in
1606
+ * its lockfile so we skip this for Bun.
1607
+ */
1608
+ const lockfilePatchedDependencies = packageManagerName === "pnpm" ? await readLockfilePatchedDependencies(workspaceRootDir) : void 0;
1609
+ const copiedPatches = {};
1610
+ for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) {
1611
+ const sourcePatchPath = path.resolve(workspaceRootDir, patchPath);
1612
+ if (!fs.existsSync(sourcePatchPath)) {
1613
+ log.warn(`Patch file not found: ${getRootRelativeLogPath(sourcePatchPath, workspaceRootDir)}`);
1614
+ continue;
1615
+ }
1616
+ /** Preserve original folder structure */
1617
+ const targetPatchPath = path.join(isolateDir, patchPath);
1618
+ await fs.ensureDir(path.dirname(targetPatchPath));
1619
+ await fs.copy(sourcePatchPath, targetPatchPath);
1620
+ log.debug(`Copied patch for ${packageSpec}: ${patchPath}`);
1621
+ const hash = (lockfilePatchedDependencies?.[packageSpec])?.hash ?? "";
1622
+ if (packageManagerName === "pnpm" && !hash) log.warn(`No hash found for patch ${packageSpec} in lockfile`);
1623
+ copiedPatches[packageSpec] = {
1624
+ path: patchPath,
1625
+ hash
1626
+ };
1627
+ }
1628
+ if (Object.keys(copiedPatches).length > 0) log.debug(`Copied ${Object.keys(copiedPatches).length} patch files`);
1629
+ return copiedPatches;
1630
+ }
1631
+ /**
1632
+ * Read the patchedDependencies from the original lockfile to get the hashes.
1633
+ * Since the file content is the same after copying, the hash remains valid.
1634
+ */
1635
+ async function readLockfilePatchedDependencies(workspaceRootDir) {
1636
+ try {
1637
+ const { majorVersion } = usePackageManager();
1638
+ const useVersion9 = majorVersion >= 9;
1639
+ const lockfileDir = isRushWorkspace(workspaceRootDir) ? path.join(workspaceRootDir, "common/config/rush") : workspaceRootDir;
1640
+ return (useVersion9 ? await readWantedLockfile$1(lockfileDir, { ignoreIncompatible: false }) : await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }))?.patchedDependencies;
1641
+ } catch {
1642
+ /** Package manager not detected or lockfile not readable */
1643
+ return;
1644
+ }
1645
+ }
1646
+ //#endregion
1386
1647
  //#region src/isolate.ts
1387
1648
  const __dirname = getDirname(import.meta.url);
1388
1649
  function createIsolator(config) {
@@ -1482,6 +1743,7 @@ function createIsolator(config) {
1482
1743
  const copiedPatches = (packageManager.name === "pnpm" || packageManager.name === "bun") && !config.forceNpm ? await copyPatches({
1483
1744
  workspaceRootDir,
1484
1745
  targetPackageManifest: outputManifest,
1746
+ packagesRegistry,
1485
1747
  isolateDir,
1486
1748
  includeDevDependencies: config.includeDevDependencies
1487
1749
  }) : {};
@@ -1575,4 +1837,4 @@ async function isolate(config) {
1575
1837
  //#endregion
1576
1838
  export { loadConfigFromFile as a, detectPackageManager as c, defineConfig as i, readTypedJson as l, listInternalPackages as n, resolveConfig as o, createPackagesRegistry as r, resolveWorkspacePaths as s, isolate as t, filterObjectUndefined as u };
1577
1839
 
1578
- //# sourceMappingURL=isolate-DtNAHzfa.mjs.map
1840
+ //# sourceMappingURL=isolate-BRD2AgVJ.mjs.map