disk-space-saviour 0.16.0 → 0.18.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.
- package/CHANGELOG.md +18 -0
- package/README.md +67 -14
- package/bin/dss.js +5 -1
- package/package.json +1 -1
- package/src/clean.js +45 -13
- package/src/cli.js +80 -14
- package/src/docker/scan.js +1 -0
- package/src/emergency.js +41 -6
- package/src/env/local.js +270 -65
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +10 -1
- package/src/fast-clean.js +39 -0
- package/src/git.js +101 -39
- package/src/health.js +3 -3
- package/src/index.d.ts +28 -1
- package/src/liveness.js +131 -9
- package/src/options.js +3 -1
- package/src/report.js +69 -2
- package/src/rules/ecosystems.js +5 -4
- package/src/rules/other.js +4 -1
- package/src/rules/versions.js +23 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +157 -22
- package/src/scanners/global.js +46 -11
- package/src/scanners/projects.js +94 -69
- package/src/scanners/versions.js +109 -7
- package/src/swap.js +69 -0
- package/src/timings.js +54 -0
package/src/env/local.js
CHANGED
|
@@ -48,6 +48,40 @@ export function parseLsofCwds(output) {
|
|
|
48
48
|
return cwds;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Absolute directories of a `PATH` value.
|
|
53
|
+
* @param {string} value
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
export function searchPathDirs(value) {
|
|
57
|
+
return (value ?? '').split(':').filter((dir) => dir.startsWith('/'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `PATH` directories by pid from `ps -E -ww -axo pid=,command=` output,
|
|
62
|
+
* which appends the environment of the user's own processes to their
|
|
63
|
+
* arguments. A value ends at the first space, so a directory whose name
|
|
64
|
+
* contains one is not tracked.
|
|
65
|
+
* @param {string} output
|
|
66
|
+
* @returns {Map<number, string[]>}
|
|
67
|
+
*/
|
|
68
|
+
export function parsePsSearchPaths(output) {
|
|
69
|
+
const paths = new Map();
|
|
70
|
+
for (const line of output.split('\n')) {
|
|
71
|
+
const match = /^\s*(\d+)\s/.exec(line);
|
|
72
|
+
if (!match) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const dirs = [...line.matchAll(/(?:^|\s)PATH=(\S*)/g)].flatMap((found) =>
|
|
76
|
+
searchPathDirs(found[1])
|
|
77
|
+
);
|
|
78
|
+
if (dirs.length > 0) {
|
|
79
|
+
paths.set(Number(match[1]), [...new Set(dirs)]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return paths;
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
/**
|
|
52
86
|
* Environment adapter for the machine running dss, backed by node:fs.
|
|
53
87
|
*
|
|
@@ -64,6 +98,9 @@ import { CACHE_VARIABLES } from './cache-roots.js';
|
|
|
64
98
|
|
|
65
99
|
import { hostExecutor } from '../exec.js';
|
|
66
100
|
import { matchesGlob } from '../paths.js';
|
|
101
|
+
import { mapLimit } from './parallel.js';
|
|
102
|
+
import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
|
|
103
|
+
import { indexedDirectories } from './platform-index.js';
|
|
67
104
|
|
|
68
105
|
// Directories read concurrently while measuring usage.
|
|
69
106
|
const WALK_BATCH = 64;
|
|
@@ -252,6 +289,7 @@ export class LocalEnv {
|
|
|
252
289
|
size: stats.size,
|
|
253
290
|
bytes: diskBytes(stats),
|
|
254
291
|
mtimeMs: stats.mtimeMs,
|
|
292
|
+
ctimeMs: stats.ctimeMs,
|
|
255
293
|
inode: `${stats.dev}:${stats.ino}`,
|
|
256
294
|
uid: stats.uid,
|
|
257
295
|
gid: stats.gid,
|
|
@@ -295,31 +333,36 @@ export class LocalEnv {
|
|
|
295
333
|
return heads;
|
|
296
334
|
}
|
|
297
335
|
|
|
298
|
-
async list(dir) {
|
|
336
|
+
async list(dir, { discovery = false } = {}) {
|
|
299
337
|
let names;
|
|
300
338
|
try {
|
|
301
|
-
names = await (this.options.fileFs ?? fsp).readdir(dir
|
|
339
|
+
names = await (this.options.fileFs ?? fsp).readdir(dir, {
|
|
340
|
+
withFileTypes: true,
|
|
341
|
+
});
|
|
302
342
|
} catch (error) {
|
|
303
343
|
this.recordAccessError(dir, error);
|
|
304
344
|
return [];
|
|
305
345
|
}
|
|
306
|
-
const entries = await
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
346
|
+
const entries = await mapLimit(names, 32, async (entry) => {
|
|
347
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
348
|
+
const full = path.join(dir, name);
|
|
349
|
+
if (discovery && typeof entry !== 'string' && !entry.isDirectory()) {
|
|
350
|
+
return { name, path: full, type: entryType(entry), mtimeMs: 0 };
|
|
351
|
+
}
|
|
352
|
+
const stats = await this.#lstat(full);
|
|
353
|
+
if (!stats) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
name,
|
|
358
|
+
path: full,
|
|
359
|
+
type: entryType(stats),
|
|
360
|
+
size: stats.size,
|
|
361
|
+
bytes: diskBytes(stats),
|
|
362
|
+
mtimeMs: stats.mtimeMs,
|
|
363
|
+
device: stats.dev,
|
|
364
|
+
};
|
|
365
|
+
});
|
|
323
366
|
return entries.filter(Boolean);
|
|
324
367
|
}
|
|
325
368
|
|
|
@@ -328,14 +371,15 @@ export class LocalEnv {
|
|
|
328
371
|
* counted a single time, symlinks not followed. Also reports the newest
|
|
329
372
|
* file mtime.
|
|
330
373
|
*/
|
|
331
|
-
async usage(target) {
|
|
332
|
-
return (await this.usageMany([target])).get(target) ?? null;
|
|
374
|
+
async usage(target, options) {
|
|
375
|
+
return (await this.usageMany([target], options)).get(target) ?? null;
|
|
333
376
|
}
|
|
334
377
|
|
|
335
378
|
async statMany(targets) {
|
|
336
|
-
const entries = await
|
|
337
|
-
|
|
338
|
-
|
|
379
|
+
const entries = await mapLimit(targets, 32, async (target) => [
|
|
380
|
+
target,
|
|
381
|
+
await this.stat(target),
|
|
382
|
+
]);
|
|
339
383
|
return new Map(entries.filter(([, stat]) => stat));
|
|
340
384
|
}
|
|
341
385
|
|
|
@@ -362,6 +406,9 @@ export class LocalEnv {
|
|
|
362
406
|
apparentBytes += stats.size;
|
|
363
407
|
if (!stats.isDirectory()) {
|
|
364
408
|
files++;
|
|
409
|
+
if (this.scanMetrics) {
|
|
410
|
+
this.scanMetrics.filesSized++;
|
|
411
|
+
}
|
|
365
412
|
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
|
|
366
413
|
}
|
|
367
414
|
};
|
|
@@ -375,12 +422,10 @@ export class LocalEnv {
|
|
|
375
422
|
errors++;
|
|
376
423
|
return [];
|
|
377
424
|
}
|
|
378
|
-
const children = await
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
})
|
|
383
|
-
);
|
|
425
|
+
const children = await mapLimit(names, 32, async (name) => {
|
|
426
|
+
const full = path.join(dir, name);
|
|
427
|
+
return [full, await this.#lstat(full)];
|
|
428
|
+
});
|
|
384
429
|
const subdirs = [];
|
|
385
430
|
for (const [full, stats] of children) {
|
|
386
431
|
if (!stats) {
|
|
@@ -453,6 +498,10 @@ export class LocalEnv {
|
|
|
453
498
|
return results;
|
|
454
499
|
}
|
|
455
500
|
|
|
501
|
+
indexedDirs(roots, globs, maxDepth) {
|
|
502
|
+
return indexedDirectories(this, roots, globs, maxDepth);
|
|
503
|
+
}
|
|
504
|
+
|
|
456
505
|
/**
|
|
457
506
|
* Finds regular files whose basename matches one of `names`.
|
|
458
507
|
* @returns {Promise<string[]>}
|
|
@@ -468,7 +517,14 @@ export class LocalEnv {
|
|
|
468
517
|
results.push(entry.path);
|
|
469
518
|
}
|
|
470
519
|
}
|
|
471
|
-
return
|
|
520
|
+
return new Set([
|
|
521
|
+
'node_modules',
|
|
522
|
+
'target',
|
|
523
|
+
'vendor',
|
|
524
|
+
'.venv',
|
|
525
|
+
'venv',
|
|
526
|
+
...this.skipNames,
|
|
527
|
+
]);
|
|
472
528
|
});
|
|
473
529
|
return results;
|
|
474
530
|
}
|
|
@@ -480,55 +536,185 @@ export class LocalEnv {
|
|
|
480
536
|
async #walk(roots, maxDepth, skipNames, visit) {
|
|
481
537
|
const skip = skipNames ? new Set(skipNames) : this.skipNames;
|
|
482
538
|
const visited = new Set();
|
|
483
|
-
|
|
539
|
+
let queue = await Promise.all(
|
|
484
540
|
roots.map(async (root) => [root, 0, await this.deviceId(root)])
|
|
485
541
|
);
|
|
486
542
|
while (queue.length > 0) {
|
|
487
543
|
checkSignal(this.signal);
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
544
|
+
const current = queue;
|
|
545
|
+
queue = [];
|
|
546
|
+
const children = await mapLimit(
|
|
547
|
+
current,
|
|
548
|
+
8,
|
|
549
|
+
async ([dir, depth, device]) => {
|
|
550
|
+
if (visited.has(dir)) {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
visited.add(dir);
|
|
554
|
+
const entries = [];
|
|
555
|
+
for (const entry of await this.discoveryList(dir)) {
|
|
556
|
+
if (
|
|
557
|
+
entry.type !== 'dir' ||
|
|
558
|
+
(entry.device ?? (await this.deviceId(entry.path))) === device
|
|
559
|
+
) {
|
|
560
|
+
entries.push(entry);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const pruned = visit(dir, entries) ?? new Set();
|
|
564
|
+
if (depth + 1 >= maxDepth) {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
const next = [];
|
|
568
|
+
for (const entry of entries) {
|
|
569
|
+
if (
|
|
570
|
+
entry.type === 'dir' &&
|
|
571
|
+
!pruned.has(entry.name) &&
|
|
572
|
+
!this.skipDiscoveryDirectory(dir, entry.name, skip) &&
|
|
573
|
+
(entry.device ?? (await this.deviceId(entry.path))) === device
|
|
574
|
+
) {
|
|
575
|
+
next.push([entry.path, depth + 1, device]);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return next;
|
|
514
579
|
}
|
|
580
|
+
);
|
|
581
|
+
queue = children.flat();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
skipDiscoveryDirectory(dir, name, skip = this.skipNames) {
|
|
586
|
+
if (skip.has(name) || /\.photoslibrary$/i.test(name)) {
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
const homes = this.options.homes ?? [this.currentHome];
|
|
590
|
+
return (
|
|
591
|
+
(['Music', 'Pictures', 'Movies'].includes(name) && homes.includes(dir)) ||
|
|
592
|
+
(name === 'Volumes' && dir === this.path.parse(dir).root)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async discoveryStat(dir) {
|
|
597
|
+
let stats = await this.stat(dir);
|
|
598
|
+
// Explicit roots may be aliases (for example /tmp on macOS). Child links
|
|
599
|
+
// are never queued by the walker, so only an explicitly supplied root follows.
|
|
600
|
+
if (stats?.type === 'symlink') {
|
|
601
|
+
const resolved = await this.realPath(dir);
|
|
602
|
+
stats = resolved ? await this.stat(resolved) : null;
|
|
603
|
+
}
|
|
604
|
+
return stats;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async discoveryList(dir) {
|
|
608
|
+
checkSignal(this.signal);
|
|
609
|
+
const stats = await this.discoveryStat(dir);
|
|
610
|
+
if (!stats || stats.type !== 'dir') {
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
const key = `list:${dir}`;
|
|
614
|
+
const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
|
|
615
|
+
let entries = this.scanIndex?.get(key, fingerprint);
|
|
616
|
+
if (entries) {
|
|
617
|
+
this.scanMetrics.indexHits++;
|
|
618
|
+
} else {
|
|
619
|
+
const errors = this.accessErrors?.size ?? 0;
|
|
620
|
+
entries = await this.list(dir, { discovery: true });
|
|
621
|
+
if ((this.accessErrors?.size ?? 0) === errors) {
|
|
622
|
+
this.scanIndex?.set(key, fingerprint, entries);
|
|
515
623
|
}
|
|
516
624
|
}
|
|
625
|
+
// Stored metadata is a discovery hint; never accept external paths from it.
|
|
626
|
+
const listing = entries
|
|
627
|
+
.filter(
|
|
628
|
+
(entry) => entry.name && this.path.basename(entry.name) === entry.name
|
|
629
|
+
)
|
|
630
|
+
.map((entry) => ({ ...entry, path: this.path.join(dir, entry.name) }))
|
|
631
|
+
.filter(
|
|
632
|
+
(entry) => !this.scanPolicy || this.scanPolicy.allows(entry.path)
|
|
633
|
+
);
|
|
634
|
+
// Mounts and link replacements need not change the parent's mtime.
|
|
635
|
+
return (
|
|
636
|
+
await mapLimit(listing, 32, async (entry) => {
|
|
637
|
+
if (entry.type !== 'dir') {
|
|
638
|
+
return entry;
|
|
639
|
+
}
|
|
640
|
+
const fresh = await this.stat(entry.path);
|
|
641
|
+
return fresh ? { ...entry, ...fresh } : null;
|
|
642
|
+
})
|
|
643
|
+
).filter(Boolean);
|
|
517
644
|
}
|
|
518
645
|
|
|
519
|
-
async usageMany(targets) {
|
|
520
|
-
const usages = await this.rawUsageMany(targets);
|
|
646
|
+
async usageMany(targets, options) {
|
|
647
|
+
const usages = await this.rawUsageMany(targets, options);
|
|
521
648
|
return this.writableLayer
|
|
522
649
|
? this.writableLayer.measure(this, usages)
|
|
523
650
|
: usages;
|
|
524
651
|
}
|
|
525
652
|
|
|
526
|
-
async rawUsageMany(targets) {
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
653
|
+
async rawUsageMany(targets, { mtime = true, cache = false } = {}) {
|
|
654
|
+
const entries = await mapLimit([...new Set(targets)], 4, async (target) => [
|
|
655
|
+
target,
|
|
656
|
+
!this.writableLayer && cache
|
|
657
|
+
? await this.cachedUsage(target, mtime)
|
|
658
|
+
: !mtime && !this.writableLayer
|
|
659
|
+
? await this.quickUsage(target)
|
|
660
|
+
: await this.rawUsage(target),
|
|
661
|
+
]);
|
|
662
|
+
return new Map(entries);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async cachedUsage(target, mtime) {
|
|
666
|
+
const stats = await this.stat(target);
|
|
667
|
+
if (!stats) {
|
|
668
|
+
return null;
|
|
530
669
|
}
|
|
531
|
-
|
|
670
|
+
const key = `usage:${mtime}:${target}`;
|
|
671
|
+
const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
|
|
672
|
+
const cached = this.scanIndex?.get(key, fingerprint, SIZE_MAX_AGE);
|
|
673
|
+
if (cached) {
|
|
674
|
+
this.scanMetrics.sizeCacheHits++;
|
|
675
|
+
return { ...cached, estimated: true };
|
|
676
|
+
}
|
|
677
|
+
const usage = mtime
|
|
678
|
+
? await this.rawUsage(target)
|
|
679
|
+
: await this.quickUsage(target);
|
|
680
|
+
if (usage && !usage.errors) {
|
|
681
|
+
this.scanIndex?.set(key, fingerprint, usage);
|
|
682
|
+
}
|
|
683
|
+
return usage;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async quickUsage(target) {
|
|
687
|
+
const stats = await this.stat(target);
|
|
688
|
+
if (!stats) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
// du cannot enforce path-glob exclusions or detect a nested mount beneath a candidate.
|
|
692
|
+
if (
|
|
693
|
+
this.platform === 'win32' ||
|
|
694
|
+
this.scanPolicy?.exclude.length ||
|
|
695
|
+
this.scanPolicy?.mounts.some(
|
|
696
|
+
(mount) =>
|
|
697
|
+
mount.path !== '/' && mount.path.startsWith(`${target}${path.sep}`)
|
|
698
|
+
)
|
|
699
|
+
) {
|
|
700
|
+
return this.rawUsage(target);
|
|
701
|
+
}
|
|
702
|
+
const result = await this.run(['du', '-skx', target]);
|
|
703
|
+
const match = /^\s*(\d+)\s/.exec(result.stdout);
|
|
704
|
+
if (result.code !== 0 || !match) {
|
|
705
|
+
return this.rawUsage(target);
|
|
706
|
+
}
|
|
707
|
+
const usage = {
|
|
708
|
+
bytes: Number(match[1]) * 1024,
|
|
709
|
+
apparentBytes: Number(match[1]) * 1024,
|
|
710
|
+
newestMtimeMs: 0,
|
|
711
|
+
files: 0,
|
|
712
|
+
errors: 0,
|
|
713
|
+
};
|
|
714
|
+
if (this.scanMetrics) {
|
|
715
|
+
this.scanMetrics.directoriesSized++;
|
|
716
|
+
}
|
|
717
|
+
return usage;
|
|
532
718
|
}
|
|
533
719
|
|
|
534
720
|
async readLink(target) {
|
|
@@ -608,6 +794,15 @@ export class LocalEnv {
|
|
|
608
794
|
).stdout
|
|
609
795
|
)
|
|
610
796
|
: new Map();
|
|
797
|
+
// A process may start programs from its PATH at any time without holding
|
|
798
|
+
// them open (for example a release directory of an updated tool).
|
|
799
|
+
const searchPaths =
|
|
800
|
+
this.platform === 'darwin'
|
|
801
|
+
? parsePsSearchPaths(
|
|
802
|
+
(await this.run(['ps', '-E', '-ww', '-axo', 'pid=,command=']))
|
|
803
|
+
.stdout
|
|
804
|
+
)
|
|
805
|
+
: new Map();
|
|
611
806
|
return result.stdout
|
|
612
807
|
.split('\n')
|
|
613
808
|
.map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
|
|
@@ -617,6 +812,7 @@ export class LocalEnv {
|
|
|
617
812
|
name: path.basename(m[2].trim()),
|
|
618
813
|
cwd: cwds.get(Number(m[1])) ?? null,
|
|
619
814
|
command: commands.get(Number(m[1])) ?? '',
|
|
815
|
+
searchPath: searchPaths.get(Number(m[1])) ?? [],
|
|
620
816
|
}))
|
|
621
817
|
.filter((proc) => !selfPids.has(proc.pid));
|
|
622
818
|
}
|
|
@@ -650,6 +846,9 @@ export class LocalEnv {
|
|
|
650
846
|
const cmdline = await io
|
|
651
847
|
.readFile(`${base}/cmdline`, 'utf8')
|
|
652
848
|
.catch(() => '');
|
|
849
|
+
const environ = await io
|
|
850
|
+
.readFile(`${base}/environ`, 'utf8')
|
|
851
|
+
.catch(() => '');
|
|
653
852
|
processes.push({
|
|
654
853
|
pid: Number(pid),
|
|
655
854
|
...(await procIdentity(base, io)),
|
|
@@ -660,6 +859,12 @@ export class LocalEnv {
|
|
|
660
859
|
aliases: processAliases(exe, cmdline.split('\0')[0]),
|
|
661
860
|
command: cmdline.replaceAll('\0', ' '),
|
|
662
861
|
argv: cmdline.split('\0').filter(Boolean),
|
|
862
|
+
searchPath: searchPathDirs(
|
|
863
|
+
environ
|
|
864
|
+
.split('\0')
|
|
865
|
+
.find((entry) => entry.startsWith('PATH='))
|
|
866
|
+
?.slice('PATH='.length)
|
|
867
|
+
),
|
|
663
868
|
});
|
|
664
869
|
}
|
|
665
870
|
return processes;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Finite worker pool; settle all in-flight work before propagating failure. */
|
|
2
|
+
export async function mapLimit(values, limit, operation) {
|
|
3
|
+
const results = new Array(values.length);
|
|
4
|
+
let cursor = 0;
|
|
5
|
+
let failure;
|
|
6
|
+
const worker = async () => {
|
|
7
|
+
while (!failure && cursor < values.length) {
|
|
8
|
+
const index = cursor++;
|
|
9
|
+
try {
|
|
10
|
+
results[index] = await operation(values[index], index);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
failure ??= error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
await Promise.all(
|
|
17
|
+
Array.from({ length: Math.min(limit, values.length) }, worker)
|
|
18
|
+
);
|
|
19
|
+
if (failure) {
|
|
20
|
+
throw failure;
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** OS index hits are hints; stale/missing indexes always fall back to discovery. */
|
|
2
|
+
import { isWithin, matchesGlob } from '../paths.js';
|
|
3
|
+
import { mapLimit } from './parallel.js';
|
|
4
|
+
|
|
5
|
+
export async function indexedDirectories(env, roots, globs, maxDepth) {
|
|
6
|
+
if (!env.scanIndex?.directory || env.platformIndex === false) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
let hits = [];
|
|
10
|
+
try {
|
|
11
|
+
if (env.platform === 'darwin' && (await env.which('mdfind'))) {
|
|
12
|
+
const query = globs
|
|
13
|
+
.map((glob) => `kMDItemFSName == "${glob.replaceAll('"', '\\"')}"`)
|
|
14
|
+
.join(' || ');
|
|
15
|
+
for (const root of roots) {
|
|
16
|
+
const result = await env.run(['mdfind', '-0', '-onlyin', root, query]);
|
|
17
|
+
if (result.code === 0) {
|
|
18
|
+
hits.push(...result.stdout.split('\0').filter(Boolean));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} else if (env.platform === 'linux') {
|
|
22
|
+
const command = (await env.which('plocate'))
|
|
23
|
+
? 'plocate'
|
|
24
|
+
: (await env.which('locate'))
|
|
25
|
+
? 'locate'
|
|
26
|
+
: null;
|
|
27
|
+
if (command) {
|
|
28
|
+
const result = await env.run([command, '-0', '-e', '-b', ...globs]);
|
|
29
|
+
if (result.code === 0) {
|
|
30
|
+
hits = result.stdout.split('\0').filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Budget/cancellation will be observed by the fallback walk too.
|
|
36
|
+
}
|
|
37
|
+
hits = [...new Set(hits)]
|
|
38
|
+
.filter((target) => {
|
|
39
|
+
const root = roots.find((candidate) =>
|
|
40
|
+
isWithin(target, candidate, env.path)
|
|
41
|
+
);
|
|
42
|
+
if (
|
|
43
|
+
!root ||
|
|
44
|
+
!env.path.isAbsolute(target) ||
|
|
45
|
+
!globs.some((glob) => matchesGlob(env.path.basename(target), glob))
|
|
46
|
+
) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const parts = env.path.relative(root, target).split(/[\\/]/);
|
|
50
|
+
return (
|
|
51
|
+
parts.length <= maxDepth &&
|
|
52
|
+
!parts
|
|
53
|
+
.slice(0, -1)
|
|
54
|
+
.some(
|
|
55
|
+
(name, index) =>
|
|
56
|
+
env.skipDiscoveryDirectory(
|
|
57
|
+
env.path.join(root, ...parts.slice(0, index)),
|
|
58
|
+
name
|
|
59
|
+
) || globs.some((glob) => matchesGlob(name, glob))
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
})
|
|
63
|
+
.slice(0, 20000);
|
|
64
|
+
const candidates = await mapLimit(hits, 8, async (target) => {
|
|
65
|
+
const stat = await env.stat(target);
|
|
66
|
+
const root = roots.find((candidate) =>
|
|
67
|
+
isWithin(target, candidate, env.path)
|
|
68
|
+
);
|
|
69
|
+
if (stat?.type !== 'dir' || stat.device !== (await env.deviceId(root))) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
let parent = env.path.dirname(target);
|
|
73
|
+
while (parent !== root && isWithin(parent, root, env.path)) {
|
|
74
|
+
const ancestor = await env.stat(parent);
|
|
75
|
+
if (ancestor?.type !== 'dir' || ancestor.device !== stat.device) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
parent = env.path.dirname(parent);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
path: target,
|
|
82
|
+
name: env.path.basename(target),
|
|
83
|
+
parent: env.path.dirname(target),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
return candidates.filter(Boolean);
|
|
87
|
+
}
|
package/src/env/shell.js
CHANGED
|
@@ -405,7 +405,16 @@ export class ShellEnv {
|
|
|
405
405
|
}
|
|
406
406
|
|
|
407
407
|
findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
|
|
408
|
-
return this.#find(roots, 'f', names, maxDepth,
|
|
408
|
+
return this.#find(roots, 'f', names, maxDepth, [
|
|
409
|
+
...new Set([
|
|
410
|
+
...(skipNames ?? this.skipNames),
|
|
411
|
+
'node_modules',
|
|
412
|
+
'target',
|
|
413
|
+
'vendor',
|
|
414
|
+
'.venv',
|
|
415
|
+
'venv',
|
|
416
|
+
]),
|
|
417
|
+
]);
|
|
409
418
|
}
|
|
410
419
|
|
|
411
420
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Clean completed safe host findings while discovery continues. */
|
|
2
|
+
import { clean } from './clean.js';
|
|
3
|
+
import { scan } from './scan.js';
|
|
4
|
+
|
|
5
|
+
export async function cleanDuringScan(options) {
|
|
6
|
+
const entries = [];
|
|
7
|
+
const health = [];
|
|
8
|
+
let failure;
|
|
9
|
+
const report = await scan({
|
|
10
|
+
...options,
|
|
11
|
+
onScanPhase: async (phase) => {
|
|
12
|
+
try {
|
|
13
|
+
const audit = await clean(phase, {
|
|
14
|
+
...options,
|
|
15
|
+
tier: 'safe',
|
|
16
|
+
priorEntries: entries,
|
|
17
|
+
priorHealth: health,
|
|
18
|
+
});
|
|
19
|
+
entries.splice(0, entries.length, ...audit.entries);
|
|
20
|
+
health.splice(0, health.length, ...audit.health);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
failure = error;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
if (failure) {
|
|
28
|
+
throw failure;
|
|
29
|
+
}
|
|
30
|
+
if (report.aborted) {
|
|
31
|
+
return { report, audit: null };
|
|
32
|
+
}
|
|
33
|
+
const audit = await clean(report, {
|
|
34
|
+
...options,
|
|
35
|
+
priorEntries: entries,
|
|
36
|
+
priorHealth: health,
|
|
37
|
+
});
|
|
38
|
+
return { report, audit };
|
|
39
|
+
}
|