disk-space-saviour 0.22.0 → 0.23.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.
@@ -1,5 +1,6 @@
1
1
  import { performance } from 'node:perf_hooks';
2
2
 
3
+ import { itemSize } from '../env/hardlinks.js';
3
4
  import { scanItems } from '../env/scope.js';
4
5
  /**
5
6
  * Per-project dependency, build and cache directories (node_modules, target,
@@ -23,6 +24,7 @@ import { block, makeItem } from '../items.js';
23
24
  import { isWithin, matchesGlob } from '../paths.js';
24
25
  import { listMany, scanTimeBusy } from './common.js';
25
26
  import { analyzeRustProfile, findRustProfiles } from './rust.js';
27
+ import { foreignDataBlocker } from './node-modules.js';
26
28
  import { timed } from '../timings.js';
27
29
 
28
30
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
@@ -240,7 +242,7 @@ async function projectItem(context, { candidate, rule }, usage) {
240
242
  ecosystem: rule.ecosystem,
241
243
  description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
242
244
  path: candidate.path,
243
- bytes: usage?.bytes ?? 0,
245
+ ...itemSize([[candidate.path, usage]]),
244
246
  sizeEstimated: Boolean(usage?.estimated),
245
247
  newestMtimeMs: usage?.newestMtimeMs ?? 0,
246
248
  project,
@@ -248,6 +250,14 @@ async function projectItem(context, { candidate, rule }, usage) {
248
250
  ...projectTier(rule),
249
251
  checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
250
252
  });
253
+ if (rule.id === 'node-modules') {
254
+ block(
255
+ item,
256
+ await timed(context.timings, 'projects:contents', () =>
257
+ foreignDataBlocker(env, candidate.path)
258
+ )
259
+ );
260
+ }
251
261
  await addBlockers(context, item, false);
252
262
  return item;
253
263
  }
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