disk-space-saviour 0.16.0 → 0.18.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,3 +1,5 @@
1
+ import { performance } from 'node:perf_hooks';
2
+
1
3
  import { scanItems } from '../env/scope.js';
2
4
  /**
3
5
  * Per-project dependency, build and cache directories (node_modules, target,
@@ -16,8 +18,9 @@ import { scanItems } from '../env/scope.js';
16
18
  import { ECOSYSTEMS, PROJECT_RULES } from '../rules/ecosystems.js';
17
19
  import { block, makeItem } from '../items.js';
18
20
  import { isWithin, matchesGlob } from '../paths.js';
19
- import { listMany, olderThan, scanTimeBusy } from './common.js';
21
+ import { listMany, scanTimeBusy } from './common.js';
20
22
  import { analyzeRustProfile, findRustProfiles } from './rust.js';
23
+ import { timed } from '../timings.js';
21
24
 
22
25
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
23
26
 
@@ -65,6 +68,14 @@ function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
65
68
  * Pairs every found directory with the first rule it satisfies (or null).
66
69
  */
67
70
  async function matchRules(env, found, rules) {
71
+ // Refresh markers and activity; the discovery index only supplies hints.
72
+ const parents = await listMany(env, [
73
+ ...new Set(found.map((candidate) => candidate.parent)),
74
+ ]);
75
+ found = found.map((candidate) => ({
76
+ ...candidate,
77
+ siblings: parents.get(candidate.parent) ?? [],
78
+ }));
68
79
  const options = found.map((candidate) => ({
69
80
  candidate,
70
81
  rules: rules.filter((rule) =>
@@ -116,6 +127,11 @@ async function rootCandidates(env, roots) {
116
127
  return candidates;
117
128
  }
118
129
 
130
+ async function indexedMatches(env, roots, globs, maxDepth, rules) {
131
+ const candidates = (await env.indexedDirs?.(roots, globs, maxDepth)) ?? [];
132
+ return matchRules(env, candidates, rules);
133
+ }
134
+
119
135
  /**
120
136
  * Finds every rule match below the roots.
121
137
  * @returns {Promise<Array<{candidate: object, rule: object}>>}
@@ -125,18 +141,34 @@ export async function findProjectDirs(env, roots, options = {}) {
125
141
  const maxDepth = options.maxDepth ?? 6;
126
142
  const globs = [...new Set(rules.flatMap((rule) => rule.names))];
127
143
  const matches = [];
144
+ const seen = new Set();
145
+ const accept = (match) => {
146
+ if (!match.rule || seen.has(match.candidate.path)) {
147
+ return;
148
+ }
149
+ seen.add(match.candidate.path);
150
+ matches.push(match);
151
+ return match;
152
+ };
153
+ const indexed = await indexedMatches(env, roots, globs, maxDepth, rules);
154
+ await options.onMatches?.(indexed.map(accept).filter(Boolean));
128
155
  if (options.includeRoots) {
129
156
  const self = await matchRules(env, await rootCandidates(env, roots), rules);
130
- matches.push(...self.filter((match) => match.rule));
157
+ const accepted = self.map(accept).filter(Boolean);
158
+ await options.onMatches?.(accepted);
131
159
  }
132
160
  let frontier = new Map([[maxDepth, roots]]);
133
161
  while (frontier.size > 0) {
134
162
  const next = new Map();
135
163
  for (const [depth, dirs] of frontier) {
136
164
  const found = await env.findDirs(dirs, { globs, maxDepth: depth });
165
+ const accepted = [];
137
166
  for (const match of await matchRules(env, found, rules)) {
138
167
  if (match.rule) {
139
- matches.push(match);
168
+ const fresh = accept(match);
169
+ if (fresh) {
170
+ accepted.push(fresh);
171
+ }
140
172
  continue;
141
173
  }
142
174
  const remaining = depth - depthBelow(env, dirs, match.candidate.path);
@@ -147,80 +179,45 @@ export async function findProjectDirs(env, roots, options = {}) {
147
179
  ]);
148
180
  }
149
181
  }
182
+ await options.onMatches?.(accepted);
150
183
  }
151
184
  frontier = next;
152
185
  }
153
186
  return matches;
154
187
  }
155
188
 
156
- function siblingActivity(candidate, globs) {
157
- return (candidate.siblings ?? [])
158
- .filter(
159
- (entry) =>
160
- entry.name !== candidate.name &&
161
- !globs.some((glob) => matchesGlob(entry.name, glob))
162
- )
163
- .reduce((newest, entry) => Math.max(newest, entry.mtimeMs ?? 0), 0);
164
- }
165
-
166
- function projectTier(context, rule, lastActivityMs) {
167
- if (rule.kind === 'cache') {
168
- return { tier: 'safe', reason: 'cache, re-created on demand' };
169
- }
170
- const { inactiveMs } = context.options;
171
- if (olderThan(context, lastActivityMs, inactiveMs)) {
172
- return {
173
- tier: rule.id === 'node-modules' ? 'safe' : 'moderate',
174
- reason: 'project inactive, re-created by the build or install step',
175
- };
176
- }
177
- return {
178
- tier: 'aggressive',
179
- reason: 'project recently active, re-created by the build or install step',
180
- };
189
+ /**
190
+ * Project caches, build outputs and dependencies are all regenerable, so how
191
+ * recently the project was used does not matter: only liveness (a tool
192
+ * running in the project, open files, recent writes) and Git safety block
193
+ * them.
194
+ */
195
+ function projectTier(rule) {
196
+ return rule.kind === 'cache'
197
+ ? { tier: 'safe', reason: 'cache, re-created on demand' }
198
+ : { tier: 'safe', reason: 're-created by the build or install step' };
181
199
  }
182
200
 
183
201
  async function addBlockers(context, item, requireClean) {
184
- for (const reason of await context.git.pathBlockers(item.path, {
185
- requireClean,
186
- })) {
202
+ const reasons = await timed(context.timings, 'projects:git', () =>
203
+ context.git.pathBlockers(item.path, { requireClean })
204
+ );
205
+ for (const reason of reasons) {
187
206
  block(item, reason);
188
207
  }
189
- const busy = await scanTimeBusy(context, item);
208
+ const busy = await timed(context.timings, 'projects:busy', () =>
209
+ scanTimeBusy(context, item)
210
+ );
190
211
  if (busy) {
191
212
  block(item, `busy: ${busy}`);
192
213
  }
193
214
  }
194
215
 
195
- async function projectActivity(context, project, candidate, usage, globs) {
196
- const { env, git } = context;
197
- const repo = await git.findRepoRoot(project);
198
- const lastCommitMs = repo ? (await git.repoState(repo)).lastCommitMs : 0;
199
- context.projectUsage ??= new Map();
200
- if (!context.projectUsage.has(project)) {
201
- context.projectUsage.set(project, await env.usage(project));
202
- }
203
- const projectUsage = context.projectUsage.get(project);
204
- return Math.max(
205
- usage?.newestMtimeMs ?? 0,
206
- projectUsage?.newestMtimeMs ?? 0,
207
- siblingActivity(candidate, globs),
208
- lastCommitMs
209
- );
210
- }
211
-
212
- async function projectItem(context, { candidate, rule }, usage, globs) {
216
+ async function projectItem(context, { candidate, rule }, usage) {
213
217
  const { env } = context;
214
218
  const project = rule.parentName
215
219
  ? env.path.dirname(candidate.parent)
216
220
  : candidate.parent;
217
- const lastActivityMs = await projectActivity(
218
- context,
219
- project,
220
- candidate,
221
- usage,
222
- globs
223
- );
224
221
  const item = makeItem(env, {
225
222
  rule: rule.id,
226
223
  kind: rule.kind,
@@ -228,11 +225,11 @@ async function projectItem(context, { candidate, rule }, usage, globs) {
228
225
  description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
229
226
  path: candidate.path,
230
227
  bytes: usage?.bytes ?? 0,
228
+ sizeEstimated: Boolean(usage?.estimated),
231
229
  newestMtimeMs: usage?.newestMtimeMs ?? 0,
232
- lastActivityMs,
233
230
  project,
234
231
  lockfiles: rule.lockfiles ?? null,
235
- ...projectTier(context, rule, lastActivityMs),
232
+ ...projectTier(rule),
236
233
  checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
237
234
  });
238
235
  await addBlockers(context, item, false);
@@ -264,7 +261,7 @@ async function rustItems(context, targetItem, rule) {
264
261
  const { env, liveness, options, now } = context;
265
262
  const items = scanItems(context);
266
263
  const semverPath = env.path.join(targetItem.path, 'semver-checks');
267
- const semverUsage = await env.usage(semverPath);
264
+ const semverUsage = await env.usage(semverPath, { cache: true });
268
265
  if (semverUsage) {
269
266
  const semver = makeItem(env, {
270
267
  rule: 'cargo-semver-checks-cache',
@@ -273,6 +270,7 @@ async function rustItems(context, targetItem, rule) {
273
270
  description: 'cargo-semver-checks baseline builds',
274
271
  path: semverPath,
275
272
  bytes: semverUsage.bytes,
273
+ sizeEstimated: Boolean(semverUsage.estimated),
276
274
  newestMtimeMs: semverUsage.newestMtimeMs,
277
275
  project: targetItem.project,
278
276
  parentId: targetItem.id,
@@ -333,24 +331,51 @@ async function rustItems(context, targetItem, rule) {
333
331
  */
334
332
  export async function scanProjects(context, { rules = PROJECT_RULES } = {}) {
335
333
  const { env, options } = context;
336
- const matches = await findProjectDirs(env, options.roots, {
337
- rules,
338
- maxDepth: options.maxDepth,
339
- includeRoots: options.includeRoots,
340
- });
341
- const usages = await env.usageMany(matches.map((m) => m.candidate.path));
342
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
343
334
  const items = scanItems(context);
335
+ const started = performance.now();
336
+ // Matches are measured while the walk continues; `projects:walk` excludes
337
+ // that time, which `projects:measure`, `projects:git` and `projects:busy`
338
+ // record.
339
+ let streamedMs = 0;
340
+ try {
341
+ await findProjectDirs(env, options.roots, {
342
+ rules,
343
+ maxDepth: options.maxDepth,
344
+ includeRoots: options.includeRoots,
345
+ onMatches: async (matches) => {
346
+ const streamStarted = performance.now();
347
+ try {
348
+ await projectMatches(context, matches, items);
349
+ } finally {
350
+ streamedMs += performance.now() - streamStarted;
351
+ }
352
+ },
353
+ });
354
+ } finally {
355
+ context.timings?.record(
356
+ 'projects:walk',
357
+ performance.now() - started - streamedMs
358
+ );
359
+ }
360
+ return items;
361
+ }
362
+
363
+ async function projectMatches(context, matches, items) {
364
+ const usages = await timed(context.timings, 'projects:measure', () =>
365
+ context.env.usageMany(
366
+ matches.map((match) => match.candidate.path),
367
+ { cache: true }
368
+ )
369
+ );
344
370
  for (const match of matches) {
345
371
  const usage = usages.get(match.candidate.path);
346
372
  if (!usage) {
347
373
  continue;
348
374
  }
349
- const item = await projectItem(context, match, usage, globs);
375
+ const item = await projectItem(context, match, usage);
350
376
  items.push(item);
351
377
  if (match.rule.id === 'cargo-target') {
352
378
  items.push(...(await rustItems(context, item, match.rule)));
353
379
  }
354
380
  }
355
- return items;
356
381
  }
@@ -6,7 +6,9 @@ import { scanItems } from '../env/scope.js';
6
6
  * Kept: the manager's default and alias targets, versions pinned by any
7
7
  * project below the scan roots, the newest install of each group, and
8
8
  * anything a running process executes from (checked by the liveness probe).
9
- * Everything else is reported as `moderate`.
9
+ * Everything else is reported as `moderate`, and blocked while it holds
10
+ * data that reinstalling the version would not bring back (pyenv virtual
11
+ * environments, globally installed npm packages).
10
12
  */
11
13
 
12
14
  import {
@@ -171,6 +173,98 @@ function newestInstalls(env, rule, installs, usages) {
171
173
  return newest;
172
174
  }
173
175
 
176
+ /**
177
+ * Follows a chain of manager aliases (`default` -> `lts/iron` -> `v20.20.2`).
178
+ * An LTS alias also keeps its release line: nvm records the newest remote
179
+ * release there, which may not be the one installed.
180
+ * @returns {Promise<string[]>}
181
+ */
182
+ async function resolveAlias(env, aliasDir, ref) {
183
+ const refs = [ref];
184
+ let current = ref;
185
+ for (let hop = 0; hop < 8 && !/^v?\d/.test(current); hop++) {
186
+ if (current.split('/').some((part) => part === '..' || part === '')) {
187
+ break;
188
+ }
189
+ const text = await env.readText(env.path.join(aliasDir, current), 4096);
190
+ const next = (text ?? '').split(/\s+/).find(Boolean);
191
+ if (!next || refs.includes(next)) {
192
+ break;
193
+ }
194
+ refs.push(next);
195
+ current = next;
196
+ }
197
+ if (ref.startsWith('lts/') && /^v?\d/.test(current)) {
198
+ refs.push(current.replace(/^v?(\d+).*$/, 'v$1'));
199
+ }
200
+ return refs;
201
+ }
202
+
203
+ async function resolveAliases(env, rule, home, refs) {
204
+ if (!rule.aliasDir) {
205
+ return refs;
206
+ }
207
+ const aliasDir = expandRulePath(configuredPattern(env, home, rule.aliasDir), {
208
+ home,
209
+ pathApi: env.path,
210
+ });
211
+ const resolved = [];
212
+ for (const ref of refs) {
213
+ resolved.push(...(await resolveAlias(env, aliasDir, ref)));
214
+ }
215
+ return [...new Set(resolved)];
216
+ }
217
+
218
+ /**
219
+ * Installs that the refs keep. With `refsPickNewest` a ref keeps only the
220
+ * newest install it matches, as nvm resolves `20` to the newest 20.x.
221
+ * @returns {Set<string>}
222
+ */
223
+ function referencedInstalls(rule, installs, refs, versionOf) {
224
+ const kept = new Set();
225
+ for (const ref of refs) {
226
+ const matching = installs
227
+ .filter((dir) => versionMatches(versionOf(dir), ref))
228
+ .sort((a, b) => compareVersions(versionOf(b), versionOf(a)));
229
+ for (const dir of rule.refsPickNewest ? matching.slice(0, 1) : matching) {
230
+ kept.add(dir);
231
+ }
232
+ }
233
+ return kept;
234
+ }
235
+
236
+ /**
237
+ * Global packages a user installed into a version (`npm install -g`) are
238
+ * lost with it. Packages bundled with the runtime and caches that are
239
+ * installed again on demand (`rule.globalPackages.regenerable`) are not.
240
+ */
241
+ async function blockGlobalPackages(env, rule, dir, item) {
242
+ if (!rule.globalPackages) {
243
+ return;
244
+ }
245
+ const names = [];
246
+ const root = env.path.join(dir, rule.globalPackages.dir);
247
+ for (const entry of await env.list(root)) {
248
+ if (entry.name.startsWith('.')) {
249
+ continue;
250
+ }
251
+ if (!entry.name.startsWith('@')) {
252
+ names.push(entry.name);
253
+ continue;
254
+ }
255
+ const scoped = await env.list(entry.path);
256
+ names.push(...scoped.map((child) => `${entry.name}/${child.name}`));
257
+ }
258
+ const own = names
259
+ .filter((name) => !rule.globalPackages.regenerable(name))
260
+ .sort();
261
+ if (own.length > 0) {
262
+ const shown = own.slice(0, 5).join(', ');
263
+ const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
264
+ block(item, `has globally installed packages: ${shown}${more}`);
265
+ }
266
+ }
267
+
174
268
  async function blockPyenvEnvironments(env, rule, dir, item) {
175
269
  if (rule.id !== 'pyenv-python') {
176
270
  return;
@@ -189,26 +283,33 @@ async function ruleItems(context, rule, home, pins) {
189
283
  if (rule.protectAll) {
190
284
  return [];
191
285
  }
192
- const refs = [...(await managerRefs(env, rule, home, installs)), ...pins];
193
- const usages = await env.usageMany(installs);
286
+ const refs = await resolveAliases(env, rule, home, [
287
+ ...(await managerRefs(env, rule, home, installs)),
288
+ ...pins,
289
+ ]);
290
+ const usages = await env.usageMany(installs, {
291
+ cache: !context.options.freshSizes,
292
+ });
194
293
  await resolvePaths(context, installs);
195
294
  const newest = newestInstalls(env, rule, installs, usages);
196
295
  const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
296
+ const referenced = referencedInstalls(rule, installs, refs, versionOf);
197
297
  const items = scanItems(context);
198
298
  for (const dir of installs) {
199
299
  const version = versionOf(dir);
200
- if (newest.has(dir) || refs.some((ref) => versionMatches(version, ref))) {
300
+ if (newest.has(dir) || referenced.has(dir)) {
201
301
  continue;
202
302
  }
203
- const usage = usages.get(dir);
303
+ const usage = usages.get(dir) ?? {};
204
304
  const item = makeItem(env, {
205
305
  rule: rule.id,
206
306
  kind: 'toolchain',
207
307
  ecosystem: rule.ecosystem,
208
308
  description: `${rule.description} ${version}`,
209
309
  path: dir,
210
- bytes: usage?.bytes ?? 0,
211
- newestMtimeMs: usage?.newestMtimeMs ?? 0,
310
+ bytes: usage.bytes ?? 0,
311
+ sizeEstimated: Boolean(usage.estimated),
312
+ newestMtimeMs: usage.newestMtimeMs ?? 0,
212
313
  tier: 'moderate',
213
314
  reason: `not the ${rule.manager} default, not pinned by a scanned project, not the newest`,
214
315
  checks: { busy: rule.busy ?? [], cwd: dir, mtime: true },
@@ -219,6 +320,7 @@ async function ruleItems(context, rule, home, pins) {
219
320
  : null,
220
321
  });
221
322
  await blockPyenvEnvironments(env, rule, dir, item);
323
+ await blockGlobalPackages(env, rule, dir, item);
222
324
  const busy = await scanTimeBusy(context, item);
223
325
  if (busy) {
224
326
  block(item, `busy: ${busy}`);
package/src/swap.js ADDED
@@ -0,0 +1,69 @@
1
+ /** Swap is a capacity diagnostic, never a deletion candidate. */
2
+ import { parseSize } from './units.js';
3
+
4
+ export function parseSwapUsage(text) {
5
+ const match =
6
+ /total\s*=\s*([\d.]+[KMGT]?B?)\s+used\s*=\s*([\d.]+[KMGT]?B?)\s+free\s*=\s*([\d.]+[KMGT]?B?)/i.exec(
7
+ text
8
+ );
9
+ return match
10
+ ? {
11
+ total: parseSize(match[1]),
12
+ used: parseSize(match[2]),
13
+ free: parseSize(match[3]),
14
+ }
15
+ : null;
16
+ }
17
+
18
+ export function parseMemoryHolders(text) {
19
+ return text
20
+ .split('\n')
21
+ .map((line) =>
22
+ /^\s*(\d+)\s+(.+?)\s+([\d.]+[KMGT]?B?[+-]?)\s+([\d.]+[KMGT]?B?[+-]?)\s*$/i.exec(
23
+ line
24
+ )
25
+ )
26
+ .filter(Boolean)
27
+ .map((match) => ({
28
+ pid: Number(match[1]),
29
+ command: match[2],
30
+ memoryBytes: parseSize(match[3].replace(/[+-]$/, '')),
31
+ compressedBytes: parseSize(match[4].replace(/[+-]$/, '')),
32
+ }))
33
+ .sort((a, b) => b.memoryBytes - a.memoryBytes)
34
+ .slice(0, 10);
35
+ }
36
+
37
+ export async function readSwap(env) {
38
+ if (env.kind !== 'host' || env.platform !== 'darwin') {
39
+ return null;
40
+ }
41
+ try {
42
+ const usage = await env.run(['sysctl', 'vm.swapusage']);
43
+ const swap = usage.code === 0 ? parseSwapUsage(usage.stdout) : null;
44
+ if (!swap) {
45
+ return null;
46
+ }
47
+ const top = await env.run([
48
+ 'top',
49
+ '-l',
50
+ '1',
51
+ '-o',
52
+ 'mem',
53
+ '-n',
54
+ '10',
55
+ '-stats',
56
+ 'pid,command,mem,cmprs',
57
+ ]);
58
+ return {
59
+ ...swap,
60
+ holders: top.code === 0 ? parseMemoryHolders(top.stdout) : [],
61
+ attribution:
62
+ 'Largest resident/compressed memory consumers; macOS does not expose per-process swap ownership here.',
63
+ advice:
64
+ 'If Docker Desktop is consuming memory, restart it after saving running work or lower its VM memory limit. Swap files are managed by macOS; dss never deletes them.',
65
+ };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
package/src/timings.js ADDED
@@ -0,0 +1,54 @@
1
+ import { performance } from 'node:perf_hooks';
2
+
3
+ /**
4
+ * Wall-clock phase timings.
5
+ *
6
+ * Collected on every run, not only with `--verbose`, so reports and audit
7
+ * logs show which phase was slow. A phase that throws or is aborted (for
8
+ * example by the scan budget) still records the time it consumed.
9
+ */
10
+
11
+ export class PhaseTimer {
12
+ constructor() {
13
+ this.phases = new Map();
14
+ }
15
+
16
+ /**
17
+ * Runs `fn` and adds its duration to `name`.
18
+ * @template T
19
+ * @param {string} name
20
+ * @param {() => Promise<T>|T} fn
21
+ * @returns {Promise<T>}
22
+ */
23
+ async measure(name, fn) {
24
+ const started = performance.now();
25
+ try {
26
+ return await fn();
27
+ } finally {
28
+ this.record(name, performance.now() - started);
29
+ }
30
+ }
31
+
32
+ record(name, ms) {
33
+ const phase = this.phases.get(name) ?? { phase: name, ms: 0, count: 0 };
34
+ phase.ms += ms;
35
+ phase.count++;
36
+ this.phases.set(name, phase);
37
+ }
38
+
39
+ /**
40
+ * @returns {Array<{phase: string, ms: number, count: number}>} slowest first
41
+ */
42
+ toJSON() {
43
+ return [...this.phases.values()]
44
+ .map((phase) => ({ ...phase, ms: Math.round(phase.ms) }))
45
+ .sort((a, b) => b.ms - a.ms);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Measures `fn` when `timer` exists, otherwise just runs it.
51
+ */
52
+ export function timed(timer, name, fn) {
53
+ return timer ? timer.measure(name, fn) : fn();
54
+ }