disk-space-saviour 0.15.2 → 0.17.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,4 @@
1
+ import { scanItems } from '../env/scope.js';
1
2
  /**
2
3
  * Global per-user caches (ecosystem download and compilation caches, IDE and
3
4
  * browser caches), system package caches, and leftovers in temporary
@@ -9,13 +10,16 @@ import { OTHER_RULES } from '../rules/other.js';
9
10
  import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
10
11
  import { compareVersions } from '../rules/versions.js';
11
12
  import { block, makeItem } from '../items.js';
12
- import { expandGlobPaths, expandRulePath } from '../paths.js';
13
- import { olderThan, scanTimeBusy } from './common.js';
13
+ import { expandGlobPaths, expandRulePath, isWithin } from '../paths.js';
14
+ import { olderThan, scanTimeBusy, resolvePaths } from './common.js';
15
+ import { configuredPattern } from '../env/cache-roots.js';
16
+ import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
14
17
 
15
18
  export const GLOBAL_RULES = [
16
19
  ...CACHE_RULES,
17
20
  ...expandChromiumCaches(OTHER_RULES),
18
21
  ...APP_RULES,
22
+ ...TOOLCHAIN_CACHE_RULES,
19
23
  ];
20
24
 
21
25
  function scopeBases(rule, homes, tmpDirs) {
@@ -32,15 +36,30 @@ function scopeBases(rule, homes, tmpDirs) {
32
36
  function rulePatterns(env, rule, base) {
33
37
  const patterns = [];
34
38
  for (const pattern of rule.paths) {
39
+ if (pattern.includes('{GEM_PATH}')) {
40
+ if (base.home === env.currentHome) {
41
+ for (const root of (env.vars.GEM_PATH ?? '').split(
42
+ env.platform === 'win32' ? ';' : ':'
43
+ )) {
44
+ if (env.path.isAbsolute(root)) {
45
+ patterns.push(pattern.replace('{GEM_PATH}', root));
46
+ }
47
+ }
48
+ }
49
+ continue;
50
+ }
35
51
  const usesVars = /\{(?!TMP\})[A-Z_]+\}/.test(pattern);
36
52
  if (usesVars && base.home !== env.currentHome) {
37
53
  continue;
38
54
  }
39
- const expanded = expandRulePath(pattern, {
40
- home: base.home ?? '/',
41
- vars: { ...env.vars, TMP: base.tmp ?? undefined },
42
- pathApi: env.path,
43
- });
55
+ const expanded = expandRulePath(
56
+ configuredPattern(env, base.home, pattern),
57
+ {
58
+ home: base.home ?? '/',
59
+ vars: { ...env.vars, TMP: base.tmp ?? undefined },
60
+ pathApi: env.path,
61
+ }
62
+ );
44
63
  if (expanded) {
45
64
  patterns.push(expanded);
46
65
  }
@@ -59,15 +78,26 @@ async function nativeAction(context, rule, base, paths) {
59
78
  if (!native || options.noNative) {
60
79
  return { action: removal, blocker: null };
61
80
  }
62
- const usable =
63
- (rule.scope === 'system' || base.home === env.currentHome) &&
64
- (await env.which(native.tool)) &&
65
- (!native.root || (await env.isRoot()));
81
+ const init = native.init
82
+ ? expandRulePath(configuredPattern(env, base.home, native.init), {
83
+ home: base.home,
84
+ pathApi: env.path,
85
+ })
86
+ : null;
87
+ const nativeRoot = native.cacheRoot
88
+ ? expandRulePath(configuredPattern(env, base.home, native.cacheRoot), {
89
+ home: base.home,
90
+ pathApi: env.path,
91
+ })
92
+ : null;
93
+ const usable = await nativeUsable(env, rule, base, paths, nativeRoot, init);
66
94
  if (usable) {
67
95
  return {
68
96
  action: {
69
97
  type: 'command',
70
- argv: native.argv,
98
+ argv: init
99
+ ? [native.tool, '-c', `. "$1" && ${native.command}`, 'dss', init]
100
+ : native.argv,
71
101
  measure: paths,
72
102
  },
73
103
  blocker: null,
@@ -82,6 +112,16 @@ async function nativeAction(context, rule, base, paths) {
82
112
  return { action: removal, blocker: null };
83
113
  }
84
114
 
115
+ async function nativeUsable(env, rule, base, paths, root, init) {
116
+ return (
117
+ (rule.scope === 'system' || base.home === env.currentHome) &&
118
+ (await env.which(rule.native.tool)) &&
119
+ (!rule.native.root || (await env.isRoot())) &&
120
+ (!root || paths.every((target) => isWithin(target, root, env.path))) &&
121
+ (!init || (await env.exists(init)))
122
+ );
123
+ }
124
+
85
125
  function ageFilter(context, rule) {
86
126
  const { options } = context;
87
127
  if (rule.minAge === 'stale') {
@@ -95,14 +135,18 @@ function ageFilter(context, rule) {
95
135
  return () => true;
96
136
  }
97
137
 
138
+ function requiresMtime(rule) {
139
+ return rule.mtime ?? (rule.scope === 'tmp' || Boolean(rule.minAge));
140
+ }
141
+
98
142
  /** Keep the latest installed revision of each browser and platform family. */
