disk-space-saviour 0.13.1 → 0.15.2

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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 841a999: Fix the remaining CI/CD false positives, false negatives and warnings: tests that cannot run on a runner are reported as skipped with the reason instead of passing vacuously or vanishing from the summary, the Deno leg runs with `-A --parallel`, the pedantic zizmor pass runs online and triggers on every file it reads, jscpd 5 receives the comment-skipping setting it understands, the example app reviews its locked install scripts, and release logs no longer warn about `deno.lock` or print the changeset publish output twice.
8
+
9
+ ## 0.15.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 2f4744b: Fix CI/CD false positives, false negatives, warnings and errors: release-preflight now proves npm trusted publishing with the OIDC package exchange, releases wait for every check (including the Docker-in-Docker job) and refuse to run after a failed or cancelled job, registry verification tolerates CDN lag, PR guards compare against a verified merge base, audit findings are cleared by upgrading Changesets, jscpd and lint-staged, and non-blocking advisories are reported as a notice. Runner images and workflow tools are pinned, CodeQL scans only shipped code, a missing GitHub release is recovered for a version already on npm, the link checker re-checks transient failures and no longer warns about an empty HTML glob, deno.lock is kept in step with package.json, and skipped tests name the real reason.
14
+
15
+ ## 0.15.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 1613aa2: Protect running package tools, expand safe application caches, respect scan boundaries and exclusions, and bound container writable-layer inspection with honest unknown-size reporting.
20
+
21
+ ## 0.14.1
22
+
23
+ ### Patch Changes
24
+
25
+ - Tests: the repository URL check no longer reads the environment under Deno, which runs the suite without env access.
26
+
27
+ The repository moved to link-foundation/disk-space-saviour: package metadata and the example app link to the new location, so npm provenance matches the repository that publishes.
28
+
29
+ ## 0.14.0
30
+
31
+ ### Minor Changes
32
+
33
+ - Make Docker cleanup reports resilient to size-query races and count writable-layer cache bytes separately from immutable image data. Improve process matching, privileged read-only liveness inspection, per-hash npx protection, stopped-container Git inspection and recovery-ref recognition. Add aged isolation-log, sanitized-upload, Claude Code and Copilot CLI cleanup candidates, direct opam download-cache removal, investigation-hold age controls, and complete scan audits with grouped hints and watched processes.
34
+
35
+ Release: npm publishes only through trusted publishing. The workflow no longer reads an `NPM_TOKEN` secret, the credential preflight requires the OIDC permission and flags any npm token, and the first-publish guidance points to package-registry-manager instead of a token.
36
+
3
37
  ## 0.13.1
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -70,7 +70,9 @@ Run `dss --help` for every option. The most useful ones:
70
70
 
71
71
  - `--only NAME` limits a run to an ecosystem, rule or kind (`--only rust`,
72
72
  `--only npm-cache`, `--only docker-stopped-container`).
73
- - `--exclude PATH|GLOB` makes paths untouchable.
73
+ - `--exclude PATH|GLOB` prunes matching paths and descendants before reading
74
+ them. Patterns containing separators match full paths and their ancestors;
75
+ other patterns also match ancestor basenames.
74
76
  - `--scanner NAME` restricts scanning to `projects`, `global`, `versions`,
75
77
  `agents` or `system`.
76
78
  - `--report FILE` limits cleaning to items in a saved `dss scan --json`
@@ -81,24 +83,28 @@ Run `dss --help` for every option. The most useful ones:
81
83
  Tiers are cumulative: `moderate` includes `safe`, and `aggressive` includes
82
84
  both.
83
85
 
