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.
- package/AI-INTEGRATION-GUIDE.md +1 -1
- package/CHANGELOG.md +52 -0
- package/README.md +45 -35
- package/VERSION +1 -1
- package/lib/constants.ps1 +7 -6
- package/lib/external.ps1 +87 -0
- package/lib/log.ps1 +2 -2
- package/lib/safety.ps1 +3 -77
- package/modules/release_helpers.ps1 +1 -3
- package/modules/runner.ps1 +4 -0
- package/modules/self_test_contract.ps1 +195 -0
- package/modules/self_test_extra.ps1 +4 -175
- package/package.json +2 -2
- package/windowsweep.ps1 +2 -2
package/AI-INTEGRATION-GUIDE.md
CHANGED
|
@@ -71,7 +71,7 @@ choice, and says so.
|
|
|
71
71
|
## The `--json` line
|
|
72
72
|
|
|
73
73
|
```json
|
|
74
|
-
{"tool":"windowsweep","version":"1.
|
|
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
|
[](https://nodejs.org)
|
|
14
14
|
[](https://github.com/aoneahsan/windowsweep#platform-support)
|
|
15
15
|
|
|
16
|
-
[
|
|
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
|
|
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** | ~
|
|
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
|
|
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 [#](#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** -
|
|
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://
|
|
183
|
+
[Installation](https://windowsweep-docs.aoneahsan.com/installation).
|
|
184
184
|
|
|
185
185
|
<a id="quick-start"></a>
|
|
186
186
|
## 🚀 Quick Start [#](#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://
|
|
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://
|
|
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 [#](#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://
|
|
326
|
+
[CLI reference](https://windowsweep-docs.aoneahsan.com/cli-reference).
|
|
327
327
|
|
|
328
328
|
<a id="examples"></a>
|
|
329
329
|
## 🧪 Examples [#](#examples)
|
|
@@ -344,17 +344,17 @@ Every flag, exit code and environment variable:
|
|
|
344
344
|
## 🎛️ Advanced Features [#](#advanced-features)
|
|
345
345
|
|
|
346
346
|
- **Profiles** - `dev`, `minimal`, `cache-only`, `system`, `deep`, `audit`.
|
|
347
|
-
[Docs](https://
|
|
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://
|
|
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://
|
|
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://
|
|
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://
|
|
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://
|
|
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://
|
|
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://
|
|
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 [#](#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://
|
|
390
|
+
More: [Troubleshooting](https://windowsweep-docs.aoneahsan.com/troubleshooting).
|
|
391
391
|
|
|
392
392
|
<a id="limitations"></a>
|
|
393
393
|
## 🚧 Limitations [#](#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://
|
|
431
|
+
More: [FAQ](https://windowsweep-docs.aoneahsan.com/faq).
|
|
432
432
|
|
|
433
433
|
<a id="documentation"></a>
|
|
434
434
|
## 📚 Documentation [#](#documentation)
|
|
435
435
|
|
|
436
436
|
| Document | Read it when |
|
|
437
437
|
|---|---|
|
|
438
|
-
| [Documentation index](https://
|
|
439
|
-
| [Quick start](https://
|
|
440
|
-
| [Safety model](https://
|
|
441
|
-
| [Developer mode](https://
|
|
442
|
-
| [Sections 0-25](https://
|
|
443
|
-
| [CLI reference](https://
|
|
444
|
-
| [Admin sections and elevation](https://
|
|
445
|
-
| [Reports and logs](https://
|
|
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://
|
|
448
|
-
| [Troubleshooting](https://
|
|
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 [#](#changelog)
|
|
453
453
|
|
|
454
|
-
Latest release: **`1.1
|
|
455
|
-
|
|
456
|
-
|
|
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 [#](#author)
|
|
500
509
|
|
|
501
|
-
**
|
|
502
|
-
[
|
|
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 [#](#links)
|
|
506
515
|
|
|
507
516
|
| | |
|
|
508
517
|
|---|---|
|
|
509
|
-
|
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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://
|
|
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
|
|
package/lib/external.ps1
ADDED
|
@@ -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:
|
|
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 =
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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:
|
|
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 ''
|
package/modules/runner.ps1
CHANGED
|
@@ -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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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.
|
|
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://
|
|
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
|
|
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
|