disk-space-saviour 0.22.1 → 0.23.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Recursive container scans honor requested roots: `--container-root PATH` (`containerRoots`) sets project roots inside every scanned container at any Docker depth, and host roots that exist inside a container join its defaults. The same roots reach the traversal policy and project discovery. Every environment now reports its effective `scope` (roots with source and status, max depth, exclusions, pruned mounts) and the text report names requested roots that were absent, inaccessible, pruned, excluded or not searched.
8
+
9
+ Hard-link aware sizes: items report physically reclaimable bytes, with `allocatedBytes` and `sharedBytes` when links outside the item keep blocks allocated. Each inode is counted once across a selected deletion set, and the audit records `sharedBytes` and marks link-aware estimated `freedBytes`.
10
+
11
+ Add explicit macOS app bundle removal through native Trash with process checks,
12
+ dry runs and audits. Report possible app leftovers separately from user data,
13
+ without automatically deleting either category.
14
+
3
15
  ## 0.22.1
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -411,6 +411,41 @@ is scanned as a new environment, down to `--depth` levels (default 3). The
411
411
  CI job `Docker-in-Docker Integration` builds a host → l1 → l2 chain and runs
412
412
  `tests/integration/dind.mjs` against it (`npm run test:dind`).
413
413
 
414
+ ### Scan roots
415
+
416
+ Project discovery walks a list of roots per environment; the traversal
417
+ policy (which mount points are crossed) uses the same requested roots, so
418
+ both always agree:
419
+
420
+ - host: the positional `paths` (`roots` in the API), otherwise the homes and
421
+ temporary directories;
422
+ - every scanned container, at every Docker depth: `--container-root PATH`
423
+ (`containerRoots`, repeatable) replaces the defaults; without it, the
424
+ container's homes, temporary directories, existing common workspace paths
425
+ (`/workspace`, `/workspaces`, `/app`, `/src`, `/code`, `/project`,
426
+ `/usr/src/app`) and every host root that also exists inside the container.
427
+ Host and container paths are different namespaces, so no path is remapped.
428
+
429
+ Each JSON environment carries `scope`: its roots with `source` (`roots`,
430
+ `container-roots`, `home`, `tmp`, `container-default`) and `status`
431
+ (`searched`, `partial`, `absent`, `inaccessible`, `pruned`, `excluded`,
432
+ `not-searched`), plus `maxDepth`, `exclude` and `prunedMounts`. The text
433
+ report names requested roots that were not searched. `scanStatus: complete`
434
+ means complete within that scope; exclusions, mount boundaries and liveness
435
+ checks still apply.
436
+
437
+ ### Hard links
438
+
439
+ Sizes are physical reclaimable bytes. A file's blocks count toward an item
440
+ only when every hard link to it lies inside the deletion set: a package
441
+ cache or `node_modules` hard-linked to a shared store (pnpm, Nix, uv) reports
442
+ those blocks as `sharedBytes`, next to `allocatedBytes` (what `du` prints).
443
+ Each `dev:inode` is counted once across the whole selection, so files linked
444
+ between two selected paths are reclaimable once. In the audit log,
445
+ `sharedBytes` lists blocks that stayed allocated; `freedBytes` is a measured
446
+ before/after value for Docker writable layers and otherwise the link-aware
447
+ estimate, marked with `freedEstimated` and `freedAccounting`.
448
+
414
449
  ## Emergency mode
415
450
 
416
451
  `dss emergency --free 20G` (at least 20 GiB available) or
@@ -776,3 +811,33 @@ Quick steps:
776
811
  ## License
777
812
 
778
813
  [Unlicense](LICENSE) - Public Domain
814
+
815
+ ### Explicit macOS app removal and leftovers
816
+
817
+ `dss apps remove "/Users/me/Applications/Example.app"` previews removal;
818
+ `--yes` moves only that bundle to the macOS Trash. It never empties Trash or
819
+ removes support data. App identity and process activity are checked before
820
+ removal; symlinked paths, nested app components and App Store receipts are
821
+ refused. Use a vendor uninstaller first when one exists.
822
+
823
+ Chrome web apps should first be uninstalled using `chrome://apps`.
824
+ `--launcher-only` explicitly allows moving their launcher bundles to Trash;
825
+ it preserves Chrome's app registration, cookies, profiles and website data,
826
+ so Chrome may recreate the launcher. The command is separate from automatic
827
+ cache cleanup. Removed URL handlers may be recreated by their owning tool.
828
+
829
+ `dss apps leftovers` reports possible leftover Library files, including logs.
830
+ An absent bundle in standard application folders is only a clue: apps can live
831
+ elsewhere, and helpers or other apps can share data. Caches and logs are marked
832
+ `disposable-after-activity-check`; settings, application support, containers,
833
+ and saved state require a user decision. Every candidate remains read-only,
834
+ including disposable candidates. No leftover is safe to delete unconditionally;
835
+ logs may be needed for investigation. Inventory errors appear in the report.
836
+
837
+ ### Platform-specific competitor comparison
838
+
839
+ See the [feature comparison tables](docs/research/platform-feature-comparison.md)
840
+ for dss and 30 competitors across Linux, macOS, FreeBSD, OpenBSD, NetBSD,
841
+ illumos and AIX. Capability, interface and safety-backend tables distinguish
842
+ implemented features, vendor documentation, partial coverage and unknowns.
843
+ The comparison is a sourced inventory, not a performance or superiority claim.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/clean.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * log.
13
13
  */
14
14
 
15
+ import { selectionBytes } from './env/hardlinks.js';
15
16
  import { once } from 'node:events';
16
17
  import { createWriteStream, promises as fsp } from 'node:fs';
17
18
  import path from 'node:path';
@@ -79,6 +80,7 @@ export function cleanOrder(a, b) {
79
80
  );
80
81
  }
81
82
 