84
- | Tier | What it removes |
85
- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
- | `safe` | Download caches (npm, pnpm, yarn, pip, uv, Cargo registry, Gradle, Go, NuGet, and many more), dangling Docker images and build cache. Includes these caches inside running containers. |
87
- | `moderate` | Whole `node_modules`, `target/`, `.venv`, `build/` and similar folders of projects inactive for `--inactive` (30 days); toolchain versions nothing uses; unused tagged images, each one only with `--remove-image REF` or an interactive yes; stopped containers (`--remove-stopped-containers` or an interactive yes). |
88
- | `aggressive` | For emergencies: everything regenerable, including build outputs of projects in active use. Only `dss emergency` reaches it. |
86
+ | Tier | What it removes |
87
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | `safe` | Download and application caches, idle package-runner entries, old browser revisions, inactive lockfile-backed `node_modules`, dangling Docker images and build cache. Includes writable caches inside running containers. |
89
+ | `moderate` | Whole `target/`, `.venv`, `build/` and similar folders of inactive projects; old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable); unused toolchain versions and tagged images; stopped containers. Container and tagged-image removal requires explicit consent. |
90
+ | `aggressive` | For emergencies: everything regenerable, including build outputs of projects in active use. Only `dss emergency` reaches it. |
89
91
 
90
92
  Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
91
93
  framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
92
94
  Go, C/C++ (CMake, ccache, vcpkg, Conan), .NET, PHP, Ruby, Swift/Xcode, Dart,
93
95
  Haskell, Scala, Elixir, OCaml, Lean, Julia, R, Zig, and scripting languages.
94
96
  Also covered: version managers (nvm, pyenv, rbenv, SDKMAN!, rustup, elan,
95
- ghcup, opam, swiftly, VS Code Server), Playwright, playwright-go, Puppeteer
97
+ ghcup, opam, swiftly, VS Code Server, Claude Code and Copilot CLI), Playwright, playwright-go, Puppeteer
96
98
  and Cypress browsers, HTTP caches of Chrome, Chromium, Edge, Brave, Opera,
97
99
  Opera GX, Vivaldi, Arc, Yandex Browser, Naver Whale, 360, QQ Browser, Firefox,
98
100
  LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
99
101
  Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
100
102
  updates staged by Sparkle and Squirrel, package manager archives, trash, crash
101
- reports, core dumps and journald logs.
103
+ reports, core dumps and journald logs. Telegram pure caches, Safari/WebKit,
104
+ Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
105
+ caches are included. Telegram databases, browser profiles and Playwright MCP
106
+ profiles are preserved. macOS access failures explain when Full Disk Access
107
+ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
102
108
 
103
109
  ## Safety
104
110
 
@@ -129,13 +135,35 @@ reports, core dumps and journald logs.
129
135
  environment stops at once, and the audit log and summary say which
130
136
  process was lost. A build that simply finishes during the cleanup also
131
137
  stops it: an exit cannot be told apart from a crash.
132
- - **Git awareness.** A project or container is not touched while it has
133
- uncommitted changes, unpushed commits or stashes. The blocker is shown in
134
- the report. Stopped containers with Git work require an exact
138
+ - **Git awareness.** Whole projects and containers are protected while they
139
+ have unsaved changes, unpushed commits or stashes. Ignored dependency and
140
+ build outputs can be cleaned despite unrelated source changes; tracked or
141
+ non-ignored files inside an artifact remain protected. Stopped-container scans show
142
+ Git state before cleanup. Unstaged and untracked files can be recognized
143
+ as preserved when their bytes and modes all match one cached remote ref,
144
+ such as `origin/recovery/<branch>`. No remote fetch is performed; missing
145
+ refs, staged work and unreadable files remain blockers. Detached upstream
146
+ checkouts are accepted only when their commit is reachable from a remote
147
+ ref. Nested Docker layer copies and temporary Codex plugin checkouts are
148
+ excluded from this inspection. Unsaved container work requires an exact
135
149
  `--allow-dirty-container ID` override.
136
150
  - **Audit log on every run.** Each run, dry runs included, writes
137
151
  `dss-<command>-<time>-<pid>.json` with every decision, the bytes freed
138
- and left per environment, and disk usage before and after.
152
+ and left per environment, and disk usage before and after. Scan audits
153
+ include every candidate and its blockers; blocked candidates do not
154
+ contribute to reclaimable tier totals.
155
+
156
+ Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
157
+ header caches to be cleaned. Active npx hashes remain protected through
158
+ their child command paths, working directories and open files; other
159
+ hashes are separate candidates subject to the age window. Install commands
160
+ still block relevant caches. Bunx, pnpm dlx and Yarn dlx entries use the same
161
+ path protection, and dss always protects its own installation. Interrupts
162
+ finish the partial audit and report the abort. Unreadable processes retain
163
+ their readable identity; uncertain activity blocks relevant paths and owners
164
+ and produces one environment hint. Old isolation logs and sanitized upload staging
165
+ are moderate-tier candidates. Opam download cleanup removes its cache
166
+ directory directly and does not invoke `opam clean` or migrate the root.
139
167
 
