disk-space-saviour 0.22.0 → 0.22.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 9479acd: Keep a `node_modules` folder that holds data no package manager installs, naming each such entry and its size, at scan time and right before deletion.
8
+
9
+ Let `clean` and `emergency` delete on a full disk: an audit log that does not fit is reported as not written and written as soon as deletions free space.
10
+
11
+ Keep everything inside a live Claude Code session scratchpad until the session and its transcript stay idle for the inactive window, at scan time and right before deletion.
12
+
13
+ Show the dss run among swap holders as `(this dss run)` and never name it as a process to quit.
14
+
15
+ Name what fills a directory of many small entries in `dss usage` by name pattern, with their count and total size.
16
+
17
+ Offer rotated app logs in `~/Library/Logs`, Crashpad reports left unsent for 30 days and the GitHub CLI run-log cache in the moderate tier.
18
+
3
19
  ## 0.22.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -94,12 +94,19 @@ Run `dss --help` for every option. The most useful ones:
94
94
  files, and for each directory what the latest dss scan or clean found
95
95
  inside it (`[dss: 1.2 GiB freeable, 3.4 GiB blocked]`). On macOS it adds
96
96
  the APFS volumes a walk cannot see (system, VM swap, Preboot, Recovery),
97
- local snapshots and the part of the Data volume left unread. It needs
98
- `du`, so it runs on macOS and Linux; a full walk takes minutes.
97
+ local snapshots and the part of the Data volume left unread. A directory
98
+ filled by many small entries names what fills it by pattern
99
+ (`T/ms-* (68523 entries, each under 64 MiB)`). It needs `du`, so it runs
100
+ on macOS and Linux; a full walk takes minutes.
99
101
  - APFS clones that share every block with an installed app, such as Chrome's
100
102
  code-sign clones, free nothing when deleted, so they are never offered. The
101
103
  scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
102
104
  them, because `du` and Finder count them at full size.
105
+ - `node_modules` counts as regenerable only while it holds what package
106
+ managers install: packages, `@scope` folders, `.bin`, lockfile copies and
107
+ tool caches such as `.cache` or `.vite`. Any other data kept there
108
+ (verification runs, captured evidence) blocks the whole directory, and the
109
+ check runs again right before deleting.
103
110
  - Git worktree folders (`.worktrees`, `worktrees`) restart the project search
104
111
  depth, so dependencies of nested worktrees are found below `--max-depth`.
105
112
  - `clean` automatically reuses a matching scan from the last 30 minutes.
@@ -139,7 +146,9 @@ their blocks with the installed app), Chromium on-device AI models
139
146
  Electron and electron-builder archives, the Bun transpiler cache, Docker
140
147
  Desktop and Warp staged updates (kept while the app runs), Zed language
141
148
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
142
- and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
149
+ Crashpad reports (processed ones, and unsent ones after 30 days), rotated app
150
+ logs in `~/Library/Logs` (`warp.log.old.2`) and the GitHub CLI run-log cache.
151
+ Telegram pure caches, Safari/WebKit,
143
152
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
144
153
  caches are included. Old Playwright browser revisions are kept while a
145
154
  Playwright package recorded in the registry's `.links` still launches them or
@@ -148,6 +157,10 @@ directories are removed once untouched. Telegram caches are removed only
148
157
  while Telegram is not running. Telegram databases, browser profiles and
149
158
  Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
150
159
  is needed. Agent scratch cleanup requires inactivity and Git safety checks.
160
+ Anything inside a Claude Code session scratchpad
161
+ (`{TMP}/claude-<uid>/<project>/<session>`) is kept until the session and its
162
+ transcript in `~/.claude/projects` stay untouched for the inactive window
163
+ (30 days), checked again right before deletion.
151
164
 
152
165
  ## Safety
153
166
 
@@ -233,7 +246,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
233
246
  include every candidate and its blockers; blocked candidates do not
234
247
  contribute to reclaimable tier totals.
235
248
  If a read-only scan cannot save its audit, it warns and still prints the
236
- report. Root scans with another user's `HOME` use a private temporary audit
249
+ report. When the disk is full, `clean` and `emergency` delete anyway and
250
+ write their audit as soon as deletions free room; if it never fits, the
251
+ summary says `Audit log: not written (ENOSPC ...)`. Other audit failures,
252
+ such as an unusable `--audit-dir`, still stop them before deleting. Root scans with another user's `HOME` use a private temporary audit
237
253
  directory. Set `--audit-dir` to choose a persistent writable location.
238
254
 
239
255
  Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
@@ -290,7 +306,8 @@ Each scan records swap usage and its largest holders in the state directory, so
290
306
  the next scan shows how much both grew during the last week. A `WARNING:` line
