@webpieces/rules-config 0.4.661 → 0.4.663

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.
@@ -0,0 +1,109 @@
1
+ import { OrphanCandidate } from './orphan-dir-scan';
2
+ /** Where every sweep's archive lands, under the repo-wide `.webpieces/` — never under a worktree's own. */
3
+ export declare const TRASH_STATE_DIR = "trash";
4
+ /** The per-sweep manifest, holding what moved and the command that brings each one back. */
5
+ export declare const TRASH_MANIFEST_FILE = "manifest.json";
6
+ /** Sweeps older than this are reaped by the next sweep. Matches RETENTION_DAYS for the aged-tree sweep. */
7
+ export declare const TRASH_RETENTION_DAYS = 30;
8
+ /**
9
+ * Moves orphan directories into `.webpieces/trash/<sweepId>/` and records how to bring each one back.
10
+ *
11
+ * ─── WHY `mv` AND NEVER `rm -rf` ──────────────────────────────────────────────────────────────────────
12
+ * This runs unattended, on other people's machines, against a predicate that is very good but is still a
13
+ * predicate. A `mv` makes the worst possible outcome — a false positive on a directory somebody actually
14
+ * wanted — a thing they undo in one command, instead of a thing they restore from a backup or lose. That
15
+ * single decision is what makes it defensible to run this automatically at all, and it is the same trade
16
+ * `wp-cleanup` already makes when it archives a branch to a tag before deleting the ref.
17
+ *
18
+ * ─── WHY ONE DIRECTORY PER SWEEP, TIMESTAMP-NAMED ─────────────────────────────────────────────────────
19
+ * `<sweepId>` is a UTC timestamp with `:` swapped for `-` (path-safe everywhere, Windows included). That
20
+ * spelling sorts LEXICALLY in chronological order, so `ls` reads oldest-first and `ls -r` newest-first,
21
+ * with no flags to remember and no dates to parse. Grouping by sweep also answers the only question
22
+ * anybody actually asks of this directory — "what did the run I just did take?" — which a single flat
23
+ * pile of moved directories cannot answer at all.
24
+ *
25
+ * Each sweep directory reproduces the repo-relative path of what it holds, so the manifest's `recover=`
26
+ * is an ordinary `mv` of one path to another, with no bookkeeping needed to reconstruct the destination.
27
+ */
28
+ export declare class OrphanDirArchiver {
29
+ /**
30
+ * Move every candidate into a fresh sweep directory. `now` is a parameter so specs pin the sweep id
31
+ * instead of racing the clock.
32
+ */
33
+ archive(repoRoot: string, candidates: readonly OrphanCandidate[], now: Date): OrphanSweepResult;
34
+ /**
35
+ * Delete sweep directories older than TRASH_RETENTION_DAYS, and report how many went. Called by the
36
+ * sweeper AFTER a successful archive, so trash cannot grow without bound on a machine that has the
37
+ * flag on. This one really does delete — it is deleting the archive, which is the second copy.
38
+ */
39
+ reapAged(repoRoot: string, now: Date): number;
40
+ /** `<repo>/.webpieces/trash` — the SHARED state dir, so a worktree's trash is not stranded with it. */
41
+ trashRoot(repoRoot: string): string;
42
+ /**
43
+ * `2026-08-19T14-32-05Z`. ISO order with `:` replaced, and milliseconds dropped — a sweep is a
44
+ * human-scale event and a second is resolution enough to name one.
45
+ */
46
+ sweepId(now: Date): string;
47
+ /** The Date a sweep id names, or null when the entry is not one of ours (a stray file, say). */
48
+ private parseSweepId;
49
+ private listSweeps;
50
+ /**
51
+ * `mv` the directory, creating the destination's parent chain first. Returns null on success, or the
52
+ * failure's message — a directory we cannot move is REPORTED and skipped, never fatal.
53
+ *
54
+ * `fs.renameSync` first because it is atomic and instant within a filesystem; a cross-device rename
55
+ * (EXDEV — a repo whose `.webpieces` sits on a different mount) falls back to a recursive copy plus
56
+ * delete, which is what `mv` itself does in the same situation.
57
+ */
58
+ private move;
59
+ private moveAcrossDevices;
60
+ private isCrossDevice;
61
+ private removeTree;
62
+ /**
63
+ * The sweep's own record, beside what it took. Written even when every move failed, because "this
64
+ * sweep tried and could not" is exactly the state somebody debugging needs to find on disk.
65
+ *
66
+ * Returns the failure message, or null on success. It is REPORTED rather than swallowed: the manifest
67
+ * is the durable copy of every `recover=` command, so losing it silently is the one failure here that
68
+ * costs somebody the ability to undo what just happened. Still never fatal — the directories are
69
+ * already safely moved by this point, and the recover lines are still printed to the terminal.
70
+ */
71
+ private writeManifest;
72
+ }
73
+ /** One directory that was moved, and the exact command that undoes it. Data-only. */
74
+ export declare class ArchivedOrphan {
75
+ /** Where it used to live, repo-relative. */
76
+ relativePath: string;
77
+ /** Where it lives now, absolute. */
78
+ archivedAt: string;
79
+ /** The `mv` that puts it back — printed to the human and stored in the manifest verbatim. */
80
+ recoverCommand: string;
81
+ constructor(relativePath: string, archivedAt: string, recoverCommand: string);
82
+ }
83
+ /** One directory the sweep found but could not move, with the reason. Data-only. */
84
+ export declare class FailedOrphan {
85
+ relativePath: string;
86
+ reason: string;
87
+ constructor(relativePath: string, reason: string);
88
+ }
89
+ /** What one sweep did. Data-only. */
90
+ export declare class OrphanSweepResult {
91
+ sweepId: string;
92
+ sweepDir: string;
93
+ moved: readonly ArchivedOrphan[];
94
+ failed: readonly FailedOrphan[];
95
+ /**
96
+ * Why the manifest could not be written, or null when it was. Non-null means the printed `recover=`
97
+ * lines are the ONLY copy — see writeManifest().
98
+ */
99
+ manifestError: string | null;
100
+ constructor(sweepId: string, sweepDir: string, moved: readonly ArchivedOrphan[], failed: readonly FailedOrphan[], manifestError: string | null);
101
+ }
102
+ /** The on-disk shape of `manifest.json`. Data-only. */
103
+ export declare class OrphanSweepManifest {
104
+ sweepId: string;
105
+ repoRoot: string;
106
+ moved: readonly ArchivedOrphan[];
107
+ failed: readonly FailedOrphan[];
108
+ constructor(sweepId: string, repoRoot: string, moved: readonly ArchivedOrphan[], failed: readonly FailedOrphan[]);
109
+ }
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OrphanSweepManifest = exports.OrphanSweepResult = exports.FailedOrphan = exports.ArchivedOrphan = exports.OrphanDirArchiver = exports.TRASH_RETENTION_DAYS = exports.TRASH_MANIFEST_FILE = exports.TRASH_STATE_DIR = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const inversify_1 = require("inversify");
8
+ const state_dir_1 = require("./state-dir");
9
+ const to_error_1 = require("./to-error");
10
+ /** Where every sweep's archive lands, under the repo-wide `.webpieces/` — never under a worktree's own. */
11
+ exports.TRASH_STATE_DIR = 'trash';
12
+ /** The per-sweep manifest, holding what moved and the command that brings each one back. */
13
+ exports.TRASH_MANIFEST_FILE = 'manifest.json';
14
+ /** Sweeps older than this are reaped by the next sweep. Matches RETENTION_DAYS for the aged-tree sweep. */
15
+ exports.TRASH_RETENTION_DAYS = 30;
16
+ const MILLIS_PER_DAY = 24 * 60 * 60 * 1000;
17
+ /**
18
+ * Moves orphan directories into `.webpieces/trash/<sweepId>/` and records how to bring each one back.
19
+ *
20
+ * ─── WHY `mv` AND NEVER `rm -rf` ──────────────────────────────────────────────────────────────────────
21
+ * This runs unattended, on other people's machines, against a predicate that is very good but is still a
22
+ * predicate. A `mv` makes the worst possible outcome — a false positive on a directory somebody actually
23
+ * wanted — a thing they undo in one command, instead of a thing they restore from a backup or lose. That
24
+ * single decision is what makes it defensible to run this automatically at all, and it is the same trade
25
+ * `wp-cleanup` already makes when it archives a branch to a tag before deleting the ref.
26
+ *
27
+ * ─── WHY ONE DIRECTORY PER SWEEP, TIMESTAMP-NAMED ─────────────────────────────────────────────────────
28
+ * `<sweepId>` is a UTC timestamp with `:` swapped for `-` (path-safe everywhere, Windows included). That
29
+ * spelling sorts LEXICALLY in chronological order, so `ls` reads oldest-first and `ls -r` newest-first,
30
+ * with no flags to remember and no dates to parse. Grouping by sweep also answers the only question
31
+ * anybody actually asks of this directory — "what did the run I just did take?" — which a single flat
32
+ * pile of moved directories cannot answer at all.
33
+ *
34
+ * Each sweep directory reproduces the repo-relative path of what it holds, so the manifest's `recover=`
35
+ * is an ordinary `mv` of one path to another, with no bookkeeping needed to reconstruct the destination.
36
+ */
37
+ let OrphanDirArchiver = class OrphanDirArchiver {
38
+ /**
39
+ * Move every candidate into a fresh sweep directory. `now` is a parameter so specs pin the sweep id
40
+ * instead of racing the clock.
41
+ */
42
+ archive(repoRoot, candidates, now) {
43
+ const sweepId = this.sweepId(now);
44
+ const sweepDir = path.join(this.trashRoot(repoRoot), sweepId);
45
+ const moved = [];
46
+ const failed = [];
47
+ for (const candidate of candidates) {
48
+ const destination = path.join(sweepDir, candidate.relativePath);
49
+ const failure = this.move(candidate.absolutePath, destination);
50
+ if (failure !== null) {
51
+ failed.push(new FailedOrphan(candidate.relativePath, failure));
52
+ continue;
53
+ }
54
+ moved.push(new ArchivedOrphan(candidate.relativePath, destination, `mv '${destination}' '${candidate.absolutePath}'`));
55
+ }
56
+ const manifestError = this.writeManifest(sweepDir, sweepId, repoRoot, moved, failed);
57
+ return new OrphanSweepResult(sweepId, sweepDir, moved, failed, manifestError);
58
+ }
59
+ /**
60
+ * Delete sweep directories older than TRASH_RETENTION_DAYS, and report how many went. Called by the
61
+ * sweeper AFTER a successful archive, so trash cannot grow without bound on a machine that has the
62
+ * flag on. This one really does delete — it is deleting the archive, which is the second copy.
63
+ */
64
+ reapAged(repoRoot, now) {
65
+ const root = this.trashRoot(repoRoot);
66
+ const cutoff = now.getTime() - exports.TRASH_RETENTION_DAYS * MILLIS_PER_DAY;
67
+ let reaped = 0;
68
+ for (const entry of this.listSweeps(root)) {
69
+ const stamp = this.parseSweepId(entry);
70
+ if (stamp === null || stamp.getTime() >= cutoff)
71
+ continue;
72
+ if (this.removeTree(path.join(root, entry)))
73
+ reaped += 1;
74
+ }
75
+ return reaped;
76
+ }
77
+ /** `<repo>/.webpieces/trash` — the SHARED state dir, so a worktree's trash is not stranded with it. */
78
+ trashRoot(repoRoot) {
79
+ return path.join(state_dir_1.dotWebpieces.shared(repoRoot), exports.TRASH_STATE_DIR);
80
+ }
81
+ /**
82
+ * `2026-08-19T14-32-05Z`. ISO order with `:` replaced, and milliseconds dropped — a sweep is a
83
+ * human-scale event and a second is resolution enough to name one.
84
+ */
85
+ sweepId(now) {
86
+ return now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
87
+ }
88
+ /** The Date a sweep id names, or null when the entry is not one of ours (a stray file, say). */
89
+ parseSweepId(entry) {
90
+ if (!SWEEP_ID_PATTERN.test(entry))
91
+ return null;
92
+ const iso = `${entry.slice(0, 10)}T${entry.slice(11, 19).replace(/-/g, ':')}Z`;
93
+ const parsed = new Date(iso);
94
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
95
+ }
96
+ listSweeps(root) {
97
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: no trash directory yet is the normal
98
+ // state on every machine that has never swept, and it is not a condition anybody needs told about
99
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
100
+ try {
101
+ return fs.readdirSync(root);
102
+ }
103
+ catch (err) {
104
+ const error = (0, to_error_1.toError)(err);
105
+ void error;
106
+ return [];
107
+ }
108
+ }
109
+ /**
110
+ * `mv` the directory, creating the destination's parent chain first. Returns null on success, or the
111
+ * failure's message — a directory we cannot move is REPORTED and skipped, never fatal.
112
+ *
113
+ * `fs.renameSync` first because it is atomic and instant within a filesystem; a cross-device rename
114
+ * (EXDEV — a repo whose `.webpieces` sits on a different mount) falls back to a recursive copy plus
115
+ * delete, which is what `mv` itself does in the same situation.
116
+ */
117
+ move(from, to) {
118
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: converts a per-directory move failure
119
+ // into a reported line, because one unmovable directory may not abandon the rest of the sweep
120
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
121
+ try {
122
+ fs.mkdirSync(path.dirname(to), { recursive: true });
123
+ fs.renameSync(from, to);
124
+ return null;
125
+ }
126
+ catch (err) {
127
+ const error = (0, to_error_1.toError)(err);
128
+ return this.moveAcrossDevices(from, to, error);
129
+ }
130
+ }
131
+ moveAcrossDevices(from, to, cause) {
132
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: the fallback's own failure becomes the
133
+ // reported message for this directory, the same contract move() has
134
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
135
+ try {
136
+ if (!this.isCrossDevice(cause))
137
+ return cause.message;
138
+ fs.cpSync(from, to, { recursive: true });
139
+ fs.rmSync(from, { recursive: true, force: true });
140
+ return null;
141
+ }
142
+ catch (err) {
143
+ const error = (0, to_error_1.toError)(err);
144
+ return error.message;
145
+ }
146
+ }
147
+ isCrossDevice(error) {
148
+ // webpieces-disable no-any-unknown -- node attaches `code` to fs errors without typing it on Error
149
+ const code = error['code'];
150
+ return code === 'EXDEV';
151
+ }
152
+ removeTree(target) {
153
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: failing to reap OLD trash is cosmetic
154
+ // and may never surface as an error on a command whose real work already succeeded
155
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
156
+ try {
157
+ fs.rmSync(target, { recursive: true, force: true });
158
+ return true;
159
+ }
160
+ catch (err) {
161
+ const error = (0, to_error_1.toError)(err);
162
+ void error;
163
+ return false;
164
+ }
165
+ }
166
+ /**
167
+ * The sweep's own record, beside what it took. Written even when every move failed, because "this
168
+ * sweep tried and could not" is exactly the state somebody debugging needs to find on disk.
169
+ *
170
+ * Returns the failure message, or null on success. It is REPORTED rather than swallowed: the manifest
171
+ * is the durable copy of every `recover=` command, so losing it silently is the one failure here that
172
+ * costs somebody the ability to undo what just happened. Still never fatal — the directories are
173
+ * already safely moved by this point, and the recover lines are still printed to the terminal.
174
+ */
175
+ writeManifest(sweepDir, sweepId, repoRoot, moved, failed) {
176
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: the directories are already safely
177
+ // moved by this point, and an unwritable manifest may not turn that success into a failure
178
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
179
+ try {
180
+ fs.mkdirSync(sweepDir, { recursive: true });
181
+ fs.writeFileSync(path.join(sweepDir, exports.TRASH_MANIFEST_FILE), `${JSON.stringify(new OrphanSweepManifest(sweepId, repoRoot, moved, failed), null, 2)}\n`, 'utf8');
182
+ return null;
183
+ }
184
+ catch (err) {
185
+ const error = (0, to_error_1.toError)(err);
186
+ return error.message;
187
+ }
188
+ }
189
+ };
190
+ exports.OrphanDirArchiver = OrphanDirArchiver;
191
+ exports.OrphanDirArchiver = OrphanDirArchiver = tslib_1.__decorate([
192
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
193
+ ], OrphanDirArchiver);
194
+ /** One directory that was moved, and the exact command that undoes it. Data-only. */
195
+ class ArchivedOrphan {
196
+ /** Where it used to live, repo-relative. */
197
+ relativePath;
198
+ /** Where it lives now, absolute. */
199
+ archivedAt;
200
+ /** The `mv` that puts it back — printed to the human and stored in the manifest verbatim. */
201
+ recoverCommand;
202
+ constructor(relativePath, archivedAt, recoverCommand) {
203
+ this.relativePath = relativePath;
204
+ this.archivedAt = archivedAt;
205
+ this.recoverCommand = recoverCommand;
206
+ }
207
+ }
208
+ exports.ArchivedOrphan = ArchivedOrphan;
209
+ /** One directory the sweep found but could not move, with the reason. Data-only. */
210
+ class FailedOrphan {
211
+ relativePath;
212
+ reason;
213
+ constructor(relativePath, reason) {
214
+ this.relativePath = relativePath;
215
+ this.reason = reason;
216
+ }
217
+ }
218
+ exports.FailedOrphan = FailedOrphan;
219
+ /** What one sweep did. Data-only. */
220
+ class OrphanSweepResult {
221
+ sweepId;
222
+ sweepDir;
223
+ moved;
224
+ failed;
225
+ /**
226
+ * Why the manifest could not be written, or null when it was. Non-null means the printed `recover=`
227
+ * lines are the ONLY copy — see writeManifest().
228
+ */
229
+ manifestError;
230
+ // eslint-disable-next-line @typescript-eslint/max-params
231
+ constructor(sweepId, sweepDir, moved, failed, manifestError) {
232
+ this.sweepId = sweepId;
233
+ this.sweepDir = sweepDir;
234
+ this.moved = moved;
235
+ this.failed = failed;
236
+ this.manifestError = manifestError;
237
+ }
238
+ }
239
+ exports.OrphanSweepResult = OrphanSweepResult;
240
+ /** The on-disk shape of `manifest.json`. Data-only. */
241
+ class OrphanSweepManifest {
242
+ sweepId;
243
+ repoRoot;
244
+ moved;
245
+ failed;
246
+ constructor(sweepId, repoRoot, moved, failed) {
247
+ this.sweepId = sweepId;
248
+ this.repoRoot = repoRoot;
249
+ this.moved = moved;
250
+ this.failed = failed;
251
+ }
252
+ }
253
+ exports.OrphanSweepManifest = OrphanSweepManifest;
254
+ // `2026-08-19T14-32-05Z` — the exact shape sweepId() writes, and the only shape reapAged() will delete.
255
+ const SWEEP_ID_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/;
256
+ //# sourceMappingURL=orphan-dir-archive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orphan-dir-archive.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/orphan-dir-archive.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { OrphanCandidate } from './orphan-dir-scan';\nimport { dotWebpieces } from './state-dir';\nimport { toError } from './to-error';\n\n/** Where every sweep's archive lands, under the repo-wide `.webpieces/` — never under a worktree's own. */\nexport const TRASH_STATE_DIR = 'trash';\n/** The per-sweep manifest, holding what moved and the command that brings each one back. */\nexport const TRASH_MANIFEST_FILE = 'manifest.json';\n/** Sweeps older than this are reaped by the next sweep. Matches RETENTION_DAYS for the aged-tree sweep. */\nexport const TRASH_RETENTION_DAYS = 30;\n\nconst MILLIS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/**\n * Moves orphan directories into `.webpieces/trash/<sweepId>/` and records how to bring each one back.\n *\n * ─── WHY `mv` AND NEVER `rm -rf` ──────────────────────────────────────────────────────────────────────\n * This runs unattended, on other people's machines, against a predicate that is very good but is still a\n * predicate. A `mv` makes the worst possible outcome — a false positive on a directory somebody actually\n * wanted — a thing they undo in one command, instead of a thing they restore from a backup or lose. That\n * single decision is what makes it defensible to run this automatically at all, and it is the same trade\n * `wp-cleanup` already makes when it archives a branch to a tag before deleting the ref.\n *\n * ─── WHY ONE DIRECTORY PER SWEEP, TIMESTAMP-NAMED ─────────────────────────────────────────────────────\n * `<sweepId>` is a UTC timestamp with `:` swapped for `-` (path-safe everywhere, Windows included). That\n * spelling sorts LEXICALLY in chronological order, so `ls` reads oldest-first and `ls -r` newest-first,\n * with no flags to remember and no dates to parse. Grouping by sweep also answers the only question\n * anybody actually asks of this directory — \"what did the run I just did take?\" — which a single flat\n * pile of moved directories cannot answer at all.\n *\n * Each sweep directory reproduces the repo-relative path of what it holds, so the manifest's `recover=`\n * is an ordinary `mv` of one path to another, with no bookkeeping needed to reconstruct the destination.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class OrphanDirArchiver {\n /**\n * Move every candidate into a fresh sweep directory. `now` is a parameter so specs pin the sweep id\n * instead of racing the clock.\n */\n archive(repoRoot: string, candidates: readonly OrphanCandidate[], now: Date): OrphanSweepResult {\n const sweepId = this.sweepId(now);\n const sweepDir = path.join(this.trashRoot(repoRoot), sweepId);\n const moved: ArchivedOrphan[] = [];\n const failed: FailedOrphan[] = [];\n for (const candidate of candidates) {\n const destination = path.join(sweepDir, candidate.relativePath);\n const failure = this.move(candidate.absolutePath, destination);\n if (failure !== null) {\n failed.push(new FailedOrphan(candidate.relativePath, failure));\n continue;\n }\n moved.push(new ArchivedOrphan(candidate.relativePath, destination,\n `mv '${destination}' '${candidate.absolutePath}'`));\n }\n const manifestError = this.writeManifest(sweepDir, sweepId, repoRoot, moved, failed);\n return new OrphanSweepResult(sweepId, sweepDir, moved, failed, manifestError);\n }\n\n /**\n * Delete sweep directories older than TRASH_RETENTION_DAYS, and report how many went. Called by the\n * sweeper AFTER a successful archive, so trash cannot grow without bound on a machine that has the\n * flag on. This one really does delete — it is deleting the archive, which is the second copy.\n */\n reapAged(repoRoot: string, now: Date): number {\n const root = this.trashRoot(repoRoot);\n const cutoff = now.getTime() - TRASH_RETENTION_DAYS * MILLIS_PER_DAY;\n let reaped = 0;\n for (const entry of this.listSweeps(root)) {\n const stamp = this.parseSweepId(entry);\n if (stamp === null || stamp.getTime() >= cutoff) continue;\n if (this.removeTree(path.join(root, entry))) reaped += 1;\n }\n return reaped;\n }\n\n /** `<repo>/.webpieces/trash` — the SHARED state dir, so a worktree's trash is not stranded with it. */\n trashRoot(repoRoot: string): string {\n return path.join(dotWebpieces.shared(repoRoot), TRASH_STATE_DIR);\n }\n\n /**\n * `2026-08-19T14-32-05Z`. ISO order with `:` replaced, and milliseconds dropped — a sweep is a\n * human-scale event and a second is resolution enough to name one.\n */\n sweepId(now: Date): string {\n return now.toISOString().replace(/\\.\\d{3}Z$/, 'Z').replace(/:/g, '-');\n }\n\n /** The Date a sweep id names, or null when the entry is not one of ours (a stray file, say). */\n private parseSweepId(entry: string): Date | null {\n if (!SWEEP_ID_PATTERN.test(entry)) return null;\n const iso = `${entry.slice(0, 10)}T${entry.slice(11, 19).replace(/-/g, ':')}Z`;\n const parsed = new Date(iso);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n }\n\n private listSweeps(root: string): string[] {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: no trash directory yet is the normal\n // state on every machine that has never swept, and it is not a condition anybody needs told about\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.readdirSync(root);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return [];\n }\n }\n\n /**\n * `mv` the directory, creating the destination's parent chain first. Returns null on success, or the\n * failure's message — a directory we cannot move is REPORTED and skipped, never fatal.\n *\n * `fs.renameSync` first because it is atomic and instant within a filesystem; a cross-device rename\n * (EXDEV — a repo whose `.webpieces` sits on a different mount) falls back to a recursive copy plus\n * delete, which is what `mv` itself does in the same situation.\n */\n private move(from: string, to: string): string | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: converts a per-directory move failure\n // into a reported line, because one unmovable directory may not abandon the rest of the sweep\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(path.dirname(to), { recursive: true });\n fs.renameSync(from, to);\n return null;\n } catch (err: unknown) {\n const error = toError(err);\n return this.moveAcrossDevices(from, to, error);\n }\n }\n\n private moveAcrossDevices(from: string, to: string, cause: Error): string | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the fallback's own failure becomes the\n // reported message for this directory, the same contract move() has\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!this.isCrossDevice(cause)) return cause.message;\n fs.cpSync(from, to, { recursive: true });\n fs.rmSync(from, { recursive: true, force: true });\n return null;\n } catch (err: unknown) {\n const error = toError(err);\n return error.message;\n }\n }\n\n private isCrossDevice(error: Error): boolean {\n // webpieces-disable no-any-unknown -- node attaches `code` to fs errors without typing it on Error\n const code = (error as unknown as Record<string, unknown>)['code'];\n return code === 'EXDEV';\n }\n\n private removeTree(target: string): boolean {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: failing to reap OLD trash is cosmetic\n // and may never surface as an error on a command whose real work already succeeded\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(target, { recursive: true, force: true });\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n /**\n * The sweep's own record, beside what it took. Written even when every move failed, because \"this\n * sweep tried and could not\" is exactly the state somebody debugging needs to find on disk.\n *\n * Returns the failure message, or null on success. It is REPORTED rather than swallowed: the manifest\n * is the durable copy of every `recover=` command, so losing it silently is the one failure here that\n * costs somebody the ability to undo what just happened. Still never fatal — the directories are\n * already safely moved by this point, and the recover lines are still printed to the terminal.\n */\n private writeManifest(sweepDir: string, sweepId: string, repoRoot: string,\n moved: readonly ArchivedOrphan[], failed: readonly FailedOrphan[]): string | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the directories are already safely\n // moved by this point, and an unwritable manifest may not turn that success into a failure\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(sweepDir, { recursive: true });\n fs.writeFileSync(path.join(sweepDir, TRASH_MANIFEST_FILE),\n `${JSON.stringify(new OrphanSweepManifest(sweepId, repoRoot, moved, failed), null, 2)}\\n`, 'utf8');\n return null;\n } catch (err: unknown) {\n const error = toError(err);\n return error.message;\n }\n }\n}\n\n/** One directory that was moved, and the exact command that undoes it. Data-only. */\nexport class ArchivedOrphan {\n /** Where it used to live, repo-relative. */\n relativePath: string;\n /** Where it lives now, absolute. */\n archivedAt: string;\n /** The `mv` that puts it back — printed to the human and stored in the manifest verbatim. */\n recoverCommand: string;\n\n constructor(relativePath: string, archivedAt: string, recoverCommand: string) {\n this.relativePath = relativePath;\n this.archivedAt = archivedAt;\n this.recoverCommand = recoverCommand;\n }\n}\n\n/** One directory the sweep found but could not move, with the reason. Data-only. */\nexport class FailedOrphan {\n relativePath: string;\n reason: string;\n\n constructor(relativePath: string, reason: string) {\n this.relativePath = relativePath;\n this.reason = reason;\n }\n}\n\n/** What one sweep did. Data-only. */\nexport class OrphanSweepResult {\n sweepId: string;\n sweepDir: string;\n moved: readonly ArchivedOrphan[];\n failed: readonly FailedOrphan[];\n /**\n * Why the manifest could not be written, or null when it was. Non-null means the printed `recover=`\n * lines are the ONLY copy — see writeManifest().\n */\n manifestError: string | null;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(sweepId: string, sweepDir: string, moved: readonly ArchivedOrphan[],\n failed: readonly FailedOrphan[], manifestError: string | null) {\n this.sweepId = sweepId;\n this.sweepDir = sweepDir;\n this.moved = moved;\n this.failed = failed;\n this.manifestError = manifestError;\n }\n}\n\n/** The on-disk shape of `manifest.json`. Data-only. */\nexport class OrphanSweepManifest {\n sweepId: string;\n repoRoot: string;\n moved: readonly ArchivedOrphan[];\n failed: readonly FailedOrphan[];\n\n constructor(sweepId: string, repoRoot: string, moved: readonly ArchivedOrphan[],\n failed: readonly FailedOrphan[]) {\n this.sweepId = sweepId;\n this.repoRoot = repoRoot;\n this.moved = moved;\n this.failed = failed;\n }\n}\n\n// `2026-08-19T14-32-05Z` — the exact shape sweepId() writes, and the only shape reapAged() will delete.\nconst SWEEP_ID_PATTERN = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}Z$/;\n"]}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Finds ORPHAN DIRECTORIES — the package directories an `nx g move` leaves behind on every clone, which
3
+ * git cannot remove and which then accumulate on every developer's machine forever.
4
+ *
5
+ * ─── WHY GIT LEAVES THEM ──────────────────────────────────────────────────────────────────────────────
6
+ * Git tracks files, not directories. When a move deletes every tracked file under `libraries/apis/foo/`,
7
+ * git removes that directory ONLY if it is then truly empty — and it never is, because the build already
8
+ * put an IGNORED `dist/` and `node_modules/` inside it. So the corpse stands, on every clone, and there
9
+ * is no git setting that changes this (`core.pruneIgnoredDirs` does not exist).
10
+ *
11
+ * ─── WHY THE PREDICATE IS GIT'S OWN ANSWER AND NOT A HAND-ROLLED WALK ─────────────────────────────────
12
+ * `git clean -Xdn` collapses a directory to a SINGLE entry exactly when everything beneath it is ignored,
13
+ * and it does that using the repo's real ignore engine — nested `.gitignore` files, `!` negations and
14
+ * all. Re-implementing that is how a sweeper ends up disagreeing with git about what is disposable.
15
+ *
16
+ * It also hands us the safety property for free, which is the reason this can run unattended: a directory
17
+ * holding even ONE untracked-but-not-ignored file is NOT reported, so a developer's uncommitted new
18
+ * package can never be a candidate. We do not check that ourselves; git's own output already encodes it.
19
+ *
20
+ * ─── THE ONE DISCRIMINATOR THAT NEEDS NO NAME LIST ────────────────────────────────────────────────────
21
+ * `clean -Xdn` reports two very different things the same way:
22
+ *
23
+ * libraries/apis/pg-dataaccess-api/ ← a corpse: nothing tracked survives under it
24
+ * libraries/apis/live-api/dist/ ← a LIVE project's build output, there on purpose
25
+ *
26
+ * They are separated by asking whether the directory ITSELF matches an ignore rule. `dist/` does
27
+ * (`.gitignore:19:dist/`); `pg-dataaccess-api` does not, which is precisely the statement that this
28
+ * directory was MEANT to hold tracked files — and none are left. No list of artifact directory names is
29
+ * needed, and none is maintained, so the rule adapts to whatever any given consumer repo ignores.
30
+ *
31
+ * That also yields the opt-out, and it is one a developer would reach for anyway: adding `my-sandbox/`
32
+ * to `.gitignore` makes the directory ignore-matched, which permanently protects it from this sweep.
33
+ */
34
+ export declare class OrphanDirScanner {
35
+ /**
36
+ * Every orphan directory under `repoRoot`, as repo-RELATIVE paths with no trailing slash. Empty when
37
+ * the tree is clean, when `repoRoot` is not a git repository, or when git cannot be run at all — a
38
+ * sweeper is never important enough to fail somebody's checkout over.
39
+ */
40
+ scan(repoRoot: string): OrphanCandidate[];
41
+ /**
42
+ * The DIRECTORY paths `git clean -Xdn` would remove, relative to `repoRoot` and slash-stripped.
43
+ *
44
+ * Only entries ending in `/` are directories; a bare path is a single ignored FILE (`.env`,
45
+ * `.claude/settings.local.json`) and is none of this sweep's business. `Would skip repository` lines
46
+ * are git telling us it found a nested repo — a linked worktree — which it refuses to descend into,
47
+ * and so do we.
48
+ */
49
+ private reportedByGitClean;
50
+ /**
51
+ * True when git matches an ignore rule against the DIRECTORY ITSELF — i.e. it is a build artifact
52
+ * somebody declared disposable-but-expected, not a corpse. `check-ignore -q` exits 0 on a match and 1
53
+ * on no match, so a null answer here (any non-zero exit, including a git we could not run) means
54
+ * "cannot confirm", and the directory is SPARED. Every uncertainty in this class resolves that way.
55
+ */
56
+ private isIgnoredItself;
57
+ /**
58
+ * The two structural exclusions, both about where a moved nx PROJECT can possibly live.
59
+ *
60
+ * DOT SEGMENTS: `.nx/`, `.idea/`, `.webpieces/` and friends are reported whole by `clean -Xdn` (their
61
+ * contents are ignored, the directories themselves are not) and would otherwise pass the discriminator
62
+ * above perfectly. They are tool state, they are supposed to be there, and no nx project is named with
63
+ * a leading dot. Excluding the whole class by shape beats naming today's four and meeting a fifth.
64
+ *
65
+ * DEPTH: a moved project lives at `libraries/x`, `apps/x`, `packages/y/z` — never at the top level.
66
+ * Requiring depth >= 2 costs nothing real and takes every top-level directory in the repo permanently
67
+ * out of reach of an automated `mv`.
68
+ */
69
+ private isSweepable;
70
+ /**
71
+ * `git <args>` in `repoRoot`, or null when git exits non-zero or could not be run at all.
72
+ *
73
+ * spawnSync rather than execFileSync because a non-zero exit is ORDINARY here — `check-ignore` says
74
+ * "not ignored" that way — and a status code is a better answer to that question than an exception.
75
+ *
76
+ * An ARGUMENT ARRAY, never a shell string: these paths come from a filesystem scan and may hold
77
+ * spaces, quotes or `$`, and interpolating them into a shell would be building a command injection
78
+ * into the one tool whose entire job is moving directories around.
79
+ */
80
+ private git;
81
+ }
82
+ /** One directory the sweep may archive. Data-only (per CLAUDE.md — classes, not interfaces, for data). */
83
+ export declare class OrphanCandidate {
84
+ /** Repo-relative, no trailing slash — e.g. `libraries/apis/pg-dataaccess-api`. */
85
+ relativePath: string;
86
+ /** The same directory as an absolute path, which is what any `mv` needs. */
87
+ absolutePath: string;
88
+ constructor(relativePath: string, absolutePath: string);
89
+ }
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OrphanCandidate = exports.OrphanDirScanner = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
6
+ const child_process_1 = require("child_process");
7
+ const inversify_1 = require("inversify");
8
+ /**
9
+ * Finds ORPHAN DIRECTORIES — the package directories an `nx g move` leaves behind on every clone, which
10
+ * git cannot remove and which then accumulate on every developer's machine forever.
11
+ *
12
+ * ─── WHY GIT LEAVES THEM ──────────────────────────────────────────────────────────────────────────────
13
+ * Git tracks files, not directories. When a move deletes every tracked file under `libraries/apis/foo/`,
14
+ * git removes that directory ONLY if it is then truly empty — and it never is, because the build already
15
+ * put an IGNORED `dist/` and `node_modules/` inside it. So the corpse stands, on every clone, and there
16
+ * is no git setting that changes this (`core.pruneIgnoredDirs` does not exist).
17
+ *
18
+ * ─── WHY THE PREDICATE IS GIT'S OWN ANSWER AND NOT A HAND-ROLLED WALK ─────────────────────────────────
19
+ * `git clean -Xdn` collapses a directory to a SINGLE entry exactly when everything beneath it is ignored,
20
+ * and it does that using the repo's real ignore engine — nested `.gitignore` files, `!` negations and
21
+ * all. Re-implementing that is how a sweeper ends up disagreeing with git about what is disposable.
22
+ *
23
+ * It also hands us the safety property for free, which is the reason this can run unattended: a directory
24
+ * holding even ONE untracked-but-not-ignored file is NOT reported, so a developer's uncommitted new
25
+ * package can never be a candidate. We do not check that ourselves; git's own output already encodes it.
26
+ *
27
+ * ─── THE ONE DISCRIMINATOR THAT NEEDS NO NAME LIST ────────────────────────────────────────────────────
28
+ * `clean -Xdn` reports two very different things the same way:
29
+ *
30
+ * libraries/apis/pg-dataaccess-api/ ← a corpse: nothing tracked survives under it
31
+ * libraries/apis/live-api/dist/ ← a LIVE project's build output, there on purpose
32
+ *
33
+ * They are separated by asking whether the directory ITSELF matches an ignore rule. `dist/` does
34
+ * (`.gitignore:19:dist/`); `pg-dataaccess-api` does not, which is precisely the statement that this
35
+ * directory was MEANT to hold tracked files — and none are left. No list of artifact directory names is
36
+ * needed, and none is maintained, so the rule adapts to whatever any given consumer repo ignores.
37
+ *
38
+ * That also yields the opt-out, and it is one a developer would reach for anyway: adding `my-sandbox/`
39
+ * to `.gitignore` makes the directory ignore-matched, which permanently protects it from this sweep.
40
+ */
41
+ let OrphanDirScanner = class OrphanDirScanner {
42
+ /**
43
+ * Every orphan directory under `repoRoot`, as repo-RELATIVE paths with no trailing slash. Empty when
44
+ * the tree is clean, when `repoRoot` is not a git repository, or when git cannot be run at all — a
45
+ * sweeper is never important enough to fail somebody's checkout over.
46
+ */
47
+ scan(repoRoot) {
48
+ const reported = this.reportedByGitClean(repoRoot);
49
+ const candidates = [];
50
+ for (const relative of reported) {
51
+ if (!this.isSweepable(relative))
52
+ continue;
53
+ if (this.isIgnoredItself(repoRoot, relative))
54
+ continue;
55
+ candidates.push(new OrphanCandidate(relative, path.join(repoRoot, relative)));
56
+ }
57
+ return candidates.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
58
+ }
59
+ /**
60
+ * The DIRECTORY paths `git clean -Xdn` would remove, relative to `repoRoot` and slash-stripped.
61
+ *
62
+ * Only entries ending in `/` are directories; a bare path is a single ignored FILE (`.env`,
63
+ * `.claude/settings.local.json`) and is none of this sweep's business. `Would skip repository` lines
64
+ * are git telling us it found a nested repo — a linked worktree — which it refuses to descend into,
65
+ * and so do we.
66
+ */
67
+ reportedByGitClean(repoRoot) {
68
+ const raw = this.git(repoRoot, ['clean', '-Xdn']);
69
+ if (raw === null)
70
+ return [];
71
+ const paths = [];
72
+ for (const line of raw.split('\n')) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed.startsWith(WOULD_REMOVE))
75
+ continue;
76
+ const target = trimmed.slice(WOULD_REMOVE.length).trim();
77
+ if (!target.endsWith('/'))
78
+ continue;
79
+ paths.push(target.slice(0, -1));
80
+ }
81
+ return paths;
82
+ }
83
+ /**
84
+ * True when git matches an ignore rule against the DIRECTORY ITSELF — i.e. it is a build artifact
85
+ * somebody declared disposable-but-expected, not a corpse. `check-ignore -q` exits 0 on a match and 1
86
+ * on no match, so a null answer here (any non-zero exit, including a git we could not run) means
87
+ * "cannot confirm", and the directory is SPARED. Every uncertainty in this class resolves that way.
88
+ */
89
+ isIgnoredItself(repoRoot, relative) {
90
+ return this.git(repoRoot, ['check-ignore', '-q', '--', relative]) !== null;
91
+ }
92
+ /**
93
+ * The two structural exclusions, both about where a moved nx PROJECT can possibly live.
94
+ *
95
+ * DOT SEGMENTS: `.nx/`, `.idea/`, `.webpieces/` and friends are reported whole by `clean -Xdn` (their
96
+ * contents are ignored, the directories themselves are not) and would otherwise pass the discriminator
97
+ * above perfectly. They are tool state, they are supposed to be there, and no nx project is named with
98
+ * a leading dot. Excluding the whole class by shape beats naming today's four and meeting a fifth.
99
+ *
100
+ * DEPTH: a moved project lives at `libraries/x`, `apps/x`, `packages/y/z` — never at the top level.
101
+ * Requiring depth >= 2 costs nothing real and takes every top-level directory in the repo permanently
102
+ * out of reach of an automated `mv`.
103
+ */
104
+ isSweepable(relative) {
105
+ const segments = relative.split('/');
106
+ if (segments.length < MIN_DEPTH)
107
+ return false;
108
+ return !segments.some((segment) => segment.startsWith('.'));
109
+ }
110
+ /**
111
+ * `git <args>` in `repoRoot`, or null when git exits non-zero or could not be run at all.
112
+ *
113
+ * spawnSync rather than execFileSync because a non-zero exit is ORDINARY here — `check-ignore` says
114
+ * "not ignored" that way — and a status code is a better answer to that question than an exception.
115
+ *
116
+ * An ARGUMENT ARRAY, never a shell string: these paths come from a filesystem scan and may hold
117
+ * spaces, quotes or `$`, and interpolating them into a shell would be building a command injection
118
+ * into the one tool whose entire job is moving directories around.
119
+ */
120
+ git(repoRoot, args) {
121
+ const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], {
122
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: GIT_OUTPUT_LIMIT,
123
+ });
124
+ if (result.error !== undefined || result.status !== 0)
125
+ return null;
126
+ return result.stdout;
127
+ }
128
+ };
129
+ exports.OrphanDirScanner = OrphanDirScanner;
130
+ exports.OrphanDirScanner = OrphanDirScanner = tslib_1.__decorate([
131
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
132
+ ], OrphanDirScanner);
133
+ /** One directory the sweep may archive. Data-only (per CLAUDE.md — classes, not interfaces, for data). */
134
+ class OrphanCandidate {
135
+ /** Repo-relative, no trailing slash — e.g. `libraries/apis/pg-dataaccess-api`. */
136
+ relativePath;
137
+ /** The same directory as an absolute path, which is what any `mv` needs. */
138
+ absolutePath;
139
+ constructor(relativePath, absolutePath) {
140
+ this.relativePath = relativePath;
141
+ this.absolutePath = absolutePath;
142
+ }
143
+ }
144
+ exports.OrphanCandidate = OrphanCandidate;
145
+ const WOULD_REMOVE = 'Would remove ';
146
+ // `libraries/foo` is depth 2. Anything shallower is a top-level directory — see isSweepable().
147
+ const MIN_DEPTH = 2;
148
+ // `clean -Xdn` on a large monorepo prints a few hundred lines; 16MB is a ceiling no real repo approaches.
149
+ const GIT_OUTPUT_LIMIT = 16 * 1024 * 1024;
150
+ //# sourceMappingURL=orphan-dir-scan.js.map