disk-space-saviour 0.20.0 → 0.21.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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Browser revisions that installed Playwright packages still launch.
3
+ *
4
+ * Playwright records every package it installed browsers for as a file in
5
+ * `<registry>/.links/` holding that package's directory, and the package's
6
+ * `browsers.json` names the revisions it launches. Playwright's own cleanup
7
+ * keeps a revision while a linked package that still exists names it; dss
8
+ * keeps the same revisions, so deleting one never breaks an installed
9
+ * project until its next `playwright install`.
10
+ */
11
+
12
+ /** Directory of a registry revision: `chromium-headless-shell` → `chromium_headless_shell-1248`. */
13
+ export function browserDirName(name, revision) {
14
+ return `${name.replace(/-/g, '_')}-${revision}`;
15
+ }
16
+
17
+ /** Revision directories one `browsers.json` names, platform overrides included. */
18
+ export function requiredDirs(text) {
19
+ const browsers = JSON.parse(text)?.browsers;
20
+ if (!Array.isArray(browsers)) {
21
+ throw new Error('no browsers list');
22
+ }
23
+ return browsers.flatMap((browser) =>
24
+ [browser.revision, ...Object.values(browser.revisionOverrides ?? {})]
25
+ .filter(Boolean)
26
+ .map((revision) => browserDirName(browser.name, revision))
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Maps each revision directory name to the linked packages that need it;
32
+ * `unreadable` lists links whose package exists but cannot be read.
33
+ */
34
+ export async function playwrightUsers(env, registry) {
35
+ const users = new Map();
36
+ const unreadable = [];
37
+ for (const link of await env.list(env.path.join(registry, '.links'))) {
38
+ const pkg = (await env.readText(link.path))?.trim();
39
+ if (!pkg) {
40
+ unreadable.push(link.path);
41
+ continue;
42
+ }
43
+ const manifest = env.path.join(pkg, 'browsers.json');
44
+ if (!(await env.exists(manifest))) {
45
+ // The project or its node_modules is gone; Playwright drops the link too.
46
+ continue;
47
+ }
48
+ try {
49
+ for (const dir of requiredDirs((await env.readText(manifest)) ?? '')) {
50
+ users.set(dir, [...(users.get(dir) ?? []), pkg]);
51
+ }
52
+ } catch {
53
+ unreadable.push(manifest);
54
+ }
55
+ }
56
+ return { users, unreadable };
57
+ }
58
+
59
+ /**
60
+ * Why `target`, a revision directory in a Playwright registry, must stay;
61
+ * null when no installed package needs it.
62
+ * @param {Map<string, Promise<object>>} [cache] registry → playwrightUsers
63
+ */
64
+ export async function playwrightBlocker(env, target, cache = new Map()) {
65
+ const registry = env.path.dirname(target);
66
+ if (!cache.has(registry)) {
67
+ cache.set(registry, playwrightUsers(env, registry));
68
+ }
69
+ const { users, unreadable } = await cache.get(registry);
70
+ if (unreadable.length > 0) {
71
+ return `cannot read Playwright install record ${unreadable[0]}`;
72
+ }
73
+ const needed = users.get(env.path.basename(target));
74
+ return needed
75
+ ? `Playwright in ${needed[0]} still uses ${env.path.basename(target)}`
76
+ : null;
77
+ }
package/src/report.js CHANGED
@@ -82,7 +82,14 @@ function tierSection(report, tier, options) {
82
82
  '',
83
83
  `${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
84
84
  ...limited(
85
- items.map((item) => itemLine(item, showEnv)),
85
+ items.flatMap((item) => [
86
+ itemLine(item, showEnv),
87
+ ...(item.globalPackages?.length
88
+ ? [
89
+ ` also removes global packages: ${item.globalPackages.join(', ')}`,
90
+ ]
91
+ : []),
92
+ ]),
86
93
  options.limit
87
94
  ),
88
95
  ];
@@ -351,6 +358,23 @@ function timingLines(report, { verbose }) {
351
358
  });
352
359
  }
353
360
 
361
+ function holderLine(holder) {
362
+ const details = [`${formatBytes(holder.memoryBytes)} memory`];
363
+ if (holder.compressedBytes !== undefined) {
364
+ details.push(`${formatBytes(holder.compressedBytes)} compressed`);
365
+ }
366
+ if (holder.swappedBytes !== undefined) {
367
+ details.push(`${formatBytes(holder.swappedBytes)} swapped`);
368
+ }
369
+ const change = holder.growth?.memoryBytes;
370
+ if (change) {
371
+ details.push(
372
+ `${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
373
+ );
374
+ }
375
+ return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
376
+ }
377
+
354
378
  /**
355
379
  * Renders a scan report.
356
380
  * @param {object} report
@@ -368,11 +392,12 @@ export function formatReport(report, options = {}) {
368
392
  lines.push(
369
393
  `Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
370
394
  );
395
+ for (const warning of host.swap.warnings ?? []) {
396
+ lines.push(`WARNING: ${warning}`);
397
+ }
371
398
  lines.push(host.swap.attribution);
372
399
  for (const holder of host.swap.holders) {
373
- lines.push(
374
- ` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
375
- );
400
+ lines.push(` ${holderLine(holder)}`);
376
401
  }
377
402
  lines.push(host.swap.advice);
378
403
  }
package/src/rules/apps.js CHANGED
@@ -36,13 +36,14 @@ export const APP_RULES = [
36
36
  },
37
37
  {
38
38
  id: 'telegram-downloaded-media',
39
- excludeNames: ['cache', 'cache-storage', 'short-cache'],
40
39
  ecosystem: 'apps',
41
40
  tier: 'moderate',
42
41
  description:
43
42
  'Telegram downloaded media: secret-chat media may not be recoverable',
43
+ // Only media Telegram downloads again from its cloud; `storage` holds
44
+ // the media database and `local-file-*` files the user sent.
44
45
  paths: [
45
- `${telegramAccounts}/*`,
46
+ `${telegramAccounts}/telegram-cloud-*`,
46
47
  ...telegramDesktop.map((root) => `${root}/user_data/downloads`),
47
48
  ],
48
49
  busy: ['Telegram', 'telegram-desktop'],
@@ -20,10 +20,12 @@ export const DOWNLOAD_RULES = [
20
20
  description:
21
21
  'Chromium app bundle clones left by exited browsers (an update makes them full copies)',
22
22
  // Chrome clones its bundle into the per-user `X` directory next to
23
- // `$TMPDIR` at launch and removes the clone when it exits. A running
24
- // browser maps the executables of its clone, so the open-file check
25
- // keeps every clone in use: a browser name would block orphans forever.
23
+ // `$TMPDIR` at launch and removes the clone when it exits. A clone
24
+ // stays while any process runs its hard-linked main executable (see
25
+ // ../scanners/app-clones.js): a browser name would block orphans
26
+ // forever, and open files miss a browser started before the update.
26
27
  paths: ['{TMP}/../X/*.code_sign_clone/code_sign_clone.*'],
28
+ appClone: { installed: ['/Applications', '~/Applications'] },
27
29
  directoryOnly: true,
28
30
  minAge: 'stale',
29
31
  busy: [],
@@ -12,7 +12,11 @@
12
12
  * otherwise each match is its own item. `minAge: 'stale'` only reports
13
13
  * matches untouched for the stale-age window, `minAge: 'inactive'` for the
14
14
  * project inactivity window. `git: true` refuses matches that are Git work
15
- * trees with uncommitted, unpushed, or stashed changes.
15
+ * trees with uncommitted, unpushed, or stashed changes. `git: 'clone'`
16
+ * matches only Git work trees with an https or ssh remote, and refuses them
17
+ * unless every worktree is clean and the remote still advertises every
18
+ * local commit. `registry: 'playwright'` keeps revisions that a Playwright
19
+ * package recorded in the registry's `.links` still launches.
16
20
  */
17
21
 
18
22
  export const OTHER_RULES = [
@@ -29,15 +33,10 @@ export const OTHER_RULES = [
29
33
  versionPattern: /^(?<family>[a-z_]+)-(?<version>\d+)$/,
30
34
  minAge: 'inactive',
31
35
  tier: 'safe',
32
- busy: [
33
- 'playwright',
34
- 'playwright-mcp',
35
- 'chrome',
36
- 'chromium',
37
- 'headless_shell',
38
- 'firefox',
39
- 'webkit',
40
- ],
36
+ registry: 'playwright',
37
+ // A running Playwright launches the revisions its package names, which
38
+ // the registry check keeps; a revision a browser runs from is open.
39
+ busy: [],
41
40
  },
42
41
  {
43
42
  id: 'playwright-go-drivers',
@@ -519,6 +518,23 @@ export const OTHER_RULES = [
519
518
  minAge: 'stale',
520
519
  busy: ['chrome', 'chromium', 'headless_shell'],
521
520
  },
521
+ {
522
+ id: 'tmp-playwright-browsers',
523
+ ecosystem: 'tmp',
524
+ scope: 'tmp',
525
+ description:
526
+ 'Playwright browser installs and downloads in temporary directories',
527
+ paths: [
528
+ '{TMP}/playwright-download-*',
529
+ '{TMP}/ms-playwright',
530
+ '{TMP}/ms-playwright-*',
531
+ '{TMP}/*/ms-playwright',
532
+ '{TMP}/*/.cache/ms-playwright',
533
+ '{TMP}/*/Library/Caches/ms-playwright',
534
+ ],
535
+ minAge: 'stale',
536
+ busy: ['playwright', 'node', 'chrome', 'chromium', 'headless_shell'],
537
+ },
522
538
  {
523
539
  id: 'node-tmp-caches',
524
540
  ecosystem: 'tmp',
@@ -533,3 +549,33 @@ export const OTHER_RULES = [
533
549
  busy: ['node'],
534
550
  },
535
551
  ];
552
+
553
+ /**
554
+ * Checkouts cloned into temporary directories by tools, tests and agents,
555
+ * checked after every named rule so a more specific rule claims its own
556
+ * paths first.
557
+ */
558
+ export const TEMP_CLONE_RULES = [
559
+ {
560
+ id: 'temp-git-clones',
561
+ ecosystem: 'tmp',
562
+ scope: 'tmp',
563
+ description:
564
+ 'Git clones in temporary directories whose commits all exist on a remote',
565
+ tier: 'safe',
566
+ paths: ['{TMP}/*', '{TMP}/*/*'],
567
+ directoryOnly: true,
568
+ minAge: 'stale',
569
+ git: 'clone',
570
+ busy: [
571
+ 'git',
572
+ 'claude',
573
+ 'codex',
574
+ 'opencode',
575
+ 'agent',
576
+ 'node',
577
+ 'bun',
578
+ 'cargo',
579
+ ],
580
+ },
581
+ ];
package/src/scan.js CHANGED
@@ -530,13 +530,17 @@ function envDescriptor(env, depth, chain) {
530
530
  };
531
531
  }
532
532
 
533
- async function withDisk(env, descriptor) {
533
+ async function withDisk(env, descriptor, options) {
534
534
  const target = env.platform === 'win32' ? env.currentHome : '/';
535
535
  const disk = await env.diskUsage(target).catch(() => null);
536
536
  return {
537
537
  ...descriptor,
538
538
  disk: disk ? { path: target, ...disk } : null,
539
- swap: await readSwap(env),
539
+ swap: await readSwap(env, {
540
+ stateDir: options.cacheDir,
541
+ disk,
542
+ now: options.now?.(),
543
+ }),
540
544
  };
541
545
  }
542
546
 
@@ -666,7 +670,7 @@ async function scanScoped(input) {
666
670
  environments.push(inner);
667
671
  }
668
672
  environments[0] = await timed(env.timings, 'disk', () =>
669
- withDisk(env, environments[0])
673
+ withDisk(env, environments[0], options)
670
674
  );
671
675
  environments[0].timings = env.timings.toJSON();
672
676
  const report = buildReport({
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Chromium browsers on macOS clone their app bundle at launch with APFS
3
+ * `clonefile` and replace the clone's main executable with a hard link to
4
+ * the installed one (chrome/browser/mac/code_sign_clone_manager.mm). The
5
+ * browser holds no file open inside its clone: it runs the shared inode,
6
+ * which `lsof` reports under the installed path until an update replaces
7
+ * the app. After an update the clone keeps the running browser's code
8
+ * signature valid, so a clone stays while any process runs its executable.
9
+ * Until then the clone shares every block with the installed app, and
10
+ * removing it frees nothing but directory entries.
11
+ */
12
+
13
+ const BUNDLE_SUFFIX = '.bundle';
14
+
15
+ /**
16
+ * Main executables of the bundle cloned in `clone`, and whether the clone
17
+ * still shares its blocks with an app installed in one of `appDirs`.
18
+ * @returns {Promise<{executables: string[], sharedWith: string|null}>}
19
+ */
20
+ export async function appCloneDetails(env, clone, appDirs) {
21
+ const executables = [];
22
+ let sharedWith = null;
23
+ for (const bundle of await env.list(clone)) {
24
+ if (
25
+ bundle.type !== 'dir' ||
26
+ !bundle.name.endsWith(`.app${BUNDLE_SUFFIX}`)
27
+ ) {
28
+ continue;
29
+ }
30
+ const appName = bundle.name.slice(0, -BUNDLE_SUFFIX.length);
31
+ const macos = env.path.join(bundle.path, 'Contents', 'MacOS');
32
+ for (const entry of await env.list(macos)) {
33
+ if (entry.type !== 'file') {
34
+ continue;
35
+ }
36
+ executables.push(entry.path);
37
+ sharedWith ??= await installedCopy(env, entry, appName, appDirs);
38
+ }
39
+ }
40
+ return { executables, sharedWith };
41
+ }
42
+
43
+ async function installedCopy(env, executable, appName, appDirs) {
44
+ const inode = (await env.stat(executable.path))?.inode;
45
+ for (const dir of appDirs) {
46
+ const app = env.path.join(dir, appName);
47
+ const installed = env.path.join(app, 'Contents', 'MacOS', executable.name);
48
+ if (inode && (await env.stat(installed))?.inode === inode) {
49
+ return app;
50
+ }
51
+ }
52
+ return null;
53
+ }
@@ -27,6 +27,7 @@ export async function listMany(env, dirs) {
27
27
  */
28
28
  export async function scanTimeBusy(context, item) {
29
29
  await resolvePaths(context, item.paths);
30
+ await context.liveness?.resolveExecutables(item.checks?.executables);
30
31
  return context.liveness ? context.liveness.busyReason(item) : null;
31
32
  }
32
33
 
@@ -1,4 +1,4 @@
1
- import { scanItems } from '../env/scope.js';
1
+ import { checkSignal, scanItems } from '../env/scope.js';
2
2
  /**
3
3
  * Global per-user caches (ecosystem download and compilation caches, IDE and
4
4
  * browser caches), system package caches, and leftovers in temporary
@@ -7,7 +7,7 @@ import { scanItems } from '../env/scope.js';
7
7
  */
8
8
 
9
9
  import { CACHE_RULES } from '../rules/ecosystems.js';
10
- import { OTHER_RULES } from '../rules/other.js';
10
+ import { OTHER_RULES, TEMP_CLONE_RULES } from '../rules/other.js';
11
11
  import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
12
12
  import { VERSION_RULES, compareVersions } from '../rules/versions.js';
13
13
  import { block, makeItem } from '../items.js';
@@ -17,6 +17,10 @@ import { configuredPattern } from '../env/cache-roots.js';
17
17
  import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
18
18
  import { DOWNLOAD_RULES } from '../rules/downloads.js';
19
19
  import { timed } from '../timings.js';
20
+ import { appCloneDetails } from './app-clones.js';
21
+ import { gitDirectories } from '../git.js';
22
+ import { networkRemotes } from '../git-remote.js';
23
+ import { playwrightBlocker } from '../playwright-registry.js';
20
24
 
21
25
  export const GLOBAL_RULES = [
22
26
  ...CACHE_RULES,
@@ -24,6 +28,7 @@ export const GLOBAL_RULES = [
24
28
  ...APP_RULES,
25
29
  ...TOOLCHAIN_CACHE_RULES,
26
30
  ...DOWNLOAD_RULES,
31
+ ...TEMP_CLONE_RULES,
27
32
  ];
28
33
 
29
34
  function scopeBases(rule, homes, tmpDirs) {
@@ -173,7 +178,13 @@ function olderVersions(env, rule, paths, stats) {
173
178
  return paths.filter((target) => old.has(target));
174
179
  }
175
180
 
176
- async function buildItem(context, rule, base, group) {
181
+ /** What cleanup re-verifies right before deleting, by the rule's `git` mode. */
182
+ const GIT_RECHECKS = {
183
+ ifPresent: { type: 'scratch' },
184
+ clone: { type: 'clone' },
185
+ };
186
+
187
+ async function buildItem(context, rule, base, group, homes) {
177
188
  const { env } = context;
178
189
  const paths = group.map((entry) => entry.path);
179
190
  const appRoot = rule.markers
@@ -195,7 +206,7 @@ async function buildItem(context, rule, base, group) {
195
206
  reason: rule.minAge
196
207
  ? `untouched for longer than the ${rule.minAge === 'stale' ? 'activity' : 'inactivity'} window`
197
208
  : 're-downloaded or re-created on demand',
198
- recheck: rule.git === 'ifPresent' ? { type: 'scratch' } : undefined,
209
+ recheck: GIT_RECHECKS[rule.git],
199
210
  action: action ?? { type: 'none' },
200
211
  checks: {
201
212
  busy: [
@@ -207,10 +218,31 @@ async function buildItem(context, rule, base, group) {
207
218
  },
208
219
  });
209
220
  block(item, blocker);
221
+ if (rule.appClone) {
222
+ await describeAppClone(context, rule, item, homes);
223
+ }
210
224
  await addBlockers(context, rule, item);
211
225
  return item;
212
226
  }
213
227
 
228
+ async function describeAppClone(context, rule, item, homes) {
229
+ const { env } = context;
230
+ const appDirs = rule.appClone.installed
231
+ .flatMap((dir) =>
232
+ homes.map((home) => expandRulePath(dir, { home, pathApi: env.path }))
233
+ )
234
+ .filter(Boolean);
235
+ const { executables, sharedWith } = await appCloneDetails(env, item.path, [
236
+ ...new Set(appDirs),
237
+ ]);
238
+ item.checks.executables = executables;
239
+ if (sharedWith) {
240
+ // Every block is shared with the installed app: nothing to reclaim.
241
+ item.bytes = 0;
242
+ item.reason = `shares its disk blocks with ${sharedWith}`;
243
+ }
244
+ }
245
+
214
246
  async function addBlockers(context, rule, item) {
215
247
  const { env, git } = context;
216
248
  if (rule.scope === 'system' && !(await env.isRoot())) {
@@ -218,13 +250,24 @@ async function addBlockers(context, rule, item) {
218
250
  }
219
251
  if (rule.git) {
220
252
  for (const target of item.paths) {
221
- for (const reason of await git.treeBlockers(target, {
222
- allowNoRepo: rule.git === 'ifPresent',
223
- })) {
253
+ for (const reason of rule.git === 'clone'
254
+ ? await git.cloneBlockers(target)
255
+ : await git.treeBlockers(target, {
256
+ allowNoRepo: rule.git === 'ifPresent',
257
+ })) {
224
258
  block(item, reason);
225
259
  }
226
260
  }
227
261
  }
262
+ if (rule.registry === 'playwright') {
263
+ context.playwrightRegistries ??= new Map();
264
+ for (const target of item.paths) {
265
+ block(
266
+ item,
267
+ await playwrightBlocker(env, target, context.playwrightRegistries)
268
+ );
269
+ }
270
+ }
228
271
  const busy = await scanTimeBusy(context, item);
229
272
  if (busy) {
230
273
  block(item, `busy: ${busy}`);
@@ -292,9 +335,12 @@ export async function scanGlobal(
292
335
 
293
336
  const accepted = [];
294
337
  for (const target of candidates) {
338
+ // Acceptance can read Git metadata of thousands of temporary entries
339
+ // without passing through the environment, so honour the budget here.
340
+ checkSignal(env.signal);
295
341
  if (
296
342
  !(await timed(context.timings, 'global:accept', () =>
297
- acceptedPath(env, rule, target, stats)
343
+ acceptedPath(env, rule, target, stats, seen)
298
344
  ))
299
345
  ) {
300
346
  continue;
@@ -338,7 +384,7 @@ export async function scanGlobal(
338
384
  : measured.filter(large).map((entry) => [entry]);
339
385
  for (const group of groups.filter((g) => g.length > 0)) {
340
386
  const item = await timed(context.timings, 'global:item', () =>
341
- buildItem(context, rule, base, group)
387
+ buildItem(context, rule, base, group, homes)
342
388
  );
343
389
  items.push(item);
344
390
  if ((await context.ready?.([item])) === false) {
@@ -349,7 +395,40 @@ export async function scanGlobal(
349
395
  return items;
350
396
  }
351
397
 
352
- async function acceptedPath(env, rule, target, stats) {
398
+ /**
399
+ * Whether `target` or one of its ancestors is in `claimed`. Both come from
400
+ * the same glob expansion, so ancestors compare as plain strings and the
401
+ * cost is the path depth, not the number of claimed paths.
402
+ */
403
+ function claimedAbove(env, target, claimed) {
404
+ for (let dir = target; ; dir = env.path.dirname(dir)) {
405
+ if (claimed.has(dir)) {
406
+ return true;
407
+ }
408
+ if (env.path.dirname(dir) === dir) {
409
+ return false;
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * A temporary directory holding a Git work tree with a network remote,
416
+ * outside every path another rule (or a parent clone) already claimed.
417
+ */
418
+ async function tempClone(env, target, seen) {
419
+ if (env.kind !== 'host' || claimedAbove(env, target, seen)) {
420
+ return false;
421
+ }
422
+ try {
423
+ // Other users' temporary directories are unreadable; that is no error.
424
+ const { commonDir } = await gitDirectories(target);
425
+ return (await networkRemotes(commonDir)).length > 0;
426
+ } catch {
427
+ return false;
428
+ }
429
+ }
430
+
431
+ async function acceptedPath(env, rule, target, stats, seen = new Set()) {
353
432
  const name = env.path.basename(target);
354
433
  if (rule.excludeNames?.includes(name)) {
355
434
  return false;
@@ -357,6 +436,9 @@ async function acceptedPath(env, rule, target, stats) {
357
436
  if (rule.directoryOnly && stats.get(target)?.type !== 'dir') {
358
437
  return false;
359
438
  }
439
+ if (rule.git === 'clone' && !(await tempClone(env, target, seen))) {
440
+ return false;
441
+ }
360
442
  if (rule.fileOnly && stats.get(target)?.type !== 'file') {
361
443
  return false;
362
444
  }
@@ -235,11 +235,13 @@ function referencedInstalls(rule, installs, refs, versionOf) {
235
235
  }
236
236
 
237
237
  /**
238
- * Global packages a user installed into a version (`npm install -g`) are
239
- * lost with it. Packages bundled with the runtime and caches that are
240
- * installed again on demand (`rule.globalPackages.regenerable`) are not.
238
+ * Global packages a user installed into a version (`npm install -g`) go
239
+ * with it, so the item names them. Packages bundled with the runtime and
240
+ * caches installed again on demand (`rule.globalPackages.regenerable`) are
241
+ * not named. A package that is running keeps the version through the
242
+ * process checks: its binary, arguments or PATH entry lies inside.
241
243
  */
242
- async function blockGlobalPackages(env, rule, dir, item) {
244
+ async function noteGlobalPackages(env, rule, dir, item) {
243
245
  if (!rule.globalPackages) {
244
246
  return;
245
247
  }
@@ -262,7 +264,8 @@ async function blockGlobalPackages(env, rule, dir, item) {
262
264
  if (own.length > 0) {
263
265
  const shown = own.slice(0, 5).join(', ');
264
266
  const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
265
- block(item, `has globally installed packages: ${shown}${more}`);
267
+ item.globalPackages = own;
268
+ item.reason += `; also removes its global packages: ${shown}${more}`;
266
269
  }
267
270
  }
268
271
 
@@ -357,7 +360,7 @@ async function ruleItems(context, rule, home, pins) {
357
360
  : null,
358
361
  });
359
362
  await blockPyenvEnvironments(env, rule, dir, item);
360
- await blockGlobalPackages(env, rule, dir, item);
363
+ await noteGlobalPackages(env, rule, dir, item);
361
364
  await blockLiveLock(context, rule, home, version, item);
362
365
  const busy = await scanTimeBusy(context, item);
363
366
  if (busy) {