291
307
  appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
292
308
  disk space it can grow into, naming the processes holding the memory and their
293
- growth. Long-running AI agent sessions keep their history in memory: quitting and
309
+ growth. The dss run itself is listed as `(this dss run)` and never named as a
310
+ process to quit. Long-running AI agent sessions keep their history in memory: quitting and
294
311
  resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
295
312
  Docker Desktop consumes memory, saving work and restarting it or lowering its VM
296
313
  memory limit can help. dss never deletes swap files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/audit.js CHANGED
@@ -101,7 +101,38 @@ export async function writeAudit(audit, options = {}) {
101
101
  dir,
102
102
  `dss-${audit.command}-${stamp}-${audit.pid}.json`
103
103
  );
104
- await fsp.writeFile(file, `${JSON.stringify(audit, null, 2)}\n`);
104
+ // A write that fails half way (a full disk) leaves the previous record.
105
+ const partial = `${file}.${process.pid}.tmp`;
106
+ try {
107
+ await fsp.writeFile(partial, `${JSON.stringify(audit, null, 2)}\n`);
108
+ await fsp.rename(partial, file);
109
+ } catch (error) {
110
+ await fsp.rm(partial, { force: true }).catch(() => {});
111
+ throw error;
112
+ }
105
113
  audit.file = file;
106
114
  return file;
107
115
  }
116
+
117
+ /** Errors meaning the disk is full, which is what cleaning fixes. */
118
+ const NO_SPACE = new Set(['ENOSPC', 'EDQUOT']);
119
+
120
+ /**
121
+ * Writes the record of a run that deletes. A full disk does not stop the
122
+ * run: the failure is kept in `audit.auditError`, and the next write, after
123
+ * deletions freed space, tries again.
124
+ * @returns {Promise<string|null>} the file, or null when the disk is full
125
+ */
126
+ export async function recordAudit(audit, options = {}) {
127
+ try {
128
+ const file = await writeAudit(audit, options);
129
+ delete audit.auditError;
130
+ return file;
131
+ } catch (error) {
132
+ if (!NO_SPACE.has(error.code)) {
133
+ throw error;
134
+ }
135
+ audit.auditError = { message: error.message, code: error.code };
136
+ return null;
137
+ }
138
+ }
package/src/clean.js CHANGED
@@ -17,7 +17,7 @@ import { createWriteStream, promises as fsp } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { finished } from 'node:stream/promises';
19
19
 
20
- import { backupDirectory, startAudit, writeAudit } from './audit.js';
20
+ import { backupDirectory, recordAudit, startAudit } from './audit.js';
21
21
  import { PhaseTimer } from './timings.js';
22
22
  import { configureContainerHost } from './docker/writable.js';
23
23
  import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
@@ -45,6 +45,7 @@ import { isWithin } from './paths.js';
45
45
  import { OTHER_RULES } from './rules/other.js';
46
46
  import { compareVersions, VERSION_RULES } from './rules/versions.js';
47
47
  import { scanVersions } from './scanners/versions.js';
48
+ import { foreignDataBlocker } from './scanners/node-modules.js';
48
49
  import { analyzeRustProfile } from './scanners/rust.js';
49
50
  import {
50
51
  filterItems,
@@ -192,6 +193,7 @@ export class Cleaner {
192
193
  git: new GitInspector(env),
193
194
  liveness: new LivenessProbe(env, {
194
195
  staleAgeMs: this.options.staleAgeMs,
196
+ inactiveMs: this.options.inactiveMs,
195
197
  now: this.options.now,
196
198
  }),
197
199
  });
@@ -447,6 +449,12 @@ export class Cleaner {
447
449
  if (mount) {
448
450
  return mount;
449
451
  }
452
+ if (item.rule === 'node-modules' && type === 'remove') {
453
+ const foreign = await foreignDataBlocker(ctx.env, item.path);
454
+ if (foreign) {
455
+ return foreign;
456
+ }
457
+ }
450
458
  return this.#gitBlocker(ctx, item);
451
459
  }
452
460
 
@@ -959,7 +967,7 @@ export async function clean(report, input = {}) {
959
967
  }
960
968
  if (options.audit !== false) {
961
969
  await timings.measure('audit-write', () =>
962
- writeAudit(audit, { ...options, inProgress: true })
970
+ recordAudit(audit, { ...options, inProgress: true })
963
971
  );
964
972
  }
965
973
  };
