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.
@@ -0,0 +1,239 @@
1
+ /** Advisory discovery/size index and authenticated, short-lived scan plans. */
2
+ import {
3
+ createHash,
4
+ createHmac,
5
+ randomBytes,
6
+ timingSafeEqual,
7
+ } from 'node:crypto';
8
+ import { promises as fs } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { trace } from './exec.js';
12
+
13
+ export const REPORT_MAX_AGE = 30 * 60 * 1000;
14
+ export const SIZE_MAX_AGE = 60 * 60 * 1000;
15
+ const LIMIT = 20000;
16
+
17
+ export function defaultStateDirectory(vars = process.env) {
18
+ return path.join(
19
+ vars.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
20
+ 'disk-space-saviour'
21
+ );
22
+ }
23
+
24
+ function payload(report) {
25
+ const { authentication, ...fields } = report;
26
+ void authentication;
27
+ return JSON.stringify(fields);
28
+ }
29
+
30
+ async function secret(directory) {
31
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
32
+ const file = path.join(directory, 'scan-key');
33
+ try {
34
+ await fs.writeFile(file, randomBytes(32), { flag: 'wx', mode: 0o600 });
35
+ } catch (error) {
36
+ if (error.code !== 'EEXIST') {
37
+ throw error;
38
+ }
39
+ }
40
+ return fs.readFile(file);
41
+ }
42
+
43
+ export async function authenticateReport(report, directory) {
44
+ if (!directory) {
45
+ return report;
46
+ }
47
+ try {
48
+ report.authentication = {
49
+ hostname: os.hostname(),
50
+ uid: process.getuid?.() ?? os.userInfo().username,
51
+ digest: createHmac('sha256', await secret(directory))
52
+ .update(payload(report))
53
+ .digest('hex'),
54
+ };
55
+ } catch (error) {
56
+ trace('scan state unavailable', error.message);
57
+ }
58
+ return report;
59
+ }
60
+
61
+ function recentLocalReport(report, now) {
62
+ const age = now - Date.parse(report.createdAt);
63
+ const auth = report.authentication;
64
+ if (
65
+ !Number.isFinite(age) ||
66
+ age < 0 ||
67
+ age > REPORT_MAX_AGE ||
68
+ report.aborted ||
69
+ auth?.hostname !== os.hostname() ||
70
+ auth?.uid !== (process.getuid?.() ?? os.userInfo().username)
71
+ ) {
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ export async function authenticatedReport(report, directory, now = Date.now()) {
78
+ if (
79
+ !directory ||
80
+ report?.schema !== 1 ||
81
+ report.tool !== 'disk-space-saviour' ||
82
+ !recentLocalReport(report, now)
83
+ ) {
84
+ return false;
85
+ }
86
+ try {
87
+ const key = await fs.readFile(path.join(directory, 'scan-key'));
88
+ const expected = createHmac('sha256', key).update(payload(report)).digest();
89
+ const actual = Buffer.from(report.authentication.digest, 'hex');
90
+ return (
91
+ expected.length === actual.length && timingSafeEqual(expected, actual)
92
+ );
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ export function scopeKey(options) {
99
+ const names = [
100
+ 'roots',
101
+ 'scanners',
102
+ 'host',
103
+ 'docker',
104
+ 'dockerDepth',
105
+ 'maxDepth',
106
+ 'staleAgeMs',
107
+ 'inactiveMs',
108
+ 'minSizeBytes',
109
+ 'only',
110
+ 'exclude',
111
+ 'includeVolumes',
112
+ 'noNative',
113
+ 'journalKeepBytes',
114
+ 'containerFilter',
115
+ 'removeStoppedContainers',
116
+ 'investigationMaxAgeMs',
117
+ 'removeContainers',
118
+ 'removeImages',
119
+ 'allowDirtyRepos',
120
+ 'allowDirtyContainers',
121
+ ];
122
+ return createHash('sha256')
123
+ .update(JSON.stringify(names.map((name) => [name, options[name]])))
124
+ .digest('hex');
125
+ }
126
+
127
+ export async function saveScan(report, directory) {
128
+ if (
129
+ !directory ||
130
+ !report.authentication ||
131
+ report.aborted ||
132
+ report.environments.some((env) => env.scanStatus === 'goal-reached')
133
+ ) {
134
+ return;
135
+ }
136
+ try {
137
+ const dir = path.join(directory, 'scans');
138
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
139
+ const file = path.join(dir, `${scopeKey(report.options)}.json`);
140
+ const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}`;
141
+ await fs.writeFile(temporary, JSON.stringify(report), { mode: 0o600 });
142
+ await fs.rename(temporary, file);
143
+ } catch (error) {
144
+ trace('cannot save scan', error.message);
145
+ }
146
+ }
147
+
148
+ export async function loadScan(options) {
149
+ if (!options.cacheDir) {
150
+ return null;
151
+ }
152
+ try {
153
+ const report = JSON.parse(
154
+ await fs.readFile(
155
+ path.join(options.cacheDir, 'scans', `${scopeKey(options)}.json`),
156
+ 'utf8'
157
+ )
158
+ );
159
+ return (await authenticatedReport(report, options.cacheDir, options.now()))
160
+ ? report
161
+ : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** Cached listings are validated by each directory's own metadata, never its parent alone. */
168
+ export class ScanIndex {
169
+ constructor(directory) {
170
+ this.directory = directory;
171
+ this.entries = new Map();
172
+ this.dirty = false;
173
+ }
174
+
175
+ async load() {
176
+ if (!this.directory) {
177
+ return;
178
+ }
179
+ try {
180
+ const data = JSON.parse(
181
+ await fs.readFile(
182
+ path.join(this.directory, 'index', 'directories.json'),
183
+ 'utf8'
184
+ )
185
+ );
186
+ if (data.schema === 1 && Array.isArray(data.entries)) {
187
+ this.entries = new Map(data.entries.slice(-LIMIT));
188
+ }
189
+ } catch {
190
+ // Missing/corrupt state only loses an optimization.
191
+ }
192
+ }
193
+
194
+ get(key, fingerprint, maxAge = Infinity) {
195
+ const entry = this.entries.get(key);
196
+ return entry &&
197
+ entry.fingerprint === fingerprint &&
198
+ Date.now() - entry.time < maxAge
199
+ ? entry.value
200
+ : null;
201
+ }
202
+
203
+ set(key, fingerprint, value) {
204
+ this.entries.delete(key);
205
+ this.entries.set(key, { fingerprint, value, time: Date.now() });
206
+ while (this.entries.size > LIMIT) {
207
+ this.entries.delete(this.entries.keys().next().value);
208
+ }
209
+ this.dirty = true;
210
+ }
211
+
212
+ async save() {
213
+ if (!this.directory || !this.dirty) {
214
+ return;
215
+ }
216
+ const dir = path.join(this.directory, 'index');
217
+ const temporary = path.join(
218
+ dir,
219
+ `directories.${process.pid}.${randomBytes(4).toString('hex')}.json`
220
+ );
221
+ try {
222
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
223
+ await fs.writeFile(
224
+ temporary,
225
+ JSON.stringify({ schema: 1, entries: [...this.entries] }),
226
+ { mode: 0o600 }
227
+ );
228
+ await fs.rename(temporary, path.join(dir, 'directories.json'));
229
+ this.dirty = false;
230
+ } catch (error) {
231
+ trace('cannot save discovery index', error.message);
232
+ } finally {
233
+ await fs.rm(temporary, { force: true }).catch(() => {});
234
+ }
235
+ }
236
+ }
237
+
238
+ export const directoryFingerprint = (stats) =>
239
+ `${stats.device}:${stats.inode}:${stats.mtimeMs}:${stats.ctimeMs}`;
package/src/scan.js CHANGED
@@ -24,6 +24,15 @@ import { scanGlobal } from './scanners/global.js';
24
24
  import { scanProjects } from './scanners/projects.js';
25
25
  import { scanSystem } from './scanners/system.js';
26
26
  import { scanVersions } from './scanners/versions.js';
27
+ import { readSwap } from './swap.js';
28
+ import {
29
+ authenticateReport,
30
+ authenticatedReport,
31
+ saveScan,
32
+ ScanIndex,
33
+ scopeKey,
34
+ } from './scan-state.js';
35
+ import { PhaseTimer, timed } from './timings.js';
27
36
 
28
37
  export const SCANNERS = {
29
38
  projects: scanProjects,
@@ -76,20 +85,47 @@ function sealReport(report) {
76
85
  return report;
77
86
  }
78
87
 
88
+ function unchangedDiscoveryScope(report, input) {
89
+ const original = resolveOptions(report.options);
90
+ const overrides = Object.fromEntries(
91
+ Object.entries(input).filter(
92
+ ([name, value]) =>
93
+ RESCAN_OPTIONS.has(name) && value !== undefined && value !== null
94
+ )
95
+ );
96
+ return (
97
+ scopeKey(original) ===
98
+ scopeKey(resolveOptions({ ...original, ...overrides }))
99
+ );
100
+ }
101
+
79
102
  /** Rebuild scanner-derived actions when a report came from JSON or changed. */
80
103
  export async function revalidateReport(saved, input = {}) {
81
104
  if (trustedReports.get(saved) === safetySnapshot(saved)) {
82
105
  return saved;
83
106
  }
84
- const scope = {
85
- roots: saved.options?.roots,
86
- scanners: saved.options?.scanners,
87
- containers: saved.options?.containers,
88
- host: saved.options?.host,
89
- docker: saved.options?.docker,
90
- dockerDepth: saved.options?.dockerDepth,
91
- maxDepth: saved.options?.maxDepth,
92
- };
107
+ saved = saved.report?.schema === REPORT_SCHEMA ? saved.report : saved;
108
+ if (
109
+ unchangedDiscoveryScope(saved, input) &&
110
+ (await authenticatedReport(
111
+ saved,
112
+ input.cacheDir,
113
+ input.now?.() ?? Date.now()
114
+ ))
115
+ ) {
116
+ return sealReport(saved);
117
+ }
118
+ const scope = Object.fromEntries(
119
+ [
120
+ 'roots',
121
+ 'scanners',
122
+ 'containers',
123
+ 'host',
124
+ 'docker',
125
+ 'dockerDepth',
126
+ 'maxDepth',
127
+ ].map((name) => [name, saved.options?.[name]])
128
+ );
93
129
  for (const [key, value] of Object.entries(input)) {
94
130
  if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
95
131
  scope[key] = value;
@@ -172,6 +208,7 @@ export async function scanEnvironment(env, options, extra = {}) {
172
208
  options.scanBudgetMs
173
209
  );
174
210
  const found = new Map();
211
+ const completed = new Set();
175
212
  let scanner = 'context';
176
213
  const progress = () =>
177
214
  options.onScanProgress?.({
@@ -179,6 +216,11 @@ export async function scanEnvironment(env, options, extra = {}) {
179
216
  scanner,
180
217
  elapsedMs: Date.now() - started,
181
218
  items: found.size,
219
+ candidates: found.size,
220
+ filesSized: env.scanMetrics?.filesSized ?? 0,
221
+ directoriesSized: env.scanMetrics?.directoriesSized ?? 0,
222
+ indexHits: env.scanMetrics?.indexHits ?? 0,
223
+ sizeCacheHits: env.scanMetrics?.sizeCacheHits ?? 0,
182
224
  status: env.scanStatus,
183
225
  });
184
226
  env.scanStatus = 'scanning';
@@ -189,10 +231,17 @@ export async function scanEnvironment(env, options, extra = {}) {
189
231
  controller.signal,
190
232
  () => {
191
233
  progress();
192
- return scanEnvironmentScoped(env, options, extra, found, (name) => {
193
- scanner = name;
194
- progress();
195
- });
234
+ return scanEnvironmentScoped(
235
+ env,
236
+ options,
237
+ extra,
238
+ found,
239
+ completed,
240
+ (name) => {
241
+ scanner = name;
242
+ progress();
243
+ }
244
+ );
196
245
  },
197
246
  deadline
198
247
  );
@@ -205,7 +254,9 @@ export async function scanEnvironment(env, options, extra = {}) {
205
254
  (extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
206
255
  const items = [...found.values()];
207
256
  for (const item of items) {
208
- block(item, `scan incomplete: ${error.message}`);
257
+ if (options.signal?.aborted || !completed.has(item.id)) {
258
+ block(item, `scan incomplete: ${error.message}`);
259
+ }
209
260
  }
210
261
  return items;
211
262
  } finally {
@@ -216,13 +267,63 @@ export async function scanEnvironment(env, options, extra = {}) {
216
267
  }
217
268
  }
218
269
 
219
- async function scanEnvironmentScoped(env, options, extra, found, stage) {
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
+
276
+ async function scanEnvironmentScoped(
277
+ env,
278
+ options,
279
+ extra,
280
+ found,
281
+ completed,
282
+ stage
283
+ ) {
220
284
  const errors = extra.errors ?? [];
221
- 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;
222
289
  context.emit = (item) => found.set(item.id, item);
290
+ let stopped = false;
291
+ const ready = async (items) => {
292
+ await env.timings.measure('storage', () => attributeStorage(env, items));
293
+ for (const item of items) {
294
+ for (const target of item.paths) {
295
+ block(item, env.scanPolicy.removalReason(target));
296
+ }
297
+ completed.add(item.id);
298
+ }
299
+ if (
300
+ env.kind === 'host' &&
301
+ context.scanner === 'global' &&
302
+ options.onScanPhase
303
+ ) {
304
+ const result = await options.onScanPhase(
305
+ sealReport({
306
+ schema: REPORT_SCHEMA,
307
+ tool: 'disk-space-saviour',
308
+ createdAt: new Date(options.now()).toISOString(),
309
+ host: { env: env.id, label: env.label, platform: env.platform },
310
+ options: reportOptions(options),
311
+ environments: [envDescriptor(env, 0, [])],
312
+ items: filterItems(items, options, [], env.path),
313
+ totals: tierTotals(items),
314
+ errors: [],
315
+ docker: null,
316
+ })
317
+ );
318
+ stopped ||= result === false;
319
+ }
320
+ return !stopped;
321
+ };
322
+ context.ready = ready;
223
323
  let incomplete = false;
224
324
  for (const name of options.scanners) {
225
325
  checkSignal(env.signal);
326
+ context.scanner = name;
226
327
  stage(name);
227
328
  const scanner = SCANNERS[name];
228
329
  if (!scanner) {
@@ -233,25 +334,31 @@ async function scanEnvironmentScoped(env, options, extra, found, stage) {
233
334
  const started = Date.now();
234
335
  const previous = new Set(found.keys());
235
336
  try {
236
- const entries = await scanner(context);
337
+ const entries = await env.timings.measure(`scanner:${name}`, () =>
338
+ scanner(context)
339
+ );
237
340
  for (const item of entries) {
238
341
  context.emit(item);
239
342
  }
343
+ await ready(entries.filter((item) => !completed.has(item.id)));
240
344
  } catch (error) {
241
345
  checkSignal(env.signal);
242
346
  incomplete = true;
243
347
  for (const [id, item] of found) {
244
- if (!previous.has(id)) {
348
+ if (![previous, completed].some((set) => set.has(id))) {
245
349
  block(item, `scan incomplete: ${error.message}`);
246
350
  }
247
351
  }
248
352
  errors.push({ env: env.id, scanner: name, message: error.message });
249
353
  }
250
354
  trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
355
+ if (stopped) {
356
+ env.scanStatus = 'goal-reached';
357
+ return [...found.values()];
358
+ }
251
359
  }
252
360
  stage('storage');
253
361
  const items = [...found.values()];
254
- await attributeStorage(env, items);
255
362
  for (const item of items) {
256
363
  for (const target of item.paths) {
257
364
  block(item, env.scanPolicy.removalReason(target));
@@ -385,13 +492,18 @@ function envDescriptor(env, depth, chain) {
385
492
  hint: env.probeHint ?? null,
386
493
  scanStatus: env.scanStatus ?? null,
387
494
  layerSource: env.writableLayer?.source ?? null,
495
+ timings: env.timings?.toJSON() ?? [],
388
496
  };
389
497
  }
390
498
 
391
499
  async function withDisk(env, descriptor) {
392
500
  const target = env.platform === 'win32' ? env.currentHome : '/';
393
501
  const disk = await env.diskUsage(target).catch(() => null);
394
- return { ...descriptor, disk: disk ? { path: target, ...disk } : null };
502
+ return {
503
+ ...descriptor,
504
+ disk: disk ? { path: target, ...disk } : null,
505
+ swap: await readSwap(env),
506
+ };
395
507
  }
396
508
 
397
509
  async function dockerSection(env, options, errors) {
@@ -479,9 +591,19 @@ async function scanScoped(input) {
479
591
  const env = input.env ?? new LocalEnv();
480
592
  const startedAt = options.now();
481
593
  const errors = [];
594
+ env.scanMetrics = {
595
+ filesSized: 0,
596
+ directoriesSized: 0,
597
+ indexHits: 0,
598
+ sizeCacheHits: 0,
599
+ };
600
+ env.scanIndex = new ScanIndex(options.cacheDir);
601
+ env.platformIndex = options.platformIndex;
602
+ await env.scanIndex.load();
482
603
  resetWritableLayers(env.executor);
483
604
  env.writableLayer = null;
484
605
  env.scanPolicy = null;
606
+ env.timings = new PhaseTimer();
485
607
  await configureScanPolicy(env, options);
486
608
  await configureContainerHost(
487
609
  env,
@@ -492,7 +614,12 @@ async function scanScoped(input) {
492
614
  options.host === false
493
615
  ? []
494
616
  : await scanEnvironment(env, options, { roots: options.roots, errors });
495
- const docker = await dockerSection(env, options, errors);
617
+ const docker =
618
+ env.scanStatus === 'goal-reached'
619
+ ? null
620
+ : await timed(env.timings, 'docker', () =>
621
+ dockerSection(env, options, errors)
622
+ );
496
623
  blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
497
624
  const items = filterItems(
498
625
  [...hostItems, ...(docker?.items ?? [])],
@@ -504,7 +631,10 @@ async function scanScoped(input) {
504
631
  for (const inner of docker?.environments ?? []) {
505
632
  environments.push(inner);
506
633
  }
507
- 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();
508
638
  const report = buildReport({
509
639
  options,
510
640
  env,
@@ -515,6 +645,10 @@ async function scanScoped(input) {
515
645
  startedAt,
516
646
  });
517
647
  reportEnvironments.set(report, env);
648
+ await env.scanIndex.save();
649
+ await authenticateReport(report, options.cacheDir);
650
+ sealReport(report);
651
+ await saveScan(report, options.cacheDir);
518
652
  return report;
519
653
  }
520
654
 
@@ -524,6 +658,7 @@ function reportOptions(options) {
524
658
  delete serializable.env;
525
659
  delete serializable.signal;
526
660
  delete serializable.onScanProgress;
661
+ delete serializable.onScanPhase;
527
662
  return serializable;
528
663
  }
529
664
 
@@ -14,6 +14,7 @@ import { expandGlobPaths, expandRulePath, isWithin } from '../paths.js';
14
14
  import { olderThan, scanTimeBusy, resolvePaths } from './common.js';
15
15
  import { configuredPattern } from '../env/cache-roots.js';
16
16
  import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
17
+ import { timed } from '../timings.js';
17
18
 
18
19
  export const GLOBAL_RULES = [
19
20
  ...CACHE_RULES,
@@ -135,6 +136,10 @@ function ageFilter(context, rule) {
135
136
  return () => true;
136
137
  }
137
138
 
139
+ function requiresMtime(rule) {
140
+ return rule.mtime ?? (rule.scope === 'tmp' || Boolean(rule.minAge));
141
+ }
142
+
138
143
  /** Keep the latest installed revision of each browser and platform family. */
139
144
  function olderVersions(env, rule, paths, stats) {
140
145
  if (!rule.versionPattern) {
@@ -181,6 +186,7 @@ async function buildItem(context, rule, base, group) {
181
186
  path: paths[0],
182
187
  paths,
183
188
  bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
189
+ sizeEstimated: group.some((entry) => entry.usage.estimated),
184
190
  newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
185
191
  tier: rule.tier ?? 'safe',
186
192
  reason: rule.minAge
@@ -237,9 +243,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
237
243
  patterns: rulePatterns(env, rule, base),
238
244
  }))
239
245
  );
240
- const matches = await expandGlobPaths(
241
- env,
242
- planned.flatMap((entry) => entry.patterns)
246
+ const matches = await timed(context.timings, 'global:expand', () =>
247
+ expandGlobPaths(
248
+ env,
249
+ planned.flatMap((entry) => entry.patterns)
250
+ )
243
251
  );
244
252
  const typedPaths = planned
245
253
  .filter(
@@ -248,7 +256,9 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
248
256
  .flatMap(({ patterns }) =>
249
257
  patterns.flatMap((pattern) => matches.get(pattern) ?? [])
250
258
  );
251
- const stats = await env.statMany([...new Set(typedPaths)]);
259
+ const stats = await timed(context.timings, 'global:stat', () =>
260
+ env.statMany([...new Set(typedPaths)])
261
+ );
252
262
  const found = [];
253
263
  const seen = new Set();
254
264
  for (const { rule, base, patterns } of planned) {
@@ -258,7 +268,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
258
268
 
259
269
  const accepted = [];
260
270
  for (const target of candidates) {
261
- if (!(await acceptedPath(env, rule, target, stats))) {
271
+ if (
272
+ !(await timed(context.timings, 'global:accept', () =>
273
+ acceptedPath(env, rule, target, stats)
274
+ ))
275
+ ) {
262
276
  continue;
263
277
  }
264
278
  accepted.push(target);
@@ -269,22 +283,43 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
269
283
  found.push({ rule, base, paths });
270
284
  }
271
285
  }
272
- const usages = await env.usageMany(found.flatMap((entry) => entry.paths));
273
- await resolvePaths(
274
- context,
275
- found.flatMap((entry) => entry.paths)
286
+ await timed(context.timings, 'global:resolve', () =>
287
+ resolvePaths(
288
+ context,
289
+ found.flatMap((entry) => entry.paths)
290
+ )
276
291
  );
277
292
  const items = scanItems(context);
293
+ // Deliver cheap native measurements before rules needing recursive activity
294
+ // timestamps, so an emergency goal can avoid the expensive work entirely.
295
+ found.sort(
296
+ (a, b) => Number(requiresMtime(a.rule)) - Number(requiresMtime(b.rule))
297
+ );
298
+ // The report drops items below `minSize`; skipping them here saves their
299
+ // liveness checks (Telegram media alone can hold 100k small files). In a
300
+ // container the writable layer re-measures sizes later, so all are kept.
301
+ const large = (entry) =>
302
+ Boolean(env.writableLayer) ||
303
+ entry.usage.bytes >= (options.minSizeBytes ?? 0);
278
304
  for (const { rule, base, paths } of found) {
305
+ const usages = await timed(context.timings, 'global:measure', () =>
306
+ env.usageMany(paths, { mtime: requiresMtime(rule), cache: true })
307
+ );
279
308
  const accept = ageFilter(context, rule);
280
309
  const measured = paths
281
310
  .map((target) => ({ path: target, usage: usages.get(target) }))
282
311
  .filter((entry) => entry.usage && accept(entry.usage));
283
312
  const groups = rule.aggregate
284
313
  ? [measured]
285
- : measured.map((entry) => [entry]);
314
+ : measured.filter(large).map((entry) => [entry]);
286
315
  for (const group of groups.filter((g) => g.length > 0)) {
287
- items.push(await buildItem(context, rule, base, group));
316
+ const item = await timed(context.timings, 'global:item', () =>
317
+ buildItem(context, rule, base, group)
318
+ );
319
+ items.push(item);
320
+ if ((await context.ready?.([item])) === false) {
321
+ return items;
322
+ }
288
323
  }
289
324
  }
290
325
  return items;