@cfour-hi/dsh-plugin-devicex 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Device X — uninstall footprint: pure classification kernel.
3
+ *
4
+ * No filesystem, no child processes, no clock. Everything here is a pure
5
+ * function over plain data so the four confidence tiers can be unit-tested
6
+ * without touching a real `~/Library` (see `test/footprint-core.test.mjs`).
7
+ *
8
+ * The classification rules encode what was actually observed on a real macOS
9
+ * host, including the traps:
10
+ *
11
+ * - Group Containers carry a 10-character Team ID prefix
12
+ * (`6N38VWS5BX.ru.keepcoder.Telegram`), and shared groups additionally carry
13
+ * a literal `group.` marker (`group.W5364U7YZB.io.tailscale.shared`);
14
+ * - a directory can match more than one installed app purely by name
15
+ * (`Doubao` vs `DoubaoWork`), so tiering needs *every* installed app, not
16
+ * just the one being uninstalled;
17
+ * - substring matching is a trap: `Code` would swallow `Codex` and
18
+ * `CodeBuddyExtension`, so name matching is anchored, never substring.
19
+ *
20
+ * @module @cfour-hi/dsh-plugin-devicex/host/footprint-core
21
+ */
22
+
23
+ /**
24
+ * A 10-character Team ID followed by a dot, at the start of a directory name.
25
+ *
26
+ * Case-insensitive on purpose: callers lowercase directory names before
27
+ * matching, which turns `6N38VWS5BX` into `6n38vws5bx`.
28
+ */
29
+ const TEAM_PREFIX = /^[A-Z0-9]{10}\./i;
30
+ /** The `group.` marker used by shared Group Containers, with or without a Team ID. */
31
+ const GROUP_PREFIX = /^group\./i;
32
+ /** The `group.<TeamID>.` form, kept separate so the Team ID is still checked. */
33
+ const GROUP_TEAM_PREFIX = /^group\.[A-Z0-9]{10}\./i;
34
+
35
+ /** Signals that a directory name is a vendor-level shared container. */
36
+ const VENDOR_ROOTS = new Set([
37
+ 'google',
38
+ 'microsoft',
39
+ 'adobe',
40
+ 'apple',
41
+ 'mozilla',
42
+ 'tencent',
43
+ 'alibaba',
44
+ 'bytedance',
45
+ 'dropbox',
46
+ 'jetbrains',
47
+ ]);
48
+
49
+ /**
50
+ * Strip the Team ID (and the `group.` marker) from a container directory name.
51
+ * Non-container names pass through unchanged.
52
+ * @param {unknown} name
53
+ * @returns {string}
54
+ */
55
+ export function stripTeamPrefix(name) {
56
+ if (typeof name !== 'string') return '';
57
+ if (GROUP_TEAM_PREFIX.test(name)) return name.replace(GROUP_TEAM_PREFIX, '');
58
+ if (GROUP_PREFIX.test(name)) return name.replace(GROUP_PREFIX, '');
59
+ return name.replace(TEAM_PREFIX, '');
60
+ }
61
+
62
+ /**
63
+ * Normalise an application name to the slug used for name-similarity matching.
64
+ *
65
+ * Lowercase, drop a trailing `.app`, and collapse any run of non-alphanumeric
66
+ * characters into a single `-`, then trim the edges. Separators are *kept* as
67
+ * `-`: stripping them entirely would make `Claude-3p` collide with `claude3p`
68
+ * and lose the boundary that `matchesAppName` relies on. `Clash Verge` and
69
+ * `clash-verge` still compare equal.
70
+ * @param {unknown} name
71
+ * @returns {string}
72
+ */
73
+ export function slugify(name) {
74
+ if (typeof name !== 'string') return '';
75
+ return name
76
+ .toLowerCase()
77
+ .replace(/\.app$/, '')
78
+ .replace(/[^a-z0-9]+/g, '-')
79
+ .replace(/^-+|-+$/g, '');
80
+ }
81
+
82
+ /**
83
+ * Strict slug: lowercase alphanumerics only, no separators.
84
+ *
85
+ * Used where a real filesystem name has to be compared against a slug with no
86
+ * known separator (`Packages/Acme.Widget_8wekyb3d8bbwe` → `acmewidget`).
87
+ * @param {unknown} name
88
+ * @returns {string}
89
+ */
90
+ export function compactSlug(name) {
91
+ return slugify(name).replace(/-/g, '');
92
+ }
93
+
94
+ /**
95
+ * Lowercase a directory base name for indexing.
96
+ * @param {unknown} name
97
+ * @returns {string}
98
+ */
99
+ function normalizeName(name) {
100
+ return typeof name === 'string' ? name.toLowerCase() : '';
101
+ }
102
+
103
+ /**
104
+ * True when `directoryName` is anchored to `bundleId`: either exactly equal, or
105
+ * a dot-separated child of it (`com.x.App` → `com.x.App.ShipIt`). A bare
106
+ * `startsWith` would make `com.foo` match `com.foobar`, which is wrong.
107
+ * @param {string} directoryName lowercased directory base name
108
+ * @param {string} bundleId lowercased bundle id
109
+ * @returns {boolean}
110
+ */
111
+ export function matchesBundleId(directoryName, bundleId) {
112
+ if (directoryName === '' || bundleId === '') return false;
113
+ if (directoryName === bundleId) return true;
114
+ return directoryName.startsWith(`${bundleId}.`);
115
+ }
116
+
117
+ /**
118
+ * True when a directory name looks like an app-name match for `slug`.
119
+ *
120
+ * Deliberately anchored at the start and separated by `-`, `.`, `_` or end of
121
+ * string. `code` matches `code` and `code-3p`, but never `codex`.
122
+ * @param {string} directoryName lowercased directory base name
123
+ * @param {string} slug slugified app name
124
+ * @returns {boolean}
125
+ */
126
+ export function matchesAppName(directoryName, slug) {
127
+ if (directoryName === '' || slug === '') return false;
128
+ if (directoryName === slug) return true;
129
+ return (
130
+ directoryName.startsWith(`${slug}-`) ||
131
+ directoryName.startsWith(`${slug}.`) ||
132
+ directoryName.startsWith(`${slug}_`)
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Strip everything after the first dot so a vendor product directory can be
138
+ * compared on its leading segment (`com.google.chrome` → `com`).
139
+ * @param {string} value
140
+ * @returns {string}
141
+ */
142
+ function leadingSegment(value) {
143
+ const index = value.indexOf('.');
144
+ return index < 0 ? value : value.slice(0, index);
145
+ }
146
+
147
+ /**
148
+ * True when the directory is a vendor-level shared directory rather than one
149
+ * product's own directory. `~/Library/Application Support/Google` is shared by
150
+ * Chrome, Chrome for Testing and the updater, so it must never be deletable.
151
+ * @param {string} directoryName original (not lowercased) base name
152
+ * @returns {boolean}
153
+ */
154
+ export function isVendorShared(directoryName) {
155
+ if (typeof directoryName !== 'string' || directoryName === '') return false;
156
+ const lowered = directoryName.toLowerCase();
157
+ if (VENDOR_ROOTS.has(lowered)) return true;
158
+ // `com.google` style vendor roots: reverse-DNS with nothing after the vendor.
159
+ if (lowered.startsWith('com.') && lowered.split('.').length === 2 && VENDOR_ROOTS.has(leadingSegment(lowered.slice(4)))) {
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Build the lookup index for a scan.
167
+ *
168
+ * @param {Array<{name: string, relPath: string, bytes?: number, files?: number,
169
+ * requiresAdmin?: boolean}>} entries every candidate directory found by a sweep
170
+ * @returns {{entries: Array<object>, byLowerName: Map<string, number[]>}}
171
+ */
172
+ export function buildIndex(entries) {
173
+ const list = Array.isArray(entries)
174
+ ? entries.filter((entry) => entry && typeof entry.name === 'string' && typeof entry.relPath === 'string')
175
+ : [];
176
+ /** @type {Map<string, number[]>} */
177
+ const byLowerName = new Map();
178
+ list.forEach((entry, index) => {
179
+ const key = normalizeName(entry.name);
180
+ const bucket = byLowerName.get(key);
181
+ if (bucket) bucket.push(index);
182
+ else byLowerName.set(key, [index]);
183
+ });
184
+ return { entries: list, byLowerName };
185
+ }
186
+
187
+ /**
188
+ * The app contexts needed to resolve a directory to one or more owners.
189
+ * @typedef {object} AppContext
190
+ * @property {string} name display name
191
+ * @property {string|null} bundleId
192
+ * @property {string} slug slugified display name
193
+ */
194
+
195
+ /**
196
+ * Normalise a raw installed-application record into an {@link AppContext}.
197
+ * @param {{name?: unknown, bundleId?: unknown}} app
198
+ * @returns {AppContext}
199
+ */
200
+ export function toAppContext(app) {
201
+ const name = typeof app?.name === 'string' ? app.name : '';
202
+ const bundleId = typeof app?.bundleId === 'string' && app.bundleId !== '' ? app.bundleId : null;
203
+ return { name, bundleId, slug: slugify(name) };
204
+ }
205
+
206
+ /**
207
+ * Find every app that could own a directory name.
208
+ *
209
+ * The Team ID prefix is stripped first, so `6N38VWS5BX.ru.keepcoder.Telegram`
210
+ * resolves to the app whose bundle id is `ru.keepcoder.Telegram`.
211
+ *
212
+ * Name matching compares *slugified* forms because directory names keep their
213
+ * spaces and punctuation: `%APPDATA%\Acme Widget` must match the slug
214
+ * `acmewidget`, and on macOS `Application Support/Clash Verge` is the same case.
215
+ * Bundle-id matching still uses the raw lowercased name, since a bundle id is
216
+ * never spelled with separators that slugify would remove.
217
+ *
218
+ * @param {string} directoryName
219
+ * @param {AppContext[]} apps every installed app
220
+ * @returns {AppContext[]}
221
+ */
222
+ export function ownersOf(directoryName, apps) {
223
+ const raw = normalizeName(directoryName);
224
+ if (raw === '') return [];
225
+ const bare = stripTeamPrefix(raw);
226
+ const bareSlug = slugify(bare);
227
+ const owners = [];
228
+ for (const app of Array.isArray(apps) ? apps : []) {
229
+ if (!app || typeof app !== 'object') continue;
230
+ const bundleId = typeof app.bundleId === 'string' ? app.bundleId.toLowerCase() : '';
231
+ const slug = typeof app.slug === 'string' ? app.slug : slugify(app.name);
232
+ if (bundleId !== '' && matchesBundleId(bare, bundleId)) {
233
+ owners.push(app);
234
+ continue;
235
+ }
236
+ if (slug !== '' && matchesAppName(bareSlug, slug)) owners.push(app);
237
+ }
238
+ return owners;
239
+ }
240
+
241
+ /**
242
+ * Decide whether two app contexts denote the same installed application.
243
+ *
244
+ * A bundle id is the real identity; the slug is only a fallback for records
245
+ * that carry no bundle id. Comparing slugs alone would treat two genuinely
246
+ * different apps that happen to share a name as one, which silently collapses
247
+ * the `conflict` tier into `high`.
248
+ *
249
+ * @param {AppContext|null|undefined} left
250
+ * @param {AppContext|null|undefined} right
251
+ * @returns {boolean}
252
+ */
253
+ export function isSameApp(left, right) {
254
+ if (!left || !right) return false;
255
+ if (left === right) return true;
256
+ const leftId = typeof left.bundleId === 'string' ? left.bundleId.toLowerCase() : '';
257
+ const rightId = typeof right.bundleId === 'string' ? right.bundleId.toLowerCase() : '';
258
+ if (leftId !== '' && rightId !== '') return leftId === rightId;
259
+ return left.slug !== '' && left.slug === right.slug;
260
+ }
261
+
262
+ /**
263
+ * Collapse repeated roster entries.
264
+ *
265
+ * The collector reports the same bundle from more than one source (an app and
266
+ * its helper both listing the parent's bundle id, or the same bundle found in
267
+ * two locations). Without this, one installed app with three duplicate rows
268
+ * looks like three competing owners and every match degrades to `conflict`.
269
+ *
270
+ * Identity is the bundle id when present, else the slug. The first entry wins
271
+ * so a bundle-id-bearing row is never displaced by a weaker one.
272
+ *
273
+ * @param {AppContext[]} apps
274
+ * @returns {AppContext[]}
275
+ */
276
+ export function dedupeApps(apps) {
277
+ /** @type {AppContext[]} */
278
+ const out = [];
279
+ /** @type {Set<string>} */
280
+ const seen = new Set();
281
+ for (const app of Array.isArray(apps) ? apps : []) {
282
+ if (!app || typeof app !== 'object') continue;
283
+ const bundleId = typeof app.bundleId === 'string' ? app.bundleId.toLowerCase() : '';
284
+ const slug = typeof app.slug === 'string' ? app.slug : slugify(app.name);
285
+ const key = bundleId !== '' ? `id:${bundleId}` : `slug:${slug}`;
286
+ if (key === 'slug:' || seen.has(key)) continue;
287
+ seen.add(key);
288
+ out.push(app);
289
+ }
290
+ return out;
291
+ }
292
+
293
+ /**
294
+ * Resolve one candidate directory to a confidence tier.
295
+ *
296
+ * @param {object} candidate
297
+ * @param {string} candidate.name directory base name (as found on disk)
298
+ * @param {string} [candidate.relPath] optional display path; classification
299
+ * only depends on the base name
300
+ * @param {AppContext} subject the app being uninstalled
301
+ * @param {AppContext[]} allApps every installed app (for conflict detection)
302
+ * @param {{fromBrewZap?: boolean, kind?: string, holdsSubject?: boolean}} [hints]
303
+ * `holdsSubject` must be `true` for a vendor-shared directory that actually
304
+ * contains the subject app's own data; a vendor directory belonging to some
305
+ * unrelated product is not a leftover of *this* app and yields `null`.
306
+ * @returns {{confidence: import('../../types/contracts.js').FootprintConfidence,
307
+ * source: import('../../types/contracts.js').FootprintSource,
308
+ * reason: string, alsoMatchedBy: string[]} | null} `null` when the directory is
309
+ * not attributable to the subject app at all; the caller drops those rows.
310
+ */
311
+ export function classify(candidate, subject, allApps, hints = {}) {
312
+ const name = typeof candidate?.name === 'string' ? candidate.name : '';
313
+ const isShared = isVendorShared(name);
314
+
315
+ if (isShared) {
316
+ // A vendor directory only concerns us when it actually holds data for the
317
+ // subject app. Otherwise it is another product's directory and showing it
318
+ // would be pure noise (IINA has nothing to do with `Application Support/
319
+ // Google`), so it is dropped entirely.
320
+ if (hints.holdsSubject !== true) return null;
321
+ return {
322
+ confidence: 'shared',
323
+ source: 'bundle-id',
324
+ reason: 'Vendor-level directory shared with other products; shown for reference only.',
325
+ alsoMatchedBy: [],
326
+ };
327
+ }
328
+
329
+ const owners = ownersOf(name, Array.isArray(allApps) ? allApps : []);
330
+ const subjectOwns = owners.some((owner) => isSameApp(owner, subject));
331
+
332
+ if (hints.fromBrewZap === true) {
333
+ return {
334
+ confidence: 'high',
335
+ source: 'brew-zap',
336
+ reason: 'Listed in this app\'s Homebrew cask zap recipe.',
337
+ alsoMatchedBy: [],
338
+ };
339
+ }
340
+
341
+ if (subjectOwns) {
342
+ const bare = stripTeamPrefix(normalizeName(name));
343
+ const viaBundleId =
344
+ typeof subject?.bundleId === 'string' && matchesBundleId(bare, subject.bundleId.toLowerCase());
345
+ const others = owners.filter((owner) => !isSameApp(owner, subject));
346
+ if (others.length > 0) {
347
+ return {
348
+ confidence: 'conflict',
349
+ source: 'name-match',
350
+ reason: `Directory name also matches ${others.length} other installed app(s).`,
351
+ alsoMatchedBy: others.map((owner) => owner.name).filter((value) => value !== ''),
352
+ };
353
+ }
354
+ // Design §4: a bundle-id hit is tier A; a pure name-similarity hit is tier
355
+ // B and must default to unselected, because the user knows better than the
356
+ // machine whether `Claude-3p` really belongs to Claude.
357
+ return {
358
+ confidence: viaBundleId ? 'high' : 'medium',
359
+ source: viaBundleId ? 'bundle-id' : 'name-match',
360
+ reason: viaBundleId
361
+ ? 'Directory name matches the app\'s bundle identifier.'
362
+ : `Directory name resembles the app name: ${subject?.name ?? ''} ≈ ${name}.`,
363
+ alsoMatchedBy: [],
364
+ };
365
+ }
366
+
367
+ return null;
368
+ }
369
+
370
+ /**
371
+ * Total bytes of a set of found items.
372
+ * @param {Array<{bytes?: unknown}>} items
373
+ * @returns {number}
374
+ */
375
+ export function totalBytes(items) {
376
+ let sum = 0;
377
+ for (const item of Array.isArray(items) ? items : []) {
378
+ const value = Number(item?.bytes);
379
+ if (Number.isFinite(value) && value > 0) sum += value;
380
+ }
381
+ return sum;
382
+ }
383
+
384
+ /**
385
+ * Bytes that a default-confirmed dialog would remove: the `high` tier only.
386
+ * @param {Array<{bytes?: unknown, confidence?: unknown}>} items
387
+ * @returns {number}
388
+ */
389
+ export function selectedBytes(items) {
390
+ const high = (Array.isArray(items) ? items : []).filter((item) => item?.confidence === 'high');
391
+ return totalBytes(high);
392
+ }
393
+
394
+ /** Test seam: the exact sets this module classifies by. */
395
+ export const __test = {
396
+ VENDOR_ROOTS,
397
+ TEAM_PREFIX,
398
+ GROUP_TEAM_PREFIX,
399
+ };