@@ -971,7 +979,7 @@ export async function clean(report, input = {}) {
971
979
  audit.health = cleaner.health.records;
972
980
  audit.timings = timings;
973
981
  if (options.audit !== false) {
974
- await writeAudit(audit, { ...options, inProgress: true });
982
+ await recordAudit(audit, { ...options, inProgress: true });
975
983
  }
976
984
  try {
977
985
  const done = new Set(audit.entries.map((entry) => entry.id));
@@ -988,7 +996,7 @@ export async function clean(report, input = {}) {
988
996
  options.onEntry?.(entry, item);
989
997
  if (options.audit !== false) {
990
998
  await timings.measure('audit-write', () =>
991
- writeAudit(audit, { ...options, inProgress: true })
999
+ recordAudit(audit, { ...options, inProgress: true })
992
1000
  );
993
1001
  }
994
1002
  }
@@ -1025,7 +1033,7 @@ export async function finishAudit(audit, report, options) {
1025
1033
  )
1026
1034
  .map((env) => ({ label: env.label, status: env.scanStatus }));
1027
1035
  if (options.audit !== false) {
1028
- await writeAudit(audit, options);
1036
+ await recordAudit(audit, options);
1029
1037
  }
1030
1038
  return audit;
1031
1039
  }
package/src/conditions.js CHANGED
@@ -47,6 +47,13 @@ const CONDITIONS = [
47
47
  action: `they stay untouched for the ${m[1]} activity window`,
48
48
  }),
49
49
  ],
