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.
- package/CHANGELOG.md +18 -0
- package/README.md +67 -14
- package/bin/dss.js +5 -1
- package/package.json +1 -1
- package/src/clean.js +45 -13
- package/src/cli.js +80 -14
- package/src/docker/scan.js +1 -0
- package/src/emergency.js +41 -6
- package/src/env/local.js +270 -65
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +10 -1
- package/src/fast-clean.js +39 -0
- package/src/git.js +101 -39
- package/src/health.js +3 -3
- package/src/index.d.ts +28 -1
- package/src/liveness.js +131 -9
- package/src/options.js +3 -1
- package/src/report.js +69 -2
- package/src/rules/ecosystems.js +5 -4
- package/src/rules/other.js +4 -1
- package/src/rules/versions.js +23 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +157 -22
- package/src/scanners/global.js +46 -11
- package/src/scanners/projects.js +94 -69
- package/src/scanners/versions.js +109 -7
- package/src/swap.js +69 -0
- package/src/timings.js +54 -0
package/src/scanners/projects.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
}
|
package/src/scanners/versions.js
CHANGED
|
@@ -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 =
|
|
193
|
-
|
|
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) ||
|
|
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
|
|
211
|
-
|
|
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
|
+
}
|