windowsweep 1.2.0 → 1.3.1

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.
@@ -71,7 +71,7 @@ choice, and says so.
71
71
  ## The `--json` line
72
72
 
73
73
  ```json
74
- {"tool":"windowsweep","version":"1.1.0","mode":"all","dry_run":false,"elevated":false,"developer":true,
74
+ {"tool":"windowsweep","version":"1.2.0","mode":"all","dry_run":false,"elevated":false,"developer":true,
75
75
  "freed_bytes":0,"estimated_bytes":0,
76
76
  "sections":[{"section":1,"status":"ran","freed_bytes":0}],
77
77
  "candidates":[],"targets":[],
package/CHANGELOG.md CHANGED
@@ -9,6 +9,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.3.1] - 2026-09-25
13
+
14
+ No change to what is cleaned, how it is decided, or what is refused. This release names the team as the
15
+ tool's maker instead of one person, and points the web address it prints at the product's own site.
16
+
17
+ ### Changed
18
+
19
+ - **The maker lines name the team.** The end-of-run line, the walkthrough and the usage header read
20
+ *by the windowsweep team*; `--version` and the session log's header read `Author: The windowsweep team`.
21
+ - **The web address the tool prints is the product's own site**, `https://windowsweep.aoneahsan.com`,
22
+ instead of a personal site: the `Web:` row of `--version` and `--feedback`, the session log's header and
23
+ the walkthrough. The contact form lives there.
24
+ - **The report credits** keep their four keys and their string type, so a reader of `schema_version: 1`
25
+ keeps working: `author.name` is `the windowsweep team`, `author.website` is the product's site, and
26
+ `author.email` and `author.linkedin` are empty strings. A report written earlier keeps what it recorded.
27
+ - **The Email and LinkedIn rows of `--version` are gone**, and so is the email address in the usage
28
+ header and the session log's header. The address stays where it is a contact channel: the crash message
29
+ and `--feedback`.
30
+
31
+ ### Fixed
32
+
33
+ - **The README's install-size row inside the 1.3.0 package** said 44 files and about 396 kB unpacked;
34
+ that package held 46 files and about 400 kB, because the two files 1.3.0 split out were never counted.
35
+ The row is measured from this package.
36
+
37
+ ## [1.3.0] - 2026-09-17
38
+
39
+ No change to what is cleaned, how it is decided, or what is refused. This release moves one printed
40
+ address, splits two files that had grown past the project's own 500-line ceiling, and corrects the
41
+ version rows inside the published package.
42
+
43
+ ### Changed
44
+
45
+ - **The documentation address the tool carries is now the documentation site**,
46
+ `https://windowsweep-docs.aoneahsan.com`, instead of the README on GitHub. The site has served the
47
+ same pages over HTTPS since 2026-09-12.
48
+ - **`lib/external.ps1`** takes `Invoke-External`, `Test-CommandPresent`, `Test-ProcessRunning`,
49
+ `Test-IsAdmin`, `Test-CanElevate` and `Invoke-Elevated` out of `lib/safety.ps1`, which is back under
50
+ 500 lines. The deletion chokepoint stays where it was: running a process and raising privilege are a
51
+ different job from refusing a path.
52
+ - **`modules/self_test_contract.ps1`** takes self-test group [18] out of `modules/self_test_extra.ps1`,
53
+ same fixture, same checks. Both moves are verbatim, and each was watched failing on a planted defect
54
+ before it was believed.
55
+ - **The self-test grew by the two new files**, because groups [3] and [4] check every engine file by
56
+ name. No check was added, changed or removed - the suite is the same suite, counting one more file
57
+ twice.
58
+
59
+ ### Fixed
60
+
61
+ - **The README at-a-glance rows inside the published package** were stale at 1.1.0 through the 1.2.0
62
+ cascade. They are correct here.
63
+
12
64
  ## [1.2.0] - 2026-09-08
13
65
 