83
+ /** Plain per-path sum: used for before/after measurements of one set. */
82
84
  function sumBytes(usages, paths = [...usages.keys()]) {
83
85
  let total = 0;
84
86
  for (const target of paths) {
@@ -334,7 +336,7 @@ export class Cleaner {
334
336
  const fresh = {
335
337
  ...item,
336
338
  paths,
337
- bytes: sumBytes(usages, paths),
339
+ bytes: selectionBytes(usages, paths).bytes,
338
340
  newestMtimeMs: newest || item.newestMtimeMs,
339
341
  action: type === 'remove' ? { ...item.action, paths } : item.action,
340
342
  };
@@ -559,7 +561,12 @@ export class Cleaner {
559
561
  entry.reason = boundary;
560
562
  return;
561
563
  }
562
- await this.#deletePaths(ctx, entry, [target], usage?.bytes ?? 0);
564
+ await this.#deletePaths(
565
+ ctx,
566
+ entry,
567
+ [target],
568
+ selectionBytes(new Map([[target, usage]]), [target])
569
+ );
563
570
  }
564
571
  if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
565
572
  entry.reason = 'every path was in use or recently written';
@@ -599,7 +606,12 @@ export class Cleaner {
599
606
  if (!selected.length) {
600
607
  return true;
601
608
  }
602
- await this.#deletePaths(ctx, entry, selected, sumBytes(usages, selected));
609
+ await this.#deletePaths(
610
+ ctx,
611
+ entry,
612
+ selected,
613
+ selectionBytes(usages, selected)
614
+ );
603
615
  const lost = await this.health.check(ctx.env, item);
604
616
  if (lost) {
605
617
  entry.reason = lost;
@@ -638,7 +650,12 @@ export class Cleaner {
638
650
  return usage.bytes === 0 ? 'no writable bytes remain' : null;
639
651
  }
640
652
 
641
- async #deletePaths(ctx, entry, selected, estimate) {
653
+ /** `accounted`: hard-link aware {bytes, sharedBytes} of `selected`. */
654
+ async #deletePaths(ctx, entry, selected, accounted) {
655
+ const estimate = accounted.bytes;
656
+ if (accounted.sharedBytes > 0) {
657
+ entry.sharedBytes = (entry.sharedBytes ?? 0) + accounted.sharedBytes;
658
+ }
642
659
  const before = await ctx.env.writableLayer?.allocatedBytes?.(
643
660
  selected,
644
661
  ctx.env.path
@@ -661,9 +678,10 @@ export class Cleaner {
661
678
  if (typeof before === 'number' && typeof after === 'number') {
662
679
  return Math.max(0, before - after);
663
680
  }
664
- if (ctx.env.writableLayer) {
665
- entry.freedEstimated = true;
666
- }
681
+ // Without a block measurement the hard-link aware selection estimate
682
+ // is reported, and marked as an estimate.
683
+ entry.freedEstimated = true;
684
+ entry.freedAccounting = 'hardlink-aware-estimate';
667
685
  return fallback;
668
686
  }
669
687
 
package/src/cli.js CHANGED
@@ -13,6 +13,7 @@ import { parseArgs } from 'node:util';
13
13
 
14
14
  import { startAudit, writeAudit } from './audit.js';
15
15
  import { clean } from './clean.js';
16
+ import { runApps } from './mac-apps.js';
16
17
  import { exitedBlockerPids, pidAlive } from './conditions.js';
17
18
  import { emergency } from './emergency.js';
18
19
  import { setTrace } from './exec.js';
@@ -34,6 +35,8 @@ Commands:
34
35
  largest directories and files, what dss can
35
36
  free inside them, APFS volumes (never deletes)
36
37
  docker scan|clean Only Docker: daemons, containers, nested daemons
38
+ apps remove APP.app Move explicitly selected macOS apps to Trash
39
+ apps leftovers Report possible app leftovers (read-only)
37
40
  help, version
38
41
 
39
42
  Scope:
@@ -43,6 +46,9 @@ Scope:
43
46
  --depth N Docker nesting depth to recurse into (default 3)
44
47
  --recursive docker: recurse into nested daemons
45
48
  --container ID|NAME limit Docker work to these containers (repeat)
49
+ --container-root PATH project root inside every scanned container,
50
+ at any Docker depth; replaces the container
51
+ defaults (repeat)
46
52
  --max-depth N directory depth for project search (default 6)
47
53
  --scan-budget AGE time budget per filesystem environment (2m)
48
54
  --only NAME|ID|PATH only these ecosystems, rules, kinds, item ids,
@@ -91,6 +97,7 @@ const OPTIONS = {
91
97
  depth: { type: 'string' },
92
98
  recursive: { type: 'boolean' },
93
99
  container: { type: 'string', multiple: true },
100
+ 'container-root': { type: 'string', multiple: true },
94
101
  'max-depth': { type: 'string' },
95
102
  'scan-budget': { type: 'string' },
96
103
  only: { type: 'string', multiple: true },
@@ -170,6 +177,7 @@ function tierOf(values) {
170
177
  export function toOptions(values, paths = []) {
171
178
  return {
172
179
  roots: paths.length > 0 ? paths : null,
180
+ containerRoots: values['container-root'],
173
181
  docker: dockerMode(values),
174
182
  dockerDepth: integer(values.depth, '--depth'),
175
183
  containers: values.container,
@@ -539,6 +547,9 @@ function dispatch(cli, command, options, values) {
539
547
  export async function runCli(argv, deps = {}) {
540
548
  const io = deps.io ?? processIo();
541
549
  try {
550
+ if (argv[0] === 'apps') {
551
+ return await runApps(argv.slice(1), io, deps.signal);
552
+ }
542
553
  const { command, paths, values } = parseCli(argv);
543
554
  if (command === 'help') {
544
555
  io.stdout(USAGE);
@@ -632,6 +632,7 @@ class DockerScan {
632
632
  this.result.items.push(...(await this.scanEnvironment(env)));
633
633
  Object.assign(descriptor, {
634
634
  scanStatus: env.scanStatus,
635
+ scope: env.scanScope ?? null,
635
636
  hint: env.probeHint ?? null,
636
637
  layerSource: env.writableLayer?.source ?? null,
637
638
  timings: env.timings?.toJSON() ?? [],
@@ -213,9 +213,15 @@ export class WritableLayer {
213
213
  )
214
214
  )
215
215
  : 0;
216
+ // Upper-layer blocks are measured directly: merged-view link data
217
+ // does not describe them, so the writable bytes are final.
218
+ const plain = { ...usage };
219
+ delete plain.links;
220
+ delete plain.sharedBytes;
216
221
  result.set(target, {
217
- ...usage,
222
+ ...plain,
218
223
  bytes,
224
+ allocatedBytes: bytes,
219
225
  totalBytes: usage.bytes,
220
226
  imageBytes: known ? Math.max(0, usage.bytes - bytes) : 0,
221
227
  sizeUnknown: !known,
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Hard-link aware accounting of reclaimable blocks.
3
+ *
4
+ * `du` (and every per-path walk) counts each inode once per measured path,
5
+ * including inodes that also have links outside that path. Deleting such a
6
+ * path removes a directory entry but releases no file blocks while another
7
+ * link remains. A file's blocks are reclaimable only when every one of its
8
+ * links is inside the selected deletion set; several links that are all
9
+ * inside the set are reclaimable once.
10
+ *
11
+ * Usage objects carry:
12
+ * - `allocatedBytes`: blocks reachable from the path (what `du` prints);
13
+ * - `sharedBytes`: blocks of multi-link files with a link outside the path;
14
+ * - `bytes`: `allocatedBytes - sharedBytes`, the physically reclaimable part;
15
+ * - `links`: the multi-link non-directory entries below the path
16
+ * (`{path, id, nlink, bytes}`), used to account a whole selection.
17
+ */
18
+
19
+ /** POSIX shell fragment listing multi-link files below "$t" (GNU/busybox or BSD stat). */
20
+ export const LINKS_SCRIPT = `if stat -c %h / >/dev/null 2>&1; then
21
+ find "$t" -xdev ! -type d -links +1 -exec stat -c '%d:%i %h %b %B %n' -- {} + 2>/dev/null
22
+ else
23
+ find "$t" -xdev ! -type d -links +1 -exec stat -f '%d:%i %l %b 512 %N' {} + 2>/dev/null
24
+ fi`;
25
+
26
+ /** Parses LINKS_SCRIPT output lines (`dev:ino nlink blocks blocksize path`). */
27
+ export function parseLinks(text) {
28
+ const links = [];
29
+ for (const line of (text ?? '').split('\n')) {
30
+ const match = /^(\d+:\d+) (\d+) (\d+) (\d+) (.+)$/.exec(line);
31
+ if (match) {
32
+ links.push({
33
+ id: match[1],
34
+ nlink: Number(match[2]),
35
+ bytes: Number(match[3]) * Number(match[4]),
36
+ path: match[5],
37
+ });
38
+ }
39
+ }
40
+ return links;
41
+ }
42
+
43
+ function groupLinks(groups) {
44
+ const inodes = new Map();
45
+ for (const [target, links] of groups) {
46
+ for (const link of links ?? []) {
47
+ if (!inodes.has(link.id)) {
48
+ inodes.set(link.id, {
49
+ nlink: link.nlink,
50
+ bytes: link.bytes,
51
+ paths: new Set(),
52
+ targets: new Set(),
53
+ });
54
+ }
55
+ const inode = inodes.get(link.id);
56
+ inode.nlink = Math.max(inode.nlink, link.nlink);
57
+ inode.paths.add(link.path);
58
+ inode.targets.add(target);
59
+ }
60
+ }
61
+ return inodes;
62
+ }
63
+
64
+ /**
65
+ * Accounts a deletion set measured per path.
66
+ * @param {Array<[string, {allocated: number, links?: object[]}]>} measured
67
+ * one entry per selected path: its per-path allocated bytes (each inode
68
+ * counted once within that path) and its multi-link entries
69
+ * @returns {{allocatedBytes: number, sharedBytes: number, bytes: number}}
70
+ * blocks reachable from the set (each inode once), blocks retained by
71
+ * links outside the set, and the physically reclaimable remainder
72
+ */
73
+ export function accountLinks(measured) {
74
+ let allocated = measured.reduce((sum, [, entry]) => sum + entry.allocated, 0);
75
+ let shared = 0;
76
+ const inodes = groupLinks(
77
+ measured.map(([target, entry]) => [target, entry.links])
78
+ );
79
+ for (const inode of inodes.values()) {
80
+ // Per-path walks counted this inode once in every path containing it.
81
+ allocated -= (inode.targets.size - 1) * inode.bytes;
82
+ if (inode.paths.size < inode.nlink) {
83
+ shared += inode.bytes;
84
+ }
85
+ }
86
+ allocated = Math.max(0, allocated);
87
+ shared = Math.min(shared, allocated);
88
+ return {
89
+ allocatedBytes: allocated,
90
+ sharedBytes: shared,
91
+ bytes: allocated - shared,
92
+ };
93
+ }
94
+
95
+ /** Adds allocated/shared/reclaimable fields to one path's usage. */
96
+ export function withLinkAccounting(usage, links) {
97
+ if (!usage) {
98
+ return usage;
99
+ }
100
+ const allocated = usage.allocatedBytes ?? usage.bytes;
101
+ const accounted = accountLinks([['', { allocated, links }]]);
102
+ return {
103
+ ...usage,
104
+ bytes: accounted.bytes,
105
+ allocatedBytes: accounted.allocatedBytes,
106
+ sharedBytes: accounted.sharedBytes,
107
+ ...(links?.length ? { links } : {}),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Reclaimable bytes of several selected paths of one environment, from
113
+ * their usages. Links between selected paths are reclaimable; links
114
+ * outside the selection are not.
115
+ */
116
+ export function selectionBytes(usages, selected) {
117
+ const measured = selected
118
+ .map((target) => [target, usages.get(target)])
119
+ .filter(([, usage]) => usage)
120
+ .map(([target, usage]) => [
121
+ target,
122
+ {
123
+ allocated: usage.allocatedBytes ?? usage.bytes,
124
+ links: usage.links,
125
+ },
126
+ ]);
127
+ return accountLinks(measured);
128
+ }
129
+
130
+ /**
131
+ * Item size fields of the usages of one item's paths: `bytes` is the
132
+ * reclaimable total; `allocatedBytes`/`sharedBytes` are added only when
133
+ * hard links outside the item retain blocks.
134
+ */
135
+ export function itemSize(entries) {
136
+ const usages = new Map(entries.filter(([, usage]) => usage));
137
+ const accounted = selectionBytes(usages, [...usages.keys()]);
138
+ return accounted.sharedBytes > 0 ? accounted : { bytes: accounted.bytes };
139
+ }
package/src/env/local.js CHANGED
@@ -100,6 +100,7 @@ import { hostExecutor } from '../exec.js';
100
100
  import { parseBundleIdentifier } from '../app-owners.js';
101
101
  import { matchesGlob } from '../paths.js';
102
102
  import { mapLimit } from './parallel.js';
103
+ import { withLinkAccounting } from './hardlinks.js';
103
104
  import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
104
105
  import { indexedDirectories } from './platform-index.js';
105
106
 
@@ -213,6 +214,15 @@ async function makeWritable(target) {
213
214
  }
214
215
  }
215
216
 
217
+ function linkOf(stats, file) {
218
+ return {
219
+ path: file,
220
+ id: `${stats.dev}:${stats.ino}`,
221
+ nlink: stats.nlink,
222
+ bytes: diskBytes(stats),
223
+ };
224
+ }
225
+
216
226
  export class LocalEnv {
217
227
  /**
218
228
  * @param {{homes?: string[], tmpDirs?: string[], executor?: object,
@@ -322,6 +332,7 @@ export class LocalEnv {
322
332
  mtimeMs: stats.mtimeMs,
323
333
  ctimeMs: stats.ctimeMs,
324
334
  inode: `${stats.dev}:${stats.ino}`,
335
+ nlink: stats.nlink,
325
336
  uid: stats.uid,
326
337
  gid: stats.gid,
327
338
  mode: stats.mode,
@@ -440,14 +451,16 @@ export class LocalEnv {
440
451
  return null;
441
452
  }
442
453
  const seen = new Set();
454
+ const links = [];
443
455
  let bytes = 0;
444
456
  let apparentBytes = 0;
445
457
  let files = 0;
446
458
  let errors = 0;
447
459
  let newestMtimeMs = 0;
448
- const account = (stats) => {
460
+ const account = (stats, full) => {
449
461
  if (stats.nlink > 1 && !stats.isDirectory()) {
450
462
  const key = `${stats.dev}:${stats.ino}`;
463
+ links.push(linkOf(stats, full));
451
464
  if (seen.has(key)) {
452
465
  return;
453
466
  }
@@ -463,7 +476,7 @@ export class LocalEnv {
463
476
  newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
464
477
  }
465
478
  };
466
- account(root);
479
+ account(root, target);
467
480
  const visit = async (dir) => {
468
481
  let names;
469
482
  try {
@@ -489,7 +502,7 @@ export class LocalEnv {
489
502
  ) {
490
503
  continue;
491
504
  }
492
- account(stats);
505
+ account(stats, full);
493
506
  if (stats.isDirectory()) {
494
507
  subdirs.push(full);
495
508
  }
@@ -511,7 +524,10 @@ export class LocalEnv {
511
524
  if (files === 0) {
512
525
  newestMtimeMs = root.mtimeMs;
513
526
  }
514
- return { bytes, apparentBytes, files, errors, newestMtimeMs };
527
+ return withLinkAccounting(
528
+ { bytes, apparentBytes, files, errors, newestMtimeMs },
529
+ links
530
+ );
515
531
  }
516
532
 
517
533
  /**
@@ -734,6 +750,29 @@ export class LocalEnv {
734
750
  return usage;
735
751
  }
736
752
 
753
+ /** Multi-link files below `target`, which `du` cannot attribute. */
754
+ async #multiLinks(target) {
755
+ const result = await this.run([
756
+ 'find',
757
+ target,
758
+ '-xdev',
759
+ '!',
760
+ '-type',
761
+ 'd',
762
+ '-links',
763
+ '+1',
764
+ '-print0',
765
+ ]);
766
+ const files = result.stdout.split('\0').filter(Boolean);
767
+ const stats = await mapLimit(files, 32, async (file) => [
768
+ file,
769
+ await this.#lstat(file),
770
+ ]);
771
+ return stats
772
+ .filter(([, entry]) => entry)
773
+ .map(([file, entry]) => linkOf(entry, file));
774
+ }
775
+
737
776
  async quickUsage(target) {
738
777
  const stats = await this.stat(target);
739
778
  if (!stats) {
@@ -755,13 +794,16 @@ export class LocalEnv {
755
794
  if (result.code !== 0 || !match) {
756
795
  return this.rawUsage(target);
757
796
  }
758
- const usage = {
759
- bytes: Number(match[1]) * 1024,
760
- apparentBytes: Number(match[1]) * 1024,
761
- newestMtimeMs: 0,
762
- files: 0,
763
- errors: 0,
764
- };
797
+ const usage = withLinkAccounting(
798
+ {
799
+ bytes: Number(match[1]) * 1024,
800
+ apparentBytes: Number(match[1]) * 1024,
801
+ newestMtimeMs: 0,
802
+ files: 0,
803
+ errors: 0,
804
+ },
805
+ await this.#multiLinks(target)
806
+ );
765
807
  if (this.scanMetrics) {
766
808
  this.scanMetrics.directoriesSized++;
767
809
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Effective scan roots of one environment and the scope report that makes
3
+ * them visible.
4
+ *
5
+ * Root semantics (also documented in README "Scan roots"):
6
+ * - host: `roots` (positional paths) or, when none, the homes and temporary
7
+ * directories;
8
+ * - container (every Docker level): `containerRoots` replaces the defaults
9
+ * when given; otherwise the defaults (homes, temporary directories and
10
+ * the common workspace paths in CONTAINER_ROOTS that exist) plus every
11
+ * host `roots` path that also exists inside that container. Host and
12
+ * container namespaces differ, so a host root absent from a container is
13
+ * reported as `absent`; paths are never guessed or remapped.
14
+ * The same requested roots reach the traversal policy (mount selection)
15
+ * and project discovery, so both always agree.
16
+ */
17
+ import { existingPaths, matchesExcluded } from '../paths.js';
18
+
19
+ /** Common project locations inside containers, scanned when present. */
20
+ export const CONTAINER_ROOTS = [
21
+ '/workspace',
22
+ '/workspaces',
23
+ '/app',
24
+ '/src',
25
+ '/code',
26
+ '/project',
27
+ '/usr/src/app',
28
+ ];
29
+
30
+ /** Roots explicitly requested for `env` (null when only defaults apply). */
31
+ export function requestedRoots(env, options, overrides = {}) {
32
+ if (overrides.roots) {
33
+ return overrides.roots;
34
+ }
35
+ if (env.kind === 'container') {
36
+ return options.containerRoots ?? options.roots ?? null;
37
+ }
38
+ return options.roots ?? null;
39
+ }
40
+
41
+ const unique = (list) => [...new Set(list)];
42
+
43
+ /**
44
+ * Resolves the roots project discovery walks plus one scope entry per
45
+ * considered root.
46
+ * @returns {Promise<{roots: string[], entries: object[]}>}
47
+ */
48
+ export async function resolveRoots(env, options, overrides, homes, tmpDirs) {
49
+ const entries = [];
50
+ const add = (path, source) => {
51
+ if (!entries.some((entry) => entry.path === path)) {
52
+ entries.push({ path, source });
53
+ }
54
+ };
55
+ if (overrides.roots) {
56
+ overrides.roots.forEach((root) => add(root, 'roots'));
57
+ } else if (env.kind === 'container' && options.containerRoots) {
58
+ options.containerRoots.forEach((root) => add(root, 'container-roots'));
59
+ } else if (env.kind !== 'container' && options.roots) {
60
+ options.roots.forEach((root) => add(root, 'roots'));
61
+ } else {
62
+ homes.forEach((root) => add(root, 'home'));
63
+ tmpDirs.forEach((root) => add(root, 'tmp'));
64
+ if (env.kind === 'container') {
65
+ const present = await existingPaths(env, CONTAINER_ROOTS);
66
+ CONTAINER_ROOTS.filter((root) => present.has(root)).forEach((root) =>
67
+ add(root, 'container-default')
68
+ );
69
+ (options.roots ?? []).forEach((root) => add(root, 'roots'));
70
+ }
71
+ }
72
+ await classify(env, options, entries);
73
+ return {
74
+ roots: unique(
75
+ entries.filter((entry) => entry.status === 'pending').map((e) => e.path)
76
+ ),
77
+ entries,
78
+ };
79
+ }
80
+
81
+ async function classify(env, options, entries) {
82
+ const policy = env.scanPolicy;
83
+ const allowed = entries.filter((entry) => {
84
+ if (matchesExcluded(entry.path, options.exclude ?? [], env.path)) {
85
+ entry.status = 'excluded';
86
+ return false;
87
+ }
88
+ if (policy && !policy.allows(entry.path)) {
89
+ entry.status = 'pruned';
90
+ return false;
91
+ }
92
+ return true;
93
+ });
94
+ const present = allowed.length
95
+ ? await existingPaths(
96
+ env,
97
+ allowed.map((entry) => entry.path)
98
+ )
99
+ : new Set();
100
+ for (const entry of allowed) {
101
+ entry.status = present.has(entry.path) ? 'pending' : 'absent';
102
+ }
103
+ }
104
+
105
+ /** Scope descriptor stored on the environment and copied into reports. */
106
+ export function scanScope(env, options, entries) {
107
+ return {
108
+ roots: entries.map((entry) => ({ ...entry })),
109
+ maxDepth: options.maxDepth,
110
+ exclude: [...(options.exclude ?? [])],
111
+ prunedMounts: [...(env.scanPolicy?.pruned ?? [])],
112
+ note:
113
+ 'scanStatus "complete" means complete within these roots, maxDepth, ' +
114
+ 'exclusions and mount boundaries; fixed cache, toolchain and agent ' +
115
+ 'locations are checked independently of roots',
116
+ };
117
+ }
118
+
119
+ /** Final per-root status when the scanners of the environment finish. */
120
+ export function finishScope(env, ranProjects) {
121
+ const scope = env.scanScope;
122
+ if (!scope) {
123
+ return;
124
+ }
125
+ const denied = new Set(env.accessErrors?.keys() ?? []);
126
+ for (const root of scope.roots) {
127
+ if (root.status !== 'pending') {
128
+ continue;
129
+ }
130
+ if (denied.has(root.path)) {
131
+ root.status = 'inaccessible';
132
+ } else if (!ranProjects) {
133
+ root.status = 'not-searched';
134
+ } else {
135
+ root.status = env.scanStatus === 'complete' ? 'searched' : 'partial';
136
+ }
137
+ }
138
+ }
package/src/env/shell.js CHANGED
@@ -20,6 +20,7 @@ import { sharedWritableLayer } from '../docker/writable.js';
20
20
  import { containerExecutor, shellQuote, trace } from '../exec.js';
21
21
  import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
22
22
  import { batches } from './batches.js';
23
+ import { LINKS_SCRIPT, parseLinks, withLinkAccounting } from './hardlinks.js';
23
24
  import { signalExecutor } from './scope.js';
24
25
 
25
26
  const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
@@ -44,6 +45,8 @@ const USAGE_MANY_SCRIPT = `for t in "$@"; do
44
45
  du -skx -- "$t" 2>/dev/null | cut -f1
45
46
  find "$t" -xdev ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
46
47
  stat -c %Y -- "$t"
48
+ echo links
49
+ ${LINKS_SCRIPT}
47
50
  done
48
51
  true`;
49
52
 
@@ -120,10 +123,18 @@ function splitRecords(output) {
120
123
  }
121
124
 
122
125
  function parseUsage(body) {
123
- const [kib, counts = '', rootMtime] = body.trim().split('\n');
126
+ const [head, links = ''] = body.split('\nlinks\n');
127
+ const [kib, counts = '', rootMtime] = head.trim().split('\n');
124
128
  const [files, newest] = counts.trim().split(/\s+/).map(Number);
125
129
  const bytes = Number(kib) * 1024;
126
130
  const newestSeconds = files > 0 ? newest : Number(rootMtime);
131
+ return withLinkAccounting(
132
+ usageFields(bytes, files, newestSeconds),
133
+ parseLinks(links)
134
+ );
135
+ }
136
+
137
+ function usageFields(bytes, files, newestSeconds) {
127
138
  return {
128
139
  bytes: Number.isFinite(bytes) ? bytes : 0,
129
140
  apparentBytes: Number.isFinite(bytes) ? bytes : 0,
@@ -292,7 +303,7 @@ export class ShellEnv {
292
303
  const result = await this.executor.run([
293
304
  'stat',
294
305
  '-c',
295
- `${STAT_FORMAT}|%d:%i:%u:%g:%f`,
306
+ `${STAT_FORMAT}|%d:%i:%u:%g:%f:%h`,
296
307
  '--',
297
308
  ...batch,
298
309
  ]);
@@ -310,6 +321,7 @@ export class ShellEnv {
310
321
  identity.length > 4
311
322
  ? Number.parseInt(identity[4], 16)
312
323
  : undefined,
324
+ nlink: identity.length > 5 ? Number(identity[5]) : undefined,
313
325
  });
314
326
  }
315
327
  }
package/src/index.d.ts CHANGED
@@ -9,6 +9,13 @@ export type Duration = number | string;
9
9
  export interface ScanOptions {
10
10
  /** Directories to search for projects (default: homes and tmp dirs). */
11
11
  roots?: string[] | string | null;
12
+ /**
13
+ * Project roots inside every scanned container (all Docker depths).
14
+ * Replaces the container defaults; without it containers search their
15
+ * homes, temporary directories, common workspace paths and every `roots`
16
+ * path that exists inside them.
17
+ */
18
+ containerRoots?: string[] | string | null;
12
19
  /** Directory depth for project search. */
13
20
  maxDepth?: number;
14
21
  /** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
@@ -117,7 +124,15 @@ export interface Item {
117
124
  description: string;
118
125
  path: string | null;
119
126
  paths: string[];
127
+ /**
128
+ * Physically reclaimable bytes: blocks of hard-linked files with a link
129
+ * outside the item are not counted.
130
+ */
120
131
  bytes: number;
132
+ /** Blocks reachable from the item (each inode once); set with sharedBytes. */
133
+ allocatedBytes?: number;
134
+ /** Blocks retained by hard links outside the item; present when non-zero. */
135
+ sharedBytes?: number;
121
136
  /** Cached size is advisory; cleanup always measures again. */
122
137
  sizeEstimated?: boolean;
123
138
  /** Merged filesystem bytes, including image data, when measured in Docker. */
@@ -198,9 +213,32 @@ export interface EnvironmentDescriptor {
198
213
  | 'budget-exceeded'
199
214
  | 'error'
200
215
  | null;
216
+ /** What `scanStatus: complete` covers in this environment. */
217
+ scope?: ScanScope | null;
201
218
  layerSource?: string | null;
202
219
  }
203
220
 
221
+ export interface ScanScope {
222
+ roots: Array<{
223
+ path: string;
224
+ source: 'roots' | 'container-roots' | 'home' | 'tmp' | 'container-default';
225
+ status:
226
+ | 'searched'
227
+ | 'partial'
228
+ | 'absent'
229
+ | 'inaccessible'
230
+ | 'pruned'
231
+ | 'excluded'
232
+ | 'not-searched'
233
+ | 'pending';
234
+ }>;
235
+ maxDepth: number;
236
+ exclude: string[];
237
+ /** Mount points never traversed (unselected, read-only or overlay). */
238
+ prunedMounts: string[];
239
+ note: string;
240
+ }
241
+
204
242
  export interface TierTotal {
205
243
  items: number;
206
244
  bytes: number;
@@ -260,6 +298,10 @@ export interface AuditEntry {
260
298
  freedBytes: number;
261
299
  /** True when authoritative before/after allocation could not be measured. */
262
300
  freedEstimated?: boolean;
301
+ /** `'hardlink-aware-estimate'`: freedBytes is the link-aware selection estimate. */
302
+ freedAccounting?: 'hardlink-aware-estimate';
303
+ /** Blocks of deleted files that stay allocated through outside hard links. */
304
+ sharedBytes?: number;
263
305
  durationMs: number;
264
306
  backup?: string;
265
307
  [key: string]: unknown;
@@ -0,0 +1,286 @@
1
+ /** Explicit macOS application removal and conservative leftover inventory. */
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { LocalEnv } from './env/local.js';
7
+ import { LivenessProbe } from './liveness.js';
8
+ import { parseBundleIdentifier } from './app-owners.js';
9
+ import { runProcess } from './exec.js';
10
+ import { startAudit, writeAudit } from './audit.js';
11
+
12
+ const TRASH = `ObjC.import('Foundation');
13
+ function run(args) {
14
+ const url = $.NSURL.fileURLWithPath(args[0]);
15
+ if (!$.NSFileManager.defaultManager.trashItemAtURLResultingItemURLError(url, null, null))
16
+ throw new Error('macOS refused the Trash operation');
17
+ return 'In Trash';
18
+ }`;
19
+
20
+ export async function appIdentity(target, home = os.homedir()) {
21
+ const full = path.resolve(target);
22
+ const allowed = ['/Applications', path.join(home, 'Applications')].map(
23
+ (root) => path.resolve(root)
24
+ );
25
+ if (
26
+ !allowed.some((root) => full.startsWith(`${root}${path.sep}`)) ||
27
+ !full.endsWith('.app')
28
+ ) {
29
+ throw new Error(
30
+ 'Select an explicit .app inside /Applications or ~/Applications'
31
+ );
32
+ }
33
+ if ((await fs.realpath(full)) !== full) {
34
+ throw new Error('Symlinked app paths are not supported');
35
+ }
36
+ if (
37
+ full
38
+ .split(path.sep)
39
+ .slice(0, -1)
40
+ .some((part) => part.endsWith('.app'))
41
+ ) {
42
+ throw new Error(
43
+ 'Nested application components cannot be removed separately'
44
+ );
45
+ }
46
+ const stats = await fs.lstat(full);
47
+ if (!stats.isDirectory()) {
48
+ throw new Error('Application must be a directory');
49
+ }
50
+ const plist = await runProcess([
51
+ '/usr/bin/plutil',
52
+ '-convert',
53
+ 'xml1',
54
+ '-o',
55
+ '-',
56
+ path.join(full, 'Contents/Info.plist'),
57
+ ]);
58
+ const id = parseBundleIdentifier(plist.stdout);
59
+ if (plist.code || !id) {
60
+ throw new Error('Application identity cannot be verified');
61
+ }
62
+ return {
63
+ path: full,
64
+ id,
65
+ device: stats.dev,
66
+ inode: stats.ino,
67
+ webApp: plist.stdout.includes('CrAppModeShortcutID'),
68
+ hasReceipt: await fs
69
+ .access(path.join(full, 'Contents/_MASReceipt/receipt'))
70
+ .then(
71
+ () => true,
72
+ () => false
73
+ ),
74
+ };
75
+ }
76
+
77
+ export function leftoverCategory(kind) {
78
+ return ['Caches', 'Logs'].includes(kind)
79
+ ? 'disposable-after-activity-check'
80
+ : 'user-decision';
81
+ }
82
+
83
+ async function installedIds(root, ids, errors, depth = 0) {
84
+ if (depth > 4) {
85
+ return;
86
+ }
87
+ const entries = await fs.readdir(root, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ if (!entry.isDirectory()) {
90
+ continue;
91
+ }
92
+ const full = path.join(root, entry.name);
93
+ if (entry.name.endsWith('.app')) {
94
+ try {
95
+ const result = await runProcess([
96
+ '/usr/bin/plutil',
97
+ '-convert',
98
+ 'xml1',
99
+ '-o',
100
+ '-',
101
+ path.join(full, 'Contents/Info.plist'),
102
+ ]);
103
+ const id = parseBundleIdentifier(result.stdout);
104
+ if (!id) {
105
+ throw new Error('Unreadable app identity');
106
+ }
107
+ ids.add(id);
108
+ } catch {
109
+ errors.push(`Cannot inventory ${full}`);
110
+ }
111
+ } else {
112
+ await installedIds(full, ids, errors, depth + 1);
113
+ }
114
+ }
115
+ }
116
+
117
+ export async function appLeftovers(home = os.homedir()) {
118
+ const ids = new Set();
119
+ const errors = [];
120
+ for (const root of [
121
+ '/Applications',
122
+ '/System/Applications',
123
+ path.join(home, 'Applications'),
124
+ ]) {
125
+ try {
126
+ await installedIds(root, ids, errors);
127
+ } catch (error) {
128
+ errors.push(`${root}: ${error.message}`);
129
+ }
130
+ }
131
+ const items = [];
132
+ for (const kind of [
133
+ 'Caches',
134
+ 'Logs',
135
+ 'Application Support',
136
+ 'Containers',
137
+ 'Group Containers',
138
+ 'Preferences',
139
+ 'Saved Application State',
140
+ ]) {
141
+ const root = path.join(home, 'Library', kind);
142
+ const entries = await fs.readdir(root).catch(() => []);
143
+ for (const name of entries) {
144
+ const id = name.replace(/\.(plist|savedState)$/, '');
145
+ if (
146
+ (kind !== 'Logs' &&
147
+ !/^(?:com|org|net|io|dev|ai|app|at|ru|us|notion)\.[a-zA-Z0-9_.-]+$/.test(
148
+ id
149
+ )) ||
150
+ id.startsWith('com.apple.')
151
+ ) {
152
+ continue;
153
+ }
154
+ if (
155
+ [...ids].some(
156
+ (installed) =>
157
+ installed === id ||
158
+ installed.startsWith(`${id}.`) ||
159
+ id.startsWith(`${installed}.`)
160
+ )
161
+ ) {
162
+ continue;
163
+ }
164
+ items.push({
165
+ path: path.join(root, name),
166
+ category: leftoverCategory(kind),
167
+ status: 'possible-leftover',
168
+ reason:
169
+ 'No matching bundle in standard application folders; app may be elsewhere or ownership shared',
170
+ automaticDeletion: false,
171
+ });
172
+ }
173
+ }
174
+ return {
175
+ items,
176
+ errors,
177
+ note: 'Read-only candidates, not proof of uninstall. Logs can contain diagnostic history. No support data is automatically removed.',
178
+ };
179
+ }
180
+
181
+ async function trashApp(identity) {
182
+ const fresh = await appIdentity(identity.path);
183
+ if (
184
+ fresh.inode !== identity.inode ||
185
+ fresh.device !== identity.device ||
186
+ fresh.id !== identity.id
187
+ ) {
188
+ throw new Error('Application changed during removal');
189
+ }
190
+ const env = new LocalEnv();
191
+ const probe = new LivenessProbe(env, { staleAgeMs: 0 });
192
+ await probe.refresh(true);
193
+ await probe.resolve([identity.path]);
194
+ const blocker = probe.busyReason({
195
+ paths: [identity.path],
196
+ checks: { mtime: false },
197
+ });
198
+ if (blocker) {
199
+ throw new Error(blocker);
200
+ }
201
+ const result = await runProcess([
202
+ '/usr/bin/osascript',
203
+ '-l',
204
+ 'JavaScript',
205
+ '-e',
206
+ TRASH,
207
+ identity.path,
208
+ ]);
209
+ if (result.code) {
210
+ throw new Error(result.stderr || 'macOS Trash operation failed');
211
+ }
212
+ if (
213
+ await fs.lstat(identity.path).then(
214
+ () => true,
215
+ () => false
216
+ )
217
+ ) {
218
+ throw new Error('Trash operation did not remove the original path');
219
+ }
220
+ return result.stdout.trim();
221
+ }
222
+
223
+ function removalDryRun(values) {
224
+ return !values.yes || Boolean(values['dry-run']);
225
+ }
226
+
227
+ export async function runApps(argv, io, signal) {
228
+ if (process.platform !== 'darwin') {
229
+ throw new Error('apps is currently macOS-only');
230
+ }
231
+ const { positionals, values } = parseArgs({
232
+ args: argv,
233
+ allowPositionals: true,
234
+ options: {
235
+ yes: { type: 'boolean' },
236
+ 'dry-run': { type: 'boolean' },
237
+ 'launcher-only': { type: 'boolean' },
238
+ json: { type: 'boolean' },
239
+ 'audit-dir': { type: 'string' },
240
+ },
241
+ });
242
+ const [command, ...targets] = positionals;
243
+ if (command === 'leftovers') {
244
+ io.stdout(JSON.stringify(await appLeftovers(), null, 2));
245
+ return 0;
246
+ }
247
+ if (command !== 'remove' || !targets.length) {
248
+ throw new Error(
249
+ 'Use apps remove /absolute/App.app [--yes] [--launcher-only], or apps leftovers'
250
+ );
251
+ }
252
+ const audit = startAudit('apps-remove', {
253
+ dryRun: removalDryRun(values),
254
+ });
255
+ for (const target of targets) {
256
+ if (signal?.aborted) {
257
+ throw new Error('Aborted');
258
+ }
259
+ try {
260
+ const identity = await appIdentity(target);
261
+ if (identity.hasReceipt) {
262
+ throw new Error('App Store app: use the macOS uninstall flow');
263
+ }
264
+ if (identity.webApp && !values['launcher-only']) {
265
+ throw new Error(
266
+ 'Use Chrome > chrome://apps > Uninstall first; --launcher-only explicitly permits the Trash fallback, preserving browser data and registration'
267
+ );
268
+ }
269
+ const destination = audit.dryRun ? null : await trashApp(identity);
270
+ audit.entries.push({
271
+ ...identity,
272
+ status: audit.dryRun ? 'would-trash' : 'trashed',
273
+ destination,
274
+ });
275
+ } catch (error) {
276
+ audit.entries.push({
277
+ path: target,
278
+ status: 'blocked',
279
+ reason: error.message,
280
+ });
281
+ }
282
+ }
283
+ const file = await writeAudit(audit, { auditDir: values['audit-dir'] });
284
+ io.stdout(JSON.stringify({ ...audit, auditFile: file }, null, 2));
285
+ return audit.entries.some((entry) => entry.status === 'blocked') ? 1 : 0;
286
+ }
package/src/options.js CHANGED
@@ -8,6 +8,7 @@ import { parseDuration, parsePercent, parseSize } from './units.js';
8
8
 
9
9
  export const DEFAULTS = {
10
10
  roots: null,
11
+ containerRoots: null,
11
12
  maxDepth: 6,
12
13
  staleAge: '1h',
13
14
  inactive: '30d',
@@ -78,9 +79,11 @@ export function resolveOptions(input = {}) {
78
79
  throw new Error('scanBudget must be a positive finite duration');
79
80
  }
80
81
  const roots = toList(merged.roots);
82
+ const containerRoots = toList(merged.containerRoots);
81
83
  return {
82
84
  ...merged,
83
85
  roots: roots.length > 0 ? roots : null,
86
+ containerRoots: containerRoots.length > 0 ? containerRoots : null,
84
87
  maxDepth: Number(merged.maxDepth),
85
88
  dockerDepth: Number(merged.dockerDepth),
86
89
  staleAgeMs: toMs(staleAge),
package/src/report.js CHANGED
@@ -455,6 +455,18 @@ function swapLines(swap) {
455
455
  ];
456
456
  }
457
457
 
458
+ /** Requested roots that were not searched, so coverage gaps are visible. */
459
+ function scopeLines(env) {
460
+ const skipped = (env.scope?.roots ?? []).filter(
461
+ (root) =>
462
+ ['roots', 'container-roots'].includes(root.source) &&
463
+ !['searched', 'pending'].includes(root.status)
464
+ );
465
+ return skipped.map(
466
+ (root) => ` ${env.label}: root ${root.path} ${root.status}`
467
+ );
468
+ }
469
+
458
470
  function environmentStatusLines(env) {
459
471
  const lines = [];
460
472
  if (env.layerSource) {
@@ -470,6 +482,7 @@ function environmentStatusLines(env) {
470
482
  if (env.hint) {
471
483
  lines.push(` ${env.label}: ${env.hint}`);
472
484
  }
485
+ lines.push(...scopeLines(env));
473
486
  return lines;
474
487
  }
475
488
 
package/src/scan-state.js CHANGED
@@ -98,6 +98,7 @@ export async function authenticatedReport(report, directory, now = Date.now()) {
98
98
  export function scopeKey(options) {
99
99
  const names = [
100
100
  'roots',
101
+ 'containerRoots',
101
102
  'scanners',
102
103
  'host',
103
104
  'docker',
package/src/scan.js CHANGED
@@ -13,13 +13,20 @@ import { scanDocker } from './docker/scan.js';
13
13
  import { LocalEnv } from './env/local.js';
14
14
  import { configureScanPolicy } from './env/policy.js';
15
15
  import { configureCacheRoots } from './env/cache-roots.js';
16
+ import {
17
+ CONTAINER_ROOTS,
18
+ finishScope,
19
+ requestedRoots,
20
+ resolveRoots,
21
+ scanScope,
22
+ } from './env/roots.js';
16
23
  import { checkSignal, environmentScope } from './env/scope.js';
17
24
  import { block, tierTotals } from './items.js';
18
25
  import { hostExecutor, trace } from './exec.js';
19
26
  import { GitInspector } from './git.js';
20
27
  import { LivenessProbe } from './liveness.js';
21
28
  import { resolveOptions } from './options.js';
22
- import { existingPaths, isWithin, matchesExcluded } from './paths.js';
29
+ import { isWithin, matchesExcluded } from './paths.js';
23
30
  import { scanAgents } from './scanners/agents.js';
24
31
  import { GLOBAL_RULES, scanGlobal, targetedRules } from './scanners/global.js';
25
32
  import { scanProjects } from './scanners/projects.js';
@@ -50,6 +57,7 @@ const reportEnvironments = new WeakMap();
50
57
  export const scannedEnvironment = (report) => reportEnvironments.get(report);
51
58
  const RESCAN_OPTIONS = new Set([
52
59
  'roots',
60
+ 'containerRoots',
53
61
  'scanners',
54
62
  'containers',
55
63
  'host',
@@ -120,6 +128,7 @@ export async function revalidateReport(saved, input = {}) {
120
128
  const scope = Object.fromEntries(
121
129
  [
122
130
  'roots',
131
+ 'containerRoots',
123
132
  'scanners',
124
133
  'containers',
125
134
  'host',
@@ -140,25 +149,7 @@ export async function revalidateReport(saved, input = {}) {
140
149
  return sealReport(current);
141
150
  }
142
151
 
143
- /** Common project locations inside containers, scanned when present. */
144
- export const CONTAINER_ROOTS = [
145
- '/workspace',
146
- '/workspaces',
147
- '/app',
148
- '/src',
149
- '/code',
150
- '/project',
151
- '/usr/src/app',
152
- ];
153
-
154
- async function defaultRoots(env, homes, tmpDirs) {
155
- const roots = [...homes, ...tmpDirs];
156
- if (env.kind === 'container') {
157
- const present = await existingPaths(env, CONTAINER_ROOTS);
158
- roots.push(...CONTAINER_ROOTS.filter((root) => present.has(root)));
159
- }
160
- return [...new Set(roots)];
161
- }
152
+ export { CONTAINER_ROOTS };
162
153
 
163
154
  /**
164
155
  * Builds the scanner context of one environment.
@@ -169,7 +160,7 @@ async function defaultRoots(env, homes, tmpDirs) {
169
160
  export async function environmentContext(env, options, overrides = {}) {
170
161
  await configureScanPolicy(env, {
171
162
  ...options,
172
- roots: overrides.roots ?? options.roots,
163
+ roots: requestedRoots(env, options, overrides),
173
164
  });
174
165
  const liveness = new LivenessProbe(env, {
175
166
  staleAgeMs: options.staleAgeMs,
@@ -180,7 +171,14 @@ export async function environmentContext(env, options, overrides = {}) {
180
171
  const homes = await env.homeDirs();
181
172
  await configureCacheRoots(env, homes);
182
173
  const tmpDirs = await env.tmpDirs();
183
- const roots = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
174
+ const { roots, entries } = await resolveRoots(
175
+ env,
176
+ options,
177
+ overrides,
178
+ homes,
179
+ tmpDirs
180
+ );
181
+ env.scanScope = scanScope(env, options, entries);
184
182
  return {
185
183
  env,
186
184
  git: new GitInspector(env),
@@ -274,6 +272,7 @@ export async function scanEnvironment(env, options, extra = {}) {
274
272
  }
275
273
  return items;
276
274
  } finally {
275
+ finishScope(env, selectedScanners(options).includes('projects'));
277
276
  clearTimeout(timer);
278
277
  clearInterval(interval);
279
278
  options.signal?.removeEventListener('abort', abort);
@@ -551,6 +550,7 @@ function envDescriptor(env, depth, chain) {
551
550
  chain,
552
551
  hint: env.probeHint ?? null,
553
552
  scanStatus: env.scanStatus ?? null,
553
+ scope: env.scanScope ?? null,
554
554
  layerSource: env.writableLayer?.source ?? null,
555
555
  timings: env.timings?.toJSON() ?? [],
556
556
  };
@@ -570,6 +570,15 @@ async function withDisk(env, descriptor, options) {
570
570
  };
571
571
  }
572
572
 
573
+ /**
574
+ * Scans one running container found by the Docker walk (any depth). Roots
575
+ * come from `containerRoots`/`roots` via environmentContext, never from the
576
+ * host's resolved defaults.
577
+ */
578
+ export function containerScanner(options, errors) {
579
+ return (inner) => scanEnvironment(inner, options, { errors });
580
+ }
581
+
573
582
  async function dockerSection(env, options, errors) {
574
583
  if (options.docker === false) {
575
584
  return null;
@@ -583,7 +592,7 @@ async function dockerSection(env, options, errors) {
583
592
  const result = await scanDocker(
584
593
  hostRecord,
585
594
  { ...options, selfContainerIds: await selfContainerIds(env) },
586
- (inner) => scanEnvironment(inner, options, { errors })
595
+ containerScanner(options, errors)
587
596
  );
588
597
  errors.push(...result.errors);
589
598
  if (options.docker === true && result.daemons.length === 0) {
@@ -20,6 +20,7 @@ import { scanItems } from '../env/scope.js';
20
20
  * (the same rule the agent's own `Project.prune()` uses).
21
21
  */
22
22
 
23
+ import { itemSize } from '../env/hardlinks.js';
23
24
  import { block, makeItem } from '../items.js';
24
25
  import { listMany, olderThan, scanTimeBusy } from './common.js';
25
26
 
@@ -127,7 +128,7 @@ async function appItems(context, app, root) {
127
128
  ecosystem: 'agents',
128
129
  description: `${app.name} snapshot store ${state.entry.name}`,
129
130
  path: state.entry.path,
130
- bytes: usage.bytes,
131
+ ...itemSize([[state.entry.path, usage]]),
131
132
  newestMtimeMs: usage.newestMtimeMs,
132
133
  worktree: state.worktree,
133
134
  ...verdict,
@@ -6,6 +6,7 @@ import { checkSignal, scanItems } from '../env/scope.js';
6
6
  * ../rules/downloads.js.
7
7
  */
8
8
 
9
+ import { itemSize } from '../env/hardlinks.js';
9
10
  import { CACHE_RULES } from '../rules/ecosystems.js';
10
11
  import { OTHER_RULES, TEMP_CLONE_RULES } from '../rules/other.js';
11
12
  import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
@@ -199,7 +200,7 @@ async function buildItem(context, rule, base, group, homes) {
199
200
  description: rule.description,
200
201
  path: paths[0],
201
202
  paths,
202
- bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
203
+ ...itemSize(group.map((entry) => [entry.path, entry.usage])),
203
204
  sizeEstimated: group.some((entry) => entry.usage.estimated),
204
205
  newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
205
206
  tier: rule.tier ?? 'safe',
@@ -1,5 +1,6 @@
1
1
  import { performance } from 'node:perf_hooks';
2
2
 
3
+ import { itemSize } from '../env/hardlinks.js';
3
4
  import { scanItems } from '../env/scope.js';
4
5
  /**
5
6
  * Per-project dependency, build and cache directories (node_modules, target,
@@ -241,7 +242,7 @@ async function projectItem(context, { candidate, rule }, usage) {
241
242
  ecosystem: rule.ecosystem,
242
243
  description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
243
244
  path: candidate.path,
244
- bytes: usage?.bytes ?? 0,
245
+ ...itemSize([[candidate.path, usage]]),
245
246
  sizeEstimated: Boolean(usage?.estimated),
246
247
  newestMtimeMs: usage?.newestMtimeMs ?? 0,
247
248
  project,