disk-space-saviour 0.0.0-stage → 0.12.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 +587 -0
- package/LICENSE +24 -0
- package/README.md +538 -2
- package/bin/dss.js +5 -0
- package/package.json +96 -4
- package/src/audit.js +84 -0
- package/src/clean.js +716 -0
- package/src/cli.js +448 -0
- package/src/docker/cli.js +289 -0
- package/src/docker/containers.js +334 -0
- package/src/docker/images.js +122 -0
- package/src/docker/scan.js +531 -0
- package/src/emergency.js +169 -0
- package/src/env/local.js +715 -0
- package/src/env/resolve.js +36 -0
- package/src/env/shell.js +433 -0
- package/src/exec.js +185 -0
- package/src/git.js +338 -0
- package/src/health.js +174 -0
- package/src/index.d.ts +255 -0
- package/src/index.js +52 -0
- package/src/items.js +166 -0
- package/src/liveness.js +190 -0
- package/src/options.js +112 -0
- package/src/paths.js +182 -0
- package/src/report.js +376 -0
- package/src/rules/ecosystems.js +916 -0
- package/src/rules/other.js +375 -0
- package/src/rules/versions.js +256 -0
- package/src/scan.js +384 -0
- package/src/scanners/agents.js +166 -0
- package/src/scanners/common.js +38 -0
- package/src/scanners/global.js +217 -0
- package/src/scanners/projects.js +335 -0
- package/src/scanners/rust.js +404 -0
- package/src/scanners/system.js +171 -0
- package/src/scanners/versions.js +230 -0
- package/src/units.js +130 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global per-user caches (ecosystem download and compilation caches, IDE and
|
|
3
|
+
* browser caches), system package caches, and leftovers in temporary
|
|
4
|
+
* directories. See ../rules/ecosystems.js and ../rules/other.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CACHE_RULES } from '../rules/ecosystems.js';
|
|
8
|
+
import { OTHER_RULES } from '../rules/other.js';
|
|
9
|
+
import { compareVersions } from '../rules/versions.js';
|
|
10
|
+
import { block, makeItem } from '../items.js';
|
|
11
|
+
import { expandGlobPaths, expandRulePath } from '../paths.js';
|
|
12
|
+
import { olderThan, scanTimeBusy } from './common.js';
|
|
13
|
+
|
|
14
|
+
export const GLOBAL_RULES = [...CACHE_RULES, ...OTHER_RULES];
|
|
15
|
+
|
|
16
|
+
function scopeBases(rule, homes, tmpDirs) {
|
|
17
|
+
const scope = rule.scope ?? 'home';
|
|
18
|
+
if (scope === 'tmp') {
|
|
19
|
+
return tmpDirs.map((tmp) => ({ home: null, tmp }));
|
|
20
|
+
}
|
|
21
|
+
if (scope === 'system') {
|
|
22
|
+
return [{ home: null, tmp: null }];
|
|
23
|
+
}
|
|
24
|
+
return homes.map((home) => ({ home, tmp: null }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rulePatterns(env, rule, base) {
|
|
28
|
+
const patterns = [];
|
|
29
|
+
for (const pattern of rule.paths) {
|
|
30
|
+
const usesVars = /\{(?!TMP\})[A-Z_]+\}/.test(pattern);
|
|
31
|
+
if (usesVars && base.home !== env.currentHome) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const expanded = expandRulePath(pattern, {
|
|
35
|
+
home: base.home ?? '/',
|
|
36
|
+
vars: { ...env.vars, TMP: base.tmp ?? undefined },
|
|
37
|
+
pathApi: env.path,
|
|
38
|
+
});
|
|
39
|
+
if (expanded) {
|
|
40
|
+
patterns.push(expanded);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return patterns;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The tool's own clean command, when it can run for this cache.
|
|
48
|
+
* @returns {Promise<{action: object|null, blocker: string|null}>}
|
|
49
|
+
*/
|
|
50
|
+
async function nativeAction(context, rule, base, paths) {
|
|
51
|
+
const { env, options } = context;
|
|
52
|
+
const native = rule.native;
|
|
53
|
+
const removal = { type: 'remove', paths };
|
|
54
|
+
if (!native || options.noNative) {
|
|
55
|
+
return { action: removal, blocker: null };
|
|
56
|
+
}
|
|
57
|
+
const usable =
|
|
58
|
+
(rule.scope === 'system' || base.home === env.currentHome) &&
|
|
59
|
+
(await env.which(native.tool)) &&
|
|
60
|
+
(!native.root || (await env.isRoot()));
|
|
61
|
+
if (usable) {
|
|
62
|
+
return {
|
|
63
|
+
action: {
|
|
64
|
+
type: 'command',
|
|
65
|
+
argv: native.argv,
|
|
66
|
+
measure: paths,
|
|
67
|
+
},
|
|
68
|
+
blocker: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (native.required) {
|
|
72
|
+
return {
|
|
73
|
+
action: null,
|
|
74
|
+
blocker: `only \`${native.argv.join(' ')}\` can clean this safely, and it is unavailable`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { action: removal, blocker: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ageFilter(context, rule) {
|
|
81
|
+
const { options } = context;
|
|
82
|
+
if (rule.minAge === 'stale') {
|
|
83
|
+
return (usage) =>
|
|
84
|
+
olderThan(context, usage.newestMtimeMs, options.staleAgeMs);
|
|
85
|
+
}
|
|
86
|
+
if (rule.minAge === 'inactive') {
|
|
87
|
+
return (usage) =>
|
|
88
|
+
olderThan(context, usage.newestMtimeMs, options.inactiveMs);
|
|
89
|
+
}
|
|
90
|
+
return () => true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Keep the latest installed revision of each browser and platform family. */
|
|
94
|
+
async function olderVersions(env, rule, paths) {
|
|
95
|
+
if (!rule.versionPattern) {
|
|
96
|
+
return paths;
|
|
97
|
+
}
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
for (const target of paths) {
|
|
100
|
+
if ((await env.stat(target))?.type !== 'dir') {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const match = rule.versionPattern.exec(env.path.basename(target));
|
|
104
|
+
if (!match) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const family = `${env.path.dirname(target)}\0${match.groups.family ?? ''}`;
|
|
108
|
+
groups.set(family, [
|
|
109
|
+
...(groups.get(family) ?? []),
|
|
110
|
+
{ target, version: match.groups.version },
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
const old = new Set();
|
|
114
|
+
for (const entries of groups.values()) {
|
|
115
|
+
entries.sort((a, b) => compareVersions(b.version, a.version));
|
|
116
|
+
for (const entry of entries.slice(1)) {
|
|
117
|
+
old.add(entry.target);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return paths.filter((target) => old.has(target));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function buildItem(context, rule, base, group) {
|
|
124
|
+
const { env } = context;
|
|
125
|
+
const paths = group.map((entry) => entry.path);
|
|
126
|
+
const { action, blocker } = await nativeAction(context, rule, base, paths);
|
|
127
|
+
const scope = rule.scope ?? 'home';
|
|
128
|
+
const item = makeItem(env, {
|
|
129
|
+
rule: rule.id,
|
|
130
|
+
kind: scope === 'tmp' ? 'leftover' : 'cache',
|
|
131
|
+
ecosystem: rule.ecosystem,
|
|
132
|
+
description: rule.description,
|
|
133
|
+
path: paths[0],
|
|
134
|
+
paths,
|
|
135
|
+
bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
|
|
136
|
+
newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
|
|
137
|
+
tier: rule.tier ?? 'safe',
|
|
138
|
+
reason: rule.minAge
|
|
139
|
+
? `untouched for longer than the ${rule.minAge === 'stale' ? 'activity' : 'inactivity'} window`
|
|
140
|
+
: 're-downloaded or re-created on demand',
|
|
141
|
+
action: action ?? { type: 'none' },
|
|
142
|
+
checks: {
|
|
143
|
+
busy: rule.busy ?? [],
|
|
144
|
+
cwd: scope === 'tmp' ? paths[0] : null,
|
|
145
|
+
mtime: scope === 'tmp' || Boolean(rule.minAge),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
block(item, blocker);
|
|
149
|
+
await addBlockers(context, rule, item);
|
|
150
|
+
return item;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function addBlockers(context, rule, item) {
|
|
154
|
+
const { env, git } = context;
|
|
155
|
+
if (rule.scope === 'system' && !(await env.isRoot())) {
|
|
156
|
+
block(item, 'system location, run as root to clean it');
|
|
157
|
+
}
|
|
158
|
+
if (rule.git) {
|
|
159
|
+
for (const target of item.paths) {
|
|
160
|
+
for (const reason of await git.treeBlockers(target)) {
|
|
161
|
+
block(item, reason);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const busy = scanTimeBusy(context, item);
|
|
166
|
+
if (busy) {
|
|
167
|
+
block(item, `busy: ${busy}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Scans global cache and leftover locations.
|
|
173
|
+
* @returns {Promise<object[]>} report items
|
|
174
|
+
*/
|
|
175
|
+
export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
176
|
+
const { env, options } = context;
|
|
177
|
+
const homes = options.homes ?? (await env.homeDirs());
|
|
178
|
+
const tmpDirs = options.tmpDirs ?? (await env.tmpDirs());
|
|
179
|
+
const planned = rules.flatMap((rule) =>
|
|
180
|
+
scopeBases(rule, homes, tmpDirs).map((base) => ({
|
|
181
|
+
rule,
|
|
182
|
+
base,
|
|
183
|
+
patterns: rulePatterns(env, rule, base),
|
|
184
|
+
}))
|
|
185
|
+
);
|
|
186
|
+
const matches = await expandGlobPaths(
|
|
187
|
+
env,
|
|
188
|
+
planned.flatMap((entry) => entry.patterns)
|
|
189
|
+
);
|
|
190
|
+
const found = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
for (const { rule, base, patterns } of planned) {
|
|
193
|
+
const candidates = [
|
|
194
|
+
...new Set(patterns.flatMap((pattern) => matches.get(pattern) ?? [])),
|
|
195
|
+
].filter((target) => !seen.has(target));
|
|
196
|
+
candidates.forEach((target) => seen.add(target));
|
|
197
|
+
const paths = await olderVersions(env, rule, candidates);
|
|
198
|
+
if (paths.length > 0) {
|
|
199
|
+
found.push({ rule, base, paths });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const usages = await env.usageMany(found.flatMap((entry) => entry.paths));
|
|
203
|
+
const items = [];
|
|
204
|
+
for (const { rule, base, paths } of found) {
|
|
205
|
+
const accept = ageFilter(context, rule);
|
|
206
|
+
const measured = paths
|
|
207
|
+
.map((target) => ({ path: target, usage: usages.get(target) }))
|
|
208
|
+
.filter((entry) => entry.usage && accept(entry.usage));
|
|
209
|
+
const groups = rule.aggregate
|
|
210
|
+
? [measured]
|
|
211
|
+
: measured.map((entry) => [entry]);
|
|
212
|
+
for (const group of groups.filter((g) => g.length > 0)) {
|
|
213
|
+
items.push(await buildItem(context, rule, base, group));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return items;
|
|
217
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project dependency, build and cache directories (node_modules, target,
|
|
3
|
+
* .venv, ...) found by walking the scan roots.
|
|
4
|
+
*
|
|
5
|
+
* A directory matches a rule when its name matches and the rule's markers
|
|
6
|
+
* are present. Generic names (`build`, `lib`, `target`, `bin`) that fail
|
|
7
|
+
* every rule are searched again below, with the remaining depth, so a
|
|
8
|
+
* non-matching `build` directory never hides real projects inside it.
|
|
9
|
+
*
|
|
10
|
+
* Cargo `target` directories additionally yield `cargo-superseded` items:
|
|
11
|
+
* older build hashes that a newer build of the same unit replaced (see
|
|
12
|
+
* `rust.js`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ECOSYSTEMS, PROJECT_RULES } from '../rules/ecosystems.js';
|
|
16
|
+
import { block, makeItem } from '../items.js';
|
|
17
|
+
import { isWithin, matchesGlob } from '../paths.js';
|
|
18
|
+
import { listMany, olderThan, scanTimeBusy } from './common.js';
|
|
19
|
+
import { analyzeRustProfile, findRustProfiles } from './rust.js';
|
|
20
|
+
|
|
21
|
+
const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
|
|
22
|
+
|
|
23
|
+
function anyMatch(entries, globs) {
|
|
24
|
+
return (entries ?? []).some((entry) =>
|
|
25
|
+
globs.some((glob) => matchesGlob(entry.name, glob))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function depthBelow(env, roots, target) {
|
|
30
|
+
const root = roots
|
|
31
|
+
.filter((candidate) => isWithin(target, candidate, env.path))
|
|
32
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
33
|
+
return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
|
|
37
|
+
if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (rule.selfMarkers && !anyMatch(selfListing, rule.selfMarkers)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (rule.parentName) {
|
|
44
|
+
if (env.path.basename(candidate.parent) !== rule.parentName) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (rule.parentMarkers && !anyMatch(grandListing, rule.parentMarkers)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
rule.lockfiles &&
|
|
53
|
+
!anyMatch(
|
|
54
|
+
rule.parentName ? grandListing : candidate.siblings,
|
|
55
|
+
rule.lockfiles
|
|
56
|
+
)
|
|
57
|
+
) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pairs every found directory with the first rule it satisfies (or null).
|
|
65
|
+
*/
|
|
66
|
+
async function matchRules(env, found, rules) {
|
|
67
|
+
const options = found.map((candidate) => ({
|
|
68
|
+
candidate,
|
|
69
|
+
rules: rules.filter((rule) =>
|
|
70
|
+
rule.names.some((glob) => matchesGlob(candidate.name, glob))
|
|
71
|
+
),
|
|
72
|
+
}));
|
|
73
|
+
const selfDirs = options
|
|
74
|
+
.filter((o) => o.rules.some((rule) => rule.selfMarkers))
|
|
75
|
+
.map((o) => o.candidate.path);
|
|
76
|
+
const grandDirs = options
|
|
77
|
+
.filter((o) =>
|
|
78
|
+
o.rules.some(
|
|
79
|
+
(rule) => rule.parentName === env.path.basename(o.candidate.parent)
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
.map((o) => env.path.dirname(o.candidate.parent));
|
|
83
|
+
const selfListings = await listMany(env, selfDirs);
|
|
84
|
+
const grandListings = await listMany(env, [...new Set(grandDirs)]);
|
|
85
|
+
return options.map(({ candidate, rules: possible }) => ({
|
|
86
|
+
candidate,
|
|
87
|
+
rule:
|
|
88
|
+
possible.find((rule) =>
|
|
89
|
+
ruleAccepts(
|
|
90
|
+
env,
|
|
91
|
+
rule,
|
|
92
|
+
candidate,
|
|
93
|
+
selfListings.get(candidate.path),
|
|
94
|
+
grandListings.get(env.path.dirname(candidate.parent))
|
|
95
|
+
)
|
|
96
|
+
) ?? null,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function rootCandidates(env, roots) {
|
|
101
|
+
const candidates = [];
|
|
102
|
+
for (const root of roots) {
|
|
103
|
+
const parent = env.path.dirname(root);
|
|
104
|
+
if (parent === root) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const siblings = await env.list(parent);
|
|
108
|
+
candidates.push({
|
|
109
|
+
path: root,
|
|
110
|
+
name: env.path.basename(root),
|
|
111
|
+
parent,
|
|
112
|
+
siblings,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return candidates;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Finds every rule match below the roots.
|
|
120
|
+
* @returns {Promise<Array<{candidate: object, rule: object}>>}
|
|
121
|
+
*/
|
|
122
|
+
export async function findProjectDirs(env, roots, options = {}) {
|
|
123
|
+
const rules = options.rules ?? PROJECT_RULES;
|
|
124
|
+
const maxDepth = options.maxDepth ?? 6;
|
|
125
|
+
const globs = [...new Set(rules.flatMap((rule) => rule.names))];
|
|
126
|
+
const matches = [];
|
|
127
|
+
if (options.includeRoots) {
|
|
128
|
+
const self = await matchRules(env, await rootCandidates(env, roots), rules);
|
|
129
|
+
matches.push(...self.filter((match) => match.rule));
|
|
130
|
+
}
|
|
131
|
+
let frontier = new Map([[maxDepth, roots]]);
|
|
132
|
+
while (frontier.size > 0) {
|
|
133
|
+
const next = new Map();
|
|
134
|
+
for (const [depth, dirs] of frontier) {
|
|
135
|
+
const found = await env.findDirs(dirs, { globs, maxDepth: depth });
|
|
136
|
+
for (const match of await matchRules(env, found, rules)) {
|
|
137
|
+
if (match.rule) {
|
|
138
|
+
matches.push(match);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const remaining = depth - depthBelow(env, dirs, match.candidate.path);
|
|
142
|
+
if (remaining > 0) {
|
|
143
|
+
next.set(remaining, [
|
|
144
|
+
...(next.get(remaining) ?? []),
|
|
145
|
+
match.candidate.path,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
frontier = next;
|
|
151
|
+
}
|
|
152
|
+
return matches;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function siblingActivity(candidate, globs) {
|
|
156
|
+
return (candidate.siblings ?? [])
|
|
157
|
+
.filter(
|
|
158
|
+
(entry) =>
|
|
159
|
+
entry.name !== candidate.name &&
|
|
160
|
+
!globs.some((glob) => matchesGlob(entry.name, glob))
|
|
161
|
+
)
|
|
162
|
+
.reduce((newest, entry) => Math.max(newest, entry.mtimeMs ?? 0), 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function projectTier(context, rule, lastActivityMs) {
|
|
166
|
+
if (rule.kind === 'cache') {
|
|
167
|
+
return { tier: 'safe', reason: 'cache, re-created on demand' };
|
|
168
|
+
}
|
|
169
|
+
const { inactiveMs } = context.options;
|
|
170
|
+
if (olderThan(context, lastActivityMs, inactiveMs)) {
|
|
171
|
+
return {
|
|
172
|
+
tier: 'moderate',
|
|
173
|
+
reason: 'project inactive, re-created by the build or install step',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
tier: 'aggressive',
|
|
178
|
+
reason: 'project recently active, re-created by the build or install step',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function addBlockers(context, item, requireClean) {
|
|
183
|
+
for (const reason of await context.git.pathBlockers(item.path, {
|
|
184
|
+
requireClean,
|
|
185
|
+
})) {
|
|
186
|
+
block(item, reason);
|
|
187
|
+
}
|
|
188
|
+
const busy = scanTimeBusy(context, item);
|
|
189
|
+
if (busy) {
|
|
190
|
+
block(item, `busy: ${busy}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function projectActivity(context, project, candidate, usage, globs) {
|
|
195
|
+
const { env, git } = context;
|
|
196
|
+
const repo = await git.findRepoRoot(project);
|
|
197
|
+
const lastCommitMs = repo ? (await git.repoState(repo)).lastCommitMs : 0;
|
|
198
|
+
context.projectUsage ??= new Map();
|
|
199
|
+
if (!context.projectUsage.has(project)) {
|
|
200
|
+
context.projectUsage.set(project, await env.usage(project));
|
|
201
|
+
}
|
|
202
|
+
const projectUsage = context.projectUsage.get(project);
|
|
203
|
+
return Math.max(
|
|
204
|
+
usage?.newestMtimeMs ?? 0,
|
|
205
|
+
projectUsage?.newestMtimeMs ?? 0,
|
|
206
|
+
siblingActivity(candidate, globs),
|
|
207
|
+
lastCommitMs
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function projectItem(context, { candidate, rule }, usage, globs) {
|
|
212
|
+
const { env, options } = context;
|
|
213
|
+
const project = rule.parentName
|
|
214
|
+
? env.path.dirname(candidate.parent)
|
|
215
|
+
: candidate.parent;
|
|
216
|
+
const lastActivityMs = await projectActivity(
|
|
217
|
+
context,
|
|
218
|
+
project,
|
|
219
|
+
candidate,
|
|
220
|
+
usage,
|
|
221
|
+
globs
|
|
222
|
+
);
|
|
223
|
+
const item = makeItem(env, {
|
|
224
|
+
rule: rule.id,
|
|
225
|
+
kind: rule.kind,
|
|
226
|
+
ecosystem: rule.ecosystem,
|
|
227
|
+
description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
|
|
228
|
+
path: candidate.path,
|
|
229
|
+
bytes: usage?.bytes ?? 0,
|
|
230
|
+
newestMtimeMs: usage?.newestMtimeMs ?? 0,
|
|
231
|
+
lastActivityMs,
|
|
232
|
+
project,
|
|
233
|
+
lockfiles: rule.lockfiles ?? null,
|
|
234
|
+
...projectTier(context, rule, lastActivityMs),
|
|
235
|
+
checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
|
|
236
|
+
});
|
|
237
|
+
await addBlockers(
|
|
238
|
+
context,
|
|
239
|
+
item,
|
|
240
|
+
item.tier !== 'safe' && !options.allowDirtyRepos
|
|
241
|
+
);
|
|
242
|
+
return item;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const RUST_PARTS = {
|
|
246
|
+
library: {
|
|
247
|
+
rule: 'cargo-superseded',
|
|
248
|
+
description: (part) =>
|
|
249
|
+
`superseded cargo artifacts (${part.removed} old units, ${part.kept} kept)`,
|
|
250
|
+
reason: 'older build hashes replaced by a newer build of the same unit',
|
|
251
|
+
},
|
|
252
|
+
leaf: {
|
|
253
|
+
rule: 'cargo-superseded-leaf',
|
|
254
|
+
description: (part) =>
|
|
255
|
+
`idle cargo incremental caches and superseded test/example binaries (${part.removed} old, ${part.kept} kept)`,
|
|
256
|
+
reason:
|
|
257
|
+
'nothing links against them; removable while cargo runs, files in use are skipped',
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One item per part and profile. The library part is busy while cargo or
|
|
263
|
+
* rustc runs in the project. The leaf part ignores running tools and drops
|
|
264
|
+
* only the paths that a process holds open (`checks.perPath`).
|
|
265
|
+
*/
|
|
266
|
+
async function rustItems(context, targetItem, rule) {
|
|
267
|
+
const { env, liveness, options, now } = context;
|
|
268
|
+
const items = [];
|
|
269
|
+
for (const profile of await findRustProfiles(env, targetItem.path)) {
|
|
270
|
+
const result = await analyzeRustProfile(env, profile, {
|
|
271
|
+
staleAgeMs: options.staleAgeMs,
|
|
272
|
+
now,
|
|
273
|
+
});
|
|
274
|
+
for (const [name, spec] of Object.entries(RUST_PARTS)) {
|
|
275
|
+
const part = result[name];
|
|
276
|
+
const leaf = name === 'leaf';
|
|
277
|
+
const open = leaf
|
|
278
|
+
? (liveness?.openPathsOf(part.entries.map((e) => e.path)) ?? new Map())
|
|
279
|
+
: new Map();
|
|
280
|
+
const entries = part.entries.filter((entry) => !open.has(entry.path));
|
|
281
|
+
if (entries.length === 0) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const item = makeItem(env, {
|
|
285
|
+
rule: spec.rule,
|
|
286
|
+
kind: 'build',
|
|
287
|
+
ecosystem: 'rust',
|
|
288
|
+
description: spec.description(part),
|
|
289
|
+
path: profile,
|
|
290
|
+
paths: entries.map((entry) => entry.path),
|
|
291
|
+
bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
292
|
+
newestMtimeMs: Math.max(...entries.map((entry) => entry.mtimeMs)),
|
|
293
|
+
project: targetItem.project,
|
|
294
|
+
tier: 'safe',
|
|
295
|
+
reason: spec.reason,
|
|
296
|
+
parentId: targetItem.id,
|
|
297
|
+
recheck: { type: 'rust', profile, part: name },
|
|
298
|
+
checks: leaf
|
|
299
|
+
? { busy: [], cwd: targetItem.project, mtime: true, perPath: true }
|
|
300
|
+
: { busy: rule.busy, cwd: targetItem.project, mtime: true },
|
|
301
|
+
});
|
|
302
|
+
await addBlockers(context, item, false);
|
|
303
|
+
items.push(item);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return items;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Scans project trees below `context.options.roots`.
|
|
311
|
+
* @returns {Promise<object[]>} report items
|
|
312
|
+
*/
|
|
313
|
+
export async function scanProjects(context, { rules = PROJECT_RULES } = {}) {
|
|
314
|
+
const { env, options } = context;
|
|
315
|
+
const matches = await findProjectDirs(env, options.roots, {
|
|
316
|
+
rules,
|
|
317
|
+
maxDepth: options.maxDepth,
|
|
318
|
+
includeRoots: options.includeRoots,
|
|
319
|
+
});
|
|
320
|
+
const usages = await env.usageMany(matches.map((m) => m.candidate.path));
|
|
321
|
+
const globs = [...new Set(rules.flatMap((rule) => rule.names))];
|
|
322
|
+
const items = [];
|
|
323
|
+
for (const match of matches) {
|
|
324
|
+
const usage = usages.get(match.candidate.path);
|
|
325
|
+
if (!usage) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const item = await projectItem(context, match, usage, globs);
|
|
329
|
+
items.push(item);
|
|
330
|
+
if (match.rule.id === 'cargo-target') {
|
|
331
|
+
items.push(...(await rustItems(context, item, match.rule)));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return items;
|
|
335
|
+
}
|