disk-space-saviour 0.16.0 → 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.
@@ -65,6 +65,14 @@ function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
65
65
  * Pairs every found directory with the first rule it satisfies (or null).
66
66
  */
67
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
+ }));
68
76
  const options = found.map((candidate) => ({
69
77
  candidate,
70
78
  rules: rules.filter((rule) =>
@@ -116,6 +124,11 @@ async function rootCandidates(env, roots) {
116
124
  return candidates;
117
125
  }
118
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
+
119
132
  /**
120
133
  * Finds every rule match below the roots.
121
134
  * @returns {Promise<Array<{candidate: object, rule: object}>>}
@@ -125,18 +138,34 @@ export async function findProjectDirs(env, roots, options = {}) {
125
138
  const maxDepth = options.maxDepth ?? 6;
126
139
  const globs = [...new Set(rules.flatMap((rule) => rule.names))];
127
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));
128
152
  if (options.includeRoots) {
129
153
  const self = await matchRules(env, await rootCandidates(env, roots), rules);
130
- matches.push(...self.filter((match) => match.rule));
154
+ const accepted = self.map(accept).filter(Boolean);
155
+ await options.onMatches?.(accepted);
131
156
  }
132
157
  let frontier = new Map([[maxDepth, roots]]);
133
158
  while (frontier.size > 0) {
134
159
  const next = new Map();
135
160
  for (const [depth, dirs] of frontier) {
136
161
  const found = await env.findDirs(dirs, { globs, maxDepth: depth });
162
+ const accepted = [];
137
163
  for (const match of await matchRules(env, found, rules)) {
138
164
  if (match.rule) {
139
- matches.push(match);
165
+ const fresh = accept(match);
166
+ if (fresh) {
167
+ accepted.push(fresh);
168
+ }
140
169
  continue;
141
170
  }
142
171
  const remaining = depth - depthBelow(env, dirs, match.candidate.path);
@@ -147,6 +176,7 @@ export async function findProjectDirs(env, roots, options = {}) {
147
176
  ]);
148
177
  }
149
178
  }
179
+ await options.onMatches?.(accepted);
150
180
  }
151
181
  frontier = next;
152
182
  }
@@ -180,6 +210,29 @@ function projectTier(context, rule, lastActivityMs) {
180
210
  };
181
211
  }
182
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
+
183
236
  async function addBlockers(context, item, requireClean) {
184
237
  for (const reason of await context.git.pathBlockers(item.path, {
185
238
  requireClean,
@@ -198,12 +251,21 @@ async function projectActivity(context, project, candidate, usage, globs) {
198
251
  const lastCommitMs = repo ? (await git.repoState(repo)).lastCommitMs : 0;
199
252
  context.projectUsage ??= new Map();
200
253
  if (!context.projectUsage.has(project)) {
201
- 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
+ );
202
264
  }
203
265
  const projectUsage = context.projectUsage.get(project);
204
266
  return Math.max(
205
267
  usage?.newestMtimeMs ?? 0,
206
- projectUsage?.newestMtimeMs ?? 0,
268
+ projectUsage ?? 0,
207
269
  siblingActivity(candidate, globs),
208
270
  lastCommitMs
209
271
  );
@@ -228,6 +290,7 @@ async function projectItem(context, { candidate, rule }, usage, globs) {
228
290
  description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
229
291
  path: candidate.path,
230
292
  bytes: usage?.bytes ?? 0,
293
+ sizeEstimated: Boolean(usage?.estimated),
231
294
  newestMtimeMs: usage?.newestMtimeMs ?? 0,
232
295
  lastActivityMs,
233
296
  project,
@@ -264,7 +327,7 @@ async function rustItems(context, targetItem, rule) {
264
327
  const { env, liveness, options, now } = context;
265
328
  const items = scanItems(context);
266
329
  const semverPath = env.path.join(targetItem.path, 'semver-checks');
267
- const semverUsage = await env.usage(semverPath);
330
+ const semverUsage = await env.usage(semverPath, { cache: true });
268
331
  if (semverUsage) {
269
332
  const semver = makeItem(env, {
270
333
  rule: 'cargo-semver-checks-cache',
@@ -273,6 +336,7 @@ async function rustItems(context, targetItem, rule) {
273
336
  description: 'cargo-semver-checks baseline builds',
274
337
  path: semverPath,
275
338
  bytes: semverUsage.bytes,
339
+ sizeEstimated: Boolean(semverUsage.estimated),
276
340
  newestMtimeMs: semverUsage.newestMtimeMs,
277
341
  project: targetItem.project,
278
342
  parentId: targetItem.id,
@@ -333,24 +397,29 @@ async function rustItems(context, targetItem, rule) {
333
397
  */
334
398
  export async function scanProjects(context, { rules = PROJECT_RULES } = {}) {
335
399
  const { env, options } = context;
336
- 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, {
337
403
  rules,
338
404
  maxDepth: options.maxDepth,
339
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
+ },
340
423
  });
341
- const usages = await env.usageMany(matches.map((m) => m.candidate.path));
342
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
343
- const items = scanItems(context);
344
- for (const match of matches) {
345
- const usage = usages.get(match.candidate.path);
346
- if (!usage) {
347
- continue;
348
- }
349
- const item = await projectItem(context, match, usage, globs);
350
- items.push(item);
351
- if (match.rule.id === 'cargo-target') {
352
- items.push(...(await rustItems(context, item, match.rule)));
353
- }
354
- }
355
424
  return items;
356
425
  }
@@ -190,7 +190,9 @@ async function ruleItems(context, rule, home, pins) {
190
190
  return [];
191
191
  }
192
192
  const refs = [...(await managerRefs(env, rule, home, installs)), ...pins];
193
- const usages = await env.usageMany(installs);
193
+ const usages = await env.usageMany(installs, {
194
+ cache: !context.options.freshSizes,
195
+ });
194
196
  await resolvePaths(context, installs);
195
197
  const newest = newestInstalls(env, rule, installs, usages);
196
198
  const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
@@ -200,15 +202,16 @@ async function ruleItems(context, rule, home, pins) {
200
202
  if (newest.has(dir) || refs.some((ref) => versionMatches(version, ref))) {
201
203
  continue;
202
204
  }
203
- const usage = usages.get(dir);
205
+ const usage = usages.get(dir) ?? {};
204
206
  const item = makeItem(env, {
205
207
  rule: rule.id,
206
208
  kind: 'toolchain',
207
209
  ecosystem: rule.ecosystem,
208
210
  description: `${rule.description} ${version}`,
209
211
  path: dir,
210
- bytes: usage?.bytes ?? 0,
211
- newestMtimeMs: usage?.newestMtimeMs ?? 0,
212
+ bytes: usage.bytes ?? 0,
213
+ sizeEstimated: Boolean(usage.estimated),
214
+ newestMtimeMs: usage.newestMtimeMs ?? 0,
212
215
  tier: 'moderate',
213
216
  reason: `not the ${rule.manager} default, not pinned by a scanned project, not the newest`,
214
217
  checks: { busy: rule.busy ?? [], cwd: dir, mtime: true },
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
+ }