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.
package/src/env/local.js CHANGED
@@ -64,6 +64,9 @@ import { CACHE_VARIABLES } from './cache-roots.js';
64
64
 
65
65
  import { hostExecutor } from '../exec.js';
66
66
  import { matchesGlob } from '../paths.js';
67
+ import { mapLimit } from './parallel.js';
68
+ import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
69
+ import { indexedDirectories } from './platform-index.js';
67
70
 
68
71
  // Directories read concurrently while measuring usage.
69
72
  const WALK_BATCH = 64;
@@ -252,6 +255,7 @@ export class LocalEnv {
252
255
  size: stats.size,
253
256
  bytes: diskBytes(stats),
254
257
  mtimeMs: stats.mtimeMs,
258
+ ctimeMs: stats.ctimeMs,
255
259
  inode: `${stats.dev}:${stats.ino}`,
256
260
  uid: stats.uid,
257
261
  gid: stats.gid,
@@ -295,31 +299,36 @@ export class LocalEnv {
295
299
  return heads;
296
300
  }
297
301
 
298
- async list(dir) {
302
+ async list(dir, { discovery = false } = {}) {
299
303
  let names;
300
304
  try {
301
- names = await (this.options.fileFs ?? fsp).readdir(dir);
305
+ names = await (this.options.fileFs ?? fsp).readdir(dir, {
306
+ withFileTypes: true,
307
+ });
302
308
  } catch (error) {
303
309
  this.recordAccessError(dir, error);
304
310
  return [];
305
311
  }
306
- const entries = await Promise.all(
307
- names.map(async (name) => {
308
- const full = path.join(dir, name);
309
- const stats = await this.#lstat(full);
310
- if (!stats) {
311
- return null;
312
- }
313
- return {
314
- name,
315
- path: full,
316
- type: entryType(stats),
317
- size: stats.size,
318
- bytes: diskBytes(stats),
319
- mtimeMs: stats.mtimeMs,
320
- };
321
- })
322
- );
312
+ const entries = await mapLimit(names, 32, async (entry) => {
313
+ const name = typeof entry === 'string' ? entry : entry.name;
314
+ const full = path.join(dir, name);
315
+ if (discovery && typeof entry !== 'string' && !entry.isDirectory()) {
316
+ return { name, path: full, type: entryType(entry), mtimeMs: 0 };
317
+ }
318
+ const stats = await this.#lstat(full);
319
+ if (!stats) {
320
+ return null;
321
+ }
322
+ return {
323
+ name,
324
+ path: full,
325
+ type: entryType(stats),
326
+ size: stats.size,
327
+ bytes: diskBytes(stats),
328
+ mtimeMs: stats.mtimeMs,
329
+ device: stats.dev,
330
+ };
331
+ });
323
332
  return entries.filter(Boolean);
324
333
  }
325
334
 
@@ -328,14 +337,15 @@ export class LocalEnv {
328
337
  * counted a single time, symlinks not followed. Also reports the newest
329
338
  * file mtime.
330
339
  */
331
- async usage(target) {
332
- return (await this.usageMany([target])).get(target) ?? null;
340
+ async usage(target, options) {
341
+ return (await this.usageMany([target], options)).get(target) ?? null;
333
342
  }
334
343
 
335
344
  async statMany(targets) {
336
- const entries = await Promise.all(
337
- targets.map(async (target) => [target, await this.stat(target)])
338
- );
345
+ const entries = await mapLimit(targets, 32, async (target) => [
346
+ target,
347
+ await this.stat(target),
348
+ ]);
339
349
  return new Map(entries.filter(([, stat]) => stat));
340
350
  }
341
351
 
@@ -362,6 +372,9 @@ export class LocalEnv {
362
372
  apparentBytes += stats.size;
363
373
  if (!stats.isDirectory()) {
364
374
  files++;
375
+ if (this.scanMetrics) {
376
+ this.scanMetrics.filesSized++;
377
+ }
365
378
  newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
366
379
  }
367
380
  };
@@ -375,12 +388,10 @@ export class LocalEnv {
375
388
  errors++;
376
389
  return [];
377
390
  }
378
- const children = await Promise.all(
379
- names.map(async (name) => {
380
- const full = path.join(dir, name);
381
- return [full, await this.#lstat(full)];
382
- })
383
- );
391
+ const children = await mapLimit(names, 32, async (name) => {
392
+ const full = path.join(dir, name);
393
+ return [full, await this.#lstat(full)];
394
+ });
384
395
  const subdirs = [];
385
396
  for (const [full, stats] of children) {
386
397
  if (!stats) {
@@ -453,6 +464,10 @@ export class LocalEnv {
453
464
  return results;
454
465
  }
455
466
 
467
+ indexedDirs(roots, globs, maxDepth) {
468
+ return indexedDirectories(this, roots, globs, maxDepth);
469
+ }
470
+
456
471
  /**
457
472
  * Finds regular files whose basename matches one of `names`.
458
473
  * @returns {Promise<string[]>}
@@ -468,7 +483,14 @@ export class LocalEnv {
468
483
  results.push(entry.path);
469
484
  }
470
485
  }
471
- return null;
486
+ return new Set([
487
+ 'node_modules',
488
+ 'target',
489
+ 'vendor',
490
+ '.venv',
491
+ 'venv',
492
+ ...this.skipNames,
493
+ ]);
472
494
  });
473
495
  return results;
474
496
  }
@@ -480,55 +502,214 @@ export class LocalEnv {
480
502
  async #walk(roots, maxDepth, skipNames, visit) {
481
503
  const skip = skipNames ? new Set(skipNames) : this.skipNames;
482
504
  const visited = new Set();
483
- const queue = await Promise.all(
505
+ let queue = await Promise.all(
484
506
  roots.map(async (root) => [root, 0, await this.deviceId(root)])
485
507
  );
486
508
  while (queue.length > 0) {
487
509
  checkSignal(this.signal);
488
- const [dir, depth, device] = queue.shift();
489
- if (visited.has(dir)) {
490
- continue;
491
- }
492
- visited.add(dir);
493
- const entries = [];
494
- for (const entry of await this.list(dir)) {
495
- if (
496
- entry.type !== 'dir' ||
497
- (await this.deviceId(entry.path)) === device
498
- ) {
499
- entries.push(entry);
510
+ const current = queue;
511
+ queue = [];
512
+ const children = await mapLimit(
513
+ current,
514
+ 8,
515
+ async ([dir, depth, device]) => {
516
+ if (visited.has(dir)) {
517
+ return [];
518
+ }
519
+ visited.add(dir);
520
+ const entries = [];
521
+ for (const entry of await this.discoveryList(dir)) {
522
+ if (
523
+ entry.type !== 'dir' ||
524
+ (entry.device ?? (await this.deviceId(entry.path))) === device
525
+ ) {
526
+ entries.push(entry);
527
+ }
528
+ }
529
+ const pruned = visit(dir, entries) ?? new Set();
530
+ if (depth + 1 >= maxDepth) {
531
+ return [];
532
+ }
533
+ const next = [];
534
+ for (const entry of entries) {
535
+ if (
536
+ entry.type === 'dir' &&
537
+ !pruned.has(entry.name) &&
538
+ !this.skipDiscoveryDirectory(dir, entry.name, skip) &&
539
+ (entry.device ?? (await this.deviceId(entry.path))) === device
540
+ ) {
541
+ next.push([entry.path, depth + 1, device]);
542
+ }
543
+ }
544
+ return next;
500
545
  }
546
+ );
547
+ queue = children.flat();
548
+ }
549
+ }
550
+
551
+ skipDiscoveryDirectory(dir, name, skip = this.skipNames) {
552
+ if (skip.has(name) || /\.photoslibrary$/i.test(name)) {
553
+ return true;
554
+ }
555
+ const homes = this.options.homes ?? [this.currentHome];
556
+ return (
557
+ (['Music', 'Pictures', 'Movies'].includes(name) && homes.includes(dir)) ||
558
+ (name === 'Volumes' && dir === this.path.parse(dir).root)
559
+ );
560
+ }
561
+
562
+ async discoveryStat(dir) {
563
+ let stats = await this.stat(dir);
564
+ // Explicit roots may be aliases (for example /tmp on macOS). Child links
565
+ // are never queued by the walker, so only an explicitly supplied root follows.
566
+ if (stats?.type === 'symlink') {
567
+ const resolved = await this.realPath(dir);
568
+ stats = resolved ? await this.stat(resolved) : null;
569
+ }
570
+ return stats;
571
+ }
572
+
573
+ async discoveryList(dir) {
574
+ checkSignal(this.signal);
575
+ const stats = await this.discoveryStat(dir);
576
+ if (!stats || stats.type !== 'dir') {
577
+ return [];
578
+ }
579
+ const key = `list:${dir}`;
580
+ const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
581
+ let entries = this.scanIndex?.get(key, fingerprint);
582
+ if (entries) {
583
+ this.scanMetrics.indexHits++;
584
+ } else {
585
+ const errors = this.accessErrors?.size ?? 0;
586
+ entries = await this.list(dir, { discovery: true });
587
+ if ((this.accessErrors?.size ?? 0) === errors) {
588
+ this.scanIndex?.set(key, fingerprint, entries);
501
589
  }
502
- const pruned = visit(dir, entries) ?? new Set();
503
- if (depth + 1 >= maxDepth) {
504
- continue;
505
- }
506
- for (const entry of entries) {
507
- if (
508
- entry.type === 'dir' &&
509
- !pruned.has(entry.name) &&
510
- !skip.has(entry.name) &&
511
- (await this.deviceId(entry.path)) === device
512
- ) {
513
- queue.push([entry.path, depth + 1, device]);
590
+ }
591
+ // Stored metadata is a discovery hint; never accept external paths from it.
592
+ const listing = entries
593
+ .filter(
594
+ (entry) => entry.name && this.path.basename(entry.name) === entry.name
595
+ )
596
+ .map((entry) => ({ ...entry, path: this.path.join(dir, entry.name) }))
597
+ .filter(
598
+ (entry) => !this.scanPolicy || this.scanPolicy.allows(entry.path)
599
+ );
600
+ // Mounts and link replacements need not change the parent's mtime.
601
+ return (
602
+ await mapLimit(listing, 32, async (entry) => {
603
+ if (entry.type !== 'dir') {
604
+ return entry;
514
605
  }
515
- }
606
+ const fresh = await this.stat(entry.path);
607
+ return fresh ? { ...entry, ...fresh } : null;
608
+ })
609
+ ).filter(Boolean);
610
+ }
611
+
612
+ /** Source activity excludes generated trees, and stops as soon as recent work is seen. */
613
+ async projectActivity(project, globs, since) {
614
+ let newest = 0;
615
+ let frontier = [project];
616
+ const device = await this.deviceId(project);
617
+ while (frontier.length && newest < since) {
618
+ const children = await mapLimit(frontier, 8, async (dir) => {
619
+ checkSignal(this.signal);
620
+ const next = [];
621
+ for (const entry of await this.list(dir)) {
622
+ if (entry.type === 'dir') {
623
+ if (
624
+ !this.skipDiscoveryDirectory(dir, entry.name) &&
625
+ !globs.some((glob) => matchesGlob(entry.name, glob)) &&
626
+ entry.device === device
627
+ ) {
628
+ next.push(entry.path);
629
+ }
630
+ } else if (entry.type === 'file') {
631
+ newest = Math.max(newest, entry.mtimeMs);
632
+ }
633
+ }
634
+ return next;
635
+ });
636
+ frontier = children.flat();
516
637
  }
638
+ return newest;
517
639
  }
518
640
 
519
- async usageMany(targets) {
520
- const usages = await this.rawUsageMany(targets);
641
+ async usageMany(targets, options) {
642
+ const usages = await this.rawUsageMany(targets, options);
521
643
  return this.writableLayer
522
644
  ? this.writableLayer.measure(this, usages)
523
645
  : usages;
524
646
  }
525
647
 
526
- async rawUsageMany(targets) {
527
- const usages = new Map();
528
- for (const target of targets) {
529
- usages.set(target, await this.rawUsage(target));
648
+ async rawUsageMany(targets, { mtime = true, cache = false } = {}) {
649
+ const entries = await mapLimit([...new Set(targets)], 4, async (target) => [
650
+ target,
651
+ !this.writableLayer && cache
652
+ ? await this.cachedUsage(target, mtime)
653
+ : !mtime && !this.writableLayer
654
+ ? await this.quickUsage(target)
655
+ : await this.rawUsage(target),
656
+ ]);
657
+ return new Map(entries);
658
+ }
659
+
660
+ async cachedUsage(target, mtime) {
661
+ const stats = await this.stat(target);
662
+ if (!stats) {
663
+ return null;
664
+ }
665
+ const key = `usage:${mtime}:${target}`;
666
+ const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
667
+ const cached = this.scanIndex?.get(key, fingerprint, SIZE_MAX_AGE);
668
+ if (cached) {
669
+ this.scanMetrics.sizeCacheHits++;
670
+ return { ...cached, estimated: true };
671
+ }
672
+ const usage = mtime
673
+ ? await this.rawUsage(target)
674
+ : await this.quickUsage(target);
675
+ if (usage && !usage.errors) {
676
+ this.scanIndex?.set(key, fingerprint, usage);
677
+ }
678
+ return usage;
679
+ }
680
+
681
+ async quickUsage(target) {
682
+ const stats = await this.stat(target);
683
+ if (!stats) {
684
+ return null;
685
+ }
686
+ // du cannot enforce path-glob exclusions or detect a nested mount beneath a candidate.
687
+ if (
688
+ this.platform === 'win32' ||
689
+ this.scanPolicy?.exclude.length ||
690
+ this.scanPolicy?.mounts.some(
691
+ (mount) =>
692
+ mount.path !== '/' && mount.path.startsWith(`${target}${path.sep}`)
693
+ )
694
+ ) {
695
+ return this.rawUsage(target);
696
+ }
697
+ const result = await this.run(['du', '-skx', target]);
698
+ const match = /^\s*(\d+)\s/.exec(result.stdout);
699
+ if (result.code !== 0 || !match) {
700
+ return this.rawUsage(target);
701
+ }
702
+ const usage = {
703
+ bytes: Number(match[1]) * 1024,
704
+ apparentBytes: Number(match[1]) * 1024,
705
+ newestMtimeMs: 0,
706
+ files: 0,
707
+ errors: 0,
708
+ };
709
+ if (this.scanMetrics) {
710
+ this.scanMetrics.directoriesSized++;
530
711
  }
531
- return usages;
712
+ return usage;
532
713
  }
533
714
 
534
715
  async readLink(target) {
@@ -0,0 +1,23 @@
1
+ /** Finite worker pool; settle all in-flight work before propagating failure. */
2
+ export async function mapLimit(values, limit, operation) {
3
+ const results = new Array(values.length);
4
+ let cursor = 0;
5
+ let failure;
6
+ const worker = async () => {
7
+ while (!failure && cursor < values.length) {
8
+ const index = cursor++;
9
+ try {
10
+ results[index] = await operation(values[index], index);
11
+ } catch (error) {
12
+ failure ??= error;
13
+ }
14
+ }
15
+ };
16
+ await Promise.all(
17
+ Array.from({ length: Math.min(limit, values.length) }, worker)
18
+ );
19
+ if (failure) {
20
+ throw failure;
21
+ }
22
+ return results;
23
+ }
@@ -0,0 +1,87 @@
1
+ /** OS index hits are hints; stale/missing indexes always fall back to discovery. */
2
+ import { isWithin, matchesGlob } from '../paths.js';
3
+ import { mapLimit } from './parallel.js';
4
+
5
+ export async function indexedDirectories(env, roots, globs, maxDepth) {
6
+ if (!env.scanIndex?.directory || env.platformIndex === false) {
7
+ return [];
8
+ }
9
+ let hits = [];
10
+ try {
11
+ if (env.platform === 'darwin' && (await env.which('mdfind'))) {
12
+ const query = globs
13
+ .map((glob) => `kMDItemFSName == "${glob.replaceAll('"', '\\"')}"`)
14
+ .join(' || ');
15
+ for (const root of roots) {
16
+ const result = await env.run(['mdfind', '-0', '-onlyin', root, query]);
17
+ if (result.code === 0) {
18
+ hits.push(...result.stdout.split('\0').filter(Boolean));
19
+ }
20
+ }
21
+ } else if (env.platform === 'linux') {
22
+ const command = (await env.which('plocate'))
23
+ ? 'plocate'
24
+ : (await env.which('locate'))
25
+ ? 'locate'
26
+ : null;
27
+ if (command) {
28
+ const result = await env.run([command, '-0', '-e', '-b', ...globs]);
29
+ if (result.code === 0) {
30
+ hits = result.stdout.split('\0').filter(Boolean);
31
+ }
32
+ }
33
+ }
34
+ } catch {
35
+ // Budget/cancellation will be observed by the fallback walk too.
36
+ }
37
+ hits = [...new Set(hits)]
38
+ .filter((target) => {
39
+ const root = roots.find((candidate) =>
40
+ isWithin(target, candidate, env.path)
41
+ );
42
+ if (
43
+ !root ||
44
+ !env.path.isAbsolute(target) ||
45
+ !globs.some((glob) => matchesGlob(env.path.basename(target), glob))
46
+ ) {
47
+ return false;
48
+ }
49
+ const parts = env.path.relative(root, target).split(/[\\/]/);
50
+ return (
51
+ parts.length <= maxDepth &&
52
+ !parts
53
+ .slice(0, -1)
54
+ .some(
55
+ (name, index) =>
56
+ env.skipDiscoveryDirectory(
57
+ env.path.join(root, ...parts.slice(0, index)),
58
+ name
59
+ ) || globs.some((glob) => matchesGlob(name, glob))
60
+ )
61
+ );
62
+ })
63
+ .slice(0, 20000);
64
+ const candidates = await mapLimit(hits, 8, async (target) => {
65
+ const stat = await env.stat(target);
66
+ const root = roots.find((candidate) =>
67
+ isWithin(target, candidate, env.path)
68
+ );
69
+ if (stat?.type !== 'dir' || stat.device !== (await env.deviceId(root))) {
70
+ return null;
71
+ }
72
+ let parent = env.path.dirname(target);
73
+ while (parent !== root && isWithin(parent, root, env.path)) {
74
+ const ancestor = await env.stat(parent);
75
+ if (ancestor?.type !== 'dir' || ancestor.device !== stat.device) {
76
+ return null;
77
+ }
78
+ parent = env.path.dirname(parent);
79
+ }
80
+ return {
81
+ path: target,
82
+ name: env.path.basename(target),
83
+ parent: env.path.dirname(target),
84
+ };
85
+ });
86
+ return candidates.filter(Boolean);
87
+ }
package/src/env/shell.js CHANGED
@@ -405,7 +405,47 @@ export class ShellEnv {
405
405
  }
406
406
 
407
407
  findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
408
- return this.#find(roots, 'f', names, maxDepth, skipNames);
408
+ return this.#find(roots, 'f', names, maxDepth, [
409
+ ...new Set([
410
+ ...(skipNames ?? this.skipNames),
411
+ 'node_modules',
412
+ 'target',
413
+ 'vendor',
414
+ '.venv',
415
+ 'venv',
416
+ ]),
417
+ ]);
418
+ }
419
+
420
+ async projectActivity(project, globs) {
421
+ const prune = [
422
+ '-type',
423
+ 'd',
424
+ '(',
425
+ ...findNameExpression([...new Set([...this.skipNames, ...globs])]),
426
+ ')',
427
+ '-prune',
428
+ '-o',
429
+ ];
430
+ const argv = [
431
+ 'find',
432
+ project,
433
+ '-xdev',
434
+ ...this.#prunePaths(),
435
+ ...prune,
436
+ '-type',
437
+ 'f',
438
+ '-exec',
439
+ 'stat',
440
+ '-c',
441
+ '%Y',
442
+ '{}',
443
+ '+',
444
+ ];
445
+ const result = await this.sh(
446
+ `${argv.map(shellQuote).join(' ')} | awk 'BEGIN{m=0} {if ($1>m) m=$1} END{print m}'`
447
+ );
448
+ return Number(result.stdout.trim()) * 1000 || 0;
409
449
  }
410
450
 
411
451
  /**
@@ -0,0 +1,39 @@
1
+ /** Clean completed safe host findings while discovery continues. */
2
+ import { clean } from './clean.js';
3
+ import { scan } from './scan.js';
4
+
5
+ export async function cleanDuringScan(options) {
6
+ const entries = [];
7
+ const health = [];
8
+ let failure;
9
+ const report = await scan({
10
+ ...options,
11
+ onScanPhase: async (phase) => {
12
+ try {
13
+ const audit = await clean(phase, {
14
+ ...options,
15
+ tier: 'safe',
16
+ priorEntries: entries,
17
+ priorHealth: health,
18
+ });
19
+ entries.splice(0, entries.length, ...audit.entries);
20
+ health.splice(0, health.length, ...audit.health);
21
+ } catch (error) {
22
+ failure = error;
23
+ return false;
24
+ }
25
+ },
26
+ });
27
+ if (failure) {
28
+ throw failure;
29
+ }
30
+ if (report.aborted) {
31
+ return { report, audit: null };
32
+ }
33
+ const audit = await clean(report, {
34
+ ...options,
35
+ priorEntries: entries,
36
+ priorHealth: health,
37
+ });
38
+ return { report, audit };
39
+ }
package/src/health.js CHANGED
@@ -107,9 +107,9 @@ function describe(lost) {
107
107
  * Per-environment snapshots and re-checks. `records` is the audit view.
108
108
  */
109
109
  export class HealthWatch {
110
- constructor() {
111
- this.records = [];
112
- this.byEnv = new Map();
110
+ constructor(records = []) {
111
+ this.records = [...records];
112
+ this.byEnv = new Map(records.map((record) => [record.env, record]));
113
113
  }
114
114
 
115
115
  /**
package/src/index.d.ts CHANGED
@@ -13,12 +13,21 @@ export interface ScanOptions {
13
13
  maxDepth?: number;
14
14
  /** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
15
15
  scanBudget?: Duration;
16
+ /** Persistent scan/index directory. Library default is false; CLI uses XDG state. */
17
+ cacheDir?: string | false;
18
+ /** Use verified Spotlight/locate hints before the discovery walk. */
19
+ platformIndex?: boolean;
16
20
  signal?: AbortSignal;
17
21
  onScanProgress?: (progress: {
18
22
  env: string;
19
23
  scanner: string;
20
24
  elapsedMs: number;
21
25
  items: number;
26
+ candidates: number;
27
+ filesSized: number;
28
+ directoriesSized: number;
29
+ indexHits: number;
30
+ sizeCacheHits: number;
22
31
  status: string;
23
32
  }) => void;
24
33
  /** Activity window: newer files are kept (`1h`). */
@@ -109,6 +118,8 @@ export interface Item {
109
118
  path: string | null;
110
119
  paths: string[];
111
120
  bytes: number;
121
+ /** Cached size is advisory; cleanup always measures again. */
122
+ sizeEstimated?: boolean;
112
123
  /** Merged filesystem bytes, including image data, when measured in Docker. */
113
124
  totalBytes?: number;
114
125
  /** Immutable image bytes excluded from reclaimable totals. */
@@ -154,9 +165,23 @@ export interface EnvironmentDescriptor {
154
165
  chain: Array<{ containerId: string; name: string }>;
155
166
  hint?: string | null;
156
167
  disk?: Disk | null;
168
+ swap?: {
169
+ total: number;
170
+ used: number;
171
+ free: number;
172
+ holders: Array<{
173
+ pid: number;
174
+ command: string;
175
+ memoryBytes: number;
176
+ compressedBytes: number;
177
+ }>;
178
+ attribution: string;
179
+ advice: string;
180
+ } | null;
157
181
  scanStatus?:
158
182
  | 'scanning'
159
183
  | 'complete'
184
+ | 'goal-reached'
160
185
  | 'aborted'
161
186
  | 'budget-exceeded'
162
187
  | 'error'
@@ -178,6 +203,7 @@ export interface Report {
178
203
  createdAt: string;
179
204
  durationMs: number;
180
205
  aborted?: boolean;
206
+ authentication?: { hostname: string; uid: number | string; digest: string };
181
207
  audit?: { file: string | null; error: string; code?: string };
182
208
  host: { env: string; label: string; platform: string };
183
209
  options: Record<string, unknown>;
@@ -235,6 +261,7 @@ export interface AuditLog {
235
261
  startedAt: string;
236
262
  finishedAt: string | null;
237
263
  aborted?: boolean;
264
+ report?: Partial<Report>;
238
265
  error?: string;
239
266
  entries: AuditEntry[];
240
267
  freedBytes: number;