disk-space-saviour 0.17.0 → 0.19.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/src/liveness.js CHANGED
@@ -8,7 +8,9 @@
8
8
  * - a process holds a file, working directory, or executable inside the path
9
9
  * (`/proc/<pid>/{fd,cwd,exe}` on Linux, `lsof` on macOS).
10
10
  * - a command argument names a path inside the item (including scripts
11
- * whose interpreter has already closed its input descriptor).
11
+ * whose interpreter has already closed its input descriptor);
12
+ * - a directory inside the item is on a process's `PATH`, so the process
13
+ * may start a program from it at any time.
12
14
  *
13
15
  * The cleaner refreshes the probe right before acting on each item.
14
16
  */
@@ -20,6 +22,44 @@ import { formatDuration } from './units.js';
20
22
 
21
23
  const PROC_COMM_LIMIT = 15;
22
24
 
25
+ /** Bun subcommands that read or write the install cache. */
26
+ const BUN_PACKAGE_COMMANDS = new Set([
27
+ 'install',
28
+ 'i',
29
+ 'add',
30
+ 'a',
31
+ 'remove',
32
+ 'rm',
33
+ 'update',
34
+ 'upgrade',
35
+ 'outdated',
36
+ 'link',
37
+ 'unlink',
38
+ 'pm',
39
+ 'x',
40
+ 'create',
41
+ 'c',
42
+ 'init',
43
+ 'patch',
44
+ 'publish',
45
+ 'audit',
46
+ 'why',
47
+ 'info',
48
+ ]);
49
+
50
+ function isBun(proc) {
51
+ return Boolean(matchingName(proc, ['bun', 'bunx']));
52
+ }
53
+
54
+ function bunPackageCommand(proc) {
55
+ const args = processArgs(proc);
56
+ if (path.posix.basename(args[0] ?? '') === 'bunx' || proc.name === 'bunx') {
57
+ return true;
58
+ }
59
+ const command = args.slice(1).find((arg) => !arg.startsWith('-'));
60
+ return BUN_PACKAGE_COMMANDS.has(command);
61
+ }
62
+
23
63
  function uncertaintyRoots(proc) {
24
64
  const roots = processArgs(proc).flatMap((arg) => {
25
65
  const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
@@ -182,6 +222,7 @@ export class LivenessProbe {
182
222
  const processes = await this.env.processes().catch(() => null);
183
223
  this.listed = processes !== null;
184
224
  this.processes = processes ?? [];
225
+ this.bunCacheUsers = await this.#bunCacheUsers();
185
226
  this.probeError =
186
227
  (processes === null || this.openPaths === null) &&
187
228
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
@@ -191,6 +232,49 @@ export class LivenessProbe {
191
232
  this.pathOwners.clear();
192
233
  }
193
234
 
235
+ /**
236
+ * Pids of Bun processes that may use the install cache: package commands,
237
+ * and anything else (scripts, tests, the REPL) without a `node_modules`
238
+ * directory above its working directory, because Bun then auto-installs
239
+ * from the cache. A long-running script of an installed project does not
240
+ * read the cache, so it does not keep the cache busy.
241
+ */
242
+ async #bunCacheUsers() {
243
+ const users = new Set();
244
+ const installed = new Map();
245
+ const hasModules = async (dir) => {
246
+ if (!installed.has(dir)) {
247
+ installed.set(
248
+ dir,
249
+ Boolean(
250
+ await this.env.exists?.(this.env.path.join(dir, 'node_modules'))
251
+ )
252
+ );
253
+ }
254
+ return installed.get(dir);
255
+ };
256
+ for (const proc of this.processes) {
257
+ if (!isBun(proc)) {
258
+ continue;
259
+ }
260
+ if (bunPackageCommand(proc) || !proc.cwd || !this.env.exists) {
261
+ users.add(proc.pid);
262
+ continue;
263
+ }
264
+ let found = false;
265
+ for (const dir of [proc.cwd, ...ancestors(proc.cwd, this.env.path)]) {
266
+ if (await hasModules(dir)) {
267
+ found = true;
268
+ break;
269
+ }
270
+ }
271
+ if (!found) {
272
+ users.add(proc.pid);
273
+ }
274
+ }
275
+ return users;
276
+ }
277
+
194
278
  /** Processes of the last refresh, or null when they could not be listed. */
195
279
  processList() {
196
280
  return this.listed ? this.processes : null;
@@ -269,6 +353,13 @@ export class LivenessProbe {
269
353
  ) {
270
354
  continue;
271
355
  }
356
+ if (
357
+ item.rule === 'bun-cache' &&
358
+ isBun(proc) &&
359
+ !this.bunCacheUsers?.has(proc.pid)
360
+ ) {
361
+ continue;
362
+ }
272
363
  const name = matchingName(proc, names);
273
364
  if (!name) {
274
365
  continue;
@@ -291,10 +382,7 @@ export class LivenessProbe {
291
382
  return null;
292
383
  }
293
384
  const pathApi = this.env.path;
294
- const targets = paths.flatMap((target) => {
295
- const real = this.realPaths.get(target);
296
- return real && real !== target ? [target, real] : [target];
297
- });
385
+ const targets = this.withRealPaths(paths);
298
386
  for (const open of this.openPaths) {
299
387
  for (const target of targets) {
300
388
  if (isWithin(open, target, pathApi)) {
@@ -340,6 +428,26 @@ export class LivenessProbe {
340
428
  return null;
341
429
  }
342
430
 
431
+ /** `paths` plus their resolved forms, as processes report paths. */
432
+ withRealPaths(paths) {
433
+ return paths.flatMap((target) => {
434
+ const real = this.realPaths.get(target);
435
+ return real && real !== target ? [target, real] : [target];
436
+ });
437
+ }
438
+
439
+ searchPathUsage(paths) {
440
+ const targets = this.withRealPaths(paths);
441
+ for (const proc of this.processes) {
442
+ for (const dir of proc.searchPath ?? []) {
443
+ if (targets.some((target) => isWithin(dir, target, this.env.path))) {
444
+ return `on the PATH of ${proc.name || 'a process'} (pid ${proc.pid}): ${dir}`;
445
+ }
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+
343
451
  uncertainUsage(paths) {
344
452
  for (const proc of this.env.unreadableProcesses ?? []) {
345
453
  const args = processArgs(proc);
@@ -399,14 +507,28 @@ export class LivenessProbe {
399
507
  const paths = checks.perPath ? [] : (item.paths ?? []);
400
508
  return (
401
509
  this.installationBlocker(item.paths ?? []) ??
402
- (this.probeError
403
- ? `cannot verify process and open-file activity${this.env.probeDiagnostic ? `: ${this.env.probeDiagnostic}` : ''}`
404
- : null) ??
510
+ this.probeBlocker() ??
405
511
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
406
512
  this.runningTool(checks.busy, checks.cwd, item) ??
513
+ this.processUsage(paths) ??
514
+ this.uncertainUsage(item.paths ?? [])
515
+ );
516
+ }
517
+
518
+ probeBlocker() {
519
+ if (!this.probeError) {
520
+ return null;
521
+ }
522
+ const diagnostic = this.env.probeDiagnostic;
523
+ return `cannot verify process and open-file activity${diagnostic ? `: ${diagnostic}` : ''}`;
524
+ }
525
+
526
+ /** Open files, command arguments and PATH entries inside the paths. */
527
+ processUsage(paths) {
528
+ return (
407
529
  this.openInside(paths) ??
408
530
  this.commandUsage(paths) ??
409
- this.uncertainUsage(item.paths ?? [])
531
+ this.searchPathUsage(paths)
410
532
  );
411
533
  }
412
534
 
package/src/report.js CHANGED
@@ -305,6 +305,52 @@ function errorsSection(errors) {
305
305
  ];
306
306
  }
307
307
 
308
+ const TIMING_LIMIT = 5;
309
+
310
+ function phaseList(phases) {
311
+ return phases
312
+ .map(
313
+ (phase) =>
314
+ `${phase.phase} ${(phase.ms / 1000).toFixed(1)}s${phase.count > 1 ? ` ×${phase.count}` : ''}`
315
+ )
316
+ .join(', ');
317
+ }
318
+
319
+ /**
320
+ * Slowest cleanup phases and items. `audit.timings` is a live PhaseTimer
321
+ * while cleaning and a plain array once read back from JSON.
322
+ */
323
+ function auditTimingLines(audit, { verbose }) {
324
+ const phases = audit.timings?.toJSON?.() ?? audit.timings ?? [];
325
+ const items = audit.entries
326
+ .filter((entry) => entry.durationMs > 0)
327
+ .sort((a, b) => b.durationMs - a.durationMs);
328
+ const limit = verbose ? Infinity : TIMING_LIMIT;
329
+ const lines = [];
330
+ if (phases.length > 0) {
331
+ lines.push(` slowest phases: ${phaseList(phases.slice(0, limit))}`);
332
+ }
333
+ for (const entry of items.slice(0, limit)) {
334
+ lines.push(
335
+ ` ${(entry.durationMs / 1000).toFixed(1)}s ${entry.rule} ${entry.path ?? entry.description}`
336
+ );
337
+ }
338
+ return lines.length > 0 ? ['', 'TIMINGS', ...lines] : [];
339
+ }
340
+
341
+ /**
342
+ * The slowest phases of each environment, so bottlenecks are visible
343
+ * without debug flags.
344
+ */
345
+ function timingLines(report, { verbose }) {
346
+ return report.environments
347
+ .filter((env) => env.timings?.length > 0)
348
+ .map((env) => {
349
+ const phases = verbose ? env.timings : env.timings.slice(0, TIMING_LIMIT);
350
+ return ` ${env.label} slowest phases: ${phaseList(phases)}`;
351
+ });
352
+ }
353
+
308
354
  /**
309
355
  * Renders a scan report.
310
356
  * @param {object} report
@@ -352,6 +398,10 @@ export function formatReport(report, options = {}) {
352
398
  lines.push(...dockerSection(report));
353
399
  lines.push(...errorsSection(report.errors));
354
400
  lines.push(...totalsSection(report));
401
+ const timings = timingLines(report, options);
402
+ if (timings.length > 0) {
403
+ lines.push('', 'TIMINGS', ...timings);
404
+ }
355
405
  return lines.join('\n');
356
406
  }
357
407
 
@@ -445,6 +495,7 @@ export function formatAudit(audit, options = {}) {
445
495
  }
446
496
  lines.push('', 'Per environment:', ...environmentLines(audit.environments));
447
497
  lines.push(...healthLines(audit.health));
498
+ lines.push(...auditTimingLines(audit, options));
448
499
  lines.push(
449
500
  '',
450
501
  audit.dryRun
package/src/rules/apps.js CHANGED
@@ -167,9 +167,13 @@ export function expandChromiumCaches(rules) {
167
167
  CACHE_LEAVES.map((leaf) => `${p}/${leaf}`)
168
168
  ),
169
169
  ...roots.flatMap((p) =>
170
- ['GrShaderCache', 'ShaderCache', 'component_crx_cache'].map(
171
- (leaf) => `${p}/${leaf}`
172
- )
170
+ [
171
+ 'GrShaderCache',
172
+ 'ShaderCache',
173
+ 'GraphiteDawnCache',
174
+ 'component_crx_cache',
175
+ 'extensions_crx_cache',
176
+ ].map((leaf) => `${p}/${leaf}`)
173
177
  ),
174
178
  ]),
175
179
  ],
@@ -0,0 +1,175 @@
1
+ import { CACHE_LEAVES } from './apps.js';
2
+
3
+ /**
4
+ * Files an application downloads or stages for itself and fetches again on
5
+ * demand: staged updates, language servers, toolchain archives, app bundle
6
+ * clones and caches outside the usual cache folders. Installed applications
7
+ * and the data they read are never matched.
8
+ */
9
+ const openaiProfiles = [
10
+ '~/Library/Caches/com.openai.atlas/browser-data/host/*',
11
+ '~/Library/Caches/Codex/*',
12
+ ];
13
+ const zedRoots = ['~/Library/Application Support/Zed', '~/.local/share/zed'];
14
+
15
+ export const DOWNLOAD_RULES = [
16
+ {
17
+ id: 'chromium-code-sign-clones',
18
+ ecosystem: 'browsers',
19
+ scope: 'tmp',
20
+ description:
21
+ 'Chromium app bundle clones left by exited browsers (an update makes them full copies)',
22
+ // Chrome clones its bundle into the per-user `X` directory next to
23
+ // `$TMPDIR` at launch and removes the clone when it exits. A running
24
+ // browser maps the executables of its clone, so the open-file check
25
+ // keeps every clone in use: a browser name would block orphans forever.
26
+ paths: ['{TMP}/../X/*.code_sign_clone/code_sign_clone.*'],
27
+ directoryOnly: true,
28
+ minAge: 'stale',
29
+ busy: [],
30
+ },
31
+ {
32
+ id: 'chrome-on-device-models',
33
+ ecosystem: 'browsers',
34
+ description: 'superseded Chrome on-device AI model versions',
35
+ paths: [
36
+ '~/Library/Application Support/Google/Chrome/OptGuideOnDeviceModel/*',
37
+ '~/.config/google-chrome/OptGuideOnDeviceModel/*',
38
+ '{LOCALAPPDATA}/Google/Chrome/User Data/OptGuideOnDeviceModel/*',
39
+ ],
40
+ versionPattern: /^(?<version>\d+(?:\.\d+){1,3})$/,
41
+ minAge: 'stale',
42
+ busy: [],
43
+ },
44
+ {
45
+ id: 'google-updater-downloads',
46
+ ecosystem: 'system',
47
+ description: 'Google Updater downloaded installers',
48
+ paths: [
49
+ '~/Library/Application Support/Google/GoogleUpdater/crx_cache',
50
+ '{LOCALAPPDATA}/Google/GoogleUpdater/crx_cache',
51
+ ],
52
+ minAge: 'stale',
53
+ busy: ['GoogleUpdater'],
54
+ },
55
+ {
56
+ id: 'openai-app-caches',
57
+ ecosystem: 'apps',
58
+ description: 'ChatGPT Atlas and Codex app browser caches',
59
+ paths: openaiProfiles.flatMap((profile) =>
60
+ CACHE_LEAVES.map((leaf) => `${profile}/${leaf}`)
61
+ ),
62
+ busy: ['ChatGPT Atlas', 'Codex'],
63
+ },
64
+ {
65
+ id: 'electron-downloads',
66
+ ecosystem: 'javascript',
67
+ description: 'Electron and electron-builder downloaded archives',
68
+ paths: [
69
+ '~/Library/Caches/electron',
70
+ '~/Library/Caches/electron-builder',
71
+ '~/.cache/electron',
72
+ '~/.cache/electron-builder',
73
+ '{LOCALAPPDATA}/electron/Cache',
74
+ '{LOCALAPPDATA}/electron-builder/Cache',
75
+ ],
76
+ minAge: 'stale',
77
+ busy: ['electron-builder', 'electron-forge'],
78
+ },
79
+ {
80
+ id: 'bun-transpiler-cache',
81
+ ecosystem: 'javascript',
82
+ description: 'Bun runtime transpiler cache',
83
+ // Bun validates every entry against its source and transpiles again on
84
+ // a miss, so only open files hold the cache back.
85
+ paths: ['~/Library/Caches/bun/@t@', '~/.cache/bun/@t@'],
86
+ busy: [],
87
+ },
88
+ {
89
+ id: 'docker-desktop-update-downloads',
90
+ ecosystem: 'apps',
91
+ description: 'Docker Desktop updates downloaded but not installed',
92
+ paths: ['~/Library/Application Support/com.docker.install/in_progress'],
93
+ minAge: 'stale',
94
+ busy: ['Docker Desktop', 'com.docker.backend', 'Docker'],
95
+ },
96
+ {
97
+ id: 'docker-desktop-update-images',
98
+ ecosystem: 'apps',
99
+ scope: 'tmp',
100
+ description: 'Docker Desktop update disk images',
101
+ paths: ['{TMP}/DockerDesktopUpdates/*'],
102
+ minAge: 'stale',
103
+ busy: ['Docker Desktop', 'com.docker.backend', 'Docker'],
104
+ },
105
+ {
106
+ id: 'warp-staged-updates',
107
+ ecosystem: 'apps',
108
+ description: 'Warp updates staged for the next restart',
109
+ paths: ['~/Library/Application Support/dev.warp.Warp-*/autoupdate/*'],
110
+ minAge: 'stale',
111
+ // Warp applies the staged bundle when it quits, so it stays while Warp
112
+ // runs. The process names are those of the Stable and Preview builds.
113
+ busy: ['stable', 'preview', 'Warp'],
114
+ },
115
+ {
116
+ id: 'zed-downloads',
117
+ ecosystem: 'ide',
118
+ description: 'Zed downloaded Node.js, language servers and Copilot',
119
+ paths: zedRoots.flatMap((root) =>
120
+ ['node', 'languages', 'copilot'].map((leaf) => `${root}/${leaf}`)
121
+ ),
122
+ busy: ['zed', 'Zed', 'zed-editor'],
123
+ },
124
+ {
125
+ id: 'tree-sitter-downloads',
126
+ ecosystem: 'c-cpp',
127
+ description: 'tree-sitter CLI downloaded WASI SDK, Binaryen and parsers',
128
+ paths: ['~/.cache/tree-sitter', '~/Library/Caches/tree-sitter'],
129
+ busy: ['tree-sitter'],
130
+ },
131
+ {
132
+ id: 'codex-plugin-staging',
133
+ ecosystem: 'agents',
134
+ description: 'abandoned Codex plugin and marketplace install staging',
135
+ paths: [
136
+ '~/.codex/plugins/..plugin-appserver.staging-*',
137
+ '~/.codex/plugins/.marketplace-plugin-source-staging/*',
138
+ '~/.codex/plugins/.remote-plugin-install-staging/*',
139
+ '~/.codex/.tmp/marketplaces/.staging/*',
140
+ ],
141
+ // The Codex daemon runs for days; an install in progress writes to its
142
+ // staging directory, which the activity window and open files catch.
143
+ minAge: 'stale',
144
+ },
145
+ {
146
+ id: 'clang-module-cache',
147
+ ecosystem: 'c-cpp',
148
+ description: 'Clang and Swift implicit module cache',
149
+ scope: 'tmp',
150
+ paths: [
151
+ '{TMP}/../C/clang/ModuleCache',
152
+ '{TMP}/../C/org.llvm.clang*/ModuleCache',
153
+ ],
154
+ // A build in another directory still reads the cache, which the
155
+ // activity window and open files catch.
156
+ minAge: 'stale',
157
+ busy: ['clang', 'clang++', 'swift-frontend', 'swiftc', 'xcodebuild'],
158
+ },
159
+ {
160
+ id: 'crashpad-reports',
161
+ ecosystem: 'system',
162
+ description: 'Chromium and Electron app crash reports already processed',
163
+ tier: 'moderate',
164
+ aggregate: true,
165
+ paths: [
166
+ '~/Library/Application Support/*/Crashpad/completed/*',
167
+ ...['Google', 'Yandex', 'BraveSoftware'].map(
168
+ (vendor) =>
169
+ `~/Library/Application Support/${vendor}/*/Crashpad/completed/*`
170
+ ),
171
+ '~/.config/*/Crashpad/completed/*',
172
+ ],
173
+ minAge: 'stale',
174
+ },
175
+ ];
@@ -8,9 +8,8 @@
8
8
  *
9
9
  * `projects` are directories inside project trees, recognised by name plus a
10
10
  * marker file next to them (`markers`), inside them (`selfMarkers`), or next
11
- * to their parent (`parentMarkers`, with `parentName`). Kind `cache` is always
12
- * `safe`; `build` and `dependencies` are `moderate` after the project has been
13
- * inactive for the configured number of days and `aggressive` otherwise.
11
+ * to their parent (`parentMarkers`, with `parentName`). All kinds (`cache`,
12
+ * `build`, `dependencies`) are `safe` whenever nothing is using them.
14
13
  *
15
14
  * `busy` lists process names that, when running (inside the project for
16
15
  * project directories), mean the tool may be using the directory right now.
@@ -71,7 +70,9 @@ export const ECOSYSTEMS = [
71
70
  description: 'Bun install cache',
72
71
  paths: ['~/.bun/install/cache'],
73
72
  native: { tool: 'bun', argv: ['bun', 'pm', 'cache', 'rm'] },
74
- busy: ['bun'],
73
+ // Only package commands and auto-installing scripts read the cache
74
+ // (see LivenessProbe.refresh).
75
+ busy: ['bun', 'bunx'],
75
76
  },
76
77
  {
77
78
  id: 'deno-cache',
@@ -107,6 +107,9 @@ export const OTHER_RULES = [
107
107
  '~/Library/Application Support/Code/Cache',
108
108
  '~/Library/Application Support/Code/CachedData',
109
109
  '~/Library/Application Support/Code/CachedExtensionVSIXs',
110
+ '~/Library/Application Support/Code/WebStorage/*/CacheStorage',
111
+ '~/.config/Code/WebStorage/*/CacheStorage',
112
+ '{APPDATA}/Code/WebStorage/*/CacheStorage',
110
113
  '~/.vscode-server/data/CachedExtensionVSIXs',
111
114
  '~/.vscode-server/data/logs',
112
115
  '~/.cursor-server/data/CachedExtensionVSIXs',
@@ -349,8 +352,11 @@ export const OTHER_RULES = [
349
352
  ecosystem: 'agents',
350
353
  description: 'abandoned Codex runtime install staging directories',
351
354
  paths: ['~/.cache/codex-runtimes/codex-runtime-install-*'],
355
+ // The Codex daemon runs for days, so a running `codex` says nothing
356
+ // about a staging directory: an install in progress writes to it and
357
+ // holds its files open, which the activity window and open-file checks
358
+ // catch.
352
359
  minAge: 'stale',
353
- busy: ['codex'],
354
360
  },
355
361
  {
356
362
  id: 'claude-cli-mcp-logs',
@@ -6,6 +6,11 @@
6
6
  * - the newest installed version,
7
7
  * - in use by a running process (checked by the liveness layer).
8
8
  *
9
+ * `refsPickNewest` makes a partial ref keep only the newest install it
10
+ * matches (nvm runs the newest 20.x for `20`); otherwise it keeps them all.
11
+ * `globalPackages` blocks versions holding packages the user installed
12
+ * globally, which reinstalling the version would not bring back.
13
+ *
9
14
  * `versionOf(dirPath)` maps an install directory to the version string that
10
15
  * defaults and pin files are compared with (see `versionMatches`).
11
16
  */
@@ -112,6 +117,16 @@ export const VERSION_RULES = [
112
117
  busy: ['claude'],
113
118
  recheck: true,
114
119
  },
120
+ {
121
+ id: 'codex-daemon-releases',
122
+ ecosystem: 'agents',
123
+ manager: 'Codex',
124
+ description: 'Codex app-server daemon release',
125
+ dirs: ['~/.codex/packages/app-server-daemon/releases/*'],
126
+ defaultLinks: ['~/.codex/packages/app-server-daemon/current'],
127
+ busy: ['codex'],
128
+ recheck: true,
129
+ },
115
130
  {
116
131
  id: 'copilot-cli-versions',
117
132
  ecosystem: 'agents',
@@ -133,6 +148,14 @@ export const VERSION_RULES = [
133
148
  aliasDir: '~/.nvm/alias',
134
149
  defaults: [{ path: '~/.nvm/alias/default', parse: firstLines }],
135
150
  projectFiles: { '.nvmrc': firstLines, '.node-version': firstLines },
151
+ refsPickNewest: true,
152
+ globalPackages: {
153
+ dir: 'lib/node_modules',
154
+ // Bundled with Node.js, or a use-m cache (`<name>-v-<version>`) that
155
+ // use-m installs again on first use.
156
+ regenerable: (name) =>
157
+ name === 'npm' || name === 'corepack' || /^[^@][^/]*-v-./.test(name),
158
+ },
136
159
  busy: ['node'],
137
160
  },
138
161
  {
package/src/scan.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  ScanIndex,
33
33
  scopeKey,
34
34
  } from './scan-state.js';
35
+ import { PhaseTimer, timed } from './timings.js';
35
36
 
36
37
  export const SCANNERS = {
37
38
  projects: scanProjects,
@@ -266,6 +267,12 @@ export async function scanEnvironment(env, options, extra = {}) {
266
267
  }
267
268
  }
268
269
 
270
+ /** Phase timings of an environment, started on its first scan. */
271
+ function environmentTimings(env) {
272
+ env.timings ??= new PhaseTimer();
273
+ return env.timings;
274
+ }
275
+
269
276
  async function scanEnvironmentScoped(
270
277
  env,
271
278
  options,
@@ -275,11 +282,14 @@ async function scanEnvironmentScoped(
275
282
  stage
276
283
  ) {
277
284
  const errors = extra.errors ?? [];
278
- const context = await environmentContext(env, options, extra);
285
+ const context = await environmentTimings(env).measure('context', () =>
286
+ environmentContext(env, options, extra)
287
+ );
288
+ context.timings = env.timings;
279
289
  context.emit = (item) => found.set(item.id, item);
280
290
  let stopped = false;
281
291
  const ready = async (items) => {
282
- await attributeStorage(env, items);
292
+ await env.timings.measure('storage', () => attributeStorage(env, items));
283
293
  for (const item of items) {
284
294
  for (const target of item.paths) {
285
295
  block(item, env.scanPolicy.removalReason(target));
@@ -324,7 +334,9 @@ async function scanEnvironmentScoped(
324
334
  const started = Date.now();
325
335
  const previous = new Set(found.keys());
326
336
  try {
327
- const entries = await scanner(context);
337
+ const entries = await env.timings.measure(`scanner:${name}`, () =>
338
+ scanner(context)
339
+ );
328
340
  for (const item of entries) {
329
341
  context.emit(item);
330
342
  }
@@ -480,6 +492,7 @@ function envDescriptor(env, depth, chain) {
480
492
  hint: env.probeHint ?? null,
481
493
  scanStatus: env.scanStatus ?? null,
482
494
  layerSource: env.writableLayer?.source ?? null,
495
+ timings: env.timings?.toJSON() ?? [],
483
496
  };
484
497
  }
485
498
 
@@ -590,6 +603,7 @@ async function scanScoped(input) {
590
603
  resetWritableLayers(env.executor);
591
604
  env.writableLayer = null;
592
605
  env.scanPolicy = null;
606
+ env.timings = new PhaseTimer();
593
607
  await configureScanPolicy(env, options);
594
608
  await configureContainerHost(
595
609
  env,
@@ -603,7 +617,9 @@ async function scanScoped(input) {
603
617
  const docker =
604
618
  env.scanStatus === 'goal-reached'
605
619
  ? null
606
- : await dockerSection(env, options, errors);
620
+ : await timed(env.timings, 'docker', () =>
621
+ dockerSection(env, options, errors)
622
+ );
607
623
  blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
608
624
  const items = filterItems(
609
625
  [...hostItems, ...(docker?.items ?? [])],
@@ -615,7 +631,10 @@ async function scanScoped(input) {
615
631
  for (const inner of docker?.environments ?? []) {
616
632
  environments.push(inner);
617
633
  }
618
- environments[0] = await withDisk(env, environments[0]);
634
+ environments[0] = await timed(env.timings, 'disk', () =>
635
+ withDisk(env, environments[0])
636
+ );
637
+ environments[0].timings = env.timings.toJSON();
619
638
  const report = buildReport({
620
639
  options,
621
640
  env,