50
+ [
51
+ /^(?:busy: )?inside the scratchpad of Claude Code session (\S+) .*; kept until it is idle for (.+)$/,
52
+ (m) => ({
53
+ key: `session:${m[1]}`,
54
+ action: `the Claude Code session ${m[1]} stays idle for ${m[2]}`,
55
+ }),
56
+ ],
50
57
  [
51
58
  /^scan incomplete: (.+)$/,
52
59
  (m) => ({
package/src/emergency.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * kept for investigation needs its own `removeContainers` entry.
13
13
  */
14
14
 
15
- import { startAudit, writeAudit, throwIfAborted } from './audit.js';
15
+ import { recordAudit, startAudit, throwIfAborted } from './audit.js';
16
16
  import { Cleaner, cleanOrder, finishAudit, wantedItems } from './clean.js';
17
17
  import { LocalEnv } from './env/local.js';
18
18
  import { TIERS, selectByTier, tierRank } from './items.js';
@@ -120,7 +120,7 @@ export async function emergency(input = {}) {
120
120
  });
121
121
  audit.report = report;
122
122
  if (options.audit !== false) {
123
- await writeAudit(audit, { ...options, inProgress: true });
123
+ await recordAudit(audit, { ...options, inProgress: true });
124
124
  }
125
125
  let disk = audit.diskAfter ?? diskBefore;
126
126
  if (report.aborted) {
@@ -145,7 +145,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
145
145
  audit.entries.push(entry);
146
146
  }
147
147
  if (options.audit !== false) {
148
- await writeAudit(audit, { ...options, inProgress: true });
148
+ await recordAudit(audit, { ...options, inProgress: true });
149
149
  }
150
150
  };
151
151
  const cleaner = new Cleaner(report, {
@@ -195,7 +195,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
195
195
  }
196
196
  options.onEntry?.(entry, item);
197
197
  if (options.audit !== false) {
198
- await writeAudit(audit, { ...options, inProgress: true });
198
+ await recordAudit(audit, { ...options, inProgress: true });
199
199
  }
200
200
  if (entry.status !== 'removed' && entry.status !== 'planned') {
201
201
  continue;
package/src/index.d.ts CHANGED
@@ -180,6 +180,8 @@ export interface EnvironmentDescriptor {
180
180
  swappedBytes?: number;
181
181
  /** Change since the earliest scan of the last week listing this process. */
182
182
  growth?: { since: number; memoryBytes: number };
183
+ /** The dss process itself, never named as one to quit. */
184
+ self?: boolean;
183
185
  }>;
184
186
  /** Change since the earliest scan of the last week. */
185
187
  growth?: { since: number; usedBytes: number } | null;
@@ -288,6 +290,8 @@ export interface AuditLog {
288
290
  plannedBytes: number;
289
291
  environments: Record<string, EnvironmentTotal>;
290
292
  file?: string;
293
+ /** Set while the disk was too full to write the log; deletion went on. */
294
+ auditError?: { message: string; code: string };
291
295
  goal?: { freeBytes: number | null; untilPercent: number | null };
292
296
  goalMet?: boolean;
293
297
  reachedTier?: Tier;
package/src/liveness.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * - a command argument names a path inside the item (including scripts
11
11
  * whose interpreter has already closed its input descriptor);
12
12
  * - a directory inside the item is on a process's `PATH`, so the process
13
- * may start a program from it at any time.
13
+ * may start a program from it at any time;
14
+ * - the path lies in the scratchpad of a Claude Code session that was active
15
+ * inside the inactive window, so the session may still rely on it.
14
16
  *
15
17
  * The cleaner refreshes the probe right before acting on each item.
16
18
  */
@@ -27,6 +29,32 @@ import { isWithin } from './paths.js';
27
29
  import { formatDuration } from './units.js';
28
30
 
29
31
  const PROC_COMM_LIMIT = 15;
32
+ const DEFAULT_INACTIVE_MS = 30 * 24 * 60 * 60 * 1000;
33
+ const SESSION_ID =
34
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
+
36
+ /**
37
+ * The Claude Code session directory a path lies in: Claude Code keeps each
38
+ * session's scratchpad under `<tmp>/claude-<uid>/<project>/<session id>`.
39
+ * @returns {{dir: string, project: string, id: string}|null}
40
+ */
41
+ export function agentSessionOf(target, pathApi = path) {
42
+ const parts = target.split(/[\\/]/);
43
+ const index = parts.findIndex(
44
+ (part, at) =>
45
+ /^claude-\d+$/.test(part) &&
46
+ Boolean(parts[at + 1]) &&
47
+ SESSION_ID.test(parts[at + 2] ?? '')
48
+ );
49
+ if (index < 0) {
50
+ return null;
51
+ }
52
+ return {
53
+ dir: parts.slice(0, index + 3).join(pathApi.sep),
54
+ project: parts[index + 1],
55
+ id: parts[index + 2],
56
+ };
57
+ }
30
58
 
31
59
  /** Bun subcommands that read or write the install cache. */
32
60
  const BUN_PACKAGE_COMMANDS = new Set([
@@ -210,11 +238,21 @@ function appLabel(proc, matched) {
210
238
  export class LivenessProbe {
211
239
  /**
212
240
  * @param {object} env environment adapter
213
- * @param {{staleAgeMs: number, now?: () => number, refreshMs?: number}} options
241
+ * @param {{staleAgeMs: number, inactiveMs?: number, now?: () => number,
242
+ * refreshMs?: number}} options
214
243
  */
215
- constructor(env, { staleAgeMs, now = () => Date.now(), refreshMs = 2000 }) {
244
+ constructor(
245
+ env,
246
+ {
247
+ staleAgeMs,
248
+ inactiveMs = DEFAULT_INACTIVE_MS,
249
+ now = () => Date.now(),
250
+ refreshMs = 2000,
251
+ }
252
+ ) {
216
253
  this.env = env;
217
254
  this.staleAgeMs = staleAgeMs;
255
+ this.inactiveMs = inactiveMs;
218
256
  this.now = now;
219
257
  this.refreshMs = refreshMs;
220
258
  this.refreshedAt = -Infinity;
@@ -225,6 +263,7 @@ export class LivenessProbe {
225
263
  this.pathOwners = new Map();
226
264
  this.pathStats = new Map();
227
265
  this.inodes = new Map();
266
+ this.agentSessions = new Map();
228
267
  // Bundle identifiers never change, so they outlive refreshes.
229
268
  this.bundleIds = new Map();
230
269
  }
@@ -247,6 +286,7 @@ export class LivenessProbe {
247
286
  this.pathStats.clear();
248
287
  this.pathOwners.clear();
249
288
  this.inodes.clear();
289
+ this.agentSessions.clear();
250
290
  }
251
291
 
252
292
  /** Reads the identifiers of the app bundles running processes come from. */
@@ -393,6 +433,7 @@ export class LivenessProbe {
393
433
  * @param {string[]} paths
394
434
  */
395
435
  async resolve(paths) {
436
+ await this.#resolveAgentSessions(paths);
396
437
  if (this.env.unreadableProcesses?.length) {
397
438
  const missing = [
398
439
  ...new Set(
@@ -436,6 +477,72 @@ export class LivenessProbe {
436
477
  }
437
478
  }
438
479
 
480
+ /**
481
+ * When each Claude Code session holding one of the paths was last active:
482
+ * its transcript, its session directory and the entries two levels below.
483
+ */
484
+ async #resolveAgentSessions(paths) {
485
+ const pending = new Map();
486
+ for (const target of paths) {
487
+ const session = agentSessionOf(target, this.env.path);
488
+ if (session && !this.agentSessions.has(session.dir)) {
489
+ pending.set(session.dir, session);
490
+ }
491
+ }
492
+ if (pending.size === 0) {
493
+ return;
494
+ }
495
+ this.homes ??= (await this.env.homeDirs?.().catch(() => null)) ?? [];
496
+ for (const session of pending.values()) {
497
+ const children = await this.env.list(session.dir);
498
+ const grandchildren = await Promise.all(
499
+ children
500
+ .filter((entry) => entry.type === 'dir')
501
+ .map((entry) => this.env.list(entry.path))
502
+ );
503
+ const candidates = [
504
+ session.dir,
505
+ ...children.map((entry) => entry.path),
506
+ ...grandchildren.flat().map((entry) => entry.path),
507
+ ...this.homes.map((home) =>
508
+ this.env.path.join(
509
+ home,
510
+ '.claude',
511
+ 'projects',
512
+ session.project,
513
+ `${session.id}.jsonl`
514
+ )
515
+ ),
516
+ ];
517
+ const stats = await Promise.all(
518
+ candidates.map((target) => this.env.stat?.(target))
519
+ );
520
+ const activeMs = Math.max(0, ...stats.map((stat) => stat?.mtimeMs ?? 0));
521
+ this.agentSessions.set(session.dir, { ...session, activeMs });
522
+ }
523
+ }
524
+
525
+ /** A path inside the scratchpad of a Claude Code session active recently. */
526
+ agentSessionUsage(paths) {
527
+ for (const target of paths) {
528
+ const session = this.agentSessions.get(
529
+ agentSessionOf(target, this.env.path)?.dir
530
+ );
531
+ if (!session) {
532
+ continue;
533
+ }
534
+ const age = this.now() - session.activeMs;
535
+ if (session.activeMs && age >= this.inactiveMs) {
536
+ continue;
537
+ }
538
+ const active = session.activeMs
539
+ ? `active ${formatDuration(Math.max(age, 0))} ago`
540
+ : 'activity unknown';
541
+ return `inside the scratchpad of Claude Code session ${session.id.slice(0, 8)} (${active}); kept until it is idle for ${formatDuration(this.inactiveMs)}`;
542
+ }
543
+ return null;
544
+ }
545
+
439
546
  recentWrite(newestMtimeMs) {
440
547
  if (!newestMtimeMs || this.staleAgeMs <= 0) {
441
548
  return null;
@@ -611,16 +718,17 @@ export class LivenessProbe {
611
718
  */
612
719
  busyReason(item) {
613
720
  const checks = item.checks ?? {};
614
- const paths = checks.perPath ? [] : (item.paths ?? []);
721
+ const all = item.paths ?? [];
615
722
  return (
616
- this.installationBlocker(item.paths ?? []) ??
723
+ this.installationBlocker(all) ??
617
724
  this.probeBlocker() ??
618
725
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
726
+ this.agentSessionUsage(all) ??
619
727
  this.runningTool(checks.busy, checks.cwd, item) ??
620
- this.appUsage(item.paths ?? []) ??
621
- this.processUsage(paths) ??
728
+ this.appUsage(all) ??
729
+ this.processUsage(checks.perPath ? [] : all) ??
622
730
  this.executableUsage(checks.executables) ??
623
- this.uncertainUsage(item.paths ?? [])
731
+ this.uncertainUsage(all)
624
732
  );
625
733
  }
626
734
 
package/src/report.js CHANGED
@@ -438,7 +438,8 @@ function holderLine(holder) {
438
438
  `${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
439
439
  );
440
440
  }
441
- return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
441
+ const self = holder.self ? ' (this dss run)' : '';
442
+ return `${holder.pid} ${holder.command}${self}: ${details.join(', ')}`;
442
443
  }
443
444
 
444
445
  function swapLines(swap) {
@@ -617,7 +618,9 @@ export function formatAudit(audit, options = {}) {
617
618
  ...conditionsSection(audit.blocked, options).slice(2)
618
619
  );
619
620
  }
620
- if (audit.file) {
621
+ if (audit.auditError) {
622
+ lines.push(`Audit log: not written (${audit.auditError.message})`);
623
+ } else if (audit.file) {
621
624
  lines.push(`Audit log: ${audit.file}`);
622
625
  }
623
626
  return lines.join('\n');
@@ -184,4 +184,31 @@ export const DOWNLOAD_RULES = [
184
184
  ],
185
185
  minAge: 'stale',
186
186
  },
187
+ {
188
+ id: 'crashpad-pending-reports',
189
+ ecosystem: 'system',
190
+ description:
191
+ 'Chromium and Electron app crash reports never sent: after a month no app uploads them',
192
+ tier: 'moderate',
193
+ aggregate: true,
194
+ paths: [
195
+ '~/Library/Application Support/*/Crashpad/pending/*',
196
+ ...['Google', 'Yandex', 'BraveSoftware'].map(
197
+ (vendor) =>
198
+ `~/Library/Application Support/${vendor}/*/Crashpad/pending/*`
199
+ ),
200
+ '~/.config/*/Crashpad/pending/*',
201
+ ],
202
+ minAge: 'inactive',
203
+ },
204
+ {
205
+ id: 'gh-run-log-cache',
206
+ ecosystem: 'system',
207
+ description:
208
+ 'GitHub CLI cached workflow run logs: gh downloads them again while GitHub keeps them',
209
+ tier: 'moderate',
210
+ aggregate: true,
211
+ paths: ['~/.cache/gh/run-log-*.zip'],
212
+ minAge: 'stale',
213
+ },
187
214
  ];
@@ -414,6 +414,15 @@ export const OTHER_RULES = [
414
414
  aggregate: true,
415
415
  paths: ['~/Library/Logs/DiagnosticReports/*'],
416
416
  },
417
+ {
418
+ id: 'rotated-app-logs',
419
+ ecosystem: 'system',
420
+ description:
421
+ 'rotated app logs (warp.log.old.2, app.log.1): apps write only the current log',
422
+ tier: 'moderate',
423
+ paths: ['~/Library/Logs/*.log.*', '~/Library/Logs/*/*.log.*'],
424
+ minAge: 'stale',
425
+ },
417
426
  {
418
427
  id: 'apt-archives',
419
428
  ecosystem: 'system',
package/src/scan.js CHANGED
@@ -173,6 +173,7 @@ export async function environmentContext(env, options, overrides = {}) {
173
173
  });
174
174
  const liveness = new LivenessProbe(env, {
175
175
  staleAgeMs: options.staleAgeMs,
176
+ inactiveMs: options.inactiveMs,
176
177
  now: options.now,
177
178
  });
178
179
  await liveness.refresh(true);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A node_modules directory is regenerable only as far as package managers
3
+ * fill it. Other data kept there (verification runs, evidence, hand-made
4
+ * fixtures) survives no reinstall, so it keeps the whole directory.
5
+ */
6
+
7
+ import { formatBytes } from '../units.js';
8
+
9
+ /**
10
+ * Top-level entries package managers and the tools they run create:
11
+ * links, lockfile copies, store and workspace state, and caches that
12
+ * re-create themselves.
13
+ */
14
+ const MANAGED_ENTRIES = new Set([
15
+ '.bin',
16
+ '.package-lock.json',
17
+ '.modules.yaml',
18
+ '.pnpm',
19
+ '.pnpm-workspace-state.json',
20
+ '.pnpm-workspace-state-v1.json',
21
+ '.yarn-state.yml',
22
+ '.yarn-integrity',
23
+ '.yarn-metadata.json',
24
+ '.store',
25
+ '.staging',
26
+ '.ignored',
27
+ '.cache',
28
+ '.vite',
29
+ '.vite-temp',
30
+ '.vitest',
31
+ '.vitest-cache',
32
+ '.deno',
33
+ '.prisma',
34
+ '.astro',
35
+ '.mf',
36
+ '.tmp',
37
+ '.DS_Store',
38
+ ]);
39
+
40
+ /**
41
+ * Entries of `dir` no package manager installs, with their size; empty
42
+ * leftovers are left out.
43
+ * @returns {Promise<Array<{name: string, bytes: number}>>}
44
+ */
45
+ export async function foreignEntries(env, dir) {
46
+ const candidates = [];
47
+ for (const entry of await env.list(dir)) {
48
+ if (
49
+ MANAGED_ENTRIES.has(entry.name) ||
50
+ entry.name.startsWith('@') ||
51
+ entry.type === 'symlink'
52
+ ) {
53
+ continue;
54
+ }
55
+ if (
56
+ entry.type === 'dir' &&
57
+ (await env.stat(env.path.join(entry.path, 'package.json')))
58
+ ) {
59
+ continue;
60
+ }
61
+ candidates.push(entry);
62
+ }
63
+ if (candidates.length === 0) {
64
+ return [];
65
+ }
66
+ const usages = await env.usageMany(
67
+ candidates.map((entry) => entry.path),
68
+ { cache: true }
69
+ );
70
+ return candidates
71
+ .map((entry) => ({ name: entry.name, usage: usages.get(entry.path) }))
72
+ .filter(({ usage }) => usage && (usage.files ?? 1) > 0)
73
+ .map(({ name, usage }) => ({ name, bytes: usage.bytes }))
74
+ .sort((a, b) => b.bytes - a.bytes);
75
+ }
76
+
77
+ /** Blocker naming the foreign data inside `dir`, or null. */
78
+ export async function foreignDataBlocker(env, dir) {
79
+ const foreign = await foreignEntries(env, dir);
80
+ if (foreign.length === 0) {
81
+ return null;
82
+ }
83
+ const names = foreign
84
+ .slice(0, 3)
85
+ .map((entry) => `${entry.name} (${formatBytes(entry.bytes)})`)
86
+ .join(', ');
87
+ const more = foreign.length > 3 ? ` and ${foreign.length - 3} more` : '';
88
+ return `holds data no package manager installs: ${names}${more}; a reinstall does not re-create it, so move it out of node_modules first`;
89
+ }
@@ -23,6 +23,7 @@ import { block, makeItem } from '../items.js';
23
23
  import { isWithin, matchesGlob } from '../paths.js';
24
24
  import { listMany, scanTimeBusy } from './common.js';
25
25
  import { analyzeRustProfile, findRustProfiles } from './rust.js';
26
+ import { foreignDataBlocker } from './node-modules.js';
26
27
  import { timed } from '../timings.js';
27
28
 
28
29
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
@@ -248,6 +249,14 @@ async function projectItem(context, { candidate, rule }, usage) {
248
249
  ...projectTier(rule),
249
250
  checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
250
251
  });
252
+ if (rule.id === 'node-modules') {
253
+ block(
254
+ item,
255
+ await timed(context.timings, 'projects:contents', () =>
256
+ foreignDataBlocker(env, candidate.path)
257
+ )
258
+ );
259
+ }
251
260
  await addBlockers(context, item, false);
252
261
  return item;
253
262
  }
package/src/swap.js CHANGED
@@ -260,8 +260,11 @@ export function swapWarnings(swap, disk) {
260
260
  if (reasons.length === 0) {
261
261
  return [];
262
262
  }
263
- // The kernel (PID 0) cannot be quit, so naming it helps nobody.
264
- const candidates = swap.holders.filter((holder) => holder.pid > 0);
263
+ // The kernel (PID 0) cannot be quit and this dss run ends by itself, so
264
+ // naming either helps nobody.
265
+ const candidates = swap.holders.filter(
266
+ (holder) => holder.pid > 0 && !holder.self
267
+ );
265
268
  const growing = candidates.filter(
266
269
  (holder) => holder.growth?.memoryBytes >= GROWTH_WARNING_BYTES
267
270
  );
@@ -308,6 +311,10 @@ export async function readSwap(env, options = {}) {
308
311
  if (!swap) {
309
312
  return null;
310
313
  }
314
+ // This dss run shows up among the holders while it scans; it ends on its own.
315
+ swap.holders = swap.holders.map((holder) =>
316
+ holder.pid === process.pid ? { ...holder, self: true } : holder
317
+ );
311
318
  const history = await loadSwapHistory(stateDir, now);
312
319
  const grown = withGrowth(swap, history);
313
320
  await saveSwapHistory(stateDir, [
package/src/usage.js CHANGED
@@ -26,6 +26,22 @@ import { formatBytes } from './units.js';
26
26
 
27
27
  const MiB = 1024 * 1024;
28
28
  const GiB = 1024 * MiB;
29
+ const GROUP_PATTERNS = 64;
30
+ const GROUP_MIN_ENTRIES = 10;
31
+
32
+ /**
33
+ * The name pattern an entry is grouped under: its leading word and the
34
+ * separator after it (`ms-tg-creds-a1b2` is `ms-*`), else its extension
35
+ * (`trace.zip` is `*.zip`), else null.
36
+ */
37
+ export function namePattern(name) {
38
+ const word = /^[A-Za-z][A-Za-z0-9]*[-_.]/.exec(name);
39
+ if (word && word[0].length < name.length) {
40
+ return `${word[0]}*`;
41
+ }
42
+ const extension = /\.[A-Za-z0-9]{1,8}$/.exec(name);
43
+ return extension ? `*${extension[0]}` : null;
44
+ }
29
45
  const DATA_VOLUME = '/System/Volumes/Data';
30
46
  const DENIED = /^du: (.+): (Permission denied|Operation not permitted)$/;
31
47
 
@@ -52,6 +68,10 @@ export class UsageCollector {
52
68
  this.entries = 0;
53
69
  this.previous = null;
54
70
  this.last = null;
71
+ // Small entries summed by name pattern, per directory still being
72
+ // listed, so a directory of many small entries can say what fills it.
73
+ this.open = new Map();
74
+ this.groups = new Map();
55
75
  }
56
76
 
57
77
  add(line) {
@@ -64,7 +84,11 @@ export class UsageCollector {
64
84
  const isDir = this.previous?.startsWith(prefix) ?? false;
65
85
  this.previous = entry.path;
66
86
  this.last = entry;
87
+ if (isDir) {
88
+ this.#closeDir(entry);
89
+ }
67
90
  if (entry.bytes < this.minBytes) {
91
+ this.#group(entry);
68
92
  return;
69
93
  }
70
94
  if (isDir) {
@@ -77,6 +101,45 @@ export class UsageCollector {
77
101
  }
78
102
  }
79
103
 
104
+ #group(entry) {
105
+ const slash = entry.path.lastIndexOf('/');
106
+ const pattern = namePattern(entry.path.slice(slash + 1));
107
+ if (slash <= 0 || !pattern) {
108
+ return;
109
+ }
110
+ const parent = entry.path.slice(0, slash);
111
+ const groups = this.open.get(parent) ?? new Map();
112
+ this.open.set(parent, groups);
113
+ const group = groups.get(pattern);
114
+ if (group) {
115
+ group.bytes += entry.bytes;
116
+ group.count += 1;
117
+ } else if (groups.size < GROUP_PATTERNS) {
118
+ groups.set(pattern, { pattern, bytes: entry.bytes, count: 1 });
119
+ }
120
+ }
121
+
122
+ /** `du` lists a directory after everything inside it. */
123
+ #closeDir(entry) {
124
+ const groups = this.open.get(entry.path);
125
+ if (!groups) {
126
+ return;
127
+ }
128
+ this.open.delete(entry.path);
129
+ const kept = [...groups.values()].filter(
130
+ (group) =>
131
+ group.bytes >= this.minBytes && group.count >= GROUP_MIN_ENTRIES
132
+ );
133
+ if (entry.bytes >= this.minBytes && kept.length > 0) {
134
+ this.groups.set(
135
+ entry.path,
136
+ kept
137
+ .sort((a, b) => b.bytes - a.bytes)
138
+ .map((group) => ({ ...group, below: this.minBytes }))
139
+ );
140
+ }
141
+ }
142
+
80
143
  #trimFiles() {
81
144
  this.files.sort((a, b) => b.bytes - a.bytes);
82
145
  this.files.length = Math.min(this.files.length, this.topFiles);
@@ -93,6 +156,7 @@ export class UsageCollector {
93
156
  entries: this.entries,
94
157
  dirs: this.dirs,
95
158
  files: this.files,
159
+ groups: this.groups,
96
160
  };
97
161
  }
98
162
  }
@@ -417,9 +481,13 @@ function rootReport(result, items, options) {
417
481
  [...result.dirs].map(([dir, bytes]) => [show(dir), bytes])
418
482
  );
419
483
  const rootPath = show(result.root);
484
+ const groups = new Map(
485
+ [...(result.groups ?? [])].map(([dir, list]) => [show(dir), list])
486
+ );
420
487
  const annotate = (node) => ({
421
488
  ...node,
422
489
  dss: summary(scanCoverage(node.path, items)),
490
+ groups: smallEntries(node, groups.get(node.path), minBytes),
423
491
  children: node.children.map(annotate),
424
492
  });
425
493
  const inside = items.filter((item) =>
@@ -458,6 +526,18 @@ function rootReport(result, items, options) {
458
526
  };
459
527
  }
460
528
 
529
+ /**
530
+ * Groups of small entries worth naming in `node`: those as large as a
531
+ * shown directory, when the shown subdirectories leave much of it unnamed.
532
+ */
533
+ function smallEntries(node, groups, minBytes) {
534
+ const named = node.children.reduce((sum, child) => sum + child.bytes, 0);
535
+ if (!groups || named >= node.bytes * 0.5) {
536
+ return [];
537
+ }
538
+ return groups.filter((group) => group.bytes >= minBytes).slice(0, 5);
539
+ }
540
+
461
541
  function summary(coverage) {
462
542
  return {
463
543
  rule: coverage.item?.rule ?? null,
@@ -517,9 +597,15 @@ function treeLines(node, depth = 0) {
517
597
  ) {
518
598
  return treeLines(only, depth);
519
599
  }
600
+ const indent = ' '.repeat(depth + 1);
601
+ const separator = node.path.endsWith('/') ? '' : '/';
520
602
  return [
521
603
  `${formatBytes(node.bytes).padStart(10)} ${' '.repeat(depth)}${node.path}${dssNote(node.dss)}`,
522
604
  ...node.children.flatMap((child) => treeLines(child, depth + 1)),
605
+ ...(node.groups ?? []).map(
606
+ (group) =>
607
+ `${formatBytes(group.bytes).padStart(10)} ${indent}${node.path}${separator}${group.pattern} (${group.count.toLocaleString('en-US')} entries, each under ${formatBytes(group.below)})`
608
+ ),
523
609
  ];
524
610
  }
525
611