99
- async function olderVersions(env, rule, paths) {
143
+ function olderVersions(env, rule, paths, stats) {
100
144
  if (!rule.versionPattern) {
101
145
  return paths;
102
146
  }
103
147
  const groups = new Map();
104
148
  for (const target of paths) {
105
- if ((await env.stat(target))?.type !== 'dir') {
149
+ if (stats.get(target)?.type !== 'dir') {
106
150
  continue;
107
151
  }
108
152
  const match = rule.versionPattern.exec(env.path.basename(target));
@@ -141,6 +185,7 @@ async function buildItem(context, rule, base, group) {
141
185
  path: paths[0],
142
186
  paths,
143
187
  bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
188
+ sizeEstimated: group.some((entry) => entry.usage.estimated),
144
189
  newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
145
190
  tier: rule.tier ?? 'safe',
146
191
  reason: rule.minAge
@@ -201,6 +246,14 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
201
246
  env,
202
247
  planned.flatMap((entry) => entry.patterns)
203
248
  );
249
+ const typedPaths = planned
250
+ .filter(
251
+ ({ rule }) => rule.directoryOnly || rule.fileOnly || rule.versionPattern
252
+ )
253
+ .flatMap(({ patterns }) =>
254
+ patterns.flatMap((pattern) => matches.get(pattern) ?? [])
255
+ );
256
+ const stats = await env.statMany([...new Set(typedPaths)]);
204
257
  const found = [];
205
258
  const seen = new Set();
206
259
  for (const { rule, base, patterns } of planned) {
@@ -210,20 +263,32 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
210
263
 
211
264
  const accepted = [];
212
265
  for (const target of candidates) {
213
- if (!(await acceptedPath(env, rule, target))) {
266
+ if (!(await acceptedPath(env, rule, target, stats))) {
214
267
  continue;
215
268
  }
216
269
  accepted.push(target);
217
270
  seen.add(target);
218
271
  }
219
- const paths = await olderVersions(env, rule, accepted);
272
+ const paths = olderVersions(env, rule, accepted, stats);
220
273
  if (paths.length > 0) {
221
274
  found.push({ rule, base, paths });
222
275
  }
223
276
  }
224
- const usages = await env.usageMany(found.flatMap((entry) => entry.paths));
225
- const items = [];
277
+ await resolvePaths(
278
+ context,
279
+ found.flatMap((entry) => entry.paths)
280
+ );
281
+ const items = scanItems(context);
282
+ // Deliver cheap native measurements before rules needing recursive activity
283
+ // timestamps, so an emergency goal can avoid the expensive work entirely.
284
+ found.sort(
285
+ (a, b) => Number(requiresMtime(a.rule)) - Number(requiresMtime(b.rule))
286
+ );
226
287
  for (const { rule, base, paths } of found) {
288
+ const usages = await env.usageMany(paths, {
289
+ mtime: requiresMtime(rule),
290
+ cache: true,
291
+ });
227
292
  const accept = ageFilter(context, rule);
228
293
  const measured = paths
229
294
  .map((target) => ({ path: target, usage: usages.get(target) }))
@@ -232,21 +297,25 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
232
297
  ? [measured]
233
298
  : measured.map((entry) => [entry]);
234
299
  for (const group of groups.filter((g) => g.length > 0)) {
235
- items.push(await buildItem(context, rule, base, group));
300
+ const item = await buildItem(context, rule, base, group);
301
+ items.push(item);
302
+ if ((await context.ready?.([item])) === false) {
303
+ return items;
304
+ }
236
305
  }
237
306
  }
238
307
  return items;
239
308
  }
240
309
 
241
- async function acceptedPath(env, rule, target) {
310
+ async function acceptedPath(env, rule, target, stats) {
242
311
  const name = env.path.basename(target);
243
312
  if (rule.excludeNames?.includes(name)) {
244
313
  return false;
245
314
  }
246
- if (rule.directoryOnly && (await env.stat(target))?.type !== 'dir') {
315
+ if (rule.directoryOnly && stats.get(target)?.type !== 'dir') {
247
316
  return false;
248
317
  }
249
- if (rule.fileOnly && (await env.stat(target))?.type !== 'file') {
318
+ if (rule.fileOnly && stats.get(target)?.type !== 'file') {
250
319
  return false;
251
320
  }
252
321
  if (
@@ -1,3 +1,4 @@
1
+ import { scanItems } from '../env/scope.js';
1
2
  /**
2
3
  * Per-project dependency, build and cache directories (node_modules, target,
3
4
  * .venv, ...) found by walking the scan roots.
@@ -64,6 +65,14 @@ function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
64
65
  * Pairs every found directory with the first rule it satisfies (or null).
65
66
  */
66
67
  async function matchRules(env, found, rules) {
68
+ // Refresh markers and activity; the discovery index only supplies hints.
69
+ const parents = await listMany(env, [
70
+ ...new Set(found.map((candidate) => candidate.parent)),
71
+ ]);
72
+ found = found.map((candidate) => ({
73
+ ...candidate,
74
+ siblings: parents.get(candidate.parent) ?? [],
75
+ }));
67
76
  const options = found.map((candidate) => ({
68
77
  candidate,
69
78
  rules: rules.filter((rule) =>
@@ -115,6 +124,11 @@ async function rootCandidates(env, roots) {
115
124
  return candidates;
116
125
  }
117
126
 
127
+ async function indexedMatches(env, roots, globs, maxDepth, rules) {
128
+ const candidates = (await env.indexedDirs?.(roots, globs, maxDepth)) ?? [];
129
+ return matchRules(env, candidates, rules);
130
+ }
131
+
118
132
  /**
119
133
  * Finds every rule match below the roots.
120
134
  * @returns {Promise<Array<{candidate: object, rule: object}>>}
@@ -124,18 +138,34 @@ export async function findProjectDirs(env, roots, options = {}) {
124
138
  const maxDepth = options.maxDepth ?? 6;
125
139
  const globs = [...new Set(rules.flatMap((rule) => rule.names))];
126
140
  const matches = [];
141
+ const seen = new Set();
142
+ const accept = (match) => {
143
+ if (!match.rule || seen.has(match.candidate.path)) {
144
+ return;
145
+ }
146
+ seen.add(match.candidate.path);
147
+ matches.push(match);
148
+ return match;
149
+ };
150
+ const indexed = await indexedMatches(env, roots, globs, maxDepth, rules);
151
+ await options.onMatches?.(indexed.map(accept).filter(Boolean));
127
152
  if (options.includeRoots) {
128
153
  const self = await matchRules(env, await rootCandidates(env, roots), rules);
129
- matches.push(...self.filter((match) => match.rule));
154
+ const accepted = self.map(accept).filter(Boolean);
155
+ await options.onMatches?.(accepted);
130
156
  }
131
157
  let frontier = new Map([[maxDepth, roots]]);
132
158
  while (frontier.size > 0) {
133
159
  const next = new Map();
134
160
  for (const [depth, dirs] of frontier) {
135
161
  const found = await env.findDirs(dirs, { globs, maxDepth: depth });
162
+ const accepted = [];
136
163
  for (const match of await matchRules(env, found, rules)) {
137
164
  if (match.rule) {
138
- matches.push(match);
165
+ const fresh = accept(match);
166
+ if (fresh) {
167
+ accepted.push(fresh);
168
+ }
139
169
  continue;
140
170
  }
141
171
  const remaining = depth - depthBelow(env, dirs, match.candidate.path);
@@ -146,6 +176,7 @@ export async function findProjectDirs(env, roots, options = {}) {
146
176
  ]);
147
177
  }
148
178
  }
179
+ await options.onMatches?.(accepted);
149
180
  }
150
181
  frontier = next;
151
182
  }
@@ -179,6 +210,29 @@ function projectTier(context, rule, lastActivityMs) {
179
210
  };
180
211
  }
181
212
 
213
+ /** Re-evaluate activity before cleaning a reused project plan. */
214
+ export async function currentProjectTier(context, item) {
215
+ const rule = PROJECT_RULES.find((entry) => entry.id === item.rule);
216
+ if (!rule || rule.kind === 'cache') {
217
+ return item.tier;
218
+ }
219
+ const globs = [...new Set(PROJECT_RULES.flatMap((entry) => entry.names))];
220
+ const parent = context.env.path.dirname(item.path);
221
+ const candidate = {
222
+ name: context.env.path.basename(item.path),
223
+ siblings: await context.env.list(parent),
224
+ };
225
+ context.git.invalidate();
226
+ const activity = await projectActivity(
227
+ context,
228
+ item.project,
229
+ candidate,
230
+ { newestMtimeMs: item.newestMtimeMs },
231
+ globs
232
+ );
233
+ return projectTier(context, rule, activity).tier;
234
+ }
235
+
182
236
  async function addBlockers(context, item, requireClean) {
183
237
  for (const reason of await context.git.pathBlockers(item.path, {
184
238
  requireClean,
@@ -197,12 +251,21 @@ async function projectActivity(context, project, candidate, usage, globs) {
197
251
  const lastCommitMs = repo ? (await git.repoState(repo)).lastCommitMs : 0;
198
252
  context.projectUsage ??= new Map();
199
253
  if (!context.projectUsage.has(project)) {
200
- context.projectUsage.set(project, await env.usage(project));
254
+ context.projectUsage.set(
255
+ project,
256
+ env.projectActivity
257
+ ? await env.projectActivity(
258
+ project,
259
+ globs,
260
+ context.now - context.options.inactiveMs
261
+ )
262
+ : (await env.usage(project))?.newestMtimeMs
263
+ );
201
264
  }
202
265
  const projectUsage = context.projectUsage.get(project);
203
266
  return Math.max(
204
267
  usage?.newestMtimeMs ?? 0,
205
- projectUsage?.newestMtimeMs ?? 0,
268
+ projectUsage ?? 0,
206
269
  siblingActivity(candidate, globs),
207
270
  lastCommitMs
208
271
  );
@@ -227,6 +290,7 @@ async function projectItem(context, { candidate, rule }, usage, globs) {
227
290
  description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
228
291
  path: candidate.path,
229
292
  bytes: usage?.bytes ?? 0,
293
+ sizeEstimated: Boolean(usage?.estimated),
230
294
  newestMtimeMs: usage?.newestMtimeMs ?? 0,
231
295
  lastActivityMs,
232
296
  project,
@@ -261,7 +325,32 @@ const RUST_PARTS = {
261
325
  */
262
326
  async function rustItems(context, targetItem, rule) {
263
327
  const { env, liveness, options, now } = context;
264
- const items = [];
328
+ const items = scanItems(context);
329
+ const semverPath = env.path.join(targetItem.path, 'semver-checks');
330
+ const semverUsage = await env.usage(semverPath, { cache: true });
331
+ if (semverUsage) {
332
+ const semver = makeItem(env, {
333
+ rule: 'cargo-semver-checks-cache',
334
+ kind: 'cache',
335
+ ecosystem: 'rust',
336
+ description: 'cargo-semver-checks baseline builds',
337
+ path: semverPath,
338
+ bytes: semverUsage.bytes,
339
+ sizeEstimated: Boolean(semverUsage.estimated),
340
+ newestMtimeMs: semverUsage.newestMtimeMs,
341
+ project: targetItem.project,
342
+ parentId: targetItem.id,
343
+ tier: 'safe',
344
+ reason: 'baseline rustdoc and builds are regenerated on demand',
345
+ checks: {
346
+ busy: ['cargo-semver-checks', ...rule.busy],
347
+ cwd: targetItem.project,
348
+ mtime: true,
349
+ },
350
+ });
351
+ await addBlockers(context, semver, false);
352
+ items.push(semver);
353
+ }
265
354
  for (const profile of await findRustProfiles(env, targetItem.path)) {
266
355
  const result = await analyzeRustProfile(env, profile, {
267
356
  staleAgeMs: options.staleAgeMs,
@@ -308,24 +397,29 @@ async function rustItems(context, targetItem, rule) {
308
397
  */
309
398
  export async function scanProjects(context, { rules = PROJECT_RULES } = {}) {
310
399
  const { env, options } = context;
311
- const matches = await findProjectDirs(env, options.roots, {
400
+ const globs = [...new Set(rules.flatMap((rule) => rule.names))];
401
+ const items = scanItems(context);
402
+ await findProjectDirs(env, options.roots, {
312
403
  rules,
313
404
  maxDepth: options.maxDepth,
314
405
  includeRoots: options.includeRoots,
406
+ onMatches: async (matches) => {
407
+ const usages = await env.usageMany(
408
+ matches.map((match) => match.candidate.path),
409
+ { cache: true }
410
+ );
411
+ for (const match of matches) {
412
+ const usage = usages.get(match.candidate.path);
413
+ if (!usage) {
414
+ continue;
415
+ }
416
+ const item = await projectItem(context, match, usage, globs);
417
+ items.push(item);
418
+ if (match.rule.id === 'cargo-target') {
419
+ items.push(...(await rustItems(context, item, match.rule)));
420
+ }
421
+ }
422
+ },
315
423
  });
316
- const usages = await env.usageMany(matches.map((m) => m.candidate.path));
317
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
318
- const items = [];
319
- for (const match of matches) {
320
- const usage = usages.get(match.candidate.path);
321
- if (!usage) {
322
- continue;
323
- }
324
- const item = await projectItem(context, match, usage, globs);
325
- items.push(item);
326
- if (match.rule.id === 'cargo-target') {
327
- items.push(...(await rustItems(context, item, match.rule)));
328
- }
329
- }
330
424
  return items;
331
425
  }
@@ -37,7 +37,13 @@ const HASHED = /^(.+)-([0-9a-f]{16})(\..*)?$/;
37
37
  const INCREMENTAL = /^(.+)-([0-9a-z]{8,20})$/;
38
38
  const LIB_PREFIXED = new Set(['.rlib', '.rmeta', '.so', '.dylib', '.a']);
39
39
  const KIND_ORDER = ['lib', 'dylib', 'staticlib', 'bin', 'rmeta', 'other'];
40
- const NON_PROFILES = new Set(['doc', 'package', 'tmp', 'flycheck0']);
40
+ const NON_PROFILES = new Set([
41
+ 'doc',
42
+ 'package',
43
+ 'tmp',
44
+ 'flycheck0',
45
+ 'semver-checks',
46
+ ]);
41
47
  const EXECUTABLE = new Set(['', '.exe']);
42
48
  export const DEFAULT_GENERATION_MS = 10 * 60 * 1000;
43
49
  export const DEFAULT_LEAF_IDLE_MS = 3 * 60 * 60 * 1000;
@@ -1,3 +1,4 @@
1
+ import { scanItems } from '../env/scope.js';
1
2
  /**
2
3
  * System reclaimers that only the owning tool can clean correctly:
3
4
  * journald archives (`journalctl --vacuum-size`), disabled snap revisions
@@ -102,7 +103,7 @@ async function snapItems(context, isRoot) {
102
103
  return [];
103
104
  }
104
105
  const result = await env.run(['snap', 'list', '--all']);
105
- const items = [];
106
+ const items = scanItems(context);
106
107
  for (const { name, revision } of parseDisabledSnaps(result.stdout)) {
107
108
  const file = `/var/lib/snapd/snaps/${name}_${revision}.snap`;
108
109
  const stat = await env.stat(file);
@@ -163,9 +164,9 @@ async function brewItems(context, isRoot) {
163
164
  */
164
165
  export async function scanSystem(context) {
165
166
  const isRoot = await context.env.isRoot();
166
- return [
167
- ...(await journalItems(context, isRoot)),
168
- ...(await snapItems(context, isRoot)),
169
- ...(await brewItems(context, isRoot)),
170
- ];
167
+ const items = scanItems(context);
168
+ for (const scanner of [journalItems, snapItems, brewItems]) {
169
+ items.push(...(await scanner(context, isRoot)));
170
+ }
171
+ return items;
171
172
  }
@@ -1,3 +1,4 @@
1
+ import { scanItems } from '../env/scope.js';
1
2
  /**
2
3
  * Toolchain versions installed by version managers (nvm, pyenv, rbenv,
3
4
  * SDKMAN!, rustup, elan, ghcup, opam, swiftly, VS Code Server).
@@ -15,21 +16,25 @@ import {
15
16
  } from '../rules/versions.js';
16
17
  import { block, makeItem } from '../items.js';
17
18
  import { expandGlobPath, expandRulePath } from '../paths.js';
18
- import { scanTimeBusy } from './common.js';
19
+ import { scanTimeBusy, resolvePaths } from './common.js';
20
+ import { configuredPattern } from '../env/cache-roots.js';
19
21
 
20
22
  async function installDirs(env, rule, home) {
21
23
  const dirs = [];
22
24
  for (const pattern of rule.dirs) {
23
- const expanded = expandRulePath(pattern, { home, pathApi: env.path });
24
- for (const candidate of await expandGlobPath(env, expanded)) {
25
+ const expanded = expandRulePath(configuredPattern(env, home, pattern), {
26
+ home,
27
+ pathApi: env.path,
28
+ });
29
+ const candidates = await expandGlobPath(env, expanded);
30
+ const stats = await env.statMany(candidates);
31
+ for (const candidate of candidates) {
25
32
  const name = env.path.basename(candidate);
26
33
  if ((rule.exclude ?? []).includes(name)) {
27
34
  continue;
28
35
  }
29
36
  if (
30
- !(rule.installTypes ?? ['dir']).includes(
31
- (await env.stat(candidate))?.type
32
- )
37
+ !(rule.installTypes ?? ['dir']).includes(stats.get(candidate)?.type)
33
38
  ) {
34
39
  continue;
35
40
  }
@@ -80,7 +85,10 @@ function linkRefs(target) {
80
85
  */
81
86
  async function managerRefs(env, rule, home, installs) {
82
87
  const expand = (pattern) =>
83
- expandRulePath(pattern, { home, pathApi: env.path });
88
+ expandRulePath(configuredPattern(env, home, pattern), {
89
+ home,
90
+ pathApi: env.path,
91
+ });
84
92
  const refs = [];
85
93
  for (const source of rule.defaults ?? []) {
86
94
  const text = await env.readText(expand(source.path), 65536);
@@ -182,24 +190,28 @@ async function ruleItems(context, rule, home, pins) {
182
190
  return [];
183
191
  }
184
192
  const refs = [...(await managerRefs(env, rule, home, installs)), ...pins];
185
- const usages = await env.usageMany(installs);
193
+ const usages = await env.usageMany(installs, {
194
+ cache: !context.options.freshSizes,
195
+ });
196
+ await resolvePaths(context, installs);
186
197
  const newest = newestInstalls(env, rule, installs, usages);
187
198
  const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
188
- const items = [];
199
+ const items = scanItems(context);
189
200
  for (const dir of installs) {
190
201
  const version = versionOf(dir);
191
202
  if (newest.has(dir) || refs.some((ref) => versionMatches(version, ref))) {
192
203
  continue;
193
204
  }
194
- const usage = usages.get(dir);
205
+ const usage = usages.get(dir) ?? {};
195
206
  const item = makeItem(env, {
196
207
  rule: rule.id,
197
208
  kind: 'toolchain',
198
209
  ecosystem: rule.ecosystem,
199
210
  description: `${rule.description} ${version}`,
200
211
  path: dir,
201
- bytes: usage?.bytes ?? 0,
202
- newestMtimeMs: usage?.newestMtimeMs ?? 0,
212
+ bytes: usage.bytes ?? 0,
213
+ sizeEstimated: Boolean(usage.estimated),
214
+ newestMtimeMs: usage.newestMtimeMs ?? 0,
203
215
  tier: 'moderate',
204
216
  reason: `not the ${rule.manager} default, not pinned by a scanned project, not the newest`,
205
217
  checks: { busy: rule.busy ?? [], cwd: dir, mtime: true },
@@ -232,7 +244,7 @@ export async function scanVersions(context, { rules = VERSION_RULES } = {}) {
232
244
  rules,
233
245
  options.maxDepth ?? 6
234
246
  );
235
- const items = [];
247
+ const items = scanItems(context);
236
248
  for (const rule of rules) {
237
249
  for (const home of homes) {
238
250
  items.push(...(await ruleItems(context, rule, home, pins.get(rule.id))));
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
+ }