isolate-package 1.30.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 (33) hide show
  1. package/dist/index.d.mts +7 -1
  2. package/dist/index.mjs +1 -1
  3. package/dist/{isolate-CJy3YyKG.mjs → isolate-BRD2AgVJ.mjs} +422 -125
  4. package/dist/isolate-BRD2AgVJ.mjs.map +1 -0
  5. package/dist/isolate-bin.mjs +3 -3
  6. package/dist/isolate-bin.mjs.map +1 -1
  7. package/package.json +11 -2
  8. package/src/isolate-bin.ts +2 -2
  9. package/src/isolate.ts +30 -0
  10. package/src/lib/config.ts +31 -6
  11. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/package-lock.json +82 -0
  12. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/package.json +8 -0
  13. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/api/package.json +12 -0
  14. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/shared/package.json +12 -0
  15. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/utils/package.json +11 -0
  16. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/package-lock.json +56 -0
  17. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/package.json +8 -0
  18. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/packages/api/package.json +11 -0
  19. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/packages/other/package.json +11 -0
  20. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +243 -0
  21. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +604 -0
  22. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +417 -21
  23. package/src/lib/lockfile/process-lockfile.test.ts +4 -0
  24. package/src/lib/lockfile/process-lockfile.ts +14 -16
  25. package/src/lib/patches/copy-patches.test.ts +78 -0
  26. package/src/lib/patches/copy-patches.ts +22 -1
  27. package/src/lib/registry/collect-reachable-package-names.test.ts +239 -0
  28. package/src/lib/registry/collect-reachable-package-names.ts +60 -0
  29. package/src/lib/registry/index.ts +1 -0
  30. package/src/lib/utils/filter-patched-dependencies.test.ts +77 -0
  31. package/src/lib/utils/filter-patched-dependencies.ts +41 -17
  32. package/src/lib/utils/is-rush-workspace.ts +6 -0
  33. package/dist/isolate-CJy3YyKG.mjs.map +0 -1
@@ -2,50 +2,446 @@ import Arborist from "@npmcli/arborist";
2
2
  import fs from "fs-extra";
3
3
  import path from "node:path";
4
4
  import { useLogger } from "~/lib/logger";
5
+ import type { PackageManifest, PackagesRegistry } from "~/lib/types";
5
6
  import { getErrorMessage } from "~/lib/utils";
6
7
  import { loadNpmConfig } from "./load-npm-config";
7
8
 
8
9
  /**
9
- * Generate an isolated / pruned lockfile, based on the contents of installed
10
- * node_modules from the monorepo root plus the adapted package manifest in the
11
- * isolate directory.
10
+ * Subset of a package-lock.json v2/v3 `packages[location]` entry that we
11
+ * care about when rewriting. Arborist / npm preserve any additional fields
12
+ * we don't enumerate here via object spread.
13
+ */
14
+ type LockfilePackageEntry = {
15
+ name?: string;
16
+ version?: string;
17
+ resolved?: string;
18
+ integrity?: string;
19
+ link?: boolean;
20
+ dev?: boolean;
21
+ optional?: boolean;
22
+ peer?: boolean;
23
+ devOptional?: boolean;
24
+ extraneous?: boolean;
25
+ dependencies?: Record<string, string>;
26
+ devDependencies?: Record<string, string>;
27
+ optionalDependencies?: Record<string, string>;
28
+ peerDependencies?: Record<string, string>;
29
+ peerDependenciesMeta?: Record<string, unknown>;
30
+ bundleDependencies?: string[] | boolean;
31
+ workspaces?: string[] | Record<string, unknown>;
32
+ engines?: Record<string, string>;
33
+ os?: string[];
34
+ cpu?: string[];
35
+ libc?: string[];
36
+ bin?: Record<string, string> | string;
37
+ funding?: unknown;
38
+ license?: string;
39
+ hasInstallScript?: boolean;
40
+ inBundle?: boolean;
41
+ deprecated?: string;
42
+ };
43
+
44
+ type NpmLockfile = {
45
+ name?: string;
46
+ version?: string;
47
+ lockfileVersion: number;
48
+ requires?: boolean;
49
+ packages: Record<string, LockfilePackageEntry>;
50
+ overrides?: Record<string, unknown>;
51
+ /** Legacy v2 nested-tree representation; dropped when emitting the isolate lockfile. */
52
+ dependencies?: unknown;
53
+ /** Allow unknown top-level fields to flow through. */
54
+ [key: string]: unknown;
55
+ };
56
+
57
+ /**
58
+ * Minimal node shape we consume from Arborist. Kept narrow so the pure JSON
59
+ * rewriter can be tested without instantiating a real tree.
60
+ */
61
+ export type ReachableNode = {
62
+ location: string;
63
+ isLink: boolean;
64
+ target?: { location: string };
65
+ };
66
+
67
+ /**
68
+ * Generate an isolated NPM lockfile for the target package.
69
+ *
70
+ * When a root `package-lock.json` exists we preserve original resolved
71
+ * versions and integrity by copying entries verbatim from the source
72
+ * lockfile. When it doesn't (forceNpm from pnpm/bun/yarn or modern-Yarn
73
+ * fallback), we fall back to Arborist's `buildIdealTree` against the
74
+ * isolate directory, which matches the prior behaviour.
12
75
  */