14
66
  ### Added
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  [![node](https://img.shields.io/node/v/windowsweep.svg)](https://nodejs.org)
14
14
  [![platform](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-0078d4?logo=windows&logoColor=white)](https://github.com/aoneahsan/windowsweep#platform-support)
15
15
 
16
- [Docs](https://github.com/aoneahsan/windowsweep/blob/main/docs/README.md) · [AI Guide](https://github.com/aoneahsan/windowsweep/blob/main/AI-INTEGRATION-GUIDE.md) · [npm](https://www.npmjs.com/package/windowsweep) · [GitHub](https://github.com/aoneahsan/windowsweep) · [Changelog](https://github.com/aoneahsan/windowsweep/blob/main/CHANGELOG.md) · [Contributing](https://github.com/aoneahsan/windowsweep/blob/main/CONTRIBUTING.md) · [Support](https://github.com/aoneahsan/windowsweep/issues)
16
+ [Website](https://windowsweep.aoneahsan.com) · [Docs](https://windowsweep-docs.aoneahsan.com/) · [AI Guide](https://github.com/aoneahsan/windowsweep/blob/main/AI-INTEGRATION-GUIDE.md) · [npm](https://www.npmjs.com/package/windowsweep) · [GitHub](https://github.com/aoneahsan/windowsweep) · [Changelog](https://github.com/aoneahsan/windowsweep/blob/main/CHANGELOG.md) · [Contributing](https://github.com/aoneahsan/windowsweep/blob/main/CONTRIBUTING.md) · [Support](https://github.com/aoneahsan/windowsweep/issues)
17
17
 
18
18
  </div>
19
19
 
@@ -36,14 +36,14 @@ It is the Windows member of a family with [linux-cleanup](https://github.com/aon
36
36
 
37
37
  | | |
38
38
  |---|---|
39
- | **Version** | `1.1.0` |
39
+ | **Version** | `1.3.1` |
40
40
  | **License** | MIT |
41
41
  | **Node** | `>=14` (launcher only) |
42
42
  | **Runtime** | Windows PowerShell 5.1 (built in) or PowerShell 7 |
43
43
  | **Platforms** | Windows 10 (1809+) and Windows 11 |
44
- | **Install size** | ~109 kB packed · ~366 kB unpacked · 44 files · no dependencies |
44
+ | **Install size** | ~120 kB packed · ~401 kB unpacked · 46 files · no dependencies |
45
45
  | **Undo** | Recycle Bin for personal files; none for caches (they regenerate) |
46
- | **Status** | Stable · 1.1.0, released 2026-09-04 |
46
+ | **Status** | Stable · 1.3.1, released 2026-09-25 |
47
47
 
48
48
  <a id="table-of-contents"></a>
49
49
  ## 🧭 Table of Contents&nbsp;[#](#table-of-contents)
@@ -126,7 +126,7 @@ reclaims disk space, nothing else.
126
126
  - **Running-app guard** - an open browser, editor or app keeps its caches; the tool tells you which to close.
127
127
  - **Session reports** - schema-versioned JSON, exportable to Markdown or a self-contained HTML page, plus
128
128
  `--json` for scripts.
129
- - **Self-test** - 151 checks prove the guards on your machine with a real junction, a 445-character path and
129
+ - **Self-test** - 156 checks prove the guards on your machine with a real junction, a 445-character path and
130
130
  a dry-run fixture before you trust it.
131
131
  - **Offline by design** - the command-line tool makes zero network calls: no telemetry, no update check.
132
132
  Self-test check [9] fails the build on an HTTP or socket call in the source. Crash bundles stay on disk.
@@ -180,7 +180,7 @@ cd windowsweep
180
180
  Both launchers start PowerShell with `-ExecutionPolicy Bypass`, so the machine's script policy never blocks a
181
181
  run. Logs and reports land in `%USERPROFILE%\.windowsweep\` on every path - outside the npm cache, so history
182
182
  survives `npx` evictions. Full detail:
183
- [Installation](https://github.com/aoneahsan/windowsweep/blob/main/docs/installation.md).
183
+ [Installation](https://windowsweep-docs.aoneahsan.com/installation).
184
184
 
185
185
  <a id="quick-start"></a>
186
186
  ## 🚀 Quick Start&nbsp;[#](#quick-start)
@@ -239,7 +239,7 @@ comes back but costs minutes, *Recycle Bin* is recoverable until you empty it (`
239
239
  `--i-understand-deep`; *interactive* ones never run unattended unless you pass a selection. The three
240
240
  *audit only* sections are read-only and safe, but stay out of `--all` so a cleanup run is a cleanup run -
241
241
  `--profile audit` is where they live. Every section is documented in
242
- [Sections 0-25](https://github.com/aoneahsan/windowsweep/blob/main/docs/sections.md).
242
+ [Sections 0-25](https://windowsweep-docs.aoneahsan.com/sections).
243
243
 
244
244
  ### Reclaim interactively
245
245
 
@@ -281,7 +281,7 @@ There is no configuration you must do. Defaults live in `%USERPROFILE%\.windowsw
281
281
  | `NO_COLOR`, `WINDOWSWEEP_NO_COLOR` | unset | Disable colour |
282
282
  | `WINDOWSWEEP_ASCII` | unset | Plain ASCII glyphs |
283
283
 
284
- Full reference: [CLI reference](https://github.com/aoneahsan/windowsweep/blob/main/docs/cli-reference.md).
284
+ Full reference: [CLI reference](https://windowsweep-docs.aoneahsan.com/cli-reference).
285
285
 
286
286
  <a id="command-line"></a>
287
287
  ## 💻 Command Line&nbsp;[#](#command-line)
@@ -323,7 +323,7 @@ windowsweep [mode] [options]
323
323
  | `--json`, `--quiet`, `--no-color`, `--ascii`, `--no-report`, `--cleanup-logs` | off | Output and record controls |
324
324
 
325
325
  Every flag, exit code and environment variable:
326
- [CLI reference](https://github.com/aoneahsan/windowsweep/blob/main/docs/cli-reference.md).
326
+ [CLI reference](https://windowsweep-docs.aoneahsan.com/cli-reference).
327
327
 
328
328
  <a id="examples"></a>
329
329
  ## 🧪 Examples&nbsp;[#](#examples)
@@ -344,17 +344,17 @@ Every flag, exit code and environment variable:
344
344
  ## 🎛️ Advanced Features&nbsp;[#](#advanced-features)
345
345
 
346
346
  - **Profiles** - `dev`, `minimal`, `cache-only`, `system`, `deep`, `audit`.
347
- [Docs](https://github.com/aoneahsan/windowsweep/blob/main/docs/profiles.md)
347
+ [Docs](https://windowsweep-docs.aoneahsan.com/profiles)
348
348
  - **Keep-newest rule** - Cypress, Playwright, Gradle distributions and Squirrel app installs keep their newest
349
349
  version whatever the idle gate says.
350
- [Docs](https://github.com/aoneahsan/windowsweep/blob/main/docs/safety-model.md)
350
+ [Docs](https://windowsweep-docs.aoneahsan.com/safety-model)
351
351
  - **Editor hygiene** - workspace storage whose folder is gone and extension folders the editor's own
352
352
  `extensions.json` no longer references.
353
- [Docs](https://github.com/aoneahsan/windowsweep/blob/main/docs/sections.md)
353
+ [Docs](https://windowsweep-docs.aoneahsan.com/sections)
354
354
  - **Disk-image compaction** - hands back the space a Docker Desktop or WSL `.vhdx` never returns on its own.
355
- [Docs](https://github.com/aoneahsan/windowsweep/blob/main/docs/admin-and-elevation.md)
355
+ [Docs](https://windowsweep-docs.aoneahsan.com/admin-and-elevation)
356
356
  - **Report export** - schema-versioned JSON to Markdown or a self-contained HTML page, no extra tools.
357
- [Docs](https://github.com/aoneahsan/windowsweep/blob/main/docs/reports-and-logs.md)
357
+ [Docs](https://windowsweep-docs.aoneahsan.com/reports-and-logs)
358
358
  - **Crash bundles** - the command line writes one locally on an unexpected exit, never transmitted.
359
359
 
360
360
  <a id="desktop-app"></a>
@@ -366,13 +366,13 @@ A Tauri window that drives this engine rather than reimplementing it: it runs th
366
366
  - **A picker** for the four sections that ask a person to choose.
367
367
  - **Settings, most of them flags.**
368
368
 
369
- Screen by screen: [Desktop app](https://github.com/aoneahsan/windowsweep/blob/main/docs/desktop.md).
369
+ Screen by screen: [Desktop app](https://windowsweep-docs.aoneahsan.com/desktop).
370
370
 
371
371
  The engine still makes no network calls at all. The window sends usage and crash reports to improve the product for everyone, and there is no switch. Never sent: a file path, a folder name, a drive label, your user name, your machine name, or the contents of anything. Sign-in is separate and optional.
372
372
 
373
- The installer is not signed with a paid code-signing certificate, so SmartScreen warns on first run. Every release publishes a SHA-256 checksum and a minisign signature the app's own updater checks; neither is a code-signing certificate. What to do about the dialog: [Desktop app](https://github.com/aoneahsan/windowsweep/blob/main/docs/desktop.md).
373
+ The installer is not signed with a paid code-signing certificate, so SmartScreen warns on first run. Every release publishes a SHA-256 checksum and a minisign signature the app's own updater checks; neither is a code-signing certificate. What to do about the dialog: [Desktop app](https://windowsweep-docs.aoneahsan.com/desktop).
374
374
 
375
- Download the `.msi` or the `.exe` installer from [Releases](https://github.com/aoneahsan/windowsweep/releases). What each one installs, and what Windows says the first time: [Desktop app](https://github.com/aoneahsan/windowsweep/blob/main/docs/desktop.md).
375
+ Download the `.msi` or the `.exe` installer from [Releases](https://github.com/aoneahsan/windowsweep/releases). What each one installs, and what Windows says the first time: [Desktop app](https://windowsweep-docs.aoneahsan.com/desktop).
376
376
 
377
377
  <a id="recovery-troubleshooting"></a>
378
378
  ## 🚑 Recovery & Troubleshooting&nbsp;[#](#recovery-troubleshooting)
@@ -387,7 +387,7 @@ Download the `.msi` or the `.exe` installer from [Releases](https://github.com/a
387
387
  | Reclaimed less than `--scan` showed | The idle gate kept recently used files; open apps were skipped | Lower `--days`, close the apps, or `--purge-all` |
388
388
  | An extension folder was removed | The editor's `extensions.json` no longer referenced it | Reinstall from the editor; referenced folders are never touched |
389
389
 
390
- More: [Troubleshooting](https://github.com/aoneahsan/windowsweep/blob/main/docs/troubleshooting.md).
390
+ More: [Troubleshooting](https://windowsweep-docs.aoneahsan.com/troubleshooting).
391
391
 
392
392
  <a id="limitations"></a>
393
393
  ## 🚧 Limitations&nbsp;[#](#limitations)
@@ -428,32 +428,41 @@ An open browser keeps its cache files locked and half-written. Close it and run
428
428
  Every Windows machine has PowerShell 5.1: no runtime to install, no binary to trust, and the source is readable
429
429
  in an afternoon.
430
430
 
431
- More: [FAQ](https://github.com/aoneahsan/windowsweep/blob/main/docs/faq.md).
431
+ More: [FAQ](https://windowsweep-docs.aoneahsan.com/faq).
432
432
 
433
433
  <a id="documentation"></a>
434
434
  ## 📚 Documentation&nbsp;[#](#documentation)
435
435
 
436
436
  | Document | Read it when |
437
437
  |---|---|
438
- | [Documentation index](https://github.com/aoneahsan/windowsweep/blob/main/docs/README.md) | you want the full map |
439
- | [Quick start](https://github.com/aoneahsan/windowsweep/blob/main/docs/quick-start.md) | running your first cleanup |
440
- | [Safety model](https://github.com/aoneahsan/windowsweep/blob/main/docs/safety-model.md) | you want every guarantee spelled out before deleting anything |
441
- | [Developer mode](https://github.com/aoneahsan/windowsweep/blob/main/docs/developer-mode.md) | you want to know what the first question changes |
442
- | [Sections 0-25](https://github.com/aoneahsan/windowsweep/blob/main/docs/sections.md) | you want to know precisely what one section touches |
443
- | [CLI reference](https://github.com/aoneahsan/windowsweep/blob/main/docs/cli-reference.md) | you need an exact flag, exit code or variable |
444
- | [Admin sections and elevation](https://github.com/aoneahsan/windowsweep/blob/main/docs/admin-and-elevation.md) | before running the system profile or touching the hibernation file |
445
- | [Reports and logs](https://github.com/aoneahsan/windowsweep/blob/main/docs/reports-and-logs.md) | parsing the JSON or finding a log |
438
+ | [Documentation index](https://windowsweep-docs.aoneahsan.com/) | you want the full map |
439
+ | [Quick start](https://windowsweep-docs.aoneahsan.com/quick-start) | running your first cleanup |
440
+ | [Safety model](https://windowsweep-docs.aoneahsan.com/safety-model) | you want every guarantee spelled out before deleting anything |
441
+ | [Developer mode](https://windowsweep-docs.aoneahsan.com/developer-mode) | you want to know what the first question changes |
442
+ | [Sections 0-25](https://windowsweep-docs.aoneahsan.com/sections) | you want to know precisely what one section touches |
443
+ | [CLI reference](https://windowsweep-docs.aoneahsan.com/cli-reference) | you need an exact flag, exit code or variable |
444
+ | [Admin sections and elevation](https://windowsweep-docs.aoneahsan.com/admin-and-elevation) | before running the system profile or touching the hibernation file |
445
+ | [Reports and logs](https://windowsweep-docs.aoneahsan.com/reports-and-logs) | parsing the JSON or finding a log |
446
446
  | [AI integration guide](https://github.com/aoneahsan/windowsweep/blob/main/AI-INTEGRATION-GUIDE.md) | an agent or a script runs it for you |
447
- | [Desktop app](https://github.com/aoneahsan/windowsweep/blob/main/docs/desktop.md) | you want the window rather than the command line |
448
- | [Troubleshooting](https://github.com/aoneahsan/windowsweep/blob/main/docs/troubleshooting.md) | something failed |
447
+ | [Desktop app](https://windowsweep-docs.aoneahsan.com/desktop) | you want the window rather than the command line |
448
+ | [Troubleshooting](https://windowsweep-docs.aoneahsan.com/troubleshooting) | something failed |
449
449
  | [Project status](https://github.com/aoneahsan/windowsweep/blob/main/docs/features/windowsweep-completion/00-tracker.json) | you want to know what is verified and what is still open: every phase and sub-task with its state and evidence |
450
450
 
451
451
  <a id="changelog"></a>
452
452
  ## 🔄 Changelog&nbsp;[#](#changelog)
453
453
 
454
- Latest release: **`1.1.0`** - four new sections (global packages, orphaned app data, idle programs, startup
455
- items), `--select` / `--select-file` so a script or a GUI can drive the interactive sections, `--notify`, and
456
- `candidates[]`, `targets[]`, progress lines and `--list --json` for machine callers.
454
+ Latest release: **`1.3.1`** - nothing about what is cleaned, how it is decided or what is refused changed.
455
+ The tool names the windowsweep team as its maker instead of one person, and the web address it prints is the
456
+ product's own site.
457
+
458
+ Before it, **`1.3.0`** - the documentation address the tool carries became the documentation site, two files
459
+ that had grown past the project's own 500-line ceiling were split (the deletion chokepoint stayed exactly where
460
+ it was), and the at-a-glance rows inside the published package were corrected.
461
+
462
+ Before that, **`1.2.0`** - `--exclude-path` honoured in every section at the deletion chokepoint (it
463
+ protected one section out of twenty-six, and never a folder's children), a dry-run that counts exactly what a
464
+ real run removes, `newest_write_utc` on every scanned target, `protected` in `--list --json`, `excluded[]` in
465
+ the summary, and the console and report strings in one voice.
457
466
  Full history: [CHANGELOG.md](https://github.com/aoneahsan/windowsweep/blob/main/CHANGELOG.md).
458
467
 
459
468
  <a id="contributing"></a>
@@ -498,15 +507,16 @@ this tool deletes files, so review what it proposes before confirming.
498
507
  <a id="author"></a>
499
508
  ## 👤 Author&nbsp;[#](#author)
500
509
 
501
- **Ahsan Mahmood** - [aoneahsan.com](https://aoneahsan.com) · [GitHub](https://github.com/aoneahsan) ·
502
- [LinkedIn](https://linkedin.com/in/aoneahsan) · [aoneahsan@gmail.com](mailto:aoneahsan@gmail.com)
510
+ **The windowsweep team** - [team page](https://windowsweep-docs.aoneahsan.com/about) ·
511
+ [contact form](https://windowsweep.aoneahsan.com/contact), after signing in
503
512
 
504
513
  <a id="links"></a>
505
514
  ## 🔗 Links&nbsp;[#](#links)
506
515
 
507
516
  | | |
508
517
  |---|---|
509
- | Documentation | https://github.com/aoneahsan/windowsweep/blob/main/docs/README.md |
518
+ | Website | https://windowsweep.aoneahsan.com |
519
+ | Documentation | https://windowsweep-docs.aoneahsan.com/ |
510
520
  | npm | https://www.npmjs.com/package/windowsweep |
511
521
  | Repository | https://github.com/aoneahsan/windowsweep |
512
522
  | Issues | https://github.com/aoneahsan/windowsweep/issues |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.1
package/lib/constants.ps1 CHANGED
@@ -4,19 +4,20 @@
4
4
  # Single source of truth for the version is package.json: the Node launcher exports it as
5
5
  # WINDOWSWEEP_VERSION. A direct checkout falls back to this literal; the version:check script
6
6
  # asserts the two never drift.
7
- $Script:WS_VERSION_FALLBACK = '1.2.0'
7
+ $Script:WS_VERSION_FALLBACK = '1.3.1'
8
8
 
9
9
  $Script:WS_NAME = 'windowsweep'
10
10
  $Script:WS_TAGLINE = 'Developer-aware Windows cleanup CLI: dry-run first, personal folders refused, zero install via npx.'
11
- $Script:WS_AUTHOR = 'Ahsan Mahmood'
11
+ # The maker is the team, never one person. WS_AUTHOR lands mid-line ("by the windowsweep team");
12
+ # WS_TEAM is the form a label shows standing alone ("Author: The windowsweep team").
13
+ $Script:WS_AUTHOR = 'the windowsweep team'
14
+ $Script:WS_TEAM = 'The windowsweep team'
12
15
  $Script:WS_EMAIL = 'aoneahsan@gmail.com'
13
- $Script:WS_WEB = 'https://aoneahsan.com'
14
- $Script:WS_LINKEDIN = 'https://linkedin.com/in/aoneahsan'
15
- $Script:WS_GITHUB = 'https://github.com/aoneahsan'
16
+ $Script:WS_WEB = 'https://windowsweep.aoneahsan.com'
16
17
  $Script:WS_REPO = 'https://github.com/aoneahsan/windowsweep'
17
18
  $Script:WS_ISSUES = 'https://github.com/aoneahsan/windowsweep/issues'
18
19
  $Script:WS_NPM = 'https://www.npmjs.com/package/windowsweep'
19
- $Script:WS_DOCS = 'https://github.com/aoneahsan/windowsweep/blob/main/docs/README.md'
20
+ $Script:WS_DOCS = 'https://windowsweep-docs.aoneahsan.com'
20
21
  $Script:WS_SUPPORT = 'https://aoneahsan.com/payment?project-id=windowsweep&project-identifier=windowsweep'
21
22
  $Script:WS_LICENSE = 'MIT License'
22
23
 
@@ -0,0 +1,87 @@
1
+ # external.ps1 - running an external process, and asking about / raising privilege.
2
+ #
3
+ # Split out of lib/safety.ps1 at the 1.3.0 cascade (RW-121), which brought that file back under the
4
+ # 500-line ceiling IRON rule 6 sets. The functions below are VERBATIM - none changed, and no behaviour
5
+ # moved with them. The deletion chokepoint stays in safety.ps1: Remove-PathSafe, Send-ToRecycleBin and
6
+ # the protection guard are that file's whole reason to exist, and they are not what was over the line.
7
+ # Running a process and elevating are a different job, which is why this is the split by concern.
8
+ #
9
+ # Dot-sourced from windowsweep.ps1 immediately after safety, so every caller still resolves at run time.
10
+
11
+ function Invoke-External {
12
+ <# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
13
+ param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
14
+ $display = "$FilePath $($ArgumentList -join ' ')"
15
+ $tag = ''
16
+ if ($Label) { $tag = " [$Label]" }
17
+ if ($Destructive -and $Script:WS.DryRun) {
18
+ Write-DryRun "would run: $display"
19
+ return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
20
+ }
21
+ Write-LogLine "run${tag}: $display"
22
+ $lines = @()
23
+ try {
24
+ $raw = & $FilePath @ArgumentList 2>&1
25
+ foreach ($l in $raw) {
26
+ $s = [string]$l
27
+ $lines += $s
28
+ if (-not $Quiet) { Write-Note $s } else { Write-LogLine $s }
29
+ }
30
+ $code = $LASTEXITCODE
31
+ if ($null -eq $code) { $code = 0 }
32
+ } catch {
33
+ $lines += $_.Exception.Message
34
+ $code = 1
35
+ }
36
+ Write-LogLine "exit $code$tag : $display"
37
+ return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
38
+ }
39
+
40
+ function Test-CommandPresent {
41
+ param([string] $Name)
42
+ return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
43
+ }
44
+
45
+ function Test-ProcessRunning {
46
+ <# .SYNOPSIS True when any process with one of the given names is running. #>
47
+ param([string[]] $Names)
48
+ foreach ($n in $Names) {
49
+ if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
50
+ }
51
+ return $false
52
+ }
53
+
54
+ function Test-IsAdmin {
55
+ try {
56
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
57
+ return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
58
+ } catch { return $false }
59
+ }
60
+
61
+ function Test-CanElevate {
62
+ <# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
63
+ try {
64
+ $out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
65
+ foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
66
+ } catch { $null = $_ }
67
+ return $false
68
+ }
69
+
70
+ function Invoke-Elevated {
71
+ <# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
72
+ $ws = $Script:WS
73
+ $childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
74
+ foreach ($a in $ws.RawArgs) {
75
+ if ($a -eq '--elevate') { continue }
76
+ if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
77
+ }
78
+ $childArgs += '--elevated-child'
79
+ Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
80
+ try {
81
+ $proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
82
+ return $proc.ExitCode
83
+ } catch {
84
+ Write-Err "elevation refused or failed: $($_.Exception.Message)"
85
+ return $Script:WS_EXIT_REFUSED
86
+ }
87
+ }
package/lib/log.ps1 CHANGED
@@ -14,7 +14,7 @@ function Initialize-Log {
14
14
  $header = @(
15
15
  '# ============================================================'
16
16
  "# $Script:WS_NAME v$(Get-ToolVersion) - session log"
17
- "# Author: $Script:WS_AUTHOR <$Script:WS_EMAIL>"
17
+ "# Author: $Script:WS_TEAM"
18
18
  "# Web: $Script:WS_WEB"
19
19
  "# License: $Script:WS_LICENSE"
20
20
  "# Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
@@ -106,7 +106,7 @@ function Save-Report {
106
106
  schema_version = 1
107
107
  credits = [ordered]@{
108
108
  tool = $Script:WS_NAME; tool_version = (Get-ToolVersion); tool_homepage = $Script:WS_REPO; tool_license = $Script:WS_LICENSE
109
- author = [ordered]@{ name = $Script:WS_AUTHOR; email = $Script:WS_EMAIL; website = $Script:WS_WEB; linkedin = $Script:WS_LINKEDIN }
109
+ author = [ordered]@{ name = $Script:WS_AUTHOR; email = ''; website = $Script:WS_WEB; linkedin = '' }
110
110
  }
111
111
  meta = [ordered]@{
112
112
  started_at = $ws.Report.startedAt
package/lib/safety.ps1 CHANGED
@@ -462,80 +462,6 @@ function Send-ToRecycleBin {
462
462
  return $r
463
463
  }
464
464
 
465
- function Invoke-External {
466
- <# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
467
- param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
468
- $display = "$FilePath $($ArgumentList -join ' ')"
469
- $tag = ''
470
- if ($Label) { $tag = " [$Label]" }
471
- if ($Destructive -and $Script:WS.DryRun) {
472
- Write-DryRun "would run: $display"
473
- return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
474
- }
475
- Write-LogLine "run${tag}: $display"
476
- $lines = @()
477
- try {
478
- $raw = & $FilePath @ArgumentList 2>&1
479
- foreach ($l in $raw) {
480
- $s = [string]$l
481
- $lines += $s
482
- if (-not $Quiet) { Write-Note $s } else { Write-LogLine $s }
483
- }
484
- $code = $LASTEXITCODE
485
- if ($null -eq $code) { $code = 0 }
486
- } catch {
487
- $lines += $_.Exception.Message
488
- $code = 1
489
- }
490
- Write-LogLine "exit $code$tag : $display"
491
- return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
492
- }
493
-
494
- function Test-CommandPresent {
495
- param([string] $Name)
496
- return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
497
- }
498
-
499
- function Test-ProcessRunning {
500
- <# .SYNOPSIS True when any process with one of the given names is running. #>
501
- param([string[]] $Names)
502
- foreach ($n in $Names) {
503
- if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
504
- }
505
- return $false
506
- }
507
-
508
- function Test-IsAdmin {
509
- try {
510
- $id = [Security.Principal.WindowsIdentity]::GetCurrent()
511
- return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
512
- } catch { return $false }
513
- }
514
-
515
- function Test-CanElevate {
516
- <# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
517
- try {
518
- $out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
519
- foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
520
- } catch { $null = $_ }
521
- return $false
522
- }
523
-
524
- function Invoke-Elevated {
525
- <# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
526
- $ws = $Script:WS
527
- $childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
528
- foreach ($a in $ws.RawArgs) {
529
- if ($a -eq '--elevate') { continue }
530
- if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
531
- }
532
- $childArgs += '--elevated-child'
533
- Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
534
- try {
535
- $proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
536
- return $proc.ExitCode
537
- } catch {
538
- Write-Err "elevation refused or failed: $($_.Exception.Message)"
539
- return $Script:WS_EXIT_REFUSED
540
- }
541
- }
465
+ # Running an external process, and asking about / raising privilege, moved to lib/external.ps1 at the
466
+ # 1.3.0 cascade (RW-121): Invoke-External, Test-CommandPresent, Test-ProcessRunning, Test-IsAdmin,
467
+ # Test-CanElevate and Invoke-Elevated. The deletion chokepoint stays here, which is the point of the split.
@@ -5,10 +5,8 @@ function Show-Version {
5
5
  Write-Host (" $Script:WS_NAME v$(Get-ToolVersion)")
6
6
  Write-Host (" $Script:WS_TAGLINE")
7
7
  Write-Host ''
8
- Write-Host (" Author: $Script:WS_AUTHOR")
9
- Write-Host (" Email: $Script:WS_EMAIL")
8
+ Write-Host (" Author: $Script:WS_TEAM")
10
9
  Write-Host (" Web: $Script:WS_WEB")
11
- Write-Host (" LinkedIn: $Script:WS_LINKEDIN")
12
10
  Write-Host (" Source: $Script:WS_REPO")
13
11
  Write-Host (" License: $Script:WS_LICENSE")
14
12
  Write-Host ''
@@ -187,6 +187,10 @@ function Show-SessionSummary {
187
187
  Write-Kv 'Duration:' (Format-Duration $secs)
188
188
  $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
189
189
  $skipped = @($ws.Report.steps | Where-Object { $_.status -notin 'ran', 'dry-run' }).Count
190
+ # C-091: 'skipped' is every step that did not run, so it folds in REFUSED and FAILED as well as skipped.
191
+ # The label is kept deliberately - the exported report was aligned to this exact wording on purpose, and
192
+ # renaming both costs two surfaces and a stored artefact's vocabulary to buy a word. The fact lives here,
193
+ # beside the slot, so a reader of `9 / 2` can find out which.
190
194
  Write-Kv 'Sections run / skipped:' "$ran / $skipped"
191
195
  Write-Kv 'Log:' $ws.LogFile
192
196
  $rep = Save-Report
@@ -0,0 +1,195 @@
1
+ # self_test_contract.ps1 - self-test group [18]: the 1.2.0 --json and --exclude-path contract.
2
+ #
3
+ # Split out of modules/self_test_extra.ps1 at the 1.3.0 cascade (RW-121), which brought that file back
4
+ # under the 500-line ceiling IRON rule 6 sets. The body below is VERBATIM - no check was changed, added or
5
+ # removed, and the self-test count is unchanged. Called from Invoke-SelfTestExtra inside its own fixture,
6
+ # so the fixture root is passed in and cleaned up there.
7
+
8
+ function Invoke-SelfTestContract {
9
+ <# .SYNOPSIS Group [18]. Takes the caller's fixture root. Returns an object with Checks and Fails counts. #>
10
+ param([Parameter(Mandatory)] [string] $Fixture)
11
+ $ws = $Script:WS
12
+ $checks = 0; $fails = 0
13
+ $fx = $Fixture
14
+ $mute = { $Script:WS.Mute = $true }
15
+ $unmute = { $Script:WS.Mute = $false }
16
+ $ancient = (Get-Date).AddDays(-400)
17
+
18
+ Write-Section '[18] The 1.2.0 contract additions: newest_write_utc, protected, --exclude-path'
19
+
20
+ # 18a - targets[].newest_write_utc reports the newest timestamp under a target, in ISO 8601 UTC.
21
+ # A fixture with a KNOWN mtime, so the assertion is about the value and not merely about the shape.
22
+ $checks++
23
+ $nw = Join-Path $fx 'newest'
24
+ New-Item -ItemType Directory -Force -Path (Join-Path $nw 'deep') | Out-Null
25
+ Set-Content -LiteralPath (Join-Path $nw 'old.txt') -Value 'x'
26
+ Set-Content -LiteralPath (Join-Path $nw 'deep\\new.txt') -Value 'x'
27
+ $stampOld = [datetime]::new(2021, 3, 4, 5, 6, 7, [DateTimeKind]::Utc)
28
+ $stampNew = [datetime]::new(2023, 11, 12, 13, 14, 15, [DateTimeKind]::Utc)
29
+ foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') {
30
+ Set-ItemProperty -LiteralPath (Join-Path $nw 'old.txt') -Name $ts -Value $stampOld.ToLocalTime()
31
+ Set-ItemProperty -LiteralPath (Join-Path $nw 'deep\\new.txt') -Name $ts -Value $stampNew.ToLocalTime()
32
+ }
33
+ $st = Get-DirectoryStats $nw
34
+ $iso = Format-Utc8601 $st.Newest
35
+ # The DEEPER file is the newer one, so a walk that stopped at the top level would report the older stamp
36
+ # and still look like a working timestamp - which is the failure this fixture is shaped to catch.
37
+ $wantIso = '2023-11-12T13:14:15Z'
38
+ $nullIso = Format-Utc8601 ([datetime]::MinValue)
39
+ if ($iso -eq $wantIso -and $null -eq $nullIso) { Write-Ok "newest_write_utc reads the deepest newest file ($iso) and is null for an empty tree" } else { Write-Err "newest_write_utc wrong: got $iso want $wantIso; MinValue gave '$nullIso'"; $fails++ }
40
+
41
+ # 18b - --list --json publishes the SAME protected lists the console prints, from one constant.
42
+ $checks++
43
+ $cat = Get-CatalogueJson
44
+ $prot = $cat.protected
45
+ $sameSub = ($null -ne $prot) -and ((@($prot.subtrees) -join '|') -eq (@($Script:WS_PROTECT.Subtrees) -join '|'))
46
+ $sameCat = ($null -ne $prot) -and ((@($prot.categories) -join '|') -eq (@($Script:WS_PROTECT_CATEGORIES) -join '|'))
47
+ $nonEmpty = ($null -ne $prot) -and (@($prot.subtrees).Count -gt 0) -and (@($prot.categories).Count -gt 0)
48
+ if ($sameSub -and $sameCat -and $nonEmpty) { Write-Ok "--list --json publishes the same $(@($prot.subtrees).Count) protected subtrees and $(@($prot.categories).Count) categories the console prints" } else { Write-Err "--list --json protected does not match the printed lists (subtrees=$sameSub categories=$sameCat nonEmpty=$nonEmpty)"; $fails++ }
49
+
50
+ # 18c - an excluded path SURVIVES a real fixture run while its sibling goes. Not a dry-run: the whole
51
+ # point is that the chokepoint refuses the deletion rather than merely declining to print it.
52
+ $checks++
53
+ $exRoot = Join-Path $fx 'excl'
54
+ $keep = Join-Path $exRoot 'keep-me'
55
+ $go = Join-Path $exRoot 'go-away'
56
+ foreach ($d in $keep, $go) { New-Item -ItemType Directory -Force -Path $d | Out-Null; Set-Content -LiteralPath (Join-Path $d 'f.txt') -Value 'xxxx' }
57
+ $savedEx = $ws.ExcludePaths
58
+ $savedExcluded = $ws.Excluded
59
+ $savedTable = $Script:WS_EXCLUDE
60
+ try {
61
+ $ws.ExcludePaths = @($keep)
62
+ $ws.Excluded = @()
63
+ Initialize-Exclusions
64
+ & $mute
65
+ $rKeep = Remove-PathSafe -Path $keep -Within $exRoot
66
+ $rGo = Remove-PathSafe -Path $go -Within $exRoot
67
+ & $unmute
68
+ $keptOnDisk = Test-Path -LiteralPath $keep
69
+ # A CHILD, not just the excluded path itself: testing only the exact path let a prefix bug
70
+ # (a doubled backslash) pass this check while nothing inside an excluded folder was refused.
71
+ $childReason = Get-ProtectionReason (Join-Path $keep 'f.txt')
72
+ $childRefused = ($childReason -like 'excluded: *')
73
+ $goneFromDisk = -not (Test-Path -LiteralPath $go)
74
+ $reported = (@($ws.Excluded).Count -eq 1)
75
+ $reasonOk = $rKeep.Reason -like 'excluded: *'
76
+ if ($keptOnDisk -and $goneFromDisk -and $rGo.Removed -and $reported -and $reasonOk -and $childRefused) { Write-Ok "--exclude-path is honoured by the chokepoint for every section: the excluded folder AND a file inside it are both refused, its sibling is not, and it is reported once" } else { Write-Err "--exclude-path not honoured (kept=$keptOnDisk siblingGone=$goneFromDisk reported=$reported childRefused=$childRefused reason='$($rKeep.Reason)')"; $fails++ }
77
+ } finally {
78
+ $ws.ExcludePaths = $savedEx
79
+ $ws.Excluded = $savedExcluded
80
+ $Script:WS_EXCLUDE = $savedTable
81
+ }
82
+
83
+
84
+ # 18d - the rehearsal and the run agree about an EXCLUDED file inside a pruned cache.
85
+ # Remove-StaleFiles used to apply Get-ProtectionReason only in its real-run loop, so a dry-run
86
+ # counted files the run then skipped and the estimate came out higher than the run - for exactly
87
+ # the files a person most wants the two numbers to agree about.
88
+ $checks++
89
+ $pr = Join-Path $fx 'prune'
90
+ $prKeep = Join-Path $pr 'keep'
91
+ New-Item -ItemType Directory -Force -Path $prKeep | Out-Null
92
+ $goFile = Join-Path $pr 'go.bin'
93
+ $keepFile = Join-Path $prKeep 'keep.bin'
94
+ Set-Content -LiteralPath $goFile -Value ('x' * 400)
95
+ Set-Content -LiteralPath $keepFile -Value ('y' * 400)
96
+ foreach ($f in @($goFile, $keepFile)) {
97
+ foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') {
98
+ Set-ItemProperty -LiteralPath $f -Name $ts -Value $ancient
99
+ }
100
+ }
101
+ $savedEx2 = $ws.ExcludePaths
102
+ $savedTbl2 = $Script:WS_EXCLUDE
103
+ $savedDry = $ws.DryRun
104
+ $savedFreed = $ws.TotalFreed
105
+ $savedEst = $ws.TotalEstimated
106
+ try {
107
+ $ws.ExcludePaths = @($prKeep)
108
+ Initialize-Exclusions
109
+ & $mute
110
+ $ws.DryRun = $true
111
+ $dry = Remove-StaleFiles -Root $pr -Within $pr -Days 1
112
+ $ws.DryRun = $false
113
+ $real = Remove-StaleFiles -Root $pr -Within $pr -Days 1
114
+ & $unmute
115
+ $agree = ($dry.Files -eq $real.Files) -and ($dry.Freed -eq $real.Freed)
116
+ $keptOnDisk2 = Test-Path -LiteralPath $keepFile
117
+ $goneFromDisk2 = -not (Test-Path -LiteralPath $goFile)
118
+ if ($agree -and $keptOnDisk2 -and $goneFromDisk2 -and $dry.Files -eq 1) {
119
+ Write-Ok "a dry-run and a real prune agree exactly when a file is excluded: both report $($dry.Files) file, the excluded one survives"
120
+ } else {
121
+ Write-Err "rehearsal and run disagree (dryFiles=$($dry.Files) realFiles=$($real.Files) dryBytes=$($dry.Freed) realBytes=$($real.Freed) kept=$keptOnDisk2 siblingGone=$goneFromDisk2)"; $fails++
122
+ }
123
+ } finally {
124
+ $ws.ExcludePaths = $savedEx2
125
+ $Script:WS_EXCLUDE = $savedTbl2
126
+ $ws.DryRun = $savedDry
127
+ $ws.TotalFreed = $savedFreed
128
+ $ws.TotalEstimated = $savedEst
129
+ }
130
+
131
+
132
+ # 18e - a read-only scan REPORTS the developer answer it was given, and invents one when it was not.
133
+ # --scan parsed --developer / --not-developer into DeveloperFlag and then never read it, because
134
+ # Resolve-DeveloperMode ran only for walkthrough, menu, all and only. So section 0 printed
135
+ # "Developer mode: not decided yet" whatever you passed, and --json returned developer: null. The
136
+ # desktop window sends those flags to --scan and could not read back the setting it had just sent.
137
+ # Three orientations, because two of them would pass on the broken code by accident:
138
+ # A the flag is honoured, B a saved answer is honoured, C neither exists -> still undecided.
139
+ $checks++
140
+ $savedFlag = $ws.DeveloperFlag
141
+ $savedDev = $ws.Developer
142
+ $savedSrc = $ws.DeveloperSource
143
+ $savedCfg = $ws.Config
144
+ $savedInter = $ws.Interactive
145
+ $savedForget = $ws.ForgetDeveloper
146
+ try {
147
+ $ws.Interactive = $true # -ReadOnly must not ask even here; a prompt would hang the suite
148
+ $ws.ForgetDeveloper = $false
149
+
150
+ # A - the flag wins, and --not-developer is the case that was silently dropped
151
+ $ws.Config = @{}
152
+ $ws.DeveloperFlag = $false
153
+ $ws.Developer = $null
154
+ $ws.DeveloperSource = $null
155
+ & $mute
156
+ Resolve-DeveloperMode -ReadOnly
157
+ & $unmute
158
+ $aOk = ($ws.Developer -eq $false) -and ($ws.DeveloperSource -eq 'flag')
159
+
160
+ # B - no flag, but a saved answer exists
161
+ $ws.Config = @{ developer = $true }
162
+ $ws.DeveloperFlag = $null
163
+ $ws.Developer = $null
164
+ $ws.DeveloperSource = $null
165
+ & $mute
166
+ Resolve-DeveloperMode -ReadOnly
167
+ & $unmute
168
+ $bOk = ($ws.Developer -eq $true) -and ($ws.DeveloperSource -eq 'config')
169
+
170
+ # C - neither: a read-only mode must NOT invent a decision, and must not write config.json
171
+ $ws.Config = @{}
172
+ $ws.DeveloperFlag = $null
173
+ $ws.Developer = $null
174
+ $ws.DeveloperSource = $null
175
+ & $mute
176
+ Resolve-DeveloperMode -ReadOnly
177
+ & $unmute
178
+ $cOk = ($null -eq $ws.Developer) -and ($null -eq $ws.Config.developer)
179
+
180
+ if ($aOk -and $bOk -and $cOk) {
181
+ Write-Ok "a read-only scan honours --not-developer (flag) and a saved answer (config), and stays undecided when it has neither"
182
+ } else {
183
+ Write-Err "read-only developer resolution wrong (flagHonoured=$aOk savedHonoured=$bOk stillUndecided=$cOk)"; $fails++
184
+ }
185
+ } finally {
186
+ $ws.DeveloperFlag = $savedFlag
187
+ $ws.Developer = $savedDev
188
+ $ws.DeveloperSource = $savedSrc
189
+ $ws.Config = $savedCfg
190
+ $ws.Interactive = $savedInter
191
+ $ws.ForgetDeveloper = $savedForget
192
+ }
193
+
194
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
195
+ }
@@ -349,181 +349,10 @@ function Invoke-SelfTestExtra {
349
349
  if ($gotGatsbyCache -and $noPlainCache -and $gotNodeModules) { Write-Ok '.cache is an artefact only beside a Gatsby/Parcel marker, and node_modules is unaffected' } else { Write-Err "marked-artefact rule wrong: gatsby=$gotGatsbyCache plainExcluded=$noPlainCache nodeModules=$gotNodeModules"; $fails++ }
350
350
 
351
351
 
352
- Write-Section '[18] The 1.2.0 contract additions: newest_write_utc, protected, --exclude-path'
353
-
354
- # 18a - targets[].newest_write_utc reports the newest timestamp under a target, in ISO 8601 UTC.
355
- # A fixture with a KNOWN mtime, so the assertion is about the value and not merely about the shape.
356
- $checks++
357
- $nw = Join-Path $fx 'newest'
358
- New-Item -ItemType Directory -Force -Path (Join-Path $nw 'deep') | Out-Null
359
- Set-Content -LiteralPath (Join-Path $nw 'old.txt') -Value 'x'
360
- Set-Content -LiteralPath (Join-Path $nw 'deep\\new.txt') -Value 'x'
361
- $stampOld = [datetime]::new(2021, 3, 4, 5, 6, 7, [DateTimeKind]::Utc)
362
- $stampNew = [datetime]::new(2023, 11, 12, 13, 14, 15, [DateTimeKind]::Utc)
363
- foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') {
364
- Set-ItemProperty -LiteralPath (Join-Path $nw 'old.txt') -Name $ts -Value $stampOld.ToLocalTime()
365
- Set-ItemProperty -LiteralPath (Join-Path $nw 'deep\\new.txt') -Name $ts -Value $stampNew.ToLocalTime()
366
- }
367
- $st = Get-DirectoryStats $nw
368
- $iso = Format-Utc8601 $st.Newest
369
- # The DEEPER file is the newer one, so a walk that stopped at the top level would report the older stamp
370
- # and still look like a working timestamp - which is the failure this fixture is shaped to catch.
371
- $wantIso = '2023-11-12T13:14:15Z'
372
- $nullIso = Format-Utc8601 ([datetime]::MinValue)
373
- if ($iso -eq $wantIso -and $null -eq $nullIso) { Write-Ok "newest_write_utc reads the deepest newest file ($iso) and is null for an empty tree" } else { Write-Err "newest_write_utc wrong: got $iso want $wantIso; MinValue gave '$nullIso'"; $fails++ }
374
-
375
- # 18b - --list --json publishes the SAME protected lists the console prints, from one constant.
376
- $checks++
377
- $cat = Get-CatalogueJson
378
- $prot = $cat.protected
379
- $sameSub = ($null -ne $prot) -and ((@($prot.subtrees) -join '|') -eq (@($Script:WS_PROTECT.Subtrees) -join '|'))
380
- $sameCat = ($null -ne $prot) -and ((@($prot.categories) -join '|') -eq (@($Script:WS_PROTECT_CATEGORIES) -join '|'))
381
- $nonEmpty = ($null -ne $prot) -and (@($prot.subtrees).Count -gt 0) -and (@($prot.categories).Count -gt 0)
382
- if ($sameSub -and $sameCat -and $nonEmpty) { Write-Ok "--list --json publishes the same $(@($prot.subtrees).Count) protected subtrees and $(@($prot.categories).Count) categories the console prints" } else { Write-Err "--list --json protected does not match the printed lists (subtrees=$sameSub categories=$sameCat nonEmpty=$nonEmpty)"; $fails++ }
383
-
384
- # 18c - an excluded path SURVIVES a real fixture run while its sibling goes. Not a dry-run: the whole
385
- # point is that the chokepoint refuses the deletion rather than merely declining to print it.
386
- $checks++
387
- $exRoot = Join-Path $fx 'excl'
388
- $keep = Join-Path $exRoot 'keep-me'
389
- $go = Join-Path $exRoot 'go-away'
390
- foreach ($d in $keep, $go) { New-Item -ItemType Directory -Force -Path $d | Out-Null; Set-Content -LiteralPath (Join-Path $d 'f.txt') -Value 'xxxx' }
391
- $savedEx = $ws.ExcludePaths
392
- $savedExcluded = $ws.Excluded
393
- $savedTable = $Script:WS_EXCLUDE
394
- try {
395
- $ws.ExcludePaths = @($keep)
396
- $ws.Excluded = @()
397
- Initialize-Exclusions
398
- & $mute
399
- $rKeep = Remove-PathSafe -Path $keep -Within $exRoot
400
- $rGo = Remove-PathSafe -Path $go -Within $exRoot
401
- & $unmute
402
- $keptOnDisk = Test-Path -LiteralPath $keep
403
- # A CHILD, not just the excluded path itself: testing only the exact path let a prefix bug
404
- # (a doubled backslash) pass this check while nothing inside an excluded folder was refused.
405
- $childReason = Get-ProtectionReason (Join-Path $keep 'f.txt')
406
- $childRefused = ($childReason -like 'excluded: *')
407
- $goneFromDisk = -not (Test-Path -LiteralPath $go)
408
- $reported = (@($ws.Excluded).Count -eq 1)
409
- $reasonOk = $rKeep.Reason -like 'excluded: *'
410
- if ($keptOnDisk -and $goneFromDisk -and $rGo.Removed -and $reported -and $reasonOk -and $childRefused) { Write-Ok "--exclude-path is honoured by the chokepoint for every section: the excluded folder AND a file inside it are both refused, its sibling is not, and it is reported once" } else { Write-Err "--exclude-path not honoured (kept=$keptOnDisk siblingGone=$goneFromDisk reported=$reported childRefused=$childRefused reason='$($rKeep.Reason)')"; $fails++ }
411
- } finally {
412
- $ws.ExcludePaths = $savedEx
413
- $ws.Excluded = $savedExcluded
414
- $Script:WS_EXCLUDE = $savedTable
415
- }
416
-
417
-
418
- # 18d - the rehearsal and the run agree about an EXCLUDED file inside a pruned cache.
419
- # Remove-StaleFiles used to apply Get-ProtectionReason only in its real-run loop, so a dry-run
420
- # counted files the run then skipped and the estimate came out higher than the run - for exactly
421
- # the files a person most wants the two numbers to agree about.
422
- $checks++
423
- $pr = Join-Path $fx 'prune'
424
- $prKeep = Join-Path $pr 'keep'
425
- New-Item -ItemType Directory -Force -Path $prKeep | Out-Null
426
- $goFile = Join-Path $pr 'go.bin'
427
- $keepFile = Join-Path $prKeep 'keep.bin'
428
- Set-Content -LiteralPath $goFile -Value ('x' * 400)
429
- Set-Content -LiteralPath $keepFile -Value ('y' * 400)
430
- foreach ($f in @($goFile, $keepFile)) {
431
- foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') {
432
- Set-ItemProperty -LiteralPath $f -Name $ts -Value $ancient
433
- }
434
- }
435
- $savedEx2 = $ws.ExcludePaths
436
- $savedTbl2 = $Script:WS_EXCLUDE
437
- $savedDry = $ws.DryRun
438
- $savedFreed = $ws.TotalFreed
439
- $savedEst = $ws.TotalEstimated
440
- try {
441
- $ws.ExcludePaths = @($prKeep)
442
- Initialize-Exclusions
443
- & $mute
444
- $ws.DryRun = $true
445
- $dry = Remove-StaleFiles -Root $pr -Within $pr -Days 1
446
- $ws.DryRun = $false
447
- $real = Remove-StaleFiles -Root $pr -Within $pr -Days 1
448
- & $unmute
449
- $agree = ($dry.Files -eq $real.Files) -and ($dry.Freed -eq $real.Freed)
450
- $keptOnDisk2 = Test-Path -LiteralPath $keepFile
451
- $goneFromDisk2 = -not (Test-Path -LiteralPath $goFile)
452
- if ($agree -and $keptOnDisk2 -and $goneFromDisk2 -and $dry.Files -eq 1) {
453
- Write-Ok "a dry-run and a real prune agree exactly when a file is excluded: both report $($dry.Files) file, the excluded one survives"
454
- } else {
455
- Write-Err "rehearsal and run disagree (dryFiles=$($dry.Files) realFiles=$($real.Files) dryBytes=$($dry.Freed) realBytes=$($real.Freed) kept=$keptOnDisk2 siblingGone=$goneFromDisk2)"; $fails++
456
- }
457
- } finally {
458
- $ws.ExcludePaths = $savedEx2
459
- $Script:WS_EXCLUDE = $savedTbl2
460
- $ws.DryRun = $savedDry
461
- $ws.TotalFreed = $savedFreed
462
- $ws.TotalEstimated = $savedEst
463
- }
464
-
465
-
466
- # 18e - a read-only scan REPORTS the developer answer it was given, and invents one when it was not.
467
- # --scan parsed --developer / --not-developer into DeveloperFlag and then never read it, because
468
- # Resolve-DeveloperMode ran only for walkthrough, menu, all and only. So section 0 printed
469
- # "Developer mode: not decided yet" whatever you passed, and --json returned developer: null. The
470
- # desktop window sends those flags to --scan and could not read back the setting it had just sent.
471
- # Three orientations, because two of them would pass on the broken code by accident:
472
- # A the flag is honoured, B a saved answer is honoured, C neither exists -> still undecided.
473
- $checks++
474
- $savedFlag = $ws.DeveloperFlag
475
- $savedDev = $ws.Developer
476
- $savedSrc = $ws.DeveloperSource
477
- $savedCfg = $ws.Config
478
- $savedInter = $ws.Interactive
479
- $savedForget = $ws.ForgetDeveloper
480
- try {
481
- $ws.Interactive = $true # -ReadOnly must not ask even here; a prompt would hang the suite
482
- $ws.ForgetDeveloper = $false
483
-
484
- # A - the flag wins, and --not-developer is the case that was silently dropped
485
- $ws.Config = @{}
486
- $ws.DeveloperFlag = $false
487
- $ws.Developer = $null
488
- $ws.DeveloperSource = $null
489
- & $mute
490
- Resolve-DeveloperMode -ReadOnly
491
- & $unmute
492
- $aOk = ($ws.Developer -eq $false) -and ($ws.DeveloperSource -eq 'flag')
493
-
494
- # B - no flag, but a saved answer exists
495
- $ws.Config = @{ developer = $true }
496
- $ws.DeveloperFlag = $null
497
- $ws.Developer = $null
498
- $ws.DeveloperSource = $null
499
- & $mute
500
- Resolve-DeveloperMode -ReadOnly
501
- & $unmute
502
- $bOk = ($ws.Developer -eq $true) -and ($ws.DeveloperSource -eq 'config')
503
-
504
- # C - neither: a read-only mode must NOT invent a decision, and must not write config.json
505
- $ws.Config = @{}
506
- $ws.DeveloperFlag = $null
507
- $ws.Developer = $null
508
- $ws.DeveloperSource = $null
509
- & $mute
510
- Resolve-DeveloperMode -ReadOnly
511
- & $unmute
512
- $cOk = ($null -eq $ws.Developer) -and ($null -eq $ws.Config.developer)
513
-
514
- if ($aOk -and $bOk -and $cOk) {
515
- Write-Ok "a read-only scan honours --not-developer (flag) and a saved answer (config), and stays undecided when it has neither"
516
- } else {
517
- Write-Err "read-only developer resolution wrong (flagHonoured=$aOk savedHonoured=$bOk stillUndecided=$cOk)"; $fails++
518
- }
519
- } finally {
520
- $ws.DeveloperFlag = $savedFlag
521
- $ws.Developer = $savedDev
522
- $ws.DeveloperSource = $savedSrc
523
- $ws.Config = $savedCfg
524
- $ws.Interactive = $savedInter
525
- $ws.ForgetDeveloper = $savedForget
526
- }
352
+ # [18] moved to modules/self_test_contract.ps1 at the 1.3.0 cascade (RW-121), to bring this file back
353
+ # under the 500-line ceiling. Same fixture root, same counters, the checks themselves unchanged.
354
+ $contract = Invoke-SelfTestContract -Fixture $fx
355
+ $checks += $contract.Checks; $fails += $contract.Fails
527
356
 
528
357
  } catch {
529
358
  & $unmute
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windowsweep",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Developer-aware Windows cleanup CLI: dry-run first, personal folders refused, zero install via npx.",
5
5
  "bin": {
6
6
  "windowsweep": "bin/windowsweep.js"
@@ -53,7 +53,7 @@
53
53
  "url": "https://aoneahsan.com"
54
54
  },
55
55
  "license": "MIT",
56
- "homepage": "https://github.com/aoneahsan/windowsweep",
56
+ "homepage": "https://windowsweep.aoneahsan.com",
57
57
  "repository": {
58
58
  "type": "git",
59
59
  "url": "git+https://github.com/aoneahsan/windowsweep.git"
package/windowsweep.ps1 CHANGED
@@ -29,7 +29,7 @@ $Script:WS = @{
29
29
  Refusals = @(); Hints = @(); ExitCode = 0; Finished = $false; AllowOwnData = $false
30
30
  }
31
31
 
32
- foreach ($lib in @('constants', 'ui', 'log', 'fs', 'safety', 'config', 'scan', 'actions')) {
32
+ foreach ($lib in @('constants', 'ui', 'log', 'fs', 'safety', 'external', 'config', 'scan', 'actions')) {
33
33
  . (Join-Path $Script:WS_ROOT "lib\$lib.ps1")
34
34
  }
35
35
  foreach ($m in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' | Sort-Object Name)) {
@@ -103,7 +103,7 @@ OUTPUT
103
103
  Logs: $($Script:WS.LogsDir)
104
104
  Reports: $($Script:WS.ReportsDir)
105
105
 
106
- $Script:WS_NAME v$v by $Script:WS_AUTHOR <$Script:WS_EMAIL> $Script:WS_WEB
106
+ $Script:WS_NAME v$v by $Script:WS_AUTHOR $Script:WS_WEB
107
107
  $Script:WS_REPO - $Script:WS_LICENSE
108
108
  "@
109
109
  Write-Host $text