@ue-shed/project-custodian 0.2.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,903 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, lstat, readdir, readFile, realpath, stat, statfs } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { Schema } from "effect";
5
+ import { defaultCustodianPolicy, projectTargetDefinitions, resolvePolicyDocument } from "./policy.js";
6
+ import { CustodianTargetId } from "./schema.js";
7
+ const gibibyte = 1024 ** 3;
8
+ const maximumDiscoveryDepth = 8;
9
+ const scanConcurrency = 4;
10
+ const policyFilename = ".ueclean.json";
11
+ const logStamp = /-backup-(\d{4})\.(\d{2})\.(\d{2})-(\d{2})\.(\d{2})\.(\d{2})\.log$/iu;
12
+ const excludedProjectComponents = new Set(["templates", "samples"]);
13
+ const discoveryPruneDirectories = new Set([
14
+ "Intermediate",
15
+ "Saved",
16
+ "Binaries",
17
+ "DerivedDataCache",
18
+ "Content",
19
+ "Source",
20
+ "Config",
21
+ "node_modules",
22
+ ".git",
23
+ ".svn",
24
+ "Windows",
25
+ "WinSxS",
26
+ "$Recycle.Bin",
27
+ "System Volume Information",
28
+ "AppData",
29
+ "Library"
30
+ ].map((value) => value.toLocaleLowerCase()));
31
+ function targetId(options) {
32
+ const digest = createHash("sha256")
33
+ .update(JSON.stringify([options.kind, resolve(options.root), options.key, options.relativePath]))
34
+ .digest("hex");
35
+ return CustodianTargetId.make(`target-${digest}`);
36
+ }
37
+ const JsonObject = Schema.Record(Schema.String, Schema.Json);
38
+ const decodeJson = Schema.decodeUnknownSync(Schema.Json);
39
+ const NodeError = Schema.Struct({ code: Schema.optional(Schema.String) });
40
+ function isRecord(value) {
41
+ return Schema.is(JsonObject)(value);
42
+ }
43
+ const engineTargetDefinitions = [
44
+ {
45
+ key: "engine_ddc",
46
+ relativePath: "Engine/DerivedDataCache",
47
+ description: "Engine-wide derived data cache",
48
+ rebuildCost: "Re-derived on the next editor start",
49
+ risk: "low",
50
+ defaultOn: true,
51
+ sourceBuildOnly: false
52
+ },
53
+ {
54
+ key: "engine_logs",
55
+ relativePath: "Engine/Saved/Logs",
56
+ description: "Engine logs",
57
+ rebuildCost: "No rebuild cost; historical diagnostics are lost",
58
+ risk: "low",
59
+ defaultOn: true,
60
+ sourceBuildOnly: false
61
+ },
62
+ {
63
+ key: "engine_crashes",
64
+ relativePath: "Engine/Saved/Crashes",
65
+ description: "Engine crash reports",
66
+ rebuildCost: "No rebuild cost; crash evidence is lost",
67
+ risk: "low",
68
+ defaultOn: true,
69
+ sourceBuildOnly: false
70
+ },
71
+ {
72
+ key: "engine_intermediate",
73
+ relativePath: "Engine/Intermediate",
74
+ description: "Engine build intermediates",
75
+ rebuildCost: "The next engine compile becomes a full rebuild",
76
+ risk: "high",
77
+ defaultOn: false,
78
+ sourceBuildOnly: true
79
+ },
80
+ {
81
+ key: "engine_binaries",
82
+ relativePath: "Engine/Binaries",
83
+ description: "Compiled engine binaries",
84
+ rebuildCost: "The editor cannot launch until the engine is fully rebuilt",
85
+ risk: "critical",
86
+ defaultOn: false,
87
+ sourceBuildOnly: true
88
+ }
89
+ ];
90
+ const defaultNodeScannerDependencies = {
91
+ deviceOf: async (path) => (await stat(path)).dev,
92
+ freeBytesAt: async (path) => {
93
+ const volume = await statfs(path);
94
+ return Math.max(0, Math.floor(volume.bavail * volume.bsize));
95
+ }
96
+ };
97
+ function throwIfAborted(signal) {
98
+ if (signal.aborted)
99
+ throw signal.reason ?? new Error("Custodian scan was cancelled.");
100
+ }
101
+ async function isDirectory(path) {
102
+ try {
103
+ return (await stat(path)).isDirectory();
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ async function discover(root, rootDevice, signal, dependencies) {
110
+ const descriptors = [];
111
+ const engineManifests = [];
112
+ const diagnostics = [];
113
+ const queue = [
114
+ { path: root, depth: 0 }
115
+ ];
116
+ for (let index = 0; index < queue.length; index++) {
117
+ throwIfAborted(signal);
118
+ const current = queue[index];
119
+ if (current === undefined)
120
+ continue;
121
+ let entries;
122
+ try {
123
+ entries = await readdir(current.path, { withFileTypes: true });
124
+ }
125
+ catch (cause) {
126
+ diagnostics.push({
127
+ code: "discovery_incomplete",
128
+ message: `Could not inspect directory: ${cause instanceof Error ? cause.message : String(cause)}`,
129
+ path: current.path
130
+ });
131
+ continue;
132
+ }
133
+ for (const entry of entries) {
134
+ const path = join(current.path, entry.name);
135
+ if (entry.isFile()) {
136
+ if (entry.name.toLocaleLowerCase().endsWith(".uproject"))
137
+ descriptors.push(path);
138
+ if (entry.name === "Build.version" &&
139
+ basename(current.path) === "Build" &&
140
+ basename(dirname(current.path)) === "Engine") {
141
+ engineManifests.push(path);
142
+ }
143
+ continue;
144
+ }
145
+ if (!entry.isDirectory() || current.depth >= maximumDiscoveryDepth)
146
+ continue;
147
+ if (discoveryPruneDirectories.has(entry.name.toLocaleLowerCase()))
148
+ continue;
149
+ try {
150
+ if ((await dependencies.deviceOf(path)) !== rootDevice) {
151
+ diagnostics.push({
152
+ code: "cross_volume_skipped",
153
+ message: "Directory is on a different filesystem than the explicit scan root.",
154
+ path
155
+ });
156
+ continue;
157
+ }
158
+ }
159
+ catch (cause) {
160
+ diagnostics.push({
161
+ code: "discovery_incomplete",
162
+ message: `Could not identify directory filesystem: ${cause instanceof Error ? cause.message : String(cause)}`,
163
+ path
164
+ });
165
+ continue;
166
+ }
167
+ queue.push({ path, depth: current.depth + 1 });
168
+ }
169
+ }
170
+ return { descriptors, engineManifests, diagnostics };
171
+ }
172
+ function isWithin(path, root) {
173
+ const fromRoot = relative(root, path);
174
+ return (fromRoot === "" ||
175
+ (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)));
176
+ }
177
+ async function identifyEngines(manifests, signal) {
178
+ const found = new Map();
179
+ for (const manifest of manifests) {
180
+ throwIfAborted(signal);
181
+ const root = dirname(dirname(dirname(manifest)));
182
+ const hasBuildMachinery = (await isDirectory(join(root, "Engine", "Source"))) ||
183
+ (await isDirectory(join(root, "Engine", "Build", "BatchFiles")));
184
+ if (!hasBuildMachinery)
185
+ continue;
186
+ try {
187
+ const document = decodeJson(JSON.parse(await readFile(manifest, "utf8")));
188
+ if (!isRecord(document))
189
+ continue;
190
+ const version = document;
191
+ const label = ["MajorVersion", "MinorVersion", "PatchVersion"]
192
+ .map((key) => (Schema.is(Schema.Number)(version[key]) ? String(version[key]) : "0"))
193
+ .join(".");
194
+ const build = join(root, "Engine", "Build");
195
+ const installedMarker = await access(join(build, "InstalledBuild.txt")).then(() => true, () => false);
196
+ const sourceMarker = await access(join(build, "SourceDistribution.txt")).then(() => true, () => false);
197
+ const canonical = await realpath(root);
198
+ found.set(canonical.toLocaleLowerCase(), {
199
+ root,
200
+ version: label,
201
+ buildKind: !installedMarker && sourceMarker ? "source" : "installed"
202
+ });
203
+ }
204
+ catch {
205
+ continue;
206
+ }
207
+ }
208
+ return [...found.values()].sort((left, right) => left.root.localeCompare(right.root));
209
+ }
210
+ async function newestMtime(root, signal) {
211
+ if (!(await isDirectory(root)))
212
+ return undefined;
213
+ let newest;
214
+ const queue = [root];
215
+ for (let index = 0; index < queue.length; index++) {
216
+ throwIfAborted(signal);
217
+ const directory = queue[index];
218
+ if (directory === undefined)
219
+ continue;
220
+ let entries;
221
+ try {
222
+ entries = await readdir(directory, { withFileTypes: true });
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ for (const entry of entries) {
228
+ if (entry.name.startsWith(".") || entry.isSymbolicLink())
229
+ continue;
230
+ const path = join(directory, entry.name);
231
+ if (entry.isDirectory()) {
232
+ queue.push(path);
233
+ continue;
234
+ }
235
+ if (!entry.isFile())
236
+ continue;
237
+ try {
238
+ const modified = (await stat(path)).mtimeMs;
239
+ if (newest === undefined || modified > newest)
240
+ newest = modified;
241
+ }
242
+ catch {
243
+ continue;
244
+ }
245
+ }
246
+ }
247
+ return newest;
248
+ }
249
+ async function fileMtime(path) {
250
+ try {
251
+ const metadata = await stat(path);
252
+ return metadata.isFile() ? metadata.mtimeMs : undefined;
253
+ }
254
+ catch {
255
+ return undefined;
256
+ }
257
+ }
258
+ function newestTimestamp(values) {
259
+ return values.reduce((current, value) => value === undefined
260
+ ? current
261
+ : current === undefined || value > current
262
+ ? value
263
+ : current, undefined);
264
+ }
265
+ async function discoverProjectPlugins(projectRoot, signal) {
266
+ const pluginsRoot = join(projectRoot, "Plugins");
267
+ if (!(await isDirectory(pluginsRoot)))
268
+ return [];
269
+ const plugins = [];
270
+ const queue = [
271
+ { path: pluginsRoot, depth: 0 }
272
+ ];
273
+ for (let index = 0; index < queue.length; index++) {
274
+ throwIfAborted(signal);
275
+ const current = queue[index];
276
+ if (current === undefined)
277
+ continue;
278
+ let entries;
279
+ try {
280
+ entries = await readdir(current.path, { withFileTypes: true });
281
+ }
282
+ catch {
283
+ continue;
284
+ }
285
+ const descriptor = entries
286
+ .filter((entry) => entry.isFile() && entry.name.toLocaleLowerCase().endsWith(".uplugin"))
287
+ .map((entry) => join(current.path, entry.name))
288
+ .sort((left, right) => left.localeCompare(right))[0];
289
+ if (descriptor !== undefined) {
290
+ plugins.push({ descriptor, root: current.path });
291
+ continue;
292
+ }
293
+ if (current.depth >= maximumDiscoveryDepth)
294
+ continue;
295
+ for (const entry of entries) {
296
+ if (!entry.isDirectory() || entry.isSymbolicLink())
297
+ continue;
298
+ if (discoveryPruneDirectories.has(entry.name.toLocaleLowerCase()))
299
+ continue;
300
+ queue.push({ path: join(current.path, entry.name), depth: current.depth + 1 });
301
+ }
302
+ }
303
+ return plugins.sort((left, right) => left.root.localeCompare(right.root));
304
+ }
305
+ async function containsBuildRule(root, signal) {
306
+ if (!(await isDirectory(root)))
307
+ return false;
308
+ const queue = [root];
309
+ for (let index = 0; index < queue.length; index++) {
310
+ throwIfAborted(signal);
311
+ const directory = queue[index];
312
+ if (directory === undefined)
313
+ continue;
314
+ let entries;
315
+ try {
316
+ entries = await readdir(directory, { withFileTypes: true });
317
+ }
318
+ catch {
319
+ continue;
320
+ }
321
+ for (const entry of entries) {
322
+ if (entry.isSymbolicLink())
323
+ continue;
324
+ if (entry.isFile() && entry.name.toLocaleLowerCase().endsWith(".build.cs"))
325
+ return true;
326
+ if (entry.isDirectory())
327
+ queue.push(join(directory, entry.name));
328
+ }
329
+ }
330
+ return false;
331
+ }
332
+ async function pluginDeclaresNativeModules(descriptor) {
333
+ try {
334
+ const document = decodeJson(JSON.parse(await readFile(descriptor, "utf8")));
335
+ if (!isRecord(document))
336
+ return true;
337
+ const modules = document["Modules"];
338
+ if (modules === undefined)
339
+ return false;
340
+ return !Array.isArray(modules) || modules.length > 0;
341
+ }
342
+ catch {
343
+ // Descriptor uncertainty must preserve binaries rather than broaden reclaim authority.
344
+ return true;
345
+ }
346
+ }
347
+ async function hasNativeCode(projectRoot, plugins, signal) {
348
+ if (await isDirectory(join(projectRoot, "Source")))
349
+ return true;
350
+ for (const plugin of plugins) {
351
+ if (await containsBuildRule(join(plugin.root, "Source"), signal))
352
+ return true;
353
+ if (await pluginDeclaresNativeModules(plugin.descriptor))
354
+ return true;
355
+ }
356
+ return false;
357
+ }
358
+ async function newestLogSession(savedLogs) {
359
+ let entries;
360
+ try {
361
+ entries = await readdir(savedLogs, { withFileTypes: true });
362
+ }
363
+ catch {
364
+ return undefined;
365
+ }
366
+ let newest;
367
+ for (const entry of entries) {
368
+ const match = logStamp.exec(entry.name);
369
+ if (match === null)
370
+ continue;
371
+ const parts = match.slice(1).map(Number);
372
+ if (parts.length !== 6 || parts.some((part) => !Number.isInteger(part)))
373
+ continue;
374
+ const [year, month, day, hour, minute, second] = parts;
375
+ if (year === undefined ||
376
+ month === undefined ||
377
+ day === undefined ||
378
+ hour === undefined ||
379
+ minute === undefined ||
380
+ second === undefined) {
381
+ continue;
382
+ }
383
+ const stamp = Date.UTC(year, month - 1, day, hour, minute, second);
384
+ if (!Number.isFinite(stamp))
385
+ continue;
386
+ if (newest === undefined || stamp > newest)
387
+ newest = stamp;
388
+ }
389
+ return newest;
390
+ }
391
+ async function measureFreshness(projectRoot, descriptor, plugins, now, signal) {
392
+ const projectAuthored = await Promise.all([
393
+ fileMtime(descriptor),
394
+ ...["Content", "Source", "Config"].map((name) => newestMtime(join(projectRoot, name), signal)),
395
+ ...plugins.flatMap((plugin) => [
396
+ fileMtime(plugin.descriptor),
397
+ ...["Content", "Source", "Config"].map((name) => newestMtime(join(plugin.root, name), signal))
398
+ ])
399
+ ]);
400
+ const authoredAtMs = newestTimestamp(projectAuthored);
401
+ const lastSessionAtMs = await newestLogSession(join(projectRoot, "Saved", "Logs"));
402
+ const effectiveAtMs = newestTimestamp([authoredAtMs, lastSessionAtMs]);
403
+ return {
404
+ ...(authoredAtMs === undefined
405
+ ? undefined
406
+ : { authoredAt: new Date(authoredAtMs).toISOString() }),
407
+ ...(lastSessionAtMs === undefined
408
+ ? undefined
409
+ : { lastSessionAt: new Date(lastSessionAtMs).toISOString() }),
410
+ ...(effectiveAtMs === undefined
411
+ ? undefined
412
+ : {
413
+ effectiveAt: new Date(effectiveAtMs).toISOString(),
414
+ ageDays: Math.max(0, (now - effectiveAtMs) / 86_400_000)
415
+ }),
416
+ mtimesLookRewritten: authoredAtMs !== undefined &&
417
+ lastSessionAtMs !== undefined &&
418
+ authoredAtMs - lastSessionAtMs > 30 * 86_400_000
419
+ };
420
+ }
421
+ async function measureDirectory(options) {
422
+ let bytes = 0;
423
+ let hardlinkExcludedBytes = 0;
424
+ const crossVolumePaths = [];
425
+ const queue = [options.path];
426
+ for (let index = 0; index < queue.length; index++) {
427
+ throwIfAborted(options.signal);
428
+ const directory = queue[index];
429
+ if (directory === undefined)
430
+ continue;
431
+ if ((await options.dependencies.deviceOf(directory)) !== options.expectedDevice) {
432
+ crossVolumePaths.push(directory);
433
+ continue;
434
+ }
435
+ const entries = await readdir(directory, { withFileTypes: true });
436
+ for (const entry of entries) {
437
+ if (entry.isSymbolicLink())
438
+ continue;
439
+ const child = join(directory, entry.name);
440
+ if (entry.isDirectory()) {
441
+ queue.push(child);
442
+ continue;
443
+ }
444
+ if (!entry.isFile())
445
+ continue;
446
+ const metadata = await lstat(child);
447
+ if (metadata.nlink > 1) {
448
+ // A read-only inventory cannot prove that every other name will be removed by a
449
+ // future plan. Excluding the allocation is conservative and prevents both
450
+ // cross-target double counting and credit while a retained link survives.
451
+ hardlinkExcludedBytes += metadata.size;
452
+ continue;
453
+ }
454
+ bytes += metadata.size;
455
+ }
456
+ }
457
+ return { bytes, crossVolumePaths, hardlinkExcludedBytes };
458
+ }
459
+ async function loadPolicy(projectRoot) {
460
+ const path = join(projectRoot, policyFilename);
461
+ try {
462
+ const contents = await readFile(path, "utf8");
463
+ const resolution = resolvePolicyDocument(decodeJson(JSON.parse(contents)));
464
+ return {
465
+ policy: resolution.policy,
466
+ ...(resolution.error === undefined
467
+ ? undefined
468
+ : {
469
+ diagnostic: {
470
+ code: "invalid_policy",
471
+ message: resolution.error,
472
+ path
473
+ }
474
+ })
475
+ };
476
+ }
477
+ catch (cause) {
478
+ if (Schema.is(NodeError)(cause) && cause.code === "ENOENT") {
479
+ return { policy: defaultCustodianPolicy };
480
+ }
481
+ return {
482
+ policy: { ...defaultCustodianPolicy, enabled: false, targets: [] },
483
+ diagnostic: {
484
+ code: "invalid_policy",
485
+ message: `Could not read ${policyFilename}: ${cause instanceof Error ? cause.message : String(cause)}`,
486
+ path
487
+ }
488
+ };
489
+ }
490
+ }
491
+ async function projectCandidates(projectRoot, policy, isCpp) {
492
+ const selected = new Set(policy.targets);
493
+ const candidates = [];
494
+ for (const definition of projectTargetDefinitions) {
495
+ if (!selected.has(definition.key))
496
+ continue;
497
+ if (isCpp &&
498
+ policy.keepBinariesForCpp &&
499
+ (definition.key === "binaries" || definition.key === "plugin_binaries")) {
500
+ continue;
501
+ }
502
+ if (definition.wildcard === "plugin") {
503
+ let plugins;
504
+ try {
505
+ plugins = await readdir(join(projectRoot, "Plugins"), { withFileTypes: true });
506
+ }
507
+ catch {
508
+ continue;
509
+ }
510
+ const tail = definition.relativePath.endsWith("Intermediate")
511
+ ? "Intermediate"
512
+ : "Binaries";
513
+ for (const plugin of plugins) {
514
+ candidates.push({
515
+ definition,
516
+ path: join(projectRoot, "Plugins", plugin.name, tail)
517
+ });
518
+ }
519
+ continue;
520
+ }
521
+ candidates.push({ definition, path: join(projectRoot, definition.relativePath) });
522
+ }
523
+ return candidates;
524
+ }
525
+ function refusal(projectRoot, path, code, reason) {
526
+ return {
527
+ path,
528
+ relativePath: relative(projectRoot, path).split(sep).join("/"),
529
+ code,
530
+ reason
531
+ };
532
+ }
533
+ async function measureProjectTargets(options) {
534
+ const targets = [];
535
+ const refusals = [];
536
+ const diagnostics = [];
537
+ const canonicalRoot = await realpath(options.projectRoot);
538
+ const candidates = await projectCandidates(options.projectRoot, options.policy, options.isCpp);
539
+ for (const candidate of candidates) {
540
+ throwIfAborted(options.signal);
541
+ if (!(await isDirectory(candidate.path)))
542
+ continue;
543
+ let canonical;
544
+ try {
545
+ canonical = await realpath(candidate.path);
546
+ }
547
+ catch (cause) {
548
+ refusals.push(refusal(options.projectRoot, candidate.path, "unreadable", cause instanceof Error ? cause.message : String(cause)));
549
+ continue;
550
+ }
551
+ if (!isWithin(canonical, canonicalRoot)) {
552
+ refusals.push(refusal(options.projectRoot, candidate.path, "outside_root", "Symlink resolves outside the named project."));
553
+ continue;
554
+ }
555
+ const relativePath = relative(options.projectRoot, candidate.path).split(sep).join("/");
556
+ const parts = relativePath.split("/");
557
+ if (relativePath === "" ||
558
+ ["Content", "Source", "Config", "Plugins", "Saved"].includes(relativePath) ||
559
+ parts.includes("SaveGames")) {
560
+ refusals.push(refusal(options.projectRoot, candidate.path, "protected_path", "Authored content, project roots, plugin roots, and save data are protected."));
561
+ continue;
562
+ }
563
+ if (candidate.definition.minimumAgeDays > 0) {
564
+ const newestTargetAt = await newestMtime(candidate.path, options.signal);
565
+ const targetAgeDays = newestTargetAt === undefined
566
+ ? undefined
567
+ : Math.max(0, (options.now - newestTargetAt) / 86_400_000);
568
+ if (targetAgeDays === undefined ||
569
+ targetAgeDays < candidate.definition.minimumAgeDays) {
570
+ continue;
571
+ }
572
+ }
573
+ try {
574
+ const measurement = await measureDirectory({
575
+ path: candidate.path,
576
+ expectedDevice: options.expectedDevice,
577
+ signal: options.signal,
578
+ dependencies: options.dependencies
579
+ });
580
+ if (measurement.crossVolumePaths.length > 0) {
581
+ refusals.push(refusal(options.projectRoot, candidate.path, "different_volume", "Target contains storage on a different filesystem than the scan root."));
582
+ continue;
583
+ }
584
+ if (measurement.hardlinkExcludedBytes > 0) {
585
+ diagnostics.push({
586
+ code: "hardlink_excluded",
587
+ message: `${measurement.hardlinkExcludedBytes} byte(s) have multiple names and ` +
588
+ "were conservatively excluded from reclaim estimates.",
589
+ path: candidate.path
590
+ });
591
+ }
592
+ targets.push({
593
+ id: targetId({
594
+ kind: "project",
595
+ root: options.projectRoot,
596
+ key: candidate.definition.key,
597
+ relativePath
598
+ }),
599
+ key: candidate.definition.key,
600
+ path: candidate.path,
601
+ relativePath,
602
+ bytes: measurement.bytes,
603
+ description: candidate.definition.description,
604
+ rebuildCost: candidate.definition.rebuildCost,
605
+ risk: candidate.definition.risk
606
+ });
607
+ }
608
+ catch (cause) {
609
+ diagnostics.push({
610
+ code: "target_unreadable",
611
+ message: cause instanceof Error ? cause.message : String(cause),
612
+ path: candidate.path
613
+ });
614
+ }
615
+ }
616
+ targets.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
617
+ return { targets, refusals, diagnostics };
618
+ }
619
+ async function mapConcurrent(values, concurrency, map) {
620
+ const results = [];
621
+ results.length = values.length;
622
+ let cursor = 0;
623
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
624
+ while (cursor < values.length) {
625
+ const index = cursor++;
626
+ const value = values[index];
627
+ if (value !== undefined)
628
+ results[index] = await map(value);
629
+ }
630
+ }));
631
+ return results;
632
+ }
633
+ async function scanProject(options) {
634
+ const root = dirname(options.descriptor);
635
+ let engineAssociation = "not specified";
636
+ const diagnostics = [];
637
+ try {
638
+ const document = decodeJson(JSON.parse(await readFile(options.descriptor, "utf8")));
639
+ if (isRecord(document)) {
640
+ const association = document.EngineAssociation;
641
+ if (Schema.is(Schema.String)(association) && association.trim() !== "") {
642
+ engineAssociation = association.trim();
643
+ }
644
+ }
645
+ }
646
+ catch (cause) {
647
+ diagnostics.push({
648
+ code: "descriptor_unreadable",
649
+ message: cause instanceof Error ? cause.message : String(cause),
650
+ path: options.descriptor
651
+ });
652
+ }
653
+ const loadedPolicy = await loadPolicy(root);
654
+ if (loadedPolicy.diagnostic !== undefined)
655
+ diagnostics.push(loadedPolicy.diagnostic);
656
+ const plugins = await discoverProjectPlugins(root, options.signal);
657
+ const freshness = await measureFreshness(root, options.descriptor, plugins, options.now, options.signal);
658
+ const isCpp = await hasNativeCode(root, plugins, options.signal);
659
+ const measured = await measureProjectTargets({
660
+ projectRoot: root,
661
+ policy: loadedPolicy.policy,
662
+ isCpp,
663
+ now: options.now,
664
+ expectedDevice: options.expectedDevice,
665
+ signal: options.signal,
666
+ dependencies: options.dependencies
667
+ });
668
+ diagnostics.push(...measured.diagnostics);
669
+ const reclaimableBytes = measured.targets.reduce((total, target) => total + target.bytes, 0);
670
+ const eligibility = loadedPolicy.diagnostic?.code === "invalid_policy"
671
+ ? { kind: "invalid_policy" }
672
+ : !loadedPolicy.policy.enabled
673
+ ? { kind: "opted_out" }
674
+ : reclaimableBytes === 0
675
+ ? { kind: "empty" }
676
+ : freshness.ageDays === undefined && loadedPolicy.policy.minAgeDays > 0
677
+ ? { kind: "unknown_age" }
678
+ : freshness.ageDays !== undefined &&
679
+ freshness.ageDays < loadedPolicy.policy.minAgeDays
680
+ ? {
681
+ kind: "recent",
682
+ eligibleAfterDays: loadedPolicy.policy.minAgeDays - freshness.ageDays
683
+ }
684
+ : { kind: "candidate" };
685
+ return {
686
+ kind: "project",
687
+ name: basename(options.descriptor, ".uproject"),
688
+ root,
689
+ descriptor: options.descriptor,
690
+ engineAssociation,
691
+ isCpp,
692
+ policy: loadedPolicy.policy,
693
+ freshness,
694
+ eligibility,
695
+ targets: measured.targets,
696
+ refusals: measured.refusals,
697
+ diagnostics,
698
+ reclaimableBytes
699
+ };
700
+ }
701
+ async function scanEngine(options) {
702
+ const { engine } = options;
703
+ const targets = [];
704
+ const refusals = [];
705
+ const diagnostics = [];
706
+ for (const definition of engineTargetDefinitions) {
707
+ throwIfAborted(options.signal);
708
+ const path = join(engine.root, ...definition.relativePath.split("/"));
709
+ if (!(await isDirectory(path)))
710
+ continue;
711
+ if (definition.sourceBuildOnly && engine.buildKind === "installed") {
712
+ refusals.push({
713
+ path,
714
+ relativePath: definition.relativePath,
715
+ code: "installed_engine",
716
+ reason: "Precompiled install: this is engine product, not rebuildable output."
717
+ });
718
+ continue;
719
+ }
720
+ if (!definition.defaultOn)
721
+ continue;
722
+ try {
723
+ const measurement = await measureDirectory({
724
+ path,
725
+ expectedDevice: options.expectedDevice,
726
+ signal: options.signal,
727
+ dependencies: options.dependencies
728
+ });
729
+ if (measurement.crossVolumePaths.length > 0) {
730
+ refusals.push({
731
+ path,
732
+ relativePath: definition.relativePath,
733
+ code: "different_volume",
734
+ reason: "Target contains storage on a different filesystem than the scan root."
735
+ });
736
+ continue;
737
+ }
738
+ if (measurement.hardlinkExcludedBytes > 0) {
739
+ diagnostics.push({
740
+ code: "hardlink_excluded",
741
+ message: `${measurement.hardlinkExcludedBytes} byte(s) have multiple names and ` +
742
+ "were conservatively excluded from reclaim estimates.",
743
+ path
744
+ });
745
+ }
746
+ targets.push({
747
+ id: targetId({
748
+ kind: "engine",
749
+ root: engine.root,
750
+ key: definition.key,
751
+ relativePath: definition.relativePath
752
+ }),
753
+ key: definition.key,
754
+ path,
755
+ relativePath: definition.relativePath,
756
+ bytes: measurement.bytes,
757
+ description: definition.description,
758
+ rebuildCost: definition.rebuildCost,
759
+ risk: definition.risk
760
+ });
761
+ }
762
+ catch (cause) {
763
+ diagnostics.push({
764
+ code: "target_unreadable",
765
+ message: cause instanceof Error ? cause.message : String(cause),
766
+ path
767
+ });
768
+ }
769
+ }
770
+ targets.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
771
+ return {
772
+ kind: "engine",
773
+ name: `UE ${engine.version}`,
774
+ root: engine.root,
775
+ version: engine.version,
776
+ buildKind: engine.buildKind,
777
+ targets,
778
+ refusals,
779
+ diagnostics,
780
+ reclaimableBytes: targets.reduce((total, target) => total + target.bytes, 0)
781
+ };
782
+ }
783
+ function makePlan(options) {
784
+ const thresholdGb = options.projects.length === 0
785
+ ? defaultCustodianPolicy.minFreeGb
786
+ : Math.max(...options.projects.map(({ policy }) => policy.minFreeGb));
787
+ const thresholdBytes = Math.round(thresholdGb * gibibyte);
788
+ const eligible = [
789
+ ...options.projects
790
+ .filter(({ eligibility }) => eligibility.kind === "candidate")
791
+ .map(({ kind, name, root, reclaimableBytes: bytes, targets }) => ({
792
+ kind,
793
+ name,
794
+ root,
795
+ bytes,
796
+ targets
797
+ })),
798
+ ...options.engines
799
+ .filter(({ reclaimableBytes }) => reclaimableBytes > 0)
800
+ .map(({ kind, name, root, reclaimableBytes: bytes, targets }) => ({
801
+ kind,
802
+ name,
803
+ root,
804
+ bytes,
805
+ targets
806
+ }))
807
+ ].sort((left, right) => right.bytes - left.bytes || left.root.localeCompare(right.root));
808
+ if (!options.ignorePressure && options.freeBytes >= thresholdBytes) {
809
+ return {
810
+ status: "pressure_satisfied",
811
+ freeBytes: options.freeBytes,
812
+ thresholdBytes,
813
+ projectedFreeBytes: options.freeBytes,
814
+ reclaimableBytes: 0,
815
+ items: []
816
+ };
817
+ }
818
+ if (eligible.length === 0) {
819
+ return {
820
+ status: "nothing_eligible",
821
+ freeBytes: options.freeBytes,
822
+ thresholdBytes,
823
+ projectedFreeBytes: options.freeBytes,
824
+ reclaimableBytes: 0,
825
+ items: []
826
+ };
827
+ }
828
+ const items = [];
829
+ let projectedFreeBytes = options.freeBytes;
830
+ for (const item of eligible) {
831
+ if (!options.ignorePressure && projectedFreeBytes >= thresholdBytes)
832
+ break;
833
+ items.push(item);
834
+ projectedFreeBytes += item.bytes;
835
+ }
836
+ return {
837
+ status: items.length === 0 ? "nothing_eligible" : "ready",
838
+ freeBytes: options.freeBytes,
839
+ thresholdBytes,
840
+ projectedFreeBytes,
841
+ reclaimableBytes: items.reduce((total, item) => total + item.bytes, 0),
842
+ items
843
+ };
844
+ }
845
+ export async function scanCustodian(request, signal, dependencies = defaultNodeScannerDependencies) {
846
+ const root = resolve(request.root);
847
+ const rootStat = await stat(root);
848
+ if (!rootStat.isDirectory())
849
+ throw new Error(`Scan root is not a directory: ${root}`);
850
+ const rootDevice = await dependencies.deviceOf(root);
851
+ const now = Date.now();
852
+ const discovery = await discover(root, rootDevice, signal, dependencies);
853
+ const engines = await identifyEngines(discovery.engineManifests, signal);
854
+ const canonicalEngineRoots = await Promise.all(engines.map(({ root }) => realpath(root)));
855
+ const descriptors = [];
856
+ const seen = new Set();
857
+ for (const descriptor of discovery.descriptors) {
858
+ throwIfAborted(signal);
859
+ const canonical = await realpath(descriptor);
860
+ const components = canonical.split(sep).map((part) => part.toLocaleLowerCase());
861
+ if (components.some((part) => excludedProjectComponents.has(part)))
862
+ continue;
863
+ if (canonicalEngineRoots.some((engineRoot) => isWithin(canonical, engineRoot)))
864
+ continue;
865
+ const identity = canonical.toLocaleLowerCase();
866
+ if (seen.has(identity))
867
+ continue;
868
+ seen.add(identity);
869
+ descriptors.push(descriptor);
870
+ }
871
+ const [projects, engineReports] = await Promise.all([
872
+ mapConcurrent(descriptors, scanConcurrency, (descriptor) => scanProject({
873
+ descriptor,
874
+ now,
875
+ expectedDevice: rootDevice,
876
+ signal,
877
+ dependencies
878
+ })),
879
+ mapConcurrent(engines, scanConcurrency, (engine) => scanEngine({ engine, expectedDevice: rootDevice, signal, dependencies }))
880
+ ]);
881
+ const freeBytes = await dependencies.freeBytesAt(root);
882
+ const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name));
883
+ const sortedEngines = [...engineReports].sort((left, right) => left.root.localeCompare(right.root));
884
+ const totalReclaimableBytes = [...sortedProjects, ...sortedEngines].reduce((total, report) => total + report.reclaimableBytes, 0);
885
+ return {
886
+ schemaVersion: 2,
887
+ root,
888
+ measuredAt: new Date(now).toISOString(),
889
+ freeBytes,
890
+ totalReclaimableBytes,
891
+ projects: sortedProjects,
892
+ engines: sortedEngines,
893
+ diagnostics: discovery.diagnostics,
894
+ plan: makePlan({
895
+ projects: sortedProjects,
896
+ engines: sortedEngines,
897
+ freeBytes,
898
+ ignorePressure: request.ignorePressure ?? false
899
+ }),
900
+ destructiveOperationsAvailable: true
901
+ };
902
+ }
903
+ //# sourceMappingURL=node-scanner.js.map