13
76
  export async function generateNpmLockfile({
14
77
  workspaceRootDir,
15
78
  isolateDir,
79
+ targetPackageName,
80
+ targetPackageManifest,
81
+ packagesRegistry,
82
+ internalDepPackageNames,
16
83
  }: {
17
84
  workspaceRootDir: string;
18
85
  isolateDir: string;
86
+ targetPackageName: string;
87
+ targetPackageManifest: PackageManifest;
88
+ packagesRegistry: PackagesRegistry;
89
+ internalDepPackageNames: string[];
19
90
  }) {
20
91
  const log = useLogger();
21
92
 
22
- log.debug("Generating NPM lockfile...");
93
+ try {
94
+ const rootLockfilePath = path.join(workspaceRootDir, "package-lock.json");
23
95
 
24
- const nodeModulesPath = path.join(workspaceRootDir, "node_modules");
96
+ if (fs.existsSync(rootLockfilePath)) {
97
+ log.debug("Generating NPM lockfile from root package-lock.json...");
98
+ await generateFromRootLockfile({
99
+ workspaceRootDir,
100
+ isolateDir,
101
+ targetPackageName,
102
+ targetPackageManifest,
103
+ packagesRegistry,
104
+ internalDepPackageNames,
105
+ });
106
+ } else {
107
+ log.debug(
108
+ "No root package-lock.json found; falling back to buildIdealTree generation",
109
+ );
110
+ await generateViaBuildIdealTree({ workspaceRootDir, isolateDir });
111
+ }
25
112
 
26
- try {
27
- if (!fs.existsSync(nodeModulesPath)) {
28
- throw new Error(`Failed to find node_modules at ${nodeModulesPath}`);
113
+ log.debug(
114
+ "Created lockfile at",
115
+ path.join(isolateDir, "package-lock.json"),
116
+ );
117
+ } catch (err) {
118
+ log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ async function generateFromRootLockfile({
124
+ workspaceRootDir,
125
+ isolateDir,
126
+ targetPackageName,
127
+ targetPackageManifest,
128
+ packagesRegistry,
129
+ internalDepPackageNames,
130
+ }: {
131
+ workspaceRootDir: string;
132
+ isolateDir: string;
133
+ targetPackageName: string;
134
+ targetPackageManifest: PackageManifest;
135
+ packagesRegistry: PackagesRegistry;
136
+ internalDepPackageNames: string[];
137
+ }) {
138
+ const log = useLogger();
139
+
140
+ const config = await loadNpmConfig({ npmPath: workspaceRootDir });
141
+
142
+ const arborist = new Arborist({
143
+ path: workspaceRootDir,
144
+ ...config.flat,
145
+ });
146
+
147
+ /**
148
+ * `loadVirtual` hydrates every Node with `resolved` and `integrity` taken
149
+ * directly from the lockfile entries. It performs no registry calls.
150
+ */
151
+ const rootTree = await arborist.loadVirtual();
152
+
153
+ const workspaceNodes = arborist.workspaceNodes(rootTree, [targetPackageName]);
154
+ const targetImporterNode = workspaceNodes[0];
155
+
156
+ if (!targetImporterNode) {
157
+ throw new Error(
158
+ `Target workspace "${targetPackageName}" not found in root package-lock.json`,
159
+ );
160
+ }
161
+
162
+ if (typeof targetImporterNode.location !== "string") {
163
+ throw new Error(
164
+ `Target workspace "${targetPackageName}" resolved to a node without a location`,
165
+ );
166
+ }
167
+
168
+ /**
169
+ * `workspaceDependencySet` walks `edgesOut` from each seed node. It does
170
+ * not add the seed node itself to the result, so ensure the target
171
+ * importer is included.
172
+ */
173
+ const reachableNodes = arborist.workspaceDependencySet(
174
+ rootTree,
175
+ [targetPackageName],
176
+ false,
177
+ );
178
+ reachableNodes.add(targetImporterNode);
179
+
180
+ const srcData = rootTree.meta?.data as NpmLockfile | undefined;
181
+ if (
182
+ !srcData ||
183
+ !srcData.packages ||
184
+ Object.keys(srcData.packages).length === 0
185
+ ) {
186
+ /**
187
+ * Arborist normalises v1 lockfiles to v3 in `loadVirtual`, but fall
188
+ * back defensively if the virtual tree still has no `packages` map
189
+ * (e.g. an unusual lockfile shape). The fallback generator reads
190
+ * node_modules and won't preserve original versions, but it will
191
+ * produce a valid lockfile rather than failing.
192
+ */
193
+ useLogger().debug(
194
+ "Source lockfile has no `packages` map; falling back to buildIdealTree",
195
+ );
196
+ await generateViaBuildIdealTree({ workspaceRootDir, isolateDir });
197
+ return;
198
+ }
199
+
200
+ const reachable: ReachableNode[] = [...reachableNodes].map((node) => ({
201
+ location: node.location,
202
+ isLink: node.isLink,
203
+ target: node.target ? { location: node.target.location } : undefined,
204
+ }));
205
+
206
+ const internalDepLocs = new Map<string, string>();
207
+ for (const depName of internalDepPackageNames) {
208
+ const pkg = packagesRegistry[depName];
209
+ if (!pkg) {
210
+ throw new Error(`Package ${depName} not found in packages registry`);
211
+ }
212
+ internalDepLocs.set(depName, toPosix(pkg.rootRelativeDir));
213
+ }
214
+
215
+ const out = buildIsolatedLockfileJson({
216
+ srcData,
217
+ reachable,
218
+ targetImporterLoc: targetImporterNode.location,
219
+ /**
220
+ * npm's lockfile exposes each workspace as a Link at
221
+ * `node_modules/<name>`. This link is pointless in the isolate (the
222
+ * target becomes the root), so filter it out if it shows up in the
223
+ * reachable set.
224
+ */
225
+ targetLinkLoc: `node_modules/${targetPackageName}`,
226
+ targetPackageManifest,
227
+ });
228
+
229
+ /**
230
+ * Overlay each internal dep's adapted manifest onto its lockfile entry
231
+ * so cross-internal-dep references use `file:` instead of `workspace:*`.
232
+ */
233
+ for (const [, depLoc] of internalDepLocs) {
234
+ if (!out.packages[depLoc]) continue;
235
+ const adaptedManifestPath = path.join(isolateDir, depLoc, "package.json");
236
+ if (!fs.existsSync(adaptedManifestPath)) {
237
+ log.debug(
238
+ `Adapted internal dep manifest missing at ${adaptedManifestPath}; leaving lockfile entry unchanged`,
239
+ );
240
+ continue;
241
+ }
242
+ const adapted = (await fs.readJson(adaptedManifestPath)) as PackageManifest;
243
+ overlayManifestDeps(out.packages[depLoc], adapted);
244
+ }
245
+
246
+ const outPath = path.join(isolateDir, "package-lock.json");
247
+ await fs.writeFile(outPath, JSON.stringify(out, null, 2) + "\n");
248
+ }
249
+
250
+ /**
251
+ * Pure JSON rewrite of the source lockfile into an isolated lockfile.
252
+ * Extracted so it can be unit tested without mocking Arborist.
253
+ */
254
+ export function buildIsolatedLockfileJson({
255
+ srcData,
256
+ reachable,
257
+ targetImporterLoc,
258
+ targetLinkLoc,
259
+ targetPackageManifest,
260
+ }: {
261
+ srcData: NpmLockfile;
262
+ reachable: ReachableNode[];
263
+ /** Source location of the target workspace's real importer (e.g. "packages/app") */
264
+ targetImporterLoc: string;
265
+ /** Source location of the target workspace's Link (e.g. "node_modules/app") */
266
+ targetLinkLoc: string;
267
+ targetPackageManifest: PackageManifest;
268
+ }): NpmLockfile {
269
+ const outPackages: Record<string, LockfilePackageEntry> = {};
270
+ const srcPackages = srcData.packages;
271
+
272
+ if (!srcPackages[targetImporterLoc]) {
273
+ throw new Error(
274
+ `Source lockfile has no entry for target importer "${targetImporterLoc}"`,
275
+ );
276
+ }
277
+
278
+ const targetNestedNodeModulesPrefix = `${targetImporterLoc}/node_modules/`;
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
+ */
283
+ const origLocByNewLoc = new Map<string, string>();
284
+
285
+ for (const node of reachable) {
286
+ const origLoc = node.location;
287
+
288
+ /** The target's self-link has no place in the isolate (root IS the target). */
289
+ if (origLoc === targetLinkLoc) continue;
290
+
291
+ /**
292
+ * The target workspace becomes the isolate root, so:
293
+ * "packages/app" -> ""
294
+ * "packages/app/node_modules/<name>" -> "node_modules/<name>"
295
+ * "packages/app/node_modules/a/node_modules/b" -> "node_modules/a/node_modules/b"
296
+ *
297
+ * Only `node_modules` subpaths under the target are remapped — other
298
+ * paths (e.g. a nested workspace importer like
299
+ * `packages/app/lib/core`) are preserved verbatim because their disk
300
+ * location in the isolate is unchanged.
301
+ */
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;
29
309
  }
30
310
 
31
- const config = await loadNpmConfig({ npmPath: workspaceRootDir });
311
+ const srcEntry = srcPackages[origLoc];
312
+ if (!srcEntry) {
313
+ throw new Error(
314
+ `Reachable node "${origLoc}" has no entry in source lockfile packages`,
315
+ );
316
+ }
32
317
 
33
- const arborist = new Arborist({
34
- path: isolateDir,
35
- ...config.flat,
36
- });
318
+ const existing = outPackages[newLoc];
319
+ if (existing && !entriesAreEquivalent(existing, srcEntry)) {
320
+ const previousOrigLoc = origLocByNewLoc.get(newLoc) ?? "<unknown>";
321
+ 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. ` +
324
+ `Please report a reproduction at https://github.com/0x80/isolate-package/issues.`,
325
+ );
326
+ }
37
327
 
38
- const { meta } = await arborist.buildIdealTree();
328
+ outPackages[newLoc] = { ...srcEntry };
329
+ origLocByNewLoc.set(newLoc, origLoc);
330
+ }
39
331
 
40
- meta?.commit();
332
+ /**
333
+ * If the target importer didn't make it into the reachable set for any
334
+ * reason (upstream Arborist bug, programmer error), bail loudly rather
335
+ * than emit a synthesised root entry with no source metadata.
336
+ */
337
+ if (!outPackages[""]) {
338
+ throw new Error(
339
+ `Target importer "${targetImporterLoc}" was not present in the reachable node set; cannot construct isolate root entry`,
340
+ );
341
+ }
41
342
 
42
- const lockfilePath = path.join(isolateDir, "package-lock.json");
343
+ /** Overlay the isolate root with the adapted target manifest. */
344
+ const rootEntry: LockfilePackageEntry = { ...outPackages[""] };
345
+ rootEntry.name = targetPackageManifest.name;
346
+ if (targetPackageManifest.version) {
347
+ rootEntry.version = targetPackageManifest.version;
348
+ }
349
+ overlayManifestDeps(rootEntry, targetPackageManifest);
350
+ /** The isolate is no longer a workspace root. */
351
+ delete rootEntry.workspaces;
352
+ outPackages[""] = rootEntry;
43
353
 
44
- await fs.writeFile(lockfilePath, String(meta));
354
+ /**
355
+ * Spread unknown top-level fields from the source lockfile so future
356
+ * npm-introduced metadata survives isolation. Then override identity
357
+ * fields and the recomputed `packages`, and drop the legacy
358
+ * `dependencies` tree which would be stale now that `packages` has
359
+ * been subsetted.
360
+ */
361
+ const out: NpmLockfile = {
362
+ ...srcData,
363
+ name: targetPackageManifest.name,
364
+ version: targetPackageManifest.version,
365
+ lockfileVersion: srcData.lockfileVersion ?? 3,
366
+ packages: outPackages,
367
+ };
368
+ /**
369
+ * `requires` is propagated via the `...srcData` spread when the source
370
+ * has it. Don't invent one when the source omitted it — that would be
371
+ * an unnecessary diff from the original lockfile shape.
372
+ */
373
+ if (srcData.requires === undefined) {
374
+ delete out.requires;
375
+ }
376
+ delete out.dependencies;
45
377
 
46
- log.debug("Created lockfile at", lockfilePath);
47
- } catch (err) {
48
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
49
- throw err;
378
+ return out;
379
+ }
380
+
381
+ /**
382
+ * Two source entries that map to the same output location are only
383
+ * "equivalent" if they install identical content. We compare the fields
384
+ * that actually determine what npm fetches and stores — version, resolved
385
+ * URL, integrity, and the link flag for workspace links.
386
+ */
387
+ function entriesAreEquivalent(
388
+ a: LockfilePackageEntry,
389
+ b: LockfilePackageEntry,
390
+ ): boolean {
391
+ return (
392
+ a.version === b.version &&
393
+ a.resolved === b.resolved &&
394
+ a.integrity === b.integrity &&
395
+ !!a.link === !!b.link
396
+ );
397
+ }
398
+
399
+ function overlayManifestDeps(
400
+ entry: LockfilePackageEntry,
401
+ manifest: PackageManifest,
402
+ ) {
403
+ const fields = [
404
+ "dependencies",
405
+ "devDependencies",
406
+ "optionalDependencies",
407
+ "peerDependencies",
408
+ ] as const;
409
+ for (const field of fields) {
410
+ const value = manifest[field];
411
+ if (value) {
412
+ entry[field] = value;
413
+ } else {
414
+ delete entry[field];
415
+ }
416
+ }
417
+ }
418
+
419
+ function toPosix(p: string): string {
420
+ return p.split(path.sep).join(path.posix.sep);
421
+ }
422
+
423
+ async function generateViaBuildIdealTree({
424
+ workspaceRootDir,
425
+ isolateDir,
426
+ }: {
427
+ workspaceRootDir: string;
428
+ isolateDir: string;
429
+ }) {
430
+ const nodeModulesPath = path.join(workspaceRootDir, "node_modules");
431
+ if (!fs.existsSync(nodeModulesPath)) {
432
+ throw new Error(`Failed to find node_modules at ${nodeModulesPath}`);
50
433
  }
434
+
435
+ const config = await loadNpmConfig({ npmPath: workspaceRootDir });
436
+
437
+ const arborist = new Arborist({
438
+ path: isolateDir,
439
+ ...config.flat,
440
+ });
441
+
442
+ const { meta } = await arborist.buildIdealTree();
443
+ meta?.commit();
444
+
445
+ const lockfilePath = path.join(isolateDir, "package-lock.json");
446
+ await fs.writeFile(lockfilePath, String(meta));
51
447
  }
@@ -72,6 +72,10 @@ describe("processLockfile", () => {
72
72
  expect(generateNpmLockfile).toHaveBeenCalledWith({
73
73
  workspaceRootDir: "/workspace",
74
74
  isolateDir: "/workspace/apps/my-app/isolate",
75
+ targetPackageName: "my-app",
76
+ targetPackageManifest: { name: "my-app", version: "1.0.0" },
77
+ packagesRegistry: {},
78
+ internalDepPackageNames: [],
75
79
  });
76
80
  expect(result).toBe(false);
77
81
  });
@@ -22,6 +22,7 @@ export async function processLockfile({
22
22
  isolateDir,
23
23
  internalDepPackageNames,
24
24
  targetPackageDir,
25
+ targetPackageName,
25
26
  targetPackageManifest,
26
27
  patchedDependencies,
27
28
  config,
@@ -39,13 +40,19 @@ export async function processLockfile({
39
40
  }) {
40
41
  const log = useLogger();
41
42
 
43
+ const npmGeneratorParams = {
44
+ workspaceRootDir,
45
+ isolateDir,
46
+ targetPackageName,
47
+ targetPackageManifest,
48
+ packagesRegistry,
49
+ internalDepPackageNames,
50
+ };
51
+
42
52
  if (config.forceNpm) {
43
53
  log.debug("Forcing to use NPM for isolate output");
44
54
 
45
- await generateNpmLockfile({
46
- workspaceRootDir,
47
- isolateDir,
48
- });
55
+ await generateNpmLockfile(npmGeneratorParams);
49
56
 
50
57
  return true;
51
58
  }
@@ -55,10 +62,7 @@ export async function processLockfile({
55
62
 
56
63
  switch (name) {
57
64
  case "npm": {
58
- await generateNpmLockfile({
59
- workspaceRootDir,
60
- isolateDir,
61
- });
65
+ await generateNpmLockfile(npmGeneratorParams);
62
66
 
63
67
  break;
64
68
  }
@@ -73,10 +77,7 @@ export async function processLockfile({
73
77
  "Detected modern version of Yarn. Using NPM lockfile fallback.",
74
78
  );
75
79
 
76
- await generateNpmLockfile({
77
- workspaceRootDir,
78
- isolateDir,
79
- });
80
+ await generateNpmLockfile(npmGeneratorParams);
80
81
 
81
82
  usedFallbackToNpm = true;
82
83
  }
@@ -112,10 +113,7 @@ export async function processLockfile({
112
113
  log.warn(
113
114
  `Unexpected package manager ${name as string}. Using NPM for output`,
114
115
  );
115
- await generateNpmLockfile({
116
- workspaceRootDir,
117
- isolateDir,
118
- });
116
+ await generateNpmLockfile(npmGeneratorParams);
119
117
 
120
118
  usedFallbackToNpm = true;
121
119
  }