140
168
  ## Docker
141
169
 
@@ -155,7 +183,11 @@ three kinds of Docker data:
155
183
  the tier totals match what `clean --tier … --yes` removes.
156
184
  - **Running containers** are never stopped, restarted or removed. They are
157
185
  scanned and cleaned from the inside through `docker exec`, with the same
158
- rules and liveness checks as the host.
186
+ rules and liveness checks as the host. An unreadable `/proc` probe is
187
+ retried with a read-only root `docker exec --privileged` inspection. If
188
+ this is denied or incomplete, cleanup stays blocked and names the
189
+ unreadable processes. This also applies when dss runs inside a container
190
+ with access to its Docker daemon.
159
191
  - **Stopped containers** are listed with their size, image, command, owner
160
192
  session (`HIVE_MIND_PARENT_SESSION_ID` and other session labels or
161
193
  environment variables, else a session id in the container name), task
@@ -169,6 +201,34 @@ three kinds of Docker data:
169
201
  killed) are never removed by a tier or by `--remove-stopped-containers`,
170
202
  in emergency mode too. Name each one with `--remove-container ID|NAME`
171
203
  (a full name, or an id prefix of at least 12 characters) to remove it.
204
+ Alternatively, `--investigation-max-age 7d` lifts holds for containers
205
+ that ended at least seven days ago; removal still requires consent and
206
+ Git checks. The default keeps investigation holds indefinitely.
207
+
208
+ Container cache sizes count changed writable-layer file blocks and mounted
209
+ data. Immutable image data is reported separately as `imageBytes` and is
210
+ excluded from reclaimable totals, including the host scan when dss runs
211
+ inside an identifiable container. Image-only caches stay visible and blocked;
212
+ rebuild the image to remove that data. These estimates exclude directory
213
+ metadata overhead. A small stopped container pinning a large image is shown
214
+ even below `--min-size`; its potential image bytes require a separate image
215
+ removal after the container is removed.
216
+
217
+ `--no-docker` makes no Docker calls, including for storage accounting and
218
+ saved-report cleanup. An overlay root without authoritative upperdir evidence
219
+ has unknown freeable bytes and contributes zero to reclaimable totals.
220
+ Measurements prefer accessible upperdirs; the diff fallback shares one
221
+ serialized snapshot and cached stats per container/run, checks host and cgroup
222
+ memory headroom, and skips excessive candidate counts. Fresh Git checks before
223
+ stopped-container removal still inspect new work. Verbose mode reports storage
224
+ measurement progress. Host scans prune runtime, nested overlay, read-only and
225
+ unselected mount points; explicitly selected ordinary mount roots are allowed.
226
+
227
+ Docker discovery uses a size-free container listing and measures each
228
+ container independently. Failed size queries are retried once; objects
229
+ remain visible with `sizeUnknown` when measurement fails. Scans continue
230
+ through other objects and nested daemons, report partial errors, and return
231
+ a nonzero CLI exit status when discovery is incomplete.
172
232
 
173
233
  `--recursive` follows Docker-in-Docker: a container that runs its own daemon
174
234
  is scanned as a new environment, down to `--depth` levels (default 3). The
@@ -283,7 +343,7 @@ Configured in `.prettierrc`:
283
343
  npm install
284
344
  npm test # Node.js, 30s per test
285
345
  bun test --timeout 30000 # Bun
286
- deno test --allow-read # Deno (read-only tests)
346
+ deno test -A --parallel # Deno (fixtures need -A)
287
347
  npm run check # lint + format + duplication
288
348
  npm run test:dind # Docker-in-Docker integration (needs --privileged)
289
349
  node bin/dss.js scan --verbose
@@ -367,14 +427,14 @@ The release workflow uses [Changesets](https://github.com/changesets/changesets)
367
427
  5. **Optional Docker Hub publishing**: When configured, waits for the exact npm version and tags the Docker image with that version
368
428
  6. **GitHub releases**: Auto-created with formatted release notes
369
429
 
370
- > **First release of a brand-new package**: OIDC trusted publishing cannot
371
- > create a package that does not exist yet (the first publish fails with
372
- > `E404`, because a trusted publisher can only be configured for an existing
373
- > package). To bootstrap, add a repository secret named `NPM_TOKEN` (a
374
- > granular/automation token with publish access). The release workflow passes
375
- > it as `NODE_AUTH_TOKEN` automatically. Once the package exists and OIDC
376
- > trusted publishing is configured on npmjs.com, the token becomes optional and
377
- > can be removed.
430
+ > **First release of a brand-new package**: npm publishes only through
431
+ > trusted publishing, and a trusted publisher can only be attached to a package
432
+ > that already exists (the first publish from CI fails with `E404`). Publish the
433
+ > first version once with
434
+ > [package-registry-manager](https://github.com/link-foundation/package-registry-manager):
435
+ > `npx package-registry-manager setup --registry npm --execute`. It signs in
436
+ > through the browser, publishes, attaches this workflow as the trusted
437
+ > publisher and signs out again. No npm token is ever stored.
378
438
 
379
439
  #### Manual Releases
380
440
 
@@ -411,7 +471,7 @@ The GitHub Actions workflow (`.github/workflows/release.yml`) implements a fast-
411
471
 
412
472
  Every CI job declares an explicit `timeout-minutes` so hung steps fail
413
473
  in minutes instead of reaching the GitHub Actions default of six hours.
414
- Fast checks use 5-10 minute caps, release jobs use 30 minutes, and the
474
+ Fast checks use 5-10 minute caps, release jobs use 50 minutes, and the
415
475
  link checker uses 10 minutes for external network variance.
416
476
 
417
477
  That cap is a backstop, never the deadline: GitHub reports a job it
@@ -485,13 +545,14 @@ variants without script edits.
485
545
 
486
546
  The link checker workflow (`.github/workflows/links.yml`) validates all links in Markdown and HTML files:
487
547
 
488
- 1. **Detection**: Uses [lychee](https://github.com/lycheeverse/lychee-action) to scan all `*.md` and `*.html` files
489
- 2. **Web Archive fallback**: For any broken links found, automatically checks the [Wayback Machine](https://web.archive.org) for archived versions
490
- 3. **Actionable suggestions**: Reports one of three outcomes for each broken link:
548
+ 1. **Detection**: Uses [lychee](https://github.com/lycheeverse/lychee-action) to walk the checkout for `.md` and `.html` files (respecting `.gitignore`, including dot directories such as `.changeset`)
549
+ 2. **Transient re-check**: Links that failed with a transport error or an HTTP 429/5xx answer are retried with a capped wait before they count as broken; GitHub file links fall back to the contents API. Set `RECHECK_VERBOSE=true` to log every probe
550
+ 3. **Web Archive fallback**: For any broken links found, automatically checks the [Wayback Machine](https://web.archive.org) for archived versions
551
+ 4. **Actionable suggestions**: Reports one of three outcomes for each broken link:
491
552
  - **Archived**: Suggests the Web Archive URL as a replacement
492
553
  - **Not archived**: Clearly reports the link is unrecoverable
493
- 4. **Scheduled checks**: Runs weekly to catch links that break over time (even if no files changed)
494
- 5. **Issue creation**: On scheduled runs, creates a GitHub Issue with the full broken links report
554
+ 5. **Scheduled checks**: Runs weekly to catch links that break over time (even if no files changed)
555
+ 6. **Issue creation**: On scheduled runs, creates a GitHub Issue with the full broken links report
495
556
 
496
557
  Add regex patterns to `.lycheeignore` to exclude URLs from checks (e.g., local dev URLs, example.com, known rate-limited sites).
497
558
 
package/bin/dss.js CHANGED
@@ -2,4 +2,15 @@
2
2
 
3
3
  import { runCli } from '../src/cli.js';
4
4
 
5
- process.exitCode = await runCli(process.argv.slice(2));
5
+ const controller = new AbortController();
6
+ const abort = () => controller.abort();
7
+ process.once('SIGINT', abort);
8
+ process.once('SIGTERM', abort);
9
+ try {
10
+ process.exitCode = await runCli(process.argv.slice(2), {
11
+ signal: controller.signal,
12
+ });
13
+ } finally {
14
+ process.removeListener('SIGINT', abort);
15
+ process.removeListener('SIGTERM', abort);
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.13.1",
3
+ "version": "0.15.2",
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",
@@ -68,19 +68,19 @@
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
71
- "url": "git+https://github.com/konard/disk-space-saviour.git"
71
+ "url": "git+https://github.com/link-foundation/disk-space-saviour.git"
72
72
  },
73
73
  "engines": {
74
74
  "node": ">=20.0.0"
75
75
  },
76
76
  "devDependencies": {
77
- "@changesets/cli": "^2.29.7",
77
+ "@changesets/cli": "^3.0.3",
78
78
  "eslint": "^9.38.0",
79
79
  "eslint-config-prettier": "^10.1.8",
80
80
  "eslint-plugin-prettier": "^5.5.4",
81
81
  "husky": "^9.1.7",
82
- "jscpd": "^4.0.5",
83
- "lint-staged": "^16.2.6",
82
+ "jscpd": "^5.4.0",
83
+ "lint-staged": "^16.4.0",
84
84
  "prettier": "^3.6.2",
85
85
  "test-anywhere": "^0.8.48"
86
86
  },
package/src/audit.js CHANGED
@@ -13,6 +13,12 @@ import path from 'node:path';
13
13
 
14
14
  export const AUDIT_SCHEMA = 1;
15
15
 
16
+ export function throwIfAborted(options) {
17
+ if (options.signal?.aborted) {
18
+ throw new Error('cleanup aborted');
19
+ }
20
+ }
21
+
16
22
  function stateDir(vars) {
17
23
  const base =
18
24
  vars.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
package/src/clean.js CHANGED
@@ -18,16 +18,19 @@ import path from 'node:path';
18
18
  import { finished } from 'node:stream/promises';
19
19
 
20
20
  import { backupDirectory, startAudit, writeAudit } from './audit.js';
21
+ import { configureContainerHost } from './docker/writable.js';
21
22
  import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
22
23
  import {
23
24
  containerGitState,
24
25
  containerNamed,
25
26
  gitBlockersForRemoval,
26
27
  investigationBlocker,
28
+ investigationExpired,
27
29
  runningBindMounts,
28
30
  } from './docker/containers.js';
29
31
  import { imageNamed } from './docker/images.js';
30
32
  import { resolveEnvironment } from './env/resolve.js';
33
+ import { configureScanPolicy } from './env/policy.js';
31
34
  import { GitInspector } from './git.js';
32
35
  import { HealthWatch } from './health.js';
33
36
  import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
@@ -35,9 +38,15 @@ import { LivenessProbe } from './liveness.js';
35
38
  import { resolveOptions } from './options.js';
36
39
  import { isWithin } from './paths.js';
37
40
  import { OTHER_RULES } from './rules/other.js';
38
- import { compareVersions } from './rules/versions.js';
41
+ import { compareVersions, VERSION_RULES } from './rules/versions.js';
42
+ import { scanVersions } from './scanners/versions.js';
39
43
  import { analyzeRustProfile } from './scanners/rust.js';
40
- import { filterItems, revalidateReport } from './scan.js';
44
+ import {
45
+ filterItems,
46
+ revalidateReport,
47
+ selfContainerIds,
48
+ scannedEnvironment,
49
+ } from './scan.js';
41
50
 
42
51
  const STOPPED = new Set(['exited', 'created', 'dead']);
43
52
  const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
@@ -123,6 +132,23 @@ async function browserRevisionBlocker(env, item) {
123
132
  return null;
124
133
  }
125
134
 
135
+ async function configureStorage(env, options) {
136
+ await configureScanPolicy(env, options);
137
+ if (env.kind === 'host' && !env.writableLayer) {
138
+ await configureContainerHost(
139
+ env,
140
+ options.docker === false ? [] : await selfContainerIds(env),
141
+ options
142
+ );
143
+ }
144
+ }
145
+
146
+ function imageOnly(bytes, usages) {
147
+ return (
148
+ bytes === 0 && [...usages.values()].some((usage) => usage.imageBytes > 0)
149
+ );
150
+ }
151
+
126
152
  export class Cleaner {
127
153
  /**
128
154
  * @param {object} report output of `scan()`
@@ -229,7 +255,8 @@ export class Cleaner {
229
255
  if (type === 'docker-rm') {
230
256
  const git = await containerGitState(
231
257
  new DockerCli(ctx.executor),
232
- item.action.containerId
258
+ item.action.containerId,
259
+ { fresh: true }
233
260
  );
234
261
  const blockers = gitBlockersForRemoval(
235
262
  git,
@@ -241,6 +268,17 @@ export class Cleaner {
241
268
  if (type === 'none' || item.paths.length === 0) {
242
269
  return { item };
243
270
  }
271
+ await configureStorage(ctx.env, this.options);
272
+ for (const target of item.paths) {
273
+ const boundary = ctx.env.scanPolicy.removalReason(target);
274
+ if (boundary) {
275
+ return { reason: boundary };
276
+ }
277
+ }
278
+ return this.#remeasure(ctx, item, type);
279
+ }
280
+
281
+ async #remeasure(ctx, item, type) {
244
282
  let paths = item.paths;
245
283
  if (item.recheck?.type === 'rust') {
246
284
  const current = await analyzeRustProfile(ctx.env, item.recheck.profile, {
@@ -263,10 +301,22 @@ export class Cleaner {
263
301
  const fresh = {
264
302
  ...item,
265
303
  paths,
266
- bytes: type === 'remove' ? sumBytes(usages, paths) : item.bytes,
304
+ bytes: sumBytes(usages, paths),
267
305
  newestMtimeMs: newest || item.newestMtimeMs,
268
306
  action: type === 'remove' ? { ...item.action, paths } : item.action,
269
307
  };
308
+ if (imageOnly(fresh.bytes, usages)) {
309
+ return {
310
+ reason:
311
+ 'all bytes are in the image layer; rebuild the image to reclaim them',
312
+ };
313
+ }
314
+ if ([...usages.values()].some((usage) => usage.sizeUnknown)) {
315
+ return {
316
+ reason:
317
+ 'writable-layer size unknown; inspect container/image storage before cleanup',
318
+ };
319
+ }
270
320
  const staticBlocker = await this.#staticBlocker(ctx, fresh);
271
321
  if (staticBlocker) {
272
322
  return { reason: staticBlocker };
@@ -306,6 +356,27 @@ export class Cleaner {
306
356
  }
307
357
 
308
358
  async #staticBlocker(ctx, item) {
359
+ if (item.recheck?.type === 'version') {
360
+ const rule = VERSION_RULES.find(
361
+ (candidate) => candidate.id === item.rule
362
+ );
363
+ const candidates = await scanVersions(
364
+ {
365
+ ...ctx,
366
+ options: {
367
+ ...this.options,
368
+ homes: [item.recheck.home],
369
+ roots: this.report.options.roots ?? [],
370
+ },
371
+ now: this.options.now(),
372
+ liveness: null,
373
+ },
374
+ { rules: [rule] }
375
+ );
376
+ if (!candidates.some((candidate) => candidate.path === item.path)) {
377
+ return `${item.path} is now current, pinned, or the newest installed version`;
378
+ }
379
+ }
309
380
  if (
310
381
  item.recheck?.type === 'pyenv-envs' &&
311
382
  (await ctx.env.list(ctx.env.path.join(item.path, 'envs'))).length > 0
@@ -358,13 +429,20 @@ export class Cleaner {
358
429
  }
359
430
 
360
431
  async #gitBlocker(ctx, item) {
361
- if (item.project || item.rule === 'gh-issue-solver-workdirs') {
432
+ if (
433
+ item.project ||
434
+ item.rule === 'gh-issue-solver-workdirs' ||
435
+ item.recheck?.type === 'scratch'
436
+ ) {
362
437
  ctx.git.invalidate();
363
438
  const blockers =
364
- item.rule === 'gh-issue-solver-workdirs'
365
- ? await ctx.git.treeBlockers(item.path)
439
+ item.rule === 'gh-issue-solver-workdirs' ||
440
+ item.recheck?.type === 'scratch'
441
+ ? await ctx.git.treeBlockers(item.path, {
442
+ allowNoRepo: item.recheck?.type === 'scratch',
443
+ })
366
444
  : await ctx.git.pathBlockers(item.path, {
367
- requireClean: item.tier !== 'safe',
445
+ requireClean: false,
368
446
  });
369
447
  if (blockers.length > 0) {
370
448
  return blockers.join('; ');
@@ -392,28 +470,28 @@ export class Cleaner {
392
470
  */
393
471
  async #removePaths(ctx, item, entry) {
394
472
  for (const target of item.action.paths) {
395
- await ctx.liveness.refresh(true);
396
- const stopped = this.health.verify(item, ctx.liveness.processList());
397
- if (stopped) {
398
- entry.reason = `stopped before ${target}: ${stopped}`;
399
- return;
473
+ if (this.options.signal?.aborted) {
474
+ throw new Error('cleanup aborted');
400
475
  }
401
- await ctx.liveness.resolve([target]);
402
- const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
403
- if (busy) {
404
- entry.reason = `stopped before ${target}: busy: ${busy}`;
476
+ const blocker = await this.#removalBlocker(ctx, item, target);
477
+ if (blocker) {
478
+ entry.reason = blocker;
405
479
  return;
406
480
  }
407
481
  const usage = await ctx.env.usage(target);
408
- const inUse =
409
- item.checks?.perPath &&
410
- (ctx.liveness.openInside([target]) ??
411
- ctx.liveness.recentWrite(usage?.newestMtimeMs));
482
+ const inUse = this.#pathActivity(ctx, item, target, usage);
412
483
  if (inUse) {
413
484
  entry.skippedPaths.push({ path: target, reason: inUse });
414
485
  continue;
415
486
  }
487
+ // The usage walk may discover an excluded child that was added since scan.
488
+ const boundary = ctx.env.scanPolicy.removalReason(target);
489
+ if (boundary) {
490
+ entry.reason = boundary;
491
+ return;
492
+ }
416
493
  await ctx.env.remove([target]);
494
+ ctx.env.writableLayer?.forget?.(target, ctx.env.path);
417
495
  entry.status = 'removed';
418
496
  entry.deletedPaths.push(target);
419
497
  entry.freedBytes += usage?.bytes ?? 0;
@@ -424,6 +502,30 @@ export class Cleaner {
424
502
  }
425
503
  }
426
504
 
505
+ #pathActivity(ctx, item, target, usage) {
506
+ return (
507
+ item.checks?.perPath &&
508
+ (ctx.liveness.openInside([target]) ??
509
+ ctx.liveness.recentWrite(usage?.newestMtimeMs))
510
+ );
511
+ }
512
+
513
+ async #removalBlocker(ctx, item, target) {
514
+ await configureScanPolicy(ctx.env, this.options);
515
+ const boundary = ctx.env.scanPolicy.removalReason(target);
516
+ if (boundary) {
517
+ return boundary;
518
+ }
519
+ await ctx.liveness.refresh(true);
520
+ const stopped = this.health.verify(item, ctx.liveness.processList());
521
+ if (stopped) {
522
+ return `stopped before ${target}: ${stopped}`;
523
+ }
524
+ await ctx.liveness.resolve([target]);
525
+ const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
526
+ return busy ? `stopped before ${target}: busy: ${busy}` : null;
527
+ }
528
+
427
529
  async #command(ctx, item, entry) {
428
530
  const { action } = item;
429
531
  const isDocker = action.argv[0] === 'docker';
@@ -490,7 +592,7 @@ export class Cleaner {
490
592
  entry.reason = kept;
491
593
  return;
492
594
  }
493
- const git = await containerGitState(docker, containerId);
595
+ const git = await containerGitState(docker, containerId, { fresh: true });
494
596
  const blockers = gitBlockersForRemoval(git, containerId, this.options);
495
597
  if (blockers.length > 0) {
496
598
  entry.reason = blockers.join('; ');
@@ -589,6 +691,7 @@ export function environmentTotals(report, entries) {
589
691
  export function wantedItems(report, options) {
590
692
  const approved = options.approvedIds ? new Set(options.approvedIds) : null;
591
693
  return filterItems(report.items, { ...options, minSizeBytes: 0 }, [], path)
694
+ .filter((item) => options.docker !== false || !dockerItem(item, report))
592
695
  .filter((item) => !approved || approved.has(item.id))
593
696
  .map((item) => {
594
697
  if (item.action?.type === 'docker-rm') {
@@ -600,6 +703,19 @@ export function wantedItems(report, options) {
600
703
  });
601
704
  }
602
705
 
706
+ function dockerItem(item, report) {
707
+ return (
708
+ ['docker', 'container'].includes(item.kind) ||
709
+ item.action?.argv?.[0] === 'docker' ||
710
+ (report.environments ?? []).some(
711
+ (env) =>
712
+ env.id === item.env &&
713
+ env.kind === 'container' &&
714
+ (env.depth > 0 || env.chain?.length > 0)
715
+ )
716
+ );
717
+ }
718
+
603
719
  /**
604
720
  * Applies per-container consent: `--remove-container` approves the removal
605
721
  * of that container and lifts its investigation hold, an exact
@@ -609,11 +725,15 @@ function containerConsent(item, options) {
609
725
  const { containerId, name } = item.action;
610
726
  const named = containerNamed(options.removeContainers, containerId, name);
611
727
  const dirtyAllowed = options.allowDirtyContainers?.includes(containerId);
612
- if (!named && !dirtyAllowed) {
728
+ const expired = investigationExpired(
729
+ { FinishedAt: item.container?.owner?.finishedAt },
730
+ options
731
+ );
732
+ if (!named && !dirtyAllowed && !expired) {
613
733
  return item;
614
734
  }
615
735
  const lifted = (reason) =>
616
- (named && reason === item.container?.investigation) ||
736
+ ((named || expired) && reason === item.container?.investigation) ||
617
737
  (dirtyAllowed &&
618
738
  item.container?.gitBlockers?.includes(reason) &&
619
739
  /^Git repository .+ has /.test(reason));
@@ -636,6 +756,7 @@ export function planItems(report, options) {
636
756
  /** Options that only the cleaning run itself may grant. */
637
757
  export const CONFIRMATION_FLAGS = [
638
758
  'removeStoppedContainers',
759
+ 'investigationMaxAge',
639
760
  'removeContainers',
640
761
  'removeImages',
641
762
  'includeVolumes',
@@ -680,21 +801,30 @@ export async function clean(report, input = {}) {
680
801
  };
681
802
  const cleaner = new Cleaner(report, {
682
803
  ...options,
804
+ env: options.env ?? scannedEnvironment(report),
683
805
  onProgress: persistProgress,
684
806
  });
685
807
  audit.health = cleaner.health.records;
686
808
  if (options.audit !== false) {
687
809
  await writeAudit(audit, { ...options, inProgress: true });
688
810
  }
689
- for (const item of planItems(report, options)) {
690
- const entry = await cleaner.process(item);
691
- if (!audit.entries.includes(entry)) {
692
- audit.entries.push(entry);
693
- }
694
- options.onEntry?.(entry, item);
695
- if (options.audit !== false) {
696
- await writeAudit(audit, { ...options, inProgress: true });
811
+ try {
812
+ for (const item of planItems(report, options)) {
813
+ if (options.signal?.aborted) {
814
+ throw new Error('cleanup aborted');
815
+ }
816
+ const entry = await cleaner.process(item);
817
+ if (!audit.entries.includes(entry)) {
818
+ audit.entries.push(entry);
819
+ }
820
+ options.onEntry?.(entry, item);
821
+ if (options.audit !== false) {
822
+ await writeAudit(audit, { ...options, inProgress: true });
823
+ }
697
824
  }
825
+ } catch (error) {
826
+ audit.aborted = true;
827
+ audit.error = error.message;
698
828
  }
699
829
  return finishAudit(audit, report, options);
700
830
  }
@@ -703,6 +833,10 @@ export async function clean(report, input = {}) {
703
833
  * Adds totals and writes the audit log (unless `audit: false`).
704
834
  */
705
835
  export async function finishAudit(audit, report, options) {
836
+ if (options.signal?.aborted) {
837
+ audit.aborted = true;
838
+ audit.error ??= 'cleanup aborted';
839
+ }
706
840
  audit.finishedAt = new Date().toISOString();
707
841
  audit.freedBytes = audit.entries.reduce((sum, e) => sum + e.freedBytes, 0);
708
842
  audit.plannedBytes